18388bb31c
- 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>
182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
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<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 })),
|
||
};
|
||
});
|
||
}
|