Files
Cibello-app/apps/api/src/routes/admin.ts
T
2026-08-05 19:21:11 +07:00

428 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 §5758) ---
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 §3334) ---
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 §1314, 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;
});
}