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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user