Initial commit (unpacked platform)
This commit is contained in:
@@ -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 §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);
|
||||
|
||||
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.");
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user