Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
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;
});
}