initial commit
Build & Deployment / Build & Deploy (push) Failing after 5s

This commit is contained in:
Raul Lugo
2026-08-10 17:55:26 +02:00
parent 6429ed3fa3
commit 4ced446c9c
74 changed files with 9242 additions and 30 deletions
+25
View File
@@ -0,0 +1,25 @@
import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";
import { getDatabaseUrl } from "@/lib/config";
import type { Database } from "@/lib/db/types";
declare global {
var __weddingDb: Kysely<Database> | undefined;
}
function createDatabase() {
return new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: getDatabaseUrl(),
}),
}),
});
}
export const db = globalThis.__weddingDb ?? createDatabase();
if (process.env.NODE_ENV !== "production") {
globalThis.__weddingDb = db;
}
+56
View File
@@ -0,0 +1,56 @@
import { db } from "@/lib/db/client"
import type { ToastRequest } from "@/lib/db/types"
import type { InteractionSubmissionInput, ToastStatus } from "@/lib/validation/interactions"
export interface InvitationInteractions {
toastRequests: ToastRequest[]
}
export async function getInvitationInteractions(
invitationId: string,
): Promise<InvitationInteractions> {
const toastRequests = await db
.selectFrom("toast_requests")
.selectAll()
.where("invitation_id", "=", invitationId)
.orderBy("created_at", "asc")
.execute()
return { toastRequests }
}
export async function replaceInvitationInteractions(input: InteractionSubmissionInput) {
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("toast_requests")
.where("invitation_id", "=", input.invitationId)
.execute()
if (input.toastRequest) {
await trx
.insertInto("toast_requests")
.values({
invitation_id: input.invitationId,
member_id: input.toastRequest.memberId,
speaker_name: input.toastRequest.speakerName,
estimated_minutes: input.toastRequest.estimatedMinutes,
status: "pending",
})
.execute()
}
await trx
.updateTable("invitations")
.set({ updated_at: new Date() })
.where("id", "=", input.invitationId)
.execute()
})
}
export async function updateToastRequestStatus(id: string, status: ToastStatus) {
await db
.updateTable("toast_requests")
.set({ status, updated_at: new Date() })
.where("id", "=", id)
.execute()
}
+260
View File
@@ -0,0 +1,260 @@
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
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 async function listInvitations(): Promise<InvitationListItem[]> {
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,
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<InvitationDetail | null> {
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<InvitationDetail | null> {
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()
})
}
+11
View File
@@ -0,0 +1,11 @@
import { db } from "@/lib/db/client"
export async function listInvitationMembers(invitationId: string) {
return db
.selectFrom("invitation_members")
.selectAll()
.where("invitation_id", "=", invitationId)
.orderBy("is_primary_contact", "desc")
.orderBy("created_at", "asc")
.execute()
}
+46
View File
@@ -0,0 +1,46 @@
import { db } from "@/lib/db/client"
import type { PhotoUpload } from "@/lib/db/types"
export async function getPhotoUploadByInvitationId(
invitationId: string,
): Promise<PhotoUpload | null> {
return (
(await db
.selectFrom("photo_uploads")
.selectAll()
.where("invitation_id", "=", invitationId)
.executeTakeFirst()) ?? null
)
}
export async function upsertPhotoUpload(input: {
invitationId: string
bucket: string
objectKey: string
originalFilename: string
contentType: string
sizeBytes: number
}) {
return db
.insertInto("photo_uploads")
.values({
invitation_id: input.invitationId,
bucket: input.bucket,
object_key: input.objectKey,
original_filename: input.originalFilename,
content_type: input.contentType,
size_bytes: input.sizeBytes,
})
.onConflict((oc) =>
oc.column("invitation_id").doUpdateSet({
bucket: input.bucket,
object_key: input.objectKey,
original_filename: input.originalFilename,
content_type: input.contentType,
size_bytes: input.sizeBytes,
updated_at: new Date(),
}),
)
.returningAll()
.executeTakeFirstOrThrow()
}
+120
View File
@@ -0,0 +1,120 @@
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";
export type Timestamp = ColumnType<Date, Date | string | undefined, Date | string>;
export type NullableTimestamp = ColumnType<
Date | null,
Date | string | null | undefined,
Date | string | null
>;
export type DateString = ColumnType<string | null, string | null | undefined, string | null>;
export type Json = JsonValue;
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
export interface InvitationsTable {
id: Generated<string>;
token: string;
display_name: string;
language: string;
contact_email: string | null;
contact_phone: string | null;
max_party_size: number;
allow_member_edits: boolean;
notes: string | null;
created_at: Timestamp;
updated_at: Timestamp;
}
export interface InvitationMembersTable {
id: Generated<string>;
invitation_id: string;
name: string;
is_primary_contact: boolean;
attending: boolean | null;
meal_preference: string | null;
dietary_restrictions: string | null;
notes: string | null;
created_at: Timestamp;
updated_at: Timestamp;
}
export interface InvitationResponsesTable {
id: Generated<string>;
invitation_id: string;
submitted_at: NullableTimestamp;
arrival_date: DateString | null;
departure_date: DateString | null;
stay_location: string | null;
needs_transport_help: boolean | null;
message_to_couple: string | null;
created_at: Timestamp;
updated_at: Timestamp;
}
export interface PhotoUploadsTable {
id: Generated<string>;
invitation_id: string;
bucket: string;
object_key: string;
original_filename: string | null;
content_type: string | null;
size_bytes: number | null;
created_at: Timestamp;
updated_at: Timestamp;
}
export interface ToastRequestsTable {
id: Generated<string>;
invitation_id: string;
member_id: string | null;
speaker_name: string;
estimated_minutes: number | null;
status: string;
created_at: Timestamp;
updated_at: Timestamp;
}
export interface AdminAuditEventsTable {
id: Generated<string>;
admin_email: string;
action: string;
target_type: string | null;
target_id: string | null;
metadata: Json | null;
created_at: Timestamp;
}
export interface Database {
invitations: InvitationsTable;
invitation_members: InvitationMembersTable;
invitation_responses: InvitationResponsesTable;
photo_uploads: PhotoUploadsTable;
toast_requests: ToastRequestsTable;
admin_audit_events: AdminAuditEventsTable;
}
export type Invitation = Selectable<InvitationsTable>;
export type NewInvitation = Insertable<InvitationsTable>;
export type InvitationUpdate = Updateable<InvitationsTable>;
export type InvitationMember = Selectable<InvitationMembersTable>;
export type NewInvitationMember = Insertable<InvitationMembersTable>;
export type InvitationMemberUpdate = Updateable<InvitationMembersTable>;
export type InvitationResponse = Selectable<InvitationResponsesTable>;
export type NewInvitationResponse = Insertable<InvitationResponsesTable>;
export type InvitationResponseUpdate = Updateable<InvitationResponsesTable>;
export type PhotoUpload = Selectable<PhotoUploadsTable>;
export type NewPhotoUpload = Insertable<PhotoUploadsTable>;
export type PhotoUploadUpdate = Updateable<PhotoUploadsTable>;
export type ToastRequest = Selectable<ToastRequestsTable>;
export type NewToastRequest = Insertable<ToastRequestsTable>;
export type ToastRequestUpdate = Updateable<ToastRequestsTable>;