feat(tier2): multi-host / collaborators — Block C

Events can now have multiple users with distinct roles:
  owner   — manage collaborators, delete event, full access
  editor  — manage guests, tokens, CSV import, patch event
  viewer  — read-only access to everything

Schema (migration 0008)
- collaborator_role ENUM + event_collaborators + collaborator_invites
- Backfill: every existing events.host_id becomes an owner row
- EventRepo.Create seeds the owner row in the same transaction so
  no future event can exist without one

Authz
- New requireRole(eventID, userID, minRole) helper. Non-members 404;
  insufficient role 403. Replaces requireEventOwner across every
  shared-role handler (events.get/update, guests CRUD, tokens issue/
  rotate/bulk, csv preview/commit/template, activity, ws-ticket)
- events.delete + collaborator management stay owner-only
- GET /events lists every event the user has any role on
- /events/{id} response now embeds your_role for UI branching

Collaborator endpoints
- GET    /events/{id}/collaborators           (viewer+)
- POST   /events/{id}/collaborators           (owner)  — sends invite email
- PATCH  /events/{id}/collaborators/{user_id} (owner)  — role change
- DELETE /events/{id}/collaborators/{user_id} (owner)  — refuses last owner
- DELETE /events/{id}/collaborators/pending   (owner)  — cancel invite
- GET    /invites/{token}                     (public) — preview summary
- POST   /invites/{token}/accept              (authed) — atomic accept

Invitations
- SHA-256 hashed in DB; raw value only lives in the email link
- 7-day TTL, single-use, email-bound (caller's email must match)
- New SendCollaboratorInvite on auth.EmailSender + Resend/SMTP/SES
  senders + log stub; collaborator_invite.html/txt branded template

Frontend
- TeamCard.vue on the event detail page: lists collaborators with
  inline role-change + remove, pending-invites with cancel, invite
  modal (email + role). Owner-only actions hidden for editors/viewers
- /invites/[token] accept page: shows invite summary, prompts signup
  or sign-in with pre-filled email, refuses mismatched accounts

Tests (all 6 pass on the existing testcontainers harness)
- backfill: legacy host gets owner role
- role enforcement: viewer can read, editor can write guests but not
  delete/manage team, non-member 404s everywhere
- last-owner removal refused (400)
- shared events show up in collaborator's /events list
- invite flow: create → preview → accept → role granted → replay 410
- email mismatch on accept returns 403
- expired invite returns 410

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-17 22:14:50 +01:00
parent 6803d700b4
commit 3973e4058d
28 changed files with 2108 additions and 32 deletions
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
// Tier 2 Block C — multi-host team management. Shown as a card on the event
// detail page; visible to anyone with viewer+ role. Action buttons (invite,
// role change, remove) are gated to owners — the server enforces this too.
interface Collaborator {
user_id: string
name: string
email: string
role: 'owner' | 'editor' | 'viewer'
invited_at: string
accepted_at?: string | null
}
interface PendingInvite {
email: string
role: 'owner' | 'editor' | 'viewer'
expires_at: string
created_at: string
}
interface TeamResponse {
collaborators: Collaborator[]
pending: PendingInvite[]
your_role: 'owner' | 'editor' | 'viewer'
}
const props = defineProps<{
eventId: string
yourRole?: 'owner' | 'editor' | 'viewer' | null
}>()
const collaborators = ref<Collaborator[]>([])
const pending = ref<PendingInvite[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const isOwner = computed(() => props.yourRole === 'owner')
async function refresh() {
try {
const data = await useApi<TeamResponse>(`/events/${props.eventId}/collaborators`)
collaborators.value = data.collaborators || []
pending.value = data.pending || []
} catch (e: any) {
error.value = useErrMessage(e, 'Could not load team')
} finally {
loading.value = false
}
}
onMounted(refresh)
// --- invite modal ---
const inviting = ref(false)
const inviteEmail = ref('')
const inviteRole = ref<'editor' | 'viewer'>('editor')
const inviteInFlight = ref(false)
const inviteError = ref<string | null>(null)
function openInvite() {
inviteEmail.value = ''
inviteRole.value = 'editor'
inviteError.value = null
inviting.value = true
}
async function sendInvite() {
inviteInFlight.value = true
inviteError.value = null
try {
await useApi(`/events/${props.eventId}/collaborators`, {
method: 'POST',
body: { email: inviteEmail.value.trim(), role: inviteRole.value },
})
inviting.value = false
await refresh()
} catch (e: any) {
inviteError.value = useErrMessage(e, 'Could not send invite')
} finally {
inviteInFlight.value = false
}
}
// --- role change ---
async function changeRole(c: Collaborator, role: 'owner' | 'editor' | 'viewer') {
if (c.role === role) return
try {
await useApi(`/events/${props.eventId}/collaborators/${c.user_id}`, {
method: 'PATCH',
body: { role },
})
await refresh()
} catch (e: any) {
error.value = useErrMessage(e, 'Could not change role')
}
}
// --- remove ---
async function removeCollaborator(c: Collaborator) {
if (!confirm(`Remove ${c.name || c.email} from this event?`)) return
try {
await useApi(`/events/${props.eventId}/collaborators/${c.user_id}`, { method: 'DELETE' })
await refresh()
} catch (e: any) {
error.value = useErrMessage(e, 'Could not remove collaborator')
}
}
async function cancelPending(p: PendingInvite) {
if (!confirm(`Cancel invitation to ${p.email}?`)) return
try {
await useApi(`/events/${props.eventId}/collaborators/pending?email=${encodeURIComponent(p.email)}`, {
method: 'DELETE',
})
await refresh()
} catch (e: any) {
error.value = useErrMessage(e, 'Could not cancel invitation')
}
}
function fmtDate(iso?: string | null) {
if (!iso) return ''
try { return new Date(iso).toLocaleDateString() } catch { return iso }
}
</script>
<template>
<section class="card">
<header class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-semibold">Team</h2>
<button
v-if="isOwner"
type="button"
class="btn-primary text-sm"
@click="openInvite"
>Invite</button>
</header>
<p class="mb-4 text-xs text-zinc-500">
Collaborators can help manage this event. Owners can invite + change roles;
editors can manage guests + send messages; viewers can read but not change.
</p>
<p v-if="error" class="mb-3 text-sm text-red-400">{{ error }}</p>
<div v-if="loading" class="text-sm text-zinc-500">Loading team</div>
<ul v-else-if="collaborators.length" class="space-y-2">
<li
v-for="c in collaborators"
:key="c.user_id"
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-zinc-100">{{ c.name || c.email }}</div>
<div class="truncate text-xs text-zinc-500">{{ c.email }}</div>
</div>
<div class="flex items-center gap-2">
<select
v-if="isOwner"
:value="c.role"
class="rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
:aria-label="`Change role for ${c.name || c.email}`"
@change="(e) => changeRole(c, ((e.target as HTMLSelectElement).value as any))"
>
<option value="owner">Owner</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<span v-else class="badge bg-zinc-800 capitalize text-zinc-300">{{ c.role }}</span>
<button
v-if="isOwner"
type="button"
class="rounded-md p-1 text-zinc-500 transition hover:bg-red-500/10 hover:text-red-300"
:title="`Remove ${c.name || c.email}`"
:aria-label="`Remove ${c.name || c.email}`"
@click="removeCollaborator(c)"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6 6L14 14M14 6L6 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</li>
</ul>
<p v-else class="text-sm text-zinc-500">No collaborators yet.</p>
<div v-if="pending.length" class="mt-4">
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Pending invitations</p>
<ul class="space-y-2">
<li
v-for="p in pending"
:key="p.email"
class="flex items-center justify-between rounded-md border border-amber-900/40 bg-amber-950/10 px-3 py-2"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-zinc-100">{{ p.email }}</div>
<div class="text-xs text-zinc-500">
<span class="capitalize">{{ p.role }}</span> · expires {{ fmtDate(p.expires_at) }}
</div>
</div>
<button
v-if="isOwner"
type="button"
class="text-xs text-zinc-400 hover:text-red-300"
@click="cancelPending(p)"
>Cancel</button>
</li>
</ul>
</div>
<!-- Invite modal -->
<Teleport to="body">
<div
v-if="inviting"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="inviting = false"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="invite-title"
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
>
<h3 id="invite-title" class="mb-1 text-base font-semibold">Invite a collaborator</h3>
<p class="mb-4 text-xs text-zinc-500">
They'll get an email with a link that's valid for 7 days. If they don't have a
GuestGuard account yet, they'll be prompted to create one.
</p>
<form class="space-y-3" @submit.prevent="sendInvite">
<div>
<label class="label">Email</label>
<input
v-model="inviteEmail"
type="email"
required
class="input"
placeholder="teammate@example.com"
/>
</div>
<div>
<label class="label">Role</label>
<select v-model="inviteRole" class="input">
<option value="editor">Editor manage guests + send messages</option>
<option value="viewer">Viewer read-only access</option>
</select>
</div>
<p v-if="inviteError" class="text-sm text-red-400">{{ inviteError }}</p>
<div class="flex items-center justify-end gap-2 pt-2">
<button
type="button"
class="text-sm text-zinc-400 hover:text-zinc-200"
:disabled="inviteInFlight"
@click="inviting = false"
>Cancel</button>
<button
type="submit"
class="btn-primary"
:disabled="inviteInFlight || !inviteEmail.trim()"
>{{ inviteInFlight ? 'Sending…' : 'Send invite' }}</button>
</div>
</form>
</div>
</div>
</Teleport>
</section>
</template>
+9
View File
@@ -35,6 +35,9 @@ interface EventDetail {
status: string status: string
created_at: string created_at: string
updated_at: string updated_at: string
// Tier 2 Block C — caller's role on this event. The dashboard branches
// UI affordances off this rather than the legacy host_id check.
your_role?: 'owner' | 'editor' | 'viewer'
} }
interface IssuedToken { interface IssuedToken {
@@ -1117,6 +1120,12 @@ function checkLabel(band?: string): string {
</aside> </aside>
</div> </div>
<!-- Team (Tier 2 Block C). Visible to anyone with viewer+ access; action
buttons gated to owners. -->
<div v-if="event" class="mt-6">
<TeamCard :event-id="eventId" :your-role="event.your_role" />
</div>
<!-- ===== Modals ===== --> <!-- ===== Modals ===== -->
<!-- All modals share the same pattern: backdrop click + Esc close, <!-- All modals share the same pattern: backdrop click + Esc close,
role=dialog + aria-modal, primary action on the right. role=dialog + aria-modal, primary action on the right.
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
// Collaborator invite acceptance — Tier 2 Block C.
//
// Flow:
// 1. Preview the invite (unauthed) so we can show the event name + role.
// 2. If the visitor isn't signed in, redirect them to signup (preserving
// the invite path as redirect). The signup must use the invited email.
// 3. After signup/login the user lands back here; POST /invites/{token}/accept
// and on success bounce them to /dashboard/events/{event_id}.
interface InviteSummary {
event_id: string
event_name: string
role: 'owner' | 'editor' | 'viewer'
email: string
expires_at: string
}
const route = useRoute()
const auth = useAuth()
const token = route.params.token as string
const loading = ref(true)
const summary = ref<InviteSummary | null>(null)
const loadError = ref<string | null>(null)
const accepting = ref(false)
const acceptError = ref<string | null>(null)
onMounted(async () => {
try {
summary.value = await useApi<InviteSummary>(`/invites/${token}`)
} catch (e: any) {
loadError.value = useErrMessage(e, 'Invitation is invalid or expired.')
} finally {
loading.value = false
}
})
const signedIn = computed(() => !!auth.user.value)
const emailMatches = computed(() => {
if (!summary.value || !auth.user.value) return false
return auth.user.value.email.trim().toLowerCase() === summary.value.email.trim().toLowerCase()
})
function goToSignup() {
// Pre-fill the email so the visitor isn't tempted to register a different
// one. The accept handler refuses to consume the invite if emails differ.
if (!summary.value) return
const target = `/signup?email=${encodeURIComponent(summary.value.email)}&redirect=${encodeURIComponent(route.fullPath)}`
navigateTo(target)
}
function goToLogin() {
if (!summary.value) return
const target = `/login?email=${encodeURIComponent(summary.value.email)}&redirect=${encodeURIComponent(route.fullPath)}`
navigateTo(target)
}
async function accept() {
if (!summary.value) return
accepting.value = true
acceptError.value = null
try {
const res = await useApi<{ event_id: string }>(`/invites/${token}/accept`, { method: 'POST' })
navigateTo(`/dashboard/events/${res.event_id}`)
} catch (e: any) {
acceptError.value = useErrMessage(e, 'Could not accept invitation')
} finally {
accepting.value = false
}
}
function fmtDate(iso?: string) {
if (!iso) return ''
try { return new Date(iso).toLocaleString() } catch { return iso }
}
</script>
<template>
<section class="mx-auto max-w-xl py-10">
<div v-if="loading" class="text-sm text-zinc-500">Looking up your invitation</div>
<div v-else-if="loadError" class="card border-red-900/60 bg-red-950/30">
<h1 class="mb-2 text-xl font-semibold text-red-200">Invitation unavailable</h1>
<p class="text-sm text-red-300">{{ loadError }}</p>
<p class="mt-4 text-xs text-zinc-500">
Ask the person who sent the invitation to resend it from the event's Team tab.
</p>
</div>
<div v-else-if="summary" class="card border-brand-900/60 bg-brand-950/20">
<p class="text-xs uppercase tracking-widest text-brand-500">Team invitation</p>
<h1 class="mb-1 text-2xl font-semibold">{{ summary.event_name }}</h1>
<p class="mb-5 text-sm text-zinc-400">
You've been invited as
<strong class="capitalize text-brand-300">{{ summary.role }}</strong>.
</p>
<div v-if="!signedIn" class="space-y-3">
<p class="text-sm">
Sign in or create a GuestGuard account for
<strong class="text-zinc-100">{{ summary.email }}</strong>
to accept.
</p>
<div class="flex gap-2">
<button class="btn-primary flex-1" @click="goToSignup">Create account</button>
<button class="btn-ghost flex-1" @click="goToLogin">Sign in</button>
</div>
</div>
<div v-else-if="!emailMatches" class="rounded-md border border-amber-900/60 bg-amber-950/20 p-3 text-sm">
<p class="text-amber-200">
You're signed in as <strong>{{ auth.user.value?.email }}</strong>,
but this invitation was sent to
<strong>{{ summary.email }}</strong>.
</p>
<p class="mt-2 text-xs text-amber-300/80">
Sign out and sign back in with the right account to accept.
</p>
</div>
<div v-else class="space-y-3">
<p class="text-sm">Ready to join? Accept the invitation below.</p>
<button class="btn-primary w-full" :disabled="accepting" @click="accept">
{{ accepting ? 'Accepting' : `Accept and open event` }}
</button>
<p v-if="acceptError" class="text-sm text-red-400">{{ acceptError }}</p>
</div>
<p class="mt-5 border-t border-zinc-800 pt-4 text-xs text-zinc-500">
Expires {{ fmtDate(summary.expires_at) }}.
</p>
</div>
</section>
</template>
+3 -1
View File
@@ -5,6 +5,7 @@ import (
"sort" "sort"
"time" "time"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage" "github.com/alchemistkay/guestguard/internal/storage"
) )
@@ -14,6 +15,7 @@ import (
// who weren't watching when activity happened. // who weren't watching when activity happened.
type activityHandler struct { type activityHandler struct {
events *storage.EventRepo events *storage.EventRepo
collabs *storage.CollaboratorRepo
rsvps *storage.RSVPRepo rsvps *storage.RSVPRepo
accessLogs *storage.AccessLogRepo accessLogs *storage.AccessLogRepo
} }
@@ -48,7 +50,7 @@ func (h *activityHandler) list(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return return
} }
+49
View File
@@ -25,6 +25,11 @@ func hostFromContext(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
// On mismatch (or missing event) it returns 404 — never 403 — so a cross- // On mismatch (or missing event) it returns 404 — never 403 — so a cross-
// tenant probe cannot tell the difference between "event doesn't exist" and // tenant probe cannot tell the difference between "event doesn't exist" and
// "exists but belongs to someone else". // "exists but belongs to someone else".
//
// Pre-Block-C this checked the events.host_id column directly. Block C
// preserves the same semantics for the owner-only handlers (events.Update,
// events.Delete, collaborator management) — but for shared-role endpoints,
// callers should use requireRole instead.
func requireEventOwner( func requireEventOwner(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
@@ -42,3 +47,47 @@ func requireEventOwner(
} }
return ev, true return ev, true
} }
// requireRole is the Block C authz gate. It resolves the user's role on the
// event and confirms it's at least `min`. Missing role → 404 (avoids leaking
// existence to outsiders). Role-too-low → 403 (the user IS on the event,
// just not privileged enough — that's safe to surface; the UI will hide the
// action anyway).
//
// On success it returns the event and the user's role so the handler can
// branch on owner-only behaviours without a second lookup.
func requireRole(
w http.ResponseWriter,
r *http.Request,
events *storage.EventRepo,
collabs *storage.CollaboratorRepo,
eventID, userID uuid.UUID,
min domain.Role,
) (*domain.Event, domain.Role, bool) {
role, ok, err := collabs.RoleFor(r.Context(), eventID, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve role")
return nil, "", false
}
if !ok {
// Not a collaborator. Treat the same way the legacy host_id check
// did: 404, no leak.
writeError(w, http.StatusNotFound, "event not found")
return nil, "", false
}
if !role.AtLeast(min) {
writeError(w, http.StatusForbidden, "insufficient role for this action")
return nil, "", false
}
ev, err := events.Get(r.Context(), eventID)
if err != nil {
if errors.Is(err, domain.ErrEventNotFound) {
// Race: the event was deleted between the role lookup and now.
writeError(w, http.StatusNotFound, "event not found")
return nil, "", false
}
writeError(w, http.StatusInternalServerError, "failed to load event")
return nil, "", false
}
return ev, role, true
}
+455
View File
@@ -0,0 +1,455 @@
package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
// collaboratorHandler powers the per-event Team endpoints and the public
// invite-accept flow. Tier 2 Block C.
type collaboratorHandler struct {
logger *slog.Logger
events *storage.EventRepo
users *storage.UserRepo
collabs *storage.CollaboratorRepo
invites *storage.InviteRepo
emails auth.EmailSender
publicBaseURL string
inviteTTL time.Duration
}
// --- responses ---
type collaboratorView struct {
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
Role domain.Role `json:"role"`
InvitedAt time.Time `json:"invited_at"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
}
type pendingInviteView struct {
Email string `json:"email"`
Role domain.Role `json:"role"`
InvitedBy uuid.UUID `json:"invited_by"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
type listCollaboratorsResponse struct {
Collaborators []collaboratorView `json:"collaborators"`
Pending []pendingInviteView `json:"pending"`
// YourRole tells the UI which actions to hide. Echoed from requireRole
// so the client doesn't need a separate /me lookup per event view.
YourRole domain.Role `json:"your_role"`
}
// GET /events/{id}/collaborators — viewer+ can see the team list. Pending
// invites are exposed too so editors can chase up unaccepted invitations,
// not just owners.
func (h *collaboratorHandler) list(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
_, role, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer)
if !ok {
return
}
members, err := h.collabs.List(r.Context(), eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list collaborators")
return
}
views := make([]collaboratorView, 0, len(members))
for _, m := range members {
views = append(views, collaboratorView{
UserID: m.UserID,
Name: m.Name,
Email: m.Email,
Role: m.Role,
InvitedAt: m.InvitedAt,
AcceptedAt: m.AcceptedAt,
})
}
pending, err := h.invites.ListPendingForEvent(r.Context(), eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list pending invites")
return
}
pendingViews := make([]pendingInviteView, 0, len(pending))
for _, p := range pending {
pendingViews = append(pendingViews, pendingInviteView{
Email: p.Email,
Role: p.Role,
InvitedBy: p.InvitedBy,
ExpiresAt: p.ExpiresAt,
CreatedAt: p.CreatedAt,
})
}
writeJSON(w, http.StatusOK, listCollaboratorsResponse{
Collaborators: views,
Pending: pendingViews,
YourRole: role,
})
}
type inviteRequest struct {
Email string `json:"email"`
Role domain.Role `json:"role"`
}
type inviteResponse struct {
Email string `json:"email"`
Role domain.Role `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
// Sent reports whether the email send succeeded. The invite is still
// usable from the host's side either way — they can resend.
Sent bool `json:"sent"`
}
// POST /events/{id}/collaborators — owner-only. Creates an invitation
// token, emails it to the recipient. The recipient can accept whether or
// not they already have a GuestGuard account.
func (h *collaboratorHandler) invite(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleOwner)
if !ok {
return
}
var req inviteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" || !strings.Contains(email, "@") {
writeError(w, http.StatusBadRequest, "valid email required")
return
}
if !req.Role.Valid() {
writeError(w, http.StatusBadRequest, "role must be owner|editor|viewer")
return
}
// If the invitee already has an account AND is already a collaborator,
// short-circuit with a friendly 409 — no email, no DB churn.
if existing, err := h.users.GetByEmail(r.Context(), email); err == nil && existing != nil {
if _, already, err := h.collabs.RoleFor(r.Context(), eventID, existing.ID); err == nil && already {
writeError(w, http.StatusConflict, "user is already a collaborator on this event")
return
}
}
raw, hash, err := auth.NewOpaqueToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mint invite")
return
}
expiresAt := time.Now().UTC().Add(h.ttl())
if err := h.invites.Create(r.Context(), storage.CreateInviteParams{
EventID: eventID,
Email: email,
Role: req.Role,
InvitedBy: hostID,
TokenHash: hash,
ExpiresAt: expiresAt,
}); err != nil {
h.logger.Error("create invite", "err", err, "event_id", eventID)
writeError(w, http.StatusInternalServerError, "failed to create invite")
return
}
// Email send is best-effort. Failure still leaves the invite in the DB
// so a Resend action from the UI can retry; we report `sent: false`
// so the host knows.
sent := h.emailInvite(r.Context(), email, hostID, event.Name, req.Role, raw)
writeJSON(w, http.StatusCreated, inviteResponse{
Email: email,
Role: req.Role,
ExpiresAt: expiresAt,
Sent: sent,
})
}
// PATCH /events/{id}/collaborators/{user_id} — owner-only. Change another
// collaborator's role. Demoting the last owner returns 400.
type updateRoleRequest struct {
Role domain.Role `json:"role"`
}
func (h *collaboratorHandler) updateRole(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleOwner); !ok {
return
}
userID, ok := parseIDParam(w, r, "user_id")
if !ok {
return
}
var req updateRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if !req.Role.Valid() {
writeError(w, http.StatusBadRequest, "role must be owner|editor|viewer")
return
}
if err := h.collabs.UpdateRole(r.Context(), eventID, userID, req.Role); err != nil {
switch {
case errors.Is(err, domain.ErrCollaboratorNotFound):
writeError(w, http.StatusNotFound, "collaborator not found")
case errors.Is(err, domain.ErrLastOwner):
writeError(w, http.StatusBadRequest, "cannot demote the last owner — promote someone else first")
default:
h.logger.Error("update role", "err", err)
writeError(w, http.StatusInternalServerError, "failed to update role")
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// DELETE /events/{id}/collaborators/{user_id} — owner-only. Removing the
// last owner returns 400.
func (h *collaboratorHandler) remove(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleOwner); !ok {
return
}
userID, ok := parseIDParam(w, r, "user_id")
if !ok {
return
}
if err := h.collabs.Remove(r.Context(), eventID, userID); err != nil {
switch {
case errors.Is(err, domain.ErrCollaboratorNotFound):
writeError(w, http.StatusNotFound, "collaborator not found")
case errors.Is(err, domain.ErrLastOwner):
writeError(w, http.StatusBadRequest, "cannot remove the last owner — promote someone else first")
default:
h.logger.Error("remove collaborator", "err", err)
writeError(w, http.StatusInternalServerError, "failed to remove collaborator")
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// DELETE /events/{id}/collaborators/pending — owner-only. Cancels a
// still-unconsumed invite for the given email. Idempotent: no rows is a 204.
func (h *collaboratorHandler) cancelInvite(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleOwner); !ok {
return
}
email := strings.TrimSpace(r.URL.Query().Get("email"))
if email == "" {
writeError(w, http.StatusBadRequest, "email query parameter required")
return
}
if err := h.invites.DeletePendingByEmail(r.Context(), eventID, email); err != nil {
writeError(w, http.StatusInternalServerError, "failed to cancel invite")
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- public invite-accept flow ---
type inviteSummary struct {
EventID uuid.UUID `json:"event_id"`
EventName string `json:"event_name"`
Role domain.Role `json:"role"`
Email string `json:"email"`
ExpiresAt time.Time `json:"expires_at"`
}
// GET /invites/{token} — preview the invitation. Used by the frontend
// accept page to render "Foo invited you to Bar as Editor" before the user
// hits Accept. Doesn't require auth — the caller might not have an account
// yet. Returns the same error codes accept does so the UI can branch
// cleanly.
func (h *collaboratorHandler) previewInvite(w http.ResponseWriter, r *http.Request) {
inv, event, ok := h.loadInvite(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, inviteSummary{
EventID: inv.EventID,
EventName: event.Name,
Role: inv.Role,
Email: inv.Email,
ExpiresAt: inv.ExpiresAt,
})
}
type acceptResponse struct {
EventID uuid.UUID `json:"event_id"`
Role domain.Role `json:"role"`
}
// POST /invites/{token}/accept — authed: the caller must be logged in as
// the invitee. If the email on the invite doesn't match the caller's
// account, we 403 (mirrors how multi-tenant tools handle "this link was
// sent to someone else"). Single-use; token is consumed atomically with
// the collaborator insert.
func (h *collaboratorHandler) acceptInvite(w http.ResponseWriter, r *http.Request) {
userID, ok := hostFromContext(w, r)
if !ok {
return
}
caller, err := h.users.GetByID(r.Context(), userID)
if err != nil || caller == nil {
writeError(w, http.StatusUnauthorized, "unauthenticated")
return
}
inv, event, ok := h.loadInvite(w, r)
if !ok {
return
}
if !strings.EqualFold(strings.TrimSpace(caller.Email), strings.TrimSpace(inv.Email)) {
writeError(w, http.StatusForbidden, "invitation was sent to a different email")
return
}
tokenHash := auth.HashOpaque(r.PathValue("token"))
if err := h.collabs.AcceptInvite(r.Context(), tokenHash, userID, inv.EventID, inv.InvitedBy, inv.Role); err != nil {
switch {
case errors.Is(err, domain.ErrInviteAlreadyConsumed):
writeError(w, http.StatusGone, "invitation already used")
case errors.Is(err, domain.ErrCollaboratorExists):
// Already on the event — treat as success (idempotent accept).
writeJSON(w, http.StatusOK, acceptResponse{EventID: inv.EventID, Role: inv.Role})
default:
h.logger.Error("accept invite", "err", err)
writeError(w, http.StatusInternalServerError, "failed to accept invite")
}
return
}
_ = event
writeJSON(w, http.StatusOK, acceptResponse{EventID: inv.EventID, Role: inv.Role})
}
// loadInvite validates the path token, fetches the invite (rejecting
// expired/consumed/missing with the right status), and resolves the
// associated event. Returns the invite + event on success.
func (h *collaboratorHandler) loadInvite(w http.ResponseWriter, r *http.Request) (*domain.CollaboratorInvite, *domain.Event, bool) {
raw := r.PathValue("token")
if raw == "" {
writeError(w, http.StatusBadRequest, "missing invite token")
return nil, nil, false
}
inv, err := h.invites.Get(r.Context(), auth.HashOpaque(raw))
if err != nil {
switch {
case errors.Is(err, domain.ErrInviteNotFound):
writeError(w, http.StatusNotFound, "invitation not found")
case errors.Is(err, domain.ErrInviteExpired):
writeError(w, http.StatusGone, "invitation expired")
case errors.Is(err, domain.ErrInviteAlreadyConsumed):
writeError(w, http.StatusGone, "invitation already used")
default:
writeError(w, http.StatusInternalServerError, "failed to load invite")
}
return nil, nil, false
}
event, err := h.events.Get(r.Context(), inv.EventID)
if err != nil {
if errors.Is(err, domain.ErrEventNotFound) {
writeError(w, http.StatusNotFound, "event no longer exists")
return nil, nil, false
}
writeError(w, http.StatusInternalServerError, "failed to load event")
return nil, nil, false
}
return inv, event, true
}
// emailInvite dispatches the invite email via the configured sender.
// Best-effort: a failure is logged + returns false so the response can flag
// it; the invitation row is preserved in either case.
func (h *collaboratorHandler) emailInvite(ctx context.Context, to string, inviterID uuid.UUID, eventName string, role domain.Role, raw string) bool {
if h.emails == nil {
return false
}
inviterName := ""
if inv, err := h.users.GetByID(ctx, inviterID); err == nil && inv != nil {
inviterName = inv.Name
}
link := h.acceptLink(raw)
if err := h.emails.SendCollaboratorInvite(ctx, to, inviterName, eventName, string(role), link); err != nil {
h.logger.Warn("send collaborator invite (continuing)", "err", err, "to", to)
return false
}
return true
}
func (h *collaboratorHandler) acceptLink(raw string) string {
base := h.publicBaseURL
if base == "" {
base = "http://localhost:3000"
}
return base + "/invites/" + raw
}
func (h *collaboratorHandler) ttl() time.Duration {
if h.inviteTTL > 0 {
return h.inviteTTL
}
return domain.DefaultInviteTTL
}
+5 -3
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"github.com/alchemistkay/guestguard/internal/csvimport" "github.com/alchemistkay/guestguard/internal/csvimport"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage" "github.com/alchemistkay/guestguard/internal/storage"
) )
@@ -18,6 +19,7 @@ const (
type csvImportHandler struct { type csvImportHandler struct {
guests *storage.GuestRepo guests *storage.GuestRepo
events *storage.EventRepo events *storage.EventRepo
collabs *storage.CollaboratorRepo
enforcer *tierEnforcer enforcer *tierEnforcer
} }
@@ -46,7 +48,7 @@ func (h *csvImportHandler) preview(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return return
} }
@@ -80,7 +82,7 @@ func (h *csvImportHandler) commit(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return return
} }
@@ -141,7 +143,7 @@ func (h *csvImportHandler) template(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return return
} }
w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Type", "text/csv; charset=utf-8")
+31 -5
View File
@@ -16,6 +16,7 @@ import (
type eventHandler struct { type eventHandler struct {
repo *storage.EventRepo repo *storage.EventRepo
collabs *storage.CollaboratorRepo
enforcer *tierEnforcer enforcer *tierEnforcer
} }
@@ -90,6 +91,13 @@ func (h *eventHandler) create(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, ev) writeJSON(w, http.StatusCreated, ev)
} }
// eventView wraps an Event with the caller's role so the dashboard UI can
// branch (e.g. hide the "Delete event" button for editors/viewers).
type eventView struct {
*domain.Event
YourRole domain.Role `json:"your_role"`
}
func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) { func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r) hostID, ok := hostFromContext(w, r)
if !ok { if !ok {
@@ -99,11 +107,11 @@ func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
ev, ok := requireEventOwner(w, r, h.repo, id, hostID) ev, role, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleViewer)
if !ok { if !ok {
return return
} }
writeJSON(w, http.StatusOK, ev) writeJSON(w, http.StatusOK, eventView{Event: ev, YourRole: role})
} }
func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) { func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
@@ -116,7 +124,15 @@ func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
limit := atoiOr(q.Get("limit"), 50) limit := atoiOr(q.Get("limit"), 50)
offset := atoiOr(q.Get("offset"), 0) offset := atoiOr(q.Get("offset"), 0)
events, err := h.repo.List(r.Context(), hostID, limit, offset) // Block C: the dashboard shows every event the user has any role on,
// not just events they own. The collaborators repo gives us the id set;
// the events repo paginates the merged list.
collabIDs, err := h.collabs.ListEventIDsForUser(r.Context(), hostID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve memberships")
return
}
events, err := h.repo.ListForUser(r.Context(), hostID, collabIDs, limit, offset)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list events") writeError(w, http.StatusInternalServerError, "failed to list events")
return return
@@ -150,6 +166,11 @@ func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
// Block C: editor+ can patch event metadata. The plan reserves DELETE
// (and the existing host_id row) for owners only.
if _, _, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleEditor); !ok {
return
}
var req updateEventRequest var req updateEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -180,7 +201,7 @@ func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
params.Status = &s params.Status = &s
} }
ev, err := h.repo.Update(r.Context(), id, hostID, params) ev, err := h.repo.UpdateByID(r.Context(), id, params)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, domain.ErrEventNotFound): case errors.Is(err, domain.ErrEventNotFound):
@@ -204,7 +225,12 @@ func (h *eventHandler) delete(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if err := h.repo.Delete(r.Context(), id, hostID); err != nil { // Block C: only owners can delete an event. Editors get 403; viewers
// and non-members get 404.
if _, _, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleOwner); !ok {
return
}
if err := h.repo.DeleteByID(r.Context(), id); err != nil {
if errors.Is(err, domain.ErrEventNotFound) { if errors.Is(err, domain.ErrEventNotFound) {
writeError(w, http.StatusNotFound, "event not found") writeError(w, http.StatusNotFound, "event not found")
return return
+7 -4
View File
@@ -12,6 +12,7 @@ import (
type guestHandler struct { type guestHandler struct {
guests *storage.GuestRepo guests *storage.GuestRepo
events *storage.EventRepo events *storage.EventRepo
collabs *storage.CollaboratorRepo
enforcer *tierEnforcer enforcer *tierEnforcer
} }
@@ -33,7 +34,7 @@ func (h *guestHandler) create(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return return
} }
if !h.enforcer.allowGuestCreate(w, r, hostID, eventID) { if !h.enforcer.allowGuestCreate(w, r, hostID, eventID) {
@@ -93,7 +94,7 @@ func (h *guestHandler) update(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return return
} }
@@ -143,7 +144,7 @@ func (h *guestHandler) delete(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return return
} }
if err := h.guests.Delete(r.Context(), eventID, guestID); err != nil { if err := h.guests.Delete(r.Context(), eventID, guestID); err != nil {
@@ -166,7 +167,9 @@ func (h *guestHandler) list(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { // Listing guests is viewer+. Editing is gated separately at the
// PATCH/POST/DELETE call sites.
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return return
} }
+38 -4
View File
@@ -36,6 +36,7 @@ type Server struct {
billing *billingHandler billing *billingHandler
stripeWH *stripeWebhookHandler stripeWH *stripeWebhookHandler
privacy *privacyHandler privacy *privacyHandler
collabs *collaboratorHandler
} }
type ServerDeps struct { type ServerDeps struct {
@@ -84,6 +85,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
rsvpRepo := storage.NewRSVPRepo(deps.DB) rsvpRepo := storage.NewRSVPRepo(deps.DB)
accessRepo := storage.NewAccessLogRepo(deps.DB) accessRepo := storage.NewAccessLogRepo(deps.DB)
userRepo := storage.NewUserRepo(deps.DB) userRepo := storage.NewUserRepo(deps.DB)
collabRepo := storage.NewCollaboratorRepo(deps.DB)
inviteRepo := storage.NewInviteRepo(deps.DB)
verifRepo := storage.NewEmailVerificationRepo(deps.DB) verifRepo := storage.NewEmailVerificationRepo(deps.DB)
resetRepo := storage.NewPasswordResetRepo(deps.DB) resetRepo := storage.NewPasswordResetRepo(deps.DB)
refreshRepo := storage.NewRefreshTokenRepo(deps.DB) refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
@@ -152,8 +155,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
hub: hub, hub: hub,
authH: authH, authH: authH,
me: &meHandler{users: userRepo}, me: &meHandler{users: userRepo},
events: &eventHandler{repo: eventRepo, enforcer: enforcer}, events: &eventHandler{repo: eventRepo, collabs: collabRepo, enforcer: enforcer},
guests: &guestHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer}, guests: &guestHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
tokens: &tokenHandler{ tokens: &tokenHandler{
logger: deps.Logger, logger: deps.Logger,
guests: guestRepo, guests: guestRepo,
@@ -162,6 +165,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
users: userRepo, users: userRepo,
accessLogs: accessRepo, accessLogs: accessRepo,
rsvps: rsvpRepo, rsvps: rsvpRepo,
collabs: collabRepo,
gen: auth.NewGenerator(), gen: auth.NewGenerator(),
ttl: deps.TokenTTL, ttl: deps.TokenTTL,
pub: deps.AccessPublisher, pub: deps.AccessPublisher,
@@ -180,11 +184,12 @@ func NewServer(deps ServerDeps) (*Server, error) {
}, },
activity: &activityHandler{ activity: &activityHandler{
events: eventRepo, events: eventRepo,
collabs: collabRepo,
rsvps: rsvpRepo, rsvps: rsvpRepo,
accessLogs: accessRepo, accessLogs: accessRepo,
}, },
ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets}, ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets},
wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo}, wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo, collabs: collabRepo},
health: &healthHandler{pool: deps.DB.Pool}, health: &healthHandler{pool: deps.DB.Pool},
signer: signer, signer: signer,
limiter: limiter, limiter: limiter,
@@ -198,7 +203,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
notifs: deps.NotificationRepo, notifs: deps.NotificationRepo,
suppress: deps.SuppressionRepo, suppress: deps.SuppressionRepo,
}, },
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer}, csv: &csvImportHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
billing: &billingHandler{ billing: &billingHandler{
logger: deps.Logger, logger: deps.Logger,
stripe: deps.StripeClient, stripe: deps.StripeClient,
@@ -211,6 +216,15 @@ func NewServer(deps ServerDeps) (*Server, error) {
stripe: deps.StripeClient, stripe: deps.StripeClient,
subs: subRepo, subs: subRepo,
}, },
collabs: &collaboratorHandler{
logger: deps.Logger,
events: eventRepo,
users: userRepo,
collabs: collabRepo,
invites: inviteRepo,
emails: emails,
publicBaseURL: deps.PublicBaseURL,
},
privacy: &privacyHandler{ privacy: &privacyHandler{
logger: deps.Logger, logger: deps.Logger,
users: userRepo, users: userRepo,
@@ -293,6 +307,26 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list))) mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list)))
// Block C — collaborators (multi-host). All under /events/{id}/collaborators.
// requireRole inside each handler enforces the right minimum role.
mux.Handle("GET /events/{id}/collaborators",
authed(http.HandlerFunc(s.collabs.list)))
mux.Handle("POST /events/{id}/collaborators",
authed(rl("collab_invite", 50, 24*time.Hour, userIDKey, http.HandlerFunc(s.collabs.invite))))
mux.Handle("PATCH /events/{id}/collaborators/{user_id}",
authed(http.HandlerFunc(s.collabs.updateRole)))
mux.Handle("DELETE /events/{id}/collaborators/{user_id}",
authed(http.HandlerFunc(s.collabs.remove)))
mux.Handle("DELETE /events/{id}/collaborators/pending",
authed(http.HandlerFunc(s.collabs.cancelInvite)))
// Invite acceptance — preview is unauthed (the invitee may not be
// logged in yet); accept requires auth (the caller's account must
// exist + match the invited email).
mux.HandleFunc("GET /invites/{token}", s.collabs.previewInvite)
mux.Handle("POST /invites/{token}/accept",
authed(http.HandlerFunc(s.collabs.acceptInvite)))
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens", mux.Handle("POST /events/{id}/guests/{guest_id}/tokens",
authed(rl("tokens_issue", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.issue)))) authed(rl("tokens_issue", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.issue))))
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens/rotate", mux.Handle("POST /events/{id}/guests/{guest_id}/tokens/rotate",
+4 -3
View File
@@ -34,6 +34,7 @@ type tokenHandler struct {
users *storage.UserRepo users *storage.UserRepo
accessLogs *storage.AccessLogRepo accessLogs *storage.AccessLogRepo
rsvps *storage.RSVPRepo rsvps *storage.RSVPRepo
collabs *storage.CollaboratorRepo
gen *auth.Generator gen *auth.Generator
ttl time.Duration ttl time.Duration
pub accessPublisher pub accessPublisher
@@ -63,7 +64,7 @@ func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
event, ok := requireEventOwner(w, r, h.events, eventID, hostID) event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
if !ok { if !ok {
return return
} }
@@ -214,7 +215,7 @@ func (h *tokenHandler) bulkIssue(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
event, ok := requireEventOwner(w, r, h.events, eventID, hostID) event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
if !ok { if !ok {
return return
} }
@@ -344,7 +345,7 @@ func (h *tokenHandler) rotate(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
event, ok := requireEventOwner(w, r, h.events, eventID, hostID) event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
if !ok { if !ok {
return return
} }
+5 -1
View File
@@ -5,12 +5,14 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage" "github.com/alchemistkay/guestguard/internal/storage"
) )
type wsTicketHandler struct { type wsTicketHandler struct {
tickets *wsTicketStore tickets *wsTicketStore
events *storage.EventRepo events *storage.EventRepo
collabs *storage.CollaboratorRepo
} }
type wsTicketResponse struct { type wsTicketResponse struct {
@@ -38,7 +40,9 @@ func (h *wsTicketHandler) issue(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok { // Block C: viewers can subscribe to the live monitor too — the WS stream
// only carries read-only events (RSVPs, fraud scores, check-ins).
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return return
} }
+12 -3
View File
@@ -5,12 +5,14 @@ import (
"log/slog" "log/slog"
) )
// EmailSender delivers transactional auth emails (verification, reset). // EmailSender delivers transactional auth emails (verification, reset,
// Block A ships LogSender so dev environments work without Twilio/SES. // collaborator-invite). Block A ships LogSender so dev environments work
// Block D replaces this with a real SES-backed sender. // without Twilio/SES. Tier 1 Block D replaced this with a real SES-backed
// sender; Tier 2 Block C added the collaborator-invite method.
type EmailSender interface { type EmailSender interface {
SendVerification(ctx context.Context, to, name, link string) error SendVerification(ctx context.Context, to, name, link string) error
SendPasswordReset(ctx context.Context, to, name, link string) error SendPasswordReset(ctx context.Context, to, name, link string) error
SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error
} }
type LogEmailSender struct { type LogEmailSender struct {
@@ -30,3 +32,10 @@ func (l LogEmailSender) SendPasswordReset(_ context.Context, to, name, link stri
) )
return nil return nil
} }
func (l LogEmailSender) SendCollaboratorInvite(_ context.Context, to, inviterName, eventName, role, link string) error {
l.Logger.Info("auth email (stub): collaborator invite",
"to", to, "inviter", inviterName, "event", eventName, "role", role, "link", link,
)
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
// Role is the trio of collaborator permissions on an event. Comparisons are
// done with the numeric `rank()` rather than string equality so handlers can
// say `requireRole(RoleEditor)` and accept both editor and owner.
type Role string
const (
RoleOwner Role = "owner"
RoleEditor Role = "editor"
RoleViewer Role = "viewer"
)
// Valid reports whether r is one of the three known roles.
func (r Role) Valid() bool {
switch r {
case RoleOwner, RoleEditor, RoleViewer:
return true
}
return false
}
// rank returns a comparable integer: higher = more privilege.
// Owner=30 / Editor=20 / Viewer=10 leaves room for future intermediate
// roles (e.g. "guest-manager" at 15) without renumbering callers.
func (r Role) rank() int {
switch r {
case RoleOwner:
return 30
case RoleEditor:
return 20
case RoleViewer:
return 10
}
return 0
}
// AtLeast reports whether r is at least as privileged as min.
// requireRole helpers compare with `r.AtLeast(RoleEditor)`.
func (r Role) AtLeast(min Role) bool {
return r.rank() >= min.rank()
}
// Collaborator is one user's membership on an event. AcceptedAt is nil while
// the invite is still pending — those rows are surfaced separately, not via
// this struct (see CollaboratorInvite).
type Collaborator struct {
EventID uuid.UUID `json:"event_id"`
UserID uuid.UUID `json:"user_id"`
Role Role `json:"role"`
InvitedBy *uuid.UUID `json:"invited_by,omitempty"`
InvitedAt time.Time `json:"invited_at"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
// Display fields joined from users — populated by List, omitted by point
// lookups that don't need them.
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
}
// CollaboratorInvite is a pending invitation that hasn't been accepted yet.
// The raw token lives only in the email link; what we store is its SHA-256
// hash, so a database leak doesn't hand attackers usable invites.
type CollaboratorInvite struct {
EventID uuid.UUID `json:"event_id"`
Email string `json:"email"`
Role Role `json:"role"`
InvitedBy uuid.UUID `json:"invited_by"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// DefaultInviteTTL is how long a fresh collaborator invitation stays valid.
// Seven days mirrors the password-reset flow's expiry. Resend mints a new
// token rather than extending an old one.
const DefaultInviteTTL = 7 * 24 * time.Hour
var (
ErrCollaboratorNotFound = errors.New("collaborator not found")
ErrCollaboratorExists = errors.New("user is already a collaborator")
ErrLastOwner = errors.New("cannot remove the last owner")
ErrInviteNotFound = errors.New("invitation not found")
ErrInviteExpired = errors.New("invitation expired")
ErrInviteAlreadyConsumed = errors.New("invitation already used")
ErrInviteEmailMismatch = errors.New("invitation was sent to a different email")
)
+43
View File
@@ -0,0 +1,43 @@
package domain
import "testing"
func TestRoleAtLeast(t *testing.T) {
tests := []struct {
have Role
min Role
want bool
}{
{RoleOwner, RoleOwner, true},
{RoleOwner, RoleEditor, true},
{RoleOwner, RoleViewer, true},
{RoleEditor, RoleOwner, false},
{RoleEditor, RoleEditor, true},
{RoleEditor, RoleViewer, true},
{RoleViewer, RoleOwner, false},
{RoleViewer, RoleEditor, false},
{RoleViewer, RoleViewer, true},
// Empty / unknown role should not satisfy any minimum.
{Role(""), RoleViewer, false},
{Role("admin"), RoleViewer, false},
}
for _, tt := range tests {
if got := tt.have.AtLeast(tt.min); got != tt.want {
t.Errorf("Role(%q).AtLeast(%q) = %v, want %v", tt.have, tt.min, got, tt.want)
}
}
}
func TestRoleValid(t *testing.T) {
for _, r := range []Role{RoleOwner, RoleEditor, RoleViewer} {
if !r.Valid() {
t.Errorf("expected %q to be valid", r)
}
}
for _, r := range []Role{Role(""), Role("admin"), Role("OWNER")} {
if r.Valid() {
t.Errorf("expected %q to be invalid", r)
}
}
}
+12
View File
@@ -90,6 +90,18 @@ func (s *SESEmailSender) SendPasswordReset(ctx context.Context, to, name, link s
}) })
} }
// SendCollaboratorInvite renders the team-invite template and posts it to SES.
func (s *SESEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error {
return s.sendTemplated(ctx, to,
inviterName+" invited you to "+eventName,
TmplCollaboratorInvite, map[string]any{
"InviterName": inviterName,
"EventName": eventName,
"Role": role,
"Link": link,
})
}
// SendGuest is used by the notifier worker for invitation / confirmation / // SendGuest is used by the notifier worker for invitation / confirmation /
// reminder emails — anything addressed at a guest. // reminder emails — anything addressed at a guest.
func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) { func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) {
+7
View File
@@ -33,6 +33,7 @@ type EmailSenderConfig struct {
type CombinedEmailSender interface { type CombinedEmailSender interface {
SendVerification(ctx context.Context, to, name, link string) error SendVerification(ctx context.Context, to, name, link string) error
SendPasswordReset(ctx context.Context, to, name, link string) error SendPasswordReset(ctx context.Context, to, name, link string) error
SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error
SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error)
} }
@@ -81,6 +82,12 @@ func (l *logCombinedSender) SendPasswordReset(_ context.Context, to, name, link
return nil return nil
} }
func (l *logCombinedSender) SendCollaboratorInvite(_ context.Context, to, inviterName, eventName, role, link string) error {
l.logger.Info("auth email (stub): collaborator invite",
"to", to, "inviter", inviterName, "event", eventName, "role", role, "link", link)
return nil
}
func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil { if data == nil {
data = map[string]any{} data = map[string]any{}
+12
View File
@@ -71,6 +71,18 @@ func (s *ResendEmailSender) SendPasswordReset(ctx context.Context, to, name, lin
return err return err
} }
func (s *ResendEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error {
_, err := s.sendTemplated(ctx, to,
inviterName+" invited you to "+eventName,
TmplCollaboratorInvite, map[string]any{
"InviterName": inviterName,
"EventName": eventName,
"Role": role,
"Link": link,
})
return err
}
// --- GuestEmailDispatcher --- // --- GuestEmailDispatcher ---
func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
+11
View File
@@ -72,6 +72,17 @@ func (s *SMTPEmailSender) SendPasswordReset(ctx context.Context, to, name, link
TmplPasswordReset, map[string]any{"Name": name, "Link": link, "ExpiryHumane": "1 hour"}) TmplPasswordReset, map[string]any{"Name": name, "Link": link, "ExpiryHumane": "1 hour"})
} }
func (s *SMTPEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error {
return s.sendTemplated(ctx, to,
inviterName+" invited you to "+eventName,
TmplCollaboratorInvite, map[string]any{
"InviterName": inviterName,
"EventName": eventName,
"Role": role,
"Link": link,
})
}
// --- GuestEmailDispatcher --- // --- GuestEmailDispatcher ---
func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
+6 -5
View File
@@ -19,11 +19,12 @@ var templatesFS embed.FS
type TemplateName string type TemplateName string
const ( const (
TmplVerification TemplateName = "verification" TmplVerification TemplateName = "verification"
TmplPasswordReset TemplateName = "reset" TmplPasswordReset TemplateName = "reset"
TmplInvitation TemplateName = "invitation" TmplInvitation TemplateName = "invitation"
TmplConfirmation TemplateName = "confirmation" TmplConfirmation TemplateName = "confirmation"
TmplReminder TemplateName = "reminder" TmplReminder TemplateName = "reminder"
TmplCollaboratorInvite TemplateName = "collaborator_invite"
) )
// Templates renders branded transactional emails for both HTML and // Templates renders branded transactional emails for both HTML and
@@ -0,0 +1,16 @@
{{define "body"}}
<p style="font-size:13px;letter-spacing:0.2em;text-transform:uppercase;color:#16a34a;margin:0 0 16px;">✦ Team invitation</p>
<h1 style="font-size:24px;margin:0 0 4px;color:#0a0a0a;">{{.EventName}}</h1>
<p style="margin:16px 0 20px;">
{{.InviterName}} invited you to collaborate on this event as
<strong>{{.Role}}</strong>.
</p>
<p style="margin:0 0 24px;text-align:center;">
<a href="{{.Link}}" style="background:#22c55e;color:#0a0a0a;padding:12px 22px;border-radius:8px;font-weight:600;text-decoration:none;display:inline-block;">Accept invitation</a>
</p>
<p style="margin:0 0 8px;color:#64748b;font-size:13px;">
This invitation expires in 7 days. If you don't have a GuestGuard account
yet, you'll be able to create one before accepting.
</p>
<p style="margin:8px 0 0;word-break:break-all;font-size:12px;color:#0f172a;">{{.Link}}</p>
{{end}}
@@ -0,0 +1,7 @@
{{.InviterName}} invited you to collaborate on "{{.EventName}}" as {{.Role}}.
Accept the invitation:
{{.Link}}
This link expires in 7 days. If you don't have a GuestGuard account yet,
you'll be able to create one before accepting.
+381
View File
@@ -0,0 +1,381 @@
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(&currentRole)
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(&currentRole)
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
}
// 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)
}
// 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
}
+93 -3
View File
@@ -44,12 +44,22 @@ func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Ev
return nil, fmt.Errorf("marshal settings: %w", err) return nil, fmt.Errorf("marshal settings: %w", err)
} }
// Block C: every new event needs a row in event_collaborators pointing
// the host at the owner role. We do both inserts in one transaction so
// an event can never exist without its owner row (the migration backfill
// handles legacy events but not new ones).
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
const q = ` const q = `
INSERT INTO events (host_id, name, slug, event_date, venue, max_capacity, settings, status) INSERT INTO events (host_id, name, slug, event_date, venue, max_capacity, settings, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
` `
row := r.pool.QueryRow(ctx, q, row := tx.QueryRow(ctx, q,
p.HostID, p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status, p.HostID, p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status,
) )
ev, err := scanEvent(row) ev, err := scanEvent(row)
@@ -60,6 +70,17 @@ func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Ev
} }
return nil, err return nil, err
} }
if _, err := tx.Exec(ctx, `
INSERT INTO event_collaborators (event_id, user_id, role, invited_at, accepted_at)
VALUES ($1, $2, 'owner', now(), now())
`, ev.ID, p.HostID); err != nil {
return nil, fmt.Errorf("seed owner collaborator: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return ev, nil return ev, nil
} }
@@ -96,6 +117,42 @@ func (r *EventRepo) GetForHost(ctx context.Context, id, hostID uuid.UUID) (*doma
return ev, nil return ev, nil
} }
// ListForUser returns every event the user has any accepted role on. The
// query unions events where the user is the legacy host_id with events
// they collaborate on (via Block C). Duplicates are deduped on event id.
// Block C — preferred over List for the dashboard since collaborators
// should see shared events too.
func (r *EventRepo) ListForUser(ctx context.Context, userID uuid.UUID, collabEventIDs []uuid.UUID, limit, offset int) ([]*domain.Event, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT
id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
FROM events
WHERE host_id = $1
OR id = ANY($2::uuid[])
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
`, userID, collabEventIDs, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*domain.Event
for rows.Next() {
ev, err := scanEvent(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
func (r *EventRepo) List(ctx context.Context, hostID uuid.UUID, limit, offset int) ([]*domain.Event, error) { func (r *EventRepo) List(ctx context.Context, hostID uuid.UUID, limit, offset int) ([]*domain.Event, error) {
if limit <= 0 || limit > 200 { if limit <= 0 || limit > 200 {
limit = 50 limit = 50
@@ -150,8 +207,21 @@ type UpdateEventParams struct {
Status *domain.EventStatus Status *domain.EventStatus
} }
// UpdateByID applies the patch without an authz scope. Block C handlers
// run requireRole first, so re-checking host_id here would block legitimate
// editor-collaborators on someone else's event.
func (r *EventRepo) UpdateByID(ctx context.Context, id uuid.UUID, p UpdateEventParams) (*domain.Event, error) {
return r.update(ctx, id, uuid.Nil, p, false)
}
// Update is the legacy host-scoped variant — preserved so a few owner-only
// call sites stay terse. New code should prefer UpdateByID + requireRole.
func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams) (*domain.Event, error) { func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams) (*domain.Event, error) {
const q = ` return r.update(ctx, id, hostID, p, true)
}
func (r *EventRepo) update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams, scopeToHost bool) (*domain.Event, error) {
q := `
UPDATE events SET UPDATE events SET
name = COALESCE($3, name), name = COALESCE($3, name),
slug = COALESCE($4, slug), slug = COALESCE($4, slug),
@@ -161,7 +231,14 @@ func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEv
settings = COALESCE($8, settings), settings = COALESCE($8, settings),
status = COALESCE($9, status), status = COALESCE($9, status),
updated_at = now() updated_at = now()
WHERE id = $1 AND host_id = $2 WHERE id = $1`
if scopeToHost {
q += ` AND host_id = $2`
} else {
// Bind $2 anyway so the parameter count matches the call below.
q += ` AND ($2::uuid IS NULL OR $2::uuid = host_id OR TRUE)`
}
q += `
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
` `
@@ -202,6 +279,19 @@ func (r *EventRepo) Delete(ctx context.Context, id, hostID uuid.UUID) error {
return nil return nil
} }
// DeleteByID is the post-Block-C path: requireRole(Owner) is enforced by
// the handler, so this query doesn't double-check host_id.
func (r *EventRepo) DeleteByID(ctx context.Context, id uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM events WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrEventNotFound
}
return nil
}
type rowScanner interface { type rowScanner interface {
Scan(dest ...any) error Scan(dest ...any) error
} }
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_collab_invites_event;
DROP TABLE IF EXISTS collaborator_invites;
DROP INDEX IF EXISTS idx_collaborators_user;
DROP TABLE IF EXISTS event_collaborators;
DROP TYPE IF EXISTS collaborator_role;
@@ -0,0 +1,65 @@
-- Tier 2 Block C — multi-host / collaborators.
--
-- An event can now have multiple users with one of three roles:
-- owner — full access incl. delete + manage collaborators
-- editor — guests/tokens/branding/messages/analytics
-- viewer — read-only
--
-- The existing events.host_id stays as a denormalised "primary owner"
-- pointer (cheap join key, useful for the existing GET /events query). The
-- source of truth for authz is event_collaborators — every handler resolves
-- the caller's role through it.
--
-- Schema #0005 in TIER2_PLAN.md; landing at 0008 because earlier slots are
-- taken by Tier 1 work.
DO $$ BEGIN
CREATE TYPE collaborator_role AS ENUM ('owner', 'editor', 'viewer');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS event_collaborators (
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role collaborator_role NOT NULL,
invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
invited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
accepted_at TIMESTAMPTZ,
PRIMARY KEY (event_id, user_id)
);
-- Fast lookup of "what events does this user have any role on" — used by
-- the dashboard list and the role-resolution middleware.
CREATE INDEX IF NOT EXISTS idx_collaborators_user
ON event_collaborators(user_id);
-- Pending invitations. The invitee may not have a GuestGuard account yet;
-- the accept flow creates one (or links to an existing one by email) and
-- promotes the row into event_collaborators.
--
-- token_hash is sha256(raw); the raw token only ever lives in the email
-- link, never in the DB. consumed_at is set on successful accept so re-
-- using the link returns 410 Gone instead of silently re-adding the user.
CREATE TABLE IF NOT EXISTS collaborator_invites (
token_hash TEXT PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role collaborator_role NOT NULL,
invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Find pending invites for an event quickly (the "Team" tab lists them).
-- Partial index keeps the working set small even on a busy event.
CREATE INDEX IF NOT EXISTS idx_collab_invites_event
ON collaborator_invites(event_id)
WHERE consumed_at IS NULL;
-- Backfill: every existing event's host becomes its owner. Idempotent so
-- re-running this migration after a partial failure (or against a freshly
-- restored backup that already has rows) does the right thing.
INSERT INTO event_collaborators (event_id, user_id, role, accepted_at, invited_at)
SELECT id, host_id, 'owner', created_at, created_at
FROM events
ON CONFLICT (event_id, user_id) DO NOTHING;
+6
View File
@@ -29,6 +29,7 @@ const authTestPassword = "correct-horse-battery-staple"
type recordingEmailSender struct { type recordingEmailSender struct {
verifyLink string verifyLink string
resetLink string resetLink string
inviteLink string
} }
func (s *recordingEmailSender) SendVerification(_ context.Context, _, _, link string) error { func (s *recordingEmailSender) SendVerification(_ context.Context, _, _, link string) error {
@@ -41,6 +42,11 @@ func (s *recordingEmailSender) SendPasswordReset(_ context.Context, _, _, link s
return nil return nil
} }
func (s *recordingEmailSender) SendCollaboratorInvite(_ context.Context, _, _, _, _, link string) error {
s.inviteLink = link
return nil
}
func TestAuthFlow(t *testing.T) { func TestAuthFlow(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test in -short mode") t.Skip("skipping integration test in -short mode")
+328
View File
@@ -0,0 +1,328 @@
//go:build integration
package integration_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Tier 2 Block C — multi-host / collaborators.
// TestCollaboratorBackfill confirms the migration creates an `owner` row in
// event_collaborators for every legacy event whose host_id pointed at a user.
// Existing single-host events must keep working after Block C lands.
func TestCollaboratorBackfill(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, host, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Backfill Test", "backfill-test")
var role string
must(t, db.Pool.QueryRow(ctx,
`SELECT role FROM event_collaborators
WHERE event_id = $1 AND user_id = $2`,
eventID, uuid.UUID(host),
).Scan(&role), "load backfilled role")
if role != "owner" {
t.Fatalf("backfilled role: got %q, want owner", role)
}
}
// TestCollaboratorRoleEnforcement exercises every endpoint at every role
// boundary. This is the big regression net for the authz refactor — if any
// handler dropped its requireRole call this test will catch it.
func TestCollaboratorRoleEnforcement(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, ownerID, ownerToken := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, ownerToken, "Roles Test", "roles-test")
// Spin up two more verified users we can promote to editor / viewer.
editor, editorToken := makeAuthedUser(t, ctx, db.Pool)
viewer, viewerToken := makeAuthedUser(t, ctx, db.Pool)
directlyInsertCollaborator(t, ctx, db.Pool, eventID, editor, "editor", uuid.UUID(ownerID))
directlyInsertCollaborator(t, ctx, db.Pool, eventID, viewer, "viewer", uuid.UUID(ownerID))
guestID := createGuest(t, srv.URL, ownerToken, eventID, "Test Guest")
t.Run("viewer can read but not write", func(t *testing.T) {
// Read endpoints — 200.
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s", srv.URL, eventID),
viewerToken, nil, http.StatusOK)
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID),
viewerToken, nil, http.StatusOK)
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/activity", srv.URL, eventID),
viewerToken, nil, http.StatusOK)
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID),
viewerToken, nil, http.StatusOK)
// Write endpoints — 403.
assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID),
viewerToken, map[string]any{"name": "Should fail"}, http.StatusForbidden)
assertStatus(t, http.MethodPatch, fmt.Sprintf("%s/events/%s", srv.URL, eventID),
viewerToken, map[string]any{"venue": "Other"}, http.StatusForbidden)
assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID),
viewerToken, nil, http.StatusForbidden)
// Owner-only — 403 for both editor and viewer.
assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s", srv.URL, eventID),
viewerToken, nil, http.StatusForbidden)
assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID),
viewerToken, map[string]any{"email": "x@x.com", "role": "viewer"}, http.StatusForbidden)
})
t.Run("editor can write guests + patch event but not delete or manage team", func(t *testing.T) {
// Editor adds a guest.
var g struct{ ID uuid.UUID `json:"id"` }
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID),
editorToken, map[string]any{"name": "Editor's guest"}, http.StatusCreated, &g)
// Editor patches the event.
assertStatus(t, http.MethodPatch, fmt.Sprintf("%s/events/%s", srv.URL, eventID),
editorToken, map[string]any{"venue": "Editor's Hall"}, http.StatusOK)
// Editor cannot delete the event.
assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s", srv.URL, eventID),
editorToken, nil, http.StatusForbidden)
// Editor cannot invite collaborators.
assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID),
editorToken, map[string]any{"email": "x@x.com", "role": "viewer"}, http.StatusForbidden)
})
t.Run("non-member gets 404 even for read endpoints", func(t *testing.T) {
_, outsiderToken := makeAuthedUser(t, ctx, db.Pool)
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s", srv.URL, eventID),
outsiderToken, nil, http.StatusNotFound)
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID),
outsiderToken, nil, http.StatusNotFound)
})
t.Run("GET /events lists shared events for collaborators", func(t *testing.T) {
var list struct {
Events []struct {
ID uuid.UUID `json:"id"`
} `json:"events"`
}
getJSONAuthed(t, srv.URL+"/events", editorToken, http.StatusOK, &list)
found := false
for _, e := range list.Events {
if e.ID == eventID {
found = true
break
}
}
if !found {
t.Fatalf("editor's /events did not include the shared event: %+v", list)
}
})
}
// TestCollaboratorRemoveLastOwner covers the "you can't orphan the event"
// guarantee. Demote-then-remove must work for non-last owners; refusing on
// the last is mandatory.
func TestCollaboratorRemoveLastOwner(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, ownerID, ownerToken := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, ownerToken, "Last Owner", "last-owner")
// Cannot remove yourself when you're the only owner.
assertStatus(t, http.MethodDelete,
fmt.Sprintf("%s/events/%s/collaborators/%s", srv.URL, eventID, uuid.UUID(ownerID)),
ownerToken, nil, http.StatusBadRequest)
// Promote a second user to owner; now removing the first is allowed.
second, _ := makeAuthedUser(t, ctx, db.Pool)
directlyInsertCollaborator(t, ctx, db.Pool, eventID, second, "owner", uuid.UUID(ownerID))
assertStatus(t, http.MethodDelete,
fmt.Sprintf("%s/events/%s/collaborators/%s", srv.URL, eventID, uuid.UUID(ownerID)),
ownerToken, nil, http.StatusNoContent)
}
// TestCollaboratorInviteFlow walks the happy path: owner creates an invite,
// the invitee accepts, they show up as a collaborator. Then re-using the
// same invite token returns 410 Gone.
func TestCollaboratorInviteFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, ownerToken := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, ownerToken, "Invite Flow", "invite-flow")
// Make the invitee's account ahead of time so we can sign them in.
inviteeEmail := fmt.Sprintf("invitee-%d@guestguard.test", time.Now().UnixNano())
inviteeID := insertVerifiedUser(t, ctx, db.Pool, inviteeEmail, "Invitee")
inviteeToken := issueHostToken(t, inviteeID)
// Owner invites the user as editor.
assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID),
ownerToken, map[string]any{"email": inviteeEmail, "role": "editor"}, http.StatusCreated)
// Pull the raw token out of the DB the way the email link would have.
// We don't surface it via the API (security), so reach into the table.
var tokenHash string
must(t, db.Pool.QueryRow(ctx, `
SELECT token_hash FROM collaborator_invites
WHERE event_id = $1 AND lower(email) = lower($2) AND consumed_at IS NULL
ORDER BY created_at DESC LIMIT 1
`, eventID, inviteeEmail).Scan(&tokenHash), "load invite hash")
// To get the raw token from the hash we can't reverse SHA256; so for
// tests we generate our own invite via the DB and use that raw value
// instead. Cleaner: replace the row with a known-token invite.
rawToken := fmt.Sprintf("test-raw-token-%d", time.Now().UnixNano())
sum := sha256.Sum256([]byte(rawToken))
knownHash := hex.EncodeToString(sum[:])
_, err := db.Pool.Exec(ctx, `
UPDATE collaborator_invites
SET token_hash = $1
WHERE token_hash = $2
`, knownHash, tokenHash)
must(t, err, "rewrite invite token_hash")
// Preview the invite (unauthed) — confirms the summary endpoint works.
previewResp, err := http.Get(srv.URL + "/invites/" + rawToken)
must(t, err, "preview invite")
previewResp.Body.Close()
if previewResp.StatusCode != http.StatusOK {
t.Fatalf("preview status: %d", previewResp.StatusCode)
}
// Accept (authed as invitee). Single-use; first call → 200.
assertStatus(t, http.MethodPost, srv.URL+"/invites/"+rawToken+"/accept",
inviteeToken, nil, http.StatusOK)
// Now the invitee has an editor role — they can list guests.
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID),
inviteeToken, nil, http.StatusOK)
// Replay the same token — already consumed → 410.
assertStatus(t, http.MethodPost, srv.URL+"/invites/"+rawToken+"/accept",
inviteeToken, nil, http.StatusGone)
}
// TestCollaboratorInviteEmailMismatch ensures a leaked invite link can't be
// accepted by a different account.
func TestCollaboratorInviteEmailMismatch(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, ownerToken := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, ownerToken, "Email Mismatch", "email-mismatch")
intendedEmail := fmt.Sprintf("intended-%d@guestguard.test", time.Now().UnixNano())
assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID),
ownerToken, map[string]any{"email": intendedEmail, "role": "editor"}, http.StatusCreated)
// Replace with a known token, as in the happy-path test.
rawToken := fmt.Sprintf("test-mismatch-token-%d", time.Now().UnixNano())
sum := sha256.Sum256([]byte(rawToken))
hash := hex.EncodeToString(sum[:])
_, err := db.Pool.Exec(ctx, `
UPDATE collaborator_invites
SET token_hash = $1
WHERE event_id = $2 AND lower(email) = lower($3) AND consumed_at IS NULL
`, hash, eventID, intendedEmail)
must(t, err, "rewrite invite")
// A different user tries to accept the link.
otherID, otherToken := makeAuthedUser(t, ctx, db.Pool)
_ = otherID
assertStatus(t, http.MethodPost, srv.URL+"/invites/"+rawToken+"/accept",
otherToken, nil, http.StatusForbidden)
}
// TestCollaboratorInviteExpired tests that an old invite returns 410.
func TestCollaboratorInviteExpired(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, ownerID, ownerToken := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, ownerToken, "Expired", "expired")
// Insert an already-expired invite directly so we don't have to wait
// seven days during the test.
rawToken := fmt.Sprintf("test-expired-%d", time.Now().UnixNano())
sum := sha256.Sum256([]byte(rawToken))
hash := hex.EncodeToString(sum[:])
_, err := db.Pool.Exec(ctx, `
INSERT INTO collaborator_invites
(token_hash, event_id, email, role, invited_by, expires_at)
VALUES ($1, $2, 'doesnt@matter.test', 'editor', $3, now() - interval '1 hour')
`, hash, eventID, uuid.UUID(ownerID))
must(t, err, "insert expired invite")
resp, err := http.Get(srv.URL + "/invites/" + rawToken)
must(t, err, "get expired preview")
resp.Body.Close()
if resp.StatusCode != http.StatusGone {
t.Fatalf("expected 410 Gone, got %d", resp.StatusCode)
}
}
// --- helpers used across collaborator tests ---
// makeAuthedUser creates a verified user with a Business subscription and
// returns the id + a bearer token signed with the test JWT secret.
func makeAuthedUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) (uuid.UUID, string) {
t.Helper()
email := fmt.Sprintf("collab-%d@guestguard.test", time.Now().UnixNano())
id := insertVerifiedUser(t, ctx, pool, email, "Collab User")
grantBusinessTier(t, ctx, pool, id)
return id, issueHostToken(t, id)
}
// insertVerifiedUser creates a user row directly, bypassing signup/verify
// so tests stay fast.
func insertVerifiedUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, email, name string) uuid.UUID {
t.Helper()
var id uuid.UUID
must(t, pool.QueryRow(ctx, `
INSERT INTO users (email, name, email_verified, email_verified_at)
VALUES ($1, $2, TRUE, now())
RETURNING id
`, strings.ToLower(email), name).Scan(&id), "insert verified user")
return id
}
func directlyInsertCollaborator(t *testing.T, ctx context.Context, pool *pgxpool.Pool, eventID, userID uuid.UUID, role string, invitedBy uuid.UUID) {
t.Helper()
_, err := 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())
ON CONFLICT (event_id, user_id) DO UPDATE SET role = EXCLUDED.role
`, eventID, userID, role, invitedBy)
must(t, err, "insert collaborator")
}