Files
Cibello-app/apps/api/src/routes/community.ts
T
Claude 18388bb31c feat(i18n): översätt kvarvarande vyer + härda vakten (audit-backlog)
- API: översatt title/displayName/toName läggs nu bredvid Sv-fälten i favoriter,
  receptvarianter, /scaled, skapar-profiler, topplistor, memory-impact,
  substitutions — samt de denormaliserade titlarna (min dag, historik, matlådor,
  matlåde-förslag, veckoplan) via nullable recipeId med svensk fallback.
  10 endpoints, batchade resolvers (ett anrop per endpoint).
- Mobil: konsumerar de nya fälten med ?? Sv-fallback (swap-meal, sparade recept,
  varianter, min dag, logg, matlådor, veckoplan, inköpslista). Hårdkodade
  strängar -> t() (kitchen, scan-review, register, recept protein/Skapat av);
  decimalkomma -> Intl.NumberFormat. Allergen-etiketter -> t() (+5 nya nycklar).
  11 nya nycklar i alla 12 språk.
- i18n-vakt härdad: fångar nu hårdkodad svenska i prop={`...`}-mallliteraler
  (blind fläck förr). Verifierat att den fäller men inte ger falska positiv.
- whySv var redan lokaliserad (buildWhy med språktagg) - orörd.
- typecheck grönt (alla paket), vakt grön (603 nycklar).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 20:07:00 +00:00

182 lines
6.2 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, 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 §3538).
* 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<number>`(${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 })),
};
});
}