428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
||
import { and, desc, eq, ilike, sql } from "drizzle-orm";
|
||
import { schema } from "@app/database";
|
||
import { z } from "zod";
|
||
import { adminGrantInputSchema, totpCodeInputSchema } from "@app/validation";
|
||
import { errors, parse } from "../lib/errors.js";
|
||
import { audit } from "../lib/helpers.js";
|
||
import { generateTotpSecret, otpauthUrl, verifyTotp } from "../lib/totp.js";
|
||
import { BRAND } from "@app/shared-types";
|
||
|
||
/** Adminpanelens API (spec §57). Alla anrop kräver admin-roll och auditloggas. */
|
||
export async function adminRoutes(app: FastifyInstance) {
|
||
const admin = { preHandler: [app.requireAdmin] };
|
||
|
||
// --- Users ---
|
||
app.get("/admin/v1/users", admin, async (req) => {
|
||
const q = z
|
||
.object({
|
||
search: z.string().max(100).optional(),
|
||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||
offset: z.coerce.number().int().min(0).default(0),
|
||
})
|
||
.parse(req.query);
|
||
const conditions = q.search ? ilike(schema.users.email, `%${q.search}%`) : undefined;
|
||
const users = await app.db
|
||
.select({
|
||
id: schema.users.id,
|
||
email: schema.users.email,
|
||
displayName: schema.users.displayName,
|
||
role: schema.users.role,
|
||
onboardingCompleted: schema.users.onboardingCompleted,
|
||
createdAt: schema.users.createdAt,
|
||
deletedAt: schema.users.deletedAt,
|
||
})
|
||
.from(schema.users)
|
||
.where(conditions)
|
||
.orderBy(desc(schema.users.createdAt))
|
||
.limit(q.limit)
|
||
.offset(q.offset);
|
||
return { users };
|
||
});
|
||
|
||
// --- Receptmoderering (spec §35 steg 4) ---
|
||
app.get("/admin/v1/moderation/recipes", admin, async () => {
|
||
const pending = await app.db
|
||
.select()
|
||
.from(schema.recipes)
|
||
.where(sql`${schema.recipes.status} IN ('submitted', 'ai_checked', 'in_moderation')`)
|
||
.orderBy(schema.recipes.createdAt)
|
||
.limit(50);
|
||
return { recipes: pending };
|
||
});
|
||
|
||
app.post("/admin/v1/moderation/recipes/:id", admin, async (req) => {
|
||
const params = z.object({ id: z.uuid() }).parse(req.params);
|
||
const body = z
|
||
.object({
|
||
action: z.enum(["approve", "reject", "request_changes"]),
|
||
note: z.string().max(1000).optional(),
|
||
})
|
||
.parse(req.body);
|
||
|
||
const status =
|
||
body.action === "approve" ? "published" : body.action === "reject" ? "rejected" : "draft";
|
||
const [recipe] = await app.db
|
||
.update(schema.recipes)
|
||
.set({ status, moderationNote: body.note ?? null, updatedAt: new Date() })
|
||
.where(eq(schema.recipes.id, params.id))
|
||
.returning();
|
||
if (!recipe) throw errors.notFound();
|
||
|
||
if (body.action === "approve" && recipe.creatorUserId) {
|
||
await app.db
|
||
.insert(schema.creatorStats)
|
||
.values({ userId: recipe.creatorUserId, publishedRecipes: 1 })
|
||
.onConflictDoUpdate({
|
||
target: schema.creatorStats.userId,
|
||
set: {
|
||
publishedRecipes: sql`${schema.creatorStats.publishedRecipes} + 1`,
|
||
updatedAt: new Date(),
|
||
},
|
||
});
|
||
}
|
||
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
actorType: "admin",
|
||
action: `moderation.recipe_${body.action}`,
|
||
targetType: "recipe",
|
||
targetId: params.id,
|
||
metadata: { note: body.note },
|
||
});
|
||
return recipe;
|
||
});
|
||
|
||
// --- Feature flags (spec §57) ---
|
||
app.get("/admin/v1/flags", admin, async () => {
|
||
return {
|
||
flags: await app.db.select().from(schema.featureFlags).orderBy(schema.featureFlags.key),
|
||
};
|
||
});
|
||
|
||
app.put("/admin/v1/flags/:key", admin, async (req) => {
|
||
const params = z.object({ key: z.string().max(60) }).parse(req.params);
|
||
const body = z
|
||
.object({
|
||
enabled: z.boolean(),
|
||
rolloutPercent: z.number().int().min(0).max(100).default(100),
|
||
descriptionSv: z.string().max(300).optional(),
|
||
})
|
||
.parse(req.body);
|
||
const [flag] = await app.db
|
||
.insert(schema.featureFlags)
|
||
.values({ key: params.key, ...body })
|
||
.onConflictDoUpdate({
|
||
target: schema.featureFlags.key,
|
||
set: { ...body, updatedAt: new Date() },
|
||
})
|
||
.returning();
|
||
app.flags.invalidate();
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
actorType: "admin",
|
||
action: "flags.updated",
|
||
targetType: "feature_flag",
|
||
targetId: params.key,
|
||
metadata: body,
|
||
});
|
||
return flag;
|
||
});
|
||
|
||
// --- Subscriptions ---
|
||
app.get("/admin/v1/subscriptions", admin, async () => {
|
||
const subs = await app.db
|
||
.select()
|
||
.from(schema.subscriptions)
|
||
.orderBy(desc(schema.subscriptions.updatedAt))
|
||
.limit(100);
|
||
return { subscriptions: subs };
|
||
});
|
||
|
||
app.post("/admin/v1/subscriptions/grant", admin, async (req) => {
|
||
const input = parse(adminGrantInputSchema, req.body);
|
||
if (input.plan === "free")
|
||
throw errors.badRequest("Använd delete i stället för att sätta free.");
|
||
const [sub] = await app.db
|
||
.insert(schema.subscriptions)
|
||
.values({
|
||
userId: input.userId,
|
||
provider: "promo",
|
||
productId: `promo_${input.plan}`,
|
||
plan: input.plan,
|
||
status: "active",
|
||
purchasedAt: new Date(),
|
||
expiresAt: new Date(Date.now() + input.days * 86_400_000),
|
||
lastVerifiedAt: new Date(),
|
||
})
|
||
.returning();
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
actorType: "admin",
|
||
action: "subscription.granted",
|
||
targetType: "user",
|
||
targetId: input.userId,
|
||
metadata: { plan: input.plan, days: input.days, reason: input.reason },
|
||
});
|
||
return sub;
|
||
});
|
||
|
||
// --- Jobb & systemhälsa (spec §57–58) ---
|
||
app.get("/admin/v1/jobs/overview", admin, async () => {
|
||
const scanStats = await app.db
|
||
.select({ status: schema.scanJobs.status, count: sql<number>`count(*)` })
|
||
.from(schema.scanJobs)
|
||
.groupBy(schema.scanJobs.status);
|
||
const queueCounts = await app.jobQueue.getJobCounts();
|
||
const unpublishedEvents = await app.db
|
||
.select({ count: sql<number>`count(*)` })
|
||
.from(schema.domainEvents)
|
||
.where(sql`${schema.domainEvents.publishedAt} IS NULL`);
|
||
return {
|
||
scanJobs: scanStats,
|
||
queue: queueCounts,
|
||
outboxPending: Number(unpublishedEvents[0]?.count ?? 0),
|
||
};
|
||
});
|
||
|
||
app.get("/admin/v1/system/health", admin, async () => {
|
||
const checks: Record<string, { ok: boolean; detail?: string }> = {};
|
||
try {
|
||
await app.db.execute(sql`SELECT 1`);
|
||
checks.database = { ok: true };
|
||
} catch (err) {
|
||
checks.database = { ok: false, detail: String(err) };
|
||
}
|
||
try {
|
||
const pong = await app.redis.ping();
|
||
checks.redis = { ok: pong === "PONG" };
|
||
} catch (err) {
|
||
checks.redis = { ok: false, detail: String(err) };
|
||
}
|
||
checks.aamos = await app.aamos.healthCheck();
|
||
checks.connectors = { ok: true, detail: `${app.connectors.list().length} registrerade` };
|
||
return { checks, timestamp: new Date().toISOString() };
|
||
});
|
||
|
||
// --- Audit logs ---
|
||
app.get("/admin/v1/audit-logs", admin, async (req) => {
|
||
const q = z
|
||
.object({
|
||
action: z.string().max(60).optional(),
|
||
limit: z.coerce.number().int().min(1).max(200).default(100),
|
||
})
|
||
.parse(req.query);
|
||
const conditions = q.action ? ilike(schema.auditLogs.action, `%${q.action}%`) : undefined;
|
||
const logs = await app.db
|
||
.select()
|
||
.from(schema.auditLogs)
|
||
.where(conditions)
|
||
.orderBy(desc(schema.auditLogs.createdAt))
|
||
.limit(q.limit);
|
||
return { logs };
|
||
});
|
||
|
||
// --- AI-korrigeringar & evals (spec §33–34) ---
|
||
app.get("/admin/v1/ai/corrections", admin, async () => {
|
||
const corrections = await app.db
|
||
.select()
|
||
.from(schema.aiCorrections)
|
||
.orderBy(desc(schema.aiCorrections.createdAt))
|
||
.limit(100);
|
||
return { corrections };
|
||
});
|
||
|
||
app.get("/admin/v1/ai/eval-runs", admin, async () => {
|
||
const runs = await app.db
|
||
.select()
|
||
.from(schema.aiEvalRuns)
|
||
.orderBy(desc(schema.aiEvalRuns.createdAt))
|
||
.limit(50);
|
||
return { runs };
|
||
});
|
||
|
||
// --- Admin-2FA (TOTP, RFC 6238) ---
|
||
|
||
/**
|
||
* Starta 2FA-setup: nytt secret + otpauth-URL för autentiseringsappen.
|
||
* OBS: kräver befintlig admin-session; kan köras om tills enable bekräftats.
|
||
*/
|
||
app.post("/admin/v1/2fa/setup", admin, async (req) => {
|
||
const [existing] = await app.db
|
||
.select({ enabledAt: schema.adminTotp.enabledAt })
|
||
.from(schema.adminTotp)
|
||
.where(eq(schema.adminTotp.userId, req.userId))
|
||
.limit(1);
|
||
if (existing?.enabledAt) {
|
||
throw errors.badRequest("2FA är redan aktiverat. Inaktivera först för att byta secret.");
|
||
}
|
||
const secret = generateTotpSecret();
|
||
const [user] = await app.db
|
||
.select({ email: schema.users.email })
|
||
.from(schema.users)
|
||
.where(eq(schema.users.id, req.userId))
|
||
.limit(1);
|
||
await app.db
|
||
.insert(schema.adminTotp)
|
||
.values({ userId: req.userId, secretBase32: secret })
|
||
.onConflictDoUpdate({
|
||
target: schema.adminTotp.userId,
|
||
set: { secretBase32: secret, enabledAt: null, lastUsedStep: null },
|
||
});
|
||
return {
|
||
secret,
|
||
otpauthUrl: otpauthUrl(secret, user?.email ?? "admin", `${BRAND.name} Admin`),
|
||
};
|
||
});
|
||
|
||
/** Bekräfta setup med första koden – först nu börjar tvånget gälla. */
|
||
app.post("/admin/v1/2fa/enable", admin, async (req) => {
|
||
const body = parse(totpCodeInputSchema, req.body);
|
||
const [row] = await app.db
|
||
.select()
|
||
.from(schema.adminTotp)
|
||
.where(eq(schema.adminTotp.userId, req.userId))
|
||
.limit(1);
|
||
if (!row) throw errors.badRequest("Kör setup först.");
|
||
if (row.enabledAt) return { enabled: true };
|
||
const step = verifyTotp(row.secretBase32, body.code);
|
||
if (step == null)
|
||
throw errors.unauthorized("Fel engångskod – kontrollera appen och försök igen.");
|
||
await app.db
|
||
.update(schema.adminTotp)
|
||
.set({ enabledAt: new Date(), lastUsedStep: step })
|
||
.where(eq(schema.adminTotp.userId, req.userId));
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
actorType: "admin",
|
||
action: "admin.2fa_enabled",
|
||
});
|
||
return { enabled: true };
|
||
});
|
||
|
||
/** Inaktivera 2FA (kräver giltig kod – aldrig bara en session). */
|
||
app.post("/admin/v1/2fa/disable", admin, async (req) => {
|
||
const body = parse(totpCodeInputSchema, req.body);
|
||
const [row] = await app.db
|
||
.select()
|
||
.from(schema.adminTotp)
|
||
.where(eq(schema.adminTotp.userId, req.userId))
|
||
.limit(1);
|
||
if (!row?.enabledAt) return { enabled: false };
|
||
if (verifyTotp(row.secretBase32, body.code) == null) {
|
||
throw errors.unauthorized("Fel engångskod.");
|
||
}
|
||
await app.db.delete(schema.adminTotp).where(eq(schema.adminTotp.userId, req.userId));
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
actorType: "admin",
|
||
action: "admin.2fa_disabled",
|
||
});
|
||
return { enabled: false };
|
||
});
|
||
|
||
/** Status för inloggad admin (visas i adminpanelen). */
|
||
app.get("/admin/v1/2fa/status", admin, async (req) => {
|
||
const [row] = await app.db
|
||
.select({ enabledAt: schema.adminTotp.enabledAt })
|
||
.from(schema.adminTotp)
|
||
.where(eq(schema.adminTotp.userId, req.userId))
|
||
.limit(1);
|
||
return { enabled: Boolean(row?.enabledAt) };
|
||
});
|
||
|
||
// --- Receptöversättningar (i18n-spec §13–14, M3) ---
|
||
|
||
/** Beställ AI-utkast för ett recept och språk. Idempotent (upsert i worker). */
|
||
app.post("/admin/v1/recipes/:id/translate", admin, async (req) => {
|
||
const params = z.object({ id: z.uuid() }).parse(req.params);
|
||
const body = z.object({ languageTag: z.string().min(2).max(35) }).parse(req.body);
|
||
const [recipe] = await app.db
|
||
.select({ id: schema.recipes.id })
|
||
.from(schema.recipes)
|
||
.where(eq(schema.recipes.id, params.id))
|
||
.limit(1);
|
||
if (!recipe) throw errors.notFound("Receptet finns inte.");
|
||
await app.jobQueue.add("TRANSLATE_RECIPE", {
|
||
jobType: "TRANSLATE_RECIPE",
|
||
recipeId: params.id,
|
||
targetLanguageTag: body.languageTag,
|
||
});
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
actorType: "admin",
|
||
action: "recipe.translate.requested",
|
||
targetType: "recipe",
|
||
targetId: params.id,
|
||
metadata: { languageTag: body.languageTag },
|
||
});
|
||
return { queued: true, recipeId: params.id, languageTag: body.languageTag };
|
||
});
|
||
|
||
/** Lista översättningar (med verifieringsresultat) för granskning. */
|
||
app.get("/admin/v1/recipes/:id/translations", admin, async (req) => {
|
||
const params = z.object({ id: z.uuid() }).parse(req.params);
|
||
const translations = await app.db
|
||
.select()
|
||
.from(schema.recipeTranslations)
|
||
.where(eq(schema.recipeTranslations.recipeId, params.id));
|
||
const steps = await app.db
|
||
.select()
|
||
.from(schema.recipeStepTranslations)
|
||
.where(eq(schema.recipeStepTranslations.recipeId, params.id))
|
||
.orderBy(schema.recipeStepTranslations.stepNumber);
|
||
return {
|
||
translations: translations.map((t) => ({
|
||
...t,
|
||
steps: steps.filter((s) => s.languageTag === t.languageTag),
|
||
})),
|
||
};
|
||
});
|
||
|
||
/** Publicera/underkänn en översättning efter mänsklig granskning. */
|
||
app.post("/admin/v1/recipes/:id/translations/:languageTag", admin, async (req) => {
|
||
const params = z
|
||
.object({ id: z.uuid(), languageTag: z.string().min(2).max(35) })
|
||
.parse(req.params);
|
||
const body = z
|
||
.object({
|
||
action: z.enum(["publish", "back_to_draft", "in_review"]),
|
||
/** Redaktören kan rätta texten i samma steg. */
|
||
title: z.string().min(1).optional(),
|
||
description: z.string().nullable().optional(),
|
||
})
|
||
.parse(req.body);
|
||
const status =
|
||
body.action === "publish"
|
||
? ("published" as const)
|
||
: body.action === "in_review"
|
||
? ("in_review" as const)
|
||
: ("draft_ai" as const);
|
||
const [row] = await app.db
|
||
.update(schema.recipeTranslations)
|
||
.set({
|
||
status,
|
||
...(body.title ? { title: body.title, source: "human" as const } : {}),
|
||
...(body.description !== undefined ? { description: body.description } : {}),
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(
|
||
and(
|
||
eq(schema.recipeTranslations.recipeId, params.id),
|
||
eq(schema.recipeTranslations.languageTag, params.languageTag),
|
||
),
|
||
)
|
||
.returning();
|
||
if (!row) throw errors.notFound("Översättningen finns inte.");
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
actorType: "admin",
|
||
action: "recipe.translation.moderated",
|
||
targetType: "recipe",
|
||
targetId: params.id,
|
||
metadata: { languageTag: params.languageTag, action: body.action },
|
||
});
|
||
return row;
|
||
});
|
||
}
|