Align backend plan with codebase

This commit is contained in:
adilallo
2026-04-04 22:20:02 -06:00
parent fe54390849
commit c8e930552b
36 changed files with 2216 additions and 2 deletions
+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 });
}
+97
View File
@@ -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 });
}
+108
View File
@@ -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 },
});
}
+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 },
});
}
+69
View File
@@ -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 },
});
}
+22
View File
@@ -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 },
);
}
}
+88
View File
@@ -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,
},
});
}
+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 });
}