import type { FastifyInstance } from "fastify"; import { and, desc, eq, sql } from "drizzle-orm"; import { schema } from "@app/database"; import { idParamSchema } from "@app/validation"; import { errors, parse } from "../lib/errors.js"; import { resolveRecipeTitles } from "../lib/contentLanguage.js"; import { loadLocalePreferences } from "../lib/localeContext.js"; /** * Community & creators (spec §35–38). * Rankinglistor bakom feature flag; ALDRIG ranking på vikt/kalorier (spec §38). */ export async function communityRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.get("/v1/creators/:id", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const [stats] = await app.db .select() .from(schema.creatorStats) .where(eq(schema.creatorStats.userId, id)) .limit(1); const [user] = await app.db .select({ displayName: schema.users.displayName }) .from(schema.users) .where(eq(schema.users.id, id)) .limit(1); if (!user) throw errors.notFound("Profilen finns inte."); if (stats?.visibility === "private" && id !== req.userId) { throw errors.forbidden("Profilen är privat."); } const recipes = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, ratingAverage: schema.recipes.ratingAverage, cookCount: schema.recipes.cookCount, verificationStatus: schema.recipes.verificationStatus, imageUrls: schema.recipes.imageUrls, }) .from(schema.recipes) .where(and(eq(schema.recipes.creatorUserId, id), eq(schema.recipes.status, "published"))) .orderBy(desc(schema.recipes.cookCount)) .limit(30); const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag; const titleMap = await resolveRecipeTitles( app.db, recipes.map((r) => r.id), languageTag, ); return { userId: id, displayName: user.displayName, level: stats?.level ?? "beginner", followers: stats?.followers ?? 0, publishedRecipes: stats?.publishedRecipes ?? recipes.length, totalCooks: stats?.totalCooks ?? 0, averageRating: stats?.averageRating ?? null, badges: stats?.badges ?? [], recipes: recipes.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })), }; }); app.post("/v1/creators/:id/follow", auth, async (req) => { const { id } = parse(idParamSchema, req.params); if (id === req.userId) throw errors.badRequest("Du kan inte följa dig själv."); await app.db .insert(schema.creatorFollows) .values({ followerUserId: req.userId, creatorUserId: id }) .onConflictDoNothing(); await app.db .insert(schema.creatorStats) .values({ userId: id, followers: 1 }) .onConflictDoUpdate({ target: schema.creatorStats.userId, set: { followers: sql`${schema.creatorStats.followers} + 1`, updatedAt: new Date() }, }); return { ok: true }; }); app.delete("/v1/creators/:id/follow", auth, async (req) => { const { id } = parse(idParamSchema, req.params); await app.db .delete(schema.creatorFollows) .where( and( eq(schema.creatorFollows.followerUserId, req.userId), eq(schema.creatorFollows.creatorUserId, id), ), ); await app.db .update(schema.creatorStats) .set({ followers: sql`GREATEST(${schema.creatorStats.followers} - 1, 0)` }) .where(eq(schema.creatorStats.userId, id)); return { ok: true }; }); /** Topplistor (spec §38) – kräver flagga + minst 5 betyg för att synas. */ app.get("/v1/rankings/:kind", auth, async (req) => { if (!(await app.flags.isEnabled("creator_rankings", req.userId))) { return { enabled: false, entries: [] }; } const kind = (req.params as { kind: string }).kind; const MIN_RATINGS = 5; let rows: Array<{ id: string; titleSv: string; value: number | null }>; if (kind === "most-cooked") { rows = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, value: schema.recipes.cookCount, }) .from(schema.recipes) .where(eq(schema.recipes.status, "published")) .orderBy(desc(schema.recipes.cookCount)) .limit(20); } else if (kind === "top-rated") { rows = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, value: schema.recipes.ratingAverage, }) .from(schema.recipes) .where( and( eq(schema.recipes.status, "published"), sql`${schema.recipes.ratingCount} >= ${MIN_RATINGS}`, ), ) .orderBy(desc(schema.recipes.ratingAverage)) .limit(20); } else if (kind === "budget") { rows = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, value: schema.recipes.estimatedCostMinorPerPortion, }) .from(schema.recipes) .where( and( eq(schema.recipes.status, "published"), sql`${schema.recipes.estimatedCostMinorPerPortion} IS NOT NULL`, ), ) .orderBy(schema.recipes.estimatedCostMinorPerPortion) .limit(20); } else if (kind === "protein") { rows = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, value: sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`, }) .from(schema.recipes) .where(eq(schema.recipes.status, "published")) .orderBy(desc(sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`)) .limit(20); } else { // Spec §38: ingen ranking på vikt, viktnedgång, kalorier eller BMI. throw errors.badRequest("Okänd rankingtyp."); } const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag; const titleMap = await resolveRecipeTitles( app.db, rows.map((r) => r.id), languageTag, ); return { enabled: true, kind, entries: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })), }; }); }