package storage import ( "context" "encoding/json" "fmt" "time" ) // DSGVO request status values (PROJ-50). const ( DSGVOStatusOpen = "open" // angelegt, noch nicht verarbeitet DSGVOStatusPartial = "partial" // teilweise abgelehnt (Mischung) DSGVOStatusCompleted = "completed" // abgeschlossen (0 Treffer, alle löschbar/abgelehnt, oder gelöscht) ) // DSGVOAffectedMail is a single mail affected by a DSGVO erasure request. type DSGVOAffectedMail struct { MailID string `json:"mail_id"` Subject string `json:"subject"` Date *time.Time `json:"date,omitempty"` RetainUntil *time.Time `json:"retain_until,omitempty"` Deletable bool `json:"deletable"` // true wenn retain_until abgelaufen/nicht gesetzt Deleted bool `json:"deleted"` // true wenn bereits über Löschmechanismus entfernt Reason string `json:"reason,omitempty"` // z.B. "Aufbewahrungspflicht bis 2030-12-31" } // DSGVOResultSummary is the aggregated outcome stored as JSON on the request. type DSGVOResultSummary struct { TotalHits int `json:"total_hits"` Rejected int `json:"rejected"` // gesetzlich gesperrt Deletable int `json:"deletable"` // löschbar Deleted int `json:"deleted"` // bereits gelöscht Truncated bool `json:"truncated"` // Treffer über Limit, Ergebnis unvollständig Mails []DSGVOAffectedMail `json:"mails"` } // DSGVORequest represents a documented DSGVO erasure request (Art. 17). type DSGVORequest struct { ID int64 `json:"id"` TenantID *int64 `json:"tenant_id"` RequestedAddress string `json:"requested_address"` RequestedBy string `json:"requested_by"` CreatedAt time.Time `json:"created_at"` Status string `json:"status"` ResultSummary *DSGVOResultSummary `json:"result_summary,omitempty"` } // initDSGVOSchema creates the dsgvo_requests table. Safe to call repeatedly. func (s *Store) initDSGVOSchema(ctx context.Context) { _, _ = s.db.Exec(ctx, `CREATE TABLE IF NOT EXISTS dsgvo_requests ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT, requested_address TEXT NOT NULL, requested_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), status TEXT NOT NULL DEFAULT 'open', result_summary JSONB )`) _, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_dsgvo_requests_tenant ON dsgvo_requests (tenant_id)`) } // CreateDSGVORequest inserts a new request and returns its generated ID. func (s *Store) CreateDSGVORequest(ctx context.Context, tenantID *int64, address, requestedBy string) (*DSGVORequest, error) { if s.db == nil { return nil, fmt.Errorf("storage: no db") } req := &DSGVORequest{ TenantID: tenantID, RequestedAddress: address, RequestedBy: requestedBy, Status: DSGVOStatusOpen, } err := s.db.QueryRow(ctx, `INSERT INTO dsgvo_requests (tenant_id, requested_address, requested_by, status) VALUES ($1,$2,$3,$4) RETURNING id, created_at`, tenantID, address, requestedBy, DSGVOStatusOpen, ).Scan(&req.ID, &req.CreatedAt) if err != nil { return nil, fmt.Errorf("storage: create dsgvo request: %w", err) } return req, nil } // UpdateDSGVOResult persists the computed result summary and status. func (s *Store) UpdateDSGVOResult(ctx context.Context, id int64, status string, summary *DSGVOResultSummary) error { if s.db == nil { return fmt.Errorf("storage: no db") } js, err := json.Marshal(summary) if err != nil { return fmt.Errorf("storage: marshal dsgvo summary: %w", err) } _, err = s.db.Exec(ctx, `UPDATE dsgvo_requests SET status=$1, result_summary=$2 WHERE id=$3`, status, js, id, ) if err != nil { return fmt.Errorf("storage: update dsgvo result: %w", err) } return nil } // ListDSGVORequests returns all requests in the given tenant scope, newest first. // A nil tenantID returns requests with NULL tenant_id (global/superadmin scope). func (s *Store) ListDSGVORequests(ctx context.Context, tenantID *int64) ([]DSGVORequest, error) { if s.db == nil { return nil, fmt.Errorf("storage: no db") } var ( query string args []interface{} ) if tenantID == nil { query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary FROM dsgvo_requests WHERE tenant_id IS NULL ORDER BY created_at DESC` } else { query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary FROM dsgvo_requests WHERE tenant_id = $1 ORDER BY created_at DESC` args = append(args, *tenantID) } rows, err := s.db.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("storage: list dsgvo requests: %w", err) } defer rows.Close() var out []DSGVORequest for rows.Next() { req, err := scanDSGVORow(rows) if err != nil { return nil, err } out = append(out, *req) } return out, rows.Err() } // GetDSGVORequest returns a single request by ID within the given tenant scope. // Returns an error if the request does not exist or belongs to another tenant. func (s *Store) GetDSGVORequest(ctx context.Context, id int64, tenantID *int64) (*DSGVORequest, error) { if s.db == nil { return nil, fmt.Errorf("storage: no db") } var ( query string args []interface{} ) if tenantID == nil { query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary FROM dsgvo_requests WHERE id=$1 AND tenant_id IS NULL` args = append(args, id) } else { query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary FROM dsgvo_requests WHERE id=$1 AND tenant_id=$2` args = append(args, id, *tenantID) } row := s.db.QueryRow(ctx, query, args...) return scanDSGVORow(row) } // DSGVOMailMeta holds the metadata needed to evaluate a DSGVO request per mail. type DSGVOMailMeta struct { Subject string ReceivedAt time.Time RetainUntil *time.Time } // GetDSGVOMailMeta batch-loads subject, received_at and retain_until for the // given mail IDs. Missing IDs are omitted. Used by the DSGVO workflow to avoid // loading and parsing every raw mail file. func (s *Store) GetDSGVOMailMeta(ctx context.Context, ids []string) (map[string]DSGVOMailMeta, error) { if s.db == nil || len(ids) == 0 { return map[string]DSGVOMailMeta{}, nil } rows, err := s.db.Query(ctx, `SELECT id, subject, received_at, retain_until FROM emails WHERE id = ANY($1)`, ids) if err != nil { return nil, fmt.Errorf("storage: dsgvo mail meta: %w", err) } defer rows.Close() out := make(map[string]DSGVOMailMeta, len(ids)) for rows.Next() { var ( id string subject *string recv time.Time until *time.Time ) if err := rows.Scan(&id, &subject, &recv, &until); err != nil { return nil, fmt.Errorf("storage: scan dsgvo mail meta: %w", err) } m := DSGVOMailMeta{ReceivedAt: recv, RetainUntil: until} if subject != nil { m.Subject = *subject } out[id] = m } return out, rows.Err() } type dsgvoScanner interface { Scan(dest ...interface{}) error } func scanDSGVORow(row dsgvoScanner) (*DSGVORequest, error) { var ( req DSGVORequest js []byte ) if err := row.Scan(&req.ID, &req.TenantID, &req.RequestedAddress, &req.RequestedBy, &req.CreatedAt, &req.Status, &js); err != nil { return nil, fmt.Errorf("storage: scan dsgvo request: %w", err) } if len(js) > 0 { var sum DSGVOResultSummary if err := json.Unmarshal(js, &sum); err == nil { req.ResultSummary = &sum } } return &req, nil }