package api import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/audit" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // inviteTTL is the lifetime of a freshly-minted invite. 24 hours // matches the spec; admin can revoke an unredeemed invite at any // time anyway, so a short TTL is just defense-in-depth against // shared-link leakage. const inviteTTL = 24 * time.Hour type inviteResp struct { Token string `json:"token"` InvitedBy string `json:"invited_by"` Note *string `json:"note"` CreatedAt string `json:"created_at"` ExpiresAt string `json:"expires_at"` RedeemedAt *string `json:"redeemed_at"` RedeemedBy *string `json:"redeemed_by"` } type listInvitesResp struct { Invites []inviteResp `json:"invites"` } func (h *handlers) handleListInvites(w http.ResponseWriter, r *http.Request) { q := dbq.New(h.pool) rows, err := q.ListInvitesActive(r.Context()) if err != nil { writeErrWithLog(w, h.logger, "admin: list invites failed", apierror.Internal(err)) return } out := make([]inviteResp, 0, len(rows)) for _, row := range rows { out = append(out, inviteFromRow(row)) } writeJSON(w, http.StatusOK, listInvitesResp{Invites: out}) } type createInviteReq struct { Note string `json:"note"` } func (h *handlers) handleCreateInvite(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } var req createInviteReq // Body is optional — empty body is fine; ignore decode errors. if r.Body != nil && r.ContentLength != 0 { _ = json.NewDecoder(r.Body).Decode(&req) } tokenBytes := make([]byte, 16) if _, err := rand.Read(tokenBytes); err != nil { writeErrWithLog(w, h.logger, "admin: invite token gen failed", apierror.Internal(err)) return } token := hex.EncodeToString(tokenBytes) expiresAt := time.Now().Add(inviteTTL) q := dbq.New(h.pool) var notePtr *string if req.Note != "" { n := req.Note notePtr = &n } row, err := q.CreateInvite(r.Context(), dbq.CreateInviteParams{ Token: token, InvitedBy: user.ID, Note: notePtr, ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, }) if err != nil { writeErrWithLog(w, h.logger, "admin: create invite failed", apierror.Internal(err)) return } audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, pgtype.UUID{}, audit.ActionInviteCreate, map[string]any{"token": token}) writeJSON(w, http.StatusOK, inviteFromRow(row)) } func (h *handlers) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } token := chi.URLParam(r, "token") if token == "" { writeErr(w, apierror.BadRequest("invalid_token", "")) return } q := dbq.New(h.pool) // Verify the invite exists + is unredeemed before deleting, so we can // return a useful 404 vs 200. if _, err := q.GetInviteByToken(r.Context(), token); err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: ""}) return } writeErrWithLog(w, h.logger, "admin: get invite failed", apierror.Internal(err)) return } if err := q.DeleteInvite(r.Context(), token); err != nil { writeErrWithLog(w, h.logger, "admin: delete invite failed", apierror.Internal(err)) return } audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, pgtype.UUID{}, audit.ActionInviteRevoke, map[string]any{"token": token}) w.WriteHeader(http.StatusNoContent) } func inviteFromRow(row dbq.UserInvite) inviteResp { resp := inviteResp{ Token: row.Token, InvitedBy: uuidToString(row.InvitedBy), Note: row.Note, CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339), ExpiresAt: row.ExpiresAt.Time.UTC().Format(time.RFC3339), } if row.RedeemedAt.Valid { s := row.RedeemedAt.Time.UTC().Format(time.RFC3339) resp.RedeemedAt = &s } if row.RedeemedBy.Valid { s := uuidToString(row.RedeemedBy) resp.RedeemedBy = &s } return resp }