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
+12
View File
@@ -0,0 +1,12 @@
export function getAdminEmails() {
return (process.env.ADMIN_EMAILS ?? "")
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean)
}
export function isAdminEmail(email: string | null | undefined) {
if (!email) return false
return getAdminEmails().includes(email.trim().toLowerCase())
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
const booleanStringSchema = z
.string()
.trim()
.transform((value) => value.toLowerCase())
.pipe(z.enum(["true", "false", "1", "0", "yes", "no"]))
.transform((value) => ["true", "1", "yes"].includes(value));
const serverConfigSchema = z.object({
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(1),
AUTH_GOOGLE_ID: z.string().min(1),
AUTH_GOOGLE_SECRET: z.string().min(1),
ADMIN_EMAILS: z
.string()
.min(1)
.transform((value) =>
value
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean),
),
APP_URL: z.string().url(),
RSVP_DEADLINE: z.iso.date(),
S3_ENDPOINT: z.string().url(),
S3_REGION: z.string().min(1),
S3_BUCKET: z.string().min(1),
S3_ACCESS_KEY_ID: z.string().min(1),
S3_SECRET_ACCESS_KEY: z.string().min(1),
S3_FORCE_PATH_STYLE: booleanStringSchema.default(true),
});
export type ServerConfig = z.infer<typeof serverConfigSchema>;
const buildTimeConfigDefaults = {
DATABASE_URL: "postgres://wedding:wedding@wedding-db:5432/wedding",
AUTH_SECRET: "docker-build-placeholder",
AUTH_GOOGLE_ID: "docker-build-placeholder",
AUTH_GOOGLE_SECRET: "docker-build-placeholder",
ADMIN_EMAILS: "admin@example.com",
APP_URL: "https://wedding.example.com",
RSVP_DEADLINE: "2099-12-31",
S3_ENDPOINT: "http://wedding-minio:9000",
S3_REGION: "us-east-1",
S3_BUCKET: "wedding-uploads",
S3_ACCESS_KEY_ID: "minioadmin",
S3_SECRET_ACCESS_KEY: "minioadmin",
S3_FORCE_PATH_STYLE: "true",
};
export function getServerConfig(env: NodeJS.ProcessEnv = process.env) {
if (env.SKIP_ENV_VALIDATION === "1") {
return serverConfigSchema.parse({ ...buildTimeConfigDefaults, ...env });
}
return serverConfigSchema.parse(env);
}
export function getDatabaseUrl(env: NodeJS.ProcessEnv = process.env) {
if (env.SKIP_ENV_VALIDATION === "1") {
return buildTimeConfigDefaults.DATABASE_URL;
}
return z.string().url().parse(env.DATABASE_URL);
}
+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>;
+19
View File
@@ -0,0 +1,19 @@
export const supportedLanguages = [
{ code: "en", label: "English" },
{ code: "es", label: "Spanish" },
{ code: "de", label: "German" },
] as const
export type SupportedLanguage = (typeof supportedLanguages)[number]["code"]
export const supportedLanguageCodes = supportedLanguages.map(
(language) => language.code,
) as [SupportedLanguage, ...SupportedLanguage[]]
export function getLanguageLabel(code: string) {
return supportedLanguages.find((language) => language.code === code)?.label ?? code
}
export function isSupportedLanguage(code: string): code is SupportedLanguage {
return supportedLanguageCodes.includes(code as SupportedLanguage)
}
+80
View File
@@ -0,0 +1,80 @@
export const de = {
invalid: {
title: "Einladung nicht gefunden",
description: "Wir konnten diese Einladung nicht finden. Bitte prüfe den Link oder kontaktiere uns.",
},
hero: {
eyebrow: "Hochzeitseinladung",
title: "Wir freuen uns sehr, mit dir zu feiern",
greeting: "Hallo, {name}!",
description:
"Bitte bestätige, wer kommen wird, und teile ein paar Reisedetails, damit wir alles gut planen können.",
},
deadline: {
open: "Du kannst diese Antwort bis zum {date} bearbeiten.",
closed: "Die RSVP-Frist ist abgelaufen. Deine Antworten sind jetzt schreibgeschützt.",
},
sections: {
wedding: "Hochzeitsdetails",
rsvp: "RSVP",
travel: "Reisedetails",
interactions: "Mach es persönlich",
},
wedding: {
dateLabel: "Datum",
dateValue: "Das Hochzeitsdatum wird bald ergänzt",
locationLabel: "Ort",
locationValue: "Details zum Veranstaltungsort folgen bald",
accommodationLabel: "Unterkunft",
accommodationValue: "Empfehlungen für Unterkünfte in der Nähe werden später ergänzt.",
},
form: {
attending: "Nimmt teil?",
yes: "Ja",
no: "Nein",
mealPreference: "Essenswunsch",
mealPlaceholder: "Essen auswählen",
dietaryRestrictions: "Ernährungseinschränkungen",
noRestrictions: "Keine Einschränkungen",
arrivalDate: "Anreisedatum",
departureDate: "Abreisedatum",
stayLocation: "Wo übernachtest du?",
needsTransportHelp: "Brauchst du Hilfe beim Transport?",
messageToCouple: "Nachricht an das Paar",
submit: "RSVP speichern",
submitted: "Danke — deine RSVP wurde gespeichert.",
},
meals: {
meat: "Fleisch",
fish: "Fisch",
vegetarian: "Vegetarisch",
vegan: "Vegan",
kids: "Kindermenü",
other: "Andere",
},
errors: {
generic: "Bitte prüfe das Formular und versuche es erneut.",
closed: "Die RSVP-Frist ist abgelaufen.",
},
interactions: {
intro: "Diese Frage ist optional und hilft uns, den Ablauf der Feier zu planen.",
toastTitle: "Toast oder Rede",
toastDescription: "Möchte jemand aus deiner Gruppe ein paar Worte sagen? Das ist optional und wird von uns geplant.",
wantsToast: "Jemand möchte sprechen",
speakerName: "Name der sprechenden Person",
estimatedMinutes: "Ungefähre Minuten",
},
upload: {
title: "Foto für die Hochzeitsüberraschung",
description: "Lade ein Foto von dir oder deiner Gruppe für eine Hochzeitsaktivität hoch. Du kannst es bis zur Frist ersetzen.",
current: "Aktuell hochgeladenes Foto: {filename}",
choose: "Bild auswählen",
upload: "Foto hochladen",
replace: "Foto ersetzen",
uploading: "Wird hochgeladen...",
success: "Foto erfolgreich hochgeladen.",
error: "Das Foto konnte nicht hochgeladen werden. Bitte versuche es erneut.",
disabled: "Foto-Uploads sind geschlossen, da die RSVP-Frist abgelaufen ist.",
requirements: "JPG, PNG oder WebP. Maximal 10 MB.",
},
} as const
+80
View File
@@ -0,0 +1,80 @@
export const en = {
invalid: {
title: "Invitation not found",
description: "We could not find this invitation. Please check the link or contact us.",
},
hero: {
eyebrow: "Wedding invitation",
title: "We cannot wait to celebrate with you",
greeting: "Hello, {name}!",
description:
"Please confirm who will attend and share a few travel details so we can plan everything with care.",
},
deadline: {
open: "You can edit this response until {date}.",
closed: "The RSVP deadline has passed. Your answers are now read-only.",
},
sections: {
wedding: "Wedding details",
rsvp: "RSVP",
travel: "Travel details",
interactions: "Make it personal",
},
wedding: {
dateLabel: "Date",
dateValue: "Wedding date coming soon",
locationLabel: "Location",
locationValue: "Venue details coming soon",
accommodationLabel: "Accommodation",
accommodationValue: "Nearby stay recommendations will be added later.",
},
form: {
attending: "Attending?",
yes: "Yes",
no: "No",
mealPreference: "Meal preference",
mealPlaceholder: "Select a meal",
dietaryRestrictions: "Dietary restrictions",
noRestrictions: "No restrictions",
arrivalDate: "Arrival date",
departureDate: "Departure date",
stayLocation: "Where are you staying?",
needsTransportHelp: "Do you need transportation help?",
messageToCouple: "Message to the couple",
submit: "Save RSVP",
submitted: "Thank you — your RSVP was saved.",
},
meals: {
meat: "Meat",
fish: "Fish",
vegetarian: "Vegetarian",
vegan: "Vegan",
kids: "Kids menu",
other: "Other",
},
errors: {
generic: "Please check the RSVP form and try again.",
closed: "The RSVP deadline has passed.",
},
interactions: {
intro: "This question is optional and helps us plan the celebration schedule.",
toastTitle: "Toast or speech",
toastDescription: "Would someone in your group like to say a few words? This is optional and will be scheduled by us.",
wantsToast: "Someone would like to speak",
speakerName: "Speaker name",
estimatedMinutes: "Estimated minutes",
},
upload: {
title: "Wedding surprise photo",
description: "Upload one photo of yourself or your group for a wedding activity. You can replace it until the deadline.",
current: "Current photo uploaded: {filename}",
choose: "Choose image",
upload: "Upload photo",
replace: "Replace photo",
uploading: "Uploading...",
success: "Photo uploaded successfully.",
error: "We could not upload the photo. Please try again.",
disabled: "Photo uploads are closed because the RSVP deadline has passed.",
requirements: "JPG, PNG, or WebP. Maximum 10 MB.",
},
} as const
+80
View File
@@ -0,0 +1,80 @@
export const es = {
invalid: {
title: "Invitación no encontrada",
description: "No pudimos encontrar esta invitación. Revisa el enlace o contáctanos.",
},
hero: {
eyebrow: "Invitación de boda",
title: "Nos emociona mucho celebrar contigo",
greeting: "¡Hola, {name}!",
description:
"Confirma quién asistirá y comparte algunos detalles del viaje para que podamos planificar todo con cariño.",
},
deadline: {
open: "Puedes editar esta respuesta hasta el {date}.",
closed: "La fecha límite para confirmar asistencia ya pasó. Tus respuestas están en modo lectura.",
},
sections: {
wedding: "Detalles de la boda",
rsvp: "Confirmación",
travel: "Detalles del viaje",
interactions: "Hazlo personal",
},
wedding: {
dateLabel: "Fecha",
dateValue: "La fecha de la boda se agregará pronto",
locationLabel: "Lugar",
locationValue: "Los detalles del lugar se agregarán pronto",
accommodationLabel: "Alojamiento",
accommodationValue: "Pronto agregaremos recomendaciones para quedarse cerca.",
},
form: {
attending: "¿Asiste?",
yes: "Sí",
no: "No",
mealPreference: "Preferencia de comida",
mealPlaceholder: "Selecciona una opción",
dietaryRestrictions: "Restricciones alimentarias",
noRestrictions: "Sin restricciones",
arrivalDate: "Fecha de llegada",
departureDate: "Fecha de salida",
stayLocation: "¿Dónde te hospedarás?",
needsTransportHelp: "¿Necesitas ayuda con transporte?",
messageToCouple: "Mensaje para la pareja",
submit: "Guardar confirmación",
submitted: "Gracias — guardamos tu confirmación.",
},
meals: {
meat: "Carne",
fish: "Pescado",
vegetarian: "Vegetariano",
vegan: "Vegano",
kids: "Menú infantil",
other: "Otro",
},
errors: {
generic: "Revisa el formulario e inténtalo de nuevo.",
closed: "La fecha límite para confirmar asistencia ya pasó.",
},
interactions: {
intro: "Esta pregunta es opcional y nos ayuda a organizar el horario de la celebración.",
toastTitle: "Brindis o discurso",
toastDescription: "¿Alguien de tu grupo quiere decir unas palabras? Es opcional y nosotros lo organizaremos.",
wantsToast: "Alguien quiere hablar",
speakerName: "Nombre de la persona",
estimatedMinutes: "Minutos aproximados",
},
upload: {
title: "Foto para la sorpresa de la boda",
description: "Sube una foto tuya o de tu grupo para una actividad de la boda. Puedes reemplazarla hasta la fecha límite.",
current: "Foto actual subida: {filename}",
choose: "Elegir imagen",
upload: "Subir foto",
replace: "Reemplazar foto",
uploading: "Subiendo...",
success: "Foto subida correctamente.",
error: "No pudimos subir la foto. Inténtalo de nuevo.",
disabled: "La subida de fotos está cerrada porque ya pasó la fecha límite.",
requirements: "JPG, PNG o WebP. Máximo 10 MB.",
},
} as const
+11
View File
@@ -0,0 +1,11 @@
import { de } from "./de"
import { en } from "./en"
import { es } from "./es"
export const dictionaries = {
en,
es,
de,
} as const
export type GuestDictionary = typeof en
+10
View File
@@ -0,0 +1,10 @@
import { dictionaries } from "@/lib/i18n/dictionaries"
import { isSupportedLanguage } from "@/lib/i18n/config"
export function getGuestDictionary(language: string) {
if (isSupportedLanguage(language)) {
return dictionaries[language]
}
return dictionaries.en
}
+14
View File
@@ -0,0 +1,14 @@
export function getRsvpDeadline() {
const value = process.env.RSVP_DEADLINE
if (!value) return null
return value
}
export function isRsvpClosed(now = new Date()) {
const deadline = getRsvpDeadline()
if (!deadline) return false
const deadlineEnd = new Date(`${deadline}T23:59:59.999Z`)
return now > deadlineEnd
}
+21
View File
@@ -0,0 +1,21 @@
import { S3Client } from "@aws-sdk/client-s3"
import { getServerConfig } from "@/lib/config"
export function getS3Client() {
const config = getServerConfig()
return new S3Client({
endpoint: config.S3_ENDPOINT,
region: config.S3_REGION,
forcePathStyle: config.S3_FORCE_PATH_STYLE,
credentials: {
accessKeyId: config.S3_ACCESS_KEY_ID,
secretAccessKey: config.S3_SECRET_ACCESS_KEY,
},
})
}
export function getUploadBucket() {
return getServerConfig().S3_BUCKET
}
+74
View File
@@ -0,0 +1,74 @@
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { nanoid } from "nanoid"
import { getS3Client, getUploadBucket } from "@/lib/storage/s3"
function safeFilename(filename: string) {
const normalized = filename
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9._-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
return normalized || "upload"
}
export function createInvitationUploadKey(invitationId: string, filename: string) {
return `invitations/${invitationId}/${nanoid(12)}-${safeFilename(filename)}`
}
export function isObjectKeyForInvitation(objectKey: string, invitationId: string) {
return objectKey.startsWith(`invitations/${invitationId}/`)
}
export async function createPresignedUploadUrl({
objectKey,
contentType,
sizeBytes,
}: {
objectKey: string
contentType: string
sizeBytes: number
}) {
const client = getS3Client()
const bucket = getUploadBucket()
return getSignedUrl(
client,
new PutObjectCommand({
Bucket: bucket,
Key: objectKey,
ContentType: contentType,
ContentLength: sizeBytes,
}),
{ expiresIn: 60 * 5 },
)
}
export async function createPresignedDownloadUrl(objectKey: string) {
const client = getS3Client()
const bucket = getUploadBucket()
return getSignedUrl(
client,
new GetObjectCommand({
Bucket: bucket,
Key: objectKey,
}),
{ expiresIn: 60 * 10 },
)
}
export async function deleteUploadObject(objectKey: string) {
const client = getS3Client()
const bucket = getUploadBucket()
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: objectKey,
}),
)
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod"
const optionalPositiveInteger = z.preprocess(
(value) => {
if (typeof value !== "string") return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
},
z.coerce.number().int().positive().nullable(),
)
export const toastRequestSchema = z.object({
memberId: z.uuid().nullable(),
speakerName: z.string().trim().min(1).max(160),
estimatedMinutes: optionalPositiveInteger,
})
export const interactionSubmissionSchema = z.object({
invitationId: z.uuid(),
toastRequest: toastRequestSchema.nullable(),
})
export const toastStatusSchema = z.enum(["pending", "accepted", "declined", "scheduled"])
export type InteractionSubmissionInput = z.infer<typeof interactionSubmissionSchema>
export type ToastStatus = z.infer<typeof toastStatusSchema>
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod"
import { supportedLanguageCodes } from "@/lib/i18n/config"
const optionalText = z.preprocess(
(value) => {
if (typeof value !== "string") return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
},
z.string().nullable(),
)
const optionalEmail = z.preprocess(
(value) => {
if (typeof value !== "string") return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
},
z.email().nullable(),
)
export const createInvitationSchema = z
.object({
displayName: z.string().trim().min(1, "Display name is required"),
language: z.enum(supportedLanguageCodes, {
error: "Select a supported language",
}),
contactEmail: optionalEmail.optional().default(null),
contactPhone: optionalText.optional().default(null),
maxPartySize: z.coerce
.number()
.int("Party size must be a whole number")
.min(1, "Party size must be at least 1"),
allowMemberEdits: z.coerce.boolean().default(false),
notes: optionalText.optional().default(null),
members: z
.array(z.string().trim().min(1))
.min(1, "Add at least one invitation member"),
})
.refine((value) => value.members.length <= value.maxPartySize, {
message: "Members cannot exceed max party size",
path: ["members"],
})
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod"
export const mealPreferenceSchema = z.enum([
"meat",
"fish",
"vegetarian",
"vegan",
"kids",
"other",
])
const optionalText = z.preprocess(
(value) => {
if (typeof value !== "string") return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
},
z.string().nullable(),
)
const optionalDate = z.preprocess(
(value) => {
if (typeof value !== "string") return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
},
z.iso.date().nullable(),
)
const memberResponseSchema = z
.object({
memberId: z.uuid(),
attending: z.boolean(),
mealPreference: mealPreferenceSchema.nullable(),
dietaryRestrictions: optionalText,
})
.refine((value) => !value.attending || value.mealPreference !== null, {
message: "Meal preference is required for attending guests",
path: ["mealPreference"],
})
export const submitInvitationResponseSchema = z.object({
token: z.string().min(1),
members: z.array(memberResponseSchema).min(1),
arrivalDate: optionalDate,
departureDate: optionalDate,
stayLocation: optionalText,
needsTransportHelp: z.boolean().nullable(),
messageToCouple: optionalText,
})
export type SubmitInvitationResponseInput = z.infer<typeof submitInvitationResponseSchema>
export type MealPreference = z.infer<typeof mealPreferenceSchema>
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod"
export const allowedImageContentTypes = ["image/jpeg", "image/png", "image/webp"] as const
export const maxUploadSizeBytes = 10 * 1024 * 1024
export const presignUploadSchema = z.object({
token: z.string().min(1),
filename: z.string().trim().min(1).max(255),
contentType: z.enum(allowedImageContentTypes),
sizeBytes: z.number().int().positive().max(maxUploadSizeBytes),
})
export const completeUploadSchema = presignUploadSchema.extend({
objectKey: z.string().min(1),
})
export type PresignUploadInput = z.infer<typeof presignUploadSchema>
export type CompleteUploadInput = z.infer<typeof completeUploadSchema>