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`count(*)` }) .from(schema.scanJobs) .groupBy(schema.scanJobs.status); const queueCounts = await app.jobQueue.getJobCounts(); const unpublishedEvents = await app.db .select({ count: sql`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 = {}; 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; }); }