Add self-host stack, initial schema, and docs

This commit is contained in:
ilia 2025-12-28 22:30:08 -05:00
parent fb727584d2
commit 341bb08858
15 changed files with 3730 additions and 41 deletions

View File

@ -0,0 +1,40 @@
---
description: MirrorMatch project rules (Next.js + Prisma + Auth + MinIO)
globs:
- "**/*.ts"
- "**/*.tsx"
- "**/*.md"
---
## Product rules
- MirrorMatch is **invite-only**. All reads/writes must be scoped to a `Group` the user is a member of.
- A **Set** has **210 Photos** and **24 Options**.
- A Photos `points` is **110** (validate server-side).
- A user can guess **once per photo**.
- A photo uploader **cannot guess** their own photo for points.
- Reveals are **manual by default**; optional auto-reveal when all Group members have guessed.
## Next.js rules
- Use **App Router** patterns.
- Do privileged operations (create set, upload, guess, reveal, invite) via **server actions** or route handlers.
- Never trust client input; validate with **zod** on the server.
## Auth rules
- Use Auth.js (next-auth) with Prisma adapter.
- Gate pages using server-side session checks.
- Treat users as identified by `user.id` from the session only.
## Storage rules
- Store images in MinIO (S3) bucket; database stores only `storageKey`.
- Access images via short-lived **presigned URLs**, generated server-side.
- Never make buckets public.
## Database rules
- Use Prisma migrations (`prisma migrate dev`) for schema changes.
- Prefer enforcing uniqueness with DB constraints (e.g., one guess per user per photo).

41
.env.example Normal file
View File

@ -0,0 +1,41 @@
########################
# Core app
########################
# Used by Prisma + the app.
DATABASE_URL="postgresql://mirrormatch:mirrormatch@localhost:5432/mirrormatch?schema=public"
# Auth.js / NextAuth
# Generate a long random string (at least 32 chars).
AUTH_SECRET="change-me"
# For local dev:
AUTH_URL="http://localhost:3000"
AUTH_TRUST_HOST="true"
########################
# Email (SMTP)
########################
# Used for magic links + group invites.
# Configure these to your own email server in prod.
EMAIL_FROM="MirrorMatch <no-reply@example.com>"
EMAIL_SERVER_HOST="localhost"
EMAIL_SERVER_PORT="1025"
EMAIL_SERVER_USER=""
EMAIL_SERVER_PASSWORD=""
########################
# S3 / MinIO
########################
S3_ENDPOINT="http://localhost:9000"
S3_REGION="us-east-1"
S3_BUCKET="mirrormatch"
S3_ACCESS_KEY_ID="minioadmin"
S3_SECRET_ACCESS_KEY="minioadmin"
# If true, use path-style URLs (needed for many MinIO setups)
S3_FORCE_PATH_STYLE="true"

6
.gitignore vendored
View File

@ -32,6 +32,10 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# local docker volumes / data
docker/
# vercel
.vercel
@ -39,3 +43,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

View File

@ -1,36 +1,45 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# MirrorMatch
## Getting Started
Invite-only photo guessing game: create a **Set** (210 photos) with **24 names**, upload photos (secretly tagged with the correct name + difficulty points), then let your **Group** guess. Reveal results when everyone has guessed or when an admin/uploader decides.
First, run the development server:
## Repo status
- **Branching**: `main` is protected on Gitea; work is pushed to `dev` and should be merged via PR.
## Tech
- **Web**: Next.js (App Router) + TypeScript + Tailwind
- **DB**: Postgres (Docker Compose)
- **Storage**: MinIO (S3-compatible, Docker Compose)
- **Auth**: Auth.js (NextAuth) + Prisma adapter
## Local development
### 1) Start dependencies
```bash
cd /home/beast/Code/mirrormatch
cp .env.example .env
docker compose up -d
```
### 2) Set up the database
```bash
npx prisma migrate dev
```
### 3) Run the app
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open `http://localhost:3000`.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Docs
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- `docs/ARCHITECTURE.md`
- `docs/DATA_MODEL.md`
- `docs/USER_FLOWS.md`
- `docs/DEPLOY_PROXMOX.md`

58
docker-compose.yml Normal file
View File

@ -0,0 +1,58 @@
services:
postgres:
image: postgres:16-alpine
container_name: mirrormatch-postgres
environment:
POSTGRES_USER: mirrormatch
POSTGRES_PASSWORD: mirrormatch
POSTGRES_DB: mirrormatch
ports:
- "5432:5432"
volumes:
- ./docker/postgres/data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mirrormatch -d mirrormatch"]
interval: 5s
timeout: 5s
retries: 20
minio:
image: minio/minio:latest
container_name: mirrormatch-minio
command: server --console-address ":9001" /data
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./docker/minio/data:/data
minio-init:
image: minio/mc:latest
container_name: mirrormatch-minio-init
depends_on:
- minio
environment:
S3_BUCKET: ${S3_BUCKET:-mirrormatch}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-minioadmin}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-minioadmin}
entrypoint: >
sh -c "
until mc alias set local http://minio:9000 $$S3_ACCESS_KEY_ID $$S3_SECRET_ACCESS_KEY; do sleep 1; done;
mc mb -p local/$$S3_BUCKET || true;
mc anonymous set none local/$$S3_BUCKET || true;
exit 0;
"
# Optional: local SMTP testing UI at http://localhost:8025
mailpit:
image: axllent/mailpit:v1.27
container_name: mirrormatch-mailpit
profiles: ["dev"]
ports:
- "1025:1025"
- "8025:8025"

37
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,37 @@
# MirrorMatch Architecture (MVP)
## Goals
- **Invite-only** groups.
- Create a **Set** with **210 photos** and **24 names (Options)**.
- Users can upload photos and set:
- the **correct Option** (secret until reveal)
- **points** (110)
- Only **other users** (not the uploader of that photo) can guess for points.
- **No reveal** until:
- an admin/uploader triggers reveal, and/or
- auto-reveal when everyone in the Group has finished guessing (configurable).
## Components
- **Next.js app**: renders UI + server actions for all privileged operations.
- **Postgres**: source of truth (users/groups/sets/guesses).
- **MinIO (S3)**: stores image objects; app stores only `storageKey`.
- **Auth.js (NextAuth)**: email magic links + optional OAuth.
## Security / privacy model
- All pages require login.
- A user can only read/write data for Groups they belong to.
- Image access is via **short-lived presigned URLs** generated server-side for authorized users.
- Invite tokens are **hashed** in the database.
## Deployment shape (Proxmox)
- Run the app as a Docker container behind your reverse proxy.
- Run Postgres + MinIO as Docker containers (or managed separately if you already have them).
- Configure SMTP env vars to your email server for:
- magic link auth
- invite emails

28
docs/DATA_MODEL.md Normal file
View File

@ -0,0 +1,28 @@
# MirrorMatch Data Model (MVP)
## Terms
- **Group**: invite-only membership boundary (family, friends, etc.).
- **Set**: a “round” containing **210 Photos** and **24 Options** (names/labels).
- **Option**: a label to guess (e.g., “Dad”, “Me”, “Twin A”, “Twin B”).
- **Photo**: an uploaded image in MinIO plus its secret `correctOptionId` and `points`.
- **Guess**: a users choice for a specific photo (one guess per user per photo).
- **Invite**: admin-created email invite with a token, used to join a Group.
## Constraints (enforced by app)
- Set has **210 photos**.
- Set has **24 options**.
- Photo `points` is **110**.
- A user can guess **at most once per photo**.
- A photo uploader **cannot** guess their own photo for points.
## Prisma models
See `prisma/schema.prisma`.
## “Same set, different groups”
If you want the same photos/options for multiple Groups, create multiple Sets (one per Group). Photos can reuse the same MinIO objects by reusing the same `storageKey` if desired.

30
docs/DEPLOY_PROXMOX.md Normal file
View File

@ -0,0 +1,30 @@
# Deploying to Proxmox (MVP)
This doc assumes youll run MirrorMatch as Docker containers on a Proxmox VM or LXC.
## Recommended layout
- **Postgres**: Docker container + persistent volume
- **MinIO**: Docker container + persistent volume
- **MirrorMatch web**: Docker container behind your reverse proxy (Caddy / Nginx / Traefik)
## Environment
Set these in your production environment (do not commit them):
- `DATABASE_URL`
- `AUTH_SECRET`
- `AUTH_URL` (your public URL, e.g. `https://mirrormatch.yourdomain.com`)
- SMTP env vars (`EMAIL_SERVER_*`, `EMAIL_FROM`)
- S3 env vars (`S3_*`)
## Reverse proxy notes
- Ensure `AUTH_URL` matches the external URL exactly (scheme + host).
- Forward `X-Forwarded-Proto` and `X-Forwarded-Host`.
## Images
MinIO bucket is private. The app should generate presigned URLs for authorized users.

44
docs/USER_FLOWS.md Normal file
View File

@ -0,0 +1,44 @@
# MirrorMatch User Flows (MVP)
## Admin: create Group + invite users
- Admin creates a Group (name + slug).
- Admin enters an email address and role (ADMIN/MEMBER).
- System emails an invite link: `/invite/<token>`.
- User clicks link, logs in, invite is redeemed, user becomes a member.
## User: create a Set
- Select Group.
- Create Set:
- title/instructions (optional)
- options (24)
- photos (210)
- For each photo:
- upload image
- choose correct option
- set points (110)
- Publish Set (status goes to OPEN).
## User: guessing
- Set screen shows **two photos at a time** (as you requested):
- UI shows a pair of target photos and asks the user to label each.
- Order is randomized per viewer (left/right and per-row).
- User selects an option for each photo and submits.
- Server validates:
- user is in the Group
- Set is OPEN
- user has not already guessed
- user is not the uploader of that photo
## Reveal
- If configured: auto-reveal when all Group members have guessed all photos.
- Always allowed: admin/uploader can manually reveal.
- Once revealed, UI shows:
- correct labels per photo
- who guessed what
- points earned + leaderboard

2906
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,17 @@
"lint": "eslint"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.958.0",
"@aws-sdk/s3-request-presigner": "^3.958.0",
"@prisma/client": "^7.2.0",
"next": "16.1.1",
"next-auth": "^4.24.13",
"nodemailer": "^7.0.12",
"prisma": "^7.2.0",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"zod": "^4.2.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

14
prisma.config.ts Normal file
View File

@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@ -0,0 +1,253 @@
-- CreateEnum
CREATE TYPE "GroupRole" AS ENUM ('ADMIN', 'MEMBER');
-- CreateEnum
CREATE TYPE "SetStatus" AS ENUM ('DRAFT', 'OPEN', 'LOCKED', 'REVEALED');
-- CreateEnum
CREATE TYPE "RevealMode" AS ENUM ('MANUAL', 'AUTO_WHEN_ALL_GUESSED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Group" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GroupMember" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "GroupRole" NOT NULL DEFAULT 'MEMBER',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GroupMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Set" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"title" TEXT,
"instructions" TEXT,
"minPhotos" INTEGER NOT NULL DEFAULT 2,
"maxPhotos" INTEGER NOT NULL DEFAULT 10,
"minOptions" INTEGER NOT NULL DEFAULT 2,
"maxOptions" INTEGER NOT NULL DEFAULT 4,
"status" "SetStatus" NOT NULL DEFAULT 'DRAFT',
"revealMode" "RevealMode" NOT NULL DEFAULT 'MANUAL',
"allowManualReveal" BOOLEAN NOT NULL DEFAULT true,
"revealedAt" TIMESTAMP(3),
"revealedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Set_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Option" (
"id" TEXT NOT NULL,
"setId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"order" INTEGER NOT NULL,
CONSTRAINT "Option_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Photo" (
"id" TEXT NOT NULL,
"setId" TEXT NOT NULL,
"uploaderId" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"mimeType" TEXT,
"order" INTEGER NOT NULL,
"points" INTEGER NOT NULL DEFAULT 1,
"correctOptionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Guess" (
"id" TEXT NOT NULL,
"photoId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"chosenOptionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Guess_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Invite" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "GroupRole" NOT NULL DEFAULT 'MEMBER',
"tokenHash" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"usedAt" TIMESTAMP(3),
"usedById" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Group_slug_key" ON "Group"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "GroupMember_groupId_userId_key" ON "GroupMember"("groupId", "userId");
-- CreateIndex
CREATE INDEX "Set_groupId_status_idx" ON "Set"("groupId", "status");
-- CreateIndex
CREATE INDEX "Option_setId_order_idx" ON "Option"("setId", "order");
-- CreateIndex
CREATE UNIQUE INDEX "Option_setId_label_key" ON "Option"("setId", "label");
-- CreateIndex
CREATE INDEX "Photo_setId_order_idx" ON "Photo"("setId", "order");
-- CreateIndex
CREATE INDEX "Guess_userId_createdAt_idx" ON "Guess"("userId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Guess_photoId_userId_key" ON "Guess"("photoId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash");
-- CreateIndex
CREATE INDEX "Invite_groupId_expiresAt_idx" ON "Invite"("groupId", "expiresAt");
-- CreateIndex
CREATE INDEX "Invite_email_expiresAt_idx" ON "Invite"("email", "expiresAt");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Group" ADD CONSTRAINT "Group_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GroupMember" ADD CONSTRAINT "GroupMember_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GroupMember" ADD CONSTRAINT "GroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Set" ADD CONSTRAINT "Set_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Set" ADD CONSTRAINT "Set_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Option" ADD CONSTRAINT "Option_setId_fkey" FOREIGN KEY ("setId") REFERENCES "Set"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_setId_fkey" FOREIGN KEY ("setId") REFERENCES "Set"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_correctOptionId_fkey" FOREIGN KEY ("correctOptionId") REFERENCES "Option"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Guess" ADD CONSTRAINT "Guess_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Guess" ADD CONSTRAINT "Guess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Guess" ADD CONSTRAINT "Guess_chosenOptionId_fkey" FOREIGN KEY ("chosenOptionId") REFERENCES "Option"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_usedById_fkey" FOREIGN KEY ("usedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

238
prisma/schema.prisma Normal file
View File

@ -0,0 +1,238 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum GroupRole {
ADMIN
MEMBER
}
enum SetStatus {
DRAFT
OPEN
LOCKED
REVEALED
}
enum RevealMode {
MANUAL
AUTO_WHEN_ALL_GUESSED
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
memberships GroupMember[]
groupsCreated Group[] @relation("groupsCreated")
setsCreated Set[] @relation("setsCreated")
photosUploaded Photo[]
guesses Guess[]
invitesCreated Invite[] @relation("invitesCreated")
invitesUsed Invite[] @relation("invitesUsed")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Auth.js / NextAuth Prisma Adapter models
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// MirrorMatch domain models
model Group {
id String @id @default(cuid())
name String
slug String @unique
createdById String
createdBy User @relation("groupsCreated", fields: [createdById], references: [id], onDelete: Restrict)
members GroupMember[]
sets Set[]
invites Invite[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GroupMember {
id String @id @default(cuid())
groupId String
userId String
role GroupRole @default(MEMBER)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
joinedAt DateTime @default(now())
@@unique([groupId, userId])
}
model Set {
id String @id @default(cuid())
groupId String
createdById String
title String?
instructions String?
// Config constraints
minPhotos Int @default(2)
maxPhotos Int @default(10)
minOptions Int @default(2)
maxOptions Int @default(4)
status SetStatus @default(DRAFT)
revealMode RevealMode @default(MANUAL)
// If revealMode == AUTO_WHEN_ALL_GUESSED, app may auto-reveal once every active member has guessed every photo.
allowManualReveal Boolean @default(true)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
createdBy User @relation("setsCreated", fields: [createdById], references: [id], onDelete: Restrict)
options Option[]
photos Photo[]
revealedAt DateTime?
revealedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([groupId, status])
}
model Option {
id String @id @default(cuid())
setId String
label String
order Int
set Set @relation(fields: [setId], references: [id], onDelete: Cascade)
photosCorrect Photo[] @relation("correctOption")
guessesChosen Guess[] @relation("chosenOption")
@@unique([setId, label])
@@index([setId, order])
}
model Photo {
id String @id @default(cuid())
setId String
uploaderId String
// Object storage pointer (MinIO / S3)
storageKey String
mimeType String?
order Int
points Int @default(1) // 110 (enforced by app)
correctOptionId String
set Set @relation(fields: [setId], references: [id], onDelete: Cascade)
uploader User @relation(fields: [uploaderId], references: [id], onDelete: Restrict)
correctOption Option @relation("correctOption", fields: [correctOptionId], references: [id], onDelete: Restrict)
guesses Guess[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([setId, order])
}
model Guess {
id String @id @default(cuid())
photoId String
userId String
chosenOptionId String
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
chosenOption Option @relation("chosenOption", fields: [chosenOptionId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
@@unique([photoId, userId])
@@index([userId, createdAt])
}
model Invite {
id String @id @default(cuid())
groupId String
email String
role GroupRole @default(MEMBER)
// Store only a hash of the token in DB.
tokenHash String @unique
createdById String
createdBy User @relation("invitesCreated", fields: [createdById], references: [id], onDelete: Restrict)
usedAt DateTime?
usedById String?
usedBy User? @relation("invitesUsed", fields: [usedById], references: [id], onDelete: SetNull)
expiresAt DateTime
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([groupId, expiresAt])
@@index([email, expiresAt])
}