Align backend plan with codebase
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
storybook-static
|
||||||
|
.runner
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Copy to `.env.local` for local development (never commit real secrets).
|
||||||
|
|
||||||
|
# PostgreSQL — use `docker compose up -d postgres` and match user/db/password.
|
||||||
|
DATABASE_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule"
|
||||||
|
|
||||||
|
# Session signing + OTP pepper (min 16 characters; use a long random string in production).
|
||||||
|
SESSION_SECRET="dev-only-change-me-16chars-min"
|
||||||
|
|
||||||
|
# Optional: Nodemailer transport URL, e.g. `smtp://localhost:1025` with Mailhog from docker-compose.
|
||||||
|
# Leave unset in development to log OTP codes to the server console instead.
|
||||||
|
SMTP_URL=
|
||||||
|
SMTP_FROM="Community Rule <noreply@localhost>"
|
||||||
|
|
||||||
|
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
||||||
|
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||||
@@ -449,6 +449,10 @@ jobs:
|
|||||||
node-version: "${{ env.NODE_VERSION }}"
|
node-version: "${{ env.NODE_VERSION }}"
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci --no-audit --fund=false
|
- run: npm ci --no-audit --fund=false
|
||||||
|
- name: Prisma schema
|
||||||
|
run: npx prisma validate
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm exec prettier -- --check "**/*.{js,jsx,ts,tsx,json,css,md}"
|
- run: npm exec prettier -- --check "**/*.{js,jsx,ts,tsx,json,css,md}"
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ 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
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Backend (local)
|
||||||
|
|
||||||
|
1. Copy [`.env.example`](.env.example) to `.env.local` and set `SESSION_SECRET` (at least 16 characters).
|
||||||
|
2. Start Postgres (and optional Mailhog): `docker compose up -d postgres mailhog`
|
||||||
|
3. Install dependencies: `npm ci`
|
||||||
|
4. Apply migrations: `npx prisma migrate dev`
|
||||||
|
5. Run the app: `npm run dev`
|
||||||
|
|
||||||
|
Use `npx prisma studio` to inspect the database.
|
||||||
|
|
||||||
|
### Prisma migrations (important)
|
||||||
|
|
||||||
|
- **Do not edit** migration files that have **already been applied** to **staging, production, or any shared database**. Changing history breaks `migrate deploy` and other environments.
|
||||||
|
- To fix a bad migration, add a **new** migration that corrects the schema. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §8 for the full policy.
|
||||||
|
|
||||||
|
### API routes (overview)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| ---------- | ----------------------- | --------------------------------------------- |
|
||||||
|
| GET | `/api/health` | Liveness / DB check |
|
||||||
|
| GET | `/api/auth/session` | Current user or null |
|
||||||
|
| POST | `/api/auth/otp/request` | Send email OTP |
|
||||||
|
| POST | `/api/auth/otp/verify` | Verify OTP, set session cookie |
|
||||||
|
| POST | `/api/auth/logout` | Clear session |
|
||||||
|
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
|
||||||
|
| GET / POST | `/api/rules` | List or publish rules |
|
||||||
|
| GET | `/api/templates` | List curated templates |
|
||||||
|
|
||||||
|
### Optional draft sync
|
||||||
|
|
||||||
|
Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env.local` so the create flow saves drafts to the server when a user is logged in.
|
||||||
|
|
||||||
|
## Frontend / tests
|
||||||
|
|
||||||
|
See [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md) and the root [README.md](README.md).
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Optional production image (Next.js standalone output + Prisma).
|
||||||
|
# Build: docker build -t community-rule .
|
||||||
|
# Run: pass DATABASE_URL, SESSION_SECRET, etc. at runtime (see .env.example).
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --fund=false
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -17,6 +17,8 @@ npm run dev
|
|||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
Backend (Postgres, Prisma, API routes) setup is documented in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## 🧪 Testing Framework
|
## 🧪 Testing Framework
|
||||||
|
|
||||||
This project uses a simplified, component‑first testing model:
|
This project uses a simplified, component‑first testing model:
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
|
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||||
|
import { destroySessionFromRequest } from "../../../../lib/server/session";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
await destroySessionFromRequest();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "../../../../../lib/server/db";
|
||||||
|
import {
|
||||||
|
getSessionPepper,
|
||||||
|
isDatabaseConfigured,
|
||||||
|
} from "../../../../../lib/server/env";
|
||||||
|
import { hashOtpCode } from "../../../../../lib/server/hash";
|
||||||
|
import { sendOtpEmail } from "../../../../../lib/server/mail";
|
||||||
|
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||||
|
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||||
|
import { logger } from "../../../../../lib/logger";
|
||||||
|
|
||||||
|
const OTP_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||||
|
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||||
|
|
||||||
|
function normalizeEmail(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const email = raw.trim().toLowerCase();
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = normalizeEmail(
|
||||||
|
body && typeof body === "object" && "email" in body
|
||||||
|
? (body as { email: unknown }).email
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json({ error: "Valid email required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
|
request.headers.get("x-real-ip") ??
|
||||||
|
"unknown";
|
||||||
|
|
||||||
|
const rlEmail = rateLimitKey(`otp-email:${email}`, EMAIL_MIN_INTERVAL_MS);
|
||||||
|
if (rlEmail.ok === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rlIp = rateLimitKey(`otp-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
||||||
|
if (rlIp.ok === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many requests", retryAfterMs: rlIp.retryAfterMs },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pepper: string;
|
||||||
|
try {
|
||||||
|
pepper = getSessionPepper();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Server misconfiguration" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
const codeHash = hashOtpCode(code, pepper);
|
||||||
|
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
||||||
|
|
||||||
|
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||||
|
await prisma.otpChallenge.create({
|
||||||
|
data: { email, codeHash, expiresAt },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendOtpEmail(email, code);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("sendOtpEmail failed:", err);
|
||||||
|
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not send email" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "../../../../../lib/server/db";
|
||||||
|
import {
|
||||||
|
getSessionPepper,
|
||||||
|
isDatabaseConfigured,
|
||||||
|
} from "../../../../../lib/server/env";
|
||||||
|
import { hashOtpCode } from "../../../../../lib/server/hash";
|
||||||
|
import {
|
||||||
|
createSessionForUser,
|
||||||
|
setSessionCookie,
|
||||||
|
} from "../../../../../lib/server/session";
|
||||||
|
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
function normalizeEmail(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const email = raw.trim().toLowerCase();
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email: rawEmail, code: rawCode } = body as {
|
||||||
|
email?: unknown;
|
||||||
|
code?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = normalizeEmail(rawEmail);
|
||||||
|
const code =
|
||||||
|
typeof rawCode === "string"
|
||||||
|
? rawCode.replace(/\s/g, "")
|
||||||
|
: String(rawCode ?? "");
|
||||||
|
|
||||||
|
if (!email || !/^\d{6}$/.test(code)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Valid email and 6-digit code required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pepper: string;
|
||||||
|
try {
|
||||||
|
pepper = getSessionPepper();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Server misconfiguration" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = await prisma.otpChallenge.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenge.attempts >= MAX_ATTEMPTS) {
|
||||||
|
await prisma.otpChallenge.delete({ where: { id: challenge.id } });
|
||||||
|
return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedHash = hashOtpCode(code, pepper);
|
||||||
|
if (expectedHash !== challenge.codeHash) {
|
||||||
|
await prisma.otpChallenge.update({
|
||||||
|
where: { id: challenge.id },
|
||||||
|
data: { attempts: { increment: 1 } },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||||
|
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email },
|
||||||
|
create: { email },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token, expiresAt } = await createSessionForUser(user.id);
|
||||||
|
await setSessionCookie(token, expiresAt);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: user.id, email: user.email },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
|
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||||
|
import { getSessionUser } from "../../../../lib/server/session";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ user: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: { id: user.id, email: user.email },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "../../../../lib/server/db";
|
||||||
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
|
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||||
|
import { getSessionUser } from "../../../../lib/server/session";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = await prisma.ruleDraft.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
draft: draft ? { payload: draft.payload, updatedAt: draft.updatedAt } : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== "object" || !("payload" in body)) {
|
||||||
|
return NextResponse.json({ error: "payload required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (body as { payload: unknown }).payload;
|
||||||
|
if (payload === undefined || typeof payload !== "object" || payload === null) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "payload must be a JSON object" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = await prisma.ruleDraft.upsert({
|
||||||
|
where: { userId: user.id },
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
payload: payload as object,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
payload: payload as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "../../../lib/server/db";
|
||||||
|
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
database: "not_configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return NextResponse.json({ ok: true, database: "connected" });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, database: "error" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "../../../lib/server/db";
|
||||||
|
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||||
|
import { dbUnavailable } from "../../../lib/server/responses";
|
||||||
|
import { getSessionUser } from "../../../lib/server/session";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||||
|
|
||||||
|
const rules = await prisma.publishedRule.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
summary: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ rules });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, summary, document } = body as {
|
||||||
|
title?: unknown;
|
||||||
|
summary?: unknown;
|
||||||
|
document?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof title !== "string" || title.trim().length === 0) {
|
||||||
|
return NextResponse.json({ error: "title required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document === undefined || typeof document !== "object" || document === null) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "document must be a JSON object" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = await prisma.publishedRule.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
title: title.trim(),
|
||||||
|
summary:
|
||||||
|
typeof summary === "string" && summary.trim().length > 0
|
||||||
|
? summary.trim()
|
||||||
|
: null,
|
||||||
|
document: document as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rule: {
|
||||||
|
id: rule.id,
|
||||||
|
title: rule.title,
|
||||||
|
summary: rule.summary,
|
||||||
|
createdAt: rule.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "../../../lib/server/db";
|
||||||
|
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||||
|
import { dbUnavailable } from "../../../lib/server/responses";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated rule templates for recommendations (seed via Prisma Studio or a script).
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = await prisma.ruleTemplate.findMany({
|
||||||
|
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
category: true,
|
||||||
|
description: true,
|
||||||
|
body: true,
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ templates });
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
fetchAuthSession,
|
||||||
|
fetchDraftFromServer,
|
||||||
|
saveDraftToServer,
|
||||||
|
} from "../../../lib/create/api";
|
||||||
|
import { useCreateFlow } from "./CreateFlowContext";
|
||||||
|
|
||||||
|
const SYNC_ENABLED =
|
||||||
|
process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true, loads the signed-in user's draft
|
||||||
|
* from the server and debounces saves. Anonymous users keep localStorage-only behavior.
|
||||||
|
*/
|
||||||
|
export function CreateFlowBackendSync() {
|
||||||
|
const { state, replaceState } = useCreateFlow();
|
||||||
|
const [hydrated, setHydrated] = useState(!SYNC_ENABLED);
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!SYNC_ENABLED) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { user } = await fetchAuthSession();
|
||||||
|
if (cancelled || !user) {
|
||||||
|
setHydrated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serverDraft = await fetchDraftFromServer();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (serverDraft && Object.keys(serverDraft).length > 0) {
|
||||||
|
replaceState(serverDraft);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setHydrated(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [replaceState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!SYNC_ENABLED || !hydrated) return;
|
||||||
|
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
saveTimer.current = null;
|
||||||
|
void (async () => {
|
||||||
|
const { user } = await fetchAuthSession();
|
||||||
|
if (!user) return;
|
||||||
|
await saveDraftToServer(state);
|
||||||
|
})();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
};
|
||||||
|
}, [state, hydrated]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -79,6 +79,11 @@ export function CreateFlowProvider({
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const replaceState = useCallback((next: CreateFlowState) => {
|
||||||
|
setState(next);
|
||||||
|
writeStateToStorage(STORAGE_KEY, next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearState = useCallback(() => {
|
const clearState = useCallback(() => {
|
||||||
setState({});
|
setState({});
|
||||||
removeFromStorage(STORAGE_KEY);
|
removeFromStorage(STORAGE_KEY);
|
||||||
@@ -89,6 +94,7 @@ export function CreateFlowProvider({
|
|||||||
state,
|
state,
|
||||||
currentStep,
|
currentStep,
|
||||||
updateState,
|
updateState,
|
||||||
|
replaceState,
|
||||||
clearState,
|
clearState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CreateFlowBackendSync } from "./context/CreateFlowBackendSync";
|
||||||
import {
|
import {
|
||||||
CreateFlowProvider,
|
CreateFlowProvider,
|
||||||
useCreateFlow,
|
useCreateFlow,
|
||||||
@@ -107,6 +108,7 @@ export default function CreateFlowLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<CreateFlowProvider>
|
<CreateFlowProvider>
|
||||||
|
<CreateFlowBackendSync />
|
||||||
<CreateFlowLayoutContent>{children}</CreateFlowLayoutContent>
|
<CreateFlowLayoutContent>{children}</CreateFlowLayoutContent>
|
||||||
</CreateFlowProvider>
|
</CreateFlowProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface CreateFlowContextValue {
|
|||||||
state: CreateFlowState;
|
state: CreateFlowState;
|
||||||
currentStep: CreateFlowStep | null;
|
currentStep: CreateFlowStep | null;
|
||||||
updateState: (_updates: Partial<CreateFlowState>) => void;
|
updateState: (_updates: Partial<CreateFlowState>) => void;
|
||||||
|
/** Replace entire flow state (e.g. hydrate from server draft). */
|
||||||
|
replaceState: (_next: CreateFlowState) => void;
|
||||||
/** Clear all flow state (e.g. on exit). Also clears persisted draft. */
|
/** Clear all flow state (e.g. on exit). Also clears persisted draft. */
|
||||||
clearState: () => void;
|
clearState: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: communityrule
|
||||||
|
POSTGRES_PASSWORD: communityrule
|
||||||
|
POSTGRES_DB: communityrule
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
mailhog:
|
||||||
|
image: mailhog/mailhog:v1.0.1
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1025:1025"
|
||||||
|
- "8025:8025"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
# Backend work — linear tickets
|
||||||
|
|
||||||
|
Copy each block into Linear (or your tracker) as a separate issue, **in order**. Earlier tickets are prerequisites for later ones.
|
||||||
|
|
||||||
|
**Foundation already in the repo (no ticket needed unless you are onboarding a greenfield clone):** Prisma schema ([prisma/schema.prisma](prisma/schema.prisma)), migrations, `lib/server/*`, Route Handlers under `app/api/*`, [docker-compose.yml](docker-compose.yml), [Dockerfile](Dockerfile), [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example), [lib/create/api.ts](lib/create/api.ts), [CreateFlowBackendSync](app/create/context/CreateFlowBackendSync.tsx) behind `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`.
|
||||||
|
|
||||||
|
### Review sync (relevant feedback only)
|
||||||
|
|
||||||
|
A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), in-memory OTP limits until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**. **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors, blocked by CR-73), **CR-85** (session lifecycle, blocked by CR-75)—see **Linear** table at the end of this doc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When you need server / admin access (and for what)
|
||||||
|
|
||||||
|
Use this if you **do not** have SSH or hosting access yet. Most engineering tickets are **local-only** until you deploy somewhere shared.
|
||||||
|
|
||||||
|
### You do **not** need the server admin for
|
||||||
|
|
||||||
|
- **Tickets 1–8, 10:** Everything runs on your machine: `docker compose up -d postgres mailhog`, `.env.local`, `npm run dev`, `npx prisma migrate dev`. OTP email can use Mailhog or dev log (no real SMTP).
|
||||||
|
- **Verifying APIs:** Use `localhost` and the same Docker Postgres—no production host.
|
||||||
|
|
||||||
|
### The **first** time you need someone with hosting access
|
||||||
|
|
||||||
|
That is when you deploy to **staging** or **production** (a URL other people use, or a persistent DB not on your laptop). Until then, you can finish the core product slice without server credentials.
|
||||||
|
|
||||||
|
Ask the admin to provide (or do for you) the items below—**Ticket 12** turns this into a written runbook.
|
||||||
|
|
||||||
|
| What | Why you need it |
|
||||||
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Postgres** | Managed instance or container; a **`DATABASE_URL`** you can plug into the deployed app. |
|
||||||
|
| **Run migrations** | Someone runs **`npx prisma migrate deploy`** against that database **before** the new app version serves traffic (or gives you a secure way to run it in CI/CD). |
|
||||||
|
| **`SESSION_SECRET`** | Long random string in production env (sessions + OTP hashing). |
|
||||||
|
| **SMTP** | **`SMTP_URL`** + **`SMTP_FROM`** for real OTP email; not required on laptop if you use logs/Mailhog. |
|
||||||
|
| **DNS for mail** | Often **SPF/DKIM** so OTP messages are not spam—admin or whoever owns DNS. |
|
||||||
|
| **TLS + hostname** | HTTPS URL for the site; reverse proxy (nginx, Caddy, etc.) in front of Node. |
|
||||||
|
| **Health check** | Load balancer or platform should probe **`GET /api/health`** (or your chosen path). |
|
||||||
|
| **Secrets storage** | Env vars or secret manager—never commit `.env.local`. |
|
||||||
|
| **Backups** | Postgres backup/restore for production (and ideally staging). |
|
||||||
|
|
||||||
|
Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admin builds/pushes/runs the container with the env vars above.
|
||||||
|
|
||||||
|
### Ticket-by-ticket: admin involvement
|
||||||
|
|
||||||
|
| Ticket | Need server admin? | What for |
|
||||||
|
| ------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1–2 | **No** | Docs and app code only. |
|
||||||
|
| 3 | **No** to build/test; **Yes** when OTP must work on a **deployed** env | Real **SMTP** + DNS on staging/prod (same as table above). |
|
||||||
|
| 4–8 | **No** | Local or staging URL is still “your” deploy—admin only if that URL is on their infra. |
|
||||||
|
| 9 | **No** to implement; **Yes** when **production** uses multiple instances or read-only FS | **Default** is external RUM/log drain; Postgres vitals only if ops explicitly wants one datastore—may need vendor keys for SaaS. |
|
||||||
|
| 10 | **No** to code | Same deploy pipeline as the rest of the app. |
|
||||||
|
| 11 | **Maybe** | Whoever owns **Gitea runners**: can they run Postgres in CI? Not the same as production server, but often the same “infra” person. |
|
||||||
|
| 12 | **Yes—this is the handoff ticket** | You (or admin) write **`docs/ops-backend-deploy.md`** so deploy steps are explicit; **you need admin input** to fill in hostnames, DB provider, SMTP, backup policy. |
|
||||||
|
|
||||||
|
### One-line summary
|
||||||
|
|
||||||
|
**You only need the server admin when you move off your laptop to a shared staging/production host**—for database, secrets, TLS, SMTP/DNS, migrations on that DB, health checks, and backups. Until then, **Tickets 1–8 are unblocked** with Docker Compose locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 1 — Align `docs/backend-roadmap.md` with the current codebase
|
||||||
|
|
||||||
|
**Depends on:** nothing.
|
||||||
|
|
||||||
|
**Goal:** Remove stale statements so the roadmap matches reality and stays a trustworthy reference until you delete it.
|
||||||
|
|
||||||
|
**Context:** Section 1 still says there is no DB and only web-vitals API; the app now has Postgres models, auth, drafts, rules, templates API, etc.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Rewrite **§1 Where we are** to list: Prisma + Postgres, existing `app/api/*` routes, `localStorage` + optional server draft sync, web-vitals still file-based.
|
||||||
|
2. In **§9 Build order** (build steps were renumbered from old §5), mark what is **operator/manual**, what is **already shipped in the repo**, and what is **still product/frontend** (sign-in UI, publish wiring, etc.).
|
||||||
|
3. Add **HTTP API (implemented in repo)** — table mirroring [CONTRIBUTING.md](CONTRIBUTING.md), plus note for `/api/web-vitals`.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [x] A new contributor reading only the roadmap does not think the backend is unbuilt.
|
||||||
|
- [x] **§13 Optional later** (old §9) unchanged in intent — optional Redis, session-library spike, draft versioning, standalone API, OpenAPI, fourth env.
|
||||||
|
|
||||||
|
**Status:** [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) **Done**.
|
||||||
|
|
||||||
|
**Files:** [docs/backend-roadmap.md](docs/backend-roadmap.md) only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 2 — Formalize `CreateFlowState` and validate API payloads
|
||||||
|
|
||||||
|
**Depends on:** Ticket 1 (optional but keeps docs honest).
|
||||||
|
|
||||||
|
**Goal:** Replace the open `[key: string]: unknown` shape in [app/create/types.ts](app/create/types.ts) with real fields (or nested objects) agreed with design/product, and validate JSON on the server for drafts and publish.
|
||||||
|
|
||||||
|
**Context:** `PUT /api/drafts/me` and `POST /api/rules` accept loose objects today; oversized or malformed payloads are a stability and security concern.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Document intended fields per create-flow step (can start minimal: e.g. `title`, `sections`, `stakeholders` placeholders) in `CreateFlowState`.
|
||||||
|
2. Add **Zod** (or reuse **Ajv** if you prefer consistency with [lib/validation.ts](lib/validation.ts)) schemas:
|
||||||
|
- `createFlowStateSchema` for draft `payload`.
|
||||||
|
- `publishedRuleDocumentSchema` for `document` on `POST /api/rules`.
|
||||||
|
3. In [app/api/drafts/me/route.ts](app/api/drafts/me/route.ts) and [app/api/rules/route.ts](app/api/rules/route.ts), parse with schema; on failure return `400` with a small `{ error, details? }` body.
|
||||||
|
4. Enforce a **max payload size** (e.g. reject bodies > 512KB) via route handler check or Next config if applicable.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] TypeScript reflects the real shape of `CreateFlowState` (no unnecessary `unknown` for known keys).
|
||||||
|
- [ ] Invalid draft/publish requests return 400, not 500.
|
||||||
|
- [ ] Unit tests for schemas (Vitest) or route tests with MSW.
|
||||||
|
|
||||||
|
**Files:** [app/create/types.ts](app/create/types.ts), [app/api/drafts/me/route.ts](app/api/drafts/me/route.ts), [app/api/rules/route.ts](app/api/rules/route.ts), new `lib/server/validation/` or `lib/validation/createFlow.ts`, [package.json](package.json) if adding `zod`.
|
||||||
|
|
||||||
|
**Note:** Repo-wide **API error JSON shape** and **request-id logging** are **Ticket 13 / CR-84**—coordinate 400 response bodies with that issue so validation errors match the agreed `{ error: { code, message } }` pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 3 — Email OTP sign-in UI (end-to-end with existing APIs)
|
||||||
|
|
||||||
|
**Depends on:** Ticket 2 (soft dependency: types help name fields you might store post-login; can start in parallel if needed).
|
||||||
|
|
||||||
|
**Server / admin:** **Not required** to build and test (Mailhog or console OTP locally). **Required** when OTP must work on **staging/production**: admin provides **SMTP** + usually **DNS (SPF/DKIM)** and sets env on the host (see top table).
|
||||||
|
|
||||||
|
**Goal:** Let a user request a code and verify it in the browser using existing endpoints.
|
||||||
|
|
||||||
|
**Context:** APIs exist: `POST /api/auth/otp/request`, `POST /api/auth/otp/verify`, `GET /api/auth/session`, `POST /api/auth/logout`. Client helpers: [lib/create/api.ts](lib/create/api.ts).
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Add a **route** (e.g. `app/(marketing)/login/page.tsx`) or a **modal** from the main header, designer-approved.
|
||||||
|
2. Flow: email → “Send code” → 6-digit code → “Verify” → success closes UI or redirects to `/create` (product decision).
|
||||||
|
3. Surface API errors: invalid email, 429 `retryAfterMs`, wrong code, network failure (accessible copy).
|
||||||
|
4. Ensure `fetch` calls use `credentials: "include"` (already in `lib/create/api.ts`).
|
||||||
|
5. **Dev:** document that without `SMTP_URL`, OTP prints to server logs; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `SMTP_URL=smtp://localhost:1025`.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] Happy path: user can complete OTP and `GET /api/auth/session` returns user in the same browser session.
|
||||||
|
- [ ] Keyboard + screen-reader friendly forms (labels, errors associated with fields).
|
||||||
|
- [ ] No secrets in client bundle.
|
||||||
|
|
||||||
|
**Files:** new page/components under `app/` and `app/components/…`, optional [messages/en/…](messages/en/) JSON for i18n, [lib/create/api.ts](lib/create/api.ts) only if you need new helpers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 4 — Session affordances in the create flow (signed-in state + sign out)
|
||||||
|
|
||||||
|
**Depends on:** Ticket 3.
|
||||||
|
|
||||||
|
**Goal:** While in `/create/*`, users see whether they are signed in and can sign out without leaving the flow awkwardly.
|
||||||
|
|
||||||
|
**Context:** [CreateFlowTopNav](app/components/utility/CreateFlowTopNav/) has props like `loggedIn` currently tied to step UI in [app/create/layout.tsx](app/create/layout.tsx) (`isCompletedStep`). Decouple **auth session** from **step**.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. On create layout mount (or a small wrapper provider), call `fetchAuthSession()` and store `{ user }` in React state or a tiny `AuthSessionContext`.
|
||||||
|
2. Pass **real** `loggedIn={Boolean(user)}` (or rename prop to `isAuthenticated` if clearer) and show **email** (truncated) per design.
|
||||||
|
3. Wire **Sign out** to `logout()` from [lib/create/api.ts](lib/create/api.ts), clear client state as needed, refresh session.
|
||||||
|
4. Optionally: if `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` and user is anonymous, show one-line CTA “Sign in to save progress to your account” linking to login.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] Completed step still works; auth state is independent of `completed` step.
|
||||||
|
- [ ] Sign out clears httpOnly session server-side and UI updates.
|
||||||
|
|
||||||
|
**Files:** [app/create/layout.tsx](app/create/layout.tsx), [app/components/utility/CreateFlowTopNav/](app/components/utility/CreateFlowTopNav/), optional new `app/create/context/AuthSessionContext.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 5 — Harden server draft sync (UX + edge cases)
|
||||||
|
|
||||||
|
**Depends on:** Tickets 2–4.
|
||||||
|
|
||||||
|
**Goal:** `CreateFlowBackendSync` is production-grade when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
||||||
|
|
||||||
|
**Context:** [app/create/context/CreateFlowBackendSync.tsx](app/create/context/CreateFlowBackendSync.tsx) hydrates from server and debounces saves; today it can race with localStorage-first paint and silently fail saves.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. **Hydration:** Show a non-blocking “Loading your saved progress…” until first session + draft fetch completes (only when sync enabled).
|
||||||
|
2. **Conflict:** If `localStorage` has non-empty state and server returns non-empty draft, pick a policy: prefer server with confirm modal, or prefer newer `updatedAt` (requires storing timestamp client-side). Document choice in code comment.
|
||||||
|
3. **Save failures:** If `PUT /api/drafts/me` fails, show toast/banner; optionally retry with backoff.
|
||||||
|
4. **Tests:** Component test or Playwright scenario with sync flag on (may require test DB or route mocks).
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] No silent data loss when server save fails.
|
||||||
|
- [ ] User understands when server draft replaced local state (if applicable).
|
||||||
|
|
||||||
|
**Files:** [app/create/context/CreateFlowBackendSync.tsx](app/create/context/CreateFlowBackendSync.tsx), possibly [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 6 — Wire “Publish rule” from the create flow to `POST /api/rules`
|
||||||
|
|
||||||
|
**Depends on:** Tickets 2–4 (Ticket 5 optional).
|
||||||
|
|
||||||
|
**Goal:** Completing the flow persists a **PublishedRule** via existing [publishRule](lib/create/api.ts).
|
||||||
|
|
||||||
|
**Context:** [lib/create/api.ts](lib/create/api.ts) already wraps `POST /api/rules`. UI on [app/create/final-review/page.tsx](app/create/final-review/page.tsx) or [completed/page.tsx](app/create/completed/page.tsx) must call it with `{ title, summary?, document }` derived from `CreateFlowState`.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Map `useCreateFlow().state` → `title` / `summary` / `document` (document likely mirrors [CommunityRuleDocument](app/components/sections/CommunityRuleDocument/) shape or raw JSON).
|
||||||
|
2. Call `publishRule` on explicit user action (“Publish” / “Finalize”) or on transition to `completed` (product decision—prefer explicit button to avoid double-submit).
|
||||||
|
3. Handle **401**: redirect or modal to sign-in (Ticket 3).
|
||||||
|
4. Success: navigate to `completed` with rule id in query or state; optional confetti per design.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] Published row appears in Postgres (`PublishedRule`) and `GET /api/rules` lists it.
|
||||||
|
- [ ] User sees clear success/failure.
|
||||||
|
|
||||||
|
**Files:** relevant `app/create/*/page.tsx`, [lib/create/api.ts](lib/create/api.ts) if request shape changes, types from Ticket 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 7 — Seed `RuleTemplate` data and document how to re-run
|
||||||
|
|
||||||
|
**Depends on:** none (API exists at [app/api/templates/route.ts](app/api/templates/route.ts)).
|
||||||
|
|
||||||
|
**Goal:** Curated templates exist in DB for recommendations (v1 = static curated list, no ML).
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Add [Prisma seed](https://www.prisma.io/docs/guides/migrate/seed-database): `prisma/seed.ts` with `upsert` on `slug` for idempotent runs.
|
||||||
|
2. In [package.json](package.json), set `"prisma": { "seed": "tsx prisma/seed.ts" }` or `node --loader ts-node/esm` per your preference.
|
||||||
|
3. Seed 3–10 rows aligned with marketing copy today ([messages/en/components/ruleStack.json](messages/en/components/ruleStack.json) or home cards) — `title`, `category`, `description`, `body` JSON, `sortOrder`, `featured`.
|
||||||
|
4. Document: `npx prisma db seed` in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] `GET /api/templates` returns non-empty `templates` after seed on empty DB.
|
||||||
|
- [ ] Re-running seed does not duplicate rows.
|
||||||
|
|
||||||
|
**Files:** `prisma/seed.ts`, [package.json](package.json), [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 8 — Load rule templates from the API in the UI
|
||||||
|
|
||||||
|
**Depends on:** Ticket 7.
|
||||||
|
|
||||||
|
**Goal:** Home or create entry surfaces use live template data instead of only static i18n JSON.
|
||||||
|
|
||||||
|
**Context:** [RuleStack.view.tsx](app/components/sections/RuleStack/RuleStack.view.tsx) and [app/create/[step]/page.tsx](app/create/[step]/page.tsx) placeholders reference future template work (CR-51–55).
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Add a small client or server data fetch to `GET /api/templates` (RSC `fetch` with cache tags, or client `useEffect` with loading skeleton—match existing data-fetch patterns in the app).
|
||||||
|
2. Map API rows to existing card components; keep i18n for chrome strings (“See all templates”).
|
||||||
|
3. Empty state: if API returns `[]`, fall back to static copy or hide section per design.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] Changing a template row in Prisma Studio reflects after refresh (or revalidate).
|
||||||
|
- [ ] No layout shift regression on LCP-critical pages (use skeletons).
|
||||||
|
|
||||||
|
**Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), [app/create/[step]/page.tsx](app/create/[step]/page.tsx) or related, possibly new `lib/templates/fetchTemplates.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM)
|
||||||
|
|
||||||
|
**Depends on:** none (orthogonal).
|
||||||
|
|
||||||
|
**Server / admin:** **Not required** to implement. **Relevant** when production is **multi-instance** or **read-only filesystem**; external tools may need **vendor API keys** in env.
|
||||||
|
|
||||||
|
**Goal:** [app/api/web-vitals/route.ts](app/api/web-vitals/route.ts) stops relying on ephemeral files under `.next/web-vitals` in production.
|
||||||
|
|
||||||
|
**Context:** Multi-instance / Docker loses file-based metrics. [docs/backend-roadmap.md](backend-roadmap.md) §7: **default** is **external** analytics or log drain—keep product Postgres for product data.
|
||||||
|
|
||||||
|
**Implementation (pick one — prefer A or B first):**
|
||||||
|
|
||||||
|
- **A (preferred):** Integrate **external RUM / logging** (host metrics, Vercel Web Analytics, OpenTelemetry export, Datadog, etc.); stop or thin local aggregation; `app/(admin)/monitor/page.tsx` links out or shows reduced scope.
|
||||||
|
- **B:** Forward events from the route to a **log drain** or APM; trim custom dashboard if redundant.
|
||||||
|
- **C (fallback only):** New Prisma model `WebVitalEvent` + migrate + read path in monitor—**only** if ops explicitly chooses a single-store tradeoff (document why).
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] Production with read-only filesystem does not break vitals collection path.
|
||||||
|
- [ ] Monitor page still useful or intentionally replaced with a doc link.
|
||||||
|
|
||||||
|
**Files:** [app/api/web-vitals/route.ts](app/api/web-vitals/route.ts), [app/(admin)/monitor/](<app/(admin)/monitor/page.tsx>) (adjust paths as needed), optionally `prisma/schema.prisma` **only if** option C.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 10 — Public rule detail (optional product scope)
|
||||||
|
|
||||||
|
**Depends on:** Ticket 6.
|
||||||
|
|
||||||
|
**Goal:** Shareable link for a published rule.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Add `GET /api/rules/[id]/route.ts` returning `{ rule }` or 404 (public read; no secrets).
|
||||||
|
2. Add `app/(marketing)/rules/[id]/page.tsx` (or under `create` if private) rendering `document` JSON with existing document components.
|
||||||
|
3. Consider soft-delete flag later; out of scope unless product requires hide.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] UUID/cuid from Ticket 6 opens a readable page for anonymous users.
|
||||||
|
- [ ] Invalid id returns 404.
|
||||||
|
|
||||||
|
**Files:** new route handler, new page, optional layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 11 — CI: database migration smoke (optional, runner-dependent)
|
||||||
|
|
||||||
|
**Depends on:** existing [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml).
|
||||||
|
|
||||||
|
**Server / admin:** **Not production server**—but you may need whoever runs **Gitea/self-hosted runners** to allow **Postgres in CI** (Docker service / sidecar) or to accept a **manual migrate** process documented instead.
|
||||||
|
|
||||||
|
**Goal:** Catch broken SQL migrations before merge.
|
||||||
|
|
||||||
|
**Context:** Lint job already runs `prisma validate` with a dummy `DATABASE_URL`. **Migrate** needs a real Postgres reachable from the runner.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. If Gitea runners support **Docker sidecar** or **postgres service**, add a job: start Postgres, set `DATABASE_URL`, `npx prisma migrate deploy`, then run a minimal test that hits `/api/health` with DB connected (may require `next build` + short `next start` + curl).
|
||||||
|
2. If **macOS self-hosted** runners cannot run service containers easily, document in CONTRIBUTING: “run `migrate deploy` against staging before prod” and keep validate-only in CI.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] Broken migration fails CI **or** documented alternative process is explicit.
|
||||||
|
|
||||||
|
**Files:** [.gitea/workflows/ci.yaml](.gitea/workflows/ci.yaml), [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 12 — Staging / production runbook (operator checklist)
|
||||||
|
|
||||||
|
**Depends on:** Tickets 1–8 complete enough to deploy a vertical slice.
|
||||||
|
|
||||||
|
**Server / admin:** **This is the main ticket where you need the admin.** You draft the runbook; **admin fills in real hostnames, DB endpoint, SMTP, backup tooling, and who runs `migrate deploy`.** Without their input, you cannot complete production-ready deploy steps.
|
||||||
|
|
||||||
|
**Goal:** Single doc for admin: env vars, TLS, DB backups, migrations, Docker, SMTP, health checks.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Add `docs/ops-backend-deploy.md` (or similar) with numbered steps:
|
||||||
|
- Required env: `DATABASE_URL`, `SESSION_SECRET`, `SMTP_URL`, `SMTP_FROM`, optional `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`.
|
||||||
|
- `docker compose` vs `Dockerfile` deploy; `prisma migrate deploy` before traffic.
|
||||||
|
- Reverse proxy: `GET /api/health` for LB health.
|
||||||
|
- Backups and restore drill for Postgres.
|
||||||
|
- SMTP DNS (SPF/DKIM).
|
||||||
|
2. Cross-link [docs/backend-roadmap.md](docs/backend-roadmap.md) §11 (environments) and §8 (migrations policy); note **never rewrite applied migrations** and where application logs go.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] Someone who did not write the code can deploy and roll back migrations with only the doc.
|
||||||
|
|
||||||
|
**Files:** new `docs/ops-backend-deploy.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 13 — API error contract + structured logging
|
||||||
|
|
||||||
|
**Depends on:** Ticket 2 (validation work defines many 400s).
|
||||||
|
|
||||||
|
**Server / admin:** None.
|
||||||
|
|
||||||
|
**Goal:** Standardize JSON errors and lightweight observability per [docs/backend-roadmap.md](backend-roadmap.md) §7.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Document target shape `{ error: { code, message }, details? }` and map validation failures into `details` where useful.
|
||||||
|
2. Add a small **route helper** or wrapper: generate or forward **`x-request-id`**, log errors with `lib/logger` + id.
|
||||||
|
3. Migrate high-traffic or auth routes first; follow-up pass for remaining `app/api/*`.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] At least auth + draft + rules routes return the agreed shape for new code paths.
|
||||||
|
- [ ] Errors in logs include request id when available.
|
||||||
|
|
||||||
|
**Files:** `lib/server/` (new helper), selected `app/api/**/route.ts`, optional tests.
|
||||||
|
|
||||||
|
**Linear:** [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) (blocked by **CR-73**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket 14 — Custom session lifecycle (rotation, cleanup, policy)
|
||||||
|
|
||||||
|
**Depends on:** Ticket 4 (session visible in create flow).
|
||||||
|
|
||||||
|
**Server / admin:** None for implementation; production cron may need admin if cleanup runs as a job.
|
||||||
|
|
||||||
|
**Goal:** Make custom Prisma sessions maintainable: rotation, invalidation policy, expired-row cleanup—per [docs/backend-roadmap.md](backend-roadmap.md) §4–5.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. **Policy:** On new OTP login, decide whether to **delete other `Session` rows** for that user (single active session) or allow multiple devices (document choice).
|
||||||
|
2. **Rotation (optional v1.1):** Issue new token on privilege-sensitive actions if product requires.
|
||||||
|
3. **Cleanup:** Delete or mark expired sessions (scheduled job, or prune on read with occasional batch).
|
||||||
|
4. **Docs:** Add short ADR or comment block in `lib/server/session.ts`.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- [ ] Documented behavior matches implementation.
|
||||||
|
- [ ] Expired sessions do not accumulate unbounded in production over months.
|
||||||
|
|
||||||
|
**Files:** [lib/server/session.ts](lib/server/session.ts), [app/api/auth/otp/verify/route.ts](app/api/auth/otp/verify/route.ts) if invalidating siblings, optional `prisma` migration if new columns (unlikely).
|
||||||
|
|
||||||
|
**Linear:** [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) (blocked by **CR-75**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary order
|
||||||
|
|
||||||
|
| Order | Ticket | Short name |
|
||||||
|
| ----: | ------ | -------------------------------- |
|
||||||
|
| 1 | 1 | Refresh backend-roadmap |
|
||||||
|
| 2 | 2 | CreateFlowState + API validation |
|
||||||
|
| 3 | 3 | OTP sign-in UI |
|
||||||
|
| 4 | 4 | Create flow session UI |
|
||||||
|
| 5 | 5 | Draft sync hardening |
|
||||||
|
| 6 | 6 | Publish wiring |
|
||||||
|
| 7 | 7 | Template seed |
|
||||||
|
| 8 | 8 | Templates in UI |
|
||||||
|
| 9 | 9 | Web vitals persistence |
|
||||||
|
| 10 | 10 | Public rule detail (optional) |
|
||||||
|
| 11 | 11 | CI migrate smoke (optional) |
|
||||||
|
| 12 | 12 | Ops runbook |
|
||||||
|
| 13 | 13 | API errors + request-id logging |
|
||||||
|
| 14 | 14 | Session lifecycle + cleanup |
|
||||||
|
|
||||||
|
Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 13–14** are parallel to that chain (blocked by **CR-73** and **CR-75** respectively), not sequential after CR-83.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linear (Community-rule team)
|
||||||
|
|
||||||
|
**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (blocked by CR-73), **CR-85** (blocked by CR-75).
|
||||||
|
|
||||||
|
| Doc ticket | Linear | Title (short) |
|
||||||
|
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||||
|
| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap |
|
||||||
|
| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation |
|
||||||
|
| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis) | OTP sign-in UI |
|
||||||
|
| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out) | Create flow session UI |
|
||||||
|
| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-createflowbackendsync) | Draft sync hardening |
|
||||||
|
| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring |
|
||||||
|
| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed |
|
||||||
|
| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI |
|
||||||
|
| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) |
|
||||||
|
| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) |
|
||||||
|
| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | CI migrate smoke (optional) |
|
||||||
|
| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops runbook / admin handoff |
|
||||||
|
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging |
|
||||||
|
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup |
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Backend roadmap (reference)
|
||||||
|
|
||||||
|
Temporary working notes for building the backend. Safe to delete once the stack is stable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Where we are
|
||||||
|
|
||||||
|
- **Next.js 16** single repo ([`package.json`](package.json)).
|
||||||
|
- **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/OTP, session, drafts, rules, templates, web-vitals).
|
||||||
|
- **Server modules** in `lib/server/` (db, session, mail, rate limiting, etc.).
|
||||||
|
- **Create flow** persists in the browser (`localStorage`); optional **server draft sync** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` and the user is signed in ([`app/create/context/CreateFlowBackendSync.tsx`](app/create/context/CreateFlowBackendSync.tsx)).
|
||||||
|
- **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts) still use **file-based** storage under `.next` (not suitable for multi-instance production).
|
||||||
|
- **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition.
|
||||||
|
|
||||||
|
### HTTP API (implemented in repo)
|
||||||
|
|
||||||
|
Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table; handlers live under `app/api/*/route.ts`.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| ---------- | ----------------------- | --------------------------------------------- |
|
||||||
|
| GET | `/api/health` | Liveness / DB check |
|
||||||
|
| GET | `/api/auth/session` | Current user or null |
|
||||||
|
| POST | `/api/auth/otp/request` | Send email OTP |
|
||||||
|
| POST | `/api/auth/otp/verify` | Verify OTP, set session cookie |
|
||||||
|
| POST | `/api/auth/logout` | Clear session |
|
||||||
|
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
|
||||||
|
| GET / POST | `/api/rules` | List or publish rules |
|
||||||
|
| GET | `/api/templates` | List curated templates |
|
||||||
|
|
||||||
|
**Also present (not in CONTRIBUTING table):** `POST` / `GET` [`/api/web-vitals`](../app/api/web-vitals/route.ts) — file-based store today; production path TBD (§7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What we’re building
|
||||||
|
|
||||||
|
**Step 1.** Treat this as **greenfield**: new **PostgreSQL** database and new tables. Do **not** migrate data from the old Community Rule backend.
|
||||||
|
|
||||||
|
**Step 2.** Keep the backend **inside this Next app**:
|
||||||
|
|
||||||
|
- HTTP handlers under `app/api/…`
|
||||||
|
- Shared server code under `lib/server/…`
|
||||||
|
|
||||||
|
**Step 3.** Use the old backend only as a **product hint** (email OTP, saving rules, listing rules). Do **not** copy its Express layout or MySQL schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stack choices
|
||||||
|
|
||||||
|
**Step 1.** Use **PostgreSQL** everywhere (local Docker, staging, production).
|
||||||
|
|
||||||
|
**Step 2.** Use **Prisma** — `schema.prisma`, `npx prisma migrate dev` / `migrate deploy`.
|
||||||
|
|
||||||
|
**Step 3.** Add **SMTP** (or Mailhog locally) for email OTP in deployed environments; dev can log OTP to the console when `SMTP_URL` is unset.
|
||||||
|
|
||||||
|
**Step 4.** **Redis / queues / Kubernetes** — not required for v1. **Exception:** before running **multiple app instances**, plan a **shared rate-limit store** (often Redis) for OTP endpoints; the current limiter is in-memory per process ([`lib/server/rateLimit.ts`](lib/server/rateLimit.ts)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data to model (first pass)
|
||||||
|
|
||||||
|
Plain-English entities (names can evolve):
|
||||||
|
|
||||||
|
| Area | Purpose |
|
||||||
|
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **User** | Identified by email after OTP verification. |
|
||||||
|
| **Session** | **Custom v1:** HttpOnly cookie; opaque token; **hash** stored in DB ([`lib/server/session.ts`](lib/server/session.ts)). Not NextAuth/Lucia. |
|
||||||
|
| **OtpChallenge** | Short-lived email codes (hashed). |
|
||||||
|
| **RuleDraft** | **One** JSON blob per user (create-flow state). Schema already has **`updatedAt`**; no draft **versioning** or **multiple named drafts** in v1. |
|
||||||
|
| **PublishedRule** | Saved rule after publish (title, summary, document JSON). |
|
||||||
|
| **RuleTemplate** | Curated templates (slug, category, ordering). |
|
||||||
|
|
||||||
|
**Session follow-ups to implement or decide:** token **rotation** on sensitive events, whether **new login invalidates other sessions**, and **cleanup** of expired `Session` rows (job or lazy delete). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly.
|
||||||
|
|
||||||
|
**RuleDraft future (not v1):** versioning, multiple drafts per user, easier corruption recovery—only if product needs them.
|
||||||
|
|
||||||
|
Align JSON shapes with `app/create/types.ts` as it matures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Session and authentication (v1)
|
||||||
|
|
||||||
|
- **Decision:** **Custom** database-backed sessions + email OTP; cookies are **httpOnly**; tokens are hashed at rest.
|
||||||
|
- **OTP rate limiting:** **In-memory** is acceptable for a **single Node process**. It does **not** coordinate across instances—**add a shared limiter (e.g. Redis)** before horizontal scaling or serious abuse exposure.
|
||||||
|
- Do **not** treat “switch to NextAuth/Lucia” as required for v1; document the custom lifecycle above instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Authorization (v1)
|
||||||
|
|
||||||
|
Match the current API behavior; tighten as product evolves:
|
||||||
|
|
||||||
|
- **`GET /api/drafts/me` / `PUT /api/drafts/me`:** Authenticated user only; draft is **scoped to that user** (`userId`).
|
||||||
|
- **`POST /api/rules`:** Authenticated user only; rule is stored with **`userId`** (owner).
|
||||||
|
- **`GET /api/rules`:** **Public list** of published rules (metadata: id, title, summary, timestamps)—no auth required today. **Not** a private “my rules” feed unless you add a separate route later.
|
||||||
|
- **v1:** No **editing** or **deleting** published rules via API in the shipped handlers; no **sharing** or **collaborative ownership**—treat each rule as **owned by one user** until product defines more.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API responses, errors, and observability
|
||||||
|
|
||||||
|
**Error JSON (target):** Prefer a stable shape, e.g. `{ "error": { "code": "string", "message": "string" }, "details"?: ... }` for 4xx/5xx, instead of only `{ "error": "string" }`. Validation errors can map into `details`. Implement gradually in route handlers.
|
||||||
|
|
||||||
|
**Logging:** Use the shared [`lib/logger.ts`](../lib/logger.ts) where possible. Include a **request correlation id** (reuse `x-request-id` if present, else generate) on API routes and log it with errors so support can tie logs together.
|
||||||
|
|
||||||
|
**Metrics:** No vendor required for v1; optional later: request duration, error counts.
|
||||||
|
|
||||||
|
**Web vitals:** **Default** is **external** RUM or log drain (e.g. host analytics, Vercel Analytics, OpenTelemetry, SaaS APM)—keep **product Postgres** focused on product entities. Storing vitals in Postgres is an **explicit tradeoff** only if ops strongly wants a single datastore.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Prisma migrations policy
|
||||||
|
|
||||||
|
- **Never edit** migration files that have **already been applied** to **staging or production** (or any shared database). Fixing schema drift = **add a new migration**.
|
||||||
|
- **Local dev:** `prisma migrate dev` creates migrations; **deployed envs:** `prisma migrate deploy` before serving new code that depends on the schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Build order (implementation steps)
|
||||||
|
|
||||||
|
**Operator / local (always manual):** Steps 1–4 — env file, Docker Postgres, `npm ci`, `prisma migrate dev`, `npm run dev`.
|
||||||
|
|
||||||
|
**Backend behavior already in the repo:** Steps **5–10** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals) is **not** production-ready (files under `.next`); treat as follow-up work aligned with §7.
|
||||||
|
|
||||||
|
**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption — see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Step 1.** Copy `.env.example` to `.env.local`. Set `DATABASE_URL` and secrets (see file comments).
|
||||||
|
|
||||||
|
**Step 2.** Start Postgres locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3.** Install dependencies and apply migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npx prisma migrate dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4.** Run the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5.** Confirm **health**: `GET /api/health` should return JSON.
|
||||||
|
|
||||||
|
**Step 6.** **OTP login** (happy path):
|
||||||
|
|
||||||
|
1. `POST /api/auth/otp/request` with `{ "email": "you@example.com" }`
|
||||||
|
2. Read the code from your mail catcher or server logs (dev).
|
||||||
|
3. `POST /api/auth/otp/verify` with `{ "email": "...", "code": "..." }`
|
||||||
|
4. `GET /api/auth/session` should show your user.
|
||||||
|
|
||||||
|
**Step 7.** **Drafts**: With a session, `GET /api/drafts/me` and `PUT /api/drafts/me` with `{ "payload": { ... } }` (create flow state object).
|
||||||
|
|
||||||
|
**Step 8.** **Publish**: `POST /api/rules` with `{ "title", "summary?", "document" }`.
|
||||||
|
|
||||||
|
**Step 9.** **Templates** (when ready): seed `RuleTemplate` rows; `GET /api/templates` is implemented.
|
||||||
|
|
||||||
|
**Step 10.** **Frontend sync**: Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env.local` for server drafts when logged in; `localStorage` remains fallback when off or anonymous.
|
||||||
|
|
||||||
|
**Step 11.** **Web vitals:** Move off `.next` files—**prefer an external analytics or logging pipeline** (see §7). Use Postgres for vitals only as a deliberate ops choice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Security checklist
|
||||||
|
|
||||||
|
- **HTTPS** in staging/production; session cookie **Secure**.
|
||||||
|
- **Rate-limit** OTP (in-memory OK for one instance; **shared store before multi-instance**—see §5).
|
||||||
|
- **Hash** OTP codes and session tokens before storing; short OTP expiry.
|
||||||
|
- **Secrets** only in env / secret store — never commit `.env.local`.
|
||||||
|
- **CORS:** prefer **same-origin** `/api/*`; if cross-origin, configure CORS and CSRF carefully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Environments
|
||||||
|
|
||||||
|
| Environment | Purpose | Notes |
|
||||||
|
| -------------- | ------------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| **Local** | Daily development | Docker Compose: Postgres + optional Mailhog (`docker compose up -d`). |
|
||||||
|
| **Staging** | Rehearse deploys and migrations | Match prod as closely as possible; test SMTP. |
|
||||||
|
| **Production** | Users | Backups, monitoring, migration job before traffic. |
|
||||||
|
|
||||||
|
**Optional QA:** Run automated tests against an **ephemeral** database in CI instead of maintaining a fourth long-lived server.
|
||||||
|
|
||||||
|
**Admin / infra (coordinate with whoever runs the server):**
|
||||||
|
|
||||||
|
1. TLS certificates and hostnames.
|
||||||
|
2. PostgreSQL backups and restore drill.
|
||||||
|
3. SMTP DNS (SPF, DKIM).
|
||||||
|
4. Health check URL for reverse proxy (`/api/health`).
|
||||||
|
5. Log retention and alerts for 5xx errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Frontend hook-up
|
||||||
|
|
||||||
|
**Step 1.** Keep default behavior: **no env flag** → create flow uses **only** `localStorage` (current behavior).
|
||||||
|
|
||||||
|
**Step 2.** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` to opt in to server drafts when logged in.
|
||||||
|
|
||||||
|
**Step 3.** Implement sign-in UI when you are ready: call the OTP routes, then rely on the browser cookie for `/api/drafts/me`.
|
||||||
|
|
||||||
|
**Step 4.** On publish, call `POST /api/rules` from the completed step when the backend is required (wire when the final review UI is ready).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Optional later
|
||||||
|
|
||||||
|
- **Session library** spike (Auth.js, Lucia) if custom lifecycle cost grows.
|
||||||
|
- **Redis** (or similar) for **shared OTP rate limits** and horizontal scale.
|
||||||
|
- **RuleDraft** versioning or multiple drafts per user.
|
||||||
|
- Standalone **API service** (Fastify/Hono) if scaling or workers demand it.
|
||||||
|
- **OpenAPI** if external API clients appear.
|
||||||
|
- **Fourth environment** or stricter rate limiting at the edge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Useful commands
|
||||||
|
|
||||||
|
| Command | When |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `npx prisma studio` | Inspect/edit DB locally. |
|
||||||
|
| `npx prisma migrate dev` | After changing `schema.prisma` in development. |
|
||||||
|
| `npx prisma migrate deploy` | Apply migrations in staging/production. |
|
||||||
|
| `docker compose up -d postgres mailhog` | Local DB + mail UI (http://localhost:8025). |
|
||||||
|
| `docker build -t community-rule .` | Optional production image (Next **standalone** + `node server.js`; see repo `Dockerfile`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External reading
|
||||||
|
|
||||||
|
- [Docker: development best practices](https://docs.docker.com/develop/dev-best-practices/)
|
||||||
|
- [Docker Compose in production](https://docs.docker.com/compose/how-tos/production/)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { CreateFlowState } from "../../app/create/types";
|
||||||
|
|
||||||
|
const jsonHeaders = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
async function parseJson<T>(response: Response): Promise<T> {
|
||||||
|
const data = (await response.json()) as T;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthSession(): Promise<{
|
||||||
|
user: { id: string; email: string } | null;
|
||||||
|
}> {
|
||||||
|
const res = await fetch("/api/auth/session", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
return parseJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestOtp(email: string): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const res = await fetch("/api/auth/otp/request", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
const data = await parseJson<{ error?: string }>(res);
|
||||||
|
if (!res.ok) {
|
||||||
|
return { error: data.error ?? "Request failed" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyOtp(
|
||||||
|
email: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<
|
||||||
|
{ ok: true; user: { id: string; email: string } } | { error: string }
|
||||||
|
> {
|
||||||
|
const res = await fetch("/api/auth/otp/verify", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ email, code }),
|
||||||
|
});
|
||||||
|
const data = await parseJson<{
|
||||||
|
error?: string;
|
||||||
|
user?: { id: string; email: string };
|
||||||
|
}>(res);
|
||||||
|
if (!res.ok || !data.user) {
|
||||||
|
return { error: data.error ?? "Verification failed" };
|
||||||
|
}
|
||||||
|
return { ok: true, user: data.user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
|
||||||
|
const res = await fetch("/api/drafts/me", { credentials: "include" });
|
||||||
|
if (res.status === 401) return null;
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await parseJson<{ draft: { payload: unknown } | null }>(res);
|
||||||
|
if (!data.draft?.payload || typeof data.draft.payload !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.draft.payload as CreateFlowState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDraftToServer(state: CreateFlowState): Promise<boolean> {
|
||||||
|
const res = await fetch("/api/drafts/me", {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ payload: state }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishRule(input: {
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
document: Record<string, unknown>;
|
||||||
|
}): Promise<
|
||||||
|
| { ok: true; id: string; title: string }
|
||||||
|
| { error: string }
|
||||||
|
> {
|
||||||
|
const res = await fetch("/api/rules", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
document: input.document,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await parseJson<{
|
||||||
|
error?: string;
|
||||||
|
rule?: { id: string; title: string };
|
||||||
|
}>(res);
|
||||||
|
if (!res.ok || !data.rule) {
|
||||||
|
return { error: data.error ?? "Publish failed" };
|
||||||
|
}
|
||||||
|
return { ok: true, id: data.rule.id, title: data.rule.title };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? ["query", "error", "warn"]
|
||||||
|
: ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function getSessionPepper(): string {
|
||||||
|
const secret = process.env.SESSION_SECRET;
|
||||||
|
if (!secret || secret.length < 16) {
|
||||||
|
throw new Error(
|
||||||
|
"SESSION_SECRET must be set (min 16 characters) for auth routes.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDatabaseConfigured(): boolean {
|
||||||
|
return Boolean(process.env.DATABASE_URL?.trim());
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createHash, randomBytes } from "crypto";
|
||||||
|
|
||||||
|
export function sha256Hex(input: string): string {
|
||||||
|
return createHash("sha256").update(input, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashOtpCode(code: string, pepper: string): string {
|
||||||
|
return sha256Hex(`${pepper}:otp:${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashSessionToken(token: string, pepper: string): string {
|
||||||
|
return sha256Hex(`${pepper}:session:${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newSessionToken(): string {
|
||||||
|
return randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
export async function sendOtpEmail(to: string, code: string): Promise<void> {
|
||||||
|
const url = process.env.SMTP_URL;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
logger.info(`[dev] OTP for ${to}: ${code}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("SMTP_URL is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(url);
|
||||||
|
const from = process.env.SMTP_FROM ?? "noreply@localhost";
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: "Your Community Rule sign-in code",
|
||||||
|
text: `Your sign-in code is: ${code}\n\nIt expires in 10 minutes.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* In-memory rate limiter (per server instance). Sufficient for small deploys;
|
||||||
|
* replace with Redis or edge limits if you scale horizontally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const windows = new Map<string, number>();
|
||||||
|
|
||||||
|
export function rateLimitKey(
|
||||||
|
key: string,
|
||||||
|
minIntervalMs: number,
|
||||||
|
): { ok: true } | { ok: false; retryAfterMs: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = windows.get(key) ?? 0;
|
||||||
|
const elapsed = now - last;
|
||||||
|
if (elapsed < minIntervalMs) {
|
||||||
|
return { ok: false, retryAfterMs: minIntervalMs - elapsed };
|
||||||
|
}
|
||||||
|
windows.set(key, now);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export function dbUnavailable(): NextResponse {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Database is not configured (DATABASE_URL)." },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import type { User } from "@prisma/client";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { getSessionPepper } from "./env";
|
||||||
|
import { hashSessionToken, newSessionToken } from "./hash";
|
||||||
|
|
||||||
|
export const SESSION_COOKIE_NAME = "cr_session";
|
||||||
|
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
|
export async function getSessionUser(): Promise<User | null> {
|
||||||
|
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
let pepper: string;
|
||||||
|
try {
|
||||||
|
pepper = getSessionPepper();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = hashSessionToken(token, pepper);
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.expiresAt < new Date()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionForUser(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ token: string; expiresAt: Date }> {
|
||||||
|
const pepper = getSessionPepper();
|
||||||
|
const token = newSessionToken();
|
||||||
|
const tokenHash = hashSessionToken(token, pepper);
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000);
|
||||||
|
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSessionCookie(token: string, expiresAt: Date): Promise<void> {
|
||||||
|
const store = await cookies();
|
||||||
|
store.set(SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
expires: expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessionCookie(): Promise<void> {
|
||||||
|
const store = await cookies();
|
||||||
|
store.set(SESSION_COOKIE_NAME, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySessionFromRequest(): Promise<void> {
|
||||||
|
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
await clearSessionCookie();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
let pepper: string;
|
||||||
|
try {
|
||||||
|
pepper = getSessionPepper();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = hashSessionToken(token, pepper);
|
||||||
|
await prisma.session.deleteMany({ where: { tokenHash } });
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import createMDX from "@next/mdx";
|
|||||||
/* eslint-env node */
|
/* eslint-env node */
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
serverExternalPackages: ["@prisma/client"],
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizeCss: true,
|
optimizeCss: true,
|
||||||
|
|||||||
Generated
+392
@@ -12,11 +12,13 @@
|
|||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@next/mdx": "^16.0.0",
|
"@next/mdx": "^16.0.0",
|
||||||
|
"@prisma/client": "^6.19.0",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
"critters": "^0.0.23",
|
"critters": "^0.0.23",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"next-intl": "^3.26.5",
|
"next-intl": "^3.26.5",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/react": "19.1.12",
|
"@types/react": "19.1.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||||
"@typescript-eslint/parser": "^8.41.0",
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
"playwright": "^1.54.2",
|
"playwright": "^1.54.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
"start-server-and-test": "^2.0.13",
|
"start-server-and-test": "^2.0.13",
|
||||||
"storybook": "^10.2.0",
|
"storybook": "^10.2.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@@ -3859,6 +3863,91 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@prisma/client": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prisma": "*",
|
||||||
|
"typescript": ">=5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"prisma": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/config": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"c12": "3.1.0",
|
||||||
|
"deepmerge-ts": "7.1.5",
|
||||||
|
"effect": "3.21.0",
|
||||||
|
"empathic": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/debug": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/engines": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3",
|
||||||
|
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"@prisma/fetch-engine": "6.19.3",
|
||||||
|
"@prisma/get-platform": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/engines-version": {
|
||||||
|
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||||
|
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/fetch-engine": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3",
|
||||||
|
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"@prisma/get-platform": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/get-platform": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@puppeteer/browsers": {
|
"node_modules/@puppeteer/browsers": {
|
||||||
"version": "2.10.10",
|
"version": "2.10.10",
|
||||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.10.tgz",
|
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.10.tgz",
|
||||||
@@ -5872,6 +5961,16 @@
|
|||||||
"undici-types": "~7.13.0"
|
"undici-types": "~7.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "6.4.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
|
||||||
|
"integrity": "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.12",
|
"version": "19.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
@@ -8147,6 +8246,35 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/c12": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.3",
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"exsolve": "^1.0.7",
|
||||||
|
"giget": "^2.0.0",
|
||||||
|
"jiti": "^2.4.2",
|
||||||
|
"ohash": "^2.0.11",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"perfect-debounce": "^1.0.0",
|
||||||
|
"pkg-types": "^2.2.0",
|
||||||
|
"rc9": "^2.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"magicast": "^0.3.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"magicast": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cac": {
|
"node_modules/cac": {
|
||||||
"version": "6.7.14",
|
"version": "6.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
@@ -8461,6 +8589,16 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/citty": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"consola": "^3.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cjs-module-lexer": {
|
"node_modules/cjs-module-lexer": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
|
||||||
@@ -8727,6 +8865,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/confbox": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/configstore": {
|
"node_modules/configstore": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz",
|
||||||
@@ -8781,6 +8926,16 @@
|
|||||||
"typedarray-to-buffer": "^3.1.5"
|
"typedarray-to-buffer": "^3.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/consola": {
|
||||||
|
"version": "3.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||||
|
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/console-browserify": {
|
"node_modules/console-browserify": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
|
||||||
@@ -9467,6 +9622,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge-ts": {
|
||||||
|
"version": "7.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/default-browser": {
|
"node_modules/default-browser": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
|
||||||
@@ -9543,6 +9708,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/defu": {
|
||||||
|
"version": "6.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz",
|
||||||
|
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/degenerator": {
|
"node_modules/degenerator": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
|
||||||
@@ -9598,6 +9770,13 @@
|
|||||||
"minimalistic-assert": "^1.0.0"
|
"minimalistic-assert": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/destr": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/destroy": {
|
"node_modules/destroy": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
@@ -9803,6 +9982,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -9839,6 +10031,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/effect": {
|
||||||
|
"version": "3.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||||
|
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"fast-check": "^3.23.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.278",
|
"version": "1.5.278",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz",
|
||||||
@@ -9886,6 +10089,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/empathic": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -11206,6 +11419,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/exsolve": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/extend": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -11289,6 +11509,29 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "3.23.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^6.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -11989,6 +12232,24 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/giget": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"citty": "^0.1.6",
|
||||||
|
"consola": "^3.4.0",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"node-fetch-native": "^1.6.6",
|
||||||
|
"nypm": "^0.6.0",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"giget": "dist/cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
@@ -16755,6 +17016,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch-native": {
|
||||||
|
"version": "1.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-fetch/node_modules/tr46": {
|
"node_modules/node-fetch/node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
@@ -16840,6 +17108,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "6.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||||
|
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/npm-run-path": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||||
@@ -16872,6 +17149,41 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nypm": {
|
||||||
|
"version": "0.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
||||||
|
"integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"citty": "^0.2.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"tinyexec": "^1.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"nypm": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nypm/node_modules/citty": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/nypm/node_modules/tinyexec": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -17019,6 +17331,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ohash": {
|
||||||
|
"version": "2.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
|
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -17552,6 +17871,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/perfect-debounce": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -17584,6 +17910,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkg-types": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"exsolve": "^1.0.7",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.55.1",
|
"version": "1.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
||||||
@@ -17909,6 +18247,32 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prisma": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/config": "6.19.3",
|
||||||
|
"@prisma/engines": "6.19.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prisma": "build/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process": {
|
"node_modules/process": {
|
||||||
"version": "0.11.10",
|
"version": "0.11.10",
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
@@ -18100,6 +18464,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
@@ -18193,6 +18574,17 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rc9": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"destr": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.1",
|
"version": "19.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||||
|
|||||||
+10
-2
@@ -10,7 +10,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 9999",
|
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 9999",
|
||||||
"postinstall": "npm rebuild lightningcss",
|
"postinstall": "npm rebuild lightningcss && prisma generate",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"storybook:local": "storybook dev -p 6006",
|
"storybook:local": "storybook dev -p 6006",
|
||||||
"storybook:github": "STORYBOOK_BASE_PATH=true storybook dev -p 6006",
|
"storybook:github": "STORYBOOK_BASE_PATH=true storybook dev -p 6006",
|
||||||
@@ -39,7 +39,11 @@
|
|||||||
"analyze": "npm run analyze:browser && npm run analyze:server",
|
"analyze": "npm run analyze:browser && npm run analyze:server",
|
||||||
"analyze:server": "ANALYZE=true npm run build",
|
"analyze:server": "ANALYZE=true npm run build",
|
||||||
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
||||||
"bundle:analyze": "node scripts/bundle-analyzer.js"
|
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
@@ -50,6 +54,8 @@
|
|||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"next-intl": "^3.26.5",
|
"next-intl": "^3.26.5",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
|
"@prisma/client": "^6.19.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
@@ -67,6 +73,7 @@
|
|||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/react": "19.1.12",
|
"@types/react": "19.1.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||||
"@typescript-eslint/parser": "^8.41.0",
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
@@ -83,6 +90,7 @@
|
|||||||
"playwright": "^1.54.2",
|
"playwright": "^1.54.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
"start-server-and-test": "^2.0.13",
|
"start-server-and-test": "^2.0.13",
|
||||||
"storybook": "^10.2.0",
|
"storybook": "^10.2.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tokenHash" 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 "OtpChallenge" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"codeHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "OtpChallenge_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RuleDraft" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RuleDraft_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PublishedRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"summary" TEXT,
|
||||||
|
"document" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PublishedRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RuleTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"category" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"body" JSONB NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"featured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "RuleTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OtpChallenge_email_idx" ON "OtpChallenge"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RuleDraft_userId_key" ON "RuleDraft"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PublishedRule_userId_idx" ON "PublishedRule"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RuleTemplate_slug_key" ON "RuleTemplate"("slug");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RuleDraft" ADD CONSTRAINT "RuleDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PublishedRule" ADD CONSTRAINT "PublishedRule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sessions Session[]
|
||||||
|
draft RuleDraft?
|
||||||
|
rules PublishedRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tokenHash String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OtpChallenge {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String
|
||||||
|
codeHash String
|
||||||
|
expiresAt DateTime
|
||||||
|
attempts Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RuleDraft {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
payload Json
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model PublishedRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
title String
|
||||||
|
summary String?
|
||||||
|
document Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RuleTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
category String?
|
||||||
|
description String?
|
||||||
|
body Json
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
featured Boolean @default(false)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user