Merge pull request 'adilallo/feature/BackendImplementation1' (#43) from adilallo/feature/BackendImplementation1 into main

Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
2026-04-06 23:08:18 +00:00
95 changed files with 4477 additions and 613 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.env*
!.env.example
coverage
playwright-report
test-results
storybook-static
.runner
+15
View File
@@ -0,0 +1,15 @@
# Copy to `.env` 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 + secret used when hashing magic-link tokens. 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 the magic-link verify URL to the server console instead of sending email.
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=
+4
View File
@@ -449,6 +449,10 @@ jobs:
node-version: "${{ env.NODE_VERSION }}"
cache: npm
- 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 exec prettier -- --check "**/*.{js,jsx,ts,tsx,json,css,md}"
+1
View File
@@ -65,6 +65,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
+46
View File
@@ -0,0 +1,46 @@
# Contributing
## Backend (local)
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET` (at least 16 characters).
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, the **magic-link verify URL** is printed in the dev server log (see `.env.example`).
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/magic-link/request` | Send sign-in link email |
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
| 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 |
### Email magic link (sign-in)
- Open **[http://localhost:3000/login](http://localhost:3000/login)** or use **Log in** in the site header (modal or full page).
- Enter email and request a link. Complete sign-in by opening the link in the **same browser** you use for the app (session cookie).
- **No `SMTP_URL`:** the full **`GET /api/auth/magic-link/verify?...`** URL is printed in the **dev server terminal** — paste it into the browser address bar.
- **Mailhog:** with Compose Mailhog running, set `SMTP_URL=smtp://localhost:1025` and open the link from the message in the Mailhog UI ([http://localhost:8025](http://localhost:8025)).
**Staging / production:** Sign-in links use the apps origin. Ensure your reverse proxy sets **`Host`** (and TLS) so links in email match the URL users open. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §9.
### Optional draft sync
Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` 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
View File
@@ -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"]
+2
View File
@@ -17,6 +17,8 @@ npm run dev
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
This project uses a simplified, componentfirst testing model:
+13
View File
@@ -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 });
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../../../lib/server/env";
import {
hashSessionToken,
newSessionToken,
} from "../../../../../lib/server/hash";
import { sendMagicLinkEmail } from "../../../../../lib/server/mail";
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
import { dbUnavailable } from "../../../../../lib/server/responses";
import { logger } from "../../../../../lib/logger";
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
const MAGIC_LINK_TTL_MS = 15 * 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;
}
function readNextPath(body: unknown): string | null {
if (!body || typeof body !== "object" || !("next" in body)) return null;
const n = (body as { next: unknown }).next;
if (typeof n !== "string") return null;
return safeInternalPath(n);
}
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(`magic-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(`magic-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 token = newSessionToken();
const tokenHash = hashSessionToken(token, pepper);
const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
const nextPath = readNextPath(body);
await prisma.magicLinkToken.deleteMany({ where: { email } });
await prisma.magicLinkToken.create({
data: {
email,
tokenHash,
expiresAt,
nextPath: nextPath ?? undefined,
},
});
const origin = request.nextUrl.origin;
const verifyUrl = `${origin}/api/auth/magic-link/verify?token=${encodeURIComponent(token)}`;
try {
await sendMagicLinkEmail(email, verifyUrl);
} catch (err) {
logger.error("sendMagicLinkEmail failed:", err);
await prisma.magicLinkToken.deleteMany({ where: { email } });
return NextResponse.json(
{ error: "Could not send email" },
{ status: 502 },
);
}
return NextResponse.json({ ok: true });
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../../../lib/server/env";
import { hashSessionToken } from "../../../../../lib/server/hash";
import {
createSessionForUser,
setSessionCookie,
} from "../../../../../lib/server/session";
import { dbUnavailable } from "../../../../../lib/server/responses";
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
export async function GET(request: NextRequest) {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const token = request.nextUrl.searchParams.get("token");
if (!token || token.length < 10) {
return NextResponse.redirect(
new URL("/login?error=invalid_link", request.url),
);
}
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return NextResponse.redirect(new URL("/login?error=server", request.url));
}
const tokenHash = hashSessionToken(token, pepper);
const row = await prisma.magicLinkToken.findUnique({
where: { tokenHash },
});
if (!row || row.expiresAt < new Date()) {
return NextResponse.redirect(
new URL("/login?error=expired_link", request.url),
);
}
await prisma.magicLinkToken.delete({ where: { id: row.id } });
const user = await prisma.user.upsert({
where: { email: row.email },
create: { email: row.email },
update: {},
});
const { token: sessionToken, expiresAt } = await createSessionForUser(
user.id,
);
await setSessionCookie(sessionToken, expiresAt);
const dest = safeInternalPath(row.nextPath);
return NextResponse.redirect(new URL(dest, request.url));
}
+19
View File
@@ -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 },
});
}
+70
View File
@@ -0,0 +1,70 @@
import type { Prisma } from "@prisma/client";
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";
import { putDraftBodySchema } from "../../../../lib/server/validation/createFlowSchemas";
import { readLimitedJson } from "../../../../lib/server/validation/requestBody";
import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp";
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 });
}
const parsedBody = await readLimitedJson(request);
if (parsedBody.ok === false) {
return parsedBody.response;
}
const validated = putDraftBodySchema.safeParse(parsedBody.value);
if (!validated.success) {
return jsonFromZodError(validated.error);
}
const { payload } = validated.data;
const jsonPayload = payload as Prisma.InputJsonValue;
const draft = await prisma.ruleDraft.upsert({
where: { userId: user.id },
create: {
userId: user.id,
payload: jsonPayload,
},
update: {
payload: jsonPayload,
},
});
return NextResponse.json({
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
});
}
+19
View File
@@ -0,0 +1,19 @@
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 });
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { Prisma } from "@prisma/client";
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";
import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas";
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
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 });
}
const parsedBody = await readLimitedJson(request);
if (parsedBody.ok === false) {
return parsedBody.response;
}
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
if (!validated.success) {
return jsonFromZodError(validated.error);
}
const { title, summary, document } = validated.data;
const rule = await prisma.publishedRule.create({
data: {
userId: user.id,
title,
summary,
document: document as Prisma.InputJsonValue,
},
});
return NextResponse.json({
rule: {
id: rule.id,
title: rule.title,
summary: rule.summary,
createdAt: rule.createdAt,
},
});
}
+28
View File
@@ -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,138 @@
"use client";
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
import { LoginView } from "./Login.view";
import type { LoginProps } from "./Login.types";
const LoginContainer = memo<LoginProps>(
({
isOpen,
onClose,
children,
belowCard,
className = "",
ariaLabel,
ariaLabelledBy,
usePortal = true,
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
const [portalReady, setPortalReady] = useState(() => !usePortal);
// Defer enabling the portal until after the layout commit so we dont sync-setState
// inside the effect (eslint react-hooks/set-state-in-effect) while still mounting
// before the next paint, avoiding a flash of underlying layout.
useLayoutEffect(() => {
if (!usePortal) return;
const id = requestAnimationFrame(() => {
setPortalReady(true);
});
return () => cancelAnimationFrame(id);
}, [usePortal]);
useEffect(() => {
if (!isOpen) return;
if (usePortal && !portalReady) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, portalReady, onClose, usePortal]);
useEffect(() => {
if (!isOpen) return;
if (usePortal && !portalReady) return;
previousActiveElementRef.current = document.activeElement as HTMLElement;
document.body.style.overflow = "hidden";
if (dialogRef.current) {
const dialog = dialogRef.current;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusInitial = () => {
const emailField = dialog.querySelector<HTMLInputElement>(
'input[type="email"]:not([disabled])',
);
if (emailField) {
emailField.focus();
return;
}
const focusableElements = dialog.querySelectorAll(focusableSelector);
const firstElement = focusableElements[0] as HTMLElement;
if (firstElement) {
firstElement.focus();
} else {
dialog.setAttribute("tabindex", "-1");
dialog.focus();
}
};
requestAnimationFrame(() => {
requestAnimationFrame(focusInitial);
});
}
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as NodeListOf<HTMLElement>;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
};
document.addEventListener("keydown", handleTab);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab);
previousActiveElementRef.current?.focus();
};
}, [isOpen, portalReady, usePortal]);
return (
<LoginView
isOpen={isOpen}
onClose={onClose}
belowCard={belowCard}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
dialogRef={dialogRef}
backdropRef={backdropRef}
portalReady={portalReady}
usePortal={usePortal}
>
{children}
</LoginView>
);
},
);
LoginContainer.displayName = "Login";
export default LoginContainer;
@@ -0,0 +1,31 @@
export interface LoginProps {
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
/** Rendered below the dialog card (e.g. “Back to home”) on the dimmed backdrop */
belowCard?: React.ReactNode;
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
/**
* When false, render the overlay in the React tree instead of `document.body`.
* Use on the dedicated `/login` page so the shell (and heading) mount on first paint
* without waiting for a portal gate (more reliable across engines).
*/
usePortal?: boolean;
}
export interface LoginViewProps {
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
belowCard?: React.ReactNode;
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
dialogRef: React.RefObject<HTMLDivElement | null>;
backdropRef: React.RefObject<HTMLDivElement | null>;
/** False until client mount — avoids SSR/client HTML mismatch for createPortal. */
portalReady: boolean;
usePortal: boolean;
}
@@ -0,0 +1,57 @@
"use client";
import { createPortal } from "react-dom";
import ModalHeader from "../../utility/ModalHeader";
import type { LoginViewProps } from "./Login.types";
export function LoginView({
isOpen,
onClose,
children,
belowCard,
className,
ariaLabel,
ariaLabelledBy,
dialogRef,
backdropRef,
portalReady,
usePortal,
}: LoginViewProps) {
if (!isOpen) return null;
if (usePortal && !portalReady) return null;
const content = (
<div
ref={backdropRef}
className="fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto bg-[var(--color-surface-inverse-brand-primary)] px-4 py-8"
onClick={onClose}
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500,20px)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`}
onClick={(e) => e.stopPropagation()}
>
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
<div className="scrollbar-design flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto px-6 pb-8 pt-0">
{children}
</div>
</div>
{belowCard ? (
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
{belowCard}
</div>
) : null}
</div>
);
if (usePortal) {
return createPortal(content, document.body);
}
return content;
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import Link from "next/link";
import { useCallback, useId, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import Button from "../../buttons/Button";
import TextInput from "../../controls/TextInput";
import ContentLockup from "../../type/ContentLockup";
import { requestMagicLink } from "../../../../lib/create/api";
import { safeInternalPath } from "../../../../lib/safeInternalPath";
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
function MailIconInline() {
return (
<svg
width={22}
height={22}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden
data-name="Asset / Icon / mail"
>
<path
fill="#000000"
d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z"
/>
<path
fill="#000000"
d="M22.5 6.908V6.75A2.25 2.25 0 0 0 20.25 4.5h-16.5A2.25 2.25 0 0 0 1.5 6.75v.158l9.714 5.978a1.5 1.5 0 0 0 1.572 0L22.5 6.908Z"
/>
</svg>
);
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function LoginForm() {
const t = useTranslation("pages.login");
const tFooter = useTranslation("footer");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const formAlertId = useId();
const emailErrorId = useId();
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [emailError, setEmailError] = useState("");
const [formError, setFormError] = useState("");
const [sent, setSent] = useState(false);
const nextParam = searchParams.get("next");
const errorParam = searchParams.get("error");
/** Drop `error` from the URL so URL-driven messages dont linger after a new attempt. */
const stripErrorQuery = useCallback(() => {
if (!searchParams.get("error")) return;
const params = new URLSearchParams(searchParams.toString());
params.delete("error");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
}, [pathname, router, searchParams]);
const sendLink = useCallback(async () => {
stripErrorQuery();
setEmailError("");
setFormError("");
const trimmed = email.trim().toLowerCase();
if (!EMAIL_PATTERN.test(trimmed)) {
setEmailError(t("errors.emailInvalid"));
return;
}
setSubmitting(true);
try {
const nextPath = safeInternalPath(nextParam);
const result = await requestMagicLink(trimmed, nextPath);
if (result.ok === false) {
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
const seconds = Math.ceil(result.retryAfterMs / 1000);
setFormError(
t("errors.rateLimited").replace("{seconds}", String(seconds)),
);
} else {
setFormError(result.error || t("errors.generic"));
}
return;
}
setEmail(trimmed);
setSent(true);
} catch {
setFormError(t("errors.network"));
} finally {
setSubmitting(false);
}
}, [email, nextParam, stripErrorQuery, t]);
const urlErrorMessage =
errorParam === "expired_link"
? t("errors.expiredLink")
: errorParam === "invalid_link" || errorParam === "server"
? errorParam === "server"
? t("errors.serverError")
: t("errors.invalidLink")
: "";
return (
<div className="flex flex-col gap-6 pt-2">
<div className="flex flex-col gap-3">
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
<MailIconInline />
</div>
<ContentLockup
titleId="login-modal-heading"
title={sent ? t("successTitle") : t("title")}
description={sent ? t("successBody") : t("subtitle")}
variant="login"
alignment="left"
/>
</div>
{urlErrorMessage ? (
<p
role="alert"
aria-live="polite"
className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
>
{urlErrorMessage}
</p>
) : null}
{formError ? (
<p
id={formAlertId}
role="alert"
aria-live="polite"
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
>
{formError}
</p>
) : null}
{!sent ? (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
void sendLink();
}}
noValidate
>
<TextInput
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
type="email"
name="email"
autoComplete="email"
inputMode="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
stripErrorQuery();
}}
disabled={submitting}
error={Boolean(emailError)}
showHelpIcon
/>
{emailError ? (
<p
id={emailErrorId}
role="alert"
aria-live="polite"
className="font-inter text-[14px] text-[var(--color-border-default-utility-negative)]"
>
{emailError}
</p>
) : null}
<Button
type="submit"
size="large"
buttonType="filled"
palette="default"
disabled={submitting}
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
>
{t("sendMagicLink")}
</Button>
<p className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)]">
{t("legalPrefix")}
<Link
href="#"
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
>
{tFooter("legal.termsOfService")}
</Link>
{t("legalAnd")}
<Link
href="#"
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
>
{tFooter("legal.privacyPolicy")}
</Link>
{t("legalSuffix")}
</p>
</form>
) : null}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Login.container";
export type { LoginProps } from "./Login.types";
@@ -14,13 +14,14 @@ const Footer = dynamic(() => import("./Footer"), {
/**
* Conditionally renders Footer based on pathname.
* Hides footer for /create/* routes (full-screen create flow).
* Hides footer for /create/* and /login (full-screen flows; login uses a body portal).
*/
const ConditionalFooter = memo(() => {
const pathname = usePathname();
const isCreateFlow = pathname?.startsWith("/create");
const isLogin = pathname === "/login";
if (isCreateFlow) {
if (isCreateFlow || isLogin) {
return null;
}
@@ -1,24 +1,11 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import TopNavWithPathname from "./TopNav/TopNavWithPathname";
import { getNavAuthSignedIn } from "../../../lib/server/navAuth";
import ConditionalNavigationClient from "./ConditionalNavigationClient";
/**
* Conditionally renders TopNav based on pathname.
* Hides navigation for /create/* routes (full-screen create flow).
* Resolves the session on the server so the header matches the HttpOnly cookie on the
* first HTML response (no “Log in” flash before `/api/auth/session`).
*/
const ConditionalNavigation = memo(() => {
const pathname = usePathname();
const isCreateFlow = pathname?.startsWith("/create");
if (isCreateFlow) {
return null;
}
return <TopNavWithPathname />;
});
ConditionalNavigation.displayName = "ConditionalNavigation";
export default ConditionalNavigation;
export default async function ConditionalNavigation() {
const initialSignedIn = await getNavAuthSignedIn();
return <ConditionalNavigationClient initialSignedIn={initialSignedIn} />;
}
@@ -0,0 +1,31 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import TopNavWithPathname from "./TopNav/TopNavWithPathname";
export type ConditionalNavigationClientProps = {
initialSignedIn: boolean;
};
/**
* Client shell: pathname-based visibility. Session for the first paint comes from the
* parent Server Component (`ConditionalNavigation`) via `initialSignedIn`.
*/
const ConditionalNavigationClient = memo(
({ initialSignedIn }: ConditionalNavigationClientProps) => {
const pathname = usePathname();
const isCreateFlow = pathname?.startsWith("/create");
const isLogin = pathname === "/login";
if (isCreateFlow || isLogin) {
return null;
}
return <TopNavWithPathname initialSignedIn={initialSignedIn} />;
},
);
ConditionalNavigationClient.displayName = "ConditionalNavigationClient";
export default ConditionalNavigationClient;
@@ -139,14 +139,24 @@ const TopNavContainer = memo<TopNavProps>(
const isSmallBreakpoint = size === "xsmall" || size === "home";
const mode = folderTop && isSmallBreakpoint ? "inverse" : "default";
const href = loggedIn ? "/profile" : "/login";
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
const ariaLabel = loggedIn
? t("ariaLabels.goToProfile")
: t("ariaLabels.logInToAccount");
const navSelected =
(loggedIn && pathname === "/profile") ||
(!loggedIn && pathname === "/login");
return (
<MenuBarItem
href="#"
href={href}
size={sizeMap[size] || "Small"}
mode={mode}
ariaLabel={t("ariaLabels.logInToAccount")}
state={navSelected ? "selected" : "default"}
ariaLabel={ariaLabel}
>
{t("buttons.logIn")}
{label}
</MenuBarItem>
);
};
@@ -1,19 +1,75 @@
"use client";
import { memo } from "react";
import { memo, useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import TopNav from "./TopNav.container";
import type { TopNavProps } from "./TopNav.types";
import { fetchAuthSession } from "../../../../lib/create/api";
export type TopNavWithPathnameProps = Omit<TopNavProps, "folderTop"> & {
/** From Server Component (`getNavAuthSignedIn`); matches first HTML paint. */
initialSignedIn?: boolean;
};
/**
* TopNav wrapper that automatically determines folderTop based on current pathname.
* Use this in layout.tsx instead of ConditionalHeader.
* TopNav wrapper: `folderTop` from pathname; Log in vs Profile from session.
*
* **SSR:** Parent passes `initialSignedIn` from `getSessionUser()` so the hydrated
* header matches the cookie (Next.js pattern for HttpOnly session UI).
*
* **Client:** Refetch on pathname change (magic-link redirect, stale layout after
* `router.refresh()`), **popstate** / **pageshow** `persisted` (bfcache / back).
*/
const TopNavWithPathname = memo<Omit<TopNavProps, "folderTop">>((props) => {
const TopNavWithPathname = memo<TopNavWithPathnameProps>((props) => {
const { initialSignedIn = false, ...topNavRest } = props;
const pathname = usePathname();
const isHomePage = pathname === "/";
const [loggedIn, setLoggedIn] = useState(initialSignedIn);
return <TopNav {...props} folderTop={isHomePage} />;
useEffect(() => {
setLoggedIn(initialSignedIn);
}, [initialSignedIn]);
const applySessionUser = useCallback(
(user: { id: string; email: string } | null) => {
setLoggedIn(Boolean(user));
},
[],
);
const syncSession = useCallback(() => {
fetchAuthSession().then(({ user }) => {
applySessionUser(user);
});
}, [applySessionUser]);
useEffect(() => {
let cancelled = false;
fetchAuthSession().then(({ user }) => {
if (!cancelled) applySessionUser(user);
});
return () => {
cancelled = true;
};
}, [pathname, applySessionUser]);
useEffect(() => {
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) syncSession();
};
window.addEventListener("pageshow", onPageShow);
return () => window.removeEventListener("pageshow", onPageShow);
}, [syncSession]);
useEffect(() => {
const onPopState = () => {
queueMicrotask(syncSession);
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [syncSession]);
return <TopNav {...topNavRest} folderTop={isHomePage} loggedIn={loggedIn} />;
});
TopNavWithPathname.displayName = "TopNavWithPathname";
@@ -112,6 +112,20 @@ const ContentLockupContainer = memo<ContentLockupProps>(
"font-inter font-normal text-[16px] leading-[24px] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
shape: "w-[16px] h-[16px]",
},
login: {
container:
"flex flex-col gap-[var(--spacing-scale-012)] items-start justify-center relative w-full",
textContainer: "flex flex-col gap-[var(--spacing-scale-012)] w-full",
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)] w-full",
titleContainer: "flex items-center justify-start w-full",
title:
"font-bricolage-grotesque font-extrabold text-[36px] leading-[44px] tracking-[0] text-[var(--color-content-default-primary)] text-left",
subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
description:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
shape: "w-[16px] h-[16px]",
},
};
const styles = variantStyles[variant] || variantStyles.hero;
@@ -5,12 +5,14 @@ export type ContentLockupVariantValue =
| "ask"
| "ask-inverse"
| "modal"
| "login"
| "Hero"
| "Feature"
| "Learn"
| "Ask"
| "Ask-Inverse"
| "Modal";
| "Modal"
| "Login";
export type ContentLockupAlignmentValue = "center" | "left" | "Center" | "Left";
@@ -58,7 +60,14 @@ export interface ContentLockupViewProps {
ctaText?: string;
ctaHref?: string;
buttonClassName: string;
variant: "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal";
variant:
| "hero"
| "feature"
| "learn"
| "ask"
| "ask-inverse"
| "modal"
| "login";
linkText?: string;
linkHref?: string;
alignment: "center" | "left";
@@ -20,18 +20,21 @@ function ContentLockupView({
}: ContentLockupViewProps) {
return (
<div className={styles.container}>
{variant === "ask" || variant === "ask-inverse" || variant === "modal" ? (
/* Simplified structure for ask and modal variants */
{variant === "ask" ||
variant === "ask-inverse" ||
variant === "modal" ||
variant === "login" ? (
/* Simplified structure for ask, modal, and login variants */
<div
className={`${styles.titleGroup} ${
alignment === "left" || variant === "modal"
alignment === "left" || variant === "modal" || variant === "login"
? "text-left"
: "text-center"
}`}
>
<div
className={`${styles.titleContainer} ${
alignment === "left" || variant === "modal"
alignment === "left" || variant === "modal" || variant === "login"
? "justify-start"
: "justify-center"
}`}
@@ -43,7 +46,7 @@ function ContentLockupView({
) : null}
</div>
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
{variant === "modal" && description && (
{(variant === "modal" || variant === "login") && description && (
<p className={styles.description}>{description}</p>
)}
</div>
@@ -1,6 +1,9 @@
import { getAssetPath } from "../../../../lib/assetUtils";
import type { ModalHeaderProps } from "./ModalHeader.types";
const iconButtonClass =
"absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full flex items-center justify-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]";
export function ModalHeaderView({
onClose,
onMoreOptions,
@@ -15,8 +18,9 @@ export function ModalHeaderView({
{/* Close Button - Left */}
{showCloseButton && (
<button
type="button"
onClick={onClose}
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full left-[24px] top-[12px] flex items-center justify-center cursor-pointer"
className={`${iconButtonClass} left-[24px] top-[12px]`}
aria-label="Close dialog"
>
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
@@ -34,8 +38,9 @@ export function ModalHeaderView({
{/* More Options Button - Right */}
{showMoreOptionsButton && (
<button
type="button"
onClick={onMoreOptions}
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full right-[24px] top-[12px] flex items-center justify-center cursor-pointer"
className={`${iconButtonClass} right-[24px] top-[12px]`}
aria-label="More options"
>
<svg
@@ -0,0 +1,71 @@
"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;
}
+6
View File
@@ -79,6 +79,11 @@ export function CreateFlowProvider({
}));
}, []);
const replaceState = useCallback((next: CreateFlowState) => {
setState(next);
writeStateToStorage(STORAGE_KEY, next);
}, []);
const clearState = useCallback(() => {
setState({});
removeFromStorage(STORAGE_KEY);
@@ -89,6 +94,7 @@ export function CreateFlowProvider({
state,
currentStep,
updateState,
replaceState,
clearState,
};
+2
View File
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
import { CreateFlowBackendSync } from "./context/CreateFlowBackendSync";
import {
CreateFlowProvider,
useCreateFlow,
@@ -107,6 +108,7 @@ export default function CreateFlowLayout({
}) {
return (
<CreateFlowProvider>
<CreateFlowBackendSync />
<CreateFlowLayoutContent>{children}</CreateFlowLayoutContent>
</CreateFlowProvider>
);
+13 -3
View File
@@ -21,11 +21,19 @@ export type CreateFlowStep =
| "completed";
/**
* Flow state interface for storing user inputs across all steps
* Will be expanded in CR-56 with specific field definitions
* Flow state for inputs across create-flow steps.
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
* Additional string keys are allowed at runtime for forward-compatible step data.
*/
export interface CreateFlowState {
// Placeholder structure - will be expanded in CR-56
title?: string;
summary?: string;
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record<string, unknown>[];
/** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */
stakeholders?: Record<string, unknown>[];
/** Extra step-specific fields (must be JSON-serializable for server draft sync). */
[key: string]: unknown;
}
@@ -37,6 +45,8 @@ export interface CreateFlowContextValue {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
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. */
clearState: () => void;
}
+3
View File
@@ -7,6 +7,9 @@ import "./globals.css";
import ConditionalNavigation from "./components/navigation/ConditionalNavigation";
import ConditionalFooter from "./components/navigation/ConditionalFooter";
/** Header reads `cr_session` via Server Components; must not use prerendered guest HTML. */
export const dynamic = "force-dynamic";
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
+35
View File
@@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslation } from "../contexts/MessagesContext";
import Login from "../components/modals/Login";
import LoginForm from "../components/modals/Login/LoginForm";
export default function LoginPageClient() {
const router = useRouter();
const t = useTranslation("pages.login");
return (
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
<Login
isOpen
usePortal={false}
onClose={() => {
router.push("/");
}}
ariaLabelledBy="login-modal-heading"
belowCard={
<Link
href="/"
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
>
{t("backToHome")}
</Link>
}
>
<LoginForm />
</Login>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import LoginPageClient from "./LoginPageClient";
export const metadata: Metadata = {
title: "Log in · CommunityRule",
robots: { index: false, follow: false },
};
function LoginFallback() {
return (
<div className="min-h-screen bg-[var(--color-surface-inverse-brand-primary)] flex items-center justify-center">
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
Loading
</p>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginFallback />}>
<LoginPageClient />
</Suspense>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useTranslation } from "../contexts/MessagesContext";
export default function ProfilePageClient() {
const t = useTranslation("pages.profile");
return (
<div className="mx-auto max-w-2xl px-4 py-16 md:py-24">
<h1 className="font-bricolage text-3xl font-extrabold text-[var(--color-content-default-primary)] md:text-4xl">
{t("placeholderTitle")}
</h1>
<p className="mt-4 font-inter text-lg leading-relaxed text-[var(--color-content-default-secondary)]">
{t("placeholderBody")}
</p>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import ProfilePageClient from "./ProfilePageClient";
export const metadata: Metadata = {
title: "Profile · CommunityRule",
robots: { index: false, follow: false },
};
export default function ProfilePage() {
return <ProfilePageClient />;
}
+24
View File
@@ -0,0 +1,24 @@
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
# Official image is amd64-only; pin platform so Apple Silicon uses emulation cleanly.
mailhog:
image: mailhog/mailhog:v1.0.1
platform: linux/amd64
restart: unless-stopped
ports:
- "1025:1025"
- "8025:8025"
volumes:
pgdata:
+516
View File
@@ -0,0 +1,516 @@
# 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), **passwordless email (magic-link request)** rate limits in-memory 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**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **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 18, 10:** Everything runs on your machine: `docker compose up -d postgres mailhog`, `.env`, `npm run dev`, `npx prisma migrate dev`. **Magic-link** sign-in email can use Mailhog or **dev server logs** (verify URL) when `SMTP_URL` is unset—no real SMTP required locally.
- **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 **+ hashed magic-link tokens**). |
| **SMTP** | **`SMTP_URL`** + **`SMTP_FROM`** for real **sign-in link** email; not required on laptop if you use logs/Mailhog. |
| **DNS for mail** | Often **SPF/DKIM** so **magic-link** 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` with secrets. |
| **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 |
| ------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 12 | **No** | Docs and app code only. |
| 3 | **No** to build/test; **Yes** when **magic-link email** must work on a **deployed** env | Real **SMTP** + DNS on staging/prod (same as table above). |
| 48 | **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 18 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 &gt; 512KB) via route handler check or Next config if applicable.
**Acceptance criteria:**
- [x] TypeScript reflects the real shape of `CreateFlowState` (no unnecessary `unknown` for known keys).
- [x] Invalid draft/publish requests return 400, not 500.
- [x] Unit tests for schemas (Vitest) or route tests with MSW.
**Status:** [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) **Done**.
**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), [lib/server/validation/](lib/server/validation/) (Zod + plain-JSON checks), [package.json](package.json) (`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 magic-link 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 verify URL in server logs locally). **Required** when **magic-link email** must work on **staging/production**: admin provides **SMTP** + usually **DNS (SPF/DKIM)** and sets env on the host (see top table). **Residual:** links in email use the app origin—reverse proxy / `Host` must match the URL users open.
**Goal:** Let a user request a **sign-in link** and complete sign-in in the browser using existing endpoints.
**Context:** APIs: `POST /api/auth/magic-link/request`, `GET /api/auth/magic-link/verify`, `GET /api/auth/session`, `POST /api/auth/logout`. Prisma: `MagicLinkToken`. Client: [`requestMagicLink`](lib/create/api.ts).
**Implementation (shipped):**
1. **`/login`** route and/or **modal** from the header (designer-approved)—[app/login/page.tsx](app/login/page.tsx), [app/login/LoginPageClient.tsx](app/login/LoginPageClient.tsx), [app/components/modals/Login/](app/components/modals/Login/) (`LoginForm.tsx`, container/view).
2. Flow: email → “Send link” → user opens link (email, Mailhog, or dev log) → `GET /api/auth/magic-link/verify?token=...` sets session and redirects; optional `next` for post-login path.
3. Surface API errors: invalid email, 429 `retryAfterMs`, expired/invalid token, network failure (accessible copy).
4. Ensure `fetch` calls use `credentials: "include"` where needed (see [lib/create/api.ts](lib/create/api.ts)).
5. **Dev:** without `SMTP_URL`, verify URL is logged; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `SMTP_URL=smtp://localhost:1025`.
6. **Marketing header:** When signed in (`fetchAuthSession`), **Log in** becomes **Profile** linking to [`/profile`](app/profile/page.tsx) (placeholder until Ticket 15 / CR-86). Implemented in [TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx) + [TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx).
**Acceptance criteria:**
- [x] Happy path: user completes magic-link verify and `GET /api/auth/session` returns `user` in the same browser session.
- [x] Keyboard + screen-reader friendly forms (labels, errors associated with fields).
- [x] No secrets in client bundle.
- [x] Header shows **Profile** → placeholder `/profile` when session present; **Log in** when anonymous.
**Status:** [CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis) **Done** for shipped UI/APIs. **Residual checklist** below: repo doc items are **done**; use Linear (CR-74 or child issue) to track **per-environment** staging URL checks.
**Files:** [app/login/](app/login/), [app/profile/](app/profile/) (placeholder), [app/components/modals/Login/](app/components/modals/Login/), [messages/en/pages/login.json](messages/en/pages/login.json), [messages/en/pages/profile.json](messages/en/pages/profile.json), [messages/en/components/header.json](messages/en/components/header.json), [app/components/navigation/TopNav/TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx), [app/components/navigation/TopNav/TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx), [lib/create/api.ts](lib/create/api.ts), [app/api/auth/magic-link/request/route.ts](app/api/auth/magic-link/request/route.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), [prisma/schema.prisma](prisma/schema.prisma) (`MagicLinkToken`), [lib/server/mail.ts](lib/server/mail.ts). Onboarding: [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example).
### Residual / before CR-75 (create-flow session UI)
**Intent:** [Ticket 4](#ticket-4--session-affordances-in-the-create-flow-signed-in-state--sign-out) (**CR-75**) needs a reliable signed-in story across marketing + `/create`. Below: what is **done in repo** vs what to **verify per environment**.
1. **Contributor / onboarding****Done:** [CONTRIBUTING.md](CONTRIBUTING.md) API table and sign-in section describe **magic-link** request/verify, dev log URL, and Mailhog. [`.env.example`](.env.example) comments match.
2. **Smoke checklist****Done:** **Email magic link (sign-in)** in [CONTRIBUTING.md](CONTRIBUTING.md); build-order §9 in [docs/backend-roadmap.md](backend-roadmap.md) includes the same happy path + session check.
3. **Staging / production URLs****Verify on each deploy:** emails use `request.nextUrl.origin`; confirm reverse proxy and **`Host`** so links in mail match the public site (CONTRIBUTING + roadmap §9 spell this out).
4. **Docs alignment****Done:** [docs/backend-roadmap.md](backend-roadmap.md) and this doc treat magic link as primary; CR-72/CR-73 schema work is not a blocker for CR-75.
---
## 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 24.
**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 (API surface):** Change [saveDraftToServer](lib/create/api.ts) from `Promise<boolean>` to a result type such as `{ ok: true } | { ok: false; message: string; status?: number }`, parsing the response body with [readApiErrorMessage](lib/create/api.ts) so both legacy `{ error: string }` and CR-73 validation `{ error: { message } }` (and 413 `payload_too_large`) produce a useful `message`. Update [CreateFlowBackendSync](app/create/context/CreateFlowBackendSync.tsx) to branch on that result.
4. **Save failures (UX):** On `ok: false`, show toast/banner (include `message`); optionally retry with backoff.
5. **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:** [lib/create/api.ts](lib/create/api.ts), [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 24 (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 310 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-5155).
**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.
**Note:** Complements **Ticket 15** profile cards: users can open a **public** detail URL from a rule listed on their dashboard; the profile page does **not** replace this ticket.
**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.
**Linear:** [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional). **Related in Linear:** [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) (Ticket 15 — profile cards linking to public detail).
---
## 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 18 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) §45.
**Implementation:**
1. **Policy:** On **new sign-in** (magic-link verification / session creation), 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/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), 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**).
---
## Ticket 15 — Profile dashboard + account (Figma profile)
**Depends on:** Ticket 3 (auth), **Ticket 4** (session in UI), **Ticket 6** (publish so users have rules to list). Soft optional: Tickets 78 for “create from template” CTA parity.
**Goal:** Signed-in **profile** experience matching [Figma — Profile](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069): **Your CommunityRules** (list **own** published rules), **duplicate** / **delete** per rule, CTAs into create flow (custom + from template), **logout** (existing API), **delete account** (policy + API + confirmation UX).
**Out of scope for this ticket**
- **Change your account email** (shown in Figma options): **deferred**—no backend in this slice. Product may **hide** the row, show **“Coming soon,”** or backlog until a **future ticket** (verified email change, conflicts, sessions).
- **`displayName` / new `User` fields:** not required—use **static** welcome copy, generic greeting, or **email local-part in UI only** until a later schema/product decision.
**Context:** Today `GET /api/rules` is a **public** list of all published rules; there is no authenticated **my rules** endpoint, no owner **DELETE** / **duplicate**, and no **delete user** API. See [docs/backend-roadmap.md](backend-roadmap.md) §1 “profile / account — not implemented yet” and §6.
**Implementation (sketch):**
1. **API:** Authenticated route(s) to list rules **where `userId` = session user**; owner-only `DELETE` (and duplicate via `POST` reuse or dedicated handler); `DELETE` user (or equivalent) with explicit Prisma policy—cascade vs orphan `PublishedRule` (today `onDelete: SetNull` on user) and cleanup of `Session` / `RuleDraft`.
2. **UI:** Marketing route (e.g. `/profile`), rule cards (title, summary, artwork from `document` as needed), **IN PROGRESS** badge per roadmap §4 (derive from JSON / future `status` / UI-only).
3. **Nav:** Link from header when signed in if design requires.
4. **i18n** for strings; legal/product review for **delete account** copy.
**Acceptance criteria:**
- [ ] Signed-in user sees **only their** published rules (not the global public list).
- [ ] Duplicate and delete actions work for **owner** only; errors are clear.
- [ ] Logout still works from profile context.
- [ ] Delete account flow matches agreed policy and is confirmed in UI.
- [ ] No verified **email change** shipped in this ticket; Figma row handled per product (hide/disabled/backlog).
**Files:** new `app/` routes and components, `app/api/rules/...` (or new segment handlers), [lib/create/api.ts](lib/create/api.ts) as needed, [prisma/schema.prisma](prisma/schema.prisma) only if account-delete policy requires schema tweaks, [messages/en/](messages/en/) for copy.
**Linear:** [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) (**Backlog**). **Blocked by** **CR-75** + **CR-77**. **Related:** [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) (public rule detail for deep links from profile cards). **Not** part of the sequential **CR-72 → CR-83** chain—parallel after publish + session, similar to CR-84/CR-85.
---
## Summary order
| Order | Ticket | Short name |
| ----: | ------ | --------------------------------- |
| 1 | 1 | Refresh backend-roadmap |
| 2 | 2 | CreateFlowState + API validation |
| 3 | 3 | Magic-link 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 |
| 15 | 15 | Profile + account (Figma profile) |
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 1314** are parallel to that chain (blocked by **CR-73** and **CR-75** respectively), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by auth + session + publish—not by the ops runbook); Linear: **CR-86**.
---
## 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), **CR-86** / Ticket 15 (blocked by CR-75 + CR-77, not in the CR-7283 sequence).
| 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) | Magic-link sign-in UI + CR-75 prep |
| 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 |
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) |
---
## Updating Linear issue CR-74 (manual)
Keep **[CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis)** aligned with **Ticket 3** (Linear UI or MCP). If Linear still describes an old sign-in approach, update it so it matches **Ticket 3** above (magic link only):
- **Title (examples):** `Magic-link sign-in UI + APIs; prep for CR-75` or `Email magic-link sign-in (UI + routes) — residuals for create-flow auth`.
- **Description — Shipped:** Magic link: `POST /api/auth/magic-link/request`, `GET /api/auth/magic-link/verify`, `MagicLinkToken`, `/login` + modal UI, `requestMagicLink`, session cookie.
- **Description — Residual / before CR-75:** Use the checklist under **Residual / before CR-75** (Ticket 3 above). Mark **done** for items 1, 2, and 4 (repo docs). Keep **open** until verified: **(3)** staging/prod `Host` / link URLs on your real hosts.
- **Comment (optional):** Start **CR-75** only after residuals are done **or** the team defers specific lines (e.g. CONTRIBUTING in a separate PR).
**Status:** CR-74 can stay **Done** with a **child issue** (e.g. “CR-74 follow-ups: auth docs + smoke”) if you prefer not to reopen the parent.
+257
View File
@@ -0,0 +1,257 @@
# 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/magic-link, 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/magic-link/request` | Send sign-in link email |
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
| 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 |
**Product sign-in** uses **magic link** (`/api/auth/magic-link/*`).
**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).
### HTTP API (profile / account — not implemented yet)
Planned for the signed-in profile/dashboard ([Figma profile frame](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069); [docs/backend-linear-tickets.md](backend-linear-tickets.md) Ticket 15; Linear **[CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile)**):
- Authenticated list of **own** `PublishedRule` rows (e.g. `GET /api/rules/me` or a strictly scoped query—**not** the same as public `GET /api/rules`).
- Owner-only **delete** and **duplicate** (clone) for published rules.
- **Delete account** (authenticated), with an explicit policy for drafts, sessions, and linked rules.
**Future (separate ticket):** **Change email** with verification (e.g. magic link to a new address, conflict handling)—**out of scope** for the profile milestone above.
---
## 2. What were 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** (passwordless email sign-in, 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 **magic-link** sign-in email in deployed environments; when `SMTP_URL` is unset in dev, the app can log the **verify URL** to the console (same pattern as [`lib/server/mail.ts`](lib/server/mail.ts)).
**Step 4.** **Redis / queues / Kubernetes** — not required for v1. **Exception:** before running **multiple app instances**, plan a **shared rate-limit store** (often Redis) for **passwordless email (magic-link request)**; 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 **magic link verification** (primary v1 path). An optional **display name** (or preferred name) could be added later for richer greetings; it does **not** block the profile page—no schema commitment in this roadmap pass alone. |
| **Session** | **Custom v1:** HttpOnly cookie; opaque token; **hash** stored in DB ([`lib/server/session.ts`](lib/server/session.ts)). Not NextAuth/Lucia. |
| **MagicLinkToken** | Short-lived **hashed** token for email sign-in links; optional `nextPath` for post-login redirect. |
| **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). Profile UI badges such as **IN PROGRESS** may be **derived from `document` JSON**, a future `status` column, or UI-only—product decision when implementing Ticket 15. |
| **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 magic link**; cookies are **httpOnly**; session and magic-link tokens are hashed at rest.
- **Rate limiting (magic-link request):** **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 (see §1 “profile / account — not implemented yet” and Ticket 15).
- **Profile / owner scope (planned):** Authenticated **list own rules**, **delete own rule**, **duplicate own rule**—required for the signed-in dashboard in design; **v1 shipped handlers** may not include these until that work lands.
- **Delete account (planned):** Authenticated endpoint + UX to remove the user record per policy (cascade vs orphan `PublishedRule`, drafts, sessions)—Ticket 15. **Change email** is **not** part of that milestone; plan a **future ticket** for verified email updates.
- **v1 (shipped today):** No **editing** or **deleting** published rules via API in current 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 14 — env file, Docker Postgres, `npm ci`, `prisma migrate dev`, `npm run dev`.
**Backend behavior already in the repo:** Steps **510** 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, **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md).
---
**Step 1.** Copy `.env.example` to `.env`. 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.** **Magic-link sign-in** (happy path):
1. `POST /api/auth/magic-link/request` with `{ "email": "you@example.com" }` (optional `"next"` for redirect after verify).
2. Open the link from email, Mailhog, or **server logs** when `SMTP_URL` is unset (dev).
3. Browser hits `GET /api/auth/magic-link/verify?token=...` (and optional `next=...`); response sets the session cookie and redirects.
4. `GET /api/auth/session` should show your user in the same browser.
**Before wiring create-flow session UI:** Confirm the same browser that completed verify gets `user` from `GET /api/auth/session` (cookie + same-site). On **staging/production**, magic-link emails embed the app origin—misconfigured **`Host`** or TLS termination can produce broken links; align reverse proxy with the public site URL.
**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` 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** magic-link **request** — in-memory OK for one instance; **shared store before multi-instance** (see §5).
- **Hash** magic-link tokens and session tokens before storing; short **magic-link** TTL (align with implementation, e.g. 15 minutes).
- **Secrets** only in env / secret store — never commit `.env` with real values.
- **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.** Sign-in UI: **`/login`** (and **Log in** in the site header) uses **magic link** (modal / page flow: request link → open verify URL); after verify, 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).
**Step 5.** **Profile / dashboard** (`/profile` or agreed path): signed-in hub for **my rules** (after Ticket 15 APIs exist), **duplicate** / **delete** rule actions, **logout**, **delete account**—aligned with [Figma profile](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069). **Change email** in design is **deferred** (hide, “coming soon,” or backlog) until a future account ticket; greeting copy can stay **static** or use **email local-part in UI only**—no `displayName` field required for MVP.
---
## 13. Optional later
- **Session library** spike (Auth.js, Lucia) if custom lifecycle cost grows.
- **Redis** (or similar) for **shared magic-link 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/)
+118
View File
@@ -0,0 +1,118 @@
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;
}
/** Supports legacy `{ error: string }` and `{ error: { message: string } }` from API routes. */
function readApiErrorMessage(data: unknown): string {
if (!data || typeof data !== "object" || !("error" in data)) {
return "Request failed";
}
const err = (data as { error: unknown }).error;
if (typeof err === "string") {
return err;
}
if (err && typeof err === "object" && "message" in err) {
const m = (err as { message: unknown }).message;
if (typeof m === "string") {
return m;
}
}
return "Request failed";
}
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 requestMagicLink(
email: string,
nextPath?: string,
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
const res = await fetch("/api/auth/magic-link/request", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({
email,
...(nextPath ? { next: nextPath } : {}),
}),
});
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res);
if (!res.ok) {
return {
ok: false,
error: readApiErrorMessage(data),
retryAfterMs:
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
};
}
return { ok: true };
}
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: readApiErrorMessage(data) };
}
return { ok: true, id: data.rule.id, title: data.rule.title };
}
+18 -3
View File
@@ -255,12 +255,27 @@ export function normalizeNavigationItemSize(
export function normalizeContentLockupVariant(
value: string | undefined,
defaultValue: "hero" = "hero",
): "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal" {
): "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal" | "login" {
if (!value) return defaultValue;
const normalized = value.toLowerCase();
const variants = ["hero", "feature", "learn", "ask", "ask-inverse", "modal"];
const variants = [
"hero",
"feature",
"learn",
"ask",
"ask-inverse",
"modal",
"login",
];
if (variants.includes(normalized)) {
return normalized as typeof defaultValue;
return normalized as
| "hero"
| "feature"
| "learn"
| "ask"
| "ask-inverse"
| "modal"
| "login";
}
return defaultValue;
}
+7
View File
@@ -0,0 +1,7 @@
/** Allow only same-origin relative paths for open redirects after auth. */
export function safeInternalPath(next: string | null | undefined): string {
if (!next || !next.startsWith("/") || next.startsWith("//")) {
return "/";
}
return next;
}
+18
View File
@@ -0,0 +1,18 @@
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;
}
+13
View File
@@ -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());
}
+13
View File
@@ -0,0 +1,13 @@
import { createHash, randomBytes } from "crypto";
export function sha256Hex(input: string): string {
return createHash("sha256").update(input, "utf8").digest("hex");
}
export function hashSessionToken(token: string, pepper: string): string {
return sha256Hex(`${pepper}:session:${token}`);
}
export function newSessionToken(): string {
return randomBytes(32).toString("base64url");
}
+27
View File
@@ -0,0 +1,27 @@
import nodemailer from "nodemailer";
import { logger } from "../logger";
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const url = process.env.SMTP_URL;
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`);
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: "Sign in to Community Rule",
text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`,
});
}
+21
View File
@@ -0,0 +1,21 @@
import { unstable_noStore as noStore } from "next/cache";
import { isDatabaseConfigured } from "./env";
import { getSessionUser } from "./session";
/**
* Whether the current request has a valid session, for marketing shell SSR.
* Aligns with GET /api/auth/session: no DB → treat as signed out; errors → signed out.
*
* `noStore()` avoids any static/prerender reuse where HTML was built without the request cookie
* but the client still receives `initialSignedIn: true` (hydration mismatch on Log in vs Profile).
*/
export async function getNavAuthSignedIn(): Promise<boolean> {
noStore();
if (!isDatabaseConfigured()) return false;
try {
const user = await getSessionUser();
return user != null;
} catch {
return false;
}
}
+20
View File
@@ -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 };
}
+8
View File
@@ -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 },
);
}
+92
View File
@@ -0,0 +1,92 @@
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 } });
}
@@ -0,0 +1,68 @@
import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/create/utils/flowSteps";
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
const createFlowStepSchema = z.enum(flowStepTuple);
/**
* Published rule `document` column: arbitrary JSON object with safety bounds.
*/
export const publishedRuleDocumentSchema = z
.record(z.string(), z.unknown())
.superRefine((doc, ctx) => {
const err = assertPlainJsonValue(doc, 0, DEFAULT_PLAIN_JSON_LIMITS);
if (err) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: err,
});
}
});
/**
* Create-flow draft payload: known optional fields plus passthrough for future steps.
* Full tree must satisfy {@link assertPlainJsonValue}.
*/
export const createFlowStateSchema = z
.object({
title: z.string().max(500).optional(),
summary: z.string().max(8000).optional(),
currentStep: createFlowStepSchema.optional(),
sections: z.array(z.unknown()).optional(),
stakeholders: z.array(z.unknown()).optional(),
})
.passthrough()
.superRefine((data, ctx) => {
const err = assertPlainJsonValue(data, 0, DEFAULT_PLAIN_JSON_LIMITS);
if (err) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: err });
}
});
export const publishRuleBodySchema = z.object({
title: z
.string()
.max(500)
.transform((s) => s.trim())
.refine((s) => s.length > 0, { message: "title required" }),
summary: z
.union([z.string().max(8000), z.null()])
.optional()
.transform((val) => {
if (val === undefined || val === null) {
return null;
}
const t = val.trim();
return t.length > 0 ? t : null;
}),
document: publishedRuleDocumentSchema,
});
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;
export const putDraftBodySchema = z.object({
payload: createFlowStateSchema,
});
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
+73
View File
@@ -0,0 +1,73 @@
/**
* Validates that a value is JSON-like (finite numbers, plain objects, no prototype tricks).
* Used after JSON.parse for defense in depth against odd clients.
*/
export const DEFAULT_PLAIN_JSON_LIMITS = {
maxDepth: 40,
maxStringLength: 50_000,
maxArrayLength: 5_000,
maxObjectKeys: 500,
} as const;
export type PlainJsonLimits = typeof DEFAULT_PLAIN_JSON_LIMITS;
/**
* @returns `null` if valid, otherwise a short error message for API responses.
*/
export function assertPlainJsonValue(
val: unknown,
depth: number,
limits: PlainJsonLimits = DEFAULT_PLAIN_JSON_LIMITS,
): string | null {
if (depth > limits.maxDepth) {
return "Maximum nesting depth exceeded";
}
if (val === null) {
return null;
}
const t = typeof val;
if (t === "string") {
const s = val as string;
return s.length > limits.maxStringLength ? "String value too long" : null;
}
if (t === "number") {
return Number.isFinite(val as number) ? null : "Invalid number value";
}
if (t === "boolean") {
return null;
}
if (t === "bigint" || t === "function" || t === "symbol") {
return "Invalid value type";
}
if (Array.isArray(val)) {
if (val.length > limits.maxArrayLength) {
return "Array too long";
}
for (let i = 0; i < val.length; i++) {
const inner = assertPlainJsonValue(val[i], depth + 1, limits);
if (inner) {
return inner;
}
}
return null;
}
if (t === "object") {
const o = val as Record<string, unknown>;
const keys = Object.keys(o);
if (keys.length > limits.maxObjectKeys) {
return "Object has too many keys";
}
for (const k of keys) {
if (k === "__proto__" || k === "constructor" || k === "prototype") {
return "Unsafe object key";
}
const inner = assertPlainJsonValue(o[k], depth + 1, limits);
if (inner) {
return inner;
}
}
return null;
}
return "Invalid value type";
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
export const MAX_JSON_BODY_BYTES = 512 * 1024;
export type LimitedJsonResult =
| { ok: true; value: unknown }
| { ok: false; response: NextResponse };
/**
* Read the body as text (bounded by maxBytes), then JSON.parse.
* Returns 413 when over limit; 400 when JSON is invalid.
*/
export async function readLimitedJson(
request: NextRequest,
maxBytes: number = MAX_JSON_BODY_BYTES,
): Promise<LimitedJsonResult> {
const text = await request.text();
if (text.length > maxBytes) {
return {
ok: false,
response: NextResponse.json(
{
error: {
code: "payload_too_large",
message: `Request body must be at most ${maxBytes} bytes`,
},
},
{ status: 413 },
),
};
}
try {
return { ok: true, value: JSON.parse(text) as unknown };
} catch {
return {
ok: false,
response: NextResponse.json(
{
error: {
code: "invalid_json",
message: "Invalid JSON",
},
},
{ status: 400 },
),
};
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import type { ZodError } from "zod";
export function jsonFromZodError(error: ZodError): NextResponse {
const issue = error.issues[0];
const message = issue?.message ?? "Validation failed";
return NextResponse.json(
{
error: {
code: "validation_error",
message,
},
details: error.flatten(),
},
{ status: 400 },
);
}
+2
View File
@@ -6,6 +6,7 @@
},
"buttons": {
"logIn": "Log in",
"profile": "Profile",
"createRule": "Create rule"
},
"ariaLabels": {
@@ -13,6 +14,7 @@
"mainNavigation": "Main navigation",
"navigateToPage": "Navigate to {text} page",
"logInToAccount": "Log in to your account",
"goToProfile": "Go to your profile",
"createNewRule": "Create a new rule with avatar decoration"
}
}
+4
View File
@@ -13,6 +13,8 @@ import ruleCard from "./components/ruleCard.json";
import ruleStack from "./components/ruleStack.json";
import home from "./pages/home.json";
import learn from "./pages/learn.json";
import login from "./pages/login.json";
import profile from "./pages/profile.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import communication from "./create/communication.json";
@@ -34,6 +36,8 @@ export default {
pages: {
home,
learn,
login,
profile,
},
create: {
communication,
+22
View File
@@ -0,0 +1,22 @@
{
"title": "Log in to CommunityRule",
"subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!",
"emailLabel": "Email address",
"emailPlaceholder": "you@example.com",
"sendMagicLink": "Send me a magic link",
"successTitle": "Check your email",
"successBody": "We sent a sign-in link. Open it on this device to continue.",
"legalPrefix": "By continuing, you agree to our ",
"legalAnd": " and ",
"legalSuffix": ".",
"backToHome": "← Back to home",
"errors": {
"network": "Something went wrong. Check your connection and try again.",
"emailInvalid": "Enter a valid email address.",
"rateLimited": "Too many requests. Try again in {seconds} seconds.",
"generic": "Something went wrong. Try again.",
"invalidLink": "That sign-in link is not valid. Request a new one from the login page.",
"expiredLink": "That sign-in link has expired. Request a new one from the login page.",
"serverError": "Something went wrong on our end. Try again later."
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"placeholderTitle": "Your profile",
"placeholderBody": "Were building this space for your CommunityRules and account options. Check back soon."
}
+2
View File
@@ -3,6 +3,8 @@ import createMDX from "@next/mdx";
/* eslint-env node */
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["@prisma/client"],
// Performance optimizations
experimental: {
optimizeCss: true,
+1030 -553
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -9,8 +9,9 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"start:e2e": "PORT=3010 HOSTNAME=127.0.0.1 node .next/standalone/server.js",
"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:local": "storybook dev -p 6006",
"storybook:github": "STORYBOOK_BASE_PATH=true storybook dev -p 6006",
@@ -32,26 +33,30 @@
"preview": "next build && next start -p 3000",
"e2e:serve": "start-server-and-test preview http://localhost:3000 e2e",
"seed-snapshots": "./scripts/seed-snapshots.sh",
"seed-snapshots:local": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium",
"seed-snapshots:local": "npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium --update-snapshots=all",
"visual:test": "npx playwright test tests/e2e/visual-regression.spec.ts",
"visual:update": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts",
"visual:update": "npx playwright test tests/e2e/visual-regression.spec.ts --update-snapshots=all",
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui",
"analyze": "npm run analyze:browser && npm run analyze:server",
"analyze:server": "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:deploy": "prisma migrate deploy"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.0.0",
"@prisma/client": "^6.19.0",
"ajv": "^8.12.0",
"critters": "^0.0.23",
"gray-matter": "^4.0.3",
"next": "^16.0.0",
"next-intl": "^3.26.5",
"nodemailer": "^8.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.2",
@@ -67,6 +72,7 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/mdx": "^2.0.13",
"@types/nodemailer": "^6.4.17",
"@types/react": "19.1.12",
"@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0",
@@ -83,6 +89,7 @@
"playwright": "^1.54.2",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prisma": "^6.19.0",
"start-server-and-test": "^2.0.13",
"storybook": "^10.2.0",
"tailwindcss": "^4.0.0",
+6 -4
View File
@@ -21,7 +21,8 @@ export default defineConfig({
reporter: [["list"], ["html", { open: "never" }]],
workers: process.env.CI ? 2 : undefined, // Reduce workers in CI to prevent server overload
use: {
baseURL: process.env.BASE_URL || "http://localhost:3010",
// Prefer 127.0.0.1 so it matches standalone server HOSTNAME and CI (wait-on tcp:127.0.0.1:3010).
baseURL: process.env.BASE_URL || "http://127.0.0.1:3010",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
@@ -38,10 +39,11 @@ export default defineConfig({
? {}
: {
webServer: {
command: "npm run build && npx next start -p 3010",
url: "http://localhost:3010",
// `output: "standalone"` — use the standalone Node server (see Dockerfile), not `next start`.
command: "npm run build && npm run start:e2e",
url: "http://127.0.0.1:3010",
reuseExistingServer: !process.env.CI,
timeout: 180_000, // Increased timeout to account for build time
timeout: 240_000,
},
}),
// Browser-specific snapshot path template (includes projectName for cross-browser support)
@@ -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,2 @@
-- Drop legacy email OTP table (product uses magic link only).
DROP TABLE IF EXISTS "OtpChallenge";
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "MagicLinkToken" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"nextPath" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MagicLinkToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "MagicLinkToken_tokenHash_key" ON "MagicLinkToken"("tokenHash");
-- CreateIndex
CREATE INDEX "MagicLinkToken_email_idx" ON "MagicLinkToken"("email");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+73
View File
@@ -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 MagicLinkToken {
id String @id @default(cuid())
email String
tokenHash String @unique
expiresAt DateTime
nextPath String?
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)
}
+1 -1
View File
@@ -33,7 +33,7 @@ docker run --rm -it \
sleep 10
echo '📸 Generating snapshots...'
PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium
npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium --update-snapshots=all
echo '✅ Snapshots generated successfully!'
"
+134
View File
@@ -0,0 +1,134 @@
import React, { Suspense, useEffect } from "react";
import Login from "../../app/components/modals/Login";
import LoginForm from "../../app/components/modals/Login/LoginForm";
/**
* Storybook runs outside Next.js request context; successful "Send link" needs fetch mocked
* because `requestMagicLink` POSTs to `/api/auth/magic-link/request`.
*/
function MagicLinkFetchMock({ children }: { children: React.ReactNode }) {
useEffect(() => {
const orig = globalThis.fetch;
globalThis.fetch = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url.includes("/api/auth/magic-link/request")) {
return new Response("{}", {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return orig(input as Request, init);
};
return () => {
globalThis.fetch = orig;
};
}, []);
return <>{children}</>;
}
export default {
title: "Components/Modals/Login",
component: Login,
parameters: {
layout: "fullscreen",
nextjs: {
appDirectory: true,
navigation: {
pathname: "/login",
},
},
docs: {
description: {
component:
"Full-page style login shell (yellow backdrop) with modal card. Uses magic-link `LoginForm` inside. Matches `/login` and header modal usage.",
},
},
},
decorators: [
(Story: () => React.ReactNode) => (
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
<MagicLinkFetchMock>
<Story />
</MagicLinkFetchMock>
</div>
),
],
tags: ["autodocs"],
};
export const ModalChromeOnly = {
name: "Modal (placeholder content)",
render: () => (
<Login
isOpen
onClose={() => {}}
ariaLabelledBy="login-modal-heading"
belowCard={
<a
href="/"
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
>
Back to home
</a>
}
>
<p
id="login-modal-heading"
className="font-inter px-2 py-4 text-[var(--color-content-default-primary)]"
>
Placeholder body use &quot;With magic link form&quot; for the real
flow.
</p>
</Login>
),
};
export const WithMagicLinkForm = {
name: "With magic link form",
render: () => (
<Login
isOpen
onClose={() => {}}
ariaLabelledBy="login-modal-heading"
belowCard={
<a
href="/"
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
>
Back to home
</a>
}
>
<Suspense fallback={<p className="font-inter p-6">Loading</p>}>
<LoginForm />
</Suspense>
</Login>
),
};
export const FormOnly = {
name: "Login form (card inset)",
parameters: {
docs: {
description: {
story:
"Form only, for inspecting copy and layout without the modal chrome. Wrap in `Login` in the app.",
},
},
},
render: () => (
<div className="mx-auto max-w-[560px] rounded-[20px] bg-[var(--color-surface-default-primary)] p-6 shadow-lg">
<Suspense fallback={<p className="font-inter">Loading</p>}>
<LoginForm />
</Suspense>
</div>
),
};
+95
View File
@@ -0,0 +1,95 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import Login from "../../app/components/modals/Login";
describe("Login", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders dialog when open and portal is ready", async () => {
renderWithProviders(
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Login content</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(screen.getByText("Login content")).toBeInTheDocument();
});
it("does not render dialog when closed", () => {
renderWithProviders(
<Login
isOpen={false}
onClose={vi.fn()}
ariaLabelledBy="login-modal-heading"
>
<p>Hidden</p>
</Login>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("calls onClose when close button is clicked", async () => {
const onClose = vi.fn();
renderWithProviders(
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("Close dialog"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when Escape is pressed", async () => {
const onClose = vi.fn();
renderWithProviders(
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("locks body scroll while open", async () => {
renderWithProviders(
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(document.body.style.overflow).toBe("hidden");
});
});
it("renders belowCard outside the dialog card", async () => {
renderWithProviders(
<Login
isOpen
onClose={vi.fn()}
ariaLabelledBy="login-modal-heading"
belowCard={<a href="/">Back to home</a>}
>
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(
screen.getByRole("link", { name: /back to home/i }),
).toBeInTheDocument();
});
});
+187
View File
@@ -0,0 +1,187 @@
import React, { Suspense } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import LoginForm from "../../app/components/modals/Login/LoginForm";
const { navMock } = vi.hoisted(() => ({
navMock: {
searchParams: new URLSearchParams(),
replace: vi.fn(),
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: navMock.replace,
push: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
}),
usePathname: () => "/login",
useSearchParams: () => navMock.searchParams,
}));
vi.mock("../../lib/create/api", () => ({
requestMagicLink: vi.fn(),
}));
import { requestMagicLink } from "../../lib/create/api";
function renderLoginForm() {
return renderWithProviders(
<Suspense fallback={null}>
<LoginForm />
</Suspense>,
);
}
describe("LoginForm", () => {
beforeEach(() => {
vi.mocked(requestMagicLink).mockReset();
navMock.replace.mockReset();
navMock.searchParams = new URLSearchParams();
});
it("renders title, email field, and submit control", () => {
renderLoginForm();
expect(
screen.getByRole("heading", { name: /log in to communityrule/i }),
).toBeInTheDocument();
expect(
screen.getByRole("textbox", { name: /email address/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /send me a magic link/i }),
).toBeInTheDocument();
});
it("shows validation error when email is invalid", async () => {
const user = userEvent.setup();
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"not-an-email",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/enter a valid email address/i),
).toBeInTheDocument();
expect(requestMagicLink).not.toHaveBeenCalled();
});
it("submits trimmed email and shows success state when API succeeds", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
" Pat@Example.COM ",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith("pat@example.com", "/");
});
expect(
await screen.findByRole("heading", { name: /check your email/i }),
).toBeInTheDocument();
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
});
it("passes safe next path when next query param is set", async () => {
const user = userEvent.setup();
navMock.searchParams = new URLSearchParams("next=/learn");
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"a@b.co",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith("a@b.co", "/learn");
});
});
it("shows API error when request fails", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({
ok: false,
error: "Server says no",
});
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(await screen.findByText("Server says no")).toBeInTheDocument();
});
it("shows rate limit message when retryAfterMs is present", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({
ok: false,
error: "Too many",
retryAfterMs: 3500,
});
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/try again in 4 seconds/i),
).toBeInTheDocument();
});
it("shows URL-driven error for expired_link", () => {
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
expect(
screen.getByText(/that sign-in link has expired/i),
).toBeInTheDocument();
});
it("calls router.replace to clear error query when user types", async () => {
const user = userEvent.setup();
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"x",
);
expect(navMock.replace).toHaveBeenCalledWith("/login", { scroll: false });
});
it("shows network error when request throws", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockRejectedValue(new Error("network"));
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/check your connection/i),
).toBeInTheDocument();
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 KiB

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 793 KiB

+7 -4
View File
@@ -32,10 +32,13 @@ describe("Page", () => {
).length,
).toBeGreaterThan(0);
// Check feature grid section (using getAllByText since there are multiple instances)
expect(
screen.getAllByText("We've got your back, every step of the way").length,
).toBeGreaterThan(0);
// FeatureGrid is next/dynamic — wait like other code-split sections
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way")
.length,
).toBeGreaterThan(0);
});
expect(
screen.getAllByText(
"Use our toolkit to improve, document, and evolve your organization.",
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from "vitest";
import {
assertPlainJsonValue,
DEFAULT_PLAIN_JSON_LIMITS,
} from "../../lib/server/validation/plainJson";
import {
createFlowStateSchema,
publishRuleBodySchema,
putDraftBodySchema,
} from "../../lib/server/validation/createFlowSchemas";
describe("assertPlainJsonValue", () => {
it("accepts plain JSON structures", () => {
expect(
assertPlainJsonValue(
{ a: [1, "x", { b: null }], c: true },
0,
DEFAULT_PLAIN_JSON_LIMITS,
),
).toBeNull();
});
it("rejects __proto__ keys", () => {
const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record<
string,
unknown
>;
expect(assertPlainJsonValue(obj, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
"Unsafe object key",
);
});
it("rejects non-finite numbers", () => {
expect(assertPlainJsonValue(Number.NaN, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
"Invalid number value",
);
});
it("rejects excessive depth", () => {
let v: unknown = 1;
for (let i = 0; i < 50; i++) {
v = { x: v };
}
expect(assertPlainJsonValue(v, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
"Maximum nesting depth exceeded",
);
});
});
describe("createFlowStateSchema", () => {
it("accepts empty object", () => {
const r = createFlowStateSchema.safeParse({});
expect(r.success).toBe(true);
});
it("accepts known fields and passthrough keys", () => {
const r = createFlowStateSchema.safeParse({
title: "My rule",
currentStep: "cards",
customField: { nested: [1, 2] },
});
expect(r.success).toBe(true);
});
it("rejects invalid currentStep", () => {
const r = createFlowStateSchema.safeParse({ currentStep: "not-a-step" });
expect(r.success).toBe(false);
});
it("rejects title that is too long", () => {
const r = createFlowStateSchema.safeParse({ title: "x".repeat(600) });
expect(r.success).toBe(false);
});
});
describe("putDraftBodySchema", () => {
it("requires payload object", () => {
expect(putDraftBodySchema.safeParse({}).success).toBe(false);
expect(putDraftBodySchema.safeParse({ payload: {} }).success).toBe(true);
});
});
describe("publishRuleBodySchema", () => {
it("accepts minimal valid body", () => {
const r = publishRuleBodySchema.safeParse({
title: " Hello ",
document: { body: "text" },
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.title).toBe("Hello");
expect(r.data.summary).toBeNull();
}
});
it("trims summary and maps empty to null", () => {
const r = publishRuleBodySchema.safeParse({
title: "T",
summary: " ",
document: {},
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.summary).toBeNull();
}
});
it("rejects empty title", () => {
const r = publishRuleBodySchema.safeParse({
title: " ",
document: {},
});
expect(r.success).toBe(false);
});
it("rejects non-object document", () => {
const r = publishRuleBodySchema.safeParse({
title: "Ok",
document: "nope",
});
expect(r.success).toBe(false);
});
});