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. + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +