From 4ced446c9c1e518193630e1a76299e64599fa0da Mon Sep 17 00:00:00 2001 From: Raul Lugo Date: Mon, 10 Aug 2026 17:55:26 +0200 Subject: [PATCH] initial commit --- .dockerignore | 34 + .env.example | 30 + .github/workflows/deployment.yaml | 69 + .gitignore | 1 + AGENTS.md | 5 - CLAUDE.md | 1 - Dockerfile | 42 + app/admin/guests/[id]/page.tsx | 350 ++ app/admin/guests/new/page.tsx | 201 + app/admin/guests/page.tsx | 139 + app/admin/layout.tsx | 87 + app/admin/page.tsx | 80 + app/api/auth/[...nextauth]/route.ts | 3 + app/api/uploads/complete/route.ts | 48 + app/api/uploads/presign/route.ts | 41 + app/auth-error/page.tsx | 38 + app/globals.css | 80 +- app/invite/[token]/page.tsx | 423 +++ app/sign-in/page.tsx | 53 + auth.ts | 23 + components.json | 22 + components/admin/copy-invite-button.tsx | 21 + components/invite/photo-upload.tsx | 153 + components/ui/alert.tsx | 66 + components/ui/avatar.tsx | 109 + components/ui/badge.tsx | 48 + components/ui/button.tsx | 64 + components/ui/card.tsx | 92 + components/ui/checkbox.tsx | 32 + components/ui/dropdown-menu.tsx | 257 ++ components/ui/input.tsx | 21 + components/ui/label.tsx | 24 + components/ui/select.tsx | 190 + components/ui/separator.tsx | 28 + components/ui/table.tsx | 116 + components/ui/textarea.tsx | 18 + deployment/minio-cors.json | 9 + docker-compose.yml | 144 + lib/admin-auth.ts | 12 + lib/config.ts | 66 + lib/db/client.ts | 25 + lib/db/queries/interactions.ts | 56 + lib/db/queries/invitations.ts | 260 ++ lib/db/queries/members.ts | 11 + lib/db/queries/uploads.ts | 46 + lib/db/types.ts | 120 + lib/i18n/config.ts | 19 + lib/i18n/dictionaries/de.ts | 80 + lib/i18n/dictionaries/en.ts | 80 + lib/i18n/dictionaries/es.ts | 80 + lib/i18n/dictionaries/index.ts | 11 + lib/i18n/get-dictionary.ts | 10 + lib/rsvp-deadline.ts | 14 + lib/storage/s3.ts | 21 + lib/storage/uploads.ts | 74 + lib/utils.ts | 6 + lib/validation/interactions.ts | 27 + lib/validation/invitation.ts | 48 + lib/validation/response.ts | 55 + lib/validation/upload.ts | 18 + migrations/0001_initial_schema.sql | 124 + .../0002_remove_discarded_guest_fields.sql | 10 + next.config.ts | 5 +- package-lock.json | 3279 ++++++++++++++++- package.json | 20 +- plans/milestone-01-infra-db.md | 106 + plans/milestone-02-auth-admin-shell.md | 82 + plans/milestone-03-admin-invitations.md | 106 + plans/milestone-04-guest-rsvp-i18n.md | 97 + plans/milestone-05-guest-interactions.md | 84 + plans/milestone-06-minio-uploads.md | 100 + plans/wedding-bootstrap-plan.md | 883 +++++ proxy.ts | 5 + scripts/migrate-sql.mjs | 70 + 74 files changed, 9242 insertions(+), 30 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/deployment.yaml delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 app/admin/guests/[id]/page.tsx create mode 100644 app/admin/guests/new/page.tsx create mode 100644 app/admin/guests/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/uploads/complete/route.ts create mode 100644 app/api/uploads/presign/route.ts create mode 100644 app/auth-error/page.tsx create mode 100644 app/invite/[token]/page.tsx create mode 100644 app/sign-in/page.tsx create mode 100644 auth.ts create mode 100644 components.json create mode 100644 components/admin/copy-invite-button.tsx create mode 100644 components/invite/photo-upload.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 deployment/minio-cors.json create mode 100644 docker-compose.yml create mode 100644 lib/admin-auth.ts create mode 100644 lib/config.ts create mode 100644 lib/db/client.ts create mode 100644 lib/db/queries/interactions.ts create mode 100644 lib/db/queries/invitations.ts create mode 100644 lib/db/queries/members.ts create mode 100644 lib/db/queries/uploads.ts create mode 100644 lib/db/types.ts create mode 100644 lib/i18n/config.ts create mode 100644 lib/i18n/dictionaries/de.ts create mode 100644 lib/i18n/dictionaries/en.ts create mode 100644 lib/i18n/dictionaries/es.ts create mode 100644 lib/i18n/dictionaries/index.ts create mode 100644 lib/i18n/get-dictionary.ts create mode 100644 lib/rsvp-deadline.ts create mode 100644 lib/storage/s3.ts create mode 100644 lib/storage/uploads.ts create mode 100644 lib/utils.ts create mode 100644 lib/validation/interactions.ts create mode 100644 lib/validation/invitation.ts create mode 100644 lib/validation/response.ts create mode 100644 lib/validation/upload.ts create mode 100644 migrations/0001_initial_schema.sql create mode 100644 migrations/0002_remove_discarded_guest_fields.sql create mode 100644 plans/milestone-01-infra-db.md create mode 100644 plans/milestone-02-auth-admin-shell.md create mode 100644 plans/milestone-03-admin-invitations.md create mode 100644 plans/milestone-04-guest-rsvp-i18n.md create mode 100644 plans/milestone-05-guest-interactions.md create mode 100644 plans/milestone-06-minio-uploads.md create mode 100644 plans/wedding-bootstrap-plan.md create mode 100644 proxy.ts create mode 100644 scripts/migrate-sql.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c296ebc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Dependencies and generated output +node_modules/ +.next/ +out/ +build/ +coverage/ + +# Version control and deployment metadata +.git/ +.github/ +.vercel/ + +# Local environment files and secrets +.env +.env.* +!.env.example + +# Logs, caches, and local tooling +*.log +*.tsbuildinfo +.cache/ +.turbo/ +.vscode/ +.idea/ +.DS_Store + +# Files not required by the image build +Dockerfile* +.dockerignore +README.md +AGENTS.md +CLAUDE.md +opencode.json +plans/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e21985d --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +POSTGRES_DB=wedding +POSTGRES_USER=wedding +POSTGRES_PASSWORD=wedding +DATABASE_URL=postgres://wedding:wedding@wedding-db:5432/wedding + +AUTH_SECRET=change-me +AUTH_GOOGLE_ID=change-me +AUTH_GOOGLE_SECRET=change-me +ADMIN_EMAILS=person-one@example.com,person-two@example.com + +APP_URL=https://wedding.example.com +AUTH_URL=https://wedding.example.com +RSVP_DEADLINE=2026-08-01 + +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +S3_ENDPOINT=https://wedding-minio.example.com +S3_REGION=us-east-1 +S3_BUCKET=wedding-uploads +S3_ACCESS_KEY_ID=minioadmin +S3_SECRET_ACCESS_KEY=minioadmin +S3_FORCE_PATH_STYLE=true + +TRAEFIK_NETWORK=traefik +TRAEFIK_ENTRYPOINT=websecure +TRAEFIK_CERT_RESOLVER=letsencrypt +WEDDING_APP_HOST=wedding.example.com +WEDDING_MINIO_API_HOST=wedding-minio.example.com +WEDDING_MINIO_CONSOLE_HOST=wedding-minio-console.example.com + diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml new file mode 100644 index 0000000..6fb6ee0 --- /dev/null +++ b/.github/workflows/deployment.yaml @@ -0,0 +1,69 @@ +name: Build & Deployment + +on: + push: + branches: + - "main" + +env: + REMOTE_WORKING_DIRECTORY: compose/wedding-website + +jobs: + build: + name: Build & Deploy + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + path: repository + + - name: Copy Repository over SSH + uses: garygrossgarten/github-action-scp@release + with: + local: repository + remote: ${{ env.REMOTE_WORKING_DIRECTORY }} + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + privateKey: ${{ secrets.SSH_KEY }} + rmRemote: true + + - name: Build ENV + run: | + echo "${{ secrets.WEDDING_WEBSITE_ENV }}" | base64 -d > .env + + - name: Copy ENV over SSH + uses: garygrossgarten/github-action-scp@release + with: + local: .env + remote: ${{ env.REMOTE_WORKING_DIRECTORY }}/.env + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + privateKey: ${{ secrets.SSH_KEY }} + + - name: Docker Compose Commands + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + script_stop: true + script: | + cd ${{ env.REMOTE_WORKING_DIRECTORY }} + docker compose build + docker compose up -d wedding-db wedding-minio wedding-minio-init + docker compose up --force-recreate --no-deps --abort-on-container-exit --exit-code-from wedding-migrate wedding-migrate + docker compose up -d wedding-app + docker compose images + docker image prune --all --force + + - name: Clean .env + if: always() + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + script: | + cd ${{ env.REMOTE_WORKING_DIRECTORY }} + rm -f .env diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 8bd0e39..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0035afe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 + +ARG NODE_VERSION=24.13.0-alpine3.23 + +FROM node:${NODE_VERSION} AS dependencies +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --no-audit --no-fund + +FROM node:${NODE_VERSION} AS builder +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 \ + SKIP_ENV_VALIDATION=1 + +COPY --from=dependencies /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:${NODE_VERSION} AS runner +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 + +RUN mkdir .next && chown node:node .next + +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/scripts ./scripts +COPY --from=builder --chown=node:node /app/migrations ./migrations + +USER node + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/app/admin/guests/[id]/page.tsx b/app/admin/guests/[id]/page.tsx new file mode 100644 index 0000000..e43d6d0 --- /dev/null +++ b/app/admin/guests/[id]/page.tsx @@ -0,0 +1,350 @@ +import Link from "next/link" +import { notFound } from "next/navigation" + +import { CopyInviteButton } from "@/components/admin/copy-invite-button" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + getInvitationInteractions, + updateToastRequestStatus, +} from "@/lib/db/queries/interactions" +import { getInvitationDetail } from "@/lib/db/queries/invitations" +import { getPhotoUploadByInvitationId } from "@/lib/db/queries/uploads" +import { getLanguageLabel } from "@/lib/i18n/config" +import { createPresignedDownloadUrl } from "@/lib/storage/uploads" +import { toastStatusSchema } from "@/lib/validation/interactions" + +export const dynamic = "force-dynamic" + +function getInviteUrl(token: string) { + return new URL(`/invite/${token}`, process.env.APP_URL ?? "http://localhost:3000").toString() +} + +function formatDate(date: Date | null | undefined) { + if (!date) return "—" + + return new Intl.DateTimeFormat("en", { + dateStyle: "medium", + timeStyle: "short", + }).format(date) +} + +function formatBoolean(value: boolean | null) { + if (value === true) return "Yes" + if (value === false) return "No" + return "Pending" +} + +function formatDateOnly(value: Date | string | null | undefined) { + if (!value) return "—" + + if (value instanceof Date) { + return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format(value) + } + + return value +} + +async function updateToastStatusAction(formData: FormData) { + "use server" + + const toastId = formData.get("toastId")?.toString() + const status = toastStatusSchema.safeParse(formData.get("status")) + + if (!toastId || !status.success) return + + await updateToastRequestStatus(toastId, status.data) +} + +export default async function GuestDetailPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ created?: string }> +}) { + const [{ id }, { created }] = await Promise.all([params, searchParams]) + const [detail, interactions, photoUpload] = await Promise.all([ + getInvitationDetail(id), + getInvitationInteractions(id), + getPhotoUploadByInvitationId(id), + ]) + + if (!detail) notFound() + + const inviteUrl = getInviteUrl(detail.invitation.token) + const photoUrl = photoUpload ? await createPresignedDownloadUrl(photoUpload.object_key) : null + + return ( +
+
+
+
+ {detail.derivedStatus} + {getLanguageLabel(detail.invitation.language)} +
+

+ {detail.invitation.display_name} +

+

Invitation details and member list.

+
+
+ + +
+
+ + {created ? ( + + Invitation created + + Copy the private invite link below and share it only with this invitation group. + + + ) : null} + +
+ + + Invite link + Private URL for this invitation group. + + + + {inviteUrl} + + + + + + + + Metadata + Admin-managed invitation fields. + + +
+ Contact email + {detail.invitation.contact_email ?? "—"} +
+
+ Contact phone + {detail.invitation.contact_phone ?? "—"} +
+
+ Max party size + {detail.invitation.max_party_size} +
+
+ Member edits + {detail.invitation.allow_member_edits ? "Allowed" : "Disabled"} +
+
+ Updated + {formatDate(detail.invitation.updated_at)} +
+
+
+
+ + + + Members + + Individual attendance and meal details from the guest RSVP. + + + + + + + Name + Primary + Attending + Meal + Dietary restrictions + + + + {detail.members.map((member) => ( + + {member.name} + {member.is_primary_contact ? "Yes" : "No"} + {formatBoolean(member.attending)} + {member.meal_preference ?? "—"} + {member.dietary_restrictions ?? "—"} + + ))} + +
+
+
+ + + + Travel and RSVP details + Group-level logistics submitted by the invitation group. + + + {detail.response ? ( +
+
+

Submitted

+

{formatDate(detail.response.submitted_at)}

+
+
+

Needs transport help

+

+ {formatBoolean(detail.response.needs_transport_help)} +

+
+
+

Arrival date

+

{formatDateOnly(detail.response.arrival_date)}

+
+
+

Departure date

+

{formatDateOnly(detail.response.departure_date)}

+
+
+

Staying at

+

{detail.response.stay_location ?? "—"}

+
+
+

Message to the couple

+

+ {detail.response.message_to_couple ?? "—"} +

+
+
+ ) : ( +

No RSVP response submitted yet.

+ )} +
+
+ + + + Admin notes + + +

+ {detail.invitation.notes ?? "No notes yet."} +

+
+
+ + + + Wedding surprise photo + One image uploaded by this invitation group. + + + {photoUpload && photoUrl ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {photoUpload.original_filename +
+
+
+

Filename

+

{photoUpload.original_filename ?? "—"}

+
+
+

Content type

+

{photoUpload.content_type ?? "—"}

+
+
+

Size

+

+ {photoUpload.size_bytes + ? `${(photoUpload.size_bytes / 1024 / 1024).toFixed(2)} MB` + : "—"} +

+
+ +
+
+ ) : ( +

No photo uploaded yet.

+ )} +
+
+ + + + Toast requests + Optional speech requests. Status is managed by admins. + + + {interactions.toastRequests.length > 0 ? ( +
+ {interactions.toastRequests.map((toast) => ( +
+
+
+
+

{toast.speaker_name}

+ {toast.status} +
+

+ Estimated time: {toast.estimated_minutes ?? "—"} minutes +

+
+
+ {(["pending", "accepted", "declined", "scheduled"] as const).map( + (status) => ( +
+ + + +
+ ), + )} +
+
+
+ ))} +
+ ) : ( +

No toast requests submitted yet.

+ )} +
+
+ +
+ ) +} diff --git a/app/admin/guests/new/page.tsx b/app/admin/guests/new/page.tsx new file mode 100644 index 0000000..bc4b252 --- /dev/null +++ b/app/admin/guests/new/page.tsx @@ -0,0 +1,201 @@ +import Link from "next/link" +import { redirect } from "next/navigation" + +import { createInvitationWithMembers } from "@/lib/db/queries/invitations" +import { supportedLanguages } from "@/lib/i18n/config" +import { createInvitationSchema } from "@/lib/validation/invitation" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" + +export const dynamic = "force-dynamic" + +function getMembersFromFormData(formData: FormData) { + return Array.from({ length: 8 }, (_, index) => + formData.get(`member-${index}`)?.toString().trim(), + ).filter((value): value is string => Boolean(value)) +} + +async function createInvitationAction(formData: FormData) { + "use server" + + const parsed = createInvitationSchema.safeParse({ + displayName: formData.get("displayName"), + language: formData.get("language"), + contactEmail: formData.get("contactEmail"), + contactPhone: formData.get("contactPhone"), + maxPartySize: formData.get("maxPartySize"), + allowMemberEdits: formData.has("allowMemberEdits"), + notes: formData.get("notes"), + members: getMembersFromFormData(formData), + }) + + if (!parsed.success) { + const message = parsed.error.issues[0]?.message ?? "Invalid invitation data" + redirect(`/admin/guests/new?error=${encodeURIComponent(message)}`) + } + + const invitation = await createInvitationWithMembers(parsed.data) + redirect(`/admin/guests/${invitation.id}?created=1`) +} + +export default async function NewGuestPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string }> +}) { + const { error } = await searchParams + + return ( +
+
+
+

New invitation

+

+ Create a private invite link and define the people included in it. +

+
+ +
+ + {error ? ( + + Could not create invitation + {error} + + ) : null} + +
+ + + Invitation details + + The selected language will control the guest-facing invitation page. + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +