feat: save the date tracking
Deploy On Server / Prepare Repository Remotely (push) Successful in 9s
Deploy On Server / Docker Compose Up (push) Successful in 17s
Deploy On Server / Prune Images (push) Successful in 3s
Deploy On Server / Remove .env File (push) Successful in 2s

This commit is contained in:
Raul Lugo
2026-08-11 23:18:23 +02:00
parent 5ff287a911
commit a4a77a3fa2
7 changed files with 186 additions and 0 deletions
+51
View File
@@ -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<UpdateSaveTheDateSentResult> {
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 }
}
+9
View File
@@ -1,6 +1,7 @@
import Link from "next/link" import Link from "next/link"
import { CopyInviteButton } from "@/components/admin/copy-invite-button" 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -91,6 +92,7 @@ export default async function GuestsPage({
<TableHead>Language</TableHead> <TableHead>Language</TableHead>
<TableHead>Members</TableHead> <TableHead>Members</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Save the date</TableHead>
<TableHead>Contact</TableHead> <TableHead>Contact</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
@@ -127,6 +129,13 @@ export default async function GuestsPage({
{invitation.derivedStatus} {invitation.derivedStatus}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>
<SaveTheDateCheckbox
invitationId={invitation.id}
displayName={invitation.displayName}
sent={invitation.saveTheDateSentAt !== null}
/>
</TableCell>
<TableCell> <TableCell>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<p>{invitation.contactEmail ?? "—"}</p> <p>{invitation.contactEmail ?? "—"}</p>
@@ -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<string | null>(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 (
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={checked}
disabled={isSaving}
onCheckedChange={(value) => void updateSent(value === true)}
aria-label={`Mark save-the-date as sent for ${displayName}`}
/>
<span className={checked ? "font-medium" : "text-muted-foreground"}>
{checked ? "Sent" : "Not sent"}
</span>
</label>
{error ? <p className="max-w-36 text-xs text-destructive">{error}</p> : null}
</div>
)
}
+54
View File
@@ -16,6 +16,7 @@ export interface InvitationListItem {
contactPhone: string | null contactPhone: string | null
maxPartySize: number maxPartySize: number
allowMemberEdits: boolean allowMemberEdits: boolean
saveTheDateSentAt: Date | null
membersCount: number membersCount: number
attendingCount: number attendingCount: number
pendingCount: 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<InvitationListItem[]> { export async function listInvitations(): Promise<InvitationListItem[]> {
const invitations = await db const invitations = await db
.selectFrom("invitations") .selectFrom("invitations")
@@ -179,6 +232,7 @@ export async function listInvitations(): Promise<InvitationListItem[]> {
contactPhone: invitation.contact_phone, contactPhone: invitation.contact_phone,
maxPartySize: invitation.max_party_size, maxPartySize: invitation.max_party_size,
allowMemberEdits: invitation.allow_member_edits, allowMemberEdits: invitation.allow_member_edits,
saveTheDateSentAt: invitation.save_the_date_sent_at,
membersCount: invitationMembers.length, membersCount: invitationMembers.length,
attendingCount: invitationMembers.filter((member) => member.attending === true).length, attendingCount: invitationMembers.filter((member) => member.attending === true).length,
pendingCount: invitationMembers.filter((member) => member.attending === null).length, pendingCount: invitationMembers.filter((member) => member.attending === null).length,
+1
View File
@@ -26,6 +26,7 @@ export interface InvitationsTable {
contact_phone: string | null; contact_phone: string | null;
max_party_size: number; max_party_size: number;
allow_member_edits: boolean; allow_member_edits: boolean;
save_the_date_sent_at: NullableTimestamp;
notes: string | null; notes: string | null;
created_at: Timestamp; created_at: Timestamp;
updated_at: Timestamp; updated_at: Timestamp;
+6
View File
@@ -49,5 +49,11 @@ export const deleteInvitationSchema = z.object({
invitationId: z.uuid(), invitationId: z.uuid(),
}) })
export const updateSaveTheDateSentSchema = z.object({
invitationId: z.uuid(),
sent: z.boolean(),
})
export type CreateInvitationInput = z.infer<typeof createInvitationSchema> export type CreateInvitationInput = z.infer<typeof createInvitationSchema>
export type DeleteInvitationInput = z.infer<typeof deleteInvitationSchema> export type DeleteInvitationInput = z.infer<typeof deleteInvitationSchema>
export type UpdateSaveTheDateSentInput = z.infer<typeof updateSaveTheDateSentSchema>
@@ -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;