This commit is contained in:
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user