package storage import ( "context" "errors" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/domain" ) // CollaboratorRepo manages the event_collaborators table. The original host // has an "owner" row inserted by the migration backfill; everyone else lands // here via the invite-accept flow. type CollaboratorRepo struct { pool *pgxpool.Pool } func NewCollaboratorRepo(db *DB) *CollaboratorRepo { return &CollaboratorRepo{pool: db.Pool} } // RoleFor returns the user's role on the event, or empty + false when they // have none. The role-gate middleware uses this exact signature so a missing // row is rendered as 404 (matching the existing pre-Block-C behaviour where // the event "didn't exist" from the perspective of a non-owner). func (r *CollaboratorRepo) RoleFor(ctx context.Context, eventID, userID uuid.UUID) (domain.Role, bool, error) { var role domain.Role err := r.pool.QueryRow(ctx, ` SELECT role FROM event_collaborators WHERE event_id = $1 AND user_id = $2 AND accepted_at IS NOT NULL `, eventID, userID).Scan(&role) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", false, nil } return "", false, err } return role, true, nil } // AddAccepted inserts a collaborator row already marked as accepted (used by // the invite-accept flow). Returns ErrCollaboratorExists when the user is // already on the event regardless of role — the Team UI prevents the // duplicate, but we double-check at the DB layer. func (r *CollaboratorRepo) AddAccepted(ctx context.Context, eventID, userID, invitedBy uuid.UUID, role domain.Role) error { _, err := r.pool.Exec(ctx, ` INSERT INTO event_collaborators (event_id, user_id, role, invited_by, invited_at, accepted_at) VALUES ($1, $2, $3, $4, now(), now()) `, eventID, userID, role, invitedBy) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return domain.ErrCollaboratorExists } return err } return nil } // List returns every collaborator on the event with their user details // joined in. Owners + editors + viewers, ordered by role precedence then by // invited_at so the host's view stays stable. func (r *CollaboratorRepo) List(ctx context.Context, eventID uuid.UUID) ([]domain.Collaborator, error) { rows, err := r.pool.Query(ctx, ` SELECT c.event_id, c.user_id, c.role, c.invited_by, c.invited_at, c.accepted_at, u.name, u.email FROM event_collaborators c JOIN users u ON u.id = c.user_id WHERE c.event_id = $1 ORDER BY CASE c.role WHEN 'owner' THEN 1 WHEN 'editor' THEN 2 WHEN 'viewer' THEN 3 END, c.invited_at ASC `, eventID) if err != nil { return nil, err } defer rows.Close() out := []domain.Collaborator{} for rows.Next() { var c domain.Collaborator if err := rows.Scan( &c.EventID, &c.UserID, &c.Role, &c.InvitedBy, &c.InvitedAt, &c.AcceptedAt, &c.Name, &c.Email, ); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // UpdateRole changes a collaborator's role. Refuses to demote the last // owner — if you'd be left with zero owners after the change, returns // ErrLastOwner and leaves the row untouched. func (r *CollaboratorRepo) UpdateRole(ctx context.Context, eventID, userID uuid.UUID, newRole domain.Role) error { tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var currentRole domain.Role err = tx.QueryRow(ctx, ` SELECT role FROM event_collaborators WHERE event_id = $1 AND user_id = $2 FOR UPDATE `, eventID, userID).Scan(¤tRole) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.ErrCollaboratorNotFound } return err } // Demoting the last owner is the only ordering trap. Promote → owner is // always fine. if currentRole == domain.RoleOwner && newRole != domain.RoleOwner { if err := assertNotLastOwner(ctx, tx, eventID); err != nil { return err } } if _, err := tx.Exec(ctx, ` UPDATE event_collaborators SET role = $3 WHERE event_id = $1 AND user_id = $2 `, eventID, userID, newRole); err != nil { return err } return tx.Commit(ctx) } // Remove deletes a collaborator. Refuses to remove the last owner so the // event can't be orphaned. The host can demote-then-leave if they really // want out, but only after promoting someone else. func (r *CollaboratorRepo) Remove(ctx context.Context, eventID, userID uuid.UUID) error { tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var currentRole domain.Role err = tx.QueryRow(ctx, ` SELECT role FROM event_collaborators WHERE event_id = $1 AND user_id = $2 FOR UPDATE `, eventID, userID).Scan(¤tRole) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.ErrCollaboratorNotFound } return err } if currentRole == domain.RoleOwner { if err := assertNotLastOwner(ctx, tx, eventID); err != nil { return err } } if _, err := tx.Exec(ctx, ` DELETE FROM event_collaborators WHERE event_id = $1 AND user_id = $2 `, eventID, userID); err != nil { return err } return tx.Commit(ctx) } // assertNotLastOwner refuses to proceed when the event has exactly one // owner. Call inside a transaction that holds a row lock on the candidate // row so the count is accurate against concurrent removals. func assertNotLastOwner(ctx context.Context, tx pgx.Tx, eventID uuid.UUID) error { var owners int if err := tx.QueryRow(ctx, ` SELECT count(*) FROM event_collaborators WHERE event_id = $1 AND role = 'owner' `, eventID).Scan(&owners); err != nil { return err } if owners <= 1 { return domain.ErrLastOwner } return nil } // RolesForUser returns a map of event_id → role for every event the user // has any accepted role on. Used by GET /events so the dashboard can // split "your events" from "shared with you" without making N queries // (one per event card). func (r *CollaboratorRepo) RolesForUser(ctx context.Context, userID uuid.UUID) (map[uuid.UUID]domain.Role, error) { rows, err := r.pool.Query(ctx, ` SELECT event_id, role FROM event_collaborators WHERE user_id = $1 AND accepted_at IS NOT NULL `, userID) if err != nil { return nil, err } defer rows.Close() out := map[uuid.UUID]domain.Role{} for rows.Next() { var ( id uuid.UUID role domain.Role ) if err := rows.Scan(&id, &role); err != nil { return nil, err } out[id] = role } return out, rows.Err() } // ListEventIDsForUser returns the set of event IDs the user has any accepted // role on. Used by GET /events to widen the dashboard list beyond just // `events.host_id = userID`. func (r *CollaboratorRepo) ListEventIDsForUser(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) { rows, err := r.pool.Query(ctx, ` SELECT event_id FROM event_collaborators WHERE user_id = $1 AND accepted_at IS NOT NULL `, userID) if err != nil { return nil, err } defer rows.Close() var out []uuid.UUID for rows.Next() { var id uuid.UUID if err := rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } // --- invites --- type InviteRepo struct { pool *pgxpool.Pool } func NewInviteRepo(db *DB) *InviteRepo { return &InviteRepo{pool: db.Pool} } type CreateInviteParams struct { EventID uuid.UUID Email string Role domain.Role InvitedBy uuid.UUID TokenHash string ExpiresAt time.Time } // Create writes an invite row. Multiple pending invites for the same email // on the same event are allowed; the most recent one wins because the user // only ever clicks the latest email. Old tokens stay valid until they // expire — that's fine, they all point at the same event/role tuple. func (r *InviteRepo) Create(ctx context.Context, p CreateInviteParams) error { _, err := r.pool.Exec(ctx, ` INSERT INTO collaborator_invites (token_hash, event_id, email, role, invited_by, expires_at) VALUES ($1, $2, $3, $4, $5, $6) `, p.TokenHash, p.EventID, strings.ToLower(strings.TrimSpace(p.Email)), p.Role, p.InvitedBy, p.ExpiresAt) return err } // Get loads an invite by token hash. Returns ErrInviteNotFound on miss, // ErrInviteExpired when the row exists but `expires_at` has passed, and // ErrInviteAlreadyConsumed when the invite was already accepted. func (r *InviteRepo) Get(ctx context.Context, tokenHash string) (*domain.CollaboratorInvite, error) { var ( inv domain.CollaboratorInvite consumedAt *time.Time ) err := r.pool.QueryRow(ctx, ` SELECT event_id, email, role, invited_by, expires_at, consumed_at, created_at FROM collaborator_invites WHERE token_hash = $1 `, tokenHash).Scan( &inv.EventID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.ExpiresAt, &consumedAt, &inv.CreatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrInviteNotFound } return nil, err } if consumedAt != nil { return nil, domain.ErrInviteAlreadyConsumed } if time.Now().UTC().After(inv.ExpiresAt) { return nil, domain.ErrInviteExpired } return &inv, nil } // MarkConsumed flags the invite as used. Called inside the same transaction // that inserts the new event_collaborators row so the two states stay in // sync (no "accepted but invite still pending" race). func (r *InviteRepo) MarkConsumed(ctx context.Context, tokenHash string) error { tag, err := r.pool.Exec(ctx, ` UPDATE collaborator_invites SET consumed_at = now() WHERE token_hash = $1 AND consumed_at IS NULL `, tokenHash) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrInviteNotFound } return nil } // AcceptInvite atomically consumes the invite and inserts the collaborator // row. Returns ErrCollaboratorExists if the user already has a role on the // event (the invite is still consumed so the link can't be replayed). func (r *CollaboratorRepo) AcceptInvite( ctx context.Context, tokenHash string, userID uuid.UUID, eventID uuid.UUID, invitedBy uuid.UUID, role domain.Role, ) error { tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) tag, err := tx.Exec(ctx, ` UPDATE collaborator_invites SET consumed_at = now() WHERE token_hash = $1 AND consumed_at IS NULL `, tokenHash) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrInviteAlreadyConsumed } _, err = tx.Exec(ctx, ` INSERT INTO event_collaborators (event_id, user_id, role, invited_by, invited_at, accepted_at) VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (event_id, user_id) DO NOTHING `, eventID, userID, role, invitedBy) if err != nil { return err } return tx.Commit(ctx) } // PendingInviteForUser is one pending invitation in a user's inbox-style // list: their own dashboard's "you've been invited" banner. We surface the // event name + inviter name here so the frontend renders one card per // invite without N follow-up lookups. type PendingInviteForUser struct { EventID uuid.UUID `json:"event_id"` EventName string `json:"event_name"` Role domain.Role `json:"role"` InvitedBy uuid.UUID `json:"invited_by"` InviterName string `json:"inviter_name"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` } // ListPendingForEmail returns every unconsumed, non-expired invite addressed // to `email`. The most recent invite per (email, event) wins — older // duplicates are squashed so the user sees one card per event even if the // owner re-sent the invitation. Drives the dashboard banner that lets a // just-signed-in user accept without re-clicking the email link. func (r *InviteRepo) ListPendingForEmail(ctx context.Context, email string) ([]PendingInviteForUser, error) { email = strings.ToLower(strings.TrimSpace(email)) rows, err := r.pool.Query(ctx, ` SELECT DISTINCT ON (ci.event_id) ci.event_id, e.name, ci.role, ci.invited_by, COALESCE(u.name, '') AS inviter_name, ci.expires_at, ci.created_at FROM collaborator_invites ci JOIN events e ON e.id = ci.event_id LEFT JOIN users u ON u.id = ci.invited_by WHERE lower(ci.email) = $1 AND ci.consumed_at IS NULL AND ci.expires_at > now() ORDER BY ci.event_id, ci.created_at DESC `, email) if err != nil { return nil, err } defer rows.Close() out := []PendingInviteForUser{} for rows.Next() { var p PendingInviteForUser if err := rows.Scan( &p.EventID, &p.EventName, &p.Role, &p.InvitedBy, &p.InviterName, &p.ExpiresAt, &p.CreatedAt, ); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // AcceptByEventAndEmail finds the latest pending invite for (email, eventID) // and atomically consumes it + inserts the collaborator row. Used by the // dashboard "Accept" button — the user authenticates with their session, // and we match by email so the cross-tab signup flow doesn't need the raw // token. Returns ErrInviteNotFound when no matching invite is pending. func (r *CollaboratorRepo) AcceptByEventAndEmail( ctx context.Context, eventID uuid.UUID, email string, userID uuid.UUID, ) (domain.Role, error) { email = strings.ToLower(strings.TrimSpace(email)) tx, err := r.pool.Begin(ctx) if err != nil { return "", err } defer tx.Rollback(ctx) // Lock the latest matching invite so two concurrent accepts can't // both consume the same row. var ( tokenHash string role domain.Role invitedBy uuid.UUID ) err = tx.QueryRow(ctx, ` SELECT token_hash, role, invited_by FROM collaborator_invites WHERE event_id = $1 AND lower(email) = $2 AND consumed_at IS NULL AND expires_at > now() ORDER BY created_at DESC LIMIT 1 FOR UPDATE `, eventID, email).Scan(&tokenHash, &role, &invitedBy) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", domain.ErrInviteNotFound } return "", err } if _, err := tx.Exec(ctx, ` UPDATE collaborator_invites SET consumed_at = now() WHERE token_hash = $1 `, tokenHash); err != nil { return "", err } if _, err := tx.Exec(ctx, ` INSERT INTO event_collaborators (event_id, user_id, role, invited_by, invited_at, accepted_at) VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (event_id, user_id) DO NOTHING `, eventID, userID, role, invitedBy); err != nil { return "", err } if err := tx.Commit(ctx); err != nil { return "", err } return role, nil } // ListPendingForEvent returns invitations the host hasn't seen accepted yet, // shown alongside accepted collaborators on the Team tab. func (r *InviteRepo) ListPendingForEvent(ctx context.Context, eventID uuid.UUID) ([]domain.CollaboratorInvite, error) { rows, err := r.pool.Query(ctx, ` SELECT event_id, email, role, invited_by, expires_at, created_at FROM collaborator_invites WHERE event_id = $1 AND consumed_at IS NULL ORDER BY created_at DESC `, eventID) if err != nil { return nil, err } defer rows.Close() out := []domain.CollaboratorInvite{} for rows.Next() { var inv domain.CollaboratorInvite if err := rows.Scan( &inv.EventID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.ExpiresAt, &inv.CreatedAt, ); err != nil { return nil, err } out = append(out, inv) } return out, rows.Err() } // DeletePendingByEmail clears any unconsumed invites for the (event, email) // pair. Called when the host cancels a pending invite from the Team UI. func (r *InviteRepo) DeletePendingByEmail(ctx context.Context, eventID uuid.UUID, email string) error { _, err := r.pool.Exec(ctx, ` DELETE FROM collaborator_invites WHERE event_id = $1 AND lower(email) = lower($2) AND consumed_at IS NULL `, eventID, strings.TrimSpace(email)) return err }