import { nanoid } from "nanoid" import { db } from "@/lib/db/client" import type { Invitation, InvitationMember, InvitationResponse } from "@/lib/db/types" import type { CreateInvitationInput } from "@/lib/validation/invitation" import type { SubmitInvitationResponseInput } from "@/lib/validation/response" export type DerivedInvitationStatus = "Pending" | "Partially answered" | "Attending" | "Declined" | "Partial" export interface InvitationListItem { id: string token: string displayName: string language: string contactEmail: string | null contactPhone: string | null maxPartySize: number allowMemberEdits: boolean saveTheDateSentAt: Date | null membersCount: number attendingCount: number pendingCount: number derivedStatus: DerivedInvitationStatus updatedAt: Date } export interface InvitationDetail { invitation: Invitation members: InvitationMember[] response: InvitationResponse | null derivedStatus: DerivedInvitationStatus } function deriveStatus(members: InvitationMember[], submittedAt?: Date | null): DerivedInvitationStatus { if (!submittedAt) return "Pending" const pendingCount = members.filter((member) => member.attending === null).length const attendingCount = members.filter((member) => member.attending === true).length const declinedCount = members.filter((member) => member.attending === false).length if (pendingCount > 0) return "Partially answered" if (attendingCount > 0 && declinedCount > 0) return "Partial" if (attendingCount > 0) return "Attending" return "Declined" } export async function createInvitationWithMembers(input: CreateInvitationInput) { return db.transaction().execute(async (trx) => { const invitation = await trx .insertInto("invitations") .values({ token: nanoid(32), display_name: input.displayName, language: input.language, contact_email: input.contactEmail, contact_phone: input.contactPhone, max_party_size: input.maxPartySize, allow_member_edits: input.allowMemberEdits, notes: input.notes, }) .returningAll() .executeTakeFirstOrThrow() await trx .insertInto("invitation_members") .values( input.members.map((name, index) => ({ invitation_id: invitation.id, name, is_primary_contact: index === 0, })), ) .execute() return invitation }) } export interface DeletedInvitationResult { id: string displayName: string photoObjectKey: string | null } export async function deleteInvitationGroup( invitationId: string, adminEmail: string, ): Promise { return db.transaction().execute(async (trx) => { const invitation = await trx .selectFrom("invitations") .select(["id", "display_name", "token"]) .where("id", "=", invitationId) .executeTakeFirst() if (!invitation) return null const photoUpload = await trx .selectFrom("photo_uploads") .select(["object_key"]) .where("invitation_id", "=", invitationId) .executeTakeFirst() const membersCount = await trx .selectFrom("invitation_members") .select((eb) => eb.fn.countAll().as("count")) .where("invitation_id", "=", invitationId) .executeTakeFirst() await trx .insertInto("admin_audit_events") .values({ admin_email: adminEmail, action: "invitation.deleted", target_type: "invitation", target_id: invitationId, metadata: { displayName: invitation.display_name, token: invitation.token, membersCount: Number(membersCount?.count ?? 0), photoObjectKey: photoUpload?.object_key ?? null, }, }) .execute() const deleted = await trx .deleteFrom("invitations") .where("id", "=", invitationId) .returning(["id", "display_name"]) .executeTakeFirst() if (!deleted) return null return { id: deleted.id, displayName: deleted.display_name, photoObjectKey: photoUpload?.object_key ?? null, } }) } 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 updateInvitationLanguage({ invitationId, language, adminEmail, }: { invitationId: string language: string adminEmail: string }) { return db.transaction().execute(async (trx) => { const invitation = await trx .selectFrom("invitations") .select(["id", "display_name", "language"]) .where("id", "=", invitationId) .executeTakeFirst() if (!invitation) return null const updated = await trx .updateTable("invitations") .set({ language, updated_at: new Date(), }) .where("id", "=", invitationId) .returning(["id", "language"]) .executeTakeFirst() if (!updated) return null await trx .insertInto("admin_audit_events") .values({ admin_email: adminEmail, action: "invitation.language_updated", target_type: "invitation", target_id: invitationId, metadata: { displayName: invitation.display_name, previousLanguage: invitation.language, newLanguage: language, }, }) .execute() return updated }) } export async function listInvitations(): Promise { const invitations = await db .selectFrom("invitations") .selectAll() .orderBy("updated_at", "desc") .execute() if (invitations.length === 0) return [] const invitationIds = invitations.map((invitation) => invitation.id) const [members, responses] = await Promise.all([ db .selectFrom("invitation_members") .selectAll() .where("invitation_id", "in", invitationIds) .execute(), db .selectFrom("invitation_responses") .selectAll() .where("invitation_id", "in", invitationIds) .execute(), ]) return invitations.map((invitation) => { const invitationMembers = members.filter( (member) => member.invitation_id === invitation.id, ) const response = responses.find( (invitationResponse) => invitationResponse.invitation_id === invitation.id, ) return { id: invitation.id, token: invitation.token, displayName: invitation.display_name, language: invitation.language, contactEmail: invitation.contact_email, 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, derivedStatus: deriveStatus(invitationMembers, response?.submitted_at ?? null), updatedAt: invitation.updated_at, } }) } export async function getInvitationDetail(id: string): Promise { const invitation = await db .selectFrom("invitations") .selectAll() .where("id", "=", id) .executeTakeFirst() if (!invitation) return null const [members, response] = await Promise.all([ db .selectFrom("invitation_members") .selectAll() .where("invitation_id", "=", id) .orderBy("is_primary_contact", "desc") .orderBy("created_at", "asc") .execute(), db .selectFrom("invitation_responses") .selectAll() .where("invitation_id", "=", id) .executeTakeFirst(), ]) return { invitation, members, response: response ?? null, derivedStatus: deriveStatus(members, response?.submitted_at ?? null), } } export async function getInvitationByToken(token: string): Promise { const invitation = await db .selectFrom("invitations") .selectAll() .where("token", "=", token) .executeTakeFirst() if (!invitation) return null const [members, response] = await Promise.all([ db .selectFrom("invitation_members") .selectAll() .where("invitation_id", "=", invitation.id) .orderBy("is_primary_contact", "desc") .orderBy("created_at", "asc") .execute(), db .selectFrom("invitation_responses") .selectAll() .where("invitation_id", "=", invitation.id) .executeTakeFirst(), ]) return { invitation, members, response: response ?? null, derivedStatus: deriveStatus(members, response?.submitted_at ?? null), } } export async function submitInvitationResponse(input: SubmitInvitationResponseInput) { return db.transaction().execute(async (trx) => { const invitation = await trx .selectFrom("invitations") .select(["id"]) .where("token", "=", input.token) .executeTakeFirst() if (!invitation) { throw new Error("Invitation not found") } const existingMembers = await trx .selectFrom("invitation_members") .select(["id"]) .where("invitation_id", "=", invitation.id) .execute() const existingMemberIds = new Set(existingMembers.map((member) => member.id)) for (const member of input.members) { if (!existingMemberIds.has(member.memberId)) { throw new Error("Invalid invitation member") } await trx .updateTable("invitation_members") .set({ attending: member.attending, meal_preference: member.attending ? member.mealPreference : null, dietary_restrictions: member.attending ? member.dietaryRestrictions : null, updated_at: new Date(), }) .where("id", "=", member.memberId) .where("invitation_id", "=", invitation.id) .execute() } await trx .insertInto("invitation_responses") .values({ invitation_id: invitation.id, submitted_at: new Date(), arrival_date: input.arrivalDate, departure_date: input.departureDate, stay_location: input.stayLocation, needs_transport_help: input.needsTransportHelp, message_to_couple: input.messageToCouple, }) .onConflict((oc) => oc.column("invitation_id").doUpdateSet({ submitted_at: new Date(), arrival_date: input.arrivalDate, departure_date: input.departureDate, stay_location: input.stayLocation, needs_transport_help: input.needsTransportHelp, message_to_couple: input.messageToCouple, updated_at: new Date(), }), ) .execute() await trx .updateTable("invitations") .set({ updated_at: new Date() }) .where("id", "=", invitation.id) .execute() }) }