Initial commit: E-Invite, a self-hosted Evite-lite

Docker-ready Next.js + Prisma/Postgres e-invitation app: themed invite
pages, RSVP, comments, share links, email + SMS invites with
reply-to-RSVP-by-text, co-hosts, photo dropbox, and browser push
notifications.
This commit is contained in:
chris 2026-07-11 10:11:12 -04:00
commit 2c78112a4f
109 changed files with 10180 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
.next
.git
*.log
.env
.env.local

43
.env.example Normal file
View File

@ -0,0 +1,43 @@
# Postgres connection used by Prisma
DATABASE_URL="postgresql://einvite:einvite@postgres:5432/einvite?schema=public"
# Base URL the app is served from (used to build share links & email links)
APP_URL="http://localhost:3100"
# Secret used to sign session cookies - set to a long random string in production
SESSION_SECRET="change-me-to-a-long-random-string"
# Set to "true" once the app is served over HTTPS (e.g. behind a reverse proxy
# terminating TLS) so session cookies get the Secure flag. Leave "false" for
# the default plain-HTTP docker-compose setup, otherwise logins will silently
# stop working.
COOKIE_SECURE="false"
# SMTP settings for outbound email. Defaults point at the bundled Mailpit
# dev catcher (web UI at http://localhost:8025). Swap these for a real
# provider (SES, Postmark, Gmail, etc.) in production - no code changes needed.
SMTP_HOST="mailpit"
SMTP_PORT="1025"
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM="E-Invite <invites@example.com>"
SMTP_SECURE="false"
# Browser push notifications (optional - hosts opt in per-device from
# Settings). Generate your own pair with:
# node -e "console.log(JSON.stringify(require('web-push').generateVAPIDKeys(),null,2))"
# The public key is baked into the client bundle at *build* time (not just
# runtime), so changing it requires a rebuild (`docker compose up --build`).
# Leave both blank to disable the feature entirely - push notifications are
# never required, email notifications always work regardless.
NEXT_PUBLIC_VAPID_PUBLIC_KEY="BJBjEBsaKn8vaj6DiqpuUMhriDgqgbgiilG0G6ejbTlu8PTfnS6DT4WHUXkBXXOZCnpc6s5TQX_M4pkrhC6y-Cc"
VAPID_PRIVATE_KEY="VsCabG9YTAo9mbL9qGhqsf7JnBfwB6s4JY4us1gY9Y0"
VAPID_CONTACT_EMAIL="admin@example.com"
# SMS invites + reply-to-RSVP-by-text (optional). Leave all three blank to
# disable - guests can still always be invited/RSVP by email. Get these from
# https://console.twilio.com (Account SID + Auth Token on the dashboard, the
# phone number from Phone Numbers > Manage > Active Numbers).
TWILIO_ACCOUNT_SID=""
TWILIO_AUTH_TOKEN=""
TWILIO_PHONE_NUMBER=""

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules
.next
out
build
.env
.env.local
*.log
.DS_Store
*.swp
*.swo
/public/uploads/*
!/public/uploads/.gitkeep

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
FROM node:20-alpine AS base
WORKDIR /app
RUN apk add --no-cache openssl
# ---- deps: install full dependency tree (needed to build) ----
FROM base AS deps
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
# ---- builder: compile the Next.js app ----
FROM base AS builder
# Next.js inlines NEXT_PUBLIC_* vars into the client bundle at build time, so
# this has to be a build arg, not just a runtime environment variable.
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=$NEXT_PUBLIC_VAPID_PUBLIC_KEY
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ---- runner: production image ----
FROM base AS runner
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci --omit=dev
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.js ./next.config.js
COPY docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["./docker/entrypoint.sh"]
CMD ["npm", "start"]

187
README.md Normal file
View File

@ -0,0 +1,187 @@
# E-Invite
A stripped-down, self-hosted alternative to Evite: create themed online invitations, share a
single link with guests, collect RSVPs, and let guests leave comments — all Docker-ready.
## Features
- **Host accounts** — email + password signup/login, forgot/reset password via email.
- **Themed invites** — pick from several built-in themes (colors + fonts) per event.
- **Share links** — every event gets a unique, unguessable public URL. No guest accounts needed.
- **RSVP** — guests respond yes/no/maybe with a headcount and optional note; host and guest both
get a confirmation email.
- **Comments / well-wishes** — guests can leave a public message on the invite; hosts can hide or
delete any comment.
- **Email invites** — hosts can paste in a list of guest emails and send the invite link to all of
them at once.
- **Co-hosts** — invite another person by email to help manage an event (edit details, view RSVPs,
moderate comments); they accept via emailed link and sign up/log in. Only the original host can
delete the event or manage who else has access.
- **End times, food/drinks** — optional event end time (shown as a date range) and simple
food/drinks-available badges on the invite.
- **Photo dropbox** — guests can upload photos once the event starts; hosts can set an auto-close
time or close uploads manually at any time, and can hide/delete individual photos.
- **Email verification** — new accounts get a verification email; a dismissable-once-verified
banner nudges unverified hosts, but doesn't block using the app.
- **Notification preferences + browser push** — hosts can toggle which events (RSVP/comment/co-host
accepted) email them, and separately opt in to browser push notifications per-device from
Settings. See "Browser push notifications" below for the HTTPS caveat.
- **Text (SMS) invites + reply-to-RSVP** — the guest list accepts phone numbers alongside emails;
phone-based guests get a text with the invite link and can also just reply YES/NO/MAYBE (with an
optional headcount, e.g. "YES 3") directly to RSVP, no link click needed. Requires a Twilio
account — see "Text invites via Twilio" below. Fully optional; email-only hosts see no change.
### Robustness
- **RSVP dedup** — resubmitting an RSVP with the same email updates the existing response instead
of creating a duplicate row, so headcounts stay accurate.
- **Rate limiting** — in-memory limits on RSVP/comment submission, login, signup, forgot-password
and invite-sending guard against spam and brute-force/credential-stuffing attempts. Login/RSVP
limits combine IP (when a reverse proxy sets `X-Forwarded-For`) with a more specific key (email,
event) so a shared/unknown IP can't lock out every visitor.
- **Honeypot** — the public RSVP and comment forms include a hidden field real users never fill;
submissions that fill it are silently dropped as bots.
- **Resilient email** — RSVP/comment/event actions never fail because SMTP is down or
misconfigured; the guest-facing action still succeeds (data is already saved) and the failure is
logged server-side. The one exception is the host-facing "send invites" action, which reports
actual delivery failures back to the host.
- **Health check**`GET /api/health` verifies DB connectivity; wired into both the Dockerfile
`HEALTHCHECK` and the `app` service's compose healthcheck.
- **Tests**`npm test` runs the Vitest suite (validation schemas, rate limiter, email/phone
parsing, SMS reply parsing, Twilio signature verification, HTML-escaping helpers).
## Quick start (Docker)
```bash
cp .env.example .env # edit SESSION_SECRET, SMTP_* etc. for production use
docker compose up --build
```
This starts three containers:
| Service | Purpose | URL |
|------------|--------------------------------------------|---------------------------|
| `app` | The Next.js application | http://localhost:3100 |
| `postgres` | Database (migrations run automatically) | internal only |
| `mailpit` | Dev SMTP catcher — view all sent emails here | http://localhost:8025 |
On startup, the app container automatically runs `prisma migrate deploy` before starting the
server, so a fresh `docker compose up` is all that's needed. Uploaded photos are stored on disk
under `public/uploads`, backed by the `uploads-data` named volume so they survive rebuilds.
## Using a real SMTP provider
By default, `SMTP_HOST`/`SMTP_PORT` point at the bundled Mailpit container so email works out of
the box in development. To send real email in production, edit `.env` (or the `app` service
environment in `docker-compose.yml`) with your provider's SMTP credentials — no code changes
required:
```
SMTP_HOST=smtp.yourprovider.com
SMTP_PORT=587
SMTP_USER=your-smtp-username
SMTP_PASS=your-smtp-password
SMTP_FROM="Your Name <invites@yourdomain.com>"
SMTP_SECURE=false
```
Also update `APP_URL` to your real public URL so links in emails point to the right place, and set
`SESSION_SECRET` to a long random string.
## Text invites via Twilio
Adding a phone number to an event's guest list (alongside or instead of emails) sends a text
invite and lets that guest RSVP by replying YES, NO, or MAYBE — optionally with a headcount, e.g.
"YES 3" — directly to the text, no link click needed. This is entirely optional: leave the
`TWILIO_*` vars blank and the guest list just works with emails only, same as before.
To enable it:
1. Create a [Twilio](https://www.twilio.com) account and buy a phone number capable of sending/
receiving SMS.
2. Set these in `.env` (or the `app` service environment in `docker-compose.yml`):
```
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your-auth-token
TWILIO_PHONE_NUMBER=+15551234567
```
Both values are on the Twilio Console dashboard; the phone number is under
**Phone Numbers → Manage → Active Numbers**.
3. In the Twilio Console, open that phone number's configuration and set **"A message comes in"**
to a webhook: `POST https://your-real-domain.com/api/sms/webhook` (must be `APP_URL` from your
`.env` plus `/api/sms/webhook` — the webhook verifies Twilio's signature against that exact URL,
so a mismatch here causes every inbound reply to be silently rejected as unverified).
4. `docker compose up -d --force-recreate app` to pick up the new env vars (no rebuild needed —
these aren't `NEXT_PUBLIC_*`, so they're read at runtime, not baked into the build).
**Caveats worth knowing:**
- There's one shared Twilio number for the whole app instance (not one per event), so an inbound
reply is matched to whichever event most recently texted that phone number. If you invite the
same guest phone number to two events in quick succession before they reply to the first, their
reply could land on the wrong event. For most single-host, single-event use this never comes up;
if you're running many concurrent events through one number, prefer the email flow for guests
invited to more than one active event at a time.
- SMS costs money per message once you're past Twilio's trial credits — this app doesn't do any
cost tracking or budgeting, that's on the Twilio side.
- A misconfigured or suspended Twilio account behaves like SMTP being down: the guest still gets
added to the guest list, the host just sees "text(s) failed" in the send-invites result instead
of the whole action erroring out.
## Deploying behind HTTPS
Session cookies are **not** marked `Secure` by default, because the bundled docker-compose stack
serves plain HTTP and a `Secure` cookie would silently never be sent back by the browser, breaking
every login. Once you have a reverse proxy terminating TLS in front of the app, set
`COOKIE_SECURE=true` so session cookies get the `Secure` flag.
## Browser push notifications
Hosts can optionally enable browser push notifications per-device from Settings, as a companion
to (not a replacement for) email notifications. This requires:
1. VAPID keys set in `.env` (`NEXT_PUBLIC_VAPID_PUBLIC_KEY` + `VAPID_PRIVATE_KEY`) - `.env.example`
ships with a working default pair; generate your own with:
```bash
node -e "console.log(JSON.stringify(require('web-push').generateVAPIDKeys(),null,2))"
```
2. **HTTPS.** The browser Push API refuses to work over plain HTTP on anything other than
`localhost` - this is a browser platform restriction, not something this app can work around.
If you're accessing the app via `http://localhost:3100` directly, push works out of the box in
development. Once deployed behind a real domain, you need a reverse proxy terminating TLS in
front of the app (see "Deploying behind HTTPS" above) before the "Enable push notifications"
button will do anything.
The public key is inlined into the client JS bundle at **build** time (not read at container
startup), so changing it requires `docker compose up --build`, not just a restart. Leaving both
keys blank disables the feature entirely - the Settings page just says push isn't configured, and
email notifications are unaffected either way.
## Local development (without Docker)
Requires Node 20+ and a running Postgres instance.
```bash
npm install
cp .env.example .env # point DATABASE_URL at your local Postgres, SMTP_HOST at a local catcher
npx prisma migrate dev
npm run dev
```
Run the test suite with:
```bash
npm test
```
## Project structure
- `prisma/schema.prisma` — data model (User, Session, Event, Rsvp, GuestInvite, Comment)
- `src/lib/` — session/auth, mail (Nodemailer templates), sms (Twilio send/verify), theme presets,
validation (zod), rate limiting, email/phone list parsing, SMS reply parsing
- `src/app/actions/` — Server Actions for auth, events, RSVP, comments, invites
- `src/app/(auth)/` — signup/login/forgot-password/reset-password pages
- `src/app/dashboard/` — host dashboard, event editor, guest/RSVP/comment management
- `src/app/invite/[slug]/` — the public, themed invite page guests land on
- `src/app/api/health/` — health check endpoint used by Docker/compose
- `src/app/api/sms/webhook/` — inbound Twilio webhook for reply-to-RSVP-by-text
- `src/app/error.tsx`, `src/app/not-found.tsx` — custom error/404 pages

65
docker-compose.yml Normal file
View File

@ -0,0 +1,65 @@
services:
app:
build:
context: .
args:
# Baked into the client bundle at build time - see Dockerfile.
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}
ports:
- "3100:3000"
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://einvite:einvite@postgres:5432/einvite?schema=public}
APP_URL: ${APP_URL:-http://localhost:3100}
SESSION_SECRET: ${SESSION_SECRET:-change-me-to-a-long-random-string}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
SMTP_HOST: ${SMTP_HOST:-mailpit}
SMTP_PORT: ${SMTP_PORT:-1025}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-E-Invite <invites@example.com>}
SMTP_SECURE: ${SMTP_SECURE:-false}
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_CONTACT_EMAIL: ${VAPID_CONTACT_EMAIL:-admin@example.com}
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID:-}
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN:-}
TWILIO_PHONE_NUMBER: ${TWILIO_PHONE_NUMBER:-}
volumes:
- uploads-data:/app/public/uploads
depends_on:
postgres:
condition: service_healthy
mailpit:
condition: service_started
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: einvite
POSTGRES_PASSWORD: einvite
POSTGRES_DB: einvite
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U einvite"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
mailpit:
image: axllent/mailpit:latest
ports:
- "8025:8025" # web UI: view caught emails at http://localhost:8025
restart: unless-stopped
volumes:
postgres-data:
uploads-data:

8
docker/entrypoint.sh Normal file
View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running database migrations..."
npx prisma migrate deploy
echo "Starting app..."
exec "$@"

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

12
next.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Server Actions default to a 1MB request body limit; photo uploads
// (src/lib/photoStorage.ts) allow up to 8MB, so raise the ceiling to match.
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
};
module.exports = nextConfig;

3843
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "einvite",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"bcryptjs": "^2.4.3",
"libphonenumber-js": "^1.13.8",
"nanoid": "^5.0.7",
"next": "^16.2.10",
"nodemailer": "^9.0.3",
"prisma": "^5.20.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twilio": "^6.0.2",
"web-push": "^3.6.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.9",
"@types/nodemailer": "^7.0.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/web-push": "^3.6.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.3",
"vitest": "^2.1.9"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,121 @@
-- CreateEnum
CREATE TYPE "RsvpStatus" AS ENUM ('YES', 'NO', 'MAYBE');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"resetToken" TEXT,
"resetTokenExpiry" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"theme" TEXT NOT NULL DEFAULT 'classic-elegant',
"eventDate" TIMESTAMP(3) NOT NULL,
"location" TEXT NOT NULL DEFAULT '',
"slug" TEXT NOT NULL,
"rsvpDeadline" TIMESTAMP(3),
"allowPlusOnes" BOOLEAN NOT NULL DEFAULT true,
"commentsEnabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rsvp" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"guestName" TEXT NOT NULL,
"guestEmail" TEXT NOT NULL,
"status" "RsvpStatus" NOT NULL,
"numGuests" INTEGER NOT NULL DEFAULT 1,
"note" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Rsvp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GuestInvite" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '',
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GuestInvite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"authorName" TEXT NOT NULL,
"authorEmail" TEXT NOT NULL DEFAULT '',
"body" TEXT NOT NULL,
"hidden" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_resetToken_key" ON "User"("resetToken");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug");
-- CreateIndex
CREATE INDEX "Event_hostId_idx" ON "Event"("hostId");
-- CreateIndex
CREATE INDEX "Rsvp_eventId_idx" ON "Rsvp"("eventId");
-- CreateIndex
CREATE INDEX "GuestInvite_eventId_idx" ON "GuestInvite"("eventId");
-- CreateIndex
CREATE INDEX "Comment_eventId_idx" ON "Comment"("eventId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rsvp" ADD CONSTRAINT "Rsvp_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GuestInvite" ADD CONSTRAINT "GuestInvite_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,9 @@
-- DropIndex
DROP INDEX "Rsvp_eventId_idx";
-- AlterTable
ALTER TABLE "Rsvp" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateIndex
CREATE UNIQUE INDEX "Rsvp_eventId_guestEmail_key" ON "Rsvp"("eventId", "guestEmail");

View File

@ -0,0 +1,40 @@
-- CreateEnum
CREATE TYPE "CollaboratorStatus" AS ENUM ('PENDING', 'ACCEPTED');
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "drinksProvided" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "endDate" TIMESTAMP(3),
ADD COLUMN "foodProvided" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Rsvp" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- CreateTable
CREATE TABLE "EventCollaborator" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"invitedEmail" TEXT NOT NULL,
"userId" TEXT,
"status" "CollaboratorStatus" NOT NULL DEFAULT 'PENDING',
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acceptedAt" TIMESTAMP(3),
CONSTRAINT "EventCollaborator_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EventCollaborator_token_key" ON "EventCollaborator"("token");
-- CreateIndex
CREATE INDEX "EventCollaborator_eventId_idx" ON "EventCollaborator"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "EventCollaborator_eventId_invitedEmail_key" ON "EventCollaborator"("eventId", "invitedEmail");
-- AddForeignKey
ALTER TABLE "EventCollaborator" ADD CONSTRAINT "EventCollaborator_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCollaborator" ADD CONSTRAINT "EventCollaborator_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,23 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "photoCloseDate" TIMESTAMP(3),
ADD COLUMN "photosClosed" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "photosEnabled" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "Photo" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"uploaderName" TEXT NOT NULL DEFAULT '',
"hidden" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Photo_eventId_idx" ON "Photo"("eventId");
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "verificationToken" TEXT,
ADD COLUMN "verificationTokenExpiry" TIMESTAMP(3);
-- CreateIndex
CREATE UNIQUE INDEX "User_verificationToken_key" ON "User"("verificationToken");

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "contactInfo" TEXT NOT NULL DEFAULT '',
ADD COLUMN "occasion" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "notifyOnCoHostAccepted" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "notifyOnComment" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "notifyOnRsvp" BOOLEAN NOT NULL DEFAULT true;

View File

@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,21 @@
-- DropIndex
DROP INDEX "GuestInvite_eventId_idx";
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'UTC';
-- AlterTable: nullable first so existing rows can be backfilled, then
-- tightened to NOT NULL - a plain NOT NULL ADD COLUMN would fail against
-- any pre-existing EventCollaborator rows.
ALTER TABLE "EventCollaborator" ADD COLUMN "expiresAt" TIMESTAMP(3);
UPDATE "EventCollaborator" SET "expiresAt" = "createdAt" + INTERVAL '7 days' WHERE "expiresAt" IS NULL;
ALTER TABLE "EventCollaborator" ALTER COLUMN "expiresAt" SET NOT NULL;
-- Deduplicate before adding the unique constraint below, keeping the most
-- recent invite per (eventId, email) - defensive in case any duplicates
-- accumulated before this constraint existed.
DELETE FROM "GuestInvite" a USING "GuestInvite" b
WHERE a."eventId" = b."eventId" AND a.email = b.email AND a."sentAt" < b."sentAt";
-- CreateIndex
CREATE UNIQUE INDEX "GuestInvite_eventId_email_key" ON "GuestInvite"("eventId", "email");

View File

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "restrictRsvpToInvited" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable: default false going forward, but every existing row was
-- created under the old logic that only inserted a GuestInvite after a
-- successful send, so backfill those as true.
ALTER TABLE "GuestInvite" ADD COLUMN "emailSent" BOOLEAN NOT NULL DEFAULT false;
UPDATE "GuestInvite" SET "emailSent" = true;

View File

@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "GuestInvite" ADD COLUMN "phone" TEXT,
ADD COLUMN "smsSent" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "email" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Rsvp" ADD COLUMN "guestPhone" TEXT,
ALTER COLUMN "guestEmail" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "GuestInvite_eventId_phone_key" ON "GuestInvite"("eventId", "phone");
-- CreateIndex
CREATE UNIQUE INDEX "Rsvp_eventId_guestPhone_key" ON "Rsvp"("eventId", "guestPhone");
-- CheckConstraint: not expressible in Prisma's schema language, so added by
-- hand. Every existing row already has a non-null guestEmail/email (the
-- prior schema required it), so no backfill is needed before adding these.
ALTER TABLE "Rsvp" ADD CONSTRAINT "rsvp_guest_contact_check"
CHECK ("guestEmail" IS NOT NULL OR "guestPhone" IS NOT NULL);
ALTER TABLE "GuestInvite" ADD CONSTRAINT "guestinvite_contact_check"
CHECK ("email" IS NOT NULL OR "phone" IS NOT NULL);

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

188
prisma/schema.prisma Normal file
View File

@ -0,0 +1,188 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
resetToken String? @unique
resetTokenExpiry DateTime?
emailVerified Boolean @default(false)
verificationToken String? @unique
verificationTokenExpiry DateTime?
notifyOnRsvp Boolean @default(true)
notifyOnComment Boolean @default(true)
notifyOnCoHostAccepted Boolean @default(true)
createdAt DateTime @default(now())
sessions Session[]
events Event[]
collaboratorInvites EventCollaborator[]
pushSubscriptions PushSubscription[]
}
model PushSubscription {
id String @id @default(cuid())
userId String
endpoint String @unique
p256dh String
auth String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model Session {
id String @id @default(cuid())
userId String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
enum RsvpStatus {
YES
NO
MAYBE
}
enum CollaboratorStatus {
PENDING
ACCEPTED
}
model Event {
id String @id @default(cuid())
hostId String
title String
occasion String @default("")
description String @default("")
contactInfo String @default("")
theme String @default("classic-elegant")
timezone String @default("UTC")
eventDate DateTime
endDate DateTime?
location String @default("")
slug String @unique
rsvpDeadline DateTime?
restrictRsvpToInvited Boolean @default(false)
allowPlusOnes Boolean @default(true)
commentsEnabled Boolean @default(true)
foodProvided Boolean @default(false)
drinksProvided Boolean @default(false)
photosEnabled Boolean @default(true)
photoCloseDate DateTime?
photosClosed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
host User @relation(fields: [hostId], references: [id], onDelete: Cascade)
rsvps Rsvp[]
comments Comment[]
guestInvites GuestInvite[]
collaborators EventCollaborator[]
photos Photo[]
@@index([hostId])
}
model Photo {
id String @id @default(cuid())
eventId String
filename String
uploaderName String @default("")
hidden Boolean @default(false)
createdAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@index([eventId])
}
model EventCollaborator {
id String @id @default(cuid())
eventId String
invitedEmail String
userId String?
status CollaboratorStatus @default(PENDING)
token String @unique
createdAt DateTime @default(now())
expiresAt DateTime
acceptedAt DateTime?
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@unique([eventId, invitedEmail])
@@index([eventId])
}
model Rsvp {
id String @id @default(cuid())
eventId String
guestName String
// Exactly one of these identifies the guest, depending on whether they
// RSVP'd via the web form (email) or by replying to an SMS invite
// (phone) - enforced by a CHECK constraint added in the migration since
// Prisma's schema language doesn't express "at least one of" directly.
guestEmail String?
guestPhone String?
status RsvpStatus
numGuests Int @default(1)
note String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([eventId, guestEmail])
@@unique([eventId, guestPhone])
}
model GuestInvite {
id String @id @default(cuid())
eventId String
// Exactly one of email/phone identifies this guest-list entry (CHECK
// constraint added in the migration).
email String?
phone String?
name String @default("")
// Being on this list (guest-list membership, used to gate RSVPs when
// Event.restrictRsvpToInvited is on, and to route an inbound SMS reply to
// the right event) is independent of whether the invite itself actually
// went out - emailSent/smsSent are purely informational.
emailSent Boolean @default(false)
smsSent Boolean @default(false)
sentAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([eventId, email])
@@unique([eventId, phone])
}
model Comment {
id String @id @default(cuid())
eventId String
authorName String
authorEmail String @default("")
body String
hidden Boolean @default(false)
createdAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@index([eventId])
}

28
public/sw.js Normal file
View File

@ -0,0 +1,28 @@
self.addEventListener('push', (event) => {
let payload = { title: 'E-Invite', body: '' };
try {
if (event.data) payload = event.data.json();
} catch {
// Ignore malformed payloads rather than crashing the service worker.
}
event.waitUntil(
self.registration.showNotification(payload.title || 'E-Invite', {
body: payload.body || '',
data: { url: payload.url || '/' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clients) => {
for (const client of clients) {
if (client.url === url && 'focus' in client) return client.focus();
}
if (self.clients.openWindow) return self.clients.openWindow(url);
})
);
});

0
public/uploads/.gitkeep Normal file
View File

View File

@ -0,0 +1,47 @@
'use client';
import { useActionState } from 'react';
import Link from 'next/link';
import { forgotPasswordAction, type ActionState } from '@/app/actions/auth';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: ActionState = {};
export default function ForgotPasswordPage() {
const [state, formAction] = useActionState(forgotPasswordAction, initialState);
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16">
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1>
<p className="mt-1 text-sm text-gray-600">
Enter your email and we'll send you a link to reset your password.
</p>
<form action={formAction} className="mt-8 flex flex-col gap-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
{state.success && <p className="text-sm text-green-700">{state.success}</p>}
<SubmitButton>Send reset link</SubmitButton>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
Back to log in
</Link>
</p>
</main>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import { Suspense, useActionState } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { loginAction, type ActionState } from '@/app/actions/auth';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: ActionState = {};
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
function LoginForm() {
const searchParams = useSearchParams();
const next = searchParams.get('next') ?? '';
const [state, formAction] = useActionState(loginAction, initialState);
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16">
<h1 className="text-2xl font-bold text-gray-900">Log in</h1>
<form action={formAction} className="mt-8 flex flex-col gap-4">
<input type="hidden" name="next" value={next} />
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
<SubmitButton>Log in</SubmitButton>
</form>
<div className="mt-4 flex justify-between text-sm">
<Link
href={next ? `/signup?next=${encodeURIComponent(next)}` : '/signup'}
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Create an account
</Link>
<Link href="/forgot-password" className="text-gray-500 hover:text-gray-700">
Forgot password?
</Link>
</div>
</main>
);
}

View File

@ -0,0 +1,78 @@
'use client';
import { Suspense, useActionState } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { resetPasswordAction, type ActionState } from '@/app/actions/auth';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: ActionState = {};
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordForm />
</Suspense>
);
}
function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams.get('token') ?? '';
const [state, formAction] = useActionState(resetPasswordAction, initialState);
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16">
<h1 className="text-2xl font-bold text-gray-900">Set a new password</h1>
{!token ? (
<p className="mt-4 text-sm text-red-600">
This reset link is missing its token. Please use the link from your email.
</p>
) : (
<form action={formAction} className="mt-8 flex flex-col gap-4">
<input type="hidden" name="token" value={token} />
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
New password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
pattern="(?=.*[A-Za-z])(?=.*\d).{8,}"
title="At least 8 characters, including a letter and a number"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">At least 8 characters, with a letter and a number.</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm new password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
{state.success && <p className="text-sm text-green-700">{state.success}</p>}
<SubmitButton>Update password</SubmitButton>
</form>
)}
<p className="mt-6 text-center text-sm text-gray-600">
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
Back to log in
</Link>
</p>
</main>
);
}

View File

@ -0,0 +1,100 @@
'use client';
import { Suspense, useActionState } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { signupAction, type ActionState } from '@/app/actions/auth';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: ActionState = {};
export default function SignupPage() {
return (
<Suspense>
<SignupForm />
</Suspense>
);
}
function SignupForm() {
const searchParams = useSearchParams();
const next = searchParams.get('next') ?? '';
const [state, formAction] = useActionState(signupAction, initialState);
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16">
<h1 className="text-2xl font-bold text-gray-900">Create your account</h1>
<p className="mt-1 text-sm text-gray-600">Start hosting themed invitations in minutes.</p>
<form action={formAction} className="mt-8 flex flex-col gap-4">
<input type="hidden" name="next" value={next} />
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
pattern="(?=.*[A-Za-z])(?=.*\d).{8,}"
title="At least 8 characters, including a letter and a number"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">At least 8 characters, with a letter and a number.</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
<SubmitButton>Sign up</SubmitButton>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
Already have an account?{' '}
<Link
href={next ? `/login?next=${encodeURIComponent(next)}` : '/login'}
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Log in
</Link>
</p>
</main>
);
}

217
src/app/actions/auth.ts Normal file
View File

@ -0,0 +1,217 @@
'use server';
import { redirect } from 'next/navigation';
import { randomBytes } from 'crypto';
import { prisma } from '@/lib/prisma';
import { createSession, destroySession, hashPassword, requireUser, verifyPassword } from '@/lib/session';
import { sendPasswordResetEmail, sendVerificationEmail } from '@/lib/mail';
import { appUrl } from '@/lib/slug';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import {
signupSchema,
loginSchema,
forgotPasswordSchema,
resetPasswordSchema,
} from '@/lib/validation';
const TOO_MANY_ATTEMPTS = 'Too many attempts. Please wait a few minutes and try again.';
export type ActionState = { error?: string; success?: string };
// Only ever redirect to a same-site relative path after auth (e.g. back to
// an invite-accept page) - a `next` value like "//evil.com" or
// "https://evil.com" would be an open redirect, so anything not starting
// with exactly one leading slash falls back to the dashboard.
function safeNextPath(next: FormDataEntryValue | null): string {
if (typeof next === 'string' && next.startsWith('/') && !next.startsWith('//')) {
return next;
}
return '/dashboard';
}
export async function signupAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
const ip = await getClientIp();
if (!checkRateLimit(`signup:${ip}`, 10, 60 * 60 * 1000)) {
return { error: TOO_MANY_ATTEMPTS };
}
const parsed = signupSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const { name, email, password } = parsed.data;
const existing = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (existing) {
return { error: 'An account with that email already exists' };
}
const passwordHash = await hashPassword(password);
const verificationToken = randomBytes(32).toString('hex');
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
passwordHash,
verificationToken,
verificationTokenExpiry: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
await sendVerificationEmail({
to: user.email,
verifyUrl: appUrl(`/verify-email/${verificationToken}`),
});
await createSession(user.id);
redirect(safeNextPath(formData.get('next')));
}
export async function loginAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
const parsed = loginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const { email, password } = parsed.data;
const ip = await getClientIp();
// Keyed on ip+email (not just ip) so a shared/unknown ip in front of the
// app can't lock out every user, while still slowing down credential
// stuffing against one account.
if (!checkRateLimit(`login:${ip}:${email.toLowerCase()}`, 8, 15 * 60 * 1000)) {
return { error: TOO_MANY_ATTEMPTS };
}
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user) {
return { error: 'Invalid email or password' };
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return { error: 'Invalid email or password' };
}
await createSession(user.id);
redirect(safeNextPath(formData.get('next')));
}
export async function logoutAction() {
await destroySession();
redirect('/');
}
export async function forgotPasswordAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = forgotPasswordSchema.safeParse({ email: formData.get('email') });
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const ip = await getClientIp();
if (!checkRateLimit(`forgot-password:${ip}`, 5, 15 * 60 * 1000)) {
return { error: TOO_MANY_ATTEMPTS };
}
const email = parsed.data.email.toLowerCase();
const user = await prisma.user.findUnique({ where: { email } });
// Always report success, whether or not the account exists, so this
// can't be used to enumerate registered emails.
if (user) {
const resetToken = randomBytes(32).toString('hex');
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
await prisma.user.update({
where: { id: user.id },
data: { resetToken, resetTokenExpiry },
});
await sendPasswordResetEmail({
to: user.email,
resetUrl: appUrl(`/reset-password?token=${resetToken}`),
});
}
return { success: 'If that email has an account, a reset link has been sent.' };
}
export async function verifyEmailAction(token: string): Promise<ActionState> {
const user = await prisma.user.findUnique({ where: { verificationToken: token } });
if (!user || !user.verificationTokenExpiry || user.verificationTokenExpiry < new Date()) {
return { error: 'This verification link is invalid or has expired.' };
}
await prisma.user.update({
where: { id: user.id },
data: { emailVerified: true, verificationToken: null, verificationTokenExpiry: null },
});
return { success: 'Email verified. Thanks!' };
}
export async function resendVerificationEmailAction(
_prev: ActionState,
_formData: FormData
): Promise<ActionState> {
const user = await requireUser();
if (user.emailVerified) {
return { success: 'Your email is already verified.' };
}
if (!checkRateLimit(`resend-verification:${user.id}`, 3, 60 * 60 * 1000)) {
return { error: TOO_MANY_ATTEMPTS };
}
const verificationToken = randomBytes(32).toString('hex');
await prisma.user.update({
where: { id: user.id },
data: {
verificationToken,
verificationTokenExpiry: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
await sendVerificationEmail({
to: user.email,
verifyUrl: appUrl(`/verify-email/${verificationToken}`),
});
return { success: 'Verification email sent - check your inbox.' };
}
export async function resetPasswordAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = resetPasswordSchema.safeParse({
token: formData.get('token'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const { token, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { resetToken: token } });
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
return { error: 'This reset link is invalid or has expired' };
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash, resetToken: null, resetTokenExpiry: null },
});
return { success: 'Password updated. You can now log in.' };
}

View File

@ -0,0 +1,133 @@
'use server';
import { randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import { notFound, redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
import { requireEventAccess } from '@/lib/eventAccess';
import { sendCollaboratorInviteEmail, sendCollaboratorAcceptedNotification } from '@/lib/mail';
import { appUrl } from '@/lib/slug';
import { inviteCollaboratorSchema } from '@/lib/validation';
import { sendPushToUser } from '@/lib/push';
export type ActionState = { error?: string; success?: string };
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export async function inviteCollaboratorAction(
eventId: string,
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const { user, event, isPrimaryHost } = await requireEventAccess(eventId);
if (!isPrimaryHost) {
return { error: 'Only the primary host can invite co-hosts' };
}
const parsed = inviteCollaboratorSchema.safeParse({
eventId,
email: formData.get('email'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const email = parsed.data.email.toLowerCase();
if (email === user.email.toLowerCase()) {
return { error: "You're already hosting this event" };
}
const existing = await prisma.eventCollaborator.findUnique({
where: { eventId_invitedEmail: { eventId: event.id, invitedEmail: email } },
});
if (existing?.status === 'ACCEPTED') {
return { error: 'That person is already a co-host' };
}
const token = randomBytes(24).toString('hex');
const expiresAt = new Date(Date.now() + INVITE_TTL_MS);
try {
if (existing) {
await prisma.eventCollaborator.update({ where: { id: existing.id }, data: { token, expiresAt } });
} else {
await prisma.eventCollaborator.create({
data: { eventId: event.id, invitedEmail: email, token, expiresAt },
});
}
} catch (err) {
// Concurrent double-submit can both pass the findUnique check above and
// race on the (eventId, invitedEmail) unique constraint - the loser
// just means someone else's invite already went out; not an error.
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
revalidatePath(`/dashboard/events/${event.id}/guests`);
return { success: `Invite sent to ${email}.` };
}
throw err;
}
await sendCollaboratorInviteEmail({
to: email,
inviterName: user.name,
eventTitle: event.title,
acceptUrl: appUrl(`/collaborate/${token}`),
});
revalidatePath(`/dashboard/events/${event.id}/guests`);
return { success: `Invite sent to ${email}.` };
}
export async function removeCollaboratorAction(collaboratorId: string) {
const collaborator = await prisma.eventCollaborator.findUnique({ where: { id: collaboratorId } });
if (!collaborator) notFound();
const { isPrimaryHost } = await requireEventAccess(collaborator.eventId);
if (!isPrimaryHost) {
notFound();
}
await prisma.eventCollaborator.delete({ where: { id: collaboratorId } });
revalidatePath(`/dashboard/events/${collaborator.eventId}/guests`);
}
export async function acceptCollaboratorInviteAction(token: string) {
const user = await requireUser();
const collaborator = await prisma.eventCollaborator.findUnique({
where: { token },
include: { event: { include: { host: true } } },
});
if (!collaborator) notFound();
if (collaborator.status !== 'ACCEPTED' && collaborator.expiresAt < new Date()) {
// Expired and never accepted - treat like any other invalid token. The
// /collaborate/[token] page itself also checks expiry up front so a
// human hitting the link sees a clear message rather than a 404; this
// is defense-in-depth against a direct POST to the action.
notFound();
}
if (collaborator.status !== 'ACCEPTED') {
await prisma.eventCollaborator.update({
where: { id: collaborator.id },
data: { status: 'ACCEPTED', userId: user.id, acceptedAt: new Date() },
});
if (collaborator.event.host.notifyOnCoHostAccepted) {
const manageUrl = appUrl(`/dashboard/events/${collaborator.eventId}/guests`);
await sendCollaboratorAcceptedNotification({
to: collaborator.event.host.email,
collaboratorName: user.name,
eventTitle: collaborator.event.title,
manageUrl,
});
await sendPushToUser(collaborator.event.host.id, {
title: 'Co-host invite accepted',
body: `${user.name} is now co-hosting ${collaborator.event.title}`,
url: manageUrl,
});
}
}
redirect(`/dashboard/events/${collaborator.eventId}/guests`);
}

100
src/app/actions/comments.ts Normal file
View File

@ -0,0 +1,100 @@
'use server';
import { notFound } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
import { accessibleEventWhere } from '@/lib/eventAccess';
import { commentSchema } from '@/lib/validation';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { sendCommentHostNotification } from '@/lib/mail';
import { appUrl } from '@/lib/slug';
import { sendPushToUser } from '@/lib/push';
export type ActionState = { error?: string };
export async function postCommentAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
// Honeypot: real guests never fill this hidden field.
if (formData.get('website')) {
return {};
}
const parsed = commentSchema.safeParse({
eventId: formData.get('eventId'),
authorName: formData.get('authorName'),
authorEmail: formData.get('authorEmail') ?? '',
body: formData.get('body'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const data = parsed.data;
const ip = await getClientIp();
const rateLimited =
!checkRateLimit(`comment:ip:${ip}`, 10, 60 * 1000) ||
!checkRateLimit(`comment:event:${data.eventId}`, 30, 60 * 1000);
if (rateLimited) {
return { error: 'Too many comments. Please wait a bit and try again.' };
}
const event = await prisma.event.findUnique({ where: { id: data.eventId }, include: { host: true } });
if (!event || !event.commentsEnabled) {
return { error: 'Comments are not available for this event' };
}
await prisma.comment.create({
data: {
eventId: event.id,
authorName: data.authorName,
authorEmail: data.authorEmail || '',
body: data.body,
},
});
if (event.host.notifyOnComment) {
const manageUrl = appUrl(`/dashboard/events/${event.id}/guests`);
await sendCommentHostNotification({
to: event.host.email,
authorName: data.authorName,
body: data.body,
eventTitle: event.title,
manageUrl,
});
await sendPushToUser(event.host.id, {
title: 'New comment',
body: `${data.authorName} commented on ${event.title}`,
url: manageUrl,
});
}
revalidatePath(`/invite/${event.slug}`);
return {};
}
async function requireOwnedComment(commentId: string) {
const user = await requireUser();
const comment = await prisma.comment.findUnique({ where: { id: commentId }, include: { event: true } });
if (!comment) notFound();
const accessibleEvent = await prisma.event.findFirst({
where: accessibleEventWhere(comment.eventId, user.id),
});
if (!accessibleEvent) notFound();
return comment;
}
export async function toggleCommentHiddenAction(commentId: string) {
const comment = await requireOwnedComment(commentId);
await prisma.comment.update({ where: { id: comment.id }, data: { hidden: !comment.hidden } });
revalidatePath(`/dashboard/events/${comment.eventId}/guests`);
revalidatePath(`/invite/${comment.event.slug}`);
}
export async function deleteCommentAction(commentId: string) {
const comment = await requireOwnedComment(commentId);
await prisma.comment.delete({ where: { id: comment.id } });
revalidatePath(`/dashboard/events/${comment.eventId}/guests`);
revalidatePath(`/invite/${comment.event.slug}`);
}

149
src/app/actions/events.ts Normal file
View File

@ -0,0 +1,149 @@
'use server';
import { notFound, redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
import { requireEventAccess } from '@/lib/eventAccess';
import { generateSlug } from '@/lib/slug';
import { eventFormSchema } from '@/lib/validation';
import { zonedDatetimeLocalToUtc } from '@/lib/timezone';
import { deletePhotoFile } from '@/lib/photoStorage';
export type ActionState = { error?: string };
function parseEventForm(formData: FormData) {
return eventFormSchema.safeParse({
title: formData.get('title'),
occasion: formData.get('occasion') ?? '',
description: formData.get('description') ?? '',
contactInfo: formData.get('contactInfo') ?? '',
theme: formData.get('theme'),
timezone: formData.get('timezone'),
eventDate: formData.get('eventDate'),
endDate: formData.get('endDate') ?? '',
location: formData.get('location') ?? '',
rsvpDeadline: formData.get('rsvpDeadline') ?? '',
restrictRsvpToInvited: formData.get('restrictRsvpToInvited') === 'on',
allowPlusOnes: formData.get('allowPlusOnes') === 'on',
commentsEnabled: formData.get('commentsEnabled') === 'on',
foodProvided: formData.get('foodProvided') === 'on',
drinksProvided: formData.get('drinksProvided') === 'on',
photosEnabled: formData.get('photosEnabled') === 'on',
photoCloseDate: formData.get('photoCloseDate') ?? '',
});
}
export async function createEventAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
const user = await requireUser();
const parsed = parseEventForm(formData);
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const data = parsed.data;
const tz = data.timezone;
let slug = generateSlug();
// Practically unreachable given the slug keyspace, but guarantee uniqueness.
for (let attempts = 0; attempts < 5; attempts++) {
const clash = await prisma.event.findUnique({ where: { slug } });
if (!clash) break;
slug = generateSlug();
}
const event = await prisma.event.create({
data: {
hostId: user.id,
title: data.title,
occasion: data.occasion,
description: data.description,
contactInfo: data.contactInfo,
theme: data.theme,
timezone: tz,
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
location: data.location,
rsvpDeadline: data.rsvpDeadline ? zonedDatetimeLocalToUtc(data.rsvpDeadline, tz) : null,
restrictRsvpToInvited: data.restrictRsvpToInvited,
allowPlusOnes: data.allowPlusOnes,
commentsEnabled: data.commentsEnabled,
foodProvided: data.foodProvided,
drinksProvided: data.drinksProvided,
photosEnabled: data.photosEnabled,
photoCloseDate: data.photoCloseDate ? zonedDatetimeLocalToUtc(data.photoCloseDate, tz) : null,
slug,
},
});
redirect(`/dashboard/events/${event.id}/guests`);
}
export async function updateEventAction(
eventId: string,
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const { event } = await requireEventAccess(eventId);
const parsed = parseEventForm(formData);
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const data = parsed.data;
const tz = data.timezone;
await prisma.event.update({
where: { id: event.id },
data: {
title: data.title,
occasion: data.occasion,
description: data.description,
contactInfo: data.contactInfo,
theme: data.theme,
timezone: tz,
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
location: data.location,
rsvpDeadline: data.rsvpDeadline ? zonedDatetimeLocalToUtc(data.rsvpDeadline, tz) : null,
restrictRsvpToInvited: data.restrictRsvpToInvited,
allowPlusOnes: data.allowPlusOnes,
commentsEnabled: data.commentsEnabled,
foodProvided: data.foodProvided,
drinksProvided: data.drinksProvided,
photosEnabled: data.photosEnabled,
photoCloseDate: data.photoCloseDate ? zonedDatetimeLocalToUtc(data.photoCloseDate, tz) : null,
},
});
revalidatePath(`/dashboard/events/${event.id}/edit`);
redirect(`/dashboard/events/${event.id}/guests`);
}
export async function deleteEventAction(eventId: string) {
const user = await requireUser();
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { photos: true } });
if (!event || event.hostId !== user.id) {
notFound();
}
// Photo rows cascade-delete with the event via the FK constraint, but the
// actual files on disk don't - clean those up first or they leak forever.
await Promise.all(event.photos.map((photo) => deletePhotoFile(event.id, photo.filename)));
await prisma.event.delete({ where: { id: event.id } });
revalidatePath('/dashboard');
redirect('/dashboard');
}
export async function regenerateSlugAction(eventId: string) {
const { event } = await requireEventAccess(eventId);
let slug = generateSlug();
for (let attempts = 0; attempts < 5; attempts++) {
const clash = await prisma.event.findUnique({ where: { slug } });
if (!clash) break;
slug = generateSlug();
}
await prisma.event.update({ where: { id: event.id }, data: { slug } });
revalidatePath(`/dashboard/events/${event.id}/guests`);
}

101
src/app/actions/invites.ts Normal file
View File

@ -0,0 +1,101 @@
'use server';
import { notFound } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireEventAccess } from '@/lib/eventAccess';
import { sendInviteEmail } from '@/lib/mail';
import { sendSms } from '@/lib/sms';
import { appUrl } from '@/lib/slug';
import { sendInvitesSchema } from '@/lib/validation';
import { parseGuestContactList } from '@/lib/emailList';
import { checkRateLimit } from '@/lib/rateLimit';
import { formatEventDateRange } from '@/lib/format';
export type ActionState = { error?: string; success?: string };
export async function sendInvitesAction(
eventId: string,
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const { user, event } = await requireEventAccess(eventId);
const parsed = sendInvitesSchema.safeParse({
eventId,
emails: formData.get('emails'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const { emails, phones, skipped } = parseGuestContactList(parsed.data.emails);
const totalGuests = emails.length + phones.length;
if (totalGuests === 0) {
return { error: 'No valid email addresses or phone numbers found' };
}
if (!checkRateLimit(`send-invites:${user.id}`, 200, 60 * 60 * 1000, totalGuests)) {
return { error: 'Too many invites sent recently. Please try again later.' };
}
const inviteUrl = appUrl(`/invite/${event.slug}`);
let emailed = 0;
let emailFailed = 0;
// Being on the guest list (used to gate RSVPs when restrictRsvpToInvited
// is on, and to route inbound SMS replies) never depends on the
// invite actually sending - a down/misconfigured SMTP/Twilio account
// shouldn't lock a host out of building their guest list.
for (const email of emails) {
const wasDelivered = await sendInviteEmail({
to: email,
hostName: user.name,
eventTitle: event.title,
eventDate: event.eventDate,
timezone: event.timezone,
inviteUrl,
});
await prisma.guestInvite.upsert({
where: { eventId_email: { eventId: event.id, email } },
create: { eventId: event.id, email, emailSent: wasDelivered },
update: { sentAt: new Date(), emailSent: wasDelivered },
});
if (wasDelivered) emailed++;
else emailFailed++;
}
let texted = 0;
let smsFailed = 0;
const smsBody = `${user.name} invited you to ${event.title} on ${formatEventDateRange(event.eventDate, null, event.timezone)}. RSVP: ${inviteUrl}\nReply YES, NO, or MAYBE to this text to RSVP directly.`;
for (const phone of phones) {
const wasDelivered = await sendSms(phone, smsBody);
await prisma.guestInvite.upsert({
where: { eventId_phone: { eventId: event.id, phone } },
create: { eventId: event.id, phone, smsSent: wasDelivered },
update: { sentAt: new Date(), smsSent: wasDelivered },
});
if (wasDelivered) texted++;
else smsFailed++;
}
revalidatePath(`/dashboard/events/${event.id}/guests`);
const parts = [`Added ${totalGuests} guest${totalGuests === 1 ? '' : 's'} to the list.`];
if (emailed > 0) parts.push(`Emailed ${emailed}.`);
if (texted > 0) parts.push(`Texted ${texted}.`);
if (emailFailed > 0) parts.push(`${emailFailed} email(s) failed - check your SMTP settings.`);
if (smsFailed > 0) parts.push(`${smsFailed} text(s) failed - check your Twilio settings.`);
if (skipped > 0) parts.push(`Skipped ${skipped} invalid entr${skipped === 1 ? 'y' : 'ies'}.`);
return { success: parts.join(' ') };
}
export async function removeGuestFromListAction(guestInviteId: string) {
const guestInvite = await prisma.guestInvite.findUnique({ where: { id: guestInviteId } });
if (!guestInvite) notFound();
await requireEventAccess(guestInvite.eventId);
await prisma.guestInvite.delete({ where: { id: guestInviteId } });
revalidatePath(`/dashboard/events/${guestInvite.eventId}/guests`);
}

116
src/app/actions/photos.ts Normal file
View File

@ -0,0 +1,116 @@
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireEventAccess } from '@/lib/eventAccess';
import { isPhotoUploadOpen } from '@/lib/photoWindow';
import { savePhotoFile, deletePhotoFile, MAX_PHOTO_BYTES, ALLOWED_PHOTO_TYPES } from '@/lib/photoStorage';
import { photoUploadSchema } from '@/lib/validation';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
class PhotoLimitReachedError extends Error {}
export type ActionState = { error?: string; success?: string };
const MAX_PHOTOS_PER_EVENT = 300;
export async function uploadPhotoAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
const parsed = photoUploadSchema.safeParse({
eventId: formData.get('eventId'),
uploaderName: formData.get('uploaderName') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const { eventId, uploaderName } = parsed.data;
const ip = await getClientIp();
const rateLimited =
!checkRateLimit(`photo:ip:${ip}`, 20, 60 * 1000) || !checkRateLimit(`photo:event:${eventId}`, 60, 60 * 1000);
if (rateLimited) {
return { error: 'Too many uploads. Please wait a bit and try again.' };
}
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) {
return { error: 'Event not found' };
}
if (!isPhotoUploadOpen(event)) {
return { error: 'Photo uploads are not open for this event' };
}
const file = formData.get('photo');
if (!(file instanceof File) || file.size === 0) {
return { error: 'Choose a photo to upload' };
}
if (!ALLOWED_PHOTO_TYPES[file.type]) {
return { error: 'Only JPEG, PNG, WebP, and GIF images are allowed' };
}
if (file.size > MAX_PHOTO_BYTES) {
return { error: 'Photo is too large (8MB max)' };
}
// Write the file first (cheap to redo/discard), then enforce the cap with
// a serializable transaction so a burst of concurrent uploads can't all
// pass a stale count check and land past MAX_PHOTOS_PER_EVENT together.
const filename = await savePhotoFile(eventId, file);
try {
await prisma.$transaction(
async (tx) => {
const photoCount = await tx.photo.count({ where: { eventId } });
if (photoCount >= MAX_PHOTOS_PER_EVENT) {
throw new PhotoLimitReachedError();
}
await tx.photo.create({ data: { eventId, filename, uploaderName } });
},
{ isolationLevel: 'Serializable' }
);
} catch (err) {
await deletePhotoFile(eventId, filename);
if (err instanceof PhotoLimitReachedError) {
return { error: 'This event has reached its photo limit' };
}
// Serializable transaction conflict under concurrent uploads - the
// guest just needs to retry, their file wasn't lost from their POV.
return { error: 'Too many uploads at once - please try again.' };
}
revalidatePath(`/invite/${event.slug}`);
revalidatePath(`/dashboard/events/${eventId}/guests`);
return { success: 'Photo uploaded! Thanks for sharing.' };
}
async function requireOwnedPhoto(photoId: string) {
const photo = await prisma.photo.findUnique({ where: { id: photoId }, include: { event: true } });
if (!photo) return null;
const { event } = photo;
return { photo, event };
}
export async function togglePhotoHiddenAction(photoId: string) {
const found = await requireOwnedPhoto(photoId);
if (!found) return;
await requireEventAccess(found.event.id);
await prisma.photo.update({ where: { id: found.photo.id }, data: { hidden: !found.photo.hidden } });
revalidatePath(`/dashboard/events/${found.event.id}/guests`);
revalidatePath(`/invite/${found.event.slug}`);
}
export async function deletePhotoAction(photoId: string) {
const found = await requireOwnedPhoto(photoId);
if (!found) return;
await requireEventAccess(found.event.id);
await prisma.photo.delete({ where: { id: found.photo.id } });
await deletePhotoFile(found.event.id, found.photo.filename);
revalidatePath(`/dashboard/events/${found.event.id}/guests`);
revalidatePath(`/invite/${found.event.slug}`);
}
export async function toggleForceCloseAction(eventId: string) {
const { event } = await requireEventAccess(eventId);
await prisma.event.update({ where: { id: event.id }, data: { photosClosed: !event.photosClosed } });
revalidatePath(`/dashboard/events/${event.id}/guests`);
revalidatePath(`/invite/${event.slug}`);
}

30
src/app/actions/push.ts Normal file
View File

@ -0,0 +1,30 @@
'use server';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
export async function subscribeToPushAction(subscription: {
endpoint: string;
keys: { p256dh: string; auth: string };
}) {
const user = await requireUser();
await prisma.pushSubscription.upsert({
where: { endpoint: subscription.endpoint },
create: {
userId: user.id,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
update: {
userId: user.id,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
});
}
export async function unsubscribeFromPushAction(endpoint: string) {
const user = await requireUser();
await prisma.pushSubscription.deleteMany({ where: { endpoint, userId: user.id } });
}

113
src/app/actions/rsvp.ts Normal file
View File

@ -0,0 +1,113 @@
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { rsvpSchema } from '@/lib/validation';
import { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail';
import { appUrl } from '@/lib/slug';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { sendPushToUser } from '@/lib/push';
export type ActionState = { error?: string; success?: string };
const SUCCESS_MESSAGE = 'Thanks! Your RSVP has been recorded.';
export async function submitRsvpAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
// Honeypot: a real guest never fills this hidden field. Bots that
// blindly fill every input do, so pretend success without doing anything.
if (formData.get('website')) {
return { success: SUCCESS_MESSAGE };
}
const parsed = rsvpSchema.safeParse({
eventId: formData.get('eventId'),
guestName: formData.get('guestName'),
guestEmail: formData.get('guestEmail'),
status: formData.get('status'),
numGuests: formData.get('numGuests') || '1',
note: formData.get('note') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const data = parsed.data;
const guestEmail = data.guestEmail.toLowerCase();
const ip = await getClientIp();
const rateLimited =
!checkRateLimit(`rsvp:ip:${ip}`, 20, 60 * 1000) ||
!checkRateLimit(`rsvp:event:${data.eventId}:${guestEmail}`, 5, 60 * 60 * 1000);
if (rateLimited) {
return { error: 'Too many submissions. Please wait a bit and try again.' };
}
const event = await prisma.event.findUnique({ where: { id: data.eventId }, include: { host: true } });
if (!event) {
return { error: 'Event not found' };
}
if (event.restrictRsvpToInvited) {
const onGuestList = await prisma.guestInvite.findUnique({
where: { eventId_email: { eventId: event.id, email: guestEmail } },
});
if (!onGuestList) {
return {
error:
'This event is invite-only. Please RSVP using the email address the host invited you with, or contact them directly.',
};
}
}
const numGuests = event.allowPlusOnes ? data.numGuests : 1;
const existing = await prisma.rsvp.findUnique({
where: { eventId_guestEmail: { eventId: event.id, guestEmail } },
});
await prisma.rsvp.upsert({
where: { eventId_guestEmail: { eventId: event.id, guestEmail } },
create: {
eventId: event.id,
guestName: data.guestName,
guestEmail,
status: data.status,
numGuests,
note: data.note,
},
update: {
guestName: data.guestName,
status: data.status,
numGuests,
note: data.note,
},
});
const inviteUrl = appUrl(`/invite/${event.slug}`);
await sendRsvpGuestConfirmation({
to: guestEmail,
guestName: data.guestName,
eventTitle: event.title,
status: data.status,
inviteUrl,
});
if (event.host.notifyOnRsvp) {
const manageUrl = appUrl(`/dashboard/events/${event.id}/guests`);
await sendRsvpHostNotification({
to: event.host.email,
guestName: data.guestName,
eventTitle: event.title,
status: data.status,
numGuests,
note: data.note,
manageUrl,
isUpdate: Boolean(existing),
});
await sendPushToUser(event.host.id, {
title: existing ? 'RSVP updated' : 'New RSVP',
body: `${data.guestName} ${existing ? 'updated their RSVP to' : 'responded'} ${data.status} for ${event.title}`,
url: manageUrl,
});
}
revalidatePath(`/invite/${event.slug}`);
return { success: SUCCESS_MESSAGE };
}

View File

@ -0,0 +1,26 @@
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
export type ActionState = { success?: string };
export async function updateNotificationPreferencesAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const user = await requireUser();
await prisma.user.update({
where: { id: user.id },
data: {
notifyOnRsvp: formData.get('notifyOnRsvp') === 'on',
notifyOnComment: formData.get('notifyOnComment') === 'on',
notifyOnCoHostAccepted: formData.get('notifyOnCoHostAccepted') === 'on',
},
});
revalidatePath('/dashboard/settings');
return { success: 'Preferences saved.' };
}

View File

@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ status: 'ok' });
} catch {
return NextResponse.json({ status: 'error' }, { status: 503 });
}
}

View File

@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { verifyTwilioSignature } from '@/lib/sms';
import { parseSmsRsvpReply } from '@/lib/smsReply';
import { normalizePhoneNumber } from '@/lib/phone';
import { appUrl } from '@/lib/slug';
import { sendRsvpHostNotification } from '@/lib/mail';
import { sendPushToUser } from '@/lib/push';
function twiml(message: string) {
const escaped = message
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return new NextResponse(
`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escaped}</Message></Response>`,
{ status: 200, headers: { 'Content-Type': 'text/xml' } }
);
}
const STATUS_LABEL: Record<string, string> = {
YES: "you're in",
NO: "you can't make it",
MAYBE: 'you might attend',
};
// Twilio POSTs application/x-www-form-urlencoded and signs the request
// against the exact URL it was configured to call plus these form params -
// we use APP_URL (the same value the webhook is registered with in the
// Twilio console) rather than trusting request.url, since a reverse proxy
// can rewrite what Next.js sees.
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const params = Object.fromEntries(new URLSearchParams(rawBody));
const signature = request.headers.get('x-twilio-signature');
const url = appUrl('/api/sms/webhook');
if (!verifyTwilioSignature(url, params, signature)) {
return new NextResponse('Invalid signature', { status: 403 });
}
const from = params.From;
const body = params.Body ?? '';
if (!from) {
return twiml("Sorry, we couldn't read your reply.");
}
const normalizedFrom = normalizePhoneNumber(from) ?? from;
// Guest-list membership is how an inbound text gets routed to an event at
// all - there's one shared Twilio number for every event hosted on this
// instance, so we match the most recent invite sent to this phone number.
const guestInvite = await prisma.guestInvite.findFirst({
where: { phone: normalizedFrom },
orderBy: { sentAt: 'desc' },
include: { event: { include: { host: true } } },
});
if (!guestInvite) {
return twiml(
"We couldn't find an invite for this number. Please use the link in your invite text, or contact the host directly."
);
}
const parsedReply = parseSmsRsvpReply(body);
if (!parsedReply) {
return twiml(
'Sorry, we didn\'t understand that. Please reply YES, NO, or MAYBE (optionally with a guest count, e.g. "YES 3").'
);
}
const { event } = guestInvite;
const numGuests = event.allowPlusOnes ? parsedReply.numGuests : 1;
const guestName = guestInvite.name || normalizedFrom;
const existing = await prisma.rsvp.findUnique({
where: { eventId_guestPhone: { eventId: event.id, guestPhone: normalizedFrom } },
});
await prisma.rsvp.upsert({
where: { eventId_guestPhone: { eventId: event.id, guestPhone: normalizedFrom } },
create: {
eventId: event.id,
guestName,
guestPhone: normalizedFrom,
status: parsedReply.status,
numGuests,
},
update: {
guestName,
status: parsedReply.status,
numGuests,
},
});
if (event.host.notifyOnRsvp) {
const manageUrl = appUrl(`/dashboard/events/${event.id}/guests`);
await sendRsvpHostNotification({
to: event.host.email,
guestName,
eventTitle: event.title,
status: parsedReply.status,
numGuests,
manageUrl,
isUpdate: Boolean(existing),
});
await sendPushToUser(event.host.id, {
title: existing ? 'RSVP updated' : 'New RSVP',
body: `${guestName} ${existing ? 'updated their RSVP to' : 'responded'} ${parsedReply.status} for ${event.title} (via text)`,
url: manageUrl,
});
}
return twiml(`Got it - ${STATUS_LABEL[parsedReply.status]} for ${event.title}! Thanks for letting ${event.host.name} know.`);
}

View File

@ -0,0 +1,60 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/session';
import { acceptCollaboratorInviteAction } from '@/app/actions/collaborators';
export default async function CollaborateInvitePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const collaborator = await prisma.eventCollaborator.findUnique({
where: { token },
include: { event: { select: { id: true, title: true } } },
});
if (!collaborator) notFound();
const isExpired = collaborator.status !== 'ACCEPTED' && collaborator.expiresAt < new Date();
const user = await getCurrentUser();
const nextPath = `/collaborate/${token}`;
const boundAccept = acceptCollaboratorInviteAction.bind(null, token);
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16 text-center">
<h1 className="text-2xl font-bold text-gray-900">Co-host invite</h1>
<p className="mt-3 text-gray-600">
You've been invited to help host <strong>{collaborator.event.title}</strong> edit
details, view RSVPs, and moderate comments together.
</p>
{isExpired ? (
<p className="mt-6 text-sm text-red-600">
This invite link has expired. Ask the host to send you a new one.
</p>
) : !user ? (
<div className="mt-6 flex flex-col gap-3">
<Link
href={`/signup?next=${encodeURIComponent(nextPath)}`}
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
>
Create an account to accept
</Link>
<Link
href={`/login?next=${encodeURIComponent(nextPath)}`}
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Log in to accept
</Link>
</div>
) : (
<form action={boundAccept} className="mt-6">
<button
type="submit"
className="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
>
Accept as {user.name}
</button>
</form>
)}
</main>
);
}

View File

@ -0,0 +1,37 @@
import { requireEventAccess } from '@/lib/eventAccess';
import { EventEditor } from '@/components/EventEditor';
import { type EventFormDefaults } from '@/components/EventForm';
import { updateEventAction } from '@/app/actions/events';
import { utcToZonedDatetimeLocal } from '@/lib/timezone';
export default async function EditEventPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { event } = await requireEventAccess(id);
const tz = event.timezone;
const defaults: EventFormDefaults = {
title: event.title,
occasion: event.occasion,
description: event.description,
contactInfo: event.contactInfo,
theme: event.theme,
timezone: tz,
eventDate: utcToZonedDatetimeLocal(event.eventDate, tz),
endDate: event.endDate ? utcToZonedDatetimeLocal(event.endDate, tz) : '',
location: event.location,
rsvpDeadline: event.rsvpDeadline ? utcToZonedDatetimeLocal(event.rsvpDeadline, tz) : '',
restrictRsvpToInvited: event.restrictRsvpToInvited,
allowPlusOnes: event.allowPlusOnes,
commentsEnabled: event.commentsEnabled,
foodProvided: event.foodProvided,
drinksProvided: event.drinksProvided,
photosEnabled: event.photosEnabled,
photoCloseDate: event.photoCloseDate ? utcToZonedDatetimeLocal(event.photoCloseDate, tz) : '',
};
const boundUpdateAction = updateEventAction.bind(null, event.id);
return (
<EventEditor heading="Edit invite" action={boundUpdateAction} defaults={defaults} submitLabel="Save changes" />
);
}

View File

@ -0,0 +1,325 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
import { accessibleEventWhere } from '@/lib/eventAccess';
import { appUrl } from '@/lib/slug';
import { getTheme } from '@/lib/themes';
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
import { ShareLinkBox } from '@/components/ShareLinkBox';
import { SendInvitesForm } from '@/components/SendInvitesForm';
import { InviteCollaboratorForm } from '@/components/InviteCollaboratorForm';
import { regenerateSlugAction, deleteEventAction } from '@/app/actions/events';
import { sendInvitesAction, removeGuestFromListAction } from '@/app/actions/invites';
import { toggleCommentHiddenAction, deleteCommentAction } from '@/app/actions/comments';
import { inviteCollaboratorAction, removeCollaboratorAction } from '@/app/actions/collaborators';
import { togglePhotoHiddenAction, deletePhotoAction, toggleForceCloseAction } from '@/app/actions/photos';
import { formatEventDateRange } from '@/lib/format';
import { isPhotoUploadOpen } from '@/lib/photoWindow';
import { photoUrl } from '@/lib/photoStorage';
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await requireUser();
const event = await prisma.event.findFirst({
where: accessibleEventWhere(id, user.id),
include: {
rsvps: { orderBy: { createdAt: 'desc' } },
comments: { orderBy: { createdAt: 'desc' } },
guestInvites: { orderBy: { sentAt: 'desc' } },
collaborators: { orderBy: { createdAt: 'asc' }, include: { user: { select: { name: true } } } },
host: { select: { name: true, email: true } },
photos: { orderBy: { createdAt: 'desc' } },
},
});
if (!event) notFound();
const isPrimaryHost = event.hostId === user.id;
const photosOpen = isPhotoUploadOpen(event);
const theme = getTheme(event.theme);
const inviteUrl = appUrl(`/invite/${event.slug}`);
const tally = {
YES: event.rsvps.filter((r) => r.status === 'YES').reduce((s, r) => s + r.numGuests, 0),
NO: event.rsvps.filter((r) => r.status === 'NO').length,
MAYBE: event.rsvps.filter((r) => r.status === 'MAYBE').reduce((s, r) => s + r.numGuests, 0),
};
const boundRegenerate = regenerateSlugAction.bind(null, event.id);
const boundSendInvites = sendInvitesAction.bind(null, event.id);
const boundInviteCollaborator = inviteCollaboratorAction.bind(null, event.id);
const boundToggleForceClose = toggleForceCloseAction.bind(null, event.id);
return (
<ThemeBackground theme={theme} applyTextStyle={false} className="flex-1 p-6 sm:p-10">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
<div className="flex flex-col gap-4 rounded-lg p-5 sm:flex-row sm:items-start sm:justify-between" style={adminCardStyle}>
<div className="flex items-center gap-3">
<span
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-2xl"
style={{ background: theme.colors.bg, border: `2px solid ${theme.colors.accent}` }}
>
{theme.emoji}
</span>
<div>
<h1 className="text-2xl font-bold text-gray-900">{event.title}</h1>
<p className="text-sm text-gray-500">{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}</p>
</div>
</div>
<div className="flex gap-2">
<Link
href={`/dashboard/events/${event.id}/edit`}
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Edit
</Link>
{isPrimaryHost && (
<form action={deleteEventAction.bind(null, event.id)}>
<button
type="submit"
className="rounded-md border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
>
Delete
</button>
</form>
)}
</div>
</div>
<section className="rounded-lg p-5" style={adminCardStyle}>
<h2 className="font-semibold text-gray-900">Share link</h2>
<p className="mt-1 text-sm text-gray-500">
{event.restrictRsvpToInvited
? 'Anyone with this link can view the invite, but only people on your guest list can RSVP.'
: 'Anyone with this link can view the invite and RSVP.'}{' '}
No account needed.
</p>
<div className="mt-3">
<ShareLinkBox url={inviteUrl} onRegenerate={boundRegenerate} />
</div>
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
<h2 className="font-semibold text-gray-900">Co-hosts</h2>
<p className="mt-1 text-sm text-gray-500">
Co-hosts can edit the invite, view RSVPs, and moderate comments, but can't delete the
event or manage other co-hosts.
</p>
<ul className="mt-3 flex flex-col gap-2 text-sm">
<li className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<span>
{event.host.name} <span className="text-gray-400">({event.host.email})</span>
</span>
<span className="text-xs font-medium text-gray-500">Primary host</span>
</li>
{event.collaborators.map((collaborator) => (
<li key={collaborator.id} className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<span>
{collaborator.user?.name ?? collaborator.invitedEmail}{' '}
<span className="text-gray-400">({collaborator.invitedEmail})</span>
</span>
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-gray-500">
{collaborator.status === 'ACCEPTED' ? 'Co-host' : 'Invite pending'}
</span>
{isPrimaryHost && (
<form action={removeCollaboratorAction.bind(null, collaborator.id)}>
<button type="submit" className="text-xs text-red-500 hover:text-red-700">
Remove
</button>
</form>
)}
</div>
</li>
))}
</ul>
{isPrimaryHost && (
<div className="mt-4">
<InviteCollaboratorForm action={boundInviteCollaborator} />
</div>
)}
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
<h2 className="font-semibold text-gray-900">Guest list</h2>
<p className="mt-1 text-sm text-gray-500">
Entering an email or phone number here always adds them to your guest list, whether or
not the invite itself successfully sends. Phone numbers get a text invite guests can
reply YES/NO/MAYBE to directly, no account or link click needed.
{event.restrictRsvpToInvited && ' Since RSVPs are restricted, only these people can RSVP.'}
</p>
<div className="mt-3">
<SendInvitesForm action={boundSendInvites} />
</div>
{event.restrictRsvpToInvited && event.guestInvites.length === 0 && (
<p className="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800">
RSVPs are restricted to your guest list, but no one is on it yet - every RSVP attempt
will be rejected until you add guests above.
</p>
)}
{event.guestInvites.length > 0 && (
<ul className="mt-4 flex flex-col gap-2 text-sm">
{event.guestInvites.map((invite) => (
<li key={invite.id} className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<span>{invite.email ?? invite.phone}</span>
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-gray-500">
{invite.email
? invite.emailSent
? 'Emailed'
: 'Added, not emailed'
: invite.smsSent
? 'Texted'
: 'Added, not texted'}
</span>
<form action={removeGuestFromListAction.bind(null, invite.id)}>
<button type="submit" className="text-xs text-red-500 hover:text-red-700">
Remove
</button>
</form>
</div>
</li>
))}
</ul>
)}
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
<h2 className="font-semibold text-gray-900">
RSVPs
{event.restrictRsvpToInvited && (
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
Restricted to guest list
</span>
)}
</h2>
<div className="mt-2 flex gap-6 text-sm">
<span className="text-green-700">{tally.YES} attending</span>
<span className="text-amber-700">{tally.MAYBE} maybe</span>
<span className="text-gray-500">{tally.NO} declined</span>
</div>
{event.rsvps.length === 0 ? (
<p className="mt-4 text-sm text-gray-500">No responses yet.</p>
) : (
<table className="mt-4 w-full text-left text-sm">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 font-medium">Name</th>
<th className="py-2 font-medium">Status</th>
<th className="py-2 font-medium">Guests</th>
<th className="py-2 font-medium">Note</th>
</tr>
</thead>
<tbody>
{event.rsvps.map((rsvp) => (
<tr key={rsvp.id} className="border-b last:border-0">
<td className="py-2">
<div>{rsvp.guestName}</div>
<div className="text-xs text-gray-400">{rsvp.guestEmail}</div>
</td>
<td className="py-2">{rsvp.status}</td>
<td className="py-2">{rsvp.numGuests}</td>
<td className="py-2 text-gray-500">{rsvp.note}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
<h2 className="font-semibold text-gray-900">Comments</h2>
{event.comments.length === 0 ? (
<p className="mt-4 text-sm text-gray-500">No comments yet.</p>
) : (
<ul className="mt-4 flex flex-col gap-3">
{event.comments.map((comment) => (
<li
key={comment.id}
className={`rounded-md border p-3 text-sm ${comment.hidden ? 'opacity-50' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-medium text-gray-800">
{comment.authorName} {comment.hidden && <span className="text-xs text-gray-400">(hidden)</span>}
</p>
<p className="mt-1 text-gray-600">{comment.body}</p>
</div>
<div className="flex shrink-0 gap-2 text-xs">
<form action={toggleCommentHiddenAction.bind(null, comment.id)}>
<button type="submit" className="text-gray-500 hover:text-gray-800">
{comment.hidden ? 'Unhide' : 'Hide'}
</button>
</form>
<form action={deleteCommentAction.bind(null, comment.id)}>
<button type="submit" className="text-red-500 hover:text-red-700">
Delete
</button>
</form>
</div>
</div>
</li>
))}
</ul>
)}
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Photos</h2>
{event.photosEnabled && (
<form action={boundToggleForceClose}>
<button
type="submit"
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
{event.photosClosed ? 'Reopen uploads' : 'Close uploads now'}
</button>
</form>
)}
</div>
<p className="mt-1 text-sm text-gray-500">
{!event.photosEnabled
? 'Disabled for this event - enable "Let guests share photos" on the Edit page.'
: photosOpen
? 'Open - guests can upload photos on the invite page.'
: event.photosClosed
? 'Closed - you closed uploads manually.'
: new Date() < event.eventDate
? 'Not open yet - uploads start once the event begins.'
: 'Closed - the photo close time has passed.'}
</p>
{event.photos.length === 0 ? (
<p className="mt-4 text-sm text-gray-500">No photos yet.</p>
) : (
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
{event.photos.map((photo) => (
<div key={photo.id} className={`relative aspect-square overflow-hidden rounded-md ${photo.hidden ? 'opacity-40' : ''}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photoUrl(event.id, photo.filename)}
alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'}
className="h-full w-full object-cover"
loading="lazy"
/>
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-2 py-1 text-xs text-white">
<form action={togglePhotoHiddenAction.bind(null, photo.id)}>
<button type="submit" className="hover:underline">
{photo.hidden ? 'Unhide' : 'Hide'}
</button>
</form>
<form action={deletePhotoAction.bind(null, photo.id)}>
<button type="submit" className="text-red-300 hover:underline">
Delete
</button>
</form>
</div>
</div>
))}
</div>
)}
</section>
</div>
</ThemeBackground>
);
}

View File

@ -0,0 +1,6 @@
import { EventEditor } from '@/components/EventEditor';
import { createEventAction } from '@/app/actions/events';
export default function NewEventPage() {
return <EventEditor heading="Create an invite" action={createEventAction} submitLabel="Create invite" />;
}

View File

@ -0,0 +1,38 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/session';
import { logoutAction } from '@/app/actions/auth';
import { EmailVerificationBanner } from '@/components/EmailVerificationBanner';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser();
if (!user) redirect('/login');
return (
<div className="flex min-h-screen flex-col bg-gray-50">
<header className="border-b bg-white">
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
<Link href="/dashboard" className="text-lg font-bold text-gray-900">
E-Invite
</Link>
<div className="flex items-center gap-4 text-sm">
<Link href="/dashboard/settings" className="text-gray-500 hover:text-gray-800">
Settings
</Link>
<span className="text-gray-600">{user.name}</span>
<form action={logoutAction}>
<button type="submit" className="font-medium text-gray-500 hover:text-gray-800">
Log out
</button>
</form>
</div>
</div>
</header>
{!user.emailVerified && <EmailVerificationBanner />}
{/* No padding/max-width here: most pages opt into it themselves via
PageContainer, but the themed event editor/guests pages need to
paint their background edge-to-edge like the public invite page. */}
<div className="flex flex-1 flex-col">{children}</div>
</div>
);
}

View File

@ -0,0 +1,90 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
import { getTheme } from '@/lib/themes';
import { PageContainer } from '@/components/PageContainer';
export default async function DashboardPage() {
const user = await requireUser();
const events = await prisma.event.findMany({
where: {
OR: [{ hostId: user.id }, { collaborators: { some: { userId: user.id, status: 'ACCEPTED' } } }],
},
orderBy: { eventDate: 'asc' },
include: {
_count: { select: { comments: true } },
rsvps: { select: { status: true, numGuests: true } },
},
});
return (
<PageContainer>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Your events</h1>
<Link
href="/dashboard/events/new"
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
>
New event
</Link>
</div>
{events.length === 0 ? (
<p className="mt-8 text-gray-600">
You haven't created any events yet.{' '}
<Link href="/dashboard/events/new" className="font-medium text-indigo-600 hover:text-indigo-500">
Create your first invite
</Link>
.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{events.map((event) => {
const theme = getTheme(event.theme);
const yesCount = event.rsvps
.filter((r) => r.status === 'YES')
.reduce((sum, r) => sum + r.numGuests, 0);
return (
<li key={event.id}>
<Link
href={`/dashboard/events/${event.id}/guests`}
className="flex items-center justify-between rounded-lg border bg-white px-5 py-4 hover:border-indigo-300"
>
<div className="flex items-center gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-full text-lg"
style={{ background: theme.colors.bg }}
>
{theme.emoji}
</span>
<div>
<p className="font-medium text-gray-900">
{event.title}
{event.hostId !== user.id && (
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
Co-hosting
</span>
)}
</p>
<p className="text-sm text-gray-500">
{event.eventDate.toLocaleDateString('en-US', {
dateStyle: 'medium',
timeZone: event.timezone,
})}
</p>
</div>
</div>
<div className="text-right text-sm text-gray-500">
<p>{yesCount} attending</p>
<p>{event._count.comments} comments</p>
</div>
</Link>
</li>
);
})}
</ul>
)}
</PageContainer>
);
}

View File

@ -0,0 +1,38 @@
import { requireUser } from '@/lib/session';
import { PageContainer } from '@/components/PageContainer';
import { NotificationPreferencesForm } from '@/components/NotificationPreferencesForm';
import { PushNotificationToggle } from '@/components/PushNotificationToggle';
export default async function SettingsPage() {
const user = await requireUser();
return (
<PageContainer>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<div className="mt-6 flex max-w-md flex-col gap-6">
<div className="rounded-lg border bg-white p-6">
<h2 className="font-semibold text-gray-900">Email notifications</h2>
<p className="mt-1 text-sm text-gray-500">
Choose which emails you get as a host. This applies to events you host directly - not
ones you're co-hosting.
</p>
<div className="mt-4">
<NotificationPreferencesForm
notifyOnRsvp={user.notifyOnRsvp}
notifyOnComment={user.notifyOnComment}
notifyOnCoHostAccepted={user.notifyOnCoHostAccepted}
/>
</div>
</div>
<div className="rounded-lg border bg-white p-6">
<h2 className="font-semibold text-gray-900">Browser push notifications</h2>
<div className="mt-4">
<PushNotificationToggle />
</div>
</div>
</div>
</PageContainer>
);
}

26
src/app/error.tsx Normal file
View File

@ -0,0 +1,26 @@
'use client';
export default function GlobalError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-2xl font-bold text-gray-900">Something went wrong</h1>
<p className="text-gray-600">
Sorry about that - an unexpected error occurred. You can try again, or head back home.
</p>
<div className="flex gap-3">
<button
onClick={reset}
className="rounded-md bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-500"
>
Try again
</button>
<a
href="/"
className="rounded-md border border-gray-300 px-4 py-2 font-medium text-gray-700 hover:bg-gray-50"
>
Go home
</a>
</div>
</main>
);
}

73
src/app/globals.css Normal file
View File

@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--theme-bg: #fafafa;
--theme-surface: #ffffff;
--theme-primary: #111827;
--theme-accent: #2563eb;
--theme-text: #111827;
}
body {
color: #111827;
background: #f3f4f6;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pop-in {
0% {
transform: scale(0.9);
}
60% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(var(--confetti-fall-distance, 220px)) rotate(var(--confetti-spin, 300deg));
opacity: 0;
}
}
.reveal {
opacity: 0;
animation: fade-in-up 0.5s ease-out forwards;
}
.pop-in {
animation: pop-in 0.35s ease-out;
}
.confetti-piece {
animation: confetti-fall var(--confetti-duration, 1.1s) ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
.reveal,
.pop-in,
.confetti-piece {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}

View File

@ -0,0 +1,145 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { getTheme } from '@/lib/themes';
import { ThemedInviteLayout } from '@/components/ThemedInviteLayout';
import { ThemeDivider } from '@/components/ThemeDivider';
import { themeCardStyle as cardStyle } from '@/components/ThemeBackground';
import { RsvpForm } from '@/components/RsvpForm';
import { CommentForm } from '@/components/CommentForm';
import { PhotoUploadForm } from '@/components/PhotoUploadForm';
import { PhotoGallery } from '@/components/PhotoGallery';
import { formatEventDateRange } from '@/lib/format';
import { isPhotoUploadOpen } from '@/lib/photoWindow';
import { photoUrl } from '@/lib/photoStorage';
export default async function PublicInvitePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const event = await prisma.event.findUnique({
where: { slug },
include: {
host: { select: { name: true } },
comments: { where: { hidden: false }, orderBy: { createdAt: 'desc' } },
photos: { where: { hidden: false }, orderBy: { createdAt: 'desc' } },
},
});
if (!event) notFound();
const theme = getTheme(event.theme);
const rsvpClosed = Boolean(event.rsvpDeadline && event.rsvpDeadline < new Date());
const photosOpen = isPhotoUploadOpen(event);
const showPhotos = event.photosEnabled && (photosOpen || event.photos.length > 0);
return (
<ThemedInviteLayout theme={theme}>
<div className="reveal rounded-lg p-6 text-center sm:p-8" style={{ ...cardStyle, animationDelay: '0s' }}>
<div
className="mx-auto flex h-20 w-20 items-center justify-center rounded-full text-4xl"
style={{ background: 'var(--theme-bg)', border: '2px solid var(--theme-accent)' }}
>
{theme.emoji}
</div>
<p className="mt-4 text-sm uppercase tracking-wide opacity-70">You're invited by {event.host.name}</p>
<h1 className="mt-2 break-words text-3xl font-bold sm:text-4xl">{event.title}</h1>
{event.occasion && <p className="mt-1 italic opacity-80">{event.occasion}</p>}
<p className="mt-3 text-lg">{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}</p>
{event.location && <p className="mt-1 opacity-80">{event.location}</p>}
{(event.foodProvided || event.drinksProvided) && (
<div className="mt-3 flex flex-wrap justify-center gap-2">
{event.foodProvided && (
<span
className="rounded-full px-3 py-1 text-xs font-medium"
style={{ background: 'var(--theme-bg)', border: '1px solid var(--theme-accent)' }}
>
🍽 Food will be provided
</span>
)}
{event.drinksProvided && (
<span
className="rounded-full px-3 py-1 text-xs font-medium"
style={{ background: 'var(--theme-bg)', border: '1px solid var(--theme-accent)' }}
>
🍹 Drinks will be provided
</span>
)}
</div>
)}
</div>
<ThemeDivider theme={theme} />
{event.description && (
<div
className="reveal mt-6 whitespace-pre-wrap rounded-lg p-6 text-center"
style={{ ...cardStyle, animationDelay: '0.1s' }}
>
{event.description}
</div>
)}
<div className="reveal mt-10 rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.2s' }}>
<h2 className="text-xl font-semibold">RSVP</h2>
{event.restrictRsvpToInvited && (
<p className="mt-1 text-xs opacity-60">This event is invite-only.</p>
)}
{rsvpClosed ? (
<p className="mt-3 text-sm opacity-70">RSVPs are closed for this event.</p>
) : (
<div className="mt-4">
<RsvpForm eventId={event.id} allowPlusOnes={event.allowPlusOnes} />
</div>
)}
</div>
{event.commentsEnabled && (
<>
<ThemeDivider theme={theme} />
<div className="reveal rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.3s' }}>
<h2 className="text-xl font-semibold">Well wishes</h2>
<div className="mt-4">
<CommentForm eventId={event.id} />
</div>
{event.comments.length > 0 && (
<ul className="mt-6 flex flex-col gap-4">
{event.comments.map((comment) => (
<li key={comment.id} className="border-t pt-4 first:border-0 first:pt-0" style={{ borderColor: 'var(--theme-accent)' }}>
<p className="font-medium">{comment.authorName}</p>
<p className="mt-1 whitespace-pre-wrap opacity-90">{comment.body}</p>
</li>
))}
</ul>
)}
</div>
</>
)}
{showPhotos && (
<>
<ThemeDivider theme={theme} />
<div className="reveal rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.4s' }}>
<h2 className="text-xl font-semibold">Photos</h2>
{photosOpen ? (
<div className="mt-4">
<PhotoUploadForm eventId={event.id} />
</div>
) : (
<p className="mt-3 text-sm opacity-70">Photo uploads are closed for this event.</p>
)}
<PhotoGallery
photos={event.photos.map((p) => ({
id: p.id,
url: photoUrl(event.id, p.filename),
uploaderName: p.uploaderName,
}))}
/>
</div>
</>
)}
{event.contactInfo && (
<p className="mt-8 text-center text-sm opacity-70">{event.contactInfo}</p>
)}
</ThemedInviteLayout>
);
}

16
src/app/layout.tsx Normal file
View File

@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { allFontVariables } from '@/lib/fonts';
import './globals.css';
export const metadata: Metadata = {
title: 'E-Invite',
description: 'Create and share themed online invitations',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={allFontVariables}>
<body className="min-h-screen antialiased">{children}</body>
</html>
);
}

18
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from 'next/link';
export default function NotFound() {
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-2xl font-bold text-gray-900">Page not found</h1>
<p className="text-gray-600">
The page or invite you're looking for doesn't exist, or the link may have been regenerated.
</p>
<Link
href="/"
className="rounded-md bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-500"
>
Go home
</Link>
</main>
);
}

55
src/app/page.tsx Normal file
View File

@ -0,0 +1,55 @@
import Link from 'next/link';
import { getCurrentUser } from '@/lib/session';
import { redirect } from 'next/navigation';
import { THEMES } from '@/lib/themes';
export default async function HomePage() {
const user = await getCurrentUser();
if (user) redirect('/dashboard');
return (
<main className="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center gap-8 px-6 py-16 text-center">
<div>
<h1 className="text-4xl font-bold tracking-tight text-gray-900">E-Invite</h1>
<p className="mt-3 text-lg text-gray-600">
Create beautiful themed online invitations, collect RSVPs, and share a single link
with everyone you're inviting.
</p>
</div>
<div className="flex gap-3">
<Link
href="/signup"
className="rounded-md bg-indigo-600 px-5 py-2.5 font-medium text-white hover:bg-indigo-500"
>
Get started
</Link>
<Link
href="/login"
className="rounded-md border border-gray-300 px-5 py-2.5 font-medium text-gray-700 hover:bg-gray-50"
>
Log in
</Link>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{THEMES.map((theme) => (
<div
key={theme.id}
className="flex flex-col items-center gap-1 rounded-lg border px-4 py-3 text-sm"
style={{
backgroundColor: theme.colors.bg,
backgroundImage: theme.pattern.backgroundImage,
backgroundSize: theme.pattern.backgroundSize,
color: theme.colors.text,
borderColor: theme.colors.accent,
}}
>
<span className="text-xl">{theme.emoji}</span>
{theme.name}
</div>
))}
</div>
</main>
);
}

View File

@ -0,0 +1,21 @@
import Link from 'next/link';
import { verifyEmailAction } from '@/app/actions/auth';
export default async function VerifyEmailPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const result = await verifyEmailAction(token);
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16 text-center">
<h1 className="text-2xl font-bold text-gray-900">Email verification</h1>
{result.success ? (
<p className="mt-3 text-green-700">{result.success}</p>
) : (
<p className="mt-3 text-red-600">{result.error}</p>
)}
<Link href="/dashboard" className="mt-6 font-medium text-indigo-600 hover:text-indigo-500">
Go to dashboard
</Link>
</main>
);
}

View File

@ -0,0 +1,48 @@
'use client';
import { useActionState } from 'react';
import { postCommentAction, type ActionState } from '@/app/actions/comments';
import { SubmitButton } from '@/components/SubmitButton';
import { Honeypot } from '@/components/Honeypot';
const initialState: ActionState = {};
export function CommentForm({ eventId }: { eventId: string }) {
const [state, formAction] = useActionState(postCommentAction, initialState);
return (
<form action={formAction} className="flex flex-col gap-3">
<Honeypot />
<input type="hidden" name="eventId" value={eventId} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<input
name="authorName"
type="text"
placeholder="Your name"
required
className="rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
/>
<input
name="authorEmail"
type="email"
placeholder="Email (optional)"
className="rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
/>
</div>
<textarea
name="body"
rows={3}
placeholder="Leave a message for the host..."
required
className="rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
/>
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
<SubmitButton
className="self-start rounded-md px-5 py-2 font-medium text-white disabled:opacity-60"
style={{ background: 'var(--theme-primary)' }}
>
Post comment
</SubmitButton>
</form>
);
}

View File

@ -0,0 +1,43 @@
'use client';
const COLORS = ['#e0218a', '#ffb703', '#2563eb', '#3f6b3a', '#9333ea', '#00e5ff'];
const PIECES = 18;
/** A one-shot burst of falling confetti pieces, purely CSS-animated. Meant
* to be mounted once (e.g. alongside a success message) and left in place -
* the animation runs once and pieces end fully transparent. */
export function ConfettiBurst() {
const pieces = Array.from({ length: PIECES }, (_, i) => {
const left = Math.random() * 100;
const delay = Math.random() * 0.25;
const duration = 0.9 + Math.random() * 0.6;
const fallDistance = 160 + Math.random() * 120;
const spin = (Math.random() > 0.5 ? 1 : -1) * (200 + Math.random() * 250);
const color = COLORS[i % COLORS.length];
const size = 6 + Math.random() * 5;
return { left, delay, duration, fallDistance, spin, color, size, key: i };
});
return (
<div className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-visible" aria-hidden="true">
{pieces.map((p) => (
<span
key={p.key}
className="confetti-piece absolute top-0 block rounded-sm"
style={
{
left: `${p.left}%`,
width: p.size,
height: p.size * 0.6,
backgroundColor: p.color,
animationDelay: `${p.delay}s`,
'--confetti-duration': `${p.duration}s`,
'--confetti-fall-distance': `${p.fallDistance}px`,
'--confetti-spin': `${p.spin}deg`,
} as React.CSSProperties
}
/>
))}
</div>
);
}

View File

@ -0,0 +1,30 @@
'use client';
import { useActionState } from 'react';
import { resendVerificationEmailAction } from '@/app/actions/auth';
import { SubmitButton } from '@/components/SubmitButton';
type ActionState = { error?: string; success?: string };
const initialState: ActionState = {};
export function EmailVerificationBanner() {
const [state, formAction] = useActionState(resendVerificationEmailAction, initialState);
if (state.success) {
return (
<div className="border-b border-green-200 bg-green-50 px-6 py-2 text-center text-sm text-green-800">
{state.success}
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-2 border-b border-amber-200 bg-amber-50 px-6 py-2 text-sm text-amber-800 sm:flex-row">
<span>Please verify your email address.</span>
<form action={formAction}>
<SubmitButton className="font-medium underline hover:no-underline">Resend verification email</SubmitButton>
</form>
{state.error && <span className="text-red-700">{state.error}</span>}
</div>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import { useState } from 'react';
import { getTheme, DEFAULT_THEME_ID } from '@/lib/themes';
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
import { EventForm, type EventActionState, type EventFormDefaults } from '@/components/EventForm';
export function EventEditor({
heading,
action,
defaults,
submitLabel,
}: {
heading: string;
action: (state: EventActionState, formData: FormData) => Promise<EventActionState>;
defaults?: EventFormDefaults;
submitLabel: string;
}) {
const [themeId, setThemeId] = useState(defaults?.theme ?? DEFAULT_THEME_ID);
const theme = getTheme(themeId);
return (
<ThemeBackground theme={theme} applyTextStyle={false} className="flex-1 p-6 sm:p-10">
<div className="mx-auto max-w-2xl rounded-lg p-6" style={adminCardStyle}>
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
<div className="mt-6">
<EventForm action={action} defaults={defaults} submitLabel={submitLabel} onThemeChange={setThemeId} />
</div>
</div>
</ThemeBackground>
);
}

View File

@ -0,0 +1,273 @@
'use client';
import { useActionState, useEffect, useState } from 'react';
import { ThemePicker } from '@/components/ThemePicker';
import { SubmitButton } from '@/components/SubmitButton';
import { DEFAULT_THEME_ID } from '@/lib/themes';
import { TIMEZONES } from '@/lib/timezone';
export type EventActionState = { error?: string };
export type EventFormDefaults = {
title: string;
occasion: string;
description: string;
contactInfo: string;
theme: string;
timezone: string;
eventDate: string;
endDate: string;
location: string;
rsvpDeadline: string;
restrictRsvpToInvited: boolean;
allowPlusOnes: boolean;
commentsEnabled: boolean;
foodProvided: boolean;
drinksProvided: boolean;
photosEnabled: boolean;
photoCloseDate: string;
};
const emptyDefaults: EventFormDefaults = {
title: '',
occasion: '',
description: '',
contactInfo: '',
theme: DEFAULT_THEME_ID,
timezone: '',
eventDate: '',
endDate: '',
location: '',
rsvpDeadline: '',
restrictRsvpToInvited: false,
allowPlusOnes: true,
commentsEnabled: true,
foodProvided: false,
drinksProvided: false,
photosEnabled: true,
photoCloseDate: '',
};
export function EventForm({
action,
defaults = emptyDefaults,
submitLabel,
onThemeChange,
}: {
action: (state: EventActionState, formData: FormData) => Promise<EventActionState>;
defaults?: EventFormDefaults;
submitLabel: string;
onThemeChange?: (themeId: string) => void;
}) {
const [state, formAction] = useActionState(action, {});
// New events (empty default) auto-detect the browser's zone; edits keep
// whatever was saved.
const [timezone, setTimezone] = useState(defaults.timezone);
useEffect(() => {
if (!defaults.timezone) {
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [defaults.timezone]);
return (
<form action={formAction} className="flex flex-col gap-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
Event title
</label>
<input
id="title"
name="title"
type="text"
required
defaultValue={defaults.title}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="occasion" className="block text-sm font-medium text-gray-700">
Who or what is this for? (optional)
</label>
<input
id="occasion"
name="occasion"
type="text"
placeholder="e.g. Jane's 30th Birthday, The Smith Family Reunion"
defaultValue={defaults.occasion}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
name="description"
rows={4}
defaultValue={defaults.description}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
Start date &amp; time
</label>
<input
id="eventDate"
name="eventDate"
type="datetime-local"
required
defaultValue={defaults.eventDate}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="endDate" className="block text-sm font-medium text-gray-700">
End date &amp; time (optional)
</label>
<input
id="endDate"
name="endDate"
type="datetime-local"
defaultValue={defaults.endDate}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
</div>
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
Timezone
</label>
<select
id="timezone"
name="timezone"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
>
{!timezone && <option value="">Select a timezone</option>}
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz.replace(/_/g, ' ')}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
All times on this invite (start, end, RSVP deadline, photo window) are interpreted in
this timezone, regardless of where a guest views it from.
</p>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
Location
</label>
<input
id="location"
name="location"
type="text"
defaultValue={defaults.location}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="contactInfo" className="block text-sm font-medium text-gray-700">
Contact info for questions (optional)
</label>
<input
id="contactInfo"
name="contactInfo"
type="text"
placeholder="e.g. Text Chris at 555-1234 with any questions"
defaultValue={defaults.contactInfo}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="rsvpDeadline" className="block text-sm font-medium text-gray-700">
RSVP deadline (optional)
</label>
<input
id="rsvpDeadline"
name="rsvpDeadline"
type="datetime-local"
defaultValue={defaults.rsvpDeadline}
className="mt-1 w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="photoCloseDate" className="block text-sm font-medium text-gray-700">
Photo uploads close at (optional)
</label>
<input
id="photoCloseDate"
name="photoCloseDate"
type="datetime-local"
defaultValue={defaults.photoCloseDate}
className="mt-1 w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">
Guests can share photos once the event starts. Leave blank to keep uploads open
indefinitely (you can also close them manually at any time).
</p>
</div>
<div>
<span className="block text-sm font-medium text-gray-700">Theme</span>
<div className="mt-2">
<ThemePicker defaultThemeId={defaults.theme} onChange={onThemeChange} />
</div>
</div>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
name="restrictRsvpToInvited"
defaultChecked={defaults.restrictRsvpToInvited}
/>
Only let people on my guest list RSVP
</label>
<p className="-mt-1 ml-6 text-xs text-gray-500">
Uses the guest list you build on the guest-management page (adding someone there always
adds them to the list, whether or not the invite email sends).
</p>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="allowPlusOnes" defaultChecked={defaults.allowPlusOnes} />
Allow guests to bring plus-ones
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="commentsEnabled" defaultChecked={defaults.commentsEnabled} />
Allow comments / well-wishes
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="foodProvided" defaultChecked={defaults.foodProvided} />
Food will be available
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="drinksProvided" defaultChecked={defaults.drinksProvided} />
Drinks will be available
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="photosEnabled" defaultChecked={defaults.photosEnabled} />
Let guests share photos during/after the event
</label>
</div>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
<SubmitButton className="rounded-md bg-indigo-600 px-5 py-2.5 font-medium text-white hover:bg-indigo-500 disabled:opacity-60 sm:self-start">
{submitLabel}
</SubmitButton>
</form>
);
}

View File

@ -0,0 +1,10 @@
// Hidden field bots tend to fill blindly; humans never see or fill it.
// Server actions treat a non-empty value as a bot and silently no-op.
export function Honeypot() {
return (
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px' }}>
<label htmlFor="website">Leave this field empty</label>
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>
);
}

View File

@ -0,0 +1,36 @@
'use client';
import { useActionState } from 'react';
import { SubmitButton } from '@/components/SubmitButton';
import type { ActionState } from '@/app/actions/collaborators';
export function InviteCollaboratorForm({
action,
}: {
action: (state: ActionState, formData: FormData) => Promise<ActionState>;
}) {
const [state, formAction] = useActionState(action, {});
return (
<form action={formAction} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<label htmlFor="collaborator-email" className="text-sm font-medium text-gray-700">
Co-host's email
</label>
<input
id="collaborator-email"
name="email"
type="email"
required
placeholder="friend@example.com"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
/>
</div>
<SubmitButton className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
Send invite
</SubmitButton>
{state.error && <p className="text-sm text-red-600 sm:basis-full">{state.error}</p>}
{state.success && <p className="text-sm text-green-700 sm:basis-full">{state.success}</p>}
</form>
);
}

View File

@ -0,0 +1,42 @@
'use client';
import { useActionState } from 'react';
import { updateNotificationPreferencesAction, type ActionState } from '@/app/actions/settings';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: ActionState = {};
export function NotificationPreferencesForm({
notifyOnRsvp,
notifyOnComment,
notifyOnCoHostAccepted,
}: {
notifyOnRsvp: boolean;
notifyOnComment: boolean;
notifyOnCoHostAccepted: boolean;
}) {
const [state, formAction] = useActionState(updateNotificationPreferencesAction, initialState);
return (
<form action={formAction} className="flex flex-col gap-3">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="notifyOnRsvp" defaultChecked={notifyOnRsvp} />
Email me when a guest RSVPs
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="notifyOnComment" defaultChecked={notifyOnComment} />
Email me when a guest leaves a comment
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="notifyOnCoHostAccepted" defaultChecked={notifyOnCoHostAccepted} />
Email me when someone accepts a co-host invite
</label>
{state.success && <p className="pop-in text-sm text-green-700">{state.success}</p>}
<SubmitButton className="self-start rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
Save preferences
</SubmitButton>
</form>
);
}

View File

@ -0,0 +1,3 @@
export function PageContainer({ children }: { children: React.ReactNode }) {
return <div className="mx-auto w-full max-w-4xl px-6 py-8">{children}</div>;
}

View File

@ -0,0 +1,28 @@
type GalleryPhoto = {
id: string;
url: string;
uploaderName: string;
};
export function PhotoGallery({ photos }: { photos: GalleryPhoto[] }) {
if (photos.length === 0) return null;
return (
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
{photos.map((photo) => (
<div key={photo.id} className="group relative aspect-square overflow-hidden rounded-md">
{/* Guest-uploaded photos aren't known ahead of time, so next/image's
static optimization doesn't gain much here - plain <img> keeps
this simple. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo.url} alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'} className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105" loading="lazy" />
{photo.uploaderName && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
{photo.uploaderName}
</span>
)}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import { useActionState } from 'react';
import { uploadPhotoAction, type ActionState } from '@/app/actions/photos';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: ActionState = {};
export function PhotoUploadForm({ eventId }: { eventId: string }) {
const [state, formAction] = useActionState(uploadPhotoAction, initialState);
return (
<form action={formAction} className="flex flex-col gap-3">
<input type="hidden" name="eventId" value={eventId} />
<div>
<label htmlFor="photo" className="block text-sm font-medium">
Add a photo
</label>
<input
id="photo"
name="photo"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-sm text-gray-900 focus:outline-none"
/>
</div>
<input
name="uploaderName"
type="text"
placeholder="Your name (optional)"
className="rounded-md border border-black/10 bg-white/90 px-3 py-2 text-sm text-gray-900 focus:outline-none"
/>
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
{state.success && <p className="pop-in text-sm text-green-700">{state.success}</p>}
<SubmitButton
className="self-start rounded-md px-5 py-2 font-medium text-white disabled:opacity-60"
style={{ background: 'var(--theme-primary)' }}
>
Upload
</SubmitButton>
</form>
);
}

View File

@ -0,0 +1,94 @@
'use client';
import { useEffect, useState } from 'react';
import { subscribeToPushAction, unsubscribeFromPushAction } from '@/app/actions/push';
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
export function PushNotificationToggle() {
const [supported, setSupported] = useState(false);
const [subscribed, setSubscribed] = useState(false);
const [status, setStatus] = useState('');
useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !VAPID_PUBLIC_KEY) return;
setSupported(true);
navigator.serviceWorker.register('/sw.js').then(async (registration) => {
const existing = await registration.pushManager.getSubscription();
setSubscribed(!!existing);
});
}, []);
async function enable() {
setStatus('');
if (!VAPID_PUBLIC_KEY) {
setStatus('Push notifications are not configured on this server.');
return;
}
try {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
setStatus('Notification permission was denied.');
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
const json = subscription.toJSON();
await subscribeToPushAction({
endpoint: json.endpoint!,
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
});
setSubscribed(true);
setStatus('Push notifications enabled on this device.');
} catch {
setStatus('Could not enable push notifications on this device.');
}
}
async function disable() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await unsubscribeFromPushAction(subscription.endpoint);
await subscription.unsubscribe();
}
setSubscribed(false);
setStatus('Push notifications disabled on this device.');
}
if (!supported) {
return (
<p className="text-sm text-gray-500">
Browser push notifications aren't available (unsupported browser, or not configured on
this server).
</p>
);
}
return (
<div className="flex flex-col gap-2">
<p className="text-sm text-gray-500">
Get a browser notification on this device for the same events as your email preferences
above. This is per-device - enable it separately on each browser you use.
</p>
<button
type="button"
onClick={subscribed ? disable : enable}
className="self-start rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-transform duration-150 hover:scale-[1.02] hover:bg-gray-50 active:scale-95"
>
{subscribed ? 'Disable push notifications on this device' : 'Enable push notifications on this device'}
</button>
{status && <p className="pop-in text-sm text-gray-600">{status}</p>}
</div>
);
}

113
src/components/RsvpForm.tsx Normal file
View File

@ -0,0 +1,113 @@
'use client';
import { useActionState, useState } from 'react';
import { submitRsvpAction, type ActionState } from '@/app/actions/rsvp';
import { SubmitButton } from '@/components/SubmitButton';
import { Honeypot } from '@/components/Honeypot';
import { ConfettiBurst } from '@/components/ConfettiBurst';
const initialState: ActionState = {};
export function RsvpForm({ eventId, allowPlusOnes }: { eventId: string; allowPlusOnes: boolean }) {
const [state, formAction] = useActionState(submitRsvpAction, initialState);
const [status, setStatus] = useState<'YES' | 'NO' | 'MAYBE'>('YES');
if (state.success) {
return (
<div className="relative">
{status !== 'NO' && <ConfettiBurst />}
<p className="pop-in rounded-md bg-green-50 px-4 py-3 text-sm text-green-800">{state.success}</p>
</div>
);
}
return (
<form action={formAction} className="flex flex-col gap-4">
<Honeypot />
<input type="hidden" name="eventId" value={eventId} />
<div>
<label htmlFor="guestName" className="block text-sm font-medium">
Your name
</label>
<input
id="guestName"
name="guestName"
type="text"
required
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
/>
</div>
<div>
<label htmlFor="guestEmail" className="block text-sm font-medium">
Your email
</label>
<input
id="guestEmail"
name="guestEmail"
type="email"
required
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
/>
</div>
<div>
<span className="block text-sm font-medium">Will you attend?</span>
<div className="mt-2 flex gap-2">
{(['YES', 'MAYBE', 'NO'] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setStatus(option)}
className={`rounded-md border px-4 py-2 text-sm font-medium transition-all duration-150 hover:-translate-y-0.5 active:translate-y-0 ${
status === option ? 'pop-in border-current bg-white/90 text-gray-900' : 'border-black/10 bg-white/50'
}`}
>
{option === 'YES' ? 'Yes' : option === 'NO' ? "Can't make it" : 'Maybe'}
</button>
))}
</div>
<input type="hidden" name="status" value={status} />
</div>
{allowPlusOnes && status !== 'NO' && (
<div>
<label htmlFor="numGuests" className="block text-sm font-medium">
Number of guests (including you)
</label>
<input
id="numGuests"
name="numGuests"
type="number"
min={1}
max={50}
defaultValue={1}
className="mt-1 w-24 rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
/>
</div>
)}
<div>
<label htmlFor="note" className="block text-sm font-medium">
Message to the host (optional)
</label>
<textarea
id="note"
name="note"
rows={2}
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
/>
</div>
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
<SubmitButton
className="self-start rounded-md px-5 py-2.5 font-medium text-white disabled:opacity-60"
style={{ background: 'var(--theme-primary)' }}
>
Submit RSVP
</SubmitButton>
</form>
);
}

View File

@ -0,0 +1,33 @@
'use client';
import { useActionState } from 'react';
import { SubmitButton } from '@/components/SubmitButton';
import type { ActionState } from '@/app/actions/invites';
export function SendInvitesForm({
action,
}: {
action: (state: ActionState, formData: FormData) => Promise<ActionState>;
}) {
const [state, formAction] = useActionState(action, {});
return (
<form action={formAction} className="flex flex-col gap-3">
<label htmlFor="emails" className="text-sm font-medium text-gray-700">
Guest emails
</label>
<textarea
id="emails"
name="emails"
rows={3}
placeholder="one@example.com, two@example.com"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
/>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
{state.success && <p className="text-sm text-green-700">{state.success}</p>}
<SubmitButton className="self-start rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
Send invites
</SubmitButton>
</form>
);
}

View File

@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
export function ShareLinkBox({
url,
onRegenerate,
}: {
url: string;
onRegenerate: () => Promise<void>;
}) {
const [copied, setCopied] = useState(false);
const [confirming, setConfirming] = useState(false);
async function copy() {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
readOnly
value={url}
onFocus={(e) => e.currentTarget.select()}
className="flex-1 truncate rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-700"
/>
<button
type="button"
onClick={copy}
className="shrink-0 rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
>
{copied ? 'Copied!' : 'Copy link'}
</button>
</div>
{confirming ? (
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-600">Old link will stop working. Are you sure?</span>
<button
type="button"
onClick={() => {
setConfirming(false);
onRegenerate();
}}
className="font-medium text-red-600 hover:text-red-500"
>
Yes, regenerate
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="self-start text-sm text-gray-500 hover:text-gray-700"
>
Regenerate link
</button>
)}
</div>
);
}

View File

@ -0,0 +1,30 @@
'use client';
import { useFormStatus } from 'react-dom';
const INTERACTIVE = 'transition-transform duration-150 hover:scale-[1.03] active:scale-95';
export function SubmitButton({
children,
className,
style,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
style={style}
className={`${INTERACTIVE} ${
className ??
'w-full rounded-md bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-500 disabled:opacity-60'
} ${pending ? 'scale-100' : ''}`}
>
{pending ? 'Please wait…' : children}
</button>
);
}

View File

@ -0,0 +1,58 @@
import type { Theme } from '@/lib/themes';
import { fontFamilyForTheme } from '@/lib/fonts';
/**
* Sets the theme's CSS custom properties (--theme-*) plus its background
* pattern on a wrapping element. `applyTextStyle` controls whether the
* theme's text color/font are also applied - the public invite page wants
* that everywhere, while the host-facing dashboard pages only want the
* background/accent color, keeping admin UI text in the normal readable
* sans-serif regardless of which decorative font the event uses.
*/
export function ThemeBackground({
theme,
className,
applyTextStyle = true,
children,
}: {
theme: Theme;
className?: string;
applyTextStyle?: boolean;
children: React.ReactNode;
}) {
const style = {
'--theme-bg': theme.colors.bg,
'--theme-surface': theme.colors.surface,
'--theme-primary': theme.colors.primary,
'--theme-accent': theme.colors.accent,
'--theme-text': theme.colors.text,
backgroundColor: theme.colors.bg,
backgroundImage: theme.pattern.backgroundImage,
backgroundSize: theme.pattern.backgroundSize,
...(applyTextStyle ? { color: theme.colors.text, fontFamily: fontFamilyForTheme(theme.id) } : {}),
} as React.CSSProperties;
return (
<div style={style} className={className}>
{children}
</div>
);
}
// For the public invite page, which fully adopts the theme (including text
// color chosen to contrast with a possibly-dark surface).
export const themeCardStyle: React.CSSProperties = {
background: 'var(--theme-surface)',
borderTop: '4px solid var(--theme-accent)',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
};
// For host-facing admin pages (event editor, guest management): always a
// plain white card so the normal dark-gray form/table text stays readable
// no matter how dark a theme's surface color is. Only the accent border
// carries the theme through.
export const adminCardStyle: React.CSSProperties = {
background: '#ffffff',
borderTop: '4px solid var(--theme-accent)',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
};

View File

@ -0,0 +1,11 @@
import type { Theme } from '@/lib/themes';
export function ThemeDivider({ theme }: { theme: Theme }) {
return (
<div className="my-2 flex items-center justify-center gap-3" aria-hidden="true">
<span className="h-px w-16" style={{ backgroundColor: 'var(--theme-accent)', opacity: 0.5 }} />
<span className="text-lg leading-none">{theme.emoji}</span>
<span className="h-px w-16" style={{ backgroundColor: 'var(--theme-accent)', opacity: 0.5 }} />
</div>
);
}

View File

@ -0,0 +1,53 @@
'use client';
import { useState } from 'react';
import { THEMES } from '@/lib/themes';
export function ThemePicker({
defaultThemeId,
onChange,
}: {
defaultThemeId: string;
onChange?: (themeId: string) => void;
}) {
const [selected, setSelected] = useState(defaultThemeId);
function select(themeId: string) {
setSelected(themeId);
onChange?.(themeId);
}
return (
<div>
<input type="hidden" name="theme" value={selected} />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{THEMES.map((theme) => {
const isSelected = theme.id === selected;
return (
<button
type="button"
key={theme.id}
onClick={() => select(theme.id)}
className={`flex flex-col items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 ${isSelected ? 'pop-in' : ''}`}
style={{
backgroundColor: theme.colors.bg,
backgroundImage: theme.pattern.backgroundImage,
backgroundSize: theme.pattern.backgroundSize,
color: theme.colors.text,
borderColor: isSelected ? theme.colors.primary : 'transparent',
}}
>
<span
className="flex h-9 w-9 items-center justify-center rounded-full text-xl transition-transform duration-200"
style={{ background: theme.colors.surface, border: `1px solid ${theme.colors.accent}` }}
>
{theme.emoji}
</span>
{theme.name}
</button>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
import type { Theme } from '@/lib/themes';
import { ThemeBackground } from '@/components/ThemeBackground';
export function ThemedInviteLayout({ theme, children }: { theme: Theme; children: React.ReactNode }) {
return (
<ThemeBackground theme={theme} className="min-h-screen">
<div className="mx-auto max-w-2xl px-6 py-16">{children}</div>
</ThemeBackground>
);
}

57
src/lib/emailList.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { parseEmailList, parseGuestContactList } from '@/lib/emailList';
describe('parseEmailList', () => {
it('parses comma and newline separated emails', () => {
const { validEmails, skipped } = parseEmailList('a@example.com, b@example.com\nc@example.com');
expect(validEmails.sort()).toEqual(['a@example.com', 'b@example.com', 'c@example.com']);
expect(skipped).toBe(0);
});
it('lowercases and deduplicates addresses', () => {
const { validEmails, skipped } = parseEmailList('A@Example.com, a@example.com');
expect(validEmails).toEqual(['a@example.com']);
expect(skipped).toBe(0);
});
it('counts invalid entries as skipped without failing the whole batch', () => {
const { validEmails, skipped } = parseEmailList('good@example.com, not-an-email, ,also bad');
expect(validEmails).toEqual(['good@example.com']);
expect(skipped).toBe(2);
});
it('returns an empty list for blank input', () => {
const { validEmails, skipped } = parseEmailList(' \n , ');
expect(validEmails).toEqual([]);
expect(skipped).toBe(0);
});
});
describe('parseGuestContactList', () => {
it('routes entries with an @ to emails and everything else to phones', () => {
const result = parseGuestContactList('a@example.com, 202-555-0134');
expect(result.emails).toEqual(['a@example.com']);
expect(result.phones).toEqual(['+12025550134']);
expect(result.skipped).toBe(0);
});
it('accepts a mix of several emails and phones across lines', () => {
const result = parseGuestContactList('one@example.com\n202-555-0134\ntwo@example.com\n(202) 555-0199');
expect(result.emails.sort()).toEqual(['one@example.com', 'two@example.com']);
expect(result.phones.sort()).toEqual(['+12025550134', '+12025550199']);
expect(result.skipped).toBe(0);
});
it('skips invalid emails and invalid phone numbers separately', () => {
const result = parseGuestContactList('not-an-email, also bad, good@example.com');
expect(result.emails).toEqual(['good@example.com']);
expect(result.phones).toEqual([]);
expect(result.skipped).toBe(2);
});
it('deduplicates and lowercases emails, and normalizes duplicate phone formats', () => {
const result = parseGuestContactList('A@Example.com, a@example.com, 2025550134, 202-555-0134');
expect(result.emails).toEqual(['a@example.com']);
expect(result.phones).toEqual(['+12025550134']);
});
});

77
src/lib/emailList.ts Normal file
View File

@ -0,0 +1,77 @@
import { z } from 'zod';
import { looksLikeEmail, normalizePhoneNumber } from '@/lib/phone';
const emailSchema = z.string().trim().email();
export type ParsedEmailList = {
validEmails: string[];
skipped: number;
};
/**
* Parses a free-form textarea of guest emails (comma and/or newline
* separated) into a deduplicated, lowercased list of valid addresses, plus
* a count of entries that didn't parse as a valid email.
*/
export function parseEmailList(raw: string): ParsedEmailList {
const rawEntries = raw
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean);
const validEmails = new Set<string>();
let skipped = 0;
for (const entry of rawEntries) {
const result = emailSchema.safeParse(entry);
if (result.success) {
validEmails.add(result.data.toLowerCase());
} else {
skipped++;
}
}
return { validEmails: [...validEmails], skipped };
}
export type ParsedGuestContactList = {
emails: string[];
phones: string[];
skipped: number;
};
/**
* Like parseEmailList, but for a guest list textarea that accepts a mix of
* emails and phone numbers - each entry is routed by whether it contains an
* '@', then validated/normalized accordingly. Anything that fails either
* check is counted as skipped rather than rejecting the whole batch.
*/
export function parseGuestContactList(raw: string): ParsedGuestContactList {
const rawEntries = raw
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean);
const emails = new Set<string>();
const phones = new Set<string>();
let skipped = 0;
for (const entry of rawEntries) {
if (looksLikeEmail(entry)) {
const result = emailSchema.safeParse(entry);
if (result.success) {
emails.add(result.data.toLowerCase());
} else {
skipped++;
}
} else {
const normalized = normalizePhoneNumber(entry);
if (normalized) {
phones.add(normalized);
} else {
skipped++;
}
}
}
return { emails: [...emails], phones: [...phones], skipped };
}

31
src/lib/eventAccess.ts Normal file
View File

@ -0,0 +1,31 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
/**
* Prisma `where` clause matching an event a given user can manage: either
* they're the primary host, or an accepted collaborator. Exported so pages
* that need to `include` relations alongside the access check (RSVPs,
* comments, ...) can compose it into their own query instead of doing a
* separate lookup.
*/
export function accessibleEventWhere(eventId: string, userId: string) {
return {
id: eventId,
OR: [{ hostId: userId }, { collaborators: { some: { userId, status: 'ACCEPTED' as const } } }],
};
}
/**
* Loads an event the current user may manage (primary host or accepted
* collaborator), 404ing otherwise. `isPrimaryHost` gates the handful of
* actions reserved for the original host - deleting the event and managing
* who else has access - everything else (edit details, RSVPs, comments,
* sending invites) is available to any accepted collaborator too.
*/
export async function requireEventAccess(eventId: string) {
const user = await requireUser();
const event = await prisma.event.findFirst({ where: accessibleEventWhere(eventId, user.id) });
if (!event) notFound();
return { user, event, isPrimaryHost: event.hostId === user.id };
}

51
src/lib/fonts.ts Normal file
View File

@ -0,0 +1,51 @@
import {
Playfair_Display,
Baloo_2,
Quicksand,
Inter,
Mountains_of_Christmas,
Special_Elite,
Press_Start_2P,
} from 'next/font/google';
const playfairDisplay = Playfair_Display({ subsets: ['latin'], variable: '--font-classic-elegant' });
const baloo2 = Baloo_2({ subsets: ['latin'], variable: '--font-birthday-confetti' });
const quicksandGarden = Quicksand({ subsets: ['latin'], variable: '--font-garden-party' });
const inter = Inter({ subsets: ['latin'], variable: '--font-modern-minimal' });
const mountainsOfChristmas = Mountains_of_Christmas({
subsets: ['latin'],
weight: ['400', '700'],
variable: '--font-holiday-festive',
});
const quicksandBaby = Quicksand({ subsets: ['latin'], variable: '--font-baby-shower-pastel' });
const specialElite = Special_Elite({ subsets: ['latin'], weight: '400', variable: '--font-goth-grunge' });
const pressStart2p = Press_Start_2P({ subsets: ['latin'], weight: '400', variable: '--font-retro-arcade' });
// All font variable classes need to be present in the document for
// next/font's generated CSS variables to be available, so we export one
// combined class list to apply on the root layout.
export const allFontVariables = [
playfairDisplay.variable,
baloo2.variable,
quicksandGarden.variable,
inter.variable,
mountainsOfChristmas.variable,
quicksandBaby.variable,
specialElite.variable,
pressStart2p.variable,
].join(' ');
const FONT_FAMILY_BY_THEME: Record<string, string> = {
'classic-elegant': 'var(--font-classic-elegant)',
'birthday-confetti': 'var(--font-birthday-confetti)',
'garden-party': 'var(--font-garden-party)',
'modern-minimal': 'var(--font-modern-minimal)',
'holiday-festive': 'var(--font-holiday-festive)',
'baby-shower-pastel': 'var(--font-baby-shower-pastel)',
'goth-grunge': 'var(--font-goth-grunge)',
'retro-arcade': 'var(--font-retro-arcade)',
};
export function fontFamilyForTheme(themeId: string) {
return FONT_FAMILY_BY_THEME[themeId] ?? FONT_FAMILY_BY_THEME['classic-elegant'];
}

33
src/lib/format.test.ts Normal file
View File

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { formatEventDateRange } from '@/lib/format';
describe('formatEventDateRange', () => {
it('formats a start-only date with no range, in the given timezone', () => {
const start = new Date('2027-07-18T16:00:00Z'); // noon Eastern (EDT, UTC-4)
const result = formatEventDateRange(start, null, 'America/New_York');
expect(result).toBe('Sunday, July 18, 2027 at 12:00 PM');
});
it('shows just the end time for a same-day range in the event timezone', () => {
const start = new Date('2027-07-18T16:00:00Z'); // noon Eastern
const end = new Date('2027-07-18T19:00:00Z'); // 3pm Eastern
const result = formatEventDateRange(start, end, 'America/New_York');
expect(result).toBe('Sunday, July 18, 2027 at 12:00 PM 3:00 PM');
});
it('shows the full end date for a multi-day range', () => {
const start = new Date('2027-07-18T16:00:00Z');
const end = new Date('2027-07-19T14:00:00Z');
const result = formatEventDateRange(start, end, 'America/New_York');
expect(result.match(/2027/g)?.length).toBe(2);
});
it('determines "same day" using the event timezone, not the server/UTC day', () => {
// 9pm Pacific on the 18th is already 4am UTC on the 19th - a naive
// UTC-based day comparison would wrongly call this a multi-day range.
const start = new Date('2027-07-19T04:00:00Z'); // 9:00 PM Jul 18 Pacific (PDT, UTC-7)
const end = new Date('2027-07-19T05:30:00Z'); // 10:30 PM Jul 18 Pacific
const result = formatEventDateRange(start, end, 'America/Los_Angeles');
expect(result).toBe('Sunday, July 18, 2027 at 9:00 PM 10:30 PM');
});
});

16
src/lib/format.ts Normal file
View File

@ -0,0 +1,16 @@
/** "Saturday, July 18, 2026 at 12:00 PM" or, with an end time on the same
* day, "... at 12:00 PM 3:00 PM"; a multi-day end gets its own full date.
* Always formatted in the event's own timezone, not the viewer's or
* server's - two people in different timezones should see the same wording. */
export function formatEventDateRange(start: Date, end: Date | null, timeZone: string): string {
const startStr = start.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short', timeZone });
if (!end) return startStr;
const dayKey = (d: Date) => d.toLocaleDateString('en-US', { timeZone });
const sameDay = dayKey(start) === dayKey(end);
const endStr = end.toLocaleString(
'en-US',
sameDay ? { timeStyle: 'short', timeZone } : { dateStyle: 'full', timeStyle: 'short', timeZone }
);
return `${startStr} ${endStr}`;
}

29
src/lib/mail.test.ts Normal file
View File

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { escapeHtml, stripNewlines } from '@/lib/mail';
describe('escapeHtml', () => {
it('escapes HTML-significant characters', () => {
expect(escapeHtml('<script>alert("hi")</script>')).toBe(
'&lt;script&gt;alert(&quot;hi&quot;)&lt;/script&gt;'
);
});
it('escapes ampersands and single quotes', () => {
expect(escapeHtml("Rock & Roll's back")).toBe('Rock &amp; Roll&#39;s back');
});
it('leaves plain text untouched', () => {
expect(escapeHtml('Birthday Bash 2027')).toBe('Birthday Bash 2027');
});
});
describe('stripNewlines', () => {
it('collapses newlines into a single space and trims', () => {
expect(stripNewlines(' Hello\nWorld\r\n! ')).toBe('Hello World !');
});
it('prevents header injection via a fake extra header line', () => {
const malicious = 'Subject\r\nBcc: victim@example.com';
expect(stripNewlines(malicious)).not.toMatch(/[\r\n]/);
});
});

242
src/lib/mail.ts Normal file
View File

@ -0,0 +1,242 @@
import 'server-only';
import nodemailer from 'nodemailer';
export function stripNewlines(value: string) {
return value.replace(/[\r\n]+/g, ' ').trim();
}
export function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
let transporter: nodemailer.Transporter | null = null;
function getTransporter() {
if (!transporter) {
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 1025),
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
});
}
return transporter;
}
// Returns whether the send succeeded rather than throwing. A guest's RSVP or
// comment is already persisted by the time we try to email a confirmation,
// so a flaky/misconfigured SMTP server shouldn't turn that into a 500 for
// them - we log it server-side and let the caller decide how to react.
async function sendMail(opts: { to: string; subject: string; html: string }): Promise<boolean> {
const from = process.env.SMTP_FROM ?? 'E-Invite <invites@example.com>';
try {
await getTransporter().sendMail({
from,
to: opts.to,
subject: stripNewlines(opts.subject),
html: opts.html,
});
return true;
} catch (err) {
console.error(`Failed to send email to ${opts.to}:`, err);
return false;
}
}
function layout(title: string, bodyHtml: string) {
return `
<div style="font-family: -apple-system, Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px; color: #1f2937;">
<h1 style="font-size: 20px; margin-bottom: 16px;">${title}</h1>
${bodyHtml}
<p style="margin-top: 32px; font-size: 12px; color: #9ca3af;">Sent by E-Invite</p>
</div>
`;
}
export async function sendInviteEmail(opts: {
to: string;
guestName?: string;
hostName: string;
eventTitle: string;
eventDate: Date;
timezone: string;
inviteUrl: string;
}): Promise<boolean> {
const guestName = opts.guestName ? escapeHtml(stripNewlines(opts.guestName)) : '';
const hostName = escapeHtml(stripNewlines(opts.hostName));
const eventTitle = escapeHtml(opts.eventTitle);
const greeting = guestName ? `Hi ${guestName},` : 'Hi,';
return sendMail({
to: opts.to,
subject: `You're invited: ${opts.eventTitle}`,
html: layout(
`You're invited to ${eventTitle}!`,
`
<p>${greeting}</p>
<p>${hostName} has invited you to <strong>${eventTitle}</strong> on
${opts.eventDate.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short', timeZone: opts.timezone })}.</p>
<p><a href="${opts.inviteUrl}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;">View invitation & RSVP</a></p>
`
),
});
}
export async function sendRsvpGuestConfirmation(opts: {
to: string;
guestName: string;
eventTitle: string;
status: 'YES' | 'NO' | 'MAYBE';
inviteUrl: string;
}): Promise<boolean> {
const statusText = { YES: "you're going", NO: "you can't make it", MAYBE: 'you might attend' }[
opts.status
];
const guestName = escapeHtml(stripNewlines(opts.guestName));
const eventTitle = escapeHtml(opts.eventTitle);
return sendMail({
to: opts.to,
subject: `RSVP received: ${opts.eventTitle}`,
html: layout(
'Your RSVP has been received',
`
<p>Hi ${guestName},</p>
<p>Thanks for responding to <strong>${eventTitle}</strong>. We've recorded that ${statusText}.</p>
<p><a href="${opts.inviteUrl}">View the invitation again</a></p>
`
),
});
}
export async function sendRsvpHostNotification(opts: {
to: string;
guestName: string;
eventTitle: string;
status: 'YES' | 'NO' | 'MAYBE';
numGuests: number;
note?: string;
manageUrl: string;
isUpdate?: boolean;
}): Promise<boolean> {
const guestName = escapeHtml(stripNewlines(opts.guestName));
const eventTitle = escapeHtml(opts.eventTitle);
const note = opts.note ? escapeHtml(opts.note) : '';
const verb = opts.isUpdate ? 'updated their RSVP to' : 'responded';
return sendMail({
to: opts.to,
subject: opts.isUpdate ? `RSVP updated for ${opts.eventTitle}` : `New RSVP for ${opts.eventTitle}`,
html: layout(
opts.isUpdate ? 'RSVP updated' : 'New RSVP',
`
<p><strong>${guestName}</strong> ${verb} <strong>${opts.status}</strong>
(${opts.numGuests} guest${opts.numGuests === 1 ? '' : 's'}) to <strong>${eventTitle}</strong>.</p>
${note ? `<p>Note: ${note}</p>` : ''}
<p><a href="${opts.manageUrl}">Manage guests</a></p>
`
),
});
}
export async function sendCommentHostNotification(opts: {
to: string;
authorName: string;
body: string;
eventTitle: string;
manageUrl: string;
}): Promise<boolean> {
const authorName = escapeHtml(stripNewlines(opts.authorName));
const eventTitle = escapeHtml(opts.eventTitle);
const body = escapeHtml(opts.body);
return sendMail({
to: opts.to,
subject: `New comment on ${opts.eventTitle}`,
html: layout(
'New comment',
`
<p><strong>${authorName}</strong> left a comment on <strong>${eventTitle}</strong>:</p>
<p style="padding:12px;background:#f3f4f6;border-radius:6px;">${body}</p>
<p><a href="${opts.manageUrl}">Manage comments</a></p>
`
),
});
}
export async function sendCollaboratorAcceptedNotification(opts: {
to: string;
collaboratorName: string;
eventTitle: string;
manageUrl: string;
}): Promise<boolean> {
const collaboratorName = escapeHtml(stripNewlines(opts.collaboratorName));
const eventTitle = escapeHtml(opts.eventTitle);
return sendMail({
to: opts.to,
subject: `${opts.collaboratorName} accepted your co-host invite`,
html: layout(
'Co-host invite accepted',
`
<p><strong>${collaboratorName}</strong> is now co-hosting <strong>${eventTitle}</strong> with you.</p>
<p><a href="${opts.manageUrl}">Manage the event</a></p>
`
),
});
}
export async function sendCollaboratorInviteEmail(opts: {
to: string;
inviterName: string;
eventTitle: string;
acceptUrl: string;
}): Promise<boolean> {
const inviterName = escapeHtml(stripNewlines(opts.inviterName));
const eventTitle = escapeHtml(opts.eventTitle);
return sendMail({
to: opts.to,
subject: `You've been invited to co-host ${opts.eventTitle}`,
html: layout(
"You've been invited to co-host an event",
`
<p>${inviterName} invited you to help manage <strong>${eventTitle}</strong> on E-Invite -
you'll be able to edit the invite, see RSVPs, and moderate comments together.</p>
<p><a href="${opts.acceptUrl}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;">Accept invite</a></p>
`
),
});
}
export async function sendVerificationEmail(opts: { to: string; verifyUrl: string }): Promise<boolean> {
return sendMail({
to: opts.to,
subject: 'Verify your email',
html: layout(
'Verify your email',
`
<p>Thanks for signing up for E-Invite! Click below to verify your email address.</p>
<p><a href="${opts.verifyUrl}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;">Verify email</a></p>
<p>This link expires in 24 hours. You can keep using your account either way - this just
confirms we can reach you.</p>
`
),
});
}
export async function sendPasswordResetEmail(opts: { to: string; resetUrl: string }): Promise<boolean> {
return sendMail({
to: opts.to,
subject: 'Reset your password',
html: layout(
'Reset your password',
`
<p>Click the link below to reset your E-Invite password. This link expires in 1 hour.</p>
<p><a href="${opts.resetUrl}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;">Reset password</a></p>
<p>If you didn't request this, you can safely ignore this email.</p>
`
),
});
}

129
src/lib/patterns.ts Normal file
View File

@ -0,0 +1,129 @@
// Tileable inline-SVG background patterns for theme decoration. Everything
// here is generated as a data: URI so there are no external image assets to
// ship - the Docker image stays fully self-contained.
function svgBackground(svg: string, size: string) {
const uri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
return { backgroundImage: uri, backgroundSize: size };
}
/** Elegant diamond lattice - fine gold lines on a light card. */
export function damaskPattern(color: string) {
const size = 64;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g fill="none" stroke="${color}" stroke-width="1" opacity="0.35">
<path d="M32 2 L62 32 L32 62 L2 32 Z"/>
<circle cx="32" cy="32" r="3" fill="${color}"/>
</g>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered confetti rectangles/circles/triangles in a few colors. */
export function confettiPattern(colors: string[]) {
const size = 140;
const [a, b, c] = colors;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g opacity="0.55">
<rect x="14" y="18" width="10" height="4" fill="${a}" transform="rotate(20 19 20)"/>
<circle cx="52" cy="30" r="4" fill="${b}"/>
<rect x="88" y="14" width="8" height="8" fill="${c}" transform="rotate(45 92 18)"/>
<path d="M110 60 l6 10 l-12 0 z" fill="${a}"/>
<circle cx="20" cy="76" r="3" fill="${c}"/>
<rect x="60" y="82" width="10" height="4" fill="${b}" transform="rotate(-15 65 84)"/>
<circle cx="100" cy="100" r="4" fill="${a}"/>
<rect x="30" y="110" width="8" height="8" fill="${c}" transform="rotate(30 34 114)"/>
<path d="M124 100 l6 10 l-12 0 z" fill="${b}"/>
</g>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Simple polka-dot grid. */
export function polkaDotPattern(color: string, radius = 2.5) {
const size = 36;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<circle cx="${size / 2}" cy="${size / 2}" r="${radius}" fill="${color}" opacity="0.4"/>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered leaf/vine motifs for a garden theme. */
export function leafPattern(leaf: string, accent: string) {
const size = 150;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g opacity="0.5" fill="none" stroke="${leaf}" stroke-width="2">
<path d="M20 30 Q30 15 45 25 Q30 35 20 30 Z" fill="${leaf}" stroke="none"/>
<path d="M100 20 Q108 8 120 15 Q110 25 100 20 Z" fill="${accent}" stroke="none"/>
<path d="M70 70 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
<path d="M20 100 Q30 85 45 95 Q30 105 20 100 Z" fill="${accent}" stroke="none"/>
<path d="M110 110 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
<path d="M60 10 C 60 40, 60 40, 60 55" stroke="${leaf}" stroke-width="1.5"/>
</g>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered 6-point snowflakes for a winter/holiday theme. */
export function snowflakePattern(color: string) {
const size = 120;
const flake = (cx: number, cy: number, r: number) => `
<g transform="translate(${cx} ${cy})" stroke="${color}" stroke-width="1.4" opacity="0.6">
${[0, 60, 120].map((deg) => `<line x1="${-r}" y1="0" x2="${r}" y2="0" transform="rotate(${deg})"/>`).join('')}
</g>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
${flake(20, 25, 7)}
${flake(85, 15, 5)}
${flake(60, 70, 9)}
${flake(105, 95, 6)}
${flake(15, 95, 5)}
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered soft 4-point sparkle stars for a pastel theme. */
export function starPattern(colors: string[]) {
const size = 130;
const [a, b] = colors;
const star = (cx: number, cy: number, r: number, fill: string) =>
`<path d="M${cx} ${cy - r} Q${cx} ${cy} ${cx + r} ${cy} Q${cx} ${cy} ${cx} ${cy + r} Q${cx} ${cy} ${cx - r} ${cy} Q${cx} ${cy} ${cx} ${cy - r} Z" fill="${fill}"/>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g opacity="0.6">
${star(25, 30, 7, a)}
${star(95, 20, 5, b)}
${star(60, 65, 6, a)}
${star(110, 90, 8, b)}
${star(20, 100, 5, a)}
</g>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered claw-mark scratches for a grunge/distressed theme. */
export function scratchPattern(color: string) {
const size = 130;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g stroke="${color}" stroke-width="1.5" opacity="0.35" stroke-linecap="round">
<line x1="10" y1="15" x2="34" y2="55"/>
<line x1="18" y1="10" x2="40" y2="48"/>
<line x1="26" y1="8" x2="46" y2="42"/>
<line x1="85" y1="75" x2="110" y2="115"/>
<line x1="93" y1="70" x2="115" y2="108"/>
<line x1="60" y1="20" x2="72" y2="38"/>
<line x1="15" y1="90" x2="28" y2="112"/>
</g>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Subtle pixel-grid with scattered neon "pixels" for a retro arcade theme. */
export function pixelGridPattern(gridColor: string, pixelColors: string[]) {
const size = 40;
const [a, b] = pixelColors;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<rect width="${size}" height="${size}" fill="none" stroke="${gridColor}" stroke-width="1" opacity="0.25"/>
<rect x="4" y="4" width="5" height="5" fill="${a}" opacity="0.6"/>
<rect x="26" y="20" width="5" height="5" fill="${b}" opacity="0.6"/>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}

45
src/lib/phone.test.ts Normal file
View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { normalizePhoneNumber, looksLikeEmail } from '@/lib/phone';
describe('normalizePhoneNumber', () => {
// 202-555-01XX is NANP's officially reserved fictional-use range (real
// area code, "555" exchange) - "555" itself is not an assigned area code,
// so it can't be used alone the way fiction often depicts it.
it('normalizes a 10-digit US number to E.164', () => {
expect(normalizePhoneNumber('202-555-0134')).toBe('+12025550134');
});
it('normalizes a US number with various punctuation', () => {
expect(normalizePhoneNumber('(202) 555-0134')).toBe('+12025550134');
});
it('accepts an already-E.164 number', () => {
expect(normalizePhoneNumber('+12025550134')).toBe('+12025550134');
});
it('normalizes an international number given its country code', () => {
expect(normalizePhoneNumber('+44 20 7946 0958')).toBe('+442079460958');
});
it('returns null for garbage input', () => {
expect(normalizePhoneNumber('not a phone number')).toBeNull();
});
it('returns null for a too-short number', () => {
expect(normalizePhoneNumber('12345')).toBeNull();
});
it('respects a non-default country', () => {
expect(normalizePhoneNumber('20 7946 0958', 'GB')).toBe('+442079460958');
});
});
describe('looksLikeEmail', () => {
it('treats anything with an @ as an email attempt', () => {
expect(looksLikeEmail('person@example.com')).toBe(true);
});
it('treats anything without an @ as not an email', () => {
expect(looksLikeEmail('555-123-4567')).toBe(false);
});
});

16
src/lib/phone.ts Normal file
View File

@ -0,0 +1,16 @@
import { parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
/** Normalizes a free-form phone number to E.164 (e.g. "+15551234567"),
* assuming `defaultCountry` for numbers with no country code. Returns null
* for anything that doesn't parse as a valid, real phone number. */
export function normalizePhoneNumber(raw: string, defaultCountry: CountryCode = 'US'): string | null {
const phone = parsePhoneNumberFromString(raw, defaultCountry);
if (!phone || !phone.isValid()) return null;
return phone.format('E.164');
}
/** A quick heuristic for routing a guest-list entry: anything with an '@'
* is treated as an email attempt, everything else as a phone attempt. */
export function looksLikeEmail(value: string): boolean {
return value.includes('@');
}

51
src/lib/photoStorage.ts Normal file
View File

@ -0,0 +1,51 @@
import 'server-only';
import { mkdir, writeFile, unlink } from 'fs/promises';
import path from 'path';
import { randomBytes } from 'crypto';
export const MAX_PHOTO_BYTES = 8 * 1024 * 1024; // 8MB
export const ALLOWED_PHOTO_TYPES: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
};
const UPLOADS_ROOT = path.join(process.cwd(), 'public', 'uploads');
function eventDir(eventId: string) {
return path.join(UPLOADS_ROOT, eventId);
}
/** Public URL path for a stored photo, for use directly in <img src>. */
export function photoUrl(eventId: string, filename: string): string {
return `/uploads/${eventId}/${filename}`;
}
/** Writes an uploaded file to disk under a per-event directory and returns
* the generated filename (not the full path - callers store just this). */
export async function savePhotoFile(eventId: string, file: File): Promise<string> {
const ext = ALLOWED_PHOTO_TYPES[file.type];
if (!ext) {
throw new Error(`Unsupported file type: ${file.type}`);
}
const dir = eventDir(eventId);
await mkdir(dir, { recursive: true });
const filename = `${randomBytes(12).toString('hex')}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(path.join(dir, filename), buffer);
return filename;
}
export async function deletePhotoFile(eventId: string, filename: string): Promise<void> {
try {
await unlink(path.join(eventDir(eventId), filename));
} catch (err) {
// Already gone is fine; anything else is worth knowing about but
// shouldn't block deleting the DB record.
console.error(`Failed to delete photo file ${filename} for event ${eventId}:`, err);
}
}

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { isPhotoUploadOpen } from '@/lib/photoWindow';
const base = {
eventDate: new Date('2027-06-01T18:00:00'),
photosEnabled: true,
photosClosed: false,
photoCloseDate: null as Date | null,
};
describe('isPhotoUploadOpen', () => {
it('is closed before the event starts', () => {
const now = new Date('2027-06-01T10:00:00');
expect(isPhotoUploadOpen(base, now)).toBe(false);
});
it('is open once the event has started with no close date', () => {
const now = new Date('2027-06-01T19:00:00');
expect(isPhotoUploadOpen(base, now)).toBe(true);
});
it('is closed if the feature is disabled', () => {
const now = new Date('2027-06-01T19:00:00');
expect(isPhotoUploadOpen({ ...base, photosEnabled: false }, now)).toBe(false);
});
it('is closed if force-closed, even mid-window', () => {
const now = new Date('2027-06-01T19:00:00');
expect(isPhotoUploadOpen({ ...base, photosClosed: true }, now)).toBe(false);
});
it('is open before the close date', () => {
const now = new Date('2027-06-02T10:00:00');
const closeDate = new Date('2027-06-03T00:00:00');
expect(isPhotoUploadOpen({ ...base, photoCloseDate: closeDate }, now)).toBe(true);
});
it('is closed after the close date', () => {
const now = new Date('2027-06-04T10:00:00');
const closeDate = new Date('2027-06-03T00:00:00');
expect(isPhotoUploadOpen({ ...base, photoCloseDate: closeDate }, now)).toBe(false);
});
});

19
src/lib/photoWindow.ts Normal file
View File

@ -0,0 +1,19 @@
type PhotoWindowEvent = {
eventDate: Date;
photosEnabled: boolean;
photosClosed: boolean;
photoCloseDate: Date | null;
};
/**
* Whether guests can currently upload photos: the feature must be enabled,
* not force-closed by the host, the event must have actually started, and
* (if set) any auto-close date must not have passed yet.
*/
export function isPhotoUploadOpen(event: PhotoWindowEvent, now: Date = new Date()): boolean {
if (!event.photosEnabled) return false;
if (event.photosClosed) return false;
if (now < event.eventDate) return false;
if (event.photoCloseDate && now > event.photoCloseDate) return false;
return true;
}

9
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

52
src/lib/push.ts Normal file
View File

@ -0,0 +1,52 @@
import 'server-only';
import webpush from 'web-push';
import { prisma } from '@/lib/prisma';
let configured = false;
function ensureConfigured() {
if (configured) return true;
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
if (!publicKey || !privateKey) return false;
webpush.setVapidDetails(
`mailto:${process.env.VAPID_CONTACT_EMAIL || 'admin@example.com'}`,
publicKey,
privateKey
);
configured = true;
return true;
}
/** Pushes a notification to every browser subscription a user has registered.
* Silently does nothing if VAPID keys aren't configured, or the user has no
* subscriptions - this always runs alongside email, never in place of it. */
export async function sendPushToUser(
userId: string,
payload: { title: string; body: string; url?: string }
) {
if (!ensureConfigured()) return;
const subscriptions = await prisma.pushSubscription.findMany({ where: { userId } });
if (subscriptions.length === 0) return;
await Promise.all(
subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
);
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode;
if (statusCode === 404 || statusCode === 410) {
// Subscription expired or was revoked by the browser - clean it up.
await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => {});
} else {
console.error(`Failed to send push notification to subscription ${sub.id}:`, err);
}
}
})
);
}

59
src/lib/rateLimit.test.ts Normal file
View File

@ -0,0 +1,59 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { checkRateLimit } from '@/lib/rateLimit';
describe('checkRateLimit', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('allows requests under the limit', () => {
const key = `test:${Math.random()}`;
expect(checkRateLimit(key, 3, 1000)).toBe(true);
expect(checkRateLimit(key, 3, 1000)).toBe(true);
expect(checkRateLimit(key, 3, 1000)).toBe(true);
});
it('rejects once the limit is exceeded', () => {
const key = `test:${Math.random()}`;
checkRateLimit(key, 2, 1000);
checkRateLimit(key, 2, 1000);
expect(checkRateLimit(key, 2, 1000)).toBe(false);
});
it('resets after the window elapses', () => {
const key = `test:${Math.random()}`;
checkRateLimit(key, 1, 1000);
expect(checkRateLimit(key, 1, 1000)).toBe(false);
vi.advanceTimersByTime(1001);
expect(checkRateLimit(key, 1, 1000)).toBe(true);
});
it('tracks separate keys independently', () => {
const keyA = `test:a:${Math.random()}`;
const keyB = `test:b:${Math.random()}`;
checkRateLimit(keyA, 1, 1000);
expect(checkRateLimit(keyA, 1, 1000)).toBe(false);
expect(checkRateLimit(keyB, 1, 1000)).toBe(true);
});
it('rejects an oversized batch without partially consuming the bucket', () => {
const key = `test:${Math.random()}`;
expect(checkRateLimit(key, 10, 1000, 20)).toBe(false);
// Since the oversized request was rejected, a normal-cost request should
// still succeed against a fresh/untouched bucket.
expect(checkRateLimit(key, 10, 1000, 1)).toBe(true);
});
it('weights hits by cost', () => {
const key = `test:${Math.random()}`;
expect(checkRateLimit(key, 10, 1000, 6)).toBe(true);
expect(checkRateLimit(key, 10, 1000, 5)).toBe(false);
expect(checkRateLimit(key, 10, 1000, 4)).toBe(true);
});
});

56
src/lib/rateLimit.ts Normal file
View File

@ -0,0 +1,56 @@
import 'server-only';
import { headers } from 'next/headers';
type Bucket = { count: number; resetAt: number };
// In-memory fixed-window limiter. This is process-local: it resets on
// restart and isn't shared across replicas if this app is ever scaled
// horizontally. That's an acceptable tradeoff for a self-hosted, single
// -instance app - swap for a shared store (e.g. Redis) if you scale out.
const buckets = new Map<string, Bucket>();
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
let lastCleanup = Date.now();
function cleanup(now: number) {
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = now;
for (const [key, bucket] of buckets) {
if (bucket.resetAt < now) buckets.delete(key);
}
}
/**
* Returns true if the action identified by `key` is allowed under `limit`
* hits per `windowMs`, and records this attempt (weighted by `cost`, for
* actions like sending a batch of N emails at once). Returns false if the
* caller should be rejected as rate-limited; the bucket is left unchanged
* in that case so a single oversized request doesn't get partially counted.
*/
export function checkRateLimit(key: string, limit: number, windowMs: number, cost = 1): boolean {
const now = Date.now();
cleanup(now);
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt < now) {
if (cost > limit) return false;
buckets.set(key, { count: cost, resetAt: now + windowMs });
return true;
}
if (bucket.count + cost > limit) return false;
bucket.count += cost;
return true;
}
/**
* Best-effort client IP from X-Forwarded-For, set by a reverse proxy in
* front of the app. Without one (e.g. hitting the container directly),
* this collapses to a single shared bucket for all clients - callers should
* always pair this with a second, more specific key (event id, email, etc).
*/
export async function getClientIp(): Promise<string> {
const h = await headers();
const forwarded = h.get('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0].trim();
return 'unknown';
}

79
src/lib/session.ts Normal file
View File

@ -0,0 +1,79 @@
import 'server-only';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import bcrypt from 'bcryptjs';
import { prisma } from '@/lib/prisma';
const SESSION_COOKIE = 'einvite_session';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// Only mark the cookie Secure when explicitly opted in. NODE_ENV=production
// is set in the Docker image regardless of whether TLS is terminated in
// front of it, and this app is served over plain HTTP by default (e.g. the
// bundled docker-compose stack) - a Secure cookie would silently never be
// sent back by the browser in that case, breaking every session right after
// login. Set COOKIE_SECURE=true once you have HTTPS in front of the app.
const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true';
export async function hashPassword(password: string) {
return bcrypt.hash(password, 12);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
export async function createSession(userId: string) {
const session = await prisma.session.create({
data: {
userId,
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
},
});
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, session.id, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: 'lax',
path: '/',
expires: session.expiresAt,
});
}
export async function destroySession() {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (sessionId) {
await prisma.session.deleteMany({ where: { id: sessionId } });
}
cookieStore.delete(SESSION_COOKIE);
}
export async function getCurrentUser() {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (!sessionId) return null;
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
if (session) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => {});
}
return null;
}
return session.user;
}
export async function requireUser() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return user;
}

13
src/lib/slug.ts Normal file
View File

@ -0,0 +1,13 @@
import { customAlphabet } from 'nanoid';
// Unambiguous alphabet (no 0/O/1/l/I) for share links that get typed or read aloud.
const nanoid = customAlphabet('23456789abcdefghjkmnpqrstuvwxyz', 12);
export function generateSlug() {
return nanoid();
}
export function appUrl(path: string) {
const base = process.env.APP_URL ?? 'http://localhost:3000';
return `${base.replace(/\/$/, '')}${path}`;
}

62
src/lib/sms.test.ts Normal file
View File

@ -0,0 +1,62 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import twilio from 'twilio';
import { verifyTwilioSignature, isTwilioConfigured } from '@/lib/sms';
const ORIGINAL_ENV = { ...process.env };
describe('verifyTwilioSignature', () => {
beforeEach(() => {
process.env.TWILIO_AUTH_TOKEN = 'test_auth_token';
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
const url = 'https://example.com/api/sms/webhook';
const params = { From: '+15551234567', Body: 'yes' };
it('accepts a genuinely valid Twilio signature', () => {
const signature = twilio.getExpectedTwilioSignature('test_auth_token', url, params);
expect(verifyTwilioSignature(url, params, signature)).toBe(true);
});
it('rejects a tampered signature', () => {
expect(verifyTwilioSignature(url, params, 'not-a-real-signature')).toBe(false);
});
it('rejects a valid signature computed for different params (replay onto a different body)', () => {
const signature = twilio.getExpectedTwilioSignature('test_auth_token', url, params);
expect(verifyTwilioSignature(url, { From: '+15551234567', Body: 'no' }, signature)).toBe(false);
});
it('rejects when no signature is provided', () => {
expect(verifyTwilioSignature(url, params, null)).toBe(false);
});
it('rejects when the auth token is not configured', () => {
delete process.env.TWILIO_AUTH_TOKEN;
const signature = twilio.getExpectedTwilioSignature('test_auth_token', url, params);
expect(verifyTwilioSignature(url, params, signature)).toBe(false);
});
});
describe('isTwilioConfigured', () => {
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it('is false when env vars are missing', () => {
delete process.env.TWILIO_ACCOUNT_SID;
delete process.env.TWILIO_AUTH_TOKEN;
delete process.env.TWILIO_PHONE_NUMBER;
expect(isTwilioConfigured()).toBe(false);
});
it('is true when all three env vars are set', () => {
process.env.TWILIO_ACCOUNT_SID = 'ACxxx';
process.env.TWILIO_AUTH_TOKEN = 'token';
process.env.TWILIO_PHONE_NUMBER = '+15551234567';
expect(isTwilioConfigured()).toBe(true);
});
});

42
src/lib/sms.ts Normal file
View File

@ -0,0 +1,42 @@
import 'server-only';
import twilio from 'twilio';
export function isTwilioConfigured(): boolean {
return Boolean(process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN && process.env.TWILIO_PHONE_NUMBER);
}
let client: ReturnType<typeof twilio> | null = null;
function getClient() {
if (!client) {
client = twilio(process.env.TWILIO_ACCOUNT_SID!, process.env.TWILIO_AUTH_TOKEN!);
}
return client;
}
/** Returns whether the send succeeded rather than throwing, same resilience
* contract as sendMail - a down/misconfigured Twilio account shouldn't turn
* "add this guest to the list" into a 500. */
export async function sendSms(to: string, body: string): Promise<boolean> {
if (!isTwilioConfigured()) return false;
try {
await getClient().messages.create({ to, from: process.env.TWILIO_PHONE_NUMBER!, body });
return true;
} catch (err) {
console.error(`Failed to send SMS to ${to}:`, err);
return false;
}
}
/** Verifies an inbound webhook request actually came from Twilio, using the
* same HMAC scheme Twilio's own helper library implements - the request's
* full URL plus its POST params, signed with the account's auth token. */
export function verifyTwilioSignature(
url: string,
params: Record<string, string>,
signature: string | null
): boolean {
const authToken = process.env.TWILIO_AUTH_TOKEN;
if (!signature || !authToken) return false;
return twilio.validateRequest(authToken, signature, url, params);
}

43
src/lib/smsReply.test.ts Normal file
View File

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { parseSmsRsvpReply } from '@/lib/smsReply';
describe('parseSmsRsvpReply', () => {
it('parses a plain yes', () => {
expect(parseSmsRsvpReply('YES')).toEqual({ status: 'YES', numGuests: 1 });
});
it('parses a plain no', () => {
expect(parseSmsRsvpReply('no')).toEqual({ status: 'NO', numGuests: 1 });
});
it('parses a plain maybe', () => {
expect(parseSmsRsvpReply('Maybe')).toEqual({ status: 'MAYBE', numGuests: 1 });
});
it('parses common casual variants', () => {
expect(parseSmsRsvpReply('yeah')).toEqual({ status: 'YES', numGuests: 1 });
expect(parseSmsRsvpReply('nope')).toEqual({ status: 'NO', numGuests: 1 });
});
it('extracts a guest count that follows the status word', () => {
expect(parseSmsRsvpReply('YES 3')).toEqual({ status: 'YES', numGuests: 3 });
expect(parseSmsRsvpReply('Yes, 2 guests please')).toEqual({ status: 'YES', numGuests: 2 });
});
it('handles a full sentence reply led by the status word', () => {
expect(parseSmsRsvpReply("Yes we'll be there!")).toEqual({ status: 'YES', numGuests: 1 });
expect(parseSmsRsvpReply('No, something came up')).toEqual({ status: 'NO', numGuests: 1 });
});
it('clamps an absurd guest count to 50', () => {
expect(parseSmsRsvpReply('yes 500')).toEqual({ status: 'YES', numGuests: 50 });
});
it('returns null for an unrecognized reply', () => {
expect(parseSmsRsvpReply('what is this event about?')).toBeNull();
});
it('returns null for blank input', () => {
expect(parseSmsRsvpReply(' ')).toBeNull();
});
});

33
src/lib/smsReply.ts Normal file
View File

@ -0,0 +1,33 @@
import type { RsvpStatus } from '@prisma/client';
export type ParsedSmsRsvpReply = { status: RsvpStatus; numGuests: number };
const YES_WORDS = new Set(['yes', 'y', 'yep', 'yeah', 'yup', 'ya']);
const NO_WORDS = new Set(['no', 'n', 'nope', 'nah']);
const MAYBE_WORDS = new Set(['maybe', 'perhaps', 'unsure', 'tentative', 'possibly']);
/**
* Parses a guest's free-form SMS reply into an RSVP status plus an optional
* guest count (e.g. "YES 3", "no", "Maybe, 2 people"). Only the first word is
* used to determine status - guests reply in all kinds of phrasing, but the
* status word almost always leads. Returns null when no recognized status
* word is found, so the caller can ask the guest to try again.
*/
export function parseSmsRsvpReply(rawBody: string): ParsedSmsRsvpReply | null {
const body = rawBody.trim();
if (!body) return null;
const firstWord = body.toLowerCase().match(/[a-z']+/)?.[0];
if (!firstWord) return null;
let status: RsvpStatus;
if (YES_WORDS.has(firstWord)) status = 'YES';
else if (NO_WORDS.has(firstWord)) status = 'NO';
else if (MAYBE_WORDS.has(firstWord)) status = 'MAYBE';
else return null;
const countMatch = body.match(/\d+/);
const numGuests = countMatch ? Math.min(Math.max(parseInt(countMatch[0], 10), 1), 50) : 1;
return { status, numGuests };
}

Some files were not shown because too many files have changed in this diff Show More