diff --git a/.env.example b/.env.example index 7543de78..9bf0f53a 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,7 @@ HEALTHCHECKS_REPORTS_DIGEST_URL= HEALTHCHECKS_BACKUP_URL= HEALTHCHECKS_INACTIVE_ACCOUNTS_SCAN_URL= HEALTHCHECKS_GAMIFICATION_RECONCILE_URL= +HEALTHCHECKS_GAMIFICATION_ACHIEVEMENTS_SWEEP_URL= HEALTHCHECKS_OFFSITE_BACKUP_URL= # --- Public hosting on a VPS (see README "Public hosting (VPS)") --- diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json index 27a4c62f..70f4a6ec 100644 --- a/apps/api/nest-cli.json +++ b/apps/api/nest-cli.json @@ -6,6 +6,7 @@ "sourceRoot": "src", "compilerOptions": { "deleteOutDir": true, + "assets": ["package.json"], "plugins": ["@nestjs/swagger"], "manualRestart": false, "builder": "tsc", diff --git a/apps/api/prisma/migrations/20260902181141_add_user_achievement/migration.sql b/apps/api/prisma/migrations/20260902181141_add_user_achievement/migration.sql new file mode 100644 index 00000000..ce0eeb5e --- /dev/null +++ b/apps/api/prisma/migrations/20260902181141_add_user_achievement/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "UserAchievement" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "unlockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "displayedAt" TIMESTAMP(3), + + CONSTRAINT "UserAchievement_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserAchievement_userId_key_key" ON "UserAchievement"("userId", "key"); + +-- AddForeignKey +ALTER TABLE "UserAchievement" ADD CONSTRAINT "UserAchievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 52559695..5a48d27d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -575,6 +575,7 @@ model User { subscriptions Subscription[] xpEntries XpEntry[] score UserScore? + achievements UserAchievement[] @@index([lastActiveAt]) } @@ -1668,3 +1669,29 @@ model UserScore { @@index([xp]) } + +// One row per unlocked achievement. Definitions live in code +// (apps/api/src/gamification/achievements/registry.ts), never in the +// database — a self-host install must never need seeding. Achievements are +// never revoked once unlocked, unlike XpEntry: a trophy isn't taken back +// because you later removed the title that earned it. +model UserAchievement { + id String @id @default(cuid()) + userId String + // Stable key matching a registry entry — string, not a Prisma enum, so a + // new achievement needs no migration (same rationale as XpEntry.reason). + // Each tier of a tiered achievement is its own key (e.g. + // "cinephile_bronze"/"cinephile_silver"/"cinephile_gold"), not a shared + // row with a tier column. + key String + unlockedAt DateTime @default(now()) + // Null until the [G6] unlock-bubble UI has shown this achievement to the + // user once — set by PATCH /achievements/:id/displayed. Independent of + // unlockedAt: an unlock can happen server-side (a live action, or the + // nightly sweep) while the user isn't even in the app. + displayedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, key]) +} diff --git a/apps/api/src/config/public-config.controller.ts b/apps/api/src/config/public-config.controller.ts index 0f43253d..fc66712d 100644 --- a/apps/api/src/config/public-config.controller.ts +++ b/apps/api/src/config/public-config.controller.ts @@ -23,7 +23,7 @@ export class PublicConfigController { @Get() @ApiOkResponse({ type: PublicConfigResponseDto }) async get(): Promise { - const raw = await readFile(join(process.cwd(), "package.json"), "utf-8"); + const raw = await readFile(join(__dirname, "../../package.json"), "utf-8"); const { version } = JSON.parse(raw) as { version: string }; return { diff --git a/apps/api/src/gamification/achievements/achievement.service.spec.ts b/apps/api/src/gamification/achievements/achievement.service.spec.ts new file mode 100644 index 00000000..89308b54 --- /dev/null +++ b/apps/api/src/gamification/achievements/achievement.service.spec.ts @@ -0,0 +1,250 @@ +import type { ConfigService } from "@nestjs/config"; +import { Prisma } from "@prisma/client"; +import { vi, type Mock } from "vitest"; +import type { FeatureFlagsService } from "../../feature-flags/feature-flags.service"; +import type { JobRunService } from "../../jobs/job-run.service"; +import type { PrismaService } from "../../prisma/prisma.service"; +import type { XpService } from "../xp.service"; +import { AchievementService } from "./achievement.service"; + +// A socialGated achievement to exercise the [G3]-reserved gate — none of +// this ticket's own registry entries (first_episode, cinephile_*) use it. +const { socialGatedCheck } = vi.hoisted(() => ({ + socialGatedCheck: vi.fn(), +})); + +vi.mock("./registry", async (importOriginal) => { + const actual = await importOriginal(); + const socialGatedDefinition = { + key: "test_social_gated", + xpAward: 10, + socialGated: true, + check: socialGatedCheck, + }; + return { + ...actual, + ACHIEVEMENTS: { + ...actual.ACHIEVEMENTS, + test_social_gated: socialGatedDefinition, + }, + ACHIEVEMENT_LIST: [...actual.ACHIEVEMENT_LIST, socialGatedDefinition], + }; +}); + +function uniqueConstraintError(): Prisma.PrismaClientKnownRequestError { + return new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }); +} + +function makeConfig(values: Record = {}): ConfigService { + return { + get: vi.fn((key: string) => values[key]), + } as unknown as ConfigService; +} + +// No Unleash client configured in tests — isEnabled always returns whatever +// fallback the caller passed, same convention as xp.service.spec.ts. +function makeFlags(): FeatureFlagsService { + return { + isEnabled: vi.fn((_name: string, fallback: boolean) => fallback), + } as unknown as FeatureFlagsService; +} + +function makeService(configValues: Record = {}) { + const prisma = { + userAchievement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: "achievement-1" }), + findMany: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + }, + episodeWatch: { findFirst: vi.fn().mockResolvedValue(null) }, + libraryEntry: { count: vi.fn().mockResolvedValue(0) }, + xpEntry: { findMany: vi.fn().mockResolvedValue([]) }, + user: { findMany: vi.fn().mockResolvedValue([]) }, + } as unknown as PrismaService; + const config = makeConfig({ GAMIFICATION_ENABLED: "true", ...configValues }); + const flags = makeFlags(); + const xp = { award: vi.fn() } as unknown as XpService; + const jobRuns = { + record: vi.fn((_key: string, fn: () => Promise) => fn()), + } as unknown as JobRunService; + + const service = new AchievementService(prisma, config, flags, xp, jobRuns); + return { service, prisma, config, flags, xp, jobRuns }; +} + +describe("AchievementService.evaluate", () => { + it("unlocks a satisfied achievement, credits its xpAward via amountOverride, sourced from the created row's id", async () => { + const { service, prisma, xp } = makeService(); + (prisma.episodeWatch.findFirst as Mock).mockResolvedValue({ id: "w1" }); + + await service.evaluate("user-1", ["first_episode"]); + + expect(prisma.userAchievement.create).toHaveBeenCalledWith({ + data: { userId: "user-1", key: "first_episode" }, + }); + expect(xp.award).toHaveBeenCalledWith( + "user-1", + "ACHIEVEMENT_UNLOCKED", + "achievement-1", + 50, + ); + }); + + it("does not even call check() when the achievement is already unlocked", async () => { + const { service, prisma } = makeService(); + (prisma.userAchievement.findUnique as Mock).mockResolvedValue({ + id: "achievement-1", + }); + + await service.evaluate("user-1", ["first_episode"]); + + expect(prisma.episodeWatch.findFirst).not.toHaveBeenCalled(); + expect(prisma.userAchievement.create).not.toHaveBeenCalled(); + }); + + it("stays locked when the check reports unlocked: false", async () => { + const { service, prisma, xp } = makeService(); + (prisma.episodeWatch.findFirst as Mock).mockResolvedValue(null); + + await service.evaluate("user-1", ["first_episode"]); + + expect(prisma.userAchievement.create).not.toHaveBeenCalled(); + expect(xp.award).not.toHaveBeenCalled(); + }); + + it("no-ops entirely (no read, no write) when GAMIFICATION_ENABLED is off", async () => { + const { service, prisma } = makeService({ GAMIFICATION_ENABLED: "false" }); + + await service.evaluate("user-1"); + + expect(prisma.userAchievement.findUnique).not.toHaveBeenCalled(); + }); + + it("never unlocks a socialGated achievement while SOCIAL_ENABLED is off", async () => { + const { service, prisma, xp } = makeService(); + socialGatedCheck.mockResolvedValue({ unlocked: true }); + + await service.evaluate("user-1", ["test_social_gated"]); + + expect(socialGatedCheck).not.toHaveBeenCalled(); + expect(prisma.userAchievement.create).not.toHaveBeenCalled(); + expect(xp.award).not.toHaveBeenCalled(); + }); + + it("credits no XP when create() hits the unique constraint (concurrent unlock)", async () => { + const { service, prisma, xp } = makeService(); + (prisma.episodeWatch.findFirst as Mock).mockResolvedValue({ id: "w1" }); + (prisma.userAchievement.create as Mock).mockRejectedValue( + uniqueConstraintError(), + ); + + await expect( + service.evaluate("user-1", ["first_episode"]), + ).resolves.toBeUndefined(); + expect(xp.award).not.toHaveBeenCalled(); + }); + + it("evaluates only the registry entries named by `keys`", async () => { + const { service, prisma } = makeService(); + + await service.evaluate("user-1", ["first_episode"]); + + // Only first_episode's own uniqueness check runs — cinephile's tiers + // and the socialGated test fixture are skipped entirely. + expect(prisma.userAchievement.findUnique).toHaveBeenCalledTimes(1); + expect(prisma.userAchievement.findUnique).toHaveBeenCalledWith({ + where: { userId_key: { userId: "user-1", key: "first_episode" } }, + select: { id: true }, + }); + }); +}); + +describe("AchievementService.pending", () => { + it("returns unlocked-but-undisplayed achievements, oldest first, with xpAwarded looked up from the matching XpEntry", async () => { + const { service, prisma } = makeService(); + (prisma.userAchievement.findMany as Mock).mockResolvedValue([ + { + id: "achievement-1", + key: "first_episode", + unlockedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + ]); + (prisma.xpEntry.findMany as Mock).mockResolvedValue([ + { sourceId: "achievement-1", amount: 50 }, + ]); + + await expect(service.pending("user-1")).resolves.toEqual([ + { + id: "achievement-1", + key: "first_episode", + unlockedAt: "2026-01-01T00:00:00.000Z", + xpAwarded: 50, + }, + ]); + expect(prisma.userAchievement.findMany).toHaveBeenCalledWith({ + where: { userId: "user-1", displayedAt: null }, + orderBy: { unlockedAt: "asc" }, + }); + }); + + it("returns an empty list (not an error) when GAMIFICATION_ENABLED is off", async () => { + const { service, prisma } = makeService({ GAMIFICATION_ENABLED: "false" }); + + await expect(service.pending("user-1")).resolves.toEqual([]); + expect(prisma.userAchievement.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("AchievementService.markDisplayed", () => { + it("404s on an achievement belonging to another user", async () => { + const { service, prisma } = makeService(); + (prisma.userAchievement.findUnique as Mock).mockResolvedValue({ + userId: "someone-else", + displayedAt: null, + }); + + await expect( + service.markDisplayed("user-1", "achievement-1"), + ).rejects.toThrow(); + expect(prisma.userAchievement.update).not.toHaveBeenCalled(); + }); + + it("404s on an unknown id", async () => { + const { service, prisma } = makeService(); + (prisma.userAchievement.findUnique as Mock).mockResolvedValue(null); + + await expect(service.markDisplayed("user-1", "missing")).rejects.toThrow(); + }); + + it("sets displayedAt on first call", async () => { + const { service, prisma } = makeService(); + (prisma.userAchievement.findUnique as Mock).mockResolvedValue({ + userId: "user-1", + displayedAt: null, + }); + + await service.markDisplayed("user-1", "achievement-1"); + + expect(prisma.userAchievement.update).toHaveBeenCalledWith({ + where: { id: "achievement-1" }, + data: { displayedAt: expect.any(Date) }, + }); + }); + + it("is idempotent: a second call is a no-op, not an error", async () => { + const { service, prisma } = makeService(); + (prisma.userAchievement.findUnique as Mock).mockResolvedValue({ + userId: "user-1", + displayedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + await expect( + service.markDisplayed("user-1", "achievement-1"), + ).resolves.toBeUndefined(); + expect(prisma.userAchievement.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/gamification/achievements/achievement.service.ts b/apps/api/src/gamification/achievements/achievement.service.ts new file mode 100644 index 00000000..bca4b0ba --- /dev/null +++ b/apps/api/src/gamification/achievements/achievement.service.ts @@ -0,0 +1,200 @@ +import { + ErrorCode, + XpReason, + type PendingAchievementDto, +} from "@loomkeep/shared"; +import { HttpStatus, Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Cron } from "@nestjs/schedule"; +import { Prisma } from "@prisma/client"; +import { AppException } from "../../common/app.exception"; +import { FeatureFlagsService } from "../../feature-flags/feature-flags.service"; +import { JOB_KEYS } from "../../jobs/job-keys"; +import { JobRunService } from "../../jobs/job-run.service"; +import { PrismaService } from "../../prisma/prisma.service"; +import { isSocialEnabled } from "../../social/social.config"; +import { isGamificationEnabled } from "../gamification.config"; +import { XpService } from "../xp.service"; +import { + ACHIEVEMENT_LIST, + ACHIEVEMENTS, + type AchievementDefinition, +} from "./registry"; + +/** + * Unlocks achievements — the engine behind the registry declared in + * `registry.ts`. Achievements are permanent: once a `UserAchievement` row + * exists, `evaluate()` never re-checks or removes it, unlike `XpService`'s + * ledger (see the [G2] plan — a trophy isn't taken back because the + * underlying activity later changes). + */ +@Injectable() +export class AchievementService { + private readonly logger = new Logger(AchievementService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly flags: FeatureFlagsService, + private readonly xp: XpService, + private readonly jobRuns: JobRunService, + ) {} + + /** + * Re-checks and unlocks achievements for `userId` — every definition in + * the registry, or only `keys` when a caller already knows which ones a + * just-credited XP reason can affect (see ACHIEVEMENT_KEYS_BY_XP_REASON). + * No-ops entirely when gamification is off. + */ + async evaluate(userId: string, keys?: string[]): Promise { + if (!isGamificationEnabled(this.config, this.flags)) return; + + const definitions = keys + ? keys + .map((key) => ACHIEVEMENTS[key]) + .filter((d): d is AchievementDefinition => d !== undefined) + : ACHIEVEMENT_LIST; + + for (const definition of definitions) { + await this.evaluateOne(userId, definition); + } + } + + private async evaluateOne( + userId: string, + definition: AchievementDefinition, + ): Promise { + if (definition.socialGated && !isSocialEnabled(this.config, this.flags)) + return; + + const already = await this.prisma.userAchievement.findUnique({ + where: { userId_key: { userId, key: definition.key } }, + select: { id: true }, + }); + if (already) return; + + const result = await definition.check(this.prisma, userId); + if (!result.unlocked) return; + + let created; + + try { + created = await this.prisma.userAchievement.create({ + data: { userId, key: definition.key }, + }); + } catch (err) { + // A concurrent evaluate() call (live wiring racing the nightly sweep, + // or two live sites in the same request) hits the unique constraint — + // expected under concurrency, not an error. No XP credit in this case: + // the call that actually created the row already credited it. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + this.logger.debug( + `Achievement ${definition.key} already unlocked for user ${userId} (concurrent)`, + ); + return; + } + + throw err; + } + + await this.xp.award( + userId, + XpReason.ACHIEVEMENT_UNLOCKED, + created.id, + definition.xpAward, + ); + } + + /** + * Unlocked achievements the [G6] unlock-bubble UI hasn't shown yet, oldest + * first (the order the bubble sequence should play them in). Empty list + * rather than an error when gamification is off. + */ + async pending(userId: string): Promise { + if (!isGamificationEnabled(this.config, this.flags)) return []; + + const rows = await this.prisma.userAchievement.findMany({ + where: { userId, displayedAt: null }, + orderBy: { unlockedAt: "asc" }, + }); + if (rows.length === 0) return []; + + // xpAwarded isn't stored on UserAchievement (see the [G2] plan) — looked + // up from the XpEntry each unlock created. + const xpEntries = await this.prisma.xpEntry.findMany({ + where: { + sourceType: "UserAchievement", + sourceId: { in: rows.map((r) => r.id) }, + }, + select: { sourceId: true, amount: true }, + }); + const xpBySourceId = new Map(xpEntries.map((e) => [e.sourceId, e.amount])); + + return rows.map((r) => ({ + id: r.id, + key: r.key, + unlockedAt: r.unlockedAt.toISOString(), + xpAwarded: xpBySourceId.get(r.id) ?? 0, + })); + } + + /** + * Marks one achievement as shown by the unlock-bubble UI. Idempotent (a + * second call is a no-op) and scoped to `userId` — a mismatch or unknown + * id both 404, never revealing whether the id belongs to someone else. + */ + async markDisplayed(userId: string, id: string): Promise { + const achievement = await this.prisma.userAchievement.findUnique({ + where: { id }, + select: { userId: true, displayedAt: true }, + }); + + if (!achievement || achievement.userId !== userId) { + throw new AppException( + HttpStatus.NOT_FOUND, + ErrorCode.GamificationAchievementNotFound, + ); + } + + if (achievement.displayedAt !== null) return; + + await this.prisma.userAchievement.update({ + where: { id }, + data: { displayedAt: new Date() }, + }); + } + + /** + * Nightly safety net: re-evaluates every achievement for every user. This + * is what catches anything not wired to a live award site (see the [G2] + * plan) — including, on its first run after deploy, every existing + * account's history, with no separate backfill script needed (`check()` + * never cares whether a row came from live use or an import). + * + * Full sweep, every user, no activity-based targeting: acceptable while + * the registry only has two achievement families (see the [G2] plan) — + * targeting active users only should be revisited once [G3]'s full + * catalogue makes this loop expensive, not solved preemptively here. + */ + @Cron("0 5 * * *") + async runAchievementsSweepJob(): Promise { + return this.jobRuns.record( + JOB_KEYS.GAMIFICATION_ACHIEVEMENTS_SWEEP, + () => this.sweepAllUsers(), + (summary) => summary, + ); + } + + private async sweepAllUsers(): Promise { + const users = await this.prisma.user.findMany({ select: { id: true } }); + + for (const user of users) { + await this.evaluate(user.id); + } + + return `Swept ${users.length} user(s)`; + } +} diff --git a/apps/api/src/gamification/achievements/achievements.controller.spec.ts b/apps/api/src/gamification/achievements/achievements.controller.spec.ts new file mode 100644 index 00000000..b489182d --- /dev/null +++ b/apps/api/src/gamification/achievements/achievements.controller.spec.ts @@ -0,0 +1,59 @@ +import { vi } from "vitest"; +import type { AchievementService } from "./achievement.service"; +import { AchievementsController } from "./achievements.controller"; + +function makeController() { + const achievements = { + pending: vi.fn().mockResolvedValue([]), + markDisplayed: vi.fn(), + } as unknown as AchievementService; + + const controller = new AchievementsController(achievements); + return { controller, achievements }; +} + +const USER = { sub: "user-1" } as never; + +describe("AchievementsController.pending", () => { + it("delegates to AchievementService.pending, scoped to the current user", async () => { + const { controller, achievements } = makeController(); + const pending = [ + { + id: "achievement-1", + key: "first_episode", + unlockedAt: "2026-01-01T00:00:00.000Z", + xpAwarded: 50, + }, + ]; + (achievements.pending as ReturnType).mockResolvedValue( + pending, + ); + + await expect(controller.pending(USER)).resolves.toEqual(pending); + expect(achievements.pending).toHaveBeenCalledWith("user-1"); + }); +}); + +describe("AchievementsController.markDisplayed", () => { + it("delegates to AchievementService.markDisplayed, scoped to the current user", async () => { + const { controller, achievements } = makeController(); + + await controller.markDisplayed(USER, "achievement-1"); + + expect(achievements.markDisplayed).toHaveBeenCalledWith( + "user-1", + "achievement-1", + ); + }); + + it("propagates the 404 AchievementService throws for someone else's achievement", async () => { + const { controller, achievements } = makeController(); + (achievements.markDisplayed as ReturnType).mockRejectedValue( + new Error("not found"), + ); + + await expect( + controller.markDisplayed(USER, "achievement-1"), + ).rejects.toThrow(); + }); +}); diff --git a/apps/api/src/gamification/achievements/achievements.controller.ts b/apps/api/src/gamification/achievements/achievements.controller.ts new file mode 100644 index 00000000..e722dfef --- /dev/null +++ b/apps/api/src/gamification/achievements/achievements.controller.ts @@ -0,0 +1,36 @@ +import type { PendingAchievementDto } from "@loomkeep/shared"; +import { + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Patch, +} from "@nestjs/common"; +import { ApiOkResponse } from "@nestjs/swagger"; +import { + CurrentUser, + type JwtPayload, +} from "../../auth/decorators/current-user.decorator"; +import { AchievementService } from "./achievement.service"; +import { PendingAchievementResponseDto } from "./dto/pending-achievement-response.dto"; + +@Controller("achievements") +export class AchievementsController { + constructor(private readonly achievements: AchievementService) {} + + @Get("pending") + @ApiOkResponse({ type: PendingAchievementResponseDto, isArray: true }) + pending(@CurrentUser() user: JwtPayload): Promise { + return this.achievements.pending(user.sub); + } + + @HttpCode(HttpStatus.NO_CONTENT) + @Patch(":id/displayed") + async markDisplayed( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + ): Promise { + await this.achievements.markDisplayed(user.sub, id); + } +} diff --git a/apps/api/src/gamification/achievements/dto/pending-achievement-response.dto.ts b/apps/api/src/gamification/achievements/dto/pending-achievement-response.dto.ts new file mode 100644 index 00000000..565bde71 --- /dev/null +++ b/apps/api/src/gamification/achievements/dto/pending-achievement-response.dto.ts @@ -0,0 +1,8 @@ +import type { PendingAchievementDto } from "@loomkeep/shared"; + +export class PendingAchievementResponseDto implements PendingAchievementDto { + id!: string; + key!: string; + unlockedAt!: string; + xpAwarded!: number; +} diff --git a/apps/api/src/gamification/achievements/registry.spec.ts b/apps/api/src/gamification/achievements/registry.spec.ts new file mode 100644 index 00000000..07ff5218 --- /dev/null +++ b/apps/api/src/gamification/achievements/registry.spec.ts @@ -0,0 +1,49 @@ +import { vi } from "vitest"; +import type { PrismaService } from "../../prisma/prisma.service"; +import { checkCinephileTier, checkFirstEpisode } from "./registry"; + +describe("checkFirstEpisode", () => { + it("unlocks once at least one EpisodeWatch exists", async () => { + const prisma = { + episodeWatch: { findFirst: vi.fn().mockResolvedValue({ id: "w1" }) }, + } as unknown as PrismaService; + + await expect(checkFirstEpisode(prisma, "user-1")).resolves.toEqual({ + unlocked: true, + }); + }); + + it("stays locked with no EpisodeWatch", async () => { + const prisma = { + episodeWatch: { findFirst: vi.fn().mockResolvedValue(null) }, + } as unknown as PrismaService; + + await expect(checkFirstEpisode(prisma, "user-1")).resolves.toEqual({ + unlocked: false, + }); + }); +}); + +describe("checkCinephileTier", () => { + it("unlocks once the movie count reaches the tier's target, and reports progress either way", async () => { + const count = vi.fn().mockResolvedValue(10); + const prisma = { libraryEntry: { count } } as unknown as PrismaService; + + await expect(checkCinephileTier(10)(prisma, "user-1")).resolves.toEqual({ + unlocked: true, + progress: { current: 10, target: 10 }, + }); + expect(count).toHaveBeenCalledWith({ + where: { + userId: "user-1", + status: "COMPLETED", + mediaItem: { type: "MOVIE" }, + }, + }); + + await expect(checkCinephileTier(50)(prisma, "user-1")).resolves.toEqual({ + unlocked: false, + progress: { current: 10, target: 50 }, + }); + }); +}); diff --git a/apps/api/src/gamification/achievements/registry.ts b/apps/api/src/gamification/achievements/registry.ts new file mode 100644 index 00000000..0b5f6b15 --- /dev/null +++ b/apps/api/src/gamification/achievements/registry.ts @@ -0,0 +1,109 @@ +import { XpReason } from "@loomkeep/shared"; +import type { PrismaService } from "../../prisma/prisma.service"; + +export interface AchievementCheckResult { + unlocked: boolean; + // Present only for achievements with a progression bar (tiered or + // single-target) — omitted for simple on/off achievements. + progress?: { current: number; target: number }; +} + +/** + * One declarative registry entry: what unlocks it, what it grants, and + * optional metadata for later tickets. Mirrors XP_RULES/XP_RULE_LIST's + * shape (see xp-rules.ts) — a single lookup-by-key registry, plus an array + * for iteration. + */ +export interface AchievementDefinition { + key: string; + // XP credited via XpReason.ACHIEVEMENT_UNLOCKED when this unlocks — varies + // by rarity, see xp-rules.ts's note on this reason. Passed as + // XpService.award's amountOverride — this reason has no fixed amount in + // the barème registry. + xpAward: number; + // A tiered achievement is modelled as several registry entries sharing + // this root (e.g. "cinephile" for cinephile_bronze/_silver/_gold) — for + // display grouping later (G3/G5), not used by the engine itself in this + // ticket. + tierOf?: string; + // Reserved for G3: the slot shows even before unlock, the name/description + // stay hidden until then (enforced by a future screen, not here). No + // achievement in this ticket's MVP catalogue sets this. + secret?: boolean; + // Reserved for G3: mirrors XpRule.socialGated. No achievement in this + // ticket's MVP catalogue sets this. + socialGated?: boolean; + check(prisma: PrismaService, userId: string): Promise; +} + +/** "first_episode": at least one EpisodeWatch exists for the user. */ +export async function checkFirstEpisode( + prisma: PrismaService, + userId: string, +): Promise { + const watch = await prisma.episodeWatch.findFirst({ + where: { userId }, + select: { id: true }, + }); + return { unlocked: watch !== null }; +} + +/** + * Shared core of the three "cinephile" tiers — only the threshold differs. + * Movies watched = LibraryEntry rows at status COMPLETED whose MediaItem is + * type MOVIE (see the [G2] plan's MVP catalogue). + */ +export function checkCinephileTier(target: number) { + return async ( + prisma: PrismaService, + userId: string, + ): Promise => { + const current = await prisma.libraryEntry.count({ + where: { userId, status: "COMPLETED", mediaItem: { type: "MOVIE" } }, + }); + return { unlocked: current >= target, progress: { current, target } }; + }; +} + +export const ACHIEVEMENTS: Record = { + first_episode: { + key: "first_episode", + xpAward: 50, + check: checkFirstEpisode, + }, + cinephile_bronze: { + key: "cinephile_bronze", + tierOf: "cinephile", + xpAward: 50, + check: checkCinephileTier(10), + }, + cinephile_silver: { + key: "cinephile_silver", + tierOf: "cinephile", + xpAward: 150, + check: checkCinephileTier(50), + }, + cinephile_gold: { + key: "cinephile_gold", + tierOf: "cinephile", + xpAward: 400, + check: checkCinephileTier(200), + }, +}; + +/** `ACHIEVEMENTS` as an array, for iteration (the engine, the nightly sweep, tests). */ +export const ACHIEVEMENT_LIST: AchievementDefinition[] = + Object.values(ACHIEVEMENTS); + +/** + * Which registry keys a live XP award site should re-evaluate right after + * crediting XP for that reason — the live-wiring half of the engine (see + * AchievementService.evaluate's callers in LibraryService). Reasons with no + * achievement depending on them are simply absent. + */ +export const ACHIEVEMENT_KEYS_BY_XP_REASON: Partial< + Record +> = { + EPISODE_WATCHED: ["first_episode"], + MOVIE_WATCHED: ["cinephile_bronze", "cinephile_silver", "cinephile_gold"], +}; diff --git a/apps/api/src/gamification/gamification.module.ts b/apps/api/src/gamification/gamification.module.ts index 6795f31d..d1531377 100644 --- a/apps/api/src/gamification/gamification.module.ts +++ b/apps/api/src/gamification/gamification.module.ts @@ -1,13 +1,16 @@ import { Module } from "@nestjs/common"; import { JobsModule } from "../jobs/jobs.module"; +import { AchievementService } from "./achievements/achievement.service"; +import { AchievementsController } from "./achievements/achievements.controller"; import { XpService } from "./xp.service"; -// G1: XP ledger + level curve. No controller yet — this ticket only wires -// the ledger itself and one witness caller (LibraryService); the -// leaderboard/profile endpoints are later tickets ([G7]+). +// G1: XP ledger + level curve. G2 adds the achievement engine (registry in +// achievements/registry.ts) and its /achievements endpoints. Still no +// leaderboard/profile endpoints — later tickets ([G7]+). @Module({ imports: [JobsModule], - providers: [XpService], - exports: [XpService], + controllers: [AchievementsController], + providers: [XpService, AchievementService], + exports: [XpService, AchievementService], }) export class GamificationModule {} diff --git a/apps/api/src/gamification/xp-rules.spec.ts b/apps/api/src/gamification/xp-rules.spec.ts index 955b27a5..921e5d54 100644 --- a/apps/api/src/gamification/xp-rules.spec.ts +++ b/apps/api/src/gamification/xp-rules.spec.ts @@ -8,9 +8,16 @@ describe("XP_RULES", () => { } }); - it("defines a fixed amount for every reason except ADMIN_ADJUSTMENT", () => { + it("defines a fixed amount for every reason except ADMIN_ADJUSTMENT/ACHIEVEMENT_UNLOCKED", () => { + // Both pass XpService.award's amountOverride instead — see xp-rules.ts's + // doc comment on ACHIEVEMENT_UNLOCKED. + const noFixedAmount = new Set([ + XpReason.ADMIN_ADJUSTMENT, + XpReason.ACHIEVEMENT_UNLOCKED, + ]); + for (const rule of Object.values(XP_RULES)) { - if (rule.reason === XpReason.ADMIN_ADJUSTMENT) { + if (noFixedAmount.has(rule.reason)) { expect(rule.amount).toBeUndefined(); } else { expect(rule.amount).toBeGreaterThan(0); diff --git a/apps/api/src/gamification/xp.service.ts b/apps/api/src/gamification/xp.service.ts index 869a9ad6..f7b73968 100644 --- a/apps/api/src/gamification/xp.service.ts +++ b/apps/api/src/gamification/xp.service.ts @@ -42,20 +42,27 @@ export class XpService { * every caller fire-and-forgets this the same way `ActivityService.emit` * is used elsewhere, so a disabled flag or an exhausted cap is never an * error. + * + * `amountOverride` is the one exception to the barème being fixed-amount: + * XpReason.ACHIEVEMENT_UNLOCKED has no `amount` in XP_RULES (it varies by + * achievement tier), so `AchievementService` passes the unlocked + * definition's own `xpAward` here instead. Every other caller omits it. */ async award( userId: string, reason: XpReason, sourceId: string, + amountOverride?: number, ): Promise { if (!isGamificationEnabled(this.config, this.flags)) return; const rule = XP_RULES[reason]; if (rule.socialGated && !isSocialEnabled(this.config, this.flags)) return; - // Only ADMIN_ADJUSTMENT (B8, not this ticket) has no fixed amount — its - // callers will set XpEntry.amount directly rather than going through - // this registry-driven path. - if (rule.amount === undefined) return; + // Only ADMIN_ADJUSTMENT (B8, not this ticket) has no fixed amount and no + // override — its callers will set XpEntry.amount directly rather than + // going through this registry-driven path. + const amount = amountOverride ?? rule.amount; + if (amount === undefined) return; if (rule.dailyCap !== undefined) { const reached = await this.dailyCapReached(userId, reason, rule.dailyCap); @@ -69,7 +76,7 @@ export class XpService { reason, sourceType: rule.sourceType, sourceId, - amount: rule.amount, + amount, }, }); } catch (err) { diff --git a/apps/api/src/import/import-job.service.spec.ts b/apps/api/src/import/import-job.service.spec.ts index d3cfe927..7a26d36e 100644 --- a/apps/api/src/import/import-job.service.spec.ts +++ b/apps/api/src/import/import-job.service.spec.ts @@ -177,6 +177,7 @@ describe("ImportJobService.startAnalyze — premium gating", () => { { isEffectivelyPremium: vi.fn().mockResolvedValue(true), } as unknown as EntitlementService, + stubXp(), ); await service.startAnalyze("u1", "tvtime", { input: "" }); diff --git a/apps/api/src/jobs/job-keys.ts b/apps/api/src/jobs/job-keys.ts index 9c166417..6b32426a 100644 --- a/apps/api/src/jobs/job-keys.ts +++ b/apps/api/src/jobs/job-keys.ts @@ -7,6 +7,7 @@ export const JOB_KEYS = { BACKUP: "backup.run", INACTIVE_ACCOUNTS_SCAN: "users.inactiveAccountsScan", GAMIFICATION_RECONCILE: "gamification.reconcile", + GAMIFICATION_ACHIEVEMENTS_SWEEP: "gamification.achievementsSweep", } as const; export type JobKey = (typeof JOB_KEYS)[keyof typeof JOB_KEYS]; @@ -24,6 +25,8 @@ export const JOB_HEALTHCHECK_ENV: Record = { [JOB_KEYS.BACKUP]: "HEALTHCHECKS_BACKUP_URL", [JOB_KEYS.INACTIVE_ACCOUNTS_SCAN]: "HEALTHCHECKS_INACTIVE_ACCOUNTS_SCAN_URL", [JOB_KEYS.GAMIFICATION_RECONCILE]: "HEALTHCHECKS_GAMIFICATION_RECONCILE_URL", + [JOB_KEYS.GAMIFICATION_ACHIEVEMENTS_SWEEP]: + "HEALTHCHECKS_GAMIFICATION_ACHIEVEMENTS_SWEEP_URL", }; /** Display metadata for the admin "Jobs & tâches" page. */ @@ -58,4 +61,8 @@ export const JOB_REGISTRY: Record = label: "Réconciliation XP (contrôle d'intégrité)", schedule: "Tous les jours à 4h", }, + [JOB_KEYS.GAMIFICATION_ACHIEVEMENTS_SWEEP]: { + label: "Balayage des succès (filet de sécurité)", + schedule: "Tous les jours à 5h", + }, }; diff --git a/apps/api/src/library/library.service.spec.ts b/apps/api/src/library/library.service.spec.ts index c45fcc7c..3afa60ea 100644 --- a/apps/api/src/library/library.service.spec.ts +++ b/apps/api/src/library/library.service.spec.ts @@ -1,6 +1,8 @@ import { vi } from "vitest"; import type { MediaItemService } from "../catalog/media-item.service"; import type { EntitlementService } from "../entitlements/entitlement.service"; +import type { AchievementService } from "../gamification/achievements/achievement.service"; +import { ACHIEVEMENT_KEYS_BY_XP_REASON } from "../gamification/achievements/registry"; import type { XpService } from "../gamification/xp.service"; import type { PrismaService } from "../prisma/prisma.service"; import type { ReviewService } from "../reviews/review.service"; @@ -18,6 +20,14 @@ function stubXp(): XpService { } as unknown as XpService; } +// Stubbed no-op — the [G2] wiring itself is covered by achievement.service.spec.ts, +// these tests only need LibraryService to not blow up calling it. +function stubAchievements(): AchievementService { + return { + evaluate: vi.fn(), + } as unknown as AchievementService; +} + function makeRow(overrides: Partial> = {}) { const id = (overrides.id as string) ?? "entry-1"; return { @@ -135,6 +145,7 @@ function makeService( { emit: vi.fn() } as unknown as ActivityService, {} as EntitlementService, stubXp(), + stubAchievements(), ); return { service, prisma, mediaItemService }; } @@ -309,6 +320,7 @@ describe("LibraryService — finishedAt sync (comment-masking gate)", () => { activity, {} as EntitlementService, stubXp(), + stubAchievements(), ); const result = await service.updateEntry("user-1", "e1", { @@ -356,6 +368,7 @@ describe("LibraryService — finishedAt sync (comment-masking gate)", () => { activity, {} as EntitlementService, stubXp(), + stubAchievements(), ); const result = await service.updateEntry("user-1", "e1", { @@ -413,6 +426,7 @@ describe("LibraryService — finishedAt sync (comment-masking gate)", () => { activity, {} as EntitlementService, stubXp(), + stubAchievements(), ); await service.watchEpisode("user-1", "ep2", {}); @@ -475,6 +489,7 @@ describe("LibraryService.unwatchSeason", () => { activity, {} as EntitlementService, stubXp(), + stubAchievements(), ); await service.unwatchSeason("user-1", "season-1"); @@ -501,6 +516,7 @@ describe("LibraryService.unwatchSeason", () => { { emit: vi.fn() } as unknown as ActivityService, {} as EntitlementService, stubXp(), + stubAchievements(), ); await expect(service.unwatchSeason("user-1", "missing")).rejects.toThrow( @@ -554,6 +570,7 @@ describe("LibraryService.deleteEntry", () => { { emit: vi.fn() } as unknown as ActivityService, {} as EntitlementService, stubXp(), + stubAchievements(), ); await service.deleteEntry("user-1", "entry-1"); @@ -610,6 +627,7 @@ describe("LibraryService.deleteEntry", () => { { emit: vi.fn() } as unknown as ActivityService, {} as EntitlementService, stubXp(), + stubAchievements(), ); await expect( @@ -635,6 +653,7 @@ describe("LibraryService.getCalendarIcs", () => { { emit: vi.fn() } as unknown as ActivityService, entitlements, stubXp(), + stubAchievements(), ); } @@ -694,6 +713,7 @@ describe("LibraryService — XP wiring", () => { }, } as unknown as PrismaService; const xp = stubXp(); + const achievements = stubAchievements(); const service = new LibraryService( prisma, @@ -709,6 +729,7 @@ describe("LibraryService — XP wiring", () => { { emit: vi.fn() } as unknown as ActivityService, {} as EntitlementService, xp, + achievements, ); await service.upsertEntry("user-1", { @@ -721,6 +742,55 @@ describe("LibraryService — XP wiring", () => { expect(xp.award).toHaveBeenCalledWith("user-1", "WORK_ADDED", "entry-1"); expect(xp.award).toHaveBeenCalledWith("user-1", "DOMAIN_STARTED", "MEDIA"); expect(xp.award).toHaveBeenCalledWith("user-1", "MOVIE_WATCHED", "entry-1"); + expect(achievements.evaluate).toHaveBeenCalledWith( + "user-1", + ACHIEVEMENT_KEYS_BY_XP_REASON.MOVIE_WATCHED, + ); + }); + + it("awards MOVIE_WATCHED and evaluates achievements on updateEntry's COMPLETED transition", async () => { + const findUnique = vi.fn().mockResolvedValue({ + userId: "user-1", + status: "PLANNED", + favorite: false, + }); + const update = vi + .fn() + .mockResolvedValue(entryRow({ status: "COMPLETED", type: "MOVIE" })); + const prisma = { + libraryEntry: { findUnique, update }, + episode: { findMany: vi.fn().mockResolvedValue([]) }, + episodeWatch: { + findMany: vi.fn().mockResolvedValue([]), + aggregate: vi.fn().mockResolvedValue({ _max: { watchedAt: null } }), + }, + } as unknown as PrismaService; + const xp = stubXp(); + const achievements = stubAchievements(); + + const service = new LibraryService( + prisma, + {} as MediaItemService, + {} as AgeGateService, + { + getRating: vi.fn().mockResolvedValue(null), + } as unknown as ReviewService, + { emit: vi.fn() } as unknown as ActivityService, + {} as EntitlementService, + xp, + achievements, + ); + + await service.updateEntry("user-1", "entry-1", { + status: "COMPLETED", + finishedAt: "2026-08-01T00:00:00.000Z", + } as never); + + expect(xp.award).toHaveBeenCalledWith("user-1", "MOVIE_WATCHED", "entry-1"); + expect(achievements.evaluate).toHaveBeenCalledWith( + "user-1", + ACHIEVEMENT_KEYS_BY_XP_REASON.MOVIE_WATCHED, + ); }); it("does not award WORK_ADDED/DOMAIN_STARTED on an update (before !== null)", async () => { @@ -737,6 +807,7 @@ describe("LibraryService — XP wiring", () => { }, } as unknown as PrismaService; const xp = stubXp(); + const achievements = stubAchievements(); const service = new LibraryService( prisma, @@ -752,6 +823,7 @@ describe("LibraryService — XP wiring", () => { { emit: vi.fn() } as unknown as ActivityService, {} as EntitlementService, xp, + achievements, ); await service.upsertEntry("user-1", { @@ -804,6 +876,7 @@ describe("LibraryService — XP wiring", () => { }, } as unknown as PrismaService; const xp = stubXp(); + const achievements = stubAchievements(); const service = new LibraryService( prisma, @@ -815,6 +888,7 @@ describe("LibraryService — XP wiring", () => { { emit: vi.fn() } as unknown as ActivityService, {} as EntitlementService, xp, + achievements, ); await service.addReplay("user-1", "entry-1", {} as never); @@ -876,6 +950,7 @@ describe("LibraryService — XP wiring", () => { }, } as unknown as PrismaService; const xp = stubXp(); + const achievements = stubAchievements(); const service = new LibraryService( prisma, @@ -885,6 +960,7 @@ describe("LibraryService — XP wiring", () => { { emit: vi.fn() } as unknown as ActivityService, {} as EntitlementService, xp, + achievements, ); await service.watchEpisode("user-1", "ep2", {} as never); @@ -894,5 +970,9 @@ describe("LibraryService — XP wiring", () => { "SEASON_COMPLETED", "season-1", ); + expect(achievements.evaluate).toHaveBeenCalledWith( + "user-1", + ACHIEVEMENT_KEYS_BY_XP_REASON.EPISODE_WATCHED, + ); }); }); diff --git a/apps/api/src/library/library.service.ts b/apps/api/src/library/library.service.ts index 3189fc8c..bd87e745 100644 --- a/apps/api/src/library/library.service.ts +++ b/apps/api/src/library/library.service.ts @@ -33,6 +33,8 @@ import { MediaItemService } from "../catalog/media-item.service"; import { AppException } from "../common/app.exception"; import { canonicalExternalId } from "../common/external-id.util"; import { EntitlementService } from "../entitlements/entitlement.service"; +import { AchievementService } from "../gamification/achievements/achievement.service"; +import { ACHIEVEMENT_KEYS_BY_XP_REASON } from "../gamification/achievements/registry"; import { isSeasonComplete, isSeriesComplete, @@ -156,6 +158,7 @@ export class LibraryService { private readonly activity: ActivityService, private readonly entitlements: EntitlementService, private readonly xp: XpService, + private readonly achievements: AchievementService, ) {} /** First touch of a media persists it (on-demand cache), then upserts the entry. */ @@ -219,6 +222,10 @@ export class LibraryService { entry.status === "COMPLETED" ) { await this.xp.award(userId, XpReason.MOVIE_WATCHED, entry.id); + await this.achievements.evaluate( + userId, + ACHIEVEMENT_KEYS_BY_XP_REASON[XpReason.MOVIE_WATCHED], + ); } // The /10 rating lives in Review (the single source of truth). @@ -386,6 +393,10 @@ export class LibraryService { entry.status === "COMPLETED" ) { await this.xp.award(userId, XpReason.MOVIE_WATCHED, entry.id); + await this.achievements.evaluate( + userId, + ACHIEVEMENT_KEYS_BY_XP_REASON[XpReason.MOVIE_WATCHED], + ); } if (dto.rating !== undefined) { @@ -646,6 +657,10 @@ export class LibraryService { }); await this.xp.award(userId, XpReason.EPISODE_WATCHED, watch.id); + await this.achievements.evaluate( + userId, + ACHIEVEMENT_KEYS_BY_XP_REASON[XpReason.EPISODE_WATCHED], + ); await this.syncSeasonAndSeriesXp(userId, [episode.seasonId]); await this.syncFinishedAt( diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 8916c6eb..c175c96c 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -1,5 +1,5 @@ -import { defineConfig, includeIgnoreFile } from "eslint/config"; import svelte from "eslint-plugin-svelte"; +import { defineConfig, includeIgnoreFile } from "eslint/config"; import path from "path"; import ts from "typescript-eslint"; import { baseConfig } from "../../eslint.config.base.mjs"; diff --git a/apps/web/messages/en/errors.json b/apps/web/messages/en/errors.json index c61a7083..f9ce0cca 100644 --- a/apps/web/messages/en/errors.json +++ b/apps/web/messages/en/errors.json @@ -57,6 +57,7 @@ "apierr_import_archive_unreadable": "The archive couldn't be read.", "apierr_import_archive_missing_files": "The archive is missing required files.", "apierr_import_archive_malformed": "The archive couldn't be read — one of its files is malformed.", + "apierr_gamification_achievement_not_found": "This achievement could not be found.", "apierr_validation_invalid_param": "Invalid parameter.", "apierr_validation_failed": "The form contains errors.", "apierr_internal_error": "Something went wrong.", diff --git a/apps/web/messages/fr/errors.json b/apps/web/messages/fr/errors.json index a0e007cb..0f477bbf 100644 --- a/apps/web/messages/fr/errors.json +++ b/apps/web/messages/fr/errors.json @@ -57,6 +57,7 @@ "apierr_import_archive_unreadable": "L'archive est illisible.", "apierr_import_archive_missing_files": "Il manque des fichiers requis dans l'archive.", "apierr_import_archive_malformed": "L'archive est illisible — un de ses fichiers est mal formé.", + "apierr_gamification_achievement_not_found": "Ce succès est introuvable.", "apierr_validation_invalid_param": "Paramètre invalide.", "apierr_validation_failed": "Le formulaire contient des erreurs.", "apierr_internal_error": "Une erreur inattendue s'est produite.", diff --git a/apps/web/src/lib/api/errors.ts b/apps/web/src/lib/api/errors.ts index a9287c24..ac8a38c0 100644 --- a/apps/web/src/lib/api/errors.ts +++ b/apps/web/src/lib/api/errors.ts @@ -193,6 +193,8 @@ const MESSAGES = { [ErrorCode.ValidationFailed]: () => m.apierr_validation_failed(), [ErrorCode.InternalError]: () => m.apierr_internal_error(), [ErrorCode.NetworkOffline]: () => m.apierr_network_offline(), + [ErrorCode.GamificationAchievementNotFound]: () => + m.apierr_gamification_achievement_not_found(), } satisfies Record string>; /** diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7748f3ed..f4554b54 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -70,6 +70,7 @@ services: HEALTHCHECKS_BACKUP_URL: ${HEALTHCHECKS_BACKUP_URL:-} HEALTHCHECKS_INACTIVE_ACCOUNTS_SCAN_URL: ${HEALTHCHECKS_INACTIVE_ACCOUNTS_SCAN_URL:-} HEALTHCHECKS_GAMIFICATION_RECONCILE_URL: ${HEALTHCHECKS_GAMIFICATION_RECONCILE_URL:-} + HEALTHCHECKS_GAMIFICATION_ACHIEVEMENTS_SWEEP_URL: ${HEALTHCHECKS_GAMIFICATION_ACHIEVEMENTS_SWEEP_URL:-} # Homepage dashboard's user-count widget auth — see docker-compose.homepage.yml. HOMEPAGE_STATS_API_KEY: ${HOMEPAGE_STATS_API_KEY:-} # Quackback feedback widget SSO token signing — must match Quackback's own widget settings. diff --git a/docs/erd.md b/docs/erd.md index 7da39564..c7913a3e 100644 --- a/docs/erd.md +++ b/docs/erd.md @@ -617,6 +617,14 @@ NEW_DEVICE_LOGIN NEW_DEVICE_LOGIN } + "ConsumedRefreshToken" { + String id "🗝️" + String tokenHash + DateTime expiresAt + DateTime createdAt + } + + "UserDevice" { String id "🗝️" String deviceKey @@ -948,6 +956,14 @@ NEW_DEVICE_LOGIN NEW_DEVICE_LOGIN DateTime updatedAt } + + "UserAchievement" { + String id "🗝️" + String key + DateTime unlockedAt + DateTime displayedAt "❓" + } + "UserEntitlement" |o--|| "User" : "user" "UserEntitlement" |o--|| "Plan" : "enum:plan" "UserEntitlement" |o--|o "EntitlementSource" : "enum:source" @@ -1008,6 +1024,7 @@ NEW_DEVICE_LOGIN NEW_DEVICE_LOGIN "Notification" }o--|| "User" : "user" "ActivityEvent" }o--|| "User" : "user" "RefreshToken" }o--|| "User" : "user" + "ConsumedRefreshToken" }o--|| "RefreshToken" : "session" "UserDevice" }o--|| "User" : "user" "UserToken" |o--|| "UserTokenType" : "enum:type" "UserToken" }o--|| "User" : "user" @@ -1059,4 +1076,5 @@ NEW_DEVICE_LOGIN NEW_DEVICE_LOGIN "MusicEntry" }o--|| "MusicItem" : "musicItem" "XpEntry" }o--|| "User" : "user" "UserScore" |o--|| "User" : "user" + "UserAchievement" }o--|| "User" : "user" ``` diff --git a/packages/shared/src/dto/gamification.ts b/packages/shared/src/dto/gamification.ts new file mode 100644 index 00000000..31f4ab35 --- /dev/null +++ b/packages/shared/src/dto/gamification.ts @@ -0,0 +1,12 @@ +/** + * An unlocked achievement not yet shown to the user by the [G6] unlock-bubble + * UI (`UserAchievement.displayedAt IS NULL`). `xpAwarded` is looked up from + * the matching XpEntry (sourceType "UserAchievement", sourceId = this id) + * rather than stored on `UserAchievement` itself — see the [G2] plan. + */ +export interface PendingAchievementDto { + id: string; + key: string; + unlockedAt: string; + xpAwarded: number; +} diff --git a/packages/shared/src/error-codes.ts b/packages/shared/src/error-codes.ts index 66145ea3..7a06c84a 100644 --- a/packages/shared/src/error-codes.ts +++ b/packages/shared/src/error-codes.ts @@ -157,6 +157,9 @@ export const ErrorCode = { ImportArchiveMissingFiles: "import.archive_missing_files", ImportArchiveMalformed: "import.archive_malformed", + // gamification + GamificationAchievementNotFound: "gamification.achievement_not_found", + // cross-cutting — owned by the infra rather than a single domain ValidationFailed: "validation.failed", InvalidParam: "validation.invalid_param", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 45b3a810..33f80689 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,7 @@ export * from "./dto/config"; export * from "./dto/data-export"; export * from "./dto/entitlement"; export * from "./dto/game"; +export * from "./dto/gamification"; export * from "./dto/import"; export * from "./dto/library"; export * from "./dto/list"; diff --git a/packages/shared/src/xp-rules.ts b/packages/shared/src/xp-rules.ts index bcca7641..fbd70f58 100644 --- a/packages/shared/src/xp-rules.ts +++ b/packages/shared/src/xp-rules.ts @@ -10,8 +10,9 @@ import { XpReason } from "./enums"; * `dailyCap` is omitted only for a reason that is inherently unique by * nature (a one-off milestone, e.g. DOMAIN_STARTED) — every repeatable * reason carries one, calibrated to what's physically plausible in a day. - * `amount` is omitted only for ADMIN_ADJUSTMENT, whose signed amount is - * chosen per grant by an admin, not fixed here. + * `amount` is omitted only for ADMIN_ADJUSTMENT (signed, chosen per grant by + * an admin) and ACHIEVEMENT_UNLOCKED (varies by achievement tier) — both + * pass `XpService.award`'s `amountOverride` instead of a fixed value here. */ export interface XpRule { reason: XpReason; @@ -194,14 +195,12 @@ export const XP_RULES: Record = { sourceType: "User", socialGated: false, }, - // Reserved for G2 — no caller in this ticket. Amount varies by achievement - // tier (50/150/400); XpService.award always takes the amount from this - // registry, so G2 will need a per-grant override path this single number - // can't express — left as a note for that ticket, not solved here. Unique - // per achievement id, so no dailyCap. + // [G2]: amount varies by achievement tier — like ADMIN_ADJUSTMENT below, + // no fixed `amount` here. AchievementService always passes + // XpService.award's `amountOverride` (the definition's own `xpAward`) + // instead. Unique per achievement id, so no dailyCap. ACHIEVEMENT_UNLOCKED: { reason: XpReason.ACHIEVEMENT_UNLOCKED, - amount: 50, sourceType: "UserAchievement", socialGated: false, },