package api import ( "context" "errors" "log/slog" "net/http" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/storage" ) // privacyHandler holds the GDPR-style "your data, your choice" endpoints: // data export, account deletion, and terms-acceptance recording. type privacyHandler struct { logger *slog.Logger users *storage.UserRepo events *storage.EventRepo guests *storage.GuestRepo tokens *storage.TokenRepo rsvps *storage.RSVPRepo access *storage.AccessLogRepo notifs *storage.DB // raw pool access for the export queries refresh *storage.RefreshTokenRepo } // DataExport is the shape of the JSON the host downloads from // GET /me/data-export. We don't paginate or stream — for the scale // GuestGuard hosts have, a single response is reasonable. If a host // ever has 100k+ access logs we'll switch to async + email-a-link. type DataExport struct { ExportedAt time.Time `json:"exported_at"` Format string `json:"format"` User *domain.User `json:"user"` Events []*domain.Event `json:"events"` Guests []*domain.Guest `json:"guests"` Tokens []exportedToken `json:"tokens"` RSVPs []exportedRSVP `json:"rsvps"` AccessLogs []exportedAccess `json:"access_logs"` Notifs []exportedNotif `json:"notifications"` } type exportedToken struct { ID uuid.UUID `json:"id"` GuestID uuid.UUID `json:"guest_id"` ExpiresAt time.Time `json:"expires_at"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } type exportedRSVP struct { ID uuid.UUID `json:"id"` GuestID uuid.UUID `json:"guest_id"` Response string `json:"response"` PlusOnes int `json:"plus_ones"` SubmittedAt time.Time `json:"submitted_at"` } type exportedAccess struct { ID uuid.UUID `json:"id"` GuestID uuid.UUID `json:"guest_id"` RiskScore *int `json:"risk_score,omitempty"` Flagged bool `json:"flagged"` CreatedAt time.Time `json:"created_at"` } type exportedNotif struct { ID uuid.UUID `json:"id"` GuestID uuid.UUID `json:"guest_id"` Channel string `json:"channel"` Type string `json:"type"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } // GET /me/data-export — returns every record the system holds about the // authenticated user. The Content-Disposition header makes browsers // offer a download rather than rendering inline. func (h *privacyHandler) dataExport(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } user, err := h.users.GetByID(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load user") return } export := DataExport{ ExportedAt: time.Now().UTC(), Format: "guestguard.v1", User: user, } // Events the user hosts. events, err := h.events.List(r.Context(), hostID, 1000, 0) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load events") return } export.Events = events // For each event, pull guests + tokens + rsvps + access_logs + notifications. for _, ev := range events { guests, err := h.guests.ListByEvent(r.Context(), ev.ID, 5000, 0) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load guests") return } export.Guests = append(export.Guests, guests...) for _, g := range guests { // Token (at most one per guest, but query as a list for symmetry). if err := h.appendTokens(r.Context(), g.ID, &export); err != nil { h.logger.Warn("export: tokens", "err", err) } if err := h.appendRSVPs(r.Context(), g.ID, &export); err != nil { h.logger.Warn("export: rsvps", "err", err) } if err := h.appendAccess(r.Context(), g.ID, &export); err != nil { h.logger.Warn("export: access", "err", err) } if err := h.appendNotifs(r.Context(), g.ID, &export); err != nil { h.logger.Warn("export: notifs", "err", err) } } } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="guestguard-data-export.json"`) writeJSON(w, http.StatusOK, export) } func (h *privacyHandler) appendTokens(ctx context.Context, guestID uuid.UUID, out *DataExport) error { rows, err := h.notifs.Pool.Query(ctx, ` SELECT id, guest_id, expires_at, status, created_at FROM tokens WHERE guest_id = $1 `, guestID) if err != nil { return err } defer rows.Close() for rows.Next() { var t exportedToken if err := rows.Scan(&t.ID, &t.GuestID, &t.ExpiresAt, &t.Status, &t.CreatedAt); err != nil { return err } out.Tokens = append(out.Tokens, t) } return rows.Err() } func (h *privacyHandler) appendRSVPs(ctx context.Context, guestID uuid.UUID, out *DataExport) error { rows, err := h.notifs.Pool.Query(ctx, ` SELECT id, guest_id, response::text, plus_ones, submitted_at FROM rsvps WHERE guest_id = $1 `, guestID) if err != nil { return err } defer rows.Close() for rows.Next() { var r exportedRSVP if err := rows.Scan(&r.ID, &r.GuestID, &r.Response, &r.PlusOnes, &r.SubmittedAt); err != nil { return err } out.RSVPs = append(out.RSVPs, r) } return rows.Err() } func (h *privacyHandler) appendAccess(ctx context.Context, guestID uuid.UUID, out *DataExport) error { rows, err := h.notifs.Pool.Query(ctx, ` SELECT id, guest_id, risk_score, flagged, created_at FROM access_logs WHERE guest_id = $1 `, guestID) if err != nil { return err } defer rows.Close() for rows.Next() { var a exportedAccess var rs *int if err := rows.Scan(&a.ID, &a.GuestID, &rs, &a.Flagged, &a.CreatedAt); err != nil { return err } a.RiskScore = rs out.AccessLogs = append(out.AccessLogs, a) } return rows.Err() } func (h *privacyHandler) appendNotifs(ctx context.Context, guestID uuid.UUID, out *DataExport) error { rows, err := h.notifs.Pool.Query(ctx, ` SELECT id, guest_id, channel::text, type::text, status::text, created_at FROM notifications WHERE guest_id = $1 `, guestID) if err != nil { return err } defer rows.Close() for rows.Next() { var n exportedNotif if err := rows.Scan(&n.ID, &n.GuestID, &n.Channel, &n.Type, &n.Status, &n.CreatedAt); err != nil { return err } out.Notifs = append(out.Notifs, n) } return rows.Err() } // DELETE /me — soft-deletes the host's account. All sessions are // revoked immediately. A hard delete happens via a separate cron 30 // days later (TBD ops work). The user is logged out from all devices // as a side effect of revoking the refresh tokens. func (h *privacyHandler) deleteMe(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } if err := h.users.SoftDelete(r.Context(), hostID); err != nil { if errors.Is(err, domain.ErrUserNotFound) { writeError(w, http.StatusNotFound, "user not found") return } writeError(w, http.StatusInternalServerError, "failed to delete account") return } // Best-effort: revoke refresh tokens so other sessions log out too. // Failure here is logged but doesn't roll back the soft-delete — the // access tokens (JWT) will still expire on their own ~15 minute TTL. if err := h.refresh.RevokeAllForUser(r.Context(), hostID); err != nil { h.logger.Warn("delete-me: revoke refresh tokens", "err", err, "user_id", hostID) } w.WriteHeader(http.StatusNoContent) } // POST /me/accept-terms — records that the authenticated user accepts // the current ToS + privacy policy. Idempotent. Used by both the // onboarding gate (existing accounts created before T&C were enforced) // and any future "we updated our terms" re-acceptance flow. func (h *privacyHandler) acceptTerms(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } if err := h.users.AcceptTerms(r.Context(), hostID); err != nil { if errors.Is(err, domain.ErrUserNotFound) { writeError(w, http.StatusNotFound, "user not found") return } writeError(w, http.StatusInternalServerError, "failed to record acceptance") return } writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"}) }