From a4a77a3fa2698d59a70518a1c59bb5bfae8a4480 Mon Sep 17 00:00:00 2001 From: Raul Lugo Date: Tue, 11 Aug 2026 23:18:23 +0200 Subject: [PATCH] feat: save the date tracking --- app/admin/guests/actions.ts | 51 ++++++++++++++++ app/admin/guests/page.tsx | 9 +++ components/admin/save-the-date-checkbox.tsx | 61 +++++++++++++++++++ lib/db/queries/invitations.ts | 54 ++++++++++++++++ lib/db/types.ts | 1 + lib/validation/invitation.ts | 6 ++ migrations/0003_add_save_the_date_sent_at.sql | 4 ++ 7 files changed, 186 insertions(+) create mode 100644 app/admin/guests/actions.ts create mode 100644 components/admin/save-the-date-checkbox.tsx create mode 100644 migrations/0003_add_save_the_date_sent_at.sql diff --git a/app/admin/guests/actions.ts b/app/admin/guests/actions.ts new file mode 100644 index 0000000..ead413c --- /dev/null +++ b/app/admin/guests/actions.ts @@ -0,0 +1,51 @@ +"use server" + +import { revalidatePath } from "next/cache" + +import { auth } from "@/auth" +import { isAdminEmail } from "@/lib/admin-auth" +import { updateSaveTheDateSent } from "@/lib/db/queries/invitations" +import { updateSaveTheDateSentSchema } from "@/lib/validation/invitation" + +export interface UpdateSaveTheDateSentResult { + ok: boolean + error: string | null +} + +export async function updateSaveTheDateSentAction(input: { + invitationId: string + sent: boolean +}): Promise { + const session = await auth() + const adminEmail = session?.user?.email + + if (!isAdminEmail(adminEmail)) { + return { ok: false, error: "You are not allowed to update invitations." } + } + + const parsed = updateSaveTheDateSentSchema.safeParse(input) + + if (!parsed.success) { + return { ok: false, error: "Could not update save-the-date status." } + } + + try { + const updated = await updateSaveTheDateSent({ + invitationId: parsed.data.invitationId, + sent: parsed.data.sent, + adminEmail: adminEmail ?? "", + }) + + if (!updated) { + return { ok: false, error: "This guest group no longer exists." } + } + } catch (error) { + console.error("Failed to update save-the-date status", error) + return { ok: false, error: "Could not update save-the-date status." } + } + + revalidatePath("/admin/guests") + revalidatePath("/admin") + + return { ok: true, error: null } +} diff --git a/app/admin/guests/page.tsx b/app/admin/guests/page.tsx index 232bd92..4101d77 100644 --- a/app/admin/guests/page.tsx +++ b/app/admin/guests/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link" import { CopyInviteButton } from "@/components/admin/copy-invite-button" +import { SaveTheDateCheckbox } from "@/components/admin/save-the-date-checkbox" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -91,6 +92,7 @@ export default async function GuestsPage({ Language Members Status + Save the date Contact Actions @@ -127,6 +129,13 @@ export default async function GuestsPage({ {invitation.derivedStatus} + + +

{invitation.contactEmail ?? "—"}

diff --git a/components/admin/save-the-date-checkbox.tsx b/components/admin/save-the-date-checkbox.tsx new file mode 100644 index 0000000..c3a394a --- /dev/null +++ b/components/admin/save-the-date-checkbox.tsx @@ -0,0 +1,61 @@ +"use client" + +import { useState } from "react" + +import { updateSaveTheDateSentAction } from "@/app/admin/guests/actions" +import { Checkbox } from "@/components/ui/checkbox" + +interface SaveTheDateCheckboxProps { + invitationId: string + displayName: string + sent: boolean +} + +export function SaveTheDateCheckbox({ + invitationId, + displayName, + sent, +}: SaveTheDateCheckboxProps) { + const [checked, setChecked] = useState(sent) + const [error, setError] = useState(null) + const [isSaving, setIsSaving] = useState(false) + + async function updateSent(nextChecked: boolean) { + const previousChecked = checked + + setChecked(nextChecked) + setError(null) + setIsSaving(true) + + try { + const result = await updateSaveTheDateSentAction({ invitationId, sent: nextChecked }) + + if (!result.ok) { + setChecked(previousChecked) + setError(result.error) + } + } catch { + setChecked(previousChecked) + setError("Could not update save-the-date status.") + } finally { + setIsSaving(false) + } + } + + return ( +
+ + {error ?

{error}

: null} +
+ ) +} diff --git a/lib/db/queries/invitations.ts b/lib/db/queries/invitations.ts index 03d3b68..3e7b3db 100644 --- a/lib/db/queries/invitations.ts +++ b/lib/db/queries/invitations.ts @@ -16,6 +16,7 @@ export interface InvitationListItem { contactPhone: string | null maxPartySize: number allowMemberEdits: boolean + saveTheDateSentAt: Date | null membersCount: number attendingCount: number pendingCount: number @@ -138,6 +139,58 @@ export async function deleteInvitationGroup( }) } +export async function updateSaveTheDateSent({ + invitationId, + sent, + adminEmail, +}: { + invitationId: string + sent: boolean + adminEmail: string +}) { + return db.transaction().execute(async (trx) => { + const invitation = await trx + .selectFrom("invitations") + .select(["id", "display_name", "save_the_date_sent_at"]) + .where("id", "=", invitationId) + .executeTakeFirst() + + if (!invitation) return null + + const previousSent = invitation.save_the_date_sent_at !== null + const saveTheDateSentAt = sent ? new Date() : null + + const updated = await trx + .updateTable("invitations") + .set({ + save_the_date_sent_at: saveTheDateSentAt, + updated_at: new Date(), + }) + .where("id", "=", invitationId) + .returning(["id", "save_the_date_sent_at"]) + .executeTakeFirst() + + if (!updated) return null + + await trx + .insertInto("admin_audit_events") + .values({ + admin_email: adminEmail, + action: sent ? "save_the_date.marked_sent" : "save_the_date.marked_unsent", + target_type: "invitation", + target_id: invitationId, + metadata: { + displayName: invitation.display_name, + previousSent, + newSent: sent, + }, + }) + .execute() + + return updated + }) +} + export async function listInvitations(): Promise { const invitations = await db .selectFrom("invitations") @@ -179,6 +232,7 @@ export async function listInvitations(): Promise { contactPhone: invitation.contact_phone, maxPartySize: invitation.max_party_size, allowMemberEdits: invitation.allow_member_edits, + saveTheDateSentAt: invitation.save_the_date_sent_at, membersCount: invitationMembers.length, attendingCount: invitationMembers.filter((member) => member.attending === true).length, pendingCount: invitationMembers.filter((member) => member.attending === null).length, diff --git a/lib/db/types.ts b/lib/db/types.ts index 8326414..4c290b3 100644 --- a/lib/db/types.ts +++ b/lib/db/types.ts @@ -26,6 +26,7 @@ export interface InvitationsTable { contact_phone: string | null; max_party_size: number; allow_member_edits: boolean; + save_the_date_sent_at: NullableTimestamp; notes: string | null; created_at: Timestamp; updated_at: Timestamp; diff --git a/lib/validation/invitation.ts b/lib/validation/invitation.ts index e01e239..bcc9bc9 100644 --- a/lib/validation/invitation.ts +++ b/lib/validation/invitation.ts @@ -49,5 +49,11 @@ export const deleteInvitationSchema = z.object({ invitationId: z.uuid(), }) +export const updateSaveTheDateSentSchema = z.object({ + invitationId: z.uuid(), + sent: z.boolean(), +}) + export type CreateInvitationInput = z.infer export type DeleteInvitationInput = z.infer +export type UpdateSaveTheDateSentInput = z.infer diff --git a/migrations/0003_add_save_the_date_sent_at.sql b/migrations/0003_add_save_the_date_sent_at.sql new file mode 100644 index 0000000..2a434b0 --- /dev/null +++ b/migrations/0003_add_save_the_date_sent_at.sql @@ -0,0 +1,4 @@ +-- Track whether the save-the-date video/PDF was sent to each invitation group. + +ALTER TABLE invitations + ADD COLUMN IF NOT EXISTS save_the_date_sent_at timestamptz NULL;