Add self-host stack, initial schema, and docs
This commit is contained in:
parent
fb727584d2
commit
341bb08858
40
.cursor/rules/mirrormatch.mdc
Normal file
40
.cursor/rules/mirrormatch.mdc
Normal 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 **2–10 Photos** and **2–4 Options**.
|
||||||
|
- A Photo’s `points` is **1–10** (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
41
.env.example
Normal 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
6
.gitignore
vendored
@ -32,6 +32,10 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# local docker volumes / data
|
||||||
|
docker/
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@ -39,3 +43,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
63
README.md
63
README.md
@ -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** (2–10 photos) with **2–4 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
|
```bash
|
||||||
npm run dev
|
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.
|
- `docs/ARCHITECTURE.md`
|
||||||
|
- `docs/DATA_MODEL.md`
|
||||||
## Learn More
|
- `docs/USER_FLOWS.md`
|
||||||
|
- `docs/DEPLOY_PROXMOX.md`
|
||||||
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.
|
|
||||||
|
|||||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal 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
37
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# MirrorMatch Architecture (MVP)
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- **Invite-only** groups.
|
||||||
|
- Create a **Set** with **2–10 photos** and **2–4 names (Options)**.
|
||||||
|
- Users can upload photos and set:
|
||||||
|
- the **correct Option** (secret until reveal)
|
||||||
|
- **points** (1–10)
|
||||||
|
- 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
28
docs/DATA_MODEL.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# MirrorMatch Data Model (MVP)
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Group**: invite-only membership boundary (family, friends, etc.).
|
||||||
|
- **Set**: a “round” containing **2–10 Photos** and **2–4 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 user’s 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 **2–10 photos**.
|
||||||
|
- Set has **2–4 options**.
|
||||||
|
- Photo `points` is **1–10**.
|
||||||
|
- 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
30
docs/DEPLOY_PROXMOX.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Deploying to Proxmox (MVP)
|
||||||
|
|
||||||
|
This doc assumes you’ll 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
44
docs/USER_FLOWS.md
Normal 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 (2–4)
|
||||||
|
- photos (2–10)
|
||||||
|
- For each photo:
|
||||||
|
- upload image
|
||||||
|
- choose correct option
|
||||||
|
- set points (1–10)
|
||||||
|
- 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
2906
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -9,9 +9,17 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "16.1.1",
|
||||||
|
"next-auth": "^4.24.13",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
|
"prisma": "^7.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
253
prisma/migrations/20251229032458_init/migration.sql
Normal file
253
prisma/migrations/20251229032458_init/migration.sql
Normal 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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
238
prisma/schema.prisma
Normal 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) // 1–10 (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])
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user