Align backend plan with codebase
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||
import { destroySessionFromRequest } from "../../../../lib/server/session";
|
||||
|
||||
export async function POST() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
await destroySessionFromRequest();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashOtpCode } from "../../../../../lib/server/hash";
|
||||
import { sendOtpEmail } from "../../../../../lib/server/mail";
|
||||
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
import { logger } from "../../../../../lib/logger";
|
||||
|
||||
const OTP_TTL_MS = 10 * 60 * 1000;
|
||||
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||
|
||||
function normalizeEmail(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const email = raw.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
|
||||
return email;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = normalizeEmail(
|
||||
body && typeof body === "object" && "email" in body
|
||||
? (body as { email: unknown }).email
|
||||
: null,
|
||||
);
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Valid email required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
const rlEmail = rateLimitKey(`otp-email:${email}`, EMAIL_MIN_INTERVAL_MS);
|
||||
if (rlEmail.ok === false) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const rlIp = rateLimitKey(`otp-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
||||
if (rlIp.ok === false) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests", retryAfterMs: rlIp.retryAfterMs },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfiguration" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
const codeHash = hashOtpCode(code, pepper);
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
||||
|
||||
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||
await prisma.otpChallenge.create({
|
||||
data: { email, codeHash, expiresAt },
|
||||
});
|
||||
|
||||
try {
|
||||
await sendOtpEmail(email, code);
|
||||
} catch (err) {
|
||||
logger.error("sendOtpEmail failed:", err);
|
||||
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||
return NextResponse.json(
|
||||
{ error: "Could not send email" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashOtpCode } from "../../../../../lib/server/hash";
|
||||
import {
|
||||
createSessionForUser,
|
||||
setSessionCookie,
|
||||
} from "../../../../../lib/server/session";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
function normalizeEmail(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const email = raw.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
|
||||
return email;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { email: rawEmail, code: rawCode } = body as {
|
||||
email?: unknown;
|
||||
code?: unknown;
|
||||
};
|
||||
|
||||
const email = normalizeEmail(rawEmail);
|
||||
const code =
|
||||
typeof rawCode === "string"
|
||||
? rawCode.replace(/\s/g, "")
|
||||
: String(rawCode ?? "");
|
||||
|
||||
if (!email || !/^\d{6}$/.test(code)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Valid email and 6-digit code required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfiguration" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const challenge = await prisma.otpChallenge.findFirst({
|
||||
where: {
|
||||
email,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!challenge) {
|
||||
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (challenge.attempts >= MAX_ATTEMPTS) {
|
||||
await prisma.otpChallenge.delete({ where: { id: challenge.id } });
|
||||
return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
|
||||
}
|
||||
|
||||
const expectedHash = hashOtpCode(code, pepper);
|
||||
if (expectedHash !== challenge.codeHash) {
|
||||
await prisma.otpChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
create: { email },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const { token, expiresAt } = await createSessionForUser(user.id);
|
||||
await setSessionCookie(token, expiresAt);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user: { id: user.id, email: user.email },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../lib/server/session";
|
||||
|
||||
export async function GET() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: { id: user.id, email: user.email },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../lib/server/session";
|
||||
|
||||
export async function GET() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const draft = await prisma.ruleDraft.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
draft: draft ? { payload: draft.payload, updatedAt: draft.updatedAt } : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object" || !("payload" in body)) {
|
||||
return NextResponse.json({ error: "payload required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = (body as { payload: unknown }).payload;
|
||||
if (payload === undefined || typeof payload !== "object" || payload === null) {
|
||||
return NextResponse.json(
|
||||
{ error: "payload must be a JSON object" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const draft = await prisma.ruleDraft.upsert({
|
||||
where: { userId: user.id },
|
||||
create: {
|
||||
userId: user.id,
|
||||
payload: payload as object,
|
||||
},
|
||||
update: {
|
||||
payload: payload as object,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
|
||||
export async function GET() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
database: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ ok: true, database: "connected" });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ ok: false, database: "error" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../lib/server/session";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||
|
||||
const rules = await prisma.publishedRule.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
summary: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ rules });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { title, summary, document } = body as {
|
||||
title?: unknown;
|
||||
summary?: unknown;
|
||||
document?: unknown;
|
||||
};
|
||||
|
||||
if (typeof title !== "string" || title.trim().length === 0) {
|
||||
return NextResponse.json({ error: "title required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (document === undefined || typeof document !== "object" || document === null) {
|
||||
return NextResponse.json(
|
||||
{ error: "document must be a JSON object" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const rule = await prisma.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title: title.trim(),
|
||||
summary:
|
||||
typeof summary === "string" && summary.trim().length > 0
|
||||
? summary.trim()
|
||||
: null,
|
||||
document: document as object,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../lib/server/responses";
|
||||
|
||||
/**
|
||||
* Curated rule templates for recommendations (seed via Prisma Studio or a script).
|
||||
*/
|
||||
export async function GET() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const templates = await prisma.ruleTemplate.findMany({
|
||||
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
category: true,
|
||||
description: true,
|
||||
body: true,
|
||||
featured: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ templates });
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchAuthSession,
|
||||
fetchDraftFromServer,
|
||||
saveDraftToServer,
|
||||
} from "../../../lib/create/api";
|
||||
import { useCreateFlow } from "./CreateFlowContext";
|
||||
|
||||
const SYNC_ENABLED =
|
||||
process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
const DEBOUNCE_MS = 1000;
|
||||
|
||||
/**
|
||||
* When NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true, loads the signed-in user's draft
|
||||
* from the server and debounces saves. Anonymous users keep localStorage-only behavior.
|
||||
*/
|
||||
export function CreateFlowBackendSync() {
|
||||
const { state, replaceState } = useCreateFlow();
|
||||
const [hydrated, setHydrated] = useState(!SYNC_ENABLED);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SYNC_ENABLED) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { user } = await fetchAuthSession();
|
||||
if (cancelled || !user) {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const serverDraft = await fetchDraftFromServer();
|
||||
if (cancelled) return;
|
||||
if (serverDraft && Object.keys(serverDraft).length > 0) {
|
||||
replaceState(serverDraft);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setHydrated(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [replaceState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SYNC_ENABLED || !hydrated) return;
|
||||
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
void (async () => {
|
||||
const { user } = await fetchAuthSession();
|
||||
if (!user) return;
|
||||
await saveDraftToServer(state);
|
||||
})();
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, [state, hydrated]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -79,6 +79,11 @@ export function CreateFlowProvider({
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const replaceState = useCallback((next: CreateFlowState) => {
|
||||
setState(next);
|
||||
writeStateToStorage(STORAGE_KEY, next);
|
||||
}, []);
|
||||
|
||||
const clearState = useCallback(() => {
|
||||
setState({});
|
||||
removeFromStorage(STORAGE_KEY);
|
||||
@@ -89,6 +94,7 @@ export function CreateFlowProvider({
|
||||
state,
|
||||
currentStep,
|
||||
updateState,
|
||||
replaceState,
|
||||
clearState,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user