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:
@@ -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>
|
||||
@@ -35,6 +35,9 @@ interface EventDetail {
|
||||
status: string
|
||||
created_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 {
|
||||
@@ -1117,6 +1120,12 @@ function checkLabel(band?: string): string {
|
||||
</aside>
|
||||
</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 ===== -->
|
||||
<!-- All modals share the same pattern: backdrop click + Esc close,
|
||||
role=dialog + aria-modal, primary action on the right.
|
||||
|
||||
@@ -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>
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
// who weren't watching when activity happened.
|
||||
type activityHandler struct {
|
||||
events *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
rsvps *storage.RSVPRepo
|
||||
accessLogs *storage.AccessLogRepo
|
||||
}
|
||||
@@ -48,7 +50,7 @@ func (h *activityHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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-
|
||||
// tenant probe cannot tell the difference between "event doesn't exist" and
|
||||
// "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(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -42,3 +47,47 @@ func requireEventOwner(
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/csvimport"
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,7 @@ const (
|
||||
type csvImportHandler struct {
|
||||
guests *storage.GuestRepo
|
||||
events *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
enforcer *tierEnforcer
|
||||
}
|
||||
|
||||
@@ -46,7 +48,7 @@ func (h *csvImportHandler) preview(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ func (h *csvImportHandler) commit(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -141,7 +143,7 @@ func (h *csvImportHandler) template(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
|
||||
+31
-5
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
type eventHandler struct {
|
||||
repo *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
enforcer *tierEnforcer
|
||||
}
|
||||
|
||||
@@ -90,6 +91,13 @@ func (h *eventHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
hostID, ok := hostFromContext(w, r)
|
||||
if !ok {
|
||||
@@ -99,11 +107,11 @@ func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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 {
|
||||
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) {
|
||||
@@ -116,7 +124,15 @@ func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
limit := atoiOr(q.Get("limit"), 50)
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list events")
|
||||
return
|
||||
@@ -150,6 +166,11 @@ func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
ev, err := h.repo.Update(r.Context(), id, hostID, params)
|
||||
ev, err := h.repo.UpdateByID(r.Context(), id, params)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrEventNotFound):
|
||||
@@ -204,7 +225,12 @@ func (h *eventHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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) {
|
||||
writeError(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type guestHandler struct {
|
||||
guests *storage.GuestRepo
|
||||
events *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
enforcer *tierEnforcer
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ func (h *guestHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
if !h.enforcer.allowGuestCreate(w, r, hostID, eventID) {
|
||||
@@ -93,7 +94,7 @@ func (h *guestHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ func (h *guestHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+38
-4
@@ -36,6 +36,7 @@ type Server struct {
|
||||
billing *billingHandler
|
||||
stripeWH *stripeWebhookHandler
|
||||
privacy *privacyHandler
|
||||
collabs *collaboratorHandler
|
||||
}
|
||||
|
||||
type ServerDeps struct {
|
||||
@@ -84,6 +85,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
rsvpRepo := storage.NewRSVPRepo(deps.DB)
|
||||
accessRepo := storage.NewAccessLogRepo(deps.DB)
|
||||
userRepo := storage.NewUserRepo(deps.DB)
|
||||
collabRepo := storage.NewCollaboratorRepo(deps.DB)
|
||||
inviteRepo := storage.NewInviteRepo(deps.DB)
|
||||
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
|
||||
resetRepo := storage.NewPasswordResetRepo(deps.DB)
|
||||
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
|
||||
@@ -152,8 +155,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
hub: hub,
|
||||
authH: authH,
|
||||
me: &meHandler{users: userRepo},
|
||||
events: &eventHandler{repo: eventRepo, enforcer: enforcer},
|
||||
guests: &guestHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer},
|
||||
events: &eventHandler{repo: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||
guests: &guestHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||
tokens: &tokenHandler{
|
||||
logger: deps.Logger,
|
||||
guests: guestRepo,
|
||||
@@ -162,6 +165,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
users: userRepo,
|
||||
accessLogs: accessRepo,
|
||||
rsvps: rsvpRepo,
|
||||
collabs: collabRepo,
|
||||
gen: auth.NewGenerator(),
|
||||
ttl: deps.TokenTTL,
|
||||
pub: deps.AccessPublisher,
|
||||
@@ -180,11 +184,12 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
},
|
||||
activity: &activityHandler{
|
||||
events: eventRepo,
|
||||
collabs: collabRepo,
|
||||
rsvps: rsvpRepo,
|
||||
accessLogs: accessRepo,
|
||||
},
|
||||
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},
|
||||
signer: signer,
|
||||
limiter: limiter,
|
||||
@@ -198,7 +203,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
notifs: deps.NotificationRepo,
|
||||
suppress: deps.SuppressionRepo,
|
||||
},
|
||||
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer},
|
||||
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||
billing: &billingHandler{
|
||||
logger: deps.Logger,
|
||||
stripe: deps.StripeClient,
|
||||
@@ -211,6 +216,15 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
stripe: deps.StripeClient,
|
||||
subs: subRepo,
|
||||
},
|
||||
collabs: &collaboratorHandler{
|
||||
logger: deps.Logger,
|
||||
events: eventRepo,
|
||||
users: userRepo,
|
||||
collabs: collabRepo,
|
||||
invites: inviteRepo,
|
||||
emails: emails,
|
||||
publicBaseURL: deps.PublicBaseURL,
|
||||
},
|
||||
privacy: &privacyHandler{
|
||||
logger: deps.Logger,
|
||||
users: userRepo,
|
||||
@@ -293,6 +307,26 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
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",
|
||||
authed(rl("tokens_issue", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.issue))))
|
||||
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens/rotate",
|
||||
|
||||
@@ -34,6 +34,7 @@ type tokenHandler struct {
|
||||
users *storage.UserRepo
|
||||
accessLogs *storage.AccessLogRepo
|
||||
rsvps *storage.RSVPRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
gen *auth.Generator
|
||||
ttl time.Duration
|
||||
pub accessPublisher
|
||||
@@ -63,7 +64,7 @@ func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -214,7 +215,7 @@ func (h *tokenHandler) bulkIssue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -344,7 +345,7 @@ func (h *tokenHandler) rotate(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
type wsTicketHandler struct {
|
||||
tickets *wsTicketStore
|
||||
events *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
}
|
||||
|
||||
type wsTicketResponse struct {
|
||||
@@ -38,7 +40,9 @@ func (h *wsTicketHandler) issue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -5,12 +5,14 @@ import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// EmailSender delivers transactional auth emails (verification, reset).
|
||||
// Block A ships LogSender so dev environments work without Twilio/SES.
|
||||
// Block D replaces this with a real SES-backed sender.
|
||||
// EmailSender delivers transactional auth emails (verification, reset,
|
||||
// collaborator-invite). Block A ships LogSender so dev environments work
|
||||
// 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 {
|
||||
SendVerification(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 {
|
||||
@@ -30,3 +32,10 @@ func (l LogEmailSender) SendPasswordReset(_ context.Context, to, name, link stri
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 /
|
||||
// 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) {
|
||||
|
||||
@@ -33,6 +33,7 @@ type EmailSenderConfig struct {
|
||||
type CombinedEmailSender interface {
|
||||
SendVerification(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)
|
||||
}
|
||||
|
||||
@@ -81,6 +82,12 @@ func (l *logCombinedSender) SendPasswordReset(_ context.Context, to, name, link
|
||||
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) {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
|
||||
@@ -71,6 +71,18 @@ func (s *ResendEmailSender) SendPasswordReset(ctx context.Context, to, name, lin
|
||||
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 ---
|
||||
|
||||
func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
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 ---
|
||||
|
||||
func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
TmplInvitation TemplateName = "invitation"
|
||||
TmplConfirmation TemplateName = "confirmation"
|
||||
TmplReminder TemplateName = "reminder"
|
||||
TmplCollaboratorInvite TemplateName = "collaborator_invite"
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -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(¤tRole)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrCollaboratorNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Demoting the last owner is the only ordering trap. Promote → owner is
|
||||
// always fine.
|
||||
if currentRole == domain.RoleOwner && newRole != domain.RoleOwner {
|
||||
if err := assertNotLastOwner(ctx, tx, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE event_collaborators SET role = $3
|
||||
WHERE event_id = $1 AND user_id = $2
|
||||
`, eventID, userID, newRole); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Remove deletes a collaborator. Refuses to remove the last owner so the
|
||||
// event can't be orphaned. The host can demote-then-leave if they really
|
||||
// want out, but only after promoting someone else.
|
||||
func (r *CollaboratorRepo) Remove(ctx context.Context, eventID, userID uuid.UUID) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var currentRole domain.Role
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT role FROM event_collaborators
|
||||
WHERE event_id = $1 AND user_id = $2
|
||||
FOR UPDATE
|
||||
`, eventID, userID).Scan(¤tRole)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrCollaboratorNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if currentRole == domain.RoleOwner {
|
||||
if err := assertNotLastOwner(ctx, tx, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM event_collaborators
|
||||
WHERE event_id = $1 AND user_id = $2
|
||||
`, eventID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// assertNotLastOwner refuses to proceed when the event has exactly one
|
||||
// owner. Call inside a transaction that holds a row lock on the candidate
|
||||
// row so the count is accurate against concurrent removals.
|
||||
func assertNotLastOwner(ctx context.Context, tx pgx.Tx, eventID uuid.UUID) error {
|
||||
var owners int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM event_collaborators
|
||||
WHERE event_id = $1 AND role = 'owner'
|
||||
`, eventID).Scan(&owners); err != nil {
|
||||
return err
|
||||
}
|
||||
if owners <= 1 {
|
||||
return domain.ErrLastOwner
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -44,12 +44,22 @@ func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Ev
|
||||
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 = `
|
||||
INSERT INTO events (host_id, name, slug, event_date, venue, max_capacity, settings, status)
|
||||
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
|
||||
`
|
||||
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,
|
||||
)
|
||||
ev, err := scanEvent(row)
|
||||
@@ -60,6 +70,17 @@ func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Ev
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -96,6 +117,42 @@ func (r *EventRepo) GetForHost(ctx context.Context, id, hostID uuid.UUID) (*doma
|
||||
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) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
@@ -150,8 +207,21 @@ type UpdateEventParams struct {
|
||||
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) {
|
||||
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
|
||||
name = COALESCE($3, name),
|
||||
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),
|
||||
status = COALESCE($9, status),
|
||||
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
|
||||
`
|
||||
|
||||
@@ -202,6 +279,19 @@ func (r *EventRepo) Delete(ctx context.Context, id, hostID uuid.UUID) error {
|
||||
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 {
|
||||
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;
|
||||
@@ -29,6 +29,7 @@ const authTestPassword = "correct-horse-battery-staple"
|
||||
type recordingEmailSender struct {
|
||||
verifyLink string
|
||||
resetLink string
|
||||
inviteLink string
|
||||
}
|
||||
|
||||
func (s *recordingEmailSender) SendVerification(_ context.Context, _, _, link string) error {
|
||||
@@ -41,6 +42,11 @@ func (s *recordingEmailSender) SendPasswordReset(_ context.Context, _, _, link s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingEmailSender) SendCollaboratorInvite(_ context.Context, _, _, _, _, link string) error {
|
||||
s.inviteLink = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAuthFlow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user