Some checks are pending
CI / build (push) Waiting to run
Reconcile the docs with the shipped implementation. The exploration
flagged three docs as out of sync; one was a scratch file.
- docs/test.md deleted. File content was literally the four bytes
'aaaa' — a debugging leftover that had no business in the repo.
- docs/admin.md rewritten end-to-end. The old file still described
the removed hardcoded super/admin credentials ('SuperAdmin will
log in with hardcoded username: super and password:admin'). The
new version covers roles, the dual-auth model, the
requireAdmin/requireAdminPost/requireSuperAdminPost guard flow,
env-based super-admin provisioning (with the bcrypt-hash generation
command), code generation + the atomic-claim guard, the
Code.createdById onDelete:Restrict decision, and the rate-limit
surface — i.e. all of the Phase 1–6 decisions in one place. It
is now the authoritative source of truth for the admin flow.
- docs/description.md (the 324-line original architecture plan) gets
a banner at the top listing every known delta with the actual
implementation: storage (S3 not UploadThing/Vercel Blob),
upload MIME allow-list, dual auth (Clerk + custom HMAC), production
deploy (Coolify/Traefik instead of Vercel), subdomain routing
(Host header, not X-Subdomain), schema notes for the deliberate
String? bornDate/passedDate, rate limiting + CSRF enforcement, and
in-house QR rendering. The body of the document is preserved as
historical design context.
- docs/adminImplem.md (the 259-line original implementation plan)
gets a similar banner — most notable is that the cookie path is
actually '/' not '/admin', and the rolling helpers in
admin-session.ts supersede the per-route inline guards the doc
describes. Body preserved as history.
No source changes — docs only.
Note: docs/db.md is left untouched per the user's instruction to defer
secret rotation to a later stage. It still contains an in-repo DB
password; that and the other .env secrets will be addressed when
rotation happens.
363 lines
15 KiB
Markdown
363 lines
15 KiB
Markdown
# 🏛️ City Monuments Memories — Platform Architecture & Implementation Plan
|
|
|
|
> ## ⚠ Historical spec — partially out of sync with the implementation
|
|
>
|
|
> This document is the **original architecture plan**. The shipped code
|
|
> has drifted from it in several ways; treat this as historical design
|
|
> context, not current documentation.
|
|
>
|
|
> Known deltas (see `docs/admin.md` and the source for the source of
|
|
> truth):
|
|
>
|
|
> - **Storage**: described as "UploadThing" + "Vercel Blob"; the
|
|
> implementation uses **AWS-compatible S3 (Contabo Object Storage)**
|
|
> via `@aws-sdk/client-s3`. There is an in-house presigned-URL helper
|
|
> in `src/lib/upload.ts` (currently the upload route still proxies
|
|
> through the server with magic-byte validation; presigned direct-to-S3
|
|
> flow is implemented but not yet wired into the client).
|
|
> - **File Upload**: 5MB cap, JPEG/PNG/WebP only (GIF was removed).
|
|
> - **Auth**: described as "Clerk only". The implementation uses
|
|
> **dual-track auth**: Clerk for end-users, plus a custom
|
|
> **HMAC-signed cookie** for admins (`src/lib/admin-session.ts`).
|
|
> Admin session secret comes from `ADMIN_SESSION_SECRET`; super-admin
|
|
> credentials from `SUPER_ADMIN_USERNAME` + `SUPER_ADMIN_PASSWORD_HASH`
|
|
> (bcrypt) — previously hardcoded `super`/`admin`, now removed.
|
|
> - **Deployment**: described as "Vercel". Actual deploy is **Docker
|
|
> (standalone Next.js) behind Traefik (Coolify)**. The `nginx/conf.d/*`
|
|
> configs have been deleted.
|
|
> - **Subdomain routing**: described as "Vercel Wildcard Domains";
|
|
> middleware parses the `Host` header (not `X-Subdomain`) behind the
|
|
> Traefik reverse proxy.
|
|
> - **Schema**: see `prisma/schema.prisma` for the current shape. Most
|
|
> notably `bornDate`/`passedDate` are deliberately kept as bounded
|
|
> `VARCHAR(50)` (imprecise free-text like "1960" or "early 1990s"),
|
|
> not `DateTime`.
|
|
> - **Rate limiting + CSRF** are now enforced on admin + sensitive
|
|
> public routes (see `src/lib/rate-limit.ts`).
|
|
> - **QR codes** are rendered in-house via the `qrcode` package, not
|
|
> via the external `api.qrserver.com` service.
|
|
|
|
|
|
## 1. Tech Stack
|
|
|
|
| Layer | Technology | Why |
|
|
|-------|-----------|-----|
|
|
| **Framework** | **Next.js 15 (App Router)** | Full-stack React framework — server components, API routes, middleware for subdomain routing |
|
|
| **Auth** | **Clerk** | Required by spec; handles sign-up/sign-in, user profiles, session management |
|
|
| **Database** | **PostgreSQL + Prisma ORM** | Relational data model (users, monuments, memories); Prisma for type-safe queries & migrations |
|
|
| **File Upload** | **UploadThing** | Built for Next.js, handles image resizing, CDN delivery, max 3 files validation |
|
|
| **QR Code** | **`qrcode` (npm)** | Pure JS, generates PNG/SVG from server, 1M+ weekly downloads |
|
|
| **Subdomain** | **Next.js Middleware + Vercel Wildcard Domains** | `*.monuments.app` rewrites to dynamic route `[subdomain]/page.tsx` |
|
|
| **Templates** | **React Server Components + Tailwind CSS** | 3 pre-designed HTML landing page templates as React components; server-rendered for SEO |
|
|
| **Deployment** | **Vercel** | Native Next.js support, wildcard domains, serverless functions, Edge middleware |
|
|
| **Storage** | **Vercel Blob / AWS S3** | For uploaded monument images |
|
|
|
|
---
|
|
|
|
## 2. Data Model (Prisma Schema)
|
|
|
|
```prisma
|
|
model User {
|
|
id String @id @default(cuid())
|
|
clerkId String @unique
|
|
email String?
|
|
name String?
|
|
subdomain String @unique // e.g. "eiffel-tower"
|
|
templateId Int // 1, 2, or 3
|
|
title String? // monument page title
|
|
description String? // user-written text
|
|
published Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
images Image[]
|
|
}
|
|
|
|
model Image {
|
|
id String @id @default(cuid())
|
|
url String // CDN URL from UploadThing
|
|
key String // UploadThing file key
|
|
order Int // display order (1-3)
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
createdAt DateTime @default(now())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Application Flow
|
|
|
|
```
|
|
┌─────────────────────────┐
|
|
│ monuments.app (landing) │
|
|
└────────┬────────────────┘
|
|
│ Sign Up via Clerk
|
|
▼
|
|
┌─────────────────────────────┐
|
|
│ Dashboard / Profile Setup │
|
|
│ - Pick template (1/2/3) │
|
|
│ - Enter monument name/text │
|
|
│ - Upload up to 3 photos │
|
|
│ - Choose custom subdomain │
|
|
└─────────────┬───────────────┘
|
|
│ Publish
|
|
▼
|
|
┌────────────────────────────────────────┐
|
|
│ 1. Generate subdomain + HTML landing │
|
|
│ 2. Generate QR code (→ subdomain URL) │
|
|
│ 3. Store everything in DB │
|
|
└────────────────┬───────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────┐
|
|
│ eiffel-tower.monuments.app │
|
|
│ → SSR landing page from template │
|
|
│ → Displays text + 3 photos │
|
|
└────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Implementation Plan — Phase by Phase
|
|
|
|
### Phase 0: Project Setup (Day 1)
|
|
|
|
```bash
|
|
npx create-next-app@latest city-monuments --typescript --tailwind --app
|
|
npm install @clerk/nextjs prisma @prisma/client @uploadthing/react uploadthing qrcode qrcode @types/qrcode
|
|
```
|
|
|
|
- [ ] Create Next.js 15 project with TypeScript + Tailwind
|
|
- [ ] Configure Clerk (`.env.local` with publishable + secret keys)
|
|
- [ ] Set up `app/layout.tsx` with `<ClerkProvider>`
|
|
- [ ] Initialize Prisma with PostgreSQL
|
|
- [ ] Set up UploadThing with file router (max 3 images, 5MB each, image only)
|
|
|
|
### Phase 1: Authentication & Onboarding (Days 2-3)
|
|
|
|
- [ ] Configure Clerk middleware (`src/middleware.ts`)
|
|
- [ ] Create `/sign-in` and `/sign-up` pages using Clerk components
|
|
- [ ] Create `/dashboard` — protected page showing user's monument (or "create one" CTA)
|
|
- [ ] Create `/onboarding` wizard page:
|
|
- **Step 1:** Enter monument name and description (text area)
|
|
- **Step 2:** Upload up to 3 photos (UploadThing dropzone, drag-to-reorder)
|
|
- **Step 3:** Choose subdomain slug (check availability via API)
|
|
- **Step 4:** Pick template (3 visual card selections)
|
|
- [ ] On submit: call `/api/publish` to save everything
|
|
|
|
### Phase 2: API Routes (Days 3-4)
|
|
|
|
| Route | Method | Purpose |
|
|
|-------|--------|---------|
|
|
| `/api/publish` | POST | Saves user data, generates subdomain, returns QR code |
|
|
| `/api/check-subdomain` | GET | `?slug=xyz` — returns `{available: bool}` |
|
|
| `/api/monument/[subdomain]` | GET | Public JSON data for a monument page |
|
|
| `/api/upload` | POST | UploadThing endpoint (auto-generated) |
|
|
| `/api/user/monument` | GET/PUT | Get/update current user's monument data |
|
|
|
|
**Key logic in `/api/publish`:**
|
|
|
|
1. Validate input (text length, image count, template ID)
|
|
2. Check subdomain availability
|
|
3. Store in DB (User + Image records)
|
|
4. Generate QR code via `qrcode.toBuffer(subdomainUrl, { type: 'png' })`
|
|
5. Return QR code as base64 data URL + public monument URL
|
|
6. (Optional) Store QR code image in blob storage
|
|
|
|
### Phase 3: Subdomain Routing & Middleware (Day 4)
|
|
|
|
```typescript
|
|
// src/middleware.ts
|
|
import { clerkMiddleware } from '@clerk/nextjs/server'
|
|
import { NextResponse } from 'next/server'
|
|
import type { NextRequest } from 'next/server'
|
|
|
|
export default clerkMiddleware(async (auth, req: NextRequest) => {
|
|
const url = req.nextUrl
|
|
const hostname = req.headers.get('host') || ''
|
|
|
|
// Extract subdomain (e.g. "eiffel-tower" from "eiffel-tower.monuments.app")
|
|
const subdomain = hostname
|
|
.replace('www.', '')
|
|
.replace('.monuments.app', '')
|
|
|
|
// If it's a subdomain (not apex), rewrite to the dynamic page
|
|
if (subdomain && !subdomain.includes('.') && hostname.includes('.monuments.app')) {
|
|
url.pathname = `/${subdomain}`
|
|
return NextResponse.rewrite(url)
|
|
}
|
|
|
|
return NextResponse.next()
|
|
})
|
|
|
|
export const config = {
|
|
matcher: ['/((?!_next|api|static|.*\\..*).*)']
|
|
}
|
|
```
|
|
|
|
- [ ] Create `app/[subdomain]/page.tsx` — fetches monument data, renders template
|
|
- [ ] Handle 404 for unknown subdomains
|
|
- [ ] Configure Vercel: add `*.monuments.app` as wildcard domain
|
|
|
|
### Phase 4: Template System (Day 5)
|
|
|
|
Create 3 React Server Components that render pure HTML landing pages:
|
|
|
|
| Template | Vibe | Layout |
|
|
|----------|------|--------|
|
|
| **Template 1 — "Classic"** | Clean, serif font, historical archive feel | Header image hero → description → 3-column photo grid |
|
|
| **Template 2 — "Modern"** | Bold, full-bleed images, sans-serif | Full-screen photo carousel → floating text overlay → gallery |
|
|
| **Template 3 — "Minimal"** | Whitespace-heavy, single-column, journal style | Title → paragraph → horizontal image strip → footer |
|
|
|
|
All templates:
|
|
|
|
- Are **React Server Components** (no JS shipped for visitors!)
|
|
- Use **Tailwind CSS** for styling
|
|
- Are **SEO-friendly** with proper `<head>` meta tags
|
|
- Render **optimized images** via Next.js `<Image>` component
|
|
- Include the **QR code badge** in the footer ("Scan to visit this monument")
|
|
- Support **Open Graph** metadata for social sharing
|
|
|
|
Example template rendering function:
|
|
|
|
```typescript
|
|
// src/lib/templates.tsx
|
|
export function renderTemplate(templateId: number, data: MonumentData) {
|
|
switch (templateId) {
|
|
case 1: return <TemplateClassic data={data} />
|
|
case 2: return <TemplateModern data={data} />
|
|
case 3: return <TemplateMinimal data={data} />
|
|
}
|
|
}
|
|
```
|
|
|
|
### Phase 5: QR Code Generation (Day 5)
|
|
|
|
```typescript
|
|
// src/lib/qrcode.ts
|
|
import QRCode from 'qrcode'
|
|
|
|
export async function generateMonumentQR(subdomain: string): Promise<string> {
|
|
const url = `https://${subdomain}.monuments.app`
|
|
const qrBuffer = await QRCode.toBuffer(url, {
|
|
type: 'png',
|
|
width: 400,
|
|
margin: 2,
|
|
color: {
|
|
dark: '#1a1a2e',
|
|
light: '#ffffff'
|
|
}
|
|
})
|
|
// Store buffer to Vercel Blob and return public URL
|
|
// OR return as data URL for download
|
|
return `data:image/png;base64,${qrBuffer.toString('base64')}`
|
|
}
|
|
```
|
|
|
|
- [ ] Generate QR on publish
|
|
- [ ] Show QR in dashboard for download (PNG)
|
|
- [ ] Optionally embed QR on the monument page footer
|
|
|
|
### Phase 6: Dashboard & User Experience (Days 6-7)
|
|
|
|
- [ ] Dashboard: view/edit monument details
|
|
- [ ] Template preview (live switching)
|
|
- [ ] Image reorder/delete
|
|
- [ ] QR download button
|
|
- [ ] Share link + copy-to-clipboard
|
|
- [ ] "Unpublish" button
|
|
- [ ] Loading skeletons, error states, empty states
|
|
|
|
### Phase 7: Polish & Production (Day 8+)
|
|
|
|
- [ ] SEO — dynamic metadata per monument page (generateMetadata)
|
|
- [ ] Analytics — Vercel Analytics or Plausible
|
|
- [ ] Rate limiting on API routes
|
|
- [ ] Image optimization (UploadThing does auto-resize/webp)
|
|
- [ ] Custom 404 page for unknown subdomains
|
|
- [ ] Proper error boundaries
|
|
- [ ] Loading UI (`loading.tsx` per route)
|
|
|
|
---
|
|
|
|
## 5. Folder Structure
|
|
|
|
```
|
|
src/
|
|
├── app/
|
|
│ ├── layout.tsx # Root layout with ClerkProvider
|
|
│ ├── page.tsx # Landing page (monuments.app)
|
|
│ ├── [subdomain]/
|
|
│ │ └── page.tsx # Dynamic monument landing page (SSR)
|
|
│ ├── sign-in/[[...sign-in]]/page.tsx
|
|
│ ├── sign-up/[[...sign-up]]/page.tsx
|
|
│ ├── dashboard/
|
|
│ │ ├── page.tsx # Dashboard home
|
|
│ │ └── preview/
|
|
│ │ └── page.tsx # Live template preview
|
|
│ └── api/
|
|
│ ├── publish/route.ts
|
|
│ ├── check-subdomain/route.ts
|
|
│ ├── user/monument/route.ts
|
|
│ └── uploadthing/route.ts
|
|
├── components/
|
|
│ ├── templates/
|
|
│ │ ├── TemplateClassic.tsx
|
|
│ │ ├── TemplateModern.tsx
|
|
│ │ └── TemplateMinimal.tsx
|
|
│ ├── OnboardingWizard.tsx
|
|
│ ├── ImageUploader.tsx
|
|
│ ├── SubdomainPicker.tsx
|
|
│ ├── TemplatePicker.tsx
|
|
│ └── QRDisplay.tsx
|
|
├── lib/
|
|
│ ├── prisma.ts # Prisma client singleton
|
|
│ ├── templates.tsx # Template registry
|
|
│ ├── qrcode.ts # QR generation logic
|
|
│ └── uploadthing.ts # UploadThing config
|
|
├── middleware.ts # Clerk + subdomain routing
|
|
├── types/
|
|
│ └── index.ts # Shared TypeScript types
|
|
└── styles/
|
|
└── globals.css
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Deployment Checklist (Vercel)
|
|
|
|
- [ ] Set environment variables in Vercel dashboard
|
|
- [ ] Configure `*.monuments.app` in Vercel project → Domains
|
|
- [ ] Add `monuments.app` apex domain
|
|
- [ ] Run `npx prisma migrate deploy` on production DB
|
|
- [ ] Configure Clerk production URLs (from localhost → monuments.app)
|
|
- [ ] Set up UploadThing production env vars
|
|
- [ ] Enable Vercel Analytics
|
|
|
|
---
|
|
|
|
## 7. Future Enhancements (v2)
|
|
|
|
- **Multi-language** — i18n support for monument descriptions
|
|
- **Audio guide** — embed audio narration per monument
|
|
- **Map integration** — pin monuments on a city map
|
|
- **Social sharing** — share to Instagram/TikTok with QR
|
|
- **Analytics per monument** — visit counter, scan counter
|
|
- **Custom domain** — allow users to bring their own domain (e.g., `eiffeltower.com`)
|
|
- **Admin panel** — moderate content, flagged monuments
|
|
|
|
---
|
|
|
|
## 8. Key Technical Decisions Summary
|
|
|
|
| Decision | Choice | Reasoning |
|
|
|----------|--------|-----------|
|
|
| Auth | Clerk | Specified requirement; great DX with Next.js |
|
|
| Subdomain routing | Middleware rewrite | Clean URLs, no path-based routing needed |
|
|
| Image storage | UploadThing | Built-in Next.js integration, CDN, auto-optimization |
|
|
| QR generation | `qrcode` npm | Pure JS, works server-side, PNG/SVG output |
|
|
| DB ORM | Prisma | Type-safe, auto-generated types, easy migrations |
|
|
| Templates | React Server Components | Zero client JS for public pages, fast SSR |
|
|
| Styling | Tailwind CSS | Rapid prototyping, consistent design system |
|
|
| Hosting | Vercel | One-command deploy, wildcard domains, Edge middleware |
|