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
|
status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
// Tier 2 Block C — caller's role on this event. The dashboard branches
|
||||||
|
// UI affordances off this rather than the legacy host_id check.
|
||||||
|
your_role?: 'owner' | 'editor' | 'viewer'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssuedToken {
|
interface IssuedToken {
|
||||||
@@ -1117,6 +1120,12 @@ function checkLabel(band?: string): string {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Team (Tier 2 Block C). Visible to anyone with viewer+ access; action
|
||||||
|
buttons gated to owners. -->
|
||||||
|
<div v-if="event" class="mt-6">
|
||||||
|
<TeamCard :event-id="eventId" :your-role="event.your_role" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===== Modals ===== -->
|
<!-- ===== Modals ===== -->
|
||||||
<!-- All modals share the same pattern: backdrop click + Esc close,
|
<!-- All modals share the same pattern: backdrop click + Esc close,
|
||||||
role=dialog + aria-modal, primary action on the right.
|
role=dialog + aria-modal, primary action on the right.
|
||||||
|
|||||||
@@ -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"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/alchemistkay/guestguard/internal/domain"
|
||||||
"github.com/alchemistkay/guestguard/internal/storage"
|
"github.com/alchemistkay/guestguard/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
// who weren't watching when activity happened.
|
// who weren't watching when activity happened.
|
||||||
type activityHandler struct {
|
type activityHandler struct {
|
||||||
events *storage.EventRepo
|
events *storage.EventRepo
|
||||||
|
collabs *storage.CollaboratorRepo
|
||||||
rsvps *storage.RSVPRepo
|
rsvps *storage.RSVPRepo
|
||||||
accessLogs *storage.AccessLogRepo
|
accessLogs *storage.AccessLogRepo
|
||||||
}
|
}
|
||||||
@@ -48,7 +50,7 @@ func (h *activityHandler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ func hostFromContext(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
|||||||
// On mismatch (or missing event) it returns 404 — never 403 — so a cross-
|
// On mismatch (or missing event) it returns 404 — never 403 — so a cross-
|
||||||
// tenant probe cannot tell the difference between "event doesn't exist" and
|
// tenant probe cannot tell the difference between "event doesn't exist" and
|
||||||
// "exists but belongs to someone else".
|
// "exists but belongs to someone else".
|
||||||
|
//
|
||||||
|
// Pre-Block-C this checked the events.host_id column directly. Block C
|
||||||
|
// preserves the same semantics for the owner-only handlers (events.Update,
|
||||||
|
// events.Delete, collaborator management) — but for shared-role endpoints,
|
||||||
|
// callers should use requireRole instead.
|
||||||
func requireEventOwner(
|
func requireEventOwner(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
@@ -42,3 +47,47 @@ func requireEventOwner(
|
|||||||
}
|
}
|
||||||
return ev, true
|
return ev, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireRole is the Block C authz gate. It resolves the user's role on the
|
||||||
|
// event and confirms it's at least `min`. Missing role → 404 (avoids leaking
|
||||||
|
// existence to outsiders). Role-too-low → 403 (the user IS on the event,
|
||||||
|
// just not privileged enough — that's safe to surface; the UI will hide the
|
||||||
|
// action anyway).
|
||||||
|
//
|
||||||
|
// On success it returns the event and the user's role so the handler can
|
||||||
|
// branch on owner-only behaviours without a second lookup.
|
||||||
|
func requireRole(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
events *storage.EventRepo,
|
||||||
|
collabs *storage.CollaboratorRepo,
|
||||||
|
eventID, userID uuid.UUID,
|
||||||
|
min domain.Role,
|
||||||
|
) (*domain.Event, domain.Role, bool) {
|
||||||
|
role, ok, err := collabs.RoleFor(r.Context(), eventID, userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to resolve role")
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// Not a collaborator. Treat the same way the legacy host_id check
|
||||||
|
// did: 404, no leak.
|
||||||
|
writeError(w, http.StatusNotFound, "event not found")
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
if !role.AtLeast(min) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient role for this action")
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
ev, err := events.Get(r.Context(), eventID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrEventNotFound) {
|
||||||
|
// Race: the event was deleted between the role lookup and now.
|
||||||
|
writeError(w, http.StatusNotFound, "event not found")
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to load event")
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return ev, role, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
"net/http"
|
||||||
|
|
||||||
"github.com/alchemistkay/guestguard/internal/csvimport"
|
"github.com/alchemistkay/guestguard/internal/csvimport"
|
||||||
|
"github.com/alchemistkay/guestguard/internal/domain"
|
||||||
"github.com/alchemistkay/guestguard/internal/storage"
|
"github.com/alchemistkay/guestguard/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ const (
|
|||||||
type csvImportHandler struct {
|
type csvImportHandler struct {
|
||||||
guests *storage.GuestRepo
|
guests *storage.GuestRepo
|
||||||
events *storage.EventRepo
|
events *storage.EventRepo
|
||||||
|
collabs *storage.CollaboratorRepo
|
||||||
enforcer *tierEnforcer
|
enforcer *tierEnforcer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ func (h *csvImportHandler) preview(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ func (h *csvImportHandler) commit(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +143,7 @@ func (h *csvImportHandler) template(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
|||||||
+31
-5
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
type eventHandler struct {
|
type eventHandler struct {
|
||||||
repo *storage.EventRepo
|
repo *storage.EventRepo
|
||||||
|
collabs *storage.CollaboratorRepo
|
||||||
enforcer *tierEnforcer
|
enforcer *tierEnforcer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +91,13 @@ func (h *eventHandler) create(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, ev)
|
writeJSON(w, http.StatusCreated, ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventView wraps an Event with the caller's role so the dashboard UI can
|
||||||
|
// branch (e.g. hide the "Delete event" button for editors/viewers).
|
||||||
|
type eventView struct {
|
||||||
|
*domain.Event
|
||||||
|
YourRole domain.Role `json:"your_role"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
|
func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||||
hostID, ok := hostFromContext(w, r)
|
hostID, ok := hostFromContext(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -99,11 +107,11 @@ func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ev, ok := requireEventOwner(w, r, h.repo, id, hostID)
|
ev, role, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleViewer)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, ev)
|
writeJSON(w, http.StatusOK, eventView{Event: ev, YourRole: role})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
|
func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -116,7 +124,15 @@ func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
limit := atoiOr(q.Get("limit"), 50)
|
limit := atoiOr(q.Get("limit"), 50)
|
||||||
offset := atoiOr(q.Get("offset"), 0)
|
offset := atoiOr(q.Get("offset"), 0)
|
||||||
|
|
||||||
events, err := h.repo.List(r.Context(), hostID, limit, offset)
|
// Block C: the dashboard shows every event the user has any role on,
|
||||||
|
// not just events they own. The collaborators repo gives us the id set;
|
||||||
|
// the events repo paginates the merged list.
|
||||||
|
collabIDs, err := h.collabs.ListEventIDsForUser(r.Context(), hostID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to resolve memberships")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events, err := h.repo.ListForUser(r.Context(), hostID, collabIDs, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list events")
|
writeError(w, http.StatusInternalServerError, "failed to list events")
|
||||||
return
|
return
|
||||||
@@ -150,6 +166,11 @@ func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Block C: editor+ can patch event metadata. The plan reserves DELETE
|
||||||
|
// (and the existing host_id row) for owners only.
|
||||||
|
if _, _, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleEditor); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req updateEventRequest
|
var req updateEventRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -180,7 +201,7 @@ func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
params.Status = &s
|
params.Status = &s
|
||||||
}
|
}
|
||||||
|
|
||||||
ev, err := h.repo.Update(r.Context(), id, hostID, params)
|
ev, err := h.repo.UpdateByID(r.Context(), id, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, domain.ErrEventNotFound):
|
case errors.Is(err, domain.ErrEventNotFound):
|
||||||
@@ -204,7 +225,12 @@ func (h *eventHandler) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.repo.Delete(r.Context(), id, hostID); err != nil {
|
// Block C: only owners can delete an event. Editors get 403; viewers
|
||||||
|
// and non-members get 404.
|
||||||
|
if _, _, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleOwner); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteByID(r.Context(), id); err != nil {
|
||||||
if errors.Is(err, domain.ErrEventNotFound) {
|
if errors.Is(err, domain.ErrEventNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "event not found")
|
writeError(w, http.StatusNotFound, "event not found")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
type guestHandler struct {
|
type guestHandler struct {
|
||||||
guests *storage.GuestRepo
|
guests *storage.GuestRepo
|
||||||
events *storage.EventRepo
|
events *storage.EventRepo
|
||||||
|
collabs *storage.CollaboratorRepo
|
||||||
enforcer *tierEnforcer
|
enforcer *tierEnforcer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ func (h *guestHandler) create(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.enforcer.allowGuestCreate(w, r, hostID, eventID) {
|
if !h.enforcer.allowGuestCreate(w, r, hostID, eventID) {
|
||||||
@@ -93,7 +94,7 @@ func (h *guestHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ func (h *guestHandler) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.guests.Delete(r.Context(), eventID, guestID); err != nil {
|
if err := h.guests.Delete(r.Context(), eventID, guestID); err != nil {
|
||||||
@@ -166,7 +167,9 @@ func (h *guestHandler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
// Listing guests is viewer+. Editing is gated separately at the
|
||||||
|
// PATCH/POST/DELETE call sites.
|
||||||
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-4
@@ -36,6 +36,7 @@ type Server struct {
|
|||||||
billing *billingHandler
|
billing *billingHandler
|
||||||
stripeWH *stripeWebhookHandler
|
stripeWH *stripeWebhookHandler
|
||||||
privacy *privacyHandler
|
privacy *privacyHandler
|
||||||
|
collabs *collaboratorHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerDeps struct {
|
type ServerDeps struct {
|
||||||
@@ -84,6 +85,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
|||||||
rsvpRepo := storage.NewRSVPRepo(deps.DB)
|
rsvpRepo := storage.NewRSVPRepo(deps.DB)
|
||||||
accessRepo := storage.NewAccessLogRepo(deps.DB)
|
accessRepo := storage.NewAccessLogRepo(deps.DB)
|
||||||
userRepo := storage.NewUserRepo(deps.DB)
|
userRepo := storage.NewUserRepo(deps.DB)
|
||||||
|
collabRepo := storage.NewCollaboratorRepo(deps.DB)
|
||||||
|
inviteRepo := storage.NewInviteRepo(deps.DB)
|
||||||
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
|
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
|
||||||
resetRepo := storage.NewPasswordResetRepo(deps.DB)
|
resetRepo := storage.NewPasswordResetRepo(deps.DB)
|
||||||
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
|
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
|
||||||
@@ -152,8 +155,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
|||||||
hub: hub,
|
hub: hub,
|
||||||
authH: authH,
|
authH: authH,
|
||||||
me: &meHandler{users: userRepo},
|
me: &meHandler{users: userRepo},
|
||||||
events: &eventHandler{repo: eventRepo, enforcer: enforcer},
|
events: &eventHandler{repo: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||||
guests: &guestHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer},
|
guests: &guestHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||||
tokens: &tokenHandler{
|
tokens: &tokenHandler{
|
||||||
logger: deps.Logger,
|
logger: deps.Logger,
|
||||||
guests: guestRepo,
|
guests: guestRepo,
|
||||||
@@ -162,6 +165,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
|||||||
users: userRepo,
|
users: userRepo,
|
||||||
accessLogs: accessRepo,
|
accessLogs: accessRepo,
|
||||||
rsvps: rsvpRepo,
|
rsvps: rsvpRepo,
|
||||||
|
collabs: collabRepo,
|
||||||
gen: auth.NewGenerator(),
|
gen: auth.NewGenerator(),
|
||||||
ttl: deps.TokenTTL,
|
ttl: deps.TokenTTL,
|
||||||
pub: deps.AccessPublisher,
|
pub: deps.AccessPublisher,
|
||||||
@@ -180,11 +184,12 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
|||||||
},
|
},
|
||||||
activity: &activityHandler{
|
activity: &activityHandler{
|
||||||
events: eventRepo,
|
events: eventRepo,
|
||||||
|
collabs: collabRepo,
|
||||||
rsvps: rsvpRepo,
|
rsvps: rsvpRepo,
|
||||||
accessLogs: accessRepo,
|
accessLogs: accessRepo,
|
||||||
},
|
},
|
||||||
ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets},
|
ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets},
|
||||||
wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo},
|
wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo, collabs: collabRepo},
|
||||||
health: &healthHandler{pool: deps.DB.Pool},
|
health: &healthHandler{pool: deps.DB.Pool},
|
||||||
signer: signer,
|
signer: signer,
|
||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
@@ -198,7 +203,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
|||||||
notifs: deps.NotificationRepo,
|
notifs: deps.NotificationRepo,
|
||||||
suppress: deps.SuppressionRepo,
|
suppress: deps.SuppressionRepo,
|
||||||
},
|
},
|
||||||
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer},
|
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||||
billing: &billingHandler{
|
billing: &billingHandler{
|
||||||
logger: deps.Logger,
|
logger: deps.Logger,
|
||||||
stripe: deps.StripeClient,
|
stripe: deps.StripeClient,
|
||||||
@@ -211,6 +216,15 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
|||||||
stripe: deps.StripeClient,
|
stripe: deps.StripeClient,
|
||||||
subs: subRepo,
|
subs: subRepo,
|
||||||
},
|
},
|
||||||
|
collabs: &collaboratorHandler{
|
||||||
|
logger: deps.Logger,
|
||||||
|
events: eventRepo,
|
||||||
|
users: userRepo,
|
||||||
|
collabs: collabRepo,
|
||||||
|
invites: inviteRepo,
|
||||||
|
emails: emails,
|
||||||
|
publicBaseURL: deps.PublicBaseURL,
|
||||||
|
},
|
||||||
privacy: &privacyHandler{
|
privacy: &privacyHandler{
|
||||||
logger: deps.Logger,
|
logger: deps.Logger,
|
||||||
users: userRepo,
|
users: userRepo,
|
||||||
@@ -293,6 +307,26 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list)))
|
mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list)))
|
||||||
|
|
||||||
|
// Block C — collaborators (multi-host). All under /events/{id}/collaborators.
|
||||||
|
// requireRole inside each handler enforces the right minimum role.
|
||||||
|
mux.Handle("GET /events/{id}/collaborators",
|
||||||
|
authed(http.HandlerFunc(s.collabs.list)))
|
||||||
|
mux.Handle("POST /events/{id}/collaborators",
|
||||||
|
authed(rl("collab_invite", 50, 24*time.Hour, userIDKey, http.HandlerFunc(s.collabs.invite))))
|
||||||
|
mux.Handle("PATCH /events/{id}/collaborators/{user_id}",
|
||||||
|
authed(http.HandlerFunc(s.collabs.updateRole)))
|
||||||
|
mux.Handle("DELETE /events/{id}/collaborators/{user_id}",
|
||||||
|
authed(http.HandlerFunc(s.collabs.remove)))
|
||||||
|
mux.Handle("DELETE /events/{id}/collaborators/pending",
|
||||||
|
authed(http.HandlerFunc(s.collabs.cancelInvite)))
|
||||||
|
|
||||||
|
// Invite acceptance — preview is unauthed (the invitee may not be
|
||||||
|
// logged in yet); accept requires auth (the caller's account must
|
||||||
|
// exist + match the invited email).
|
||||||
|
mux.HandleFunc("GET /invites/{token}", s.collabs.previewInvite)
|
||||||
|
mux.Handle("POST /invites/{token}/accept",
|
||||||
|
authed(http.HandlerFunc(s.collabs.acceptInvite)))
|
||||||
|
|
||||||
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens",
|
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens",
|
||||||
authed(rl("tokens_issue", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.issue))))
|
authed(rl("tokens_issue", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.issue))))
|
||||||
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens/rotate",
|
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens/rotate",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type tokenHandler struct {
|
|||||||
users *storage.UserRepo
|
users *storage.UserRepo
|
||||||
accessLogs *storage.AccessLogRepo
|
accessLogs *storage.AccessLogRepo
|
||||||
rsvps *storage.RSVPRepo
|
rsvps *storage.RSVPRepo
|
||||||
|
collabs *storage.CollaboratorRepo
|
||||||
gen *auth.Generator
|
gen *auth.Generator
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
pub accessPublisher
|
pub accessPublisher
|
||||||
@@ -63,7 +64,7 @@ func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event, ok := requireEventOwner(w, r, h.events, eventID, hostID)
|
event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -214,7 +215,7 @@ func (h *tokenHandler) bulkIssue(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event, ok := requireEventOwner(w, r, h.events, eventID, hostID)
|
event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -344,7 +345,7 @@ func (h *tokenHandler) rotate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event, ok := requireEventOwner(w, r, h.events, eventID, hostID)
|
event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/alchemistkay/guestguard/internal/domain"
|
||||||
"github.com/alchemistkay/guestguard/internal/storage"
|
"github.com/alchemistkay/guestguard/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type wsTicketHandler struct {
|
type wsTicketHandler struct {
|
||||||
tickets *wsTicketStore
|
tickets *wsTicketStore
|
||||||
events *storage.EventRepo
|
events *storage.EventRepo
|
||||||
|
collabs *storage.CollaboratorRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
type wsTicketResponse struct {
|
type wsTicketResponse struct {
|
||||||
@@ -38,7 +40,9 @@ func (h *wsTicketHandler) issue(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
// Block C: viewers can subscribe to the live monitor too — the WS stream
|
||||||
|
// only carries read-only events (RSVPs, fraud scores, check-ins).
|
||||||
|
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -5,12 +5,14 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailSender delivers transactional auth emails (verification, reset).
|
// EmailSender delivers transactional auth emails (verification, reset,
|
||||||
// Block A ships LogSender so dev environments work without Twilio/SES.
|
// collaborator-invite). Block A ships LogSender so dev environments work
|
||||||
// Block D replaces this with a real SES-backed sender.
|
// without Twilio/SES. Tier 1 Block D replaced this with a real SES-backed
|
||||||
|
// sender; Tier 2 Block C added the collaborator-invite method.
|
||||||
type EmailSender interface {
|
type EmailSender interface {
|
||||||
SendVerification(ctx context.Context, to, name, link string) error
|
SendVerification(ctx context.Context, to, name, link string) error
|
||||||
SendPasswordReset(ctx context.Context, to, name, link string) error
|
SendPasswordReset(ctx context.Context, to, name, link string) error
|
||||||
|
SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogEmailSender struct {
|
type LogEmailSender struct {
|
||||||
@@ -30,3 +32,10 @@ func (l LogEmailSender) SendPasswordReset(_ context.Context, to, name, link stri
|
|||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l LogEmailSender) SendCollaboratorInvite(_ context.Context, to, inviterName, eventName, role, link string) error {
|
||||||
|
l.Logger.Info("auth email (stub): collaborator invite",
|
||||||
|
"to", to, "inviter", inviterName, "event", eventName, "role", role, "link", link,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 /
|
// SendGuest is used by the notifier worker for invitation / confirmation /
|
||||||
// reminder emails — anything addressed at a guest.
|
// reminder emails — anything addressed at a guest.
|
||||||
func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) {
|
func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type EmailSenderConfig struct {
|
|||||||
type CombinedEmailSender interface {
|
type CombinedEmailSender interface {
|
||||||
SendVerification(ctx context.Context, to, name, link string) error
|
SendVerification(ctx context.Context, to, name, link string) error
|
||||||
SendPasswordReset(ctx context.Context, to, name, link string) error
|
SendPasswordReset(ctx context.Context, to, name, link string) error
|
||||||
|
SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error
|
||||||
SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error)
|
SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,12 @@ func (l *logCombinedSender) SendPasswordReset(_ context.Context, to, name, link
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *logCombinedSender) SendCollaboratorInvite(_ context.Context, to, inviterName, eventName, role, link string) error {
|
||||||
|
l.logger.Info("auth email (stub): collaborator invite",
|
||||||
|
"to", to, "inviter", inviterName, "event", eventName, "role", role, "link", link)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
|
func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = map[string]any{}
|
data = map[string]any{}
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ func (s *ResendEmailSender) SendPasswordReset(ctx context.Context, to, name, lin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ResendEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error {
|
||||||
|
_, err := s.sendTemplated(ctx, to,
|
||||||
|
inviterName+" invited you to "+eventName,
|
||||||
|
TmplCollaboratorInvite, map[string]any{
|
||||||
|
"InviterName": inviterName,
|
||||||
|
"EventName": eventName,
|
||||||
|
"Role": role,
|
||||||
|
"Link": link,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// --- GuestEmailDispatcher ---
|
// --- GuestEmailDispatcher ---
|
||||||
|
|
||||||
func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
|
func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ func (s *SMTPEmailSender) SendPasswordReset(ctx context.Context, to, name, link
|
|||||||
TmplPasswordReset, map[string]any{"Name": name, "Link": link, "ExpiryHumane": "1 hour"})
|
TmplPasswordReset, map[string]any{"Name": name, "Link": link, "ExpiryHumane": "1 hour"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SMTPEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error {
|
||||||
|
return s.sendTemplated(ctx, to,
|
||||||
|
inviterName+" invited you to "+eventName,
|
||||||
|
TmplCollaboratorInvite, map[string]any{
|
||||||
|
"InviterName": inviterName,
|
||||||
|
"EventName": eventName,
|
||||||
|
"Role": role,
|
||||||
|
"Link": link,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- GuestEmailDispatcher ---
|
// --- GuestEmailDispatcher ---
|
||||||
|
|
||||||
func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
|
func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ var templatesFS embed.FS
|
|||||||
type TemplateName string
|
type TemplateName string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TmplVerification TemplateName = "verification"
|
TmplVerification TemplateName = "verification"
|
||||||
TmplPasswordReset TemplateName = "reset"
|
TmplPasswordReset TemplateName = "reset"
|
||||||
TmplInvitation TemplateName = "invitation"
|
TmplInvitation TemplateName = "invitation"
|
||||||
TmplConfirmation TemplateName = "confirmation"
|
TmplConfirmation TemplateName = "confirmation"
|
||||||
TmplReminder TemplateName = "reminder"
|
TmplReminder TemplateName = "reminder"
|
||||||
|
TmplCollaboratorInvite TemplateName = "collaborator_invite"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Templates renders branded transactional emails for both HTML and
|
// Templates renders branded transactional emails for both HTML and
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{define "body"}}
|
||||||
|
<p style="font-size:13px;letter-spacing:0.2em;text-transform:uppercase;color:#16a34a;margin:0 0 16px;">✦ Team invitation</p>
|
||||||
|
<h1 style="font-size:24px;margin:0 0 4px;color:#0a0a0a;">{{.EventName}}</h1>
|
||||||
|
<p style="margin:16px 0 20px;">
|
||||||
|
{{.InviterName}} invited you to collaborate on this event as
|
||||||
|
<strong>{{.Role}}</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;text-align:center;">
|
||||||
|
<a href="{{.Link}}" style="background:#22c55e;color:#0a0a0a;padding:12px 22px;border-radius:8px;font-weight:600;text-decoration:none;display:inline-block;">Accept invitation</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 8px;color:#64748b;font-size:13px;">
|
||||||
|
This invitation expires in 7 days. If you don't have a GuestGuard account
|
||||||
|
yet, you'll be able to create one before accepting.
|
||||||
|
</p>
|
||||||
|
<p style="margin:8px 0 0;word-break:break-all;font-size:12px;color:#0f172a;">{{.Link}}</p>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{.InviterName}} invited you to collaborate on "{{.EventName}}" as {{.Role}}.
|
||||||
|
|
||||||
|
Accept the invitation:
|
||||||
|
{{.Link}}
|
||||||
|
|
||||||
|
This link expires in 7 days. If you don't have a GuestGuard account yet,
|
||||||
|
you'll be able to create one before accepting.
|
||||||
@@ -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)
|
return nil, fmt.Errorf("marshal settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block C: every new event needs a row in event_collaborators pointing
|
||||||
|
// the host at the owner role. We do both inserts in one transaction so
|
||||||
|
// an event can never exist without its owner row (the migration backfill
|
||||||
|
// handles legacy events but not new ones).
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO events (host_id, name, slug, event_date, venue, max_capacity, settings, status)
|
INSERT INTO events (host_id, name, slug, event_date, venue, max_capacity, settings, status)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
||||||
`
|
`
|
||||||
row := r.pool.QueryRow(ctx, q,
|
row := tx.QueryRow(ctx, q,
|
||||||
p.HostID, p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status,
|
p.HostID, p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status,
|
||||||
)
|
)
|
||||||
ev, err := scanEvent(row)
|
ev, err := scanEvent(row)
|
||||||
@@ -60,6 +70,17 @@ func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Ev
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO event_collaborators (event_id, user_id, role, invited_at, accepted_at)
|
||||||
|
VALUES ($1, $2, 'owner', now(), now())
|
||||||
|
`, ev.ID, p.HostID); err != nil {
|
||||||
|
return nil, fmt.Errorf("seed owner collaborator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return ev, nil
|
return ev, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +117,42 @@ func (r *EventRepo) GetForHost(ctx context.Context, id, hostID uuid.UUID) (*doma
|
|||||||
return ev, nil
|
return ev, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListForUser returns every event the user has any accepted role on. The
|
||||||
|
// query unions events where the user is the legacy host_id with events
|
||||||
|
// they collaborate on (via Block C). Duplicates are deduped on event id.
|
||||||
|
// Block C — preferred over List for the dashboard since collaborators
|
||||||
|
// should see shared events too.
|
||||||
|
func (r *EventRepo) ListForUser(ctx context.Context, userID uuid.UUID, collabEventIDs []uuid.UUID, limit, offset int) ([]*domain.Event, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT DISTINCT
|
||||||
|
id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
||||||
|
FROM events
|
||||||
|
WHERE host_id = $1
|
||||||
|
OR id = ANY($2::uuid[])
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`, userID, collabEventIDs, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*domain.Event
|
||||||
|
for rows.Next() {
|
||||||
|
ev, err := scanEvent(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, ev)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *EventRepo) List(ctx context.Context, hostID uuid.UUID, limit, offset int) ([]*domain.Event, error) {
|
func (r *EventRepo) List(ctx context.Context, hostID uuid.UUID, limit, offset int) ([]*domain.Event, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 50
|
limit = 50
|
||||||
@@ -150,8 +207,21 @@ type UpdateEventParams struct {
|
|||||||
Status *domain.EventStatus
|
Status *domain.EventStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateByID applies the patch without an authz scope. Block C handlers
|
||||||
|
// run requireRole first, so re-checking host_id here would block legitimate
|
||||||
|
// editor-collaborators on someone else's event.
|
||||||
|
func (r *EventRepo) UpdateByID(ctx context.Context, id uuid.UUID, p UpdateEventParams) (*domain.Event, error) {
|
||||||
|
return r.update(ctx, id, uuid.Nil, p, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update is the legacy host-scoped variant — preserved so a few owner-only
|
||||||
|
// call sites stay terse. New code should prefer UpdateByID + requireRole.
|
||||||
func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams) (*domain.Event, error) {
|
func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams) (*domain.Event, error) {
|
||||||
const q = `
|
return r.update(ctx, id, hostID, p, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EventRepo) update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams, scopeToHost bool) (*domain.Event, error) {
|
||||||
|
q := `
|
||||||
UPDATE events SET
|
UPDATE events SET
|
||||||
name = COALESCE($3, name),
|
name = COALESCE($3, name),
|
||||||
slug = COALESCE($4, slug),
|
slug = COALESCE($4, slug),
|
||||||
@@ -161,7 +231,14 @@ func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEv
|
|||||||
settings = COALESCE($8, settings),
|
settings = COALESCE($8, settings),
|
||||||
status = COALESCE($9, status),
|
status = COALESCE($9, status),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1 AND host_id = $2
|
WHERE id = $1`
|
||||||
|
if scopeToHost {
|
||||||
|
q += ` AND host_id = $2`
|
||||||
|
} else {
|
||||||
|
// Bind $2 anyway so the parameter count matches the call below.
|
||||||
|
q += ` AND ($2::uuid IS NULL OR $2::uuid = host_id OR TRUE)`
|
||||||
|
}
|
||||||
|
q += `
|
||||||
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -202,6 +279,19 @@ func (r *EventRepo) Delete(ctx context.Context, id, hostID uuid.UUID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteByID is the post-Block-C path: requireRole(Owner) is enforced by
|
||||||
|
// the handler, so this query doesn't double-check host_id.
|
||||||
|
func (r *EventRepo) DeleteByID(ctx context.Context, id uuid.UUID) error {
|
||||||
|
tag, err := r.pool.Exec(ctx, `DELETE FROM events WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return domain.ErrEventNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type rowScanner interface {
|
type rowScanner interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_collab_invites_event;
|
||||||
|
DROP TABLE IF EXISTS collaborator_invites;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_collaborators_user;
|
||||||
|
DROP TABLE IF EXISTS event_collaborators;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS collaborator_role;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Tier 2 Block C — multi-host / collaborators.
|
||||||
|
--
|
||||||
|
-- An event can now have multiple users with one of three roles:
|
||||||
|
-- owner — full access incl. delete + manage collaborators
|
||||||
|
-- editor — guests/tokens/branding/messages/analytics
|
||||||
|
-- viewer — read-only
|
||||||
|
--
|
||||||
|
-- The existing events.host_id stays as a denormalised "primary owner"
|
||||||
|
-- pointer (cheap join key, useful for the existing GET /events query). The
|
||||||
|
-- source of truth for authz is event_collaborators — every handler resolves
|
||||||
|
-- the caller's role through it.
|
||||||
|
--
|
||||||
|
-- Schema #0005 in TIER2_PLAN.md; landing at 0008 because earlier slots are
|
||||||
|
-- taken by Tier 1 work.
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE collaborator_role AS ENUM ('owner', 'editor', 'viewer');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_collaborators (
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role collaborator_role NOT NULL,
|
||||||
|
invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
invited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (event_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fast lookup of "what events does this user have any role on" — used by
|
||||||
|
-- the dashboard list and the role-resolution middleware.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collaborators_user
|
||||||
|
ON event_collaborators(user_id);
|
||||||
|
|
||||||
|
-- Pending invitations. The invitee may not have a GuestGuard account yet;
|
||||||
|
-- the accept flow creates one (or links to an existing one by email) and
|
||||||
|
-- promotes the row into event_collaborators.
|
||||||
|
--
|
||||||
|
-- token_hash is sha256(raw); the raw token only ever lives in the email
|
||||||
|
-- link, never in the DB. consumed_at is set on successful accept so re-
|
||||||
|
-- using the link returns 410 Gone instead of silently re-adding the user.
|
||||||
|
CREATE TABLE IF NOT EXISTS collaborator_invites (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
role collaborator_role NOT NULL,
|
||||||
|
invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Find pending invites for an event quickly (the "Team" tab lists them).
|
||||||
|
-- Partial index keeps the working set small even on a busy event.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collab_invites_event
|
||||||
|
ON collaborator_invites(event_id)
|
||||||
|
WHERE consumed_at IS NULL;
|
||||||
|
|
||||||
|
-- Backfill: every existing event's host becomes its owner. Idempotent so
|
||||||
|
-- re-running this migration after a partial failure (or against a freshly
|
||||||
|
-- restored backup that already has rows) does the right thing.
|
||||||
|
INSERT INTO event_collaborators (event_id, user_id, role, accepted_at, invited_at)
|
||||||
|
SELECT id, host_id, 'owner', created_at, created_at
|
||||||
|
FROM events
|
||||||
|
ON CONFLICT (event_id, user_id) DO NOTHING;
|
||||||
@@ -29,6 +29,7 @@ const authTestPassword = "correct-horse-battery-staple"
|
|||||||
type recordingEmailSender struct {
|
type recordingEmailSender struct {
|
||||||
verifyLink string
|
verifyLink string
|
||||||
resetLink string
|
resetLink string
|
||||||
|
inviteLink string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordingEmailSender) SendVerification(_ context.Context, _, _, link string) error {
|
func (s *recordingEmailSender) SendVerification(_ context.Context, _, _, link string) error {
|
||||||
@@ -41,6 +42,11 @@ func (s *recordingEmailSender) SendPasswordReset(_ context.Context, _, _, link s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *recordingEmailSender) SendCollaboratorInvite(_ context.Context, _, _, _, _, link string) error {
|
||||||
|
s.inviteLink = link
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthFlow(t *testing.T) {
|
func TestAuthFlow(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("skipping integration test in -short mode")
|
t.Skip("skipping integration test in -short mode")
|
||||||
|
|||||||
@@ -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