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
+165
View File
@@ -0,0 +1,165 @@
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";
/**
* 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);
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,
};
});
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;
if (kind === "most-cooked") {
const 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);
return { enabled: true, kind, entries: rows };
}
if (kind === "top-rated") {
const 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);
return { enabled: true, kind, entries: rows };
}
if (kind === "budget") {
const 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);
return { enabled: true, kind, entries: rows };
}
if (kind === "protein") {
const 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);
return { enabled: true, kind, entries: rows };
}
// Spec §38: ingen ranking på vikt, viktnedgång, kalorier eller BMI.
throw errors.badRequest("Okänd rankingtyp.");
});
}