feat: save the date tracking
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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({
|
||||
<TableHead>Language</TableHead>
|
||||
<TableHead>Members</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Save the date</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -127,6 +129,13 @@ export default async function GuestsPage({
|
||||
{invitation.derivedStatus}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SaveTheDateCheckbox
|
||||
invitationId={invitation.id}
|
||||
displayName={invitation.displayName}
|
||||
sent={invitation.saveTheDateSentAt !== null}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-sm">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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<InvitationListItem[]> {
|
||||
const invitations = await db
|
||||
.selectFrom("invitations")
|
||||
@@ -179,6 +232,7 @@ export async function listInvitations(): Promise<InvitationListItem[]> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof createInvitationSchema>
|
||||
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;
|
||||
Reference in New Issue
Block a user