diff --git a/prisma/migrations/20260820161759_progression_xp_missions_coins/migration.sql b/prisma/migrations/20260820161759_progression_xp_missions_coins/migration.sql new file mode 100644 index 00000000..d4e12a74 --- /dev/null +++ b/prisma/migrations/20260820161759_progression_xp_missions_coins/migration.sql @@ -0,0 +1,205 @@ +-- CreateEnum +CREATE TYPE "XpReason" AS ENUM ('EpisodeWatched', 'ProgressStarted', 'ProgressCompleted', 'ReviewAdded', 'ListCreated', 'ListItemAdded', 'FavoriteAdded', 'Followed', 'CommentAdded', 'ReactionAdded', 'StreakBonus', 'MissionCompleted'); + +-- CreateEnum +CREATE TYPE "MissionMetric" AS ENUM ('EpisodesWatched', 'ProgressCompleted', 'ReviewsWritten', 'FavoritesAdded', 'ListsCreated', 'ListItemsAdded', 'UsersFollowed', 'CommentsWritten', 'LevelReached', 'StreakReached', 'ContentTypesReviewed'); + +-- CreateEnum +CREATE TYPE "MissionTier" AS ENUM ('Bronze', 'Silver', 'Gold', 'Platinum'); + +-- CreateEnum +CREATE TYPE "CoinReason" AS ENUM ('LevelUp', 'MissionCompleted', 'AdminGrant'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "ActivityType" ADD VALUE 'LevelUp'; +ALTER TYPE "ActivityType" ADD VALUE 'MissionCompleted'; + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "NotificationType" ADD VALUE 'LevelUp'; +ALTER TYPE "NotificationType" ADD VALUE 'MissionCompleted'; + +-- AlterTable +ALTER TABLE "NotificationPreference" ADD COLUMN "levelUp" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "mission" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "UserXp" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "totalXp" INTEGER NOT NULL DEFAULT 0, + "level" INTEGER NOT NULL DEFAULT 1, + "currentStreak" INTEGER NOT NULL DEFAULT 0, + "longestStreak" INTEGER NOT NULL DEFAULT 0, + "lastActiveDate" DATE, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserXp_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserContentXp" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "contentType" "ContentType" NOT NULL, + "xp" INTEGER NOT NULL DEFAULT 0, + "level" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserContentXp_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "XpLedger" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "reason" "XpReason" NOT NULL, + "contentType" "ContentType", + "amount" INTEGER NOT NULL, + "sourceKey" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "XpLedger_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Mission" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "metric" "MissionMetric" NOT NULL, + "contentType" "ContentType", + "target" INTEGER NOT NULL, + "xpReward" INTEGER NOT NULL DEFAULT 0, + "coinReward" INTEGER NOT NULL DEFAULT 0, + "cosmeticKey" TEXT, + "medalId" TEXT, + "tier" "MissionTier" NOT NULL DEFAULT 'Bronze', + "position" INTEGER NOT NULL DEFAULT 0, + "hidden" BOOLEAN NOT NULL DEFAULT false, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Mission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserMission" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "missionId" TEXT NOT NULL, + "progress" INTEGER NOT NULL DEFAULT 0, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserMission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserWallet" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "balance" INTEGER NOT NULL DEFAULT 0, + "lifetimeEarned" INTEGER NOT NULL DEFAULT 0, + "lifetimeSpent" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserWallet_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CoinLedger" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "reason" "CoinReason" NOT NULL, + "amount" INTEGER NOT NULL, + "sourceKey" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CoinLedger_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserXp_userId_key" ON "UserXp"("userId"); + +-- CreateIndex +CREATE INDEX "UserXp_level_idx" ON "UserXp"("level"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserContentXp_userId_contentType_key" ON "UserContentXp"("userId", "contentType"); + +-- CreateIndex +CREATE INDEX "XpLedger_userId_createdAt_idx" ON "XpLedger"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "XpLedger_userId_reason_createdAt_idx" ON "XpLedger"("userId", "reason", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "XpLedger_userId_sourceKey_key" ON "XpLedger"("userId", "sourceKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "Mission_key_key" ON "Mission"("key"); + +-- CreateIndex +CREATE INDEX "Mission_metric_active_idx" ON "Mission"("metric", "active"); + +-- CreateIndex +CREATE INDEX "Mission_active_tier_position_idx" ON "Mission"("active", "tier", "position"); + +-- CreateIndex +CREATE INDEX "UserMission_userId_completedAt_idx" ON "UserMission"("userId", "completedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserMission_userId_missionId_key" ON "UserMission"("userId", "missionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserWallet_userId_key" ON "UserWallet"("userId"); + +-- CreateIndex +CREATE INDEX "CoinLedger_userId_createdAt_idx" ON "CoinLedger"("userId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "CoinLedger_userId_sourceKey_key" ON "CoinLedger"("userId", "sourceKey"); + +-- AddForeignKey +ALTER TABLE "UserXp" ADD CONSTRAINT "UserXp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserContentXp" ADD CONSTRAINT "UserContentXp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "XpLedger" ADD CONSTRAINT "XpLedger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Mission" ADD CONSTRAINT "Mission_medalId_fkey" FOREIGN KEY ("medalId") REFERENCES "Medal"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMission" ADD CONSTRAINT "UserMission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMission" ADD CONSTRAINT "UserMission_missionId_fkey" FOREIGN KEY ("missionId") REFERENCES "Mission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserWallet" ADD CONSTRAINT "UserWallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoinLedger" ADD CONSTRAINT "CoinLedger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260820173305_add_cosmetic_shop/migration.sql b/prisma/migrations/20260820173305_add_cosmetic_shop/migration.sql new file mode 100644 index 00000000..0cc91fe1 --- /dev/null +++ b/prisma/migrations/20260820173305_add_cosmetic_shop/migration.sql @@ -0,0 +1,27 @@ +-- CreateEnum +CREATE TYPE "CosmeticType" AS ENUM ('ProfileColor', 'AvatarFrame', 'ProfileTitle', 'BannerEffect'); + +-- AlterEnum +ALTER TYPE "CoinReason" ADD VALUE 'Purchase'; + +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "avatarFrame" TEXT, +ADD COLUMN "bannerEffect" TEXT, +ADD COLUMN "title" TEXT; + +-- CreateTable +CREATE TABLE "UserCosmetic" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "CosmeticType" NOT NULL, + "key" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserCosmetic_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserCosmetic_userId_type_key_key" ON "UserCosmetic"("userId", "type", "key"); + +-- AddForeignKey +ALTER TABLE "UserCosmetic" ADD CONSTRAINT "UserCosmetic_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260820195434_setup_item_type/migration.sql b/prisma/migrations/20260820195434_setup_item_type/migration.sql new file mode 100644 index 00000000..1a0942fe --- /dev/null +++ b/prisma/migrations/20260820195434_setup_item_type/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "SetupItemType" AS ENUM ('COMPONENT', 'TITLE', 'DIVIDER'); + +-- AlterTable +ALTER TABLE "SetupItem" ADD COLUMN "type" "SetupItemType" NOT NULL DEFAULT 'COMPONENT', +ALTER COLUMN "name" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11066b27..a78aa28d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,6 +69,13 @@ model User { notificationsSent Notification[] @relation("NotificationActor") notificationPreference NotificationPreference? statusTransitionLogs StatusTransitionLog[] + xp UserXp? + wallet UserWallet? + contentXp UserContentXp[] + xpLedger XpLedger[] + coinLedger CoinLedger[] + missions UserMission[] + cosmetics UserCosmetic[] @@unique([email]) @@unique([username]) @@ -149,6 +156,9 @@ model Profile { id String @id @default(uuid(7)) userId String @unique color String? @default("#10b981") + avatarFrame String? + title String? + bannerEffect String? language String? @default("en-US") timezone String? watchRegion String? @default("US") @@ -179,15 +189,22 @@ model SetupPhoto { @@index([profileId]) } +enum SetupItemType { + COMPONENT + TITLE + DIVIDER +} + model SetupItem { - id String @id @default(uuid(7)) + id String @id @default(uuid(7)) profileId String - name String + type SetupItemType @default(COMPONENT) + name String? brand String? link String? - position Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + position Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) @@ -202,6 +219,7 @@ model Medal { updatedAt DateTime @updatedAt userMedals UserMedal[] + missions Mission[] @@unique([name]) } @@ -347,6 +365,8 @@ enum NotificationType { NewChapterReleased NewGameReleased SequelAdded + LevelUp + MissionCompleted } model Notification { @@ -398,6 +418,8 @@ model NotificationPreference { gameRelease Boolean @default(true) reopenedCompleted Boolean @default(true) sequelAdded Boolean @default(true) + levelUp Boolean @default(true) + mission Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -417,6 +439,8 @@ enum ActivityType { Watched Followed MedalEarned + LevelUp + MissionCompleted } model Activity { @@ -1405,3 +1429,179 @@ model CatchupAuditLog { @@index([runId]) @@index([action, createdAt]) } + +enum XpReason { + EpisodeWatched + ProgressStarted + ProgressCompleted + ReviewAdded + ListCreated + ListItemAdded + FavoriteAdded + Followed + CommentAdded + ReactionAdded + StreakBonus + MissionCompleted +} + +model UserXp { + id String @id @default(uuid(7)) + userId String @unique + totalXp Int @default(0) + level Int @default(1) + currentStreak Int @default(0) + longestStreak Int @default(0) + lastActiveDate DateTime? @db.Date + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([level]) +} + +model UserContentXp { + id String @id @default(uuid(7)) + userId String + contentType ContentType + xp Int @default(0) + level Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, contentType]) +} + +model XpLedger { + id String @id @default(uuid(7)) + userId String + reason XpReason + contentType ContentType? + amount Int + sourceKey String + metadata Json? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, sourceKey]) + @@index([userId, createdAt]) + @@index([userId, reason, createdAt]) +} + +enum MissionMetric { + EpisodesWatched + ProgressCompleted + ReviewsWritten + FavoritesAdded + ListsCreated + ListItemsAdded + UsersFollowed + CommentsWritten + LevelReached + StreakReached + ContentTypesReviewed +} + +enum MissionTier { + Bronze + Silver + Gold + Platinum +} + +model Mission { + id String @id @default(uuid(7)) + key String @unique + metric MissionMetric + contentType ContentType? + target Int + xpReward Int @default(0) + coinReward Int @default(0) + cosmeticKey String? + medalId String? + tier MissionTier @default(Bronze) + position Int @default(0) + hidden Boolean @default(false) + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + medal Medal? @relation(fields: [medalId], references: [id], onDelete: SetNull) + userMissions UserMission[] + + @@index([metric, active]) + @@index([active, tier, position]) +} + +model UserMission { + id String @id @default(uuid(7)) + userId String + missionId String + progress Int @default(0) + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + mission Mission @relation(fields: [missionId], references: [id], onDelete: Cascade) + + @@unique([userId, missionId]) + @@index([userId, completedAt]) +} + +enum CoinReason { + LevelUp + MissionCompleted + AdminGrant + Purchase +} + +model UserWallet { + id String @id @default(uuid(7)) + userId String @unique + balance Int @default(0) + lifetimeEarned Int @default(0) + lifetimeSpent Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model CoinLedger { + id String @id @default(uuid(7)) + userId String + reason CoinReason + amount Int + sourceKey String + metadata Json? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, sourceKey]) + @@index([userId, createdAt]) +} + +enum CosmeticType { + ProfileColor + AvatarFrame + ProfileTitle + BannerEffect +} + +model UserCosmetic { + id String @id @default(uuid(7)) + userId String + type CosmeticType + key String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, type, key]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 9629b3b8..b2edc734 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -4,7 +4,7 @@ import { PrismaPg } from "@prisma/adapter-pg"; import { Pool } from "pg"; import { v7 as uuid } from "uuid"; -import { PrismaClient, UserRole } from "./generated/client"; +import { ContentType, MissionMetric, MissionTier, PrismaClient, UserRole } from "./generated/client"; const nodeEnv = process.env.NODE_ENV; @@ -26,6 +26,7 @@ const prisma = new PrismaClient({ adapter }); async function main() { await populateMedals(prisma); + await populateMissions(prisma); if (isDev) { await createFirstUser(prisma); @@ -43,6 +44,23 @@ export async function populateMedals(prisma: PrismaClient) { name: "staff", imageUrl: "https://i.ibb.co/99PS2x8m/logo.png", }, + // Marcos de level, concedidos automaticamente pelo XpProcessor. + { + name: "level-10", + imageUrl: "https://i.ibb.co/99PS2x8m/logo.png", + }, + { + name: "level-25", + imageUrl: "https://i.ibb.co/99PS2x8m/logo.png", + }, + { + name: "level-50", + imageUrl: "https://i.ibb.co/99PS2x8m/logo.png", + }, + { + name: "level-100", + imageUrl: "https://i.ibb.co/99PS2x8m/logo.png", + }, ], skipDuplicates: true, }); @@ -52,6 +70,132 @@ export async function populateMedals(prisma: PrismaClient) { } } +// Recompensa por tier. Manter em tabela evita valores soltos linha a linha. +const MISSION_TIER_REWARD: Record = { + [MissionTier.Bronze]: { xpReward: 50, coinReward: 5 }, + [MissionTier.Silver]: { xpReward: 150, coinReward: 15 }, + [MissionTier.Gold]: { xpReward: 400, coinReward: 40 }, + [MissionTier.Platinum]: { xpReward: 1000, coinReward: 100 }, +}; + +type MissionSeed = { + key: string; + metric: MissionMetric; + target: number; + tier: MissionTier; + contentType?: ContentType; + cosmeticKey?: string; + hidden?: boolean; +}; + +// Catálogo inicial. Depois daqui, o CRUD admin (/admin/missions) é a fonte da +// verdade — o seed usa skipDuplicates para nunca sobrescrever ajuste manual. +const MISSION_CATALOG: MissionSeed[] = [ + { key: "watch_10_episodes", metric: MissionMetric.EpisodesWatched, target: 10, tier: MissionTier.Bronze }, + { key: "watch_100_episodes", metric: MissionMetric.EpisodesWatched, target: 100, tier: MissionTier.Silver }, + { key: "watch_500_episodes", metric: MissionMetric.EpisodesWatched, target: 500, tier: MissionTier.Gold }, + { + key: "watch_1000_episodes", + metric: MissionMetric.EpisodesWatched, + target: 1000, + tier: MissionTier.Platinum, + cosmeticKey: "otaku", + }, + + { key: "complete_1_title", metric: MissionMetric.ProgressCompleted, target: 1, tier: MissionTier.Bronze }, + { key: "complete_25_titles", metric: MissionMetric.ProgressCompleted, target: 25, tier: MissionTier.Silver }, + { key: "complete_100_titles", metric: MissionMetric.ProgressCompleted, target: 100, tier: MissionTier.Gold }, + { key: "complete_500_titles", metric: MissionMetric.ProgressCompleted, target: 500, tier: MissionTier.Platinum }, + { + key: "complete_50_animes", + metric: MissionMetric.ProgressCompleted, + target: 50, + tier: MissionTier.Silver, + contentType: ContentType.Anime, + }, + { + key: "complete_50_games", + metric: MissionMetric.ProgressCompleted, + target: 50, + tier: MissionTier.Silver, + contentType: ContentType.Game, + }, + + { key: "write_1_review", metric: MissionMetric.ReviewsWritten, target: 1, tier: MissionTier.Bronze }, + { key: "write_10_reviews", metric: MissionMetric.ReviewsWritten, target: 10, tier: MissionTier.Silver }, + { key: "write_50_reviews", metric: MissionMetric.ReviewsWritten, target: 50, tier: MissionTier.Gold }, + { + key: "write_100_reviews", + metric: MissionMetric.ReviewsWritten, + target: 100, + tier: MissionTier.Platinum, + cosmeticKey: "critic", + }, + { + key: "review_every_content_type", + metric: MissionMetric.ContentTypesReviewed, + target: 6, + tier: MissionTier.Gold, + cosmeticKey: "omnivore", + }, + + { key: "favorite_5_titles", metric: MissionMetric.FavoritesAdded, target: 5, tier: MissionTier.Bronze }, + { key: "favorite_25_titles", metric: MissionMetric.FavoritesAdded, target: 25, tier: MissionTier.Silver }, + + { key: "create_1_list", metric: MissionMetric.ListsCreated, target: 1, tier: MissionTier.Bronze }, + { key: "create_10_lists", metric: MissionMetric.ListsCreated, target: 10, tier: MissionTier.Silver }, + { key: "add_50_list_items", metric: MissionMetric.ListItemsAdded, target: 50, tier: MissionTier.Bronze }, + { key: "add_500_list_items", metric: MissionMetric.ListItemsAdded, target: 500, tier: MissionTier.Gold }, + + { key: "follow_1_user", metric: MissionMetric.UsersFollowed, target: 1, tier: MissionTier.Bronze }, + { key: "follow_10_users", metric: MissionMetric.UsersFollowed, target: 10, tier: MissionTier.Silver }, + { key: "write_1_comment", metric: MissionMetric.CommentsWritten, target: 1, tier: MissionTier.Bronze }, + { key: "write_50_comments", metric: MissionMetric.CommentsWritten, target: 50, tier: MissionTier.Silver }, + + { key: "reach_level_10", metric: MissionMetric.LevelReached, target: 10, tier: MissionTier.Bronze }, + { key: "reach_level_25", metric: MissionMetric.LevelReached, target: 25, tier: MissionTier.Silver }, + { key: "reach_level_50", metric: MissionMetric.LevelReached, target: 50, tier: MissionTier.Gold }, + { key: "reach_level_100", metric: MissionMetric.LevelReached, target: 100, tier: MissionTier.Platinum }, + + { key: "streak_7_days", metric: MissionMetric.StreakReached, target: 7, tier: MissionTier.Bronze }, + { + key: "streak_30_days", + metric: MissionMetric.StreakReached, + target: 30, + tier: MissionTier.Silver, + cosmeticKey: "devoted", + }, + { key: "streak_100_days", metric: MissionMetric.StreakReached, target: 100, tier: MissionTier.Gold }, + { + key: "streak_365_days", + metric: MissionMetric.StreakReached, + target: 365, + tier: MissionTier.Platinum, + hidden: true, + }, +]; + +export async function populateMissions(prisma: PrismaClient) { + const missions = await prisma.mission.createMany({ + data: MISSION_CATALOG.map((mission, index) => ({ + key: mission.key, + metric: mission.metric, + target: mission.target, + tier: mission.tier, + contentType: mission.contentType ?? null, + cosmeticKey: mission.cosmeticKey ?? null, + hidden: mission.hidden ?? false, + position: index, + ...MISSION_TIER_REWARD[mission.tier], + })), + skipDuplicates: true, + }); + + if (missions.count > 0) { + console.log(`Inserted ${missions.count} missions.`); + } +} + async function createFirstUser(prisma: PrismaClient) { const password = "$2a$12$VFoLlPVUMw.kcjR4L8Cdx.A4UrkBt4CWFZRLIrD1KOohG19Mc3XzC"; // "super-secure-password" diff --git a/scripts/backfill-xp.tsx b/scripts/backfill-xp.tsx new file mode 100644 index 00000000..2de6ff42 --- /dev/null +++ b/scripts/backfill-xp.tsx @@ -0,0 +1,654 @@ +import "dotenv/config"; + +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; + +import { + CoinReason, + ContentType, + MissionMetric, + PrismaClient, + ProgressStatus, + WatchEpisodeStatus, + XpReason, +} from "../prisma/generated/client"; +import { contentLevelFromXp, levelFromXp } from "../src/modules/xp/service/xp-level.util"; +import { XP_REASON_TO_MISSION_METRIC } from "../src/shared/constants/mission"; +import { + COINS_PER_LEVEL, + LEVEL_MILESTONE_MEDAL_NAMES, + LEVEL_MILESTONES, + XP_RULES, + XP_SOURCE_KEYS, +} from "../src/shared/constants/xp"; + +// Reconstrói o histórico de progressão a partir das tabelas de origem, com os +// MESMOS sourceKeys do runtime — @@unique([userId, sourceKey]) torna o script +// idempotente por construção: rodar duas vezes não paga nada duas vezes. +// +// Ignora teto diário de propósito (é backfill, não farming) e não gera +// activities nem notificações — nada de spam retroativo no feed. +// +// Usage: bun run scripts/backfill-xp.tsx + +const connectionString = `${process.env.DATABASE_URL}`; +const pool = new Pool({ connectionString }); + +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const BATCH_SIZE = 1000; + +type LedgerRow = { + userId: string; + reason: XpReason; + contentType: ContentType | null; + amount: number; + sourceKey: string; + createdAt: Date; +}; + +const WATCHED_STATUSES: WatchEpisodeStatus[] = [WatchEpisodeStatus.Watching, WatchEpisodeStatus.Completed]; + +const STARTED_STATUSES: ProgressStatus[] = [ + ProgressStatus.Watching, + ProgressStatus.Playing, + ProgressStatus.Reading, + ProgressStatus.Rewatching, + ProgressStatus.Replaying, + ProgressStatus.Rereading, + ProgressStatus.Paused, + ProgressStatus.Dropped, +]; + +function row( + userId: string, + reason: XpReason, + contentType: ContentType | null, + sourceKey: string, + createdAt: Date, + amount = XP_RULES[reason].amount, +): LedgerRow { + return { userId, reason, contentType, amount, sourceKey, createdAt }; +} + +async function insertLedger(rows: LedgerRow[]) { + let inserted = 0; + + for (let index = 0; index < rows.length; index += BATCH_SIZE) { + const result = await prisma.xpLedger.createMany({ + data: rows.slice(index, index + BATCH_SIZE), + skipDuplicates: true, + }); + + inserted += result.count; + } + + return inserted; +} + +async function collectEpisodeRows(): Promise { + const [animeWatches, tvShowWatches] = await Promise.all([ + prisma.animeEpisodeWatch.findMany({ + where: { status: { in: WATCHED_STATUSES } }, + select: { userId: true, animeId: true, episode: true, createdAt: true }, + }), + prisma.tvShowEpisodeWatch.findMany({ + where: { status: { in: WATCHED_STATUSES } }, + select: { userId: true, tvShowId: true, season: true, episode: true, createdAt: true }, + }), + ]); + + return [ + ...animeWatches.map((watch) => + row( + watch.userId, + XpReason.EpisodeWatched, + ContentType.Anime, + XP_SOURCE_KEYS.episode(ContentType.Anime, watch.animeId, watch.episode), + watch.createdAt, + ), + ), + ...tvShowWatches.map((watch) => + row( + watch.userId, + XpReason.EpisodeWatched, + ContentType.TVShow, + XP_SOURCE_KEYS.episode(ContentType.TVShow, watch.tvShowId, watch.episode), + watch.createdAt, + ), + ), + ]; +} + +const PROGRESS_SOURCES = [ + { model: "animeProgress", idField: "animeId", contentType: ContentType.Anime }, + { model: "mangaProgress", idField: "mangaId", contentType: ContentType.Manga }, + { model: "tvShowProgress", idField: "tvShowId", contentType: ContentType.TVShow }, + { model: "movieProgress", idField: "movieId", contentType: ContentType.Movie }, + { model: "gameProgress", idField: "gameId", contentType: ContentType.Game }, + { model: "bookProgress", idField: "bookId", contentType: ContentType.Book }, +] as const; + +async function collectProgressRows(): Promise { + const rows: LedgerRow[] = []; + + for (const source of PROGRESS_SOURCES) { + const delegate = prisma[source.model] as { + findMany(args: object): Promise[]>; + }; + + const entries = await delegate.findMany({ + where: { status: { in: [...STARTED_STATUSES, ProgressStatus.Completed] } }, + select: { userId: true, status: true, createdAt: true, [source.idField]: true }, + }); + + for (const entry of entries) { + const userId = entry.userId as string; + const mediaId = entry[source.idField] as string; + const createdAt = entry.createdAt as Date; + + // Título completado quase sempre passou por "started" antes; o estado + // intermediário se perdeu, então o backfill paga as duas linhas. + rows.push( + row( + userId, + XpReason.ProgressStarted, + source.contentType, + XP_SOURCE_KEYS.progressStarted(source.contentType, mediaId), + createdAt, + ), + ); + + if (entry.status === ProgressStatus.Completed) { + rows.push( + row( + userId, + XpReason.ProgressCompleted, + source.contentType, + XP_SOURCE_KEYS.progressCompleted(source.contentType, mediaId), + createdAt, + ), + ); + } + } + } + + return rows; +} + +const REVIEW_SOURCES = [ + { model: "animeReview", idField: "animeId", contentType: ContentType.Anime }, + { model: "mangaReview", idField: "mangaId", contentType: ContentType.Manga }, + { model: "tvShowReview", idField: "tvShowId", contentType: ContentType.TVShow }, + { model: "movieReview", idField: "movieId", contentType: ContentType.Movie }, + { model: "gameReview", idField: "gameId", contentType: ContentType.Game }, + { model: "bookReview", idField: "bookId", contentType: ContentType.Book }, +] as const; + +async function collectReviewRows(): Promise { + const rows: LedgerRow[] = []; + + for (const source of REVIEW_SOURCES) { + const delegate = prisma[source.model] as { + findMany(args: object): Promise[]>; + }; + + const entries = await delegate.findMany({ + select: { userId: true, createdAt: true, [source.idField]: true }, + }); + + for (const entry of entries) { + rows.push( + row( + entry.userId as string, + XpReason.ReviewAdded, + source.contentType, + XP_SOURCE_KEYS.review(source.contentType, entry[source.idField] as string), + entry.createdAt as Date, + ), + ); + } + } + + return rows; +} + +async function collectSocialRows(): Promise { + const [favorites, lists, listItems, followings, comments, reactions] = await Promise.all([ + prisma.favorite.findMany(), + prisma.list.findMany({ select: { id: true, userId: true, type: true, createdAt: true } }), + prisma.listItem.findMany({ include: { list: { select: { userId: true, createdAt: true } } } }), + prisma.following.findMany({ select: { followerId: true, followingId: true, createdAt: true } }), + prisma.comment.findMany({ select: { id: true, userId: true, createdAt: true } }), + prisma.reaction.findMany({ select: { id: true, userId: true, createdAt: true } }), + ]); + + const rows: LedgerRow[] = []; + + type MediaRefs = { + animeId: string | null; + mangaId: string | null; + tvShowId: string | null; + movieId: string | null; + gameId: string | null; + bookId: string | null; + }; + + const mediaIdOf = (entity: MediaRefs) => + entity.animeId ?? entity.mangaId ?? entity.tvShowId ?? entity.movieId ?? entity.gameId ?? entity.bookId; + + const contentTypeOf = (entity: MediaRefs): ContentType | null => { + if (entity.animeId) return ContentType.Anime; + if (entity.mangaId) return ContentType.Manga; + if (entity.tvShowId) return ContentType.TVShow; + if (entity.movieId) return ContentType.Movie; + if (entity.gameId) return ContentType.Game; + if (entity.bookId) return ContentType.Book; + return null; + }; + + for (const favorite of favorites) { + const mediaId = mediaIdOf(favorite) ?? favorite.personId; + + if (!mediaId) continue; + + rows.push( + row( + favorite.userId, + XpReason.FavoriteAdded, + contentTypeOf(favorite), + XP_SOURCE_KEYS.favorite(favorite.type, mediaId), + favorite.createdAt, + ), + ); + } + + for (const list of lists) { + rows.push( + row( + list.userId, + XpReason.ListCreated, + list.type as unknown as ContentType, + XP_SOURCE_KEYS.list(list.id), + list.createdAt, + ), + ); + } + + for (const item of listItems) { + const mediaId = mediaIdOf(item); + + if (!mediaId) continue; + + // ListItem não tem createdAt próprio; a data da lista é o melhor proxy. + rows.push( + row( + item.list.userId, + XpReason.ListItemAdded, + contentTypeOf(item), + XP_SOURCE_KEYS.listItem(item.listId, mediaId), + item.list.createdAt, + ), + ); + } + + for (const following of followings) { + rows.push( + row( + following.followerId, + XpReason.Followed, + null, + XP_SOURCE_KEYS.follow(following.followingId), + following.createdAt, + ), + ); + } + + for (const comment of comments) { + rows.push(row(comment.userId, XpReason.CommentAdded, null, XP_SOURCE_KEYS.comment(comment.id), comment.createdAt)); + } + + for (const reaction of reactions) { + rows.push( + row(reaction.userId, XpReason.ReactionAdded, null, XP_SOURCE_KEYS.reaction(reaction.id), reaction.createdAt), + ); + } + + return rows; +} + +// Recalcula UserXp/UserContentXp inteiros a partir do ledger. Streak fica +// intocado: histórico de streak não é reconstruível. +async function recomputeTotals() { + const [globalSums, contentSums] = await Promise.all([ + prisma.xpLedger.groupBy({ by: ["userId"], _sum: { amount: true } }), + prisma.xpLedger.groupBy({ + by: ["userId", "contentType"], + where: { contentType: { not: null } }, + _sum: { amount: true }, + }), + ]); + + for (const entry of globalSums) { + const totalXp = entry._sum.amount ?? 0; + const level = levelFromXp(totalXp); + + await prisma.userXp.upsert({ + where: { userId: entry.userId }, + create: { userId: entry.userId, totalXp, level }, + update: { totalXp, level }, + }); + } + + for (const entry of contentSums) { + if (!entry.contentType) continue; + + const xp = entry._sum.amount ?? 0; + const level = contentLevelFromXp(xp); + + await prisma.userContentXp.upsert({ + where: { userId_contentType: { userId: entry.userId, contentType: entry.contentType } }, + create: { userId: entry.userId, contentType: entry.contentType, xp, level }, + update: { xp, level }, + }); + } + + return globalSums.length; +} + +const METRIC_TO_XP_REASON = Object.fromEntries( + Object.entries(XP_REASON_TO_MISSION_METRIC).map(([reason, metric]) => [metric, reason as XpReason]), +) as Partial>; + +// Mede o progresso de uma missão para todos os usuários de uma vez. +async function measureMission(mission: { + metric: MissionMetric; + contentType: ContentType | null; +}): Promise> { + const progressByUser = new Map(); + + const counterReason = METRIC_TO_XP_REASON[mission.metric]; + + if (counterReason) { + const counts = await prisma.xpLedger.groupBy({ + by: ["userId"], + where: { reason: counterReason, ...(mission.contentType && { contentType: mission.contentType }) }, + _count: { _all: true }, + }); + + for (const entry of counts) { + progressByUser.set(entry.userId, entry._count._all); + } + + return progressByUser; + } + + if (mission.metric === MissionMetric.ContentTypesReviewed) { + const reviewed = await prisma.xpLedger.findMany({ + where: { reason: XpReason.ReviewAdded, contentType: { not: null } }, + distinct: ["userId", "contentType"], + select: { userId: true }, + }); + + for (const entry of reviewed) { + progressByUser.set(entry.userId, (progressByUser.get(entry.userId) ?? 0) + 1); + } + + return progressByUser; + } + + if (mission.metric === MissionMetric.LevelReached) { + if (mission.contentType) { + const levels = await prisma.userContentXp.findMany({ + where: { contentType: mission.contentType }, + select: { userId: true, level: true }, + }); + + for (const entry of levels) { + progressByUser.set(entry.userId, entry.level); + } + } else { + const levels = await prisma.userXp.findMany({ select: { userId: true, level: true } }); + + for (const entry of levels) { + progressByUser.set(entry.userId, entry.level); + } + } + + return progressByUser; + } + + // StreakReached: histórico de streak não existe no backfill. + return progressByUser; +} + +// Sincroniza progresso e fecha missões batidas. Retorna as recém-fechadas para +// o chamador pagar XP/coins/medalha. Loop no chamador: fechar missão paga XP, +// que pode subir level, que pode fechar missão de LevelReached. +async function syncMissions() { + const missions = await prisma.mission.findMany({ where: { active: true } }); + + const closed: { userId: string; missionId: string; xpReward: number; coinReward: number; medalId: string | null }[] = + []; + + for (const mission of missions) { + const progressByUser = await measureMission(mission); + + for (const [userId, progress] of progressByUser) { + const userMission = await prisma.userMission.upsert({ + where: { userId_missionId: { userId, missionId: mission.id } }, + create: { userId, missionId: mission.id, progress }, + update: { progress }, + }); + + if (progress >= mission.target && !userMission.completedAt) { + await prisma.userMission.update({ + where: { id: userMission.id }, + data: { completedAt: new Date() }, + }); + + closed.push({ + userId, + missionId: mission.id, + xpReward: mission.xpReward, + coinReward: mission.coinReward, + medalId: mission.medalId, + }); + } + } + } + + return closed; +} + +async function grantMissionRewards( + closed: { userId: string; missionId: string; xpReward: number; medalId: string | null }[], +) { + const xpRows = closed + .filter((entry) => entry.xpReward > 0) + .map((entry) => + row( + entry.userId, + XpReason.MissionCompleted, + null, + XP_SOURCE_KEYS.mission(entry.missionId), + new Date(), + entry.xpReward, + ), + ); + + await insertLedger(xpRows); + + const medalRows = closed + .filter((entry): entry is typeof entry & { medalId: string } => Boolean(entry.medalId)) + .map((entry) => ({ userId: entry.userId, medalId: entry.medalId })); + + if (medalRows.length > 0) { + await prisma.userMedal.createMany({ data: medalRows, skipDuplicates: true }); + } +} + +async function grantMilestoneMedals() { + const medals = await prisma.medal.findMany({ + where: { name: { in: Object.values(LEVEL_MILESTONE_MEDAL_NAMES) } }, + }); + + const medalByName = new Map(medals.map((medal) => [medal.name, medal])); + const users = await prisma.userXp.findMany({ select: { userId: true, level: true } }); + + const rows: { userId: string; medalId: string }[] = []; + + for (const user of users) { + for (const milestone of LEVEL_MILESTONES) { + const medal = medalByName.get(LEVEL_MILESTONE_MEDAL_NAMES[milestone]); + + if (medal && user.level >= milestone) { + rows.push({ userId: user.userId, medalId: medal.id }); + } + } + } + + if (rows.length > 0) { + await prisma.userMedal.createMany({ data: rows, skipDuplicates: true }); + } +} + +async function backfillCoins() { + const users = await prisma.userXp.findMany({ select: { userId: true, level: true } }); + + const coinRows: { + userId: string; + reason: CoinReason; + amount: number; + sourceKey: string; + }[] = []; + + for (const user of users) { + for (let level = 2; level <= user.level; level++) { + coinRows.push({ + userId: user.userId, + reason: CoinReason.LevelUp, + amount: COINS_PER_LEVEL * level, + sourceKey: XP_SOURCE_KEYS.levelUp(level), + }); + } + } + + const completedMissions = await prisma.userMission.findMany({ + where: { completedAt: { not: null } }, + select: { userId: true, missionId: true, mission: { select: { coinReward: true } } }, + }); + + for (const entry of completedMissions) { + if (entry.mission.coinReward > 0) { + coinRows.push({ + userId: entry.userId, + reason: CoinReason.MissionCompleted, + amount: entry.mission.coinReward, + sourceKey: XP_SOURCE_KEYS.mission(entry.missionId), + }); + } + } + + let inserted = 0; + + for (let index = 0; index < coinRows.length; index += BATCH_SIZE) { + const result = await prisma.coinLedger.createMany({ + data: coinRows.slice(index, index + BATCH_SIZE), + skipDuplicates: true, + }); + + inserted += result.count; + } + + // Carteira derivada do ledger, nunca escrita por fora. + const sums = await prisma.coinLedger.groupBy({ by: ["userId"], _sum: { amount: true } }); + + for (const entry of sums) { + const earned = await prisma.coinLedger.aggregate({ + where: { userId: entry.userId, amount: { gt: 0 } }, + _sum: { amount: true }, + }); + + const spent = await prisma.coinLedger.aggregate({ + where: { userId: entry.userId, amount: { lt: 0 } }, + _sum: { amount: true }, + }); + + const lifetimeEarned = earned._sum.amount ?? 0; + const lifetimeSpent = Math.abs(spent._sum.amount ?? 0); + + await prisma.userWallet.upsert({ + where: { userId: entry.userId }, + create: { userId: entry.userId, balance: lifetimeEarned - lifetimeSpent, lifetimeEarned, lifetimeSpent }, + update: { balance: lifetimeEarned - lifetimeSpent, lifetimeEarned, lifetimeSpent }, + }); + } + + return inserted; +} + +async function main() { + console.log("Collecting historical sources..."); + + const [episodeRows, progressRows, reviewRows, socialRows] = await Promise.all([ + collectEpisodeRows(), + collectProgressRows(), + collectReviewRows(), + collectSocialRows(), + ]); + + const allRows = [...episodeRows, ...progressRows, ...reviewRows, ...socialRows]; + + console.log(`Candidate ledger rows: ${allRows.length}`); + + const inserted = await insertLedger(allRows); + + console.log(`Inserted ${inserted} new xp ledger rows (${allRows.length - inserted} already present).`); + + let users = await recomputeTotals(); + + console.log(`Recomputed totals for ${users} users.`); + + // Fechar missão paga XP, que pode subir level, que fecha missão de + // LevelReached — itera até estabilizar (na prática 2-3 voltas). + let totalClosed = 0; + + for (let pass = 1; pass <= 5; pass++) { + const closed = await syncMissions(); + + if (closed.length === 0) break; + + totalClosed += closed.length; + + console.log(`Pass ${pass}: closed ${closed.length} missions.`); + + await grantMissionRewards(closed); + + users = await recomputeTotals(); + } + + console.log(`Total missions closed: ${totalClosed}`); + + await grantMilestoneMedals(); + + const coins = await backfillCoins(); + + console.log(`Inserted ${coins} new coin ledger rows.`); + console.log("Backfill done."); +} + +main() + .then(async () => { + await prisma.$disconnect(); + await pool.end(); + }) + .catch(async (err) => { + console.error(err); + + await prisma.$disconnect(); + await pool.end(); + + process.exit(1); + }); diff --git a/src/app.module.ts b/src/app.module.ts index 64e496c6..0963daab 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,12 +9,15 @@ import { AnimeModule } from "./modules/anime/anime.module"; import { AuthModule } from "./modules/auth/auth.module"; import { BookModule } from "./modules/book/book.module"; import { CatchupModule } from "./modules/catchup/catchup.module"; +import { CoinModule } from "./modules/coin/coin.module"; import { CommentModule } from "./modules/comment/comment.module"; import { CompanyModule } from "./modules/company/company.module"; +import { CosmeticModule } from "./modules/cosmetic/cosmetic.module"; import { FavoriteModule } from "./modules/favorite/favorite.module"; import { GameModule } from "./modules/game/game.module"; import { ListModule } from "./modules/list/list.module"; import { MangaModule } from "./modules/manga/manga.module"; +import { MissionModule } from "./modules/mission/mission.module"; import { MovieModule } from "./modules/movie/movie.module"; import { NotificationModule } from "./modules/notification/notification.module"; import { PaymentModule } from "./modules/payment/payment.module"; @@ -23,6 +26,7 @@ import { ProfileModule } from "./modules/profile/profile.module"; import { ReactionModule } from "./modules/reaction/reaction.module"; import { TVShowModule } from "./modules/tv-show/tv-show.module"; import { UserModule } from "./modules/user/user.module"; +import { XpModule } from "./modules/xp/xp.module"; import { HttpThrottlerGuard } from "./shared/guards/http-throttler.guard"; import { CacheModule } from "./shared/infra/cache/cache.module"; import { DatabaseModule } from "./shared/infra/database/database.module"; @@ -71,6 +75,10 @@ import { MediaFilterModule } from "./shared/media-filter/media-filter.module"; ListModule, PaymentModule, CatchupModule, + XpModule, + MissionModule, + CoinModule, + CosmeticModule, ], providers: [ { diff --git a/src/modules/activity/activity.utils.ts b/src/modules/activity/activity.utils.ts index a4597bae..4a3d07df 100644 --- a/src/modules/activity/activity.utils.ts +++ b/src/modules/activity/activity.utils.ts @@ -1,4 +1,4 @@ -import { ActivityType, ProgressStatus } from "@prisma/generated/enums"; +import { ActivityType, ProgressStatus, XpReason } from "@prisma/generated/enums"; const STARTED_STATUSES: ProgressStatus[] = [ ProgressStatus.Watching, @@ -31,3 +31,17 @@ export function activityTypeFromProgressStatus(status: ProgressStatus): Activity return null; } + +// Só começar e completar valem XP. Pausar e dropar não pagam nada, e como o +// sourceKey é fixo por mídia, alternar entre statuses não paga duas vezes. +export function xpReasonFromProgressStatus(status: ProgressStatus): XpReason | null { + if (status === ProgressStatus.Completed) { + return XpReason.ProgressCompleted; + } + + if (STARTED_STATUSES.includes(status)) { + return XpReason.ProgressStarted; + } + + return null; +} diff --git a/src/modules/anime/service/anime-episode-watch.service.ts b/src/modules/anime/service/anime-episode-watch.service.ts index ef73006e..e364e473 100644 --- a/src/modules/anime/service/anime-episode-watch.service.ts +++ b/src/modules/anime/service/anime-episode-watch.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@nestjs/common"; -import { WatchEpisodeStatus } from "@prisma/generated/enums"; +import { ContentType, WatchEpisodeStatus, XpReason } from "@prisma/generated/enums"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -75,6 +76,17 @@ export class AnimeEpisodeWatchService { if (watchedEpisodes.length > 0) { await this.queueService.toWatchedActivityJob({ userId, animeId, episodes: watchedEpisodes }); } + + // Um job por episódio: o sourceKey é por episódio, então remarcar o mesmo + // episódio nunca paga de novo. + for (const episode of watchedEpisodes) { + await this.queueService.toXpJob({ + userId, + reason: XpReason.EpisodeWatched, + contentType: ContentType.Anime, + sourceKey: XP_SOURCE_KEYS.episode(ContentType.Anime, animeId, episode), + }); + } } async watchAllAnimeEpisodes({ animeId, userId }: WatchAllAnimeEpisodesDto) { diff --git a/src/modules/anime/service/anime-progress.service.ts b/src/modules/anime/service/anime-progress.service.ts index df91bce2..35ca5e2a 100644 --- a/src/modules/anime/service/anime-progress.service.ts +++ b/src/modules/anime/service/anime-progress.service.ts @@ -1,8 +1,9 @@ import { Injectable } from "@nestjs/common"; -import { ProgressStatus } from "@prisma/generated/enums"; +import { ContentType, ProgressStatus } from "@prisma/generated/enums"; import { AnimeProgressFindManyArgs } from "@prisma/generated/models"; -import { activityTypeFromProgressStatus } from "@/modules/activity/activity.utils"; +import { activityTypeFromProgressStatus, xpReasonFromProgressStatus } from "@/modules/activity/activity.utils"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -81,6 +82,17 @@ export class AnimeProgressService { }); } + const xpReason = xpReasonFromProgressStatus(status); + + if (xpReason) { + await this.queueService.toXpJob({ + userId, + reason: xpReason, + contentType: ContentType.Anime, + sourceKey: XP_SOURCE_KEYS.progress(xpReason, ContentType.Anime, animeId), + }); + } + if (status === ProgressStatus.Completed) { await this.animeEpisodeWatchService.watchAllAnimeEpisodes({ animeId, userId }); } diff --git a/src/modules/anime/service/anime-review.service.ts b/src/modules/anime/service/anime-review.service.ts index 7cf5f32b..133ac380 100644 --- a/src/modules/anime/service/anime-review.service.ts +++ b/src/modules/anime/service/anime-review.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType } from "@prisma/generated/enums"; +import { ActivityType, ContentType, XpReason } from "@prisma/generated/enums"; import { AnimeReviewFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -65,6 +66,13 @@ export class AnimeReviewService { animeReviewId: animeReview.id, metadata: { ...animeReview }, }); + + await this.queueService.toXpJob({ + userId: createAnimeReviewDto.userId, + reason: XpReason.ReviewAdded, + contentType: ContentType.Anime, + sourceKey: XP_SOURCE_KEYS.review(ContentType.Anime, createAnimeReviewDto.animeId), + }); } async getAnimeReviewById(animeReviewId: string) { diff --git a/src/modules/book/service/book-progress.service.ts b/src/modules/book/service/book-progress.service.ts index 101e1d7e..d2d9095a 100644 --- a/src/modules/book/service/book-progress.service.ts +++ b/src/modules/book/service/book-progress.service.ts @@ -1,7 +1,9 @@ import { Injectable } from "@nestjs/common"; +import { ContentType } from "@prisma/generated/enums"; import { BookProgressFindManyArgs } from "@prisma/generated/models"; -import { activityTypeFromProgressStatus } from "@/modules/activity/activity.utils"; +import { activityTypeFromProgressStatus, xpReasonFromProgressStatus } from "@/modules/activity/activity.utils"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -79,6 +81,17 @@ export class BookProgressService { metadata: { ...bookProgress }, }); } + + const xpReason = xpReasonFromProgressStatus(status); + + if (xpReason) { + await this.queueService.toXpJob({ + userId, + reason: xpReason, + contentType: ContentType.Book, + sourceKey: XP_SOURCE_KEYS.progress(xpReason, ContentType.Book, bookId), + }); + } } async deleteBookProgress(bookProgressId: string, userId: string) { diff --git a/src/modules/book/service/book-review.service.ts b/src/modules/book/service/book-review.service.ts index eb1bea18..97c94ec0 100644 --- a/src/modules/book/service/book-review.service.ts +++ b/src/modules/book/service/book-review.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType } from "@prisma/generated/enums"; +import { ActivityType, ContentType, XpReason } from "@prisma/generated/enums"; import { BookReviewFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -61,6 +62,13 @@ export class BookReviewService { bookReviewId: bookReview.id, metadata: { ...bookReview }, }); + + await this.queueService.toXpJob({ + userId: createBookReviewDto.userId, + reason: XpReason.ReviewAdded, + contentType: ContentType.Book, + sourceKey: XP_SOURCE_KEYS.review(ContentType.Book, createBookReviewDto.bookId), + }); } async getBookReviewById(bookReviewId: string) { diff --git a/src/modules/catchup/catchup.module.ts b/src/modules/catchup/catchup.module.ts index 025dd0ec..f6a1aae8 100644 --- a/src/modules/catchup/catchup.module.ts +++ b/src/modules/catchup/catchup.module.ts @@ -1,11 +1,11 @@ import { Module } from "@nestjs/common"; import { CatchupController } from "./controller/catchup.controller"; +import { CatchupService } from "./service/catchup.service"; import { CatchupAuditService } from "./service/catchup-audit.service"; import { CatchupFeedService } from "./service/catchup-feed.service"; import { CatchupFlagsService } from "./service/catchup-flags.service"; import { CatchupNotificationService } from "./service/catchup-notification.service"; import { CatchupRunService } from "./service/catchup-run.service"; -import { CatchupService } from "./service/catchup.service"; import { DailyReleaseIngestorService } from "./service/daily-release-ingestor.service"; import { ReleaseEventService } from "./service/release-event.service"; import { ReleaseMatcherService } from "./service/release-matcher.service"; diff --git a/src/modules/catchup/dto/get-catchup-feed.dto.ts b/src/modules/catchup/dto/get-catchup-feed.dto.ts index c5a76740..f8295e12 100644 --- a/src/modules/catchup/dto/get-catchup-feed.dto.ts +++ b/src/modules/catchup/dto/get-catchup-feed.dto.ts @@ -1,7 +1,7 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; +import { CatchupMediaType } from "@prisma/generated/enums"; import { Type } from "class-transformer"; import { IsEnum, IsInt, IsOptional, Max, Min } from "class-validator"; -import { CatchupMediaType } from "@prisma/generated/enums"; import { OffsetPaginationParamsDto } from "@/shared/infra/database/dtos/offset-pagination.dto"; export class GetCatchupFeedDto extends OffsetPaginationParamsDto { diff --git a/src/modules/catchup/service/catchup.service.ts b/src/modules/catchup/service/catchup.service.ts index 76da0ba0..514f5a62 100644 --- a/src/modules/catchup/service/catchup.service.ts +++ b/src/modules/catchup/service/catchup.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { CATCHUP_AUDIT_ACTIONS } from "../constants/catchup.constants"; -import { createEmptyRunStats, CatchupRunStats, NormalizedRelease } from "../types/catchup.types"; +import { CatchupRunStats, createEmptyRunStats, NormalizedRelease } from "../types/catchup.types"; import { CatchupAuditService } from "./catchup-audit.service"; import { CatchupNotificationService } from "./catchup-notification.service"; import { CatchupRunService } from "./catchup-run.service"; diff --git a/src/modules/coin/coin.module.ts b/src/modules/coin/coin.module.ts new file mode 100644 index 00000000..e7eb0de5 --- /dev/null +++ b/src/modules/coin/coin.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { CoinController } from "./controller/coin.controller"; +import { CoinService } from "./service/coin.service"; + +@Module({ + imports: [], + controllers: [CoinController], + providers: [CoinService], + exports: [CoinService], +}) +export class CoinModule {} diff --git a/src/modules/coin/controller/coin.controller.ts b/src/modules/coin/controller/coin.controller.ts new file mode 100644 index 00000000..3fed78aa --- /dev/null +++ b/src/modules/coin/controller/coin.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { AuthGuard, Session, type UserSession } from "@thallesp/nestjs-better-auth"; +import { GetCoinHistoryDto } from "../dto/get-coin-history.dto"; +import { CoinService } from "../service/coin.service"; + +@ApiTags("Coin") +@Controller("/coins") +export class CoinController { + constructor(private readonly coinService: CoinService) {} + + @Get("/me") + @UseGuards(AuthGuard) + async getMyWallet(@Session() session: UserSession) { + const wallet = await this.coinService.getWalletByUserId(session.user.id); + + return { wallet }; + } + + @Get("/history") + @UseGuards(AuthGuard) + async getMyCoinHistory(@Session() session: UserSession, @Query() query: GetCoinHistoryDto) { + const history = await this.coinService.getCoinHistory(session.user.id, query); + + return { history }; + } +} diff --git a/src/modules/coin/dto/get-coin-history.dto.ts b/src/modules/coin/dto/get-coin-history.dto.ts new file mode 100644 index 00000000..f34dd5fd --- /dev/null +++ b/src/modules/coin/dto/get-coin-history.dto.ts @@ -0,0 +1,3 @@ +import { OffsetPaginationParamsDto } from "@/shared/infra/database/dtos/offset-pagination.dto"; + +export class GetCoinHistoryDto extends OffsetPaginationParamsDto {} diff --git a/src/modules/coin/dto/grant-coins.dto.ts b/src/modules/coin/dto/grant-coins.dto.ts new file mode 100644 index 00000000..89165a6a --- /dev/null +++ b/src/modules/coin/dto/grant-coins.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CoinReason } from "@prisma/generated/enums"; +import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; + +export class GrantCoinsDto { + @IsNotEmpty() + @IsUUID() + @ApiProperty({ type: "string", format: "uuid" }) + readonly userId: string; + + @IsEnum(CoinReason) + @IsNotEmpty() + @ApiProperty({ enum: CoinReason }) + readonly reason: CoinReason; + + // Chave de idempotência, mesma ideia do XpLedger. + @IsNotEmpty() + @IsString() + @ApiProperty() + readonly sourceKey: string; + + @IsInt() + @IsNotEmpty() + @ApiProperty() + readonly amount: number; + + @IsOptional() + readonly metadata?: Record; +} diff --git a/src/modules/coin/dto/spend-coins.dto.ts b/src/modules/coin/dto/spend-coins.dto.ts new file mode 100644 index 00000000..9574b593 --- /dev/null +++ b/src/modules/coin/dto/spend-coins.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CoinReason } from "@prisma/generated/enums"; +import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; + +export class SpendCoinsDto { + @IsNotEmpty() + @IsUUID() + @ApiProperty({ type: "string", format: "uuid" }) + readonly userId: string; + + @IsEnum(CoinReason) + @IsNotEmpty() + @ApiProperty({ enum: CoinReason }) + readonly reason: CoinReason; + + // Chave de idempotência, mesma ideia do GrantCoinsDto. + @IsNotEmpty() + @IsString() + @ApiProperty() + readonly sourceKey: string; + + // Valor positivo a debitar; a linha do ledger é gravada negativa. + @IsInt() + @IsNotEmpty() + @ApiProperty() + readonly amount: number; + + @IsOptional() + readonly metadata?: Record; +} diff --git a/src/modules/coin/service/coin.service.ts b/src/modules/coin/service/coin.service.ts new file mode 100644 index 00000000..69a1d527 --- /dev/null +++ b/src/modules/coin/service/coin.service.ts @@ -0,0 +1,127 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/generated/client"; +import { CoinLedgerFindManyArgs } from "@prisma/generated/models"; +import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { AppException } from "@/shared/exceptions/app.exceptions"; +import { DatabaseService } from "@/shared/infra/database/database.service"; +import { GetCoinHistoryDto } from "../dto/get-coin-history.dto"; +import { GrantCoinsDto } from "../dto/grant-coins.dto"; +import { SpendCoinsDto } from "../dto/spend-coins.dto"; + +const UNIQUE_VIOLATION = "P2002"; + +export type CoinGrantResult = { + amount: number; + balance: number; +}; + +export type CoinSpendResult = { + amount: number; + balance: number; +}; + +@Injectable() +export class CoinService { + constructor(private readonly databaseService: DatabaseService) {} + + // Append-only: o saldo em UserWallet é sempre derivado do CoinLedger e nunca + // escrito fora daqui. Retorna null quando o sourceKey já foi pago. + async grantCoins(grantCoinsDto: GrantCoinsDto): Promise { + const { userId, reason, sourceKey, amount, metadata } = grantCoinsDto; + + if (amount <= 0) return null; + + return this.databaseService + .$transaction(async (tx) => { + await tx.coinLedger.create({ + data: { + userId, + reason, + sourceKey, + amount, + ...(metadata && { metadata: { ...metadata } }), + }, + }); + + const wallet = await tx.userWallet.upsert({ + where: { userId }, + create: { userId, balance: amount, lifetimeEarned: amount }, + update: { balance: { increment: amount }, lifetimeEarned: { increment: amount } }, + }); + + return { amount, balance: wallet.balance } satisfies CoinGrantResult; + }) + .catch((error: { code?: string }) => { + if (error?.code === UNIQUE_VIOLATION) return null; + + throw error; + }); + } + + // Débito com as mesmas garantias do grant: sourceKey repetido retorna null + // sem tocar no saldo. Aceita um client de transação para compor com quem + // precisa debitar e criar outra coisa atomicamente (ex.: compra na loja). + async spendCoins(spendCoinsDto: SpendCoinsDto, tx?: Prisma.TransactionClient): Promise { + const { userId, reason, sourceKey, amount, metadata } = spendCoinsDto; + + if (amount <= 0) return null; + + const run = async (client: Prisma.TransactionClient) => { + // Ledger negativo primeiro: o unique de sourceKey estoura antes de + // qualquer decremento, então retry nunca debita duas vezes. + await client.coinLedger.create({ + data: { + userId, + reason, + sourceKey, + amount: -amount, + ...(metadata && { metadata: { ...metadata } }), + }, + }); + + // UPDATE condicional atômico: o WHERE balance >= amount faz parte da + // mesma instrução, então concorrentes serializam no row lock do próprio + // UPDATE e o saldo nunca fica negativo. + const updated = await client.userWallet.updateMany({ + where: { userId, balance: { gte: amount } }, + data: { balance: { decrement: amount }, lifetimeSpent: { increment: amount } }, + }); + + if (updated.count === 0) { + throw new AppException(ERROR_CODES.INSUFFICIENT_BALANCE); + } + + const wallet = await client.userWallet.findUniqueOrThrow({ where: { userId } }); + + return { amount, balance: wallet.balance } satisfies CoinSpendResult; + }; + + const result = tx ? run(tx) : this.databaseService.$transaction(run); + + return result.catch((error: { code?: string }) => { + if (error?.code === UNIQUE_VIOLATION) return null; + + throw error; + }); + } + + async getWalletByUserId(userId: string) { + const wallet = await this.databaseService.userWallet.findUnique({ where: { userId } }); + + return { + balance: wallet?.balance ?? 0, + lifetimeEarned: wallet?.lifetimeEarned ?? 0, + lifetimeSpent: wallet?.lifetimeSpent ?? 0, + }; + } + + async getCoinHistory(userId: string, getCoinHistoryDto: GetCoinHistoryDto) { + return this.databaseService.offsetPagination({ + model: "coinLedger", + page: getCoinHistoryDto.page, + itemsPerPage: getCoinHistoryDto.itemsPerPage, + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + } +} diff --git a/src/modules/comment/service/comment.service.ts b/src/modules/comment/service/comment.service.ts index 73cd1cd8..ecfa8b16 100644 --- a/src/modules/comment/service/comment.service.ts +++ b/src/modules/comment/service/comment.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@nestjs/common"; +import { XpReason } from "@prisma/generated/enums"; import { CommentFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -41,6 +43,12 @@ export class CommentService { }); await this.queueService.toCommentNotificationJob({ commentId: comment.id }); + + await this.queueService.toXpJob({ + userId, + reason: XpReason.CommentAdded, + sourceKey: XP_SOURCE_KEYS.comment(comment.id), + }); } async deleteComment(deleteCommentDto: DeleteCommentDto) { @@ -74,6 +82,13 @@ export class CommentService { select: { id: true, avatarUrl: true, + avatarFrame: true, + title: true, + }, + }, + xp: { + select: { + level: true, }, }, } as const; diff --git a/src/modules/cosmetic/controller/cosmetic.controller.ts b/src/modules/cosmetic/controller/cosmetic.controller.ts new file mode 100644 index 00000000..0541c042 --- /dev/null +++ b/src/modules/cosmetic/controller/cosmetic.controller.ts @@ -0,0 +1,22 @@ +import { Body, Controller, Get, Post, UseGuards } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { AuthGuard, Session, type UserSession } from "@thallesp/nestjs-better-auth"; +import { PurchaseCosmeticDto } from "../dto/purchase-cosmetic.dto"; +import { CosmeticService } from "../service/cosmetic.service"; + +@ApiTags("Cosmetic") +@Controller("/cosmetics") +@UseGuards(AuthGuard) +export class CosmeticController { + constructor(private readonly cosmeticService: CosmeticService) {} + + @Get("/") + async getCosmetics(@Session() session: UserSession) { + return this.cosmeticService.getCatalog(session.user.id); + } + + @Post("/purchase") + async purchaseCosmetic(@Session() session: UserSession, @Body() purchaseCosmeticDto: PurchaseCosmeticDto) { + return this.cosmeticService.purchaseCosmetic(session.user.id, purchaseCosmeticDto); + } +} diff --git a/src/modules/cosmetic/cosmetic.module.ts b/src/modules/cosmetic/cosmetic.module.ts new file mode 100644 index 00000000..c60635bb --- /dev/null +++ b/src/modules/cosmetic/cosmetic.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { CoinModule } from "@/modules/coin/coin.module"; +import { CosmeticController } from "./controller/cosmetic.controller"; +import { CosmeticService } from "./service/cosmetic.service"; + +@Module({ + imports: [CoinModule], + controllers: [CosmeticController], + providers: [CosmeticService], + exports: [CosmeticService], +}) +export class CosmeticModule {} diff --git a/src/modules/cosmetic/dto/purchase-cosmetic.dto.ts b/src/modules/cosmetic/dto/purchase-cosmetic.dto.ts new file mode 100644 index 00000000..90f2c1f4 --- /dev/null +++ b/src/modules/cosmetic/dto/purchase-cosmetic.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CosmeticType } from "@prisma/generated/enums"; +import { IsEnum, IsNotEmpty, IsString } from "class-validator"; + +export class PurchaseCosmeticDto { + @IsEnum(CosmeticType) + @IsNotEmpty() + @ApiProperty({ enum: CosmeticType }) + readonly type: CosmeticType; + + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly key: string; +} diff --git a/src/modules/cosmetic/service/cosmetic.service.ts b/src/modules/cosmetic/service/cosmetic.service.ts new file mode 100644 index 00000000..782f355b --- /dev/null +++ b/src/modules/cosmetic/service/cosmetic.service.ts @@ -0,0 +1,184 @@ +import { Injectable } from "@nestjs/common"; +import { CoinReason, CosmeticType } from "@prisma/generated/enums"; +import { CoinService } from "@/modules/coin/service/coin.service"; +import { + AVATAR_FRAMES, + BANNER_EFFECTS, + COSMETICS_BY_KEY, + type Cosmetic, + type CosmeticUnlockState, + HEX_COLOR_REGEX, + isCosmeticUnlocked, + PROFILE_COLORS, + PROFILE_COLORS_BY_VALUE, + PROFILE_GRADIENT_PREFIX, + PROFILE_TITLES, +} from "@/shared/constants/cosmetics"; +import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { AppException } from "@/shared/exceptions/app.exceptions"; +import { DatabaseService } from "@/shared/infra/database/database.service"; +import { PurchaseCosmeticDto } from "../dto/purchase-cosmetic.dto"; + +type EquippedCosmetics = { + color: string | null; + avatarFrame: string | null; + title: string | null; + bannerEffect: string | null; +}; + +@Injectable() +export class CosmeticService { + constructor( + private readonly databaseService: DatabaseService, + private readonly coinService: CoinService, + ) {} + + async getCatalog(userId: string) { + const [state, profile] = await Promise.all([this.getUnlockState(userId), this.getEquipped(userId)]); + + const toItem = (type: CosmeticType, equippedValue: string | null) => (cosmetic: Cosmetic) => ({ + key: cosmetic.key, + value: cosmetic.value, + unlock: cosmetic.unlock, + unlocked: isCosmeticUnlocked(type, cosmetic, state), + owned: state.ownedKeys.has(`${type}:${cosmetic.key}`), + equipped: equippedValue === cosmetic.value, + }); + + return { + profileColors: PROFILE_COLORS.map(toItem(CosmeticType.ProfileColor, profile.color)), + avatarFrames: AVATAR_FRAMES.map(toItem(CosmeticType.AvatarFrame, profile.avatarFrame ?? "none")), + profileTitles: PROFILE_TITLES.map(toItem(CosmeticType.ProfileTitle, profile.title ?? "none")), + bannerEffects: BANNER_EFFECTS.map(toItem(CosmeticType.BannerEffect, profile.bannerEffect ?? "none")), + }; + } + + // Checagem no cliente é só UX: a cor só entra no perfil se passar por aqui. + // Qualquer hex é livre (custom color); só gradientes exigem posse. + async assertProfileColorUnlocked(userId: string, value: string) { + if (HEX_COLOR_REGEX.test(value)) return; + + if (!value.startsWith(PROFILE_GRADIENT_PREFIX)) { + throw new AppException(ERROR_CODES.COSMETIC_NOT_FOUND); + } + + const cosmetic = PROFILE_COLORS_BY_VALUE.get(value); + + if (!cosmetic) { + throw new AppException(ERROR_CODES.COSMETIC_NOT_FOUND); + } + + const state = await this.getUnlockState(userId); + + if (!isCosmeticUnlocked(CosmeticType.ProfileColor, cosmetic, state)) { + throw new AppException(ERROR_CODES.COSMETIC_LOCKED); + } + } + + // Para os tipos equipados por key (moldura, título, efeito de banner). + // "none" é o estado desequipado e passa sempre. + async assertCosmeticUnlocked(userId: string, type: CosmeticType, key: string) { + if (key === "none") return; + + const cosmetic = COSMETICS_BY_KEY[type].get(key); + + if (!cosmetic) { + throw new AppException(ERROR_CODES.COSMETIC_NOT_FOUND); + } + + const state = await this.getUnlockState(userId); + + if (!isCosmeticUnlocked(type, cosmetic, state)) { + throw new AppException(ERROR_CODES.COSMETIC_LOCKED); + } + } + + async purchaseCosmetic(userId: string, purchaseCosmeticDto: PurchaseCosmeticDto) { + const { type, key } = purchaseCosmeticDto; + + const cosmetic = COSMETICS_BY_KEY[type].get(key); + + if (!cosmetic) { + throw new AppException(ERROR_CODES.COSMETIC_NOT_FOUND); + } + + if (cosmetic.unlock.type !== "purchase") { + throw new AppException(ERROR_CODES.COSMETIC_NOT_PURCHASABLE); + } + + const price = cosmetic.unlock.price; + + const owned = await this.databaseService.userCosmetic.findUnique({ + where: { userId_type_key: { userId, type, key } }, + }); + + if (owned) { + throw new AppException(ERROR_CODES.COSMETIC_ALREADY_OWNED); + } + + await this.databaseService.$transaction(async (tx) => { + const spent = await this.coinService.spendCoins( + { + userId, + reason: CoinReason.Purchase, + sourceKey: `purchase:${type}:${key}`, + amount: price, + metadata: { type, key }, + }, + tx, + ); + + // null = sourceKey já pago: uma compra concorrente ganhou a corrida. + if (!spent) { + throw new AppException(ERROR_CODES.COSMETIC_ALREADY_OWNED); + } + + await tx.userCosmetic.create({ data: { userId, type, key } }); + }); + + const wallet = await this.coinService.getWalletByUserId(userId); + + return { + wallet, + cosmetic: { + key: cosmetic.key, + value: cosmetic.value, + unlock: cosmetic.unlock, + unlocked: true, + owned: true, + equipped: false, + }, + }; + } + + private async getEquipped(userId: string): Promise { + const profile = await this.databaseService.profile.findUnique({ + where: { userId }, + select: { color: true, avatarFrame: true, title: true, bannerEffect: true }, + }); + + return { + color: profile?.color ?? null, + avatarFrame: profile?.avatarFrame ?? null, + title: profile?.title ?? null, + bannerEffect: profile?.bannerEffect ?? null, + }; + } + + private async getUnlockState(userId: string): Promise { + const [xp, completed, owned] = await Promise.all([ + this.databaseService.userXp.findUnique({ where: { userId }, select: { level: true } }), + this.databaseService.userMission.findMany({ + where: { userId, completedAt: { not: null } }, + select: { mission: { select: { key: true } } }, + }), + this.databaseService.userCosmetic.findMany({ where: { userId }, select: { type: true, key: true } }), + ]); + + return { + level: xp?.level ?? 1, + completedMissionKeys: new Set(completed.map(({ mission }) => mission.key)), + ownedKeys: new Set(owned.map(({ type, key }) => `${type}:${key}`)), + }; + } +} diff --git a/src/modules/favorite/service/favorite.service.ts b/src/modules/favorite/service/favorite.service.ts index 220a3be0..ff50c2c4 100644 --- a/src/modules/favorite/service/favorite.service.ts +++ b/src/modules/favorite/service/favorite.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType } from "@prisma/generated/enums"; +import { ActivityType, ContentType, FavoriteType, XpReason } from "@prisma/generated/enums"; import { FavoriteFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -119,6 +120,19 @@ export class FavoriteService { favoriteId: favorite.id, metadata: { ...favorite }, }); + + // Um favorito referencia exatamente uma entidade. Favorito de pessoa conta + // XP, mas não pertence a nenhuma mídia — vai sem contentType. + const mediaId = Object.values(entityIds).find((id): id is string => Boolean(id)); + + if (mediaId) { + await this.queueService.toXpJob({ + userId, + reason: XpReason.FavoriteAdded, + ...(type !== FavoriteType.Person && { contentType: type as unknown as ContentType }), + sourceKey: XP_SOURCE_KEYS.favorite(type, mediaId), + }); + } } async getFavoritesByUserId(getFavoritesByUserIdDto: GetFavoritesByUserIdDto) { diff --git a/src/modules/game/service/game-progress.service.ts b/src/modules/game/service/game-progress.service.ts index 8283011b..0386c63b 100644 --- a/src/modules/game/service/game-progress.service.ts +++ b/src/modules/game/service/game-progress.service.ts @@ -1,7 +1,9 @@ import { Injectable } from "@nestjs/common"; +import { ContentType } from "@prisma/generated/enums"; import { GameProgressFindManyArgs } from "@prisma/generated/models"; -import { activityTypeFromProgressStatus } from "@/modules/activity/activity.utils"; +import { activityTypeFromProgressStatus, xpReasonFromProgressStatus } from "@/modules/activity/activity.utils"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -88,6 +90,17 @@ export class GameProgressService { metadata: { ...gameProgress }, }); } + + const xpReason = xpReasonFromProgressStatus(status); + + if (xpReason) { + await this.queueService.toXpJob({ + userId, + reason: xpReason, + contentType: ContentType.Game, + sourceKey: XP_SOURCE_KEYS.progress(xpReason, ContentType.Game, gameId), + }); + } } private async resolveGamePlatforms(gameId: string, platforms: string[]) { diff --git a/src/modules/game/service/game-review.service.ts b/src/modules/game/service/game-review.service.ts index f88f774c..91d3102d 100644 --- a/src/modules/game/service/game-review.service.ts +++ b/src/modules/game/service/game-review.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType } from "@prisma/generated/enums"; +import { ActivityType, ContentType, XpReason } from "@prisma/generated/enums"; import { GameReviewFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -64,6 +65,13 @@ export class GameReviewService { metadata: { ...gameReview }, }); + await this.queueService.toXpJob({ + userId: createGameReviewDto.userId, + reason: XpReason.ReviewAdded, + contentType: ContentType.Game, + sourceKey: XP_SOURCE_KEYS.review(ContentType.Game, createGameReviewDto.gameId), + }); + return gameReview; } diff --git a/src/modules/list/service/list.service.ts b/src/modules/list/service/list.service.ts index 2f259ddd..6dea1a1c 100644 --- a/src/modules/list/service/list.service.ts +++ b/src/modules/list/service/list.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType } from "@prisma/generated/enums"; +import { ActivityType, ContentType, XpReason } from "@prisma/generated/enums"; import { ListFindManyArgs, ListItemFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService, @@ -135,10 +136,17 @@ export class ListService { listId: list.id, metadata: { ...list }, }); + + await this.queueService.toXpJob({ + userId, + reason: XpReason.ListCreated, + contentType: type as unknown as ContentType, + sourceKey: XP_SOURCE_KEYS.list(list.id), + }); } async addItemToList(addItemToListDto: AddItemToListDto) { - const { listId, userId, position, type: _, ...entityIds } = addItemToListDto; + const { listId, userId, position, type, ...entityIds } = addItemToListDto; const listAlreadyExists = await this.databaseService.list.findFirst({ where: { @@ -246,6 +254,17 @@ export class ListService { listItemId: listItem.id, metadata: { ...listItem }, }); + + const mediaId = Object.values(entityIds).find((id): id is string => Boolean(id)); + + if (mediaId) { + await this.queueService.toXpJob({ + userId, + reason: XpReason.ListItemAdded, + contentType: type as unknown as ContentType, + sourceKey: XP_SOURCE_KEYS.listItem(listId, mediaId), + }); + } } async getListById(listId: string) { diff --git a/src/modules/manga/service/manga-progress.service.ts b/src/modules/manga/service/manga-progress.service.ts index 81cef5d3..5fd567f8 100644 --- a/src/modules/manga/service/manga-progress.service.ts +++ b/src/modules/manga/service/manga-progress.service.ts @@ -1,7 +1,9 @@ import { Injectable } from "@nestjs/common"; +import { ContentType } from "@prisma/generated/enums"; import { MangaProgressFindManyArgs } from "@prisma/generated/models"; -import { activityTypeFromProgressStatus } from "@/modules/activity/activity.utils"; +import { activityTypeFromProgressStatus, xpReasonFromProgressStatus } from "@/modules/activity/activity.utils"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -92,6 +94,17 @@ export class MangaProgressService { metadata: { ...mangaProgress }, }); } + + const xpReason = xpReasonFromProgressStatus(status); + + if (xpReason) { + await this.queueService.toXpJob({ + userId, + reason: xpReason, + contentType: ContentType.Manga, + sourceKey: XP_SOURCE_KEYS.progress(xpReason, ContentType.Manga, mangaId), + }); + } } async deleteMangaProgress(mangaProgressId: string, userId: string) { diff --git a/src/modules/manga/service/manga-review.service.ts b/src/modules/manga/service/manga-review.service.ts index 9bee7724..c66b9e9e 100644 --- a/src/modules/manga/service/manga-review.service.ts +++ b/src/modules/manga/service/manga-review.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType } from "@prisma/generated/enums"; +import { ActivityType, ContentType, XpReason } from "@prisma/generated/enums"; import { MangaReviewFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -63,6 +64,13 @@ export class MangaReviewService { mangaReviewId: mangaReview.id, metadata: { ...mangaReview }, }); + + await this.queueService.toXpJob({ + userId: createMangaReviewDto.userId, + reason: XpReason.ReviewAdded, + contentType: ContentType.Manga, + sourceKey: XP_SOURCE_KEYS.review(ContentType.Manga, createMangaReviewDto.mangaId), + }); } async getMangaReviewById(mangaReviewId: string) { diff --git a/src/modules/mission/controller/mission-admin.controller.ts b/src/modules/mission/controller/mission-admin.controller.ts new file mode 100644 index 00000000..e92246cd --- /dev/null +++ b/src/modules/mission/controller/mission-admin.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { UserRole } from "@prisma/generated/enums"; +import { AuthGuard } from "@thallesp/nestjs-better-auth"; +import { Roles } from "@/shared/decorators/roles.decorator"; +import { RolesGuard } from "@/shared/guards/roles.guard"; +import { CreateMissionDto } from "../dto/create-mission.dto"; +import { UpdateMissionDto } from "../dto/update-mission.dto"; +import { MissionService } from "../service/mission.service"; + +@ApiTags("Mission") +@Controller("/admin/missions") +@UseGuards(AuthGuard, RolesGuard) +@Roles(UserRole.Administrator) +export class MissionAdminController { + constructor(private readonly missionService: MissionService) {} + + @Get("/") + async listMissions() { + const missions = await this.missionService.listMissions(); + + return { missions }; + } + + @Post("/") + async createMission(@Body() body: CreateMissionDto) { + const mission = await this.missionService.createMission(body); + + return { mission }; + } + + @Patch("/:missionId") + async updateMission(@Param("missionId") missionId: string, @Body() body: UpdateMissionDto) { + const mission = await this.missionService.updateMission(missionId, body); + + return { mission }; + } + + @Delete("/:missionId") + @HttpCode(204) + async deleteMission(@Param("missionId") missionId: string) { + await this.missionService.deleteMission(missionId); + } +} diff --git a/src/modules/mission/controller/mission.controller.ts b/src/modules/mission/controller/mission.controller.ts new file mode 100644 index 00000000..efe51e54 --- /dev/null +++ b/src/modules/mission/controller/mission.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Param, UseGuards } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { AuthGuard, Session, type UserSession } from "@thallesp/nestjs-better-auth"; +import { MissionService } from "../service/mission.service"; + +@ApiTags("Mission") +@Controller("/missions") +export class MissionController { + constructor(private readonly missionService: MissionService) {} + + @Get("/me") + @UseGuards(AuthGuard) + async getMyMissions(@Session() session: UserSession) { + const missions = await this.missionService.getMissionsByUserId(session.user.id); + + return { missions }; + } + + @Get("/user/:userId") + async getMissionsByUserId(@Param("userId") userId: string) { + const missions = await this.missionService.getMissionsByUserId(userId); + + return { missions }; + } +} diff --git a/src/modules/mission/dto/create-mission.dto.ts b/src/modules/mission/dto/create-mission.dto.ts new file mode 100644 index 00000000..f85c1b73 --- /dev/null +++ b/src/modules/mission/dto/create-mission.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ContentType, MissionMetric, MissionTier } from "@prisma/generated/enums"; +import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Matches, Min } from "class-validator"; + +export class CreateMissionDto { + // Chave i18n: missions:{key}.name / .description. O banco não guarda texto. + @IsNotEmpty() + @IsString() + @Matches(/^[a-z0-9_]+$/, { message: "key must be lower_snake_case" }) + @ApiProperty() + readonly key: string; + + @IsEnum(MissionMetric) + @IsNotEmpty() + @ApiProperty({ enum: MissionMetric }) + readonly metric: MissionMetric; + + @IsOptional() + @IsEnum(ContentType) + @ApiPropertyOptional({ enum: ContentType }) + readonly contentType?: ContentType; + + @IsInt() + @Min(1) + @ApiProperty() + readonly target: number; + + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional() + readonly xpReward?: number; + + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional() + readonly coinReward?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly cosmeticKey?: string; + + @IsOptional() + @IsUUID("7") + @ApiPropertyOptional() + readonly medalId?: string; + + @IsOptional() + @IsEnum(MissionTier) + @ApiPropertyOptional({ enum: MissionTier }) + readonly tier?: MissionTier; + + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional() + readonly position?: number; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly hidden?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly active?: boolean; +} diff --git a/src/modules/mission/dto/update-mission.dto.ts b/src/modules/mission/dto/update-mission.dto.ts new file mode 100644 index 00000000..951e8676 --- /dev/null +++ b/src/modules/mission/dto/update-mission.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateMissionDto } from "./create-mission.dto"; + +export class UpdateMissionDto extends PartialType(CreateMissionDto) {} diff --git a/src/modules/mission/mission.module.ts b/src/modules/mission/mission.module.ts new file mode 100644 index 00000000..085c7285 --- /dev/null +++ b/src/modules/mission/mission.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { MissionController } from "./controller/mission.controller"; +import { MissionAdminController } from "./controller/mission-admin.controller"; +import { MissionService } from "./service/mission.service"; + +@Module({ + imports: [], + controllers: [MissionController, MissionAdminController], + providers: [MissionService], + exports: [MissionService], +}) +export class MissionModule {} diff --git a/src/modules/mission/service/mission.service.ts b/src/modules/mission/service/mission.service.ts new file mode 100644 index 00000000..6198c59c --- /dev/null +++ b/src/modules/mission/service/mission.service.ts @@ -0,0 +1,278 @@ +import { Injectable } from "@nestjs/common"; +import { ContentType, MissionMetric, XpReason } from "@prisma/generated/enums"; +import { MissionModel, UserMissionModel } from "@prisma/generated/models"; +import { contentLevelFromXp } from "@/modules/xp/service/xp-level.util"; +import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { + DerivedMissionMetric, + XP_REASON_TO_DERIVED_METRICS, + XP_REASON_TO_MISSION_METRIC, +} from "@/shared/constants/mission"; +import { AppException } from "@/shared/exceptions/app.exceptions"; +import { DatabaseService } from "@/shared/infra/database/database.service"; +import { CreateMissionDto } from "../dto/create-mission.dto"; +import { UpdateMissionDto } from "../dto/update-mission.dto"; + +export type CompletedMission = { + mission: MissionModel; + userMission: UserMissionModel; +}; + +@Injectable() +export class MissionService { + constructor(private readonly databaseService: DatabaseService) {} + + // Chamado depois de um grantXp bem-sucedido. Avança tanto as métricas de + // contador quanto as derivadas afetadas pelo evento, e devolve o que fechou. + async advanceForXpReason( + userId: string, + reason: XpReason, + contentType: ContentType | null, + ): Promise { + const completed: CompletedMission[] = []; + + const counterMetric = XP_REASON_TO_MISSION_METRIC[reason]; + + if (counterMetric) { + completed.push(...(await this.incrementCounter(userId, counterMetric, contentType))); + } + + for (const metric of XP_REASON_TO_DERIVED_METRICS[reason] ?? []) { + completed.push(...(await this.syncDerived(userId, metric))); + } + + return completed; + } + + async advanceLevelReached(userId: string): Promise { + return this.syncDerived(userId, MissionMetric.LevelReached); + } + + private async incrementCounter( + userId: string, + metric: MissionMetric, + contentType: ContentType | null, + ): Promise { + // Missão sem contentType conta em todas as mídias; missão com contentType só + // conta quando o evento é daquela mídia. + const missions = await this.databaseService.mission.findMany({ + where: { + metric, + active: true, + ...(contentType ? { OR: [{ contentType: null }, { contentType }] } : { contentType: null }), + }, + }); + + const completed: CompletedMission[] = []; + + for (const mission of missions) { + const userMission = await this.databaseService.userMission.upsert({ + where: { userId_missionId: { userId, missionId: mission.id } }, + create: { userId, missionId: mission.id, progress: 1 }, + update: { progress: { increment: 1 } }, + }); + + const closed = await this.closeIfReached(mission, userMission); + + if (closed) completed.push(closed); + } + + return completed; + } + + private async syncDerived(userId: string, metric: DerivedMissionMetric): Promise { + const missions = await this.databaseService.mission.findMany({ where: { metric, active: true } }); + + if (missions.length === 0) return []; + + const completed: CompletedMission[] = []; + + for (const mission of missions) { + const progress = await this.measureDerived(userId, metric, mission.contentType); + + const userMission = await this.databaseService.userMission.upsert({ + where: { userId_missionId: { userId, missionId: mission.id } }, + create: { userId, missionId: mission.id, progress }, + update: { progress }, + }); + + const closed = await this.closeIfReached(mission, userMission); + + if (closed) completed.push(closed); + } + + return completed; + } + + private async measureDerived( + userId: string, + metric: DerivedMissionMetric, + contentType: ContentType | null, + ): Promise { + if (metric === MissionMetric.ContentTypesReviewed) { + const reviewed = await this.databaseService.xpLedger.findMany({ + where: { userId, reason: XpReason.ReviewAdded, contentType: { not: null } }, + distinct: ["contentType"], + select: { contentType: true }, + }); + + return reviewed.length; + } + + if (metric === MissionMetric.StreakReached) { + const xp = await this.databaseService.userXp.findUnique({ where: { userId } }); + + return xp?.longestStreak ?? 0; + } + + // LevelReached: missão com contentType mede o level daquela mídia; sem + // contentType, mede o level global. + if (contentType) { + const content = await this.databaseService.userContentXp.findUnique({ + where: { userId_contentType: { userId, contentType } }, + }); + + return contentLevelFromXp(content?.xp ?? 0); + } + + const xp = await this.databaseService.userXp.findUnique({ where: { userId } }); + + return xp?.level ?? 1; + } + + private async closeIfReached(mission: MissionModel, userMission: UserMissionModel): Promise { + if (userMission.completedAt || userMission.progress < mission.target) return null; + + const closed = await this.databaseService.userMission.update({ + where: { id: userMission.id }, + data: { completedAt: new Date() }, + }); + + return { mission, userMission: closed }; + } + + async getMissionsByUserId(userId: string) { + const [missions, userMissions] = await Promise.all([ + this.databaseService.mission.findMany({ + where: { active: true }, + orderBy: [{ tier: "asc" }, { position: "asc" }], + include: { medal: true }, + }), + this.databaseService.userMission.findMany({ where: { userId } }), + ]); + + const byMissionId = new Map(userMissions.map((item) => [item.missionId, item])); + + const items = missions.map((mission) => { + const userMission = byMissionId.get(mission.id); + const progress = Math.min(userMission?.progress ?? 0, mission.target); + const completedAt = userMission?.completedAt ?? null; + + // Missão secreta só revela metric/target/recompensa depois de concluída. + if (mission.hidden && !completedAt) { + return { + id: mission.id, + key: mission.key, + tier: mission.tier, + hidden: true as const, + progress: 0, + target: 0, + percentage: 0, + completedAt: null, + }; + } + + return { + id: mission.id, + key: mission.key, + metric: mission.metric, + contentType: mission.contentType, + tier: mission.tier, + hidden: false as const, + target: mission.target, + xpReward: mission.xpReward, + coinReward: mission.coinReward, + cosmeticKey: mission.cosmeticKey, + medal: mission.medal, + progress, + percentage: mission.target > 0 ? Math.min(100, Math.round((progress / mission.target) * 100)) : 0, + completedAt, + }; + }); + + return { + total: items.length, + completed: items.filter((item) => item.completedAt !== null).length, + items, + }; + } + + // Resumo enxuto para o payload do perfil: contagem e as últimas concluídas. + async getMissionSummaryByUserId(userId: string, recent = 5) { + const [total, completed, latest] = await Promise.all([ + this.databaseService.mission.count({ where: { active: true } }), + this.databaseService.userMission.count({ where: { userId, completedAt: { not: null } } }), + this.databaseService.userMission.findMany({ + where: { userId, completedAt: { not: null } }, + orderBy: { completedAt: "desc" }, + take: recent, + select: { + completedAt: true, + mission: { select: { id: true, key: true, tier: true, hidden: true } }, + }, + }), + ]); + + return { + total, + completed, + latest: latest.map(({ completedAt, mission }) => ({ ...mission, completedAt })), + }; + } + + async getCompletedMissionKeys(userId: string): Promise { + const completed = await this.databaseService.userMission.findMany({ + where: { userId, completedAt: { not: null } }, + select: { mission: { select: { key: true } } }, + }); + + return completed.map((item) => item.mission.key); + } + + async listMissions() { + return this.databaseService.mission.findMany({ + orderBy: [{ tier: "asc" }, { position: "asc" }], + include: { medal: true }, + }); + } + + async createMission(createMissionDto: CreateMissionDto) { + const exists = await this.databaseService.mission.findUnique({ where: { key: createMissionDto.key } }); + + if (exists) { + throw new AppException(ERROR_CODES.MISSION_KEY_ALREADY_EXISTS); + } + + return this.databaseService.mission.create({ data: createMissionDto }); + } + + async updateMission(missionId: string, updateMissionDto: UpdateMissionDto) { + const mission = await this.databaseService.mission.findUnique({ where: { id: missionId } }); + + if (!mission) { + throw new AppException(ERROR_CODES.MISSION_NOT_FOUND); + } + + return this.databaseService.mission.update({ where: { id: missionId }, data: updateMissionDto }); + } + + async deleteMission(missionId: string) { + const mission = await this.databaseService.mission.findUnique({ where: { id: missionId } }); + + if (!mission) { + throw new AppException(ERROR_CODES.MISSION_NOT_FOUND); + } + + await this.databaseService.mission.delete({ where: { id: missionId } }); + } +} diff --git a/src/modules/movie/service/movie-progress.service.ts b/src/modules/movie/service/movie-progress.service.ts index 1a405200..52f2583f 100644 --- a/src/modules/movie/service/movie-progress.service.ts +++ b/src/modules/movie/service/movie-progress.service.ts @@ -1,7 +1,9 @@ import { Injectable } from "@nestjs/common"; +import { ContentType } from "@prisma/generated/enums"; import { MovieProgressFindManyArgs } from "@prisma/generated/models"; -import { activityTypeFromProgressStatus } from "@/modules/activity/activity.utils"; +import { activityTypeFromProgressStatus, xpReasonFromProgressStatus } from "@/modules/activity/activity.utils"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -74,6 +76,17 @@ export class MovieProgressService { metadata: { ...movieProgress }, }); } + + const xpReason = xpReasonFromProgressStatus(status); + + if (xpReason) { + await this.queueService.toXpJob({ + userId, + reason: xpReason, + contentType: ContentType.Movie, + sourceKey: XP_SOURCE_KEYS.progress(xpReason, ContentType.Movie, movieId), + }); + } } async deleteMovieProgress(movieProgressId: string, userId: string) { diff --git a/src/modules/movie/service/movie-review.service.ts b/src/modules/movie/service/movie-review.service.ts index 0dfa08cd..a00729c7 100644 --- a/src/modules/movie/service/movie-review.service.ts +++ b/src/modules/movie/service/movie-review.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType } from "@prisma/generated/enums"; +import { ActivityType, ContentType, XpReason } from "@prisma/generated/enums"; import { MovieReviewFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -63,6 +64,13 @@ export class MovieReviewService { movieReviewId: movieReview.id, metadata: { ...movieReview }, }); + + await this.queueService.toXpJob({ + userId: createMovieReviewDto.userId, + reason: XpReason.ReviewAdded, + contentType: ContentType.Movie, + sourceKey: XP_SOURCE_KEYS.review(ContentType.Movie, createMovieReviewDto.movieId), + }); } async getMovieReviewById(movieReviewId: string) { diff --git a/src/modules/notification/dto/notification-preference.dto.ts b/src/modules/notification/dto/notification-preference.dto.ts index b7998e7f..ae96ba1b 100644 --- a/src/modules/notification/dto/notification-preference.dto.ts +++ b/src/modules/notification/dto/notification-preference.dto.ts @@ -42,6 +42,16 @@ export class UpdateNotificationPreferencesDto { description: "Receive notifications when a sequel of a finished title lands.", }) readonly sequelAdded?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: "boolean", description: "Receive notifications when you reach a new level." }) + readonly levelUp?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: "boolean", description: "Receive notifications when you complete a mission." }) + readonly mission?: boolean; } export interface NotificationPreferences { @@ -52,4 +62,6 @@ export interface NotificationPreferences { readonly gameRelease: boolean; readonly reopenedCompleted: boolean; readonly sequelAdded: boolean; + readonly levelUp: boolean; + readonly mission: boolean; } diff --git a/src/modules/notification/dto/notification.dto.ts b/src/modules/notification/dto/notification.dto.ts index 0aa3c563..2450082f 100644 --- a/src/modules/notification/dto/notification.dto.ts +++ b/src/modules/notification/dto/notification.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { ArrayNotEmpty, IsArray, IsNotEmpty, IsObject, IsUUID } from "class-validator"; +import { NotificationType } from "@prisma/generated/enums"; +import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsObject, IsUUID } from "class-validator"; // Shape of Notification.metadata when type is System. Kept open on purpose — what the system sends // is not settled yet, so anything beyond these fields rides along untouched. @@ -41,3 +42,20 @@ export class CreateReactionNotificationDto { @ApiProperty({ type: "string", format: "uuid" }) readonly reactionId: string; } + +export class CreateProgressionNotificationDto { + @IsNotEmpty() + @IsUUID("7") + @ApiProperty({ type: "string", format: "uuid" }) + readonly recipientId: string; + + @IsEnum(NotificationType) + @IsNotEmpty() + @ApiProperty({ enum: [NotificationType.LevelUp, NotificationType.MissionCompleted] }) + readonly type: typeof NotificationType.LevelUp | typeof NotificationType.MissionCompleted; + + @IsObject() + @IsNotEmpty() + @ApiProperty({ type: "object", additionalProperties: true }) + readonly metadata: SystemNotificationMetadata; +} diff --git a/src/modules/notification/service/notification.service.ts b/src/modules/notification/service/notification.service.ts index a521064c..c9085e56 100644 --- a/src/modules/notification/service/notification.service.ts +++ b/src/modules/notification/service/notification.service.ts @@ -8,6 +8,7 @@ import { DatabaseService } from "@/shared/infra/database/database.service"; import { GetNotificationsByUserDto } from "../dto/get-notifications.dto"; import { CreateCommentNotificationDto, + CreateProgressionNotificationDto, CreateReactionNotificationDto, CreateSystemNotificationDto, } from "../dto/notification.dto"; @@ -21,6 +22,8 @@ const DEFAULT_PREFERENCES: NotificationPreferences = { gameRelease: true, reopenedCompleted: true, sequelAdded: true, + levelUp: true, + mission: true, }; const PREFERENCE_SELECT = { @@ -31,6 +34,8 @@ const PREFERENCE_SELECT = { gameRelease: true, reopenedCompleted: true, sequelAdded: true, + levelUp: true, + mission: true, } as const; const REACTION_TARGETS = { @@ -102,6 +107,18 @@ export class NotificationService { }); } + // Level-up e missão concluída. Respeitam a preferência do usuário e usam + // titleKey/descriptionKey, resolvidos por i18n no cliente. + async createProgressionNotification({ recipientId, type, metadata }: CreateProgressionNotificationDto) { + const category = type === NotificationType.LevelUp ? "levelUp" : "mission"; + + if (!(await this.isCategoryEnabled(recipientId, category))) return; + + await this.databaseService.notification.create({ + data: { type, recipientId, metadata: { ...metadata } }, + }); + } + async createFromComment({ commentId }: CreateCommentNotificationDto) { const comment = await this.databaseService.comment.findUnique({ where: { id: commentId }, diff --git a/src/modules/profile/dto/create-setup-item.dto.ts b/src/modules/profile/dto/create-setup-item.dto.ts index 8058d671..12291442 100644 --- a/src/modules/profile/dto/create-setup-item.dto.ts +++ b/src/modules/profile/dto/create-setup-item.dto.ts @@ -1,18 +1,29 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from "class-validator"; +import { SetupItemType } from "@prisma/generated/enums"; +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from "class-validator"; export class CreateSetupItemDto { readonly userId: string; + @IsEnum(SetupItemType) + @IsOptional() + @ApiPropertyOptional({ + description: "Type of the entry", + enum: SetupItemType, + default: SetupItemType.COMPONENT, + }) + readonly type?: SetupItemType; + + @ValidateIf((object: CreateSetupItemDto) => object.type !== SetupItemType.DIVIDER) @IsString() @IsNotEmpty() @MaxLength(100) @ApiProperty({ - description: "Name of the component", + description: "Name of the component, or text of the title", example: 'Odyssey G7 27"', type: "string", }) - readonly name: string; + readonly name?: string; @IsString() @IsOptional() diff --git a/src/modules/profile/dto/update-profile.dto.ts b/src/modules/profile/dto/update-profile.dto.ts index 3531cc9e..47fce6f3 100644 --- a/src/modules/profile/dto/update-profile.dto.ts +++ b/src/modules/profile/dto/update-profile.dto.ts @@ -11,6 +11,21 @@ export class UpdateProfileDto { @ApiPropertyOptional({ type: "string" }) readonly color?: string; + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: "string", nullable: true }) + readonly avatarFrame?: string | null; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: "string", nullable: true }) + readonly title?: string | null; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: "string", nullable: true }) + readonly bannerEffect?: string | null; + @IsOptional() @IsString() @ApiPropertyOptional({ type: "string" }) diff --git a/src/modules/profile/dto/update-setup-item.dto.ts b/src/modules/profile/dto/update-setup-item.dto.ts index 78b2dde4..0860476d 100644 --- a/src/modules/profile/dto/update-setup-item.dto.ts +++ b/src/modules/profile/dto/update-setup-item.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from "class-validator"; +import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from "class-validator"; export class UpdateSetupItemDto { readonly userId: string; @@ -7,10 +7,11 @@ export class UpdateSetupItemDto { readonly itemId: string; @IsString() + @IsNotEmpty() @IsOptional() @MaxLength(100) @ApiPropertyOptional({ - description: "Name of the component", + description: "Name of the component, or text of the title", example: 'Odyssey G7 27"', type: "string", }) diff --git a/src/modules/profile/profile.module.ts b/src/modules/profile/profile.module.ts index 609acbb4..abbd1b03 100644 --- a/src/modules/profile/profile.module.ts +++ b/src/modules/profile/profile.module.ts @@ -1,10 +1,11 @@ import { Global, Module } from "@nestjs/common"; +import { CosmeticModule } from "@/modules/cosmetic/cosmetic.module"; import { ProfileController } from "./controller/profile.controller"; import { ProfileService } from "./service/profile.service"; @Global() @Module({ - imports: [], + imports: [CosmeticModule], controllers: [ProfileController], providers: [ProfileService], exports: [ProfileService], diff --git a/src/modules/profile/service/profile.service.ts b/src/modules/profile/service/profile.service.ts index b277e797..34c7f3d8 100644 --- a/src/modules/profile/service/profile.service.ts +++ b/src/modules/profile/service/profile.service.ts @@ -1,4 +1,7 @@ import { Injectable } from "@nestjs/common"; +import { CosmeticType, SetupItemType } from "@prisma/generated/enums"; +import { CosmeticService } from "@/modules/cosmetic/service/cosmetic.service"; +import { HEX_COLOR_REGEX } from "@/shared/constants/cosmetics"; import { ERROR_CODES } from "@/shared/constants/error-codes"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; @@ -18,6 +21,7 @@ export class ProfileService { constructor( private readonly databaseService: DatabaseService, private readonly uploadService: UploadService, + private readonly cosmeticService: CosmeticService, ) {} async createProfile(createProfileDto: CreateProfileDto) { @@ -30,10 +34,45 @@ export class ProfileService { } async updateProfile(updateProfileDto: UpdateProfileDto) { + const color = HEX_COLOR_REGEX.test(updateProfileDto.color ?? "") + ? updateProfileDto.color?.toLowerCase() + : updateProfileDto.color; + + if (color) { + await this.cosmeticService.assertProfileColorUnlocked(updateProfileDto.userId, color); + } + + if (updateProfileDto.avatarFrame) { + await this.cosmeticService.assertCosmeticUnlocked( + updateProfileDto.userId, + CosmeticType.AvatarFrame, + updateProfileDto.avatarFrame, + ); + } + + if (updateProfileDto.title) { + await this.cosmeticService.assertCosmeticUnlocked( + updateProfileDto.userId, + CosmeticType.ProfileTitle, + updateProfileDto.title, + ); + } + + if (updateProfileDto.bannerEffect) { + await this.cosmeticService.assertCosmeticUnlocked( + updateProfileDto.userId, + CosmeticType.BannerEffect, + updateProfileDto.bannerEffect, + ); + } + await this.databaseService.profile.update({ where: { userId: updateProfileDto.userId }, data: { - color: updateProfileDto.color, + color, + avatarFrame: updateProfileDto.avatarFrame, + title: updateProfileDto.title, + bannerEffect: updateProfileDto.bannerEffect, language: updateProfileDto.language, timezone: updateProfileDto.timezone, watchRegion: updateProfileDto.watchRegion, @@ -117,12 +156,16 @@ export class ProfileService { throw new AppException(ERROR_CODES.SETUP_ITEM_LIMIT_REACHED); } + const type = createSetupItemDto.type ?? SetupItemType.COMPONENT; + const isComponent = type === SetupItemType.COMPONENT; + return this.databaseService.setupItem.create({ data: { profileId, - name: createSetupItemDto.name, - brand: createSetupItemDto.brand || null, - link: createSetupItemDto.link || null, + type, + name: type === SetupItemType.DIVIDER ? null : (createSetupItemDto.name ?? null), + brand: isComponent ? createSetupItemDto.brand || null : null, + link: isComponent ? createSetupItemDto.link || null : null, position: items, }, }); @@ -131,12 +174,15 @@ export class ProfileService { async updateSetupItem(updateSetupItemDto: UpdateSetupItemDto) { const item = await this.findOwnedSetupItem(updateSetupItemDto.userId, updateSetupItemDto.itemId); + const isComponent = item.type === SetupItemType.COMPONENT; + return this.databaseService.setupItem.update({ where: { id: item.id }, data: { - ...(updateSetupItemDto.name !== undefined && { name: updateSetupItemDto.name }), - ...(updateSetupItemDto.brand !== undefined && { brand: updateSetupItemDto.brand || null }), - ...(updateSetupItemDto.link !== undefined && { link: updateSetupItemDto.link || null }), + ...(item.type !== SetupItemType.DIVIDER && + updateSetupItemDto.name !== undefined && { name: updateSetupItemDto.name }), + ...(isComponent && updateSetupItemDto.brand !== undefined && { brand: updateSetupItemDto.brand || null }), + ...(isComponent && updateSetupItemDto.link !== undefined && { link: updateSetupItemDto.link || null }), }, }); } @@ -192,7 +238,7 @@ export class ProfileService { const item = await this.databaseService.setupItem.findFirst({ where: { id: itemId, profileId }, - select: { id: true }, + select: { id: true, type: true }, }); if (!item) { diff --git a/src/modules/reaction/service/reaction.service.ts b/src/modules/reaction/service/reaction.service.ts index a13651fa..93fee2fb 100644 --- a/src/modules/reaction/service/reaction.service.ts +++ b/src/modules/reaction/service/reaction.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@nestjs/common"; +import { XpReason } from "@prisma/generated/enums"; import { ReactionFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -28,6 +30,12 @@ export class ReactionService { }); await this.queueService.toReactionNotificationJob({ reactionId: reaction.id }); + + await this.queueService.toXpJob({ + userId, + reason: XpReason.ReactionAdded, + sourceKey: XP_SOURCE_KEYS.reaction(reaction.id), + }); } async deleteReaction(deleteReactionDto: DeleteReactionDto) { diff --git a/src/modules/tv-show/service/tv-show-episode-watch.service.ts b/src/modules/tv-show/service/tv-show-episode-watch.service.ts index dd3dba8a..f3187b6e 100644 --- a/src/modules/tv-show/service/tv-show-episode-watch.service.ts +++ b/src/modules/tv-show/service/tv-show-episode-watch.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@nestjs/common"; -import { WatchEpisodeStatus } from "@prisma/generated/enums"; +import { ContentType, WatchEpisodeStatus, XpReason } from "@prisma/generated/enums"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { TMDBTVShowSeason } from "@/shared/infra/integrations/tmdb.service"; @@ -78,6 +79,17 @@ export class TVShowEpisodeWatchService { if (watchedEpisodes.length > 0) { await this.queueService.toWatchedActivityJob({ userId, tvShowId, episodes: watchedEpisodes }); } + + // Um job por episódio: o sourceKey é por episódio, então remarcar o mesmo + // episódio nunca paga de novo. + for (const episode of watchedEpisodes) { + await this.queueService.toXpJob({ + userId, + reason: XpReason.EpisodeWatched, + contentType: ContentType.TVShow, + sourceKey: XP_SOURCE_KEYS.episode(ContentType.TVShow, tvShowId, episode), + }); + } } async watchAllEpisodesOfTVShow({ tvShowId, userId }: WatchAllEpisodesOfTVShowDto) { diff --git a/src/modules/tv-show/service/tv-show-progress.service.ts b/src/modules/tv-show/service/tv-show-progress.service.ts index 938436af..e4fb7333 100644 --- a/src/modules/tv-show/service/tv-show-progress.service.ts +++ b/src/modules/tv-show/service/tv-show-progress.service.ts @@ -1,8 +1,9 @@ import { Injectable } from "@nestjs/common"; -import { ProgressStatus } from "@prisma/generated/enums"; +import { ContentType, ProgressStatus } from "@prisma/generated/enums"; import { TvShowProgressFindManyArgs } from "@prisma/generated/models"; -import { activityTypeFromProgressStatus } from "@/modules/activity/activity.utils"; +import { activityTypeFromProgressStatus, xpReasonFromProgressStatus } from "@/modules/activity/activity.utils"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -83,6 +84,17 @@ export class TVShowProgressService { }); } + const xpReason = xpReasonFromProgressStatus(status); + + if (xpReason) { + await this.queueService.toXpJob({ + userId, + reason: xpReason, + contentType: ContentType.TVShow, + sourceKey: XP_SOURCE_KEYS.progress(xpReason, ContentType.TVShow, tvShowId), + }); + } + if (status === ProgressStatus.Completed) { await this.tvShowEpisodeWatchService.watchAllEpisodesOfTVShow({ tvShowId, userId }); } diff --git a/src/modules/tv-show/service/tv-show-review.service.ts b/src/modules/tv-show/service/tv-show-review.service.ts index c4441497..bf2ac3df 100644 --- a/src/modules/tv-show/service/tv-show-review.service.ts +++ b/src/modules/tv-show/service/tv-show-review.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType } from "@prisma/generated/enums"; +import { ActivityType, ContentType, XpReason } from "@prisma/generated/enums"; import { TvShowReviewFindManyArgs } from "@prisma/generated/models"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -62,6 +63,13 @@ export class TVShowReviewService { tvShowReviewId: tvShowReview.id, metadata: { ...tvShowReview }, }); + + await this.queueService.toXpJob({ + userId: createTVShowReviewDto.userId, + reason: XpReason.ReviewAdded, + contentType: ContentType.TVShow, + sourceKey: XP_SOURCE_KEYS.review(ContentType.TVShow, createTVShowReviewDto.tvShowId), + }); } async getTVShowReviewById(tvShowReviewId: string) { diff --git a/src/modules/user/service/user.service.ts b/src/modules/user/service/user.service.ts index 06e763f4..9ce3bb9a 100644 --- a/src/modules/user/service/user.service.ts +++ b/src/modules/user/service/user.service.ts @@ -1,7 +1,11 @@ import { Injectable } from "@nestjs/common"; -import { ActivityType, ProgressStatus } from "@prisma/generated/enums"; +import { ActivityType, ProgressStatus, XpReason } from "@prisma/generated/enums"; import { FollowingFindManyArgs, UserFindManyArgs } from "@prisma/generated/models"; +import { CoinService } from "@/modules/coin/service/coin.service"; +import { MissionService } from "@/modules/mission/service/mission.service"; +import { XpService } from "@/modules/xp/service/xp.service"; import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { XP_SOURCE_KEYS } from "@/shared/constants/xp"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { QueueService } from "@/shared/infra/queue/queue.service"; @@ -19,6 +23,9 @@ type ProgressGroup = { export class UserService { constructor( private readonly databaseService: DatabaseService, + private readonly xpService: XpService, + private readonly coinService: CoinService, + private readonly missionService: MissionService, private readonly queueService: QueueService, ) {} @@ -296,7 +303,23 @@ export class UserService { const latestFavoriteType = latestFavorite?.type ?? null; - return { ...user, progressStats, counts, latestReviewType, latestProgressType, latestFavoriteType }; + const [xp, coins, missions] = await Promise.all([ + this.xpService.getXpByUserId(user.id), + this.coinService.getWalletByUserId(user.id), + this.missionService.getMissionSummaryByUserId(user.id), + ]); + + return { + ...user, + progressStats, + counts, + latestReviewType, + latestProgressType, + latestFavoriteType, + xp, + coins, + missions, + }; } getName(name: string | null | undefined, email: string) { @@ -376,6 +399,12 @@ export class UserService { followingId: following.id, metadata: { ...following }, }); + + await this.queueService.toXpJob({ + userId, + reason: XpReason.Followed, + sourceKey: XP_SOURCE_KEYS.follow(targetUserId), + }); } async unfollowUser(userId: string, targetUserId: string) { diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index ee687dbf..16fcef4e 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -1,10 +1,13 @@ import { Global, Module } from "@nestjs/common"; +import { CoinModule } from "@/modules/coin/coin.module"; +import { MissionModule } from "@/modules/mission/mission.module"; +import { XpModule } from "@/modules/xp/xp.module"; import { UserController } from "./controller/user.controller"; import { UserService } from "./service/user.service"; @Global() @Module({ - imports: [], + imports: [XpModule, MissionModule, CoinModule], controllers: [UserController], providers: [UserService], exports: [UserService], diff --git a/src/modules/xp/controller/xp.controller.ts b/src/modules/xp/controller/xp.controller.ts new file mode 100644 index 00000000..30f12be1 --- /dev/null +++ b/src/modules/xp/controller/xp.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { AuthGuard, Session, type UserSession } from "@thallesp/nestjs-better-auth"; +import { GetXpHistoryDto } from "../dto/get-xp-history.dto"; +import { XpService } from "../service/xp.service"; + +@ApiTags("Xp") +@Controller("/xp") +export class XpController { + constructor(private readonly xpService: XpService) {} + + @Get("/me") + @UseGuards(AuthGuard) + async getMyXp(@Session() session: UserSession) { + const xp = await this.xpService.getXpByUserId(session.user.id); + + return { xp }; + } + + @Get("/history") + @UseGuards(AuthGuard) + async getMyXpHistory(@Session() session: UserSession, @Query() query: GetXpHistoryDto) { + const history = await this.xpService.getXpHistory(session.user.id, query); + + return { history }; + } + + @Get("/user/:username") + async getXpByUsername(@Param("username") username: string) { + const xp = await this.xpService.getXpByUsername(username); + + return { xp }; + } +} diff --git a/src/modules/xp/dto/get-xp-history.dto.ts b/src/modules/xp/dto/get-xp-history.dto.ts new file mode 100644 index 00000000..ce29e2a4 --- /dev/null +++ b/src/modules/xp/dto/get-xp-history.dto.ts @@ -0,0 +1,3 @@ +import { OffsetPaginationParamsDto } from "@/shared/infra/database/dtos/offset-pagination.dto"; + +export class GetXpHistoryDto extends OffsetPaginationParamsDto {} diff --git a/src/modules/xp/dto/grant-xp.dto.ts b/src/modules/xp/dto/grant-xp.dto.ts new file mode 100644 index 00000000..856abfbe --- /dev/null +++ b/src/modules/xp/dto/grant-xp.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ContentType, XpReason } from "@prisma/generated/enums"; +import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from "class-validator"; + +export class GrantXpDto { + @IsNotEmpty() + @IsUUID() + @ApiProperty({ type: "string", format: "uuid" }) + readonly userId: string; + + @IsEnum(XpReason) + @IsNotEmpty() + @ApiProperty({ enum: XpReason }) + readonly reason: XpReason; + + // Chave de idempotência. Ver XP_SOURCE_KEYS em shared/constants/xp.ts. + @IsNotEmpty() + @IsString() + @ApiProperty() + readonly sourceKey: string; + + @IsOptional() + @IsEnum(ContentType) + @ApiPropertyOptional({ enum: ContentType }) + readonly contentType?: ContentType; + + // Sobrescreve o valor da tabela XP_RULES. Usado por MissionCompleted, cujo + // valor vem da linha da missão no banco. + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional() + readonly amount?: number; + + // Ignora o teto diário. Só o backfill usa. + @IsOptional() + readonly skipDailyCap?: boolean; + + @IsOptional() + readonly metadata?: Record; +} diff --git a/src/modules/xp/service/xp-level.util.ts b/src/modules/xp/service/xp-level.util.ts new file mode 100644 index 00000000..2b2e7334 --- /dev/null +++ b/src/modules/xp/service/xp-level.util.ts @@ -0,0 +1,54 @@ +import { CONTENT_XP_BASE, GLOBAL_XP_BASE, XP_CURVE_EXPONENT } from "@/shared/constants/xp"; + +export type LevelProgress = { + level: number; + totalXp: number; + // XP já acumulado dentro do level atual, e quanto o level atual custa por inteiro. + currentLevelXp: number; + nextLevelXp: number; + percentage: number; +}; + +// XP acumulado necessário para ALCANÇAR o level informado. Level 1 custa 0. +export function xpForLevel(level: number, base = GLOBAL_XP_BASE): number { + if (level <= 1) return 0; + + return Math.floor(base * (level - 1) ** XP_CURVE_EXPONENT); +} + +// Inversa da curva — fechada, sem loop nem tabela. +export function levelFromXp(totalXp: number, base = GLOBAL_XP_BASE): number { + if (totalXp <= 0) return 1; + + const level = Math.floor((totalXp / base) ** (1 / XP_CURVE_EXPONENT)) + 1; + + // Piso e teto contra erro de arredondamento em ponto flutuante: garante que o + // level devolvido sempre satisfaz xpForLevel(level) <= totalXp < xpForLevel(level + 1). + if (xpForLevel(level + 1, base) <= totalXp) return level + 1; + if (xpForLevel(level, base) > totalXp) return level - 1; + + return level; +} + +export function levelProgress(totalXp: number, base = GLOBAL_XP_BASE): LevelProgress { + const level = levelFromXp(totalXp, base); + const floor = xpForLevel(level, base); + const ceiling = xpForLevel(level + 1, base); + const span = ceiling - floor; + + return { + level, + totalXp, + currentLevelXp: totalXp - floor, + nextLevelXp: span, + percentage: span > 0 ? Math.min(100, parseFloat((((totalXp - floor) / span) * 100).toFixed(1))) : 100, + }; +} + +export function contentLevelFromXp(xp: number): number { + return levelFromXp(xp, CONTENT_XP_BASE); +} + +export function contentLevelProgress(xp: number): LevelProgress { + return levelProgress(xp, CONTENT_XP_BASE); +} diff --git a/src/modules/xp/service/xp.service.ts b/src/modules/xp/service/xp.service.ts new file mode 100644 index 00000000..ad4333c1 --- /dev/null +++ b/src/modules/xp/service/xp.service.ts @@ -0,0 +1,256 @@ +import { Injectable } from "@nestjs/common"; +import { ContentType, XpReason } from "@prisma/generated/enums"; +import { MedalModel, UserMedalModel, XpLedgerFindManyArgs } from "@prisma/generated/models"; +import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { + CONTENT_XP_BASE, + LEVEL_MILESTONE_MEDAL_NAMES, + LEVEL_MILESTONES, + STREAK_BASE_XP, + STREAK_MAX_MULTIPLIER, + STREAK_STEP_XP, + XP_RULES, +} from "@/shared/constants/xp"; +import { AppException } from "@/shared/exceptions/app.exceptions"; +import { DatabaseService } from "@/shared/infra/database/database.service"; +import { diffUtcDays, startOfUtcDay, utcDayKey } from "@/shared/utils/date"; +import { GetXpHistoryDto } from "../dto/get-xp-history.dto"; +import { GrantXpDto } from "../dto/grant-xp.dto"; +import { contentLevelFromXp, contentLevelProgress, levelFromXp, levelProgress } from "./xp-level.util"; + +const UNIQUE_VIOLATION = "P2002"; + +export type XpGrantResult = { + amount: number; + reason: XpReason; + totalXp: number; + level: number; + previousLevel: number; + leveledUp: boolean; + contentType: ContentType | null; + contentLevel: number | null; + contentLeveledUp: boolean; +}; + +export type StreakTouchResult = { + dayKey: string; + currentStreak: number; + longestStreak: number; + bonusXp: number; +}; + +@Injectable() +export class XpService { + constructor(private readonly databaseService: DatabaseService) {} + + // Retorna null quando nada foi concedido: sourceKey repetido (idempotência) ou + // teto diário já esgotado. Quem chama usa isso para não disparar efeito nenhum. + async grantXp(grantXpDto: GrantXpDto): Promise { + const { userId, reason, sourceKey, contentType = null, metadata, skipDailyCap = false } = grantXpDto; + + const rule = XP_RULES[reason]; + const requested = grantXpDto.amount ?? rule.amount; + + if (requested <= 0) return null; + + return this.databaseService + .$transaction(async (tx) => { + const amount = skipDailyCap ? requested : await this.applyDailyCap(tx, userId, reason, requested); + + if (amount <= 0) return null; + + await tx.xpLedger.create({ + data: { + userId, + reason, + sourceKey, + amount, + ...(contentType && { contentType }), + ...(metadata && { metadata: { ...metadata } }), + }, + }); + + const xp = await tx.userXp.upsert({ + where: { userId }, + create: { userId, totalXp: amount, level: levelFromXp(amount) }, + update: { totalXp: { increment: amount } }, + }); + + // O upsert de create já devolve o total final; o de update devolve o + // valor pós-incremento, então em ambos os casos totalXp está correto. + const previousLevel = levelFromXp(xp.totalXp - amount); + const level = levelFromXp(xp.totalXp); + + if (level !== xp.level) { + await tx.userXp.update({ where: { userId }, data: { level } }); + } + + let contentLevel: number | null = null; + let contentLeveledUp = false; + + if (contentType) { + const content = await tx.userContentXp.upsert({ + where: { userId_contentType: { userId, contentType } }, + create: { userId, contentType, xp: amount, level: contentLevelFromXp(amount) }, + update: { xp: { increment: amount } }, + }); + + contentLevel = contentLevelFromXp(content.xp); + contentLeveledUp = contentLevel > contentLevelFromXp(content.xp - amount); + + if (contentLevel !== content.level) { + await tx.userContentXp.update({ + where: { userId_contentType: { userId, contentType } }, + data: { level: contentLevel }, + }); + } + } + + return { + amount, + reason, + totalXp: xp.totalXp, + level, + previousLevel, + leveledUp: level > previousLevel, + contentType, + contentLevel, + contentLeveledUp, + } satisfies XpGrantResult; + }) + .catch((error: { code?: string }) => { + // sourceKey repetido: esse evento já foi pago alguma vez. Silencioso de propósito. + if (error?.code === UNIQUE_VIOLATION) return null; + + throw error; + }); + } + + // Soma o que já foi concedido hoje naquele reason e corta o excedente. + private async applyDailyCap( + tx: Pick, + userId: string, + reason: XpReason, + requested: number, + ): Promise { + const cap = XP_RULES[reason].dailyCap; + + if (cap === null) return requested; + + const today = await tx.xpLedger.aggregate({ + where: { userId, reason, createdAt: { gte: startOfUtcDay(new Date()) } }, + _sum: { amount: true }, + }); + + const used = today._sum.amount ?? 0; + + return Math.max(0, Math.min(requested, cap - used)); + } + + // Avança o streak no primeiro evento de cada dia UTC. Sem cron: a quebra é + // detectada preguiçosamente quando o usuário volta. Retorna null se o dia já + // foi contabilizado. + async touchStreak(userId: string): Promise { + const now = new Date(); + const today = startOfUtcDay(now); + + const current = await this.databaseService.userXp.findUnique({ where: { userId } }); + + if (current?.lastActiveDate && diffUtcDays(current.lastActiveDate, today) === 0) return null; + + const continued = current?.lastActiveDate ? diffUtcDays(current.lastActiveDate, today) === 1 : false; + const currentStreak = continued ? (current?.currentStreak ?? 0) + 1 : 1; + const longestStreak = Math.max(currentStreak, current?.longestStreak ?? 0); + + await this.databaseService.userXp.upsert({ + where: { userId }, + create: { userId, currentStreak, longestStreak, lastActiveDate: today }, + update: { currentStreak, longestStreak, lastActiveDate: today }, + }); + + return { + dayKey: utcDayKey(now), + currentStreak, + longestStreak, + bonusXp: STREAK_BASE_XP + Math.min(currentStreak, STREAK_MAX_MULTIPLIER) * STREAK_STEP_XP, + }; + } + + // Medalhas de marco de level. Devolve só as concedidas agora, para o chamador + // enfileirar as activities correspondentes. + async grantMilestoneMedals(userId: string, previousLevel: number, level: number) { + const crossed = LEVEL_MILESTONES.filter((milestone) => milestone > previousLevel && milestone <= level); + + if (crossed.length === 0) return []; + + const names = crossed.map((milestone) => LEVEL_MILESTONE_MEDAL_NAMES[milestone]); + const medals = await this.databaseService.medal.findMany({ where: { name: { in: names } } }); + + const granted: { userMedal: UserMedalModel; medal: MedalModel }[] = []; + + for (const medal of medals) { + const userMedal = await this.grantMedal(userId, medal.id); + + if (userMedal) { + granted.push({ userMedal, medal }); + } + } + + return granted; + } + + // Concede uma medalha ignorando quem já tem. Retorna null nesse caso, para o + // chamador não emitir activity duplicada. + async grantMedal(userId: string, medalId: string) { + return this.databaseService.userMedal.create({ data: { userId, medalId } }).catch((error: { code?: string }) => { + if (error?.code === UNIQUE_VIOLATION) return null; + + throw error; + }); + } + + async getXpByUserId(userId: string) { + const [xp, contentXp] = await Promise.all([ + this.databaseService.userXp.findUnique({ where: { userId } }), + this.databaseService.userContentXp.findMany({ where: { userId } }), + ]); + + const totalXp = xp?.totalXp ?? 0; + + // Streak parado em um dia anterior a ontem já está quebrado — só ainda não + // foi zerado no banco, porque a atualização é preguiçosa. + const stale = xp?.lastActiveDate ? diffUtcDays(xp.lastActiveDate, new Date()) > 1 : true; + + return { + ...levelProgress(totalXp), + currentStreak: stale ? 0 : (xp?.currentStreak ?? 0), + longestStreak: xp?.longestStreak ?? 0, + lastActiveDate: xp?.lastActiveDate ?? null, + contentTypes: Object.values(ContentType).map((contentType) => { + const entry = contentXp.find((item) => item.contentType === contentType); + + return { contentType, base: CONTENT_XP_BASE, ...contentLevelProgress(entry?.xp ?? 0) }; + }), + }; + } + + async getXpByUsername(username: string) { + const user = await this.databaseService.user.findUnique({ where: { username }, select: { id: true } }); + + if (!user) { + throw new AppException(ERROR_CODES.USER_NOT_FOUND); + } + + return this.getXpByUserId(user.id); + } + + async getXpHistory(userId: string, getXpHistoryDto: GetXpHistoryDto) { + return this.databaseService.offsetPagination({ + model: "xpLedger", + page: getXpHistoryDto.page, + itemsPerPage: getXpHistoryDto.itemsPerPage, + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + } +} diff --git a/src/modules/xp/xp.module.ts b/src/modules/xp/xp.module.ts new file mode 100644 index 00000000..0625d268 --- /dev/null +++ b/src/modules/xp/xp.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { XpController } from "./controller/xp.controller"; +import { XpService } from "./service/xp.service"; + +@Module({ + imports: [], + controllers: [XpController], + providers: [XpService], + exports: [XpService], +}) +export class XpModule {} diff --git a/src/shared/constants/cosmetics.ts b/src/shared/constants/cosmetics.ts new file mode 100644 index 00000000..13d6d8a7 --- /dev/null +++ b/src/shared/constants/cosmetics.ts @@ -0,0 +1,122 @@ +import { CosmeticType } from "@prisma/generated/enums"; + +export type CosmeticUnlock = + | { type: "default" } + | { type: "level"; value: number } + | { type: "mission"; key: string } + | { type: "purchase"; price: number }; + +export type Cosmetic = { + key: string; + // Hex para ProfileColor (Profile.color guarda o hex); igual à key nos demais + // tipos, cujo visual vive no web mapeado pela key. + value: string; + unlock: CosmeticUnlock; +}; + +// A cor default do Profile (schema.prisma: Profile.color) precisa estar sempre +// destravada, senão um usuário novo já nasce com um valor inválido. +export const DEFAULT_PROFILE_COLOR = "#10b981"; + +// Cores sólidas são todas livres: o perfil aceita qualquer hex (custom color), +// então o catálogo de sólidas existe só como preset de UX. Gradientes são os +// únicos itens pagos deste tipo e vivem em Profile.color como `gradient:`. +export const PROFILE_GRADIENT_PREFIX = "gradient:"; + +export const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +export const PROFILE_COLORS: Cosmetic[] = [ + { key: "emerald", value: DEFAULT_PROFILE_COLOR, unlock: { type: "default" } }, + { key: "slate", value: "#64748b", unlock: { type: "default" } }, + { key: "blue", value: "#3b82f6", unlock: { type: "default" } }, + { key: "rose", value: "#f43f5e", unlock: { type: "default" } }, + { key: "amber", value: "#f59e0b", unlock: { type: "default" } }, + { key: "violet", value: "#8b5cf6", unlock: { type: "default" } }, + { key: "cyan", value: "#06b6d4", unlock: { type: "default" } }, + { key: "fuchsia", value: "#d946ef", unlock: { type: "default" } }, + { key: "gold", value: "#eab308", unlock: { type: "default" } }, + { key: "crimson", value: "#dc2626", unlock: { type: "default" } }, + { key: "obsidian", value: "#27272a", unlock: { type: "default" } }, + { key: "otaku", value: "#ff6b9d", unlock: { type: "default" } }, + { key: "critic", value: "#14b8a6", unlock: { type: "default" } }, + { key: "omnivore", value: "#a855f7", unlock: { type: "default" } }, + { key: "devoted", value: "#f97316", unlock: { type: "default" } }, + { key: "graphite", value: "#374151", unlock: { type: "default" } }, + { key: "ruby", value: "#e11d48", unlock: { type: "default" } }, + { key: "sapphire", value: "#2563eb", unlock: { type: "default" } }, + { key: "jade", value: "#059669", unlock: { type: "default" } }, + { key: "sunset", value: "#fb923c", unlock: { type: "default" } }, + { key: "orchid", value: "#c026d3", unlock: { type: "default" } }, + { key: "dusk", value: `${PROFILE_GRADIENT_PREFIX}dusk`, unlock: { type: "purchase", price: 200 } }, + { key: "oceanic", value: `${PROFILE_GRADIENT_PREFIX}oceanic`, unlock: { type: "purchase", price: 200 } }, + { key: "sunrise", value: `${PROFILE_GRADIENT_PREFIX}sunrise`, unlock: { type: "purchase", price: 250 } }, + { key: "nebula", value: `${PROFILE_GRADIENT_PREFIX}nebula`, unlock: { type: "purchase", price: 300 } }, + { key: "aurora_flow", value: `${PROFILE_GRADIENT_PREFIX}aurora_flow`, unlock: { type: "purchase", price: 400 } }, + { key: "prism", value: `${PROFILE_GRADIENT_PREFIX}prism`, unlock: { type: "purchase", price: 550 } }, +]; + +export const AVATAR_FRAMES: Cosmetic[] = [ + { key: "none", value: "none", unlock: { type: "default" } }, + { key: "glow", value: "glow", unlock: { type: "purchase", price: 100 } }, + { key: "neon", value: "neon", unlock: { type: "purchase", price: 150 } }, + { key: "gold_ring", value: "gold_ring", unlock: { type: "purchase", price: 250 } }, + { key: "rose_petals", value: "rose_petals", unlock: { type: "purchase", price: 250 } }, + { key: "aurora", value: "aurora", unlock: { type: "purchase", price: 400 } }, + { key: "obsidian_ring", value: "obsidian_ring", unlock: { type: "purchase", price: 550 } }, +]; + +export const PROFILE_TITLES: Cosmetic[] = [ + { key: "none", value: "none", unlock: { type: "default" } }, + { key: "collector", value: "collector", unlock: { type: "purchase", price: 60 } }, + { key: "night_owl", value: "night_owl", unlock: { type: "purchase", price: 60 } }, + { key: "binge_watcher", value: "binge_watcher", unlock: { type: "purchase", price: 90 } }, + { key: "page_turner", value: "page_turner", unlock: { type: "purchase", price: 90 } }, + { key: "trendsetter", value: "trendsetter", unlock: { type: "purchase", price: 150 } }, + { key: "veteran", value: "veteran", unlock: { type: "purchase", price: 200 } }, + { key: "curator", value: "curator", unlock: { type: "purchase", price: 250 } }, + { key: "legend", value: "legend", unlock: { type: "purchase", price: 400 } }, + { key: "founder", value: "founder", unlock: { type: "purchase", price: 600 } }, +]; + +export const BANNER_EFFECTS: Cosmetic[] = [ + { key: "none", value: "none", unlock: { type: "default" } }, + { key: "gradient_wave", value: "gradient_wave", unlock: { type: "purchase", price: 180 } }, + { key: "particles", value: "particles", unlock: { type: "purchase", price: 280 } }, + { key: "scanlines", value: "scanlines", unlock: { type: "purchase", price: 280 } }, + { key: "vignette_glow", value: "vignette_glow", unlock: { type: "purchase", price: 380 } }, + { key: "aurora_sky", value: "aurora_sky", unlock: { type: "purchase", price: 500 } }, +]; + +export const COSMETIC_CATALOGS: Record = { + [CosmeticType.ProfileColor]: PROFILE_COLORS, + [CosmeticType.AvatarFrame]: AVATAR_FRAMES, + [CosmeticType.ProfileTitle]: PROFILE_TITLES, + [CosmeticType.BannerEffect]: BANNER_EFFECTS, +}; + +export const COSMETICS_BY_KEY = Object.fromEntries( + Object.entries(COSMETIC_CATALOGS).map(([type, items]) => [type, new Map(items.map((item) => [item.key, item]))]), +) as Record>; + +// Profile.color guarda o hex, então a validação de cor busca pelo value. +export const PROFILE_COLORS_BY_VALUE = new Map(PROFILE_COLORS.map((cosmetic) => [cosmetic.value, cosmetic])); + +export type CosmeticUnlockState = { + level: number; + completedMissionKeys: Set; + // Chaves compostas `${type}:${key}` das compras do usuário. + ownedKeys: Set; +}; + +export function isCosmeticUnlocked(type: CosmeticType, cosmetic: Cosmetic, state: CosmeticUnlockState): boolean { + switch (cosmetic.unlock.type) { + case "default": + return true; + case "level": + return state.level >= cosmetic.unlock.value; + case "mission": + return state.completedMissionKeys.has(cosmetic.unlock.key); + case "purchase": + return state.ownedKeys.has(`${type}:${cosmetic.key}`); + } +} diff --git a/src/shared/constants/error-codes.ts b/src/shared/constants/error-codes.ts index f484ebb2..065b7c86 100644 --- a/src/shared/constants/error-codes.ts +++ b/src/shared/constants/error-codes.ts @@ -295,4 +295,36 @@ export const ERROR_CODES = { code: "USER_USERNAME_ALREADY_EXISTS", status: 409, }, + COSMETIC_NOT_FOUND: { + code: "COSMETIC_NOT_FOUND", + status: 404, + }, + COSMETIC_LOCKED: { + code: "COSMETIC_LOCKED", + status: 403, + }, + COSMETIC_NOT_PURCHASABLE: { + code: "COSMETIC_NOT_PURCHASABLE", + status: 400, + }, + COSMETIC_ALREADY_OWNED: { + code: "COSMETIC_ALREADY_OWNED", + status: 409, + }, + INSUFFICIENT_BALANCE: { + code: "INSUFFICIENT_BALANCE", + status: 409, + }, + MISSION_NOT_FOUND: { + code: "MISSION_NOT_FOUND", + status: 404, + }, + MISSION_KEY_ALREADY_EXISTS: { + code: "MISSION_KEY_ALREADY_EXISTS", + status: 409, + }, + FORBIDDEN_ROLE: { + code: "FORBIDDEN_ROLE", + status: 403, + }, } as const; diff --git a/src/shared/constants/job.ts b/src/shared/constants/job.ts index 3f1550d3..d7f3e24c 100644 --- a/src/shared/constants/job.ts +++ b/src/shared/constants/job.ts @@ -23,3 +23,5 @@ export const PAYMENT_FAILED_JOB = "payment-failed-job"; export const CATCHUP_DAILY_JOB = "catchup-daily-job"; export const CATCHUP_DAILY_SCHEDULER_ID = "catchup-daily-scheduler"; + +export const XP_JOB = "xp-job"; diff --git a/src/shared/constants/mission.ts b/src/shared/constants/mission.ts new file mode 100644 index 00000000..40398b25 --- /dev/null +++ b/src/shared/constants/mission.ts @@ -0,0 +1,37 @@ +import { MissionMetric, XpReason } from "@prisma/generated/enums"; + +// Só a REGRA DE CONTAGEM vive em código (tipada). As instâncias — meta, +// recompensa, tier, ordem — vivem no banco e são editáveis sem deploy. + +// Métricas de contador: avançam +1 a cada evento que de fato concedeu XP. +// Teto diário batido não infla missão, porque o XP não foi concedido. +export const XP_REASON_TO_MISSION_METRIC: Partial> = { + [XpReason.EpisodeWatched]: MissionMetric.EpisodesWatched, + [XpReason.ProgressCompleted]: MissionMetric.ProgressCompleted, + [XpReason.ReviewAdded]: MissionMetric.ReviewsWritten, + [XpReason.FavoriteAdded]: MissionMetric.FavoritesAdded, + [XpReason.ListCreated]: MissionMetric.ListsCreated, + [XpReason.ListItemAdded]: MissionMetric.ListItemsAdded, + [XpReason.Followed]: MissionMetric.UsersFollowed, + [XpReason.CommentAdded]: MissionMetric.CommentsWritten, +}; + +// Métricas derivadas: não dá pra somar +1, precisam de uma query pontual. +// São raras o bastante para não pesar. +export const DERIVED_MISSION_METRICS = [ + MissionMetric.ContentTypesReviewed, + MissionMetric.LevelReached, + MissionMetric.StreakReached, +] as const; + +export type DerivedMissionMetric = (typeof DERIVED_MISSION_METRICS)[number]; + +export function isDerivedMetric(metric: MissionMetric): metric is DerivedMissionMetric { + return (DERIVED_MISSION_METRICS as readonly MissionMetric[]).includes(metric); +} + +// Quais derivadas recalcular quando chega cada evento. +export const XP_REASON_TO_DERIVED_METRICS: Partial> = { + [XpReason.ReviewAdded]: [MissionMetric.ContentTypesReviewed], + [XpReason.StreakBonus]: [MissionMetric.StreakReached], +}; diff --git a/src/shared/constants/queue.ts b/src/shared/constants/queue.ts index 155807f3..95163024 100644 --- a/src/shared/constants/queue.ts +++ b/src/shared/constants/queue.ts @@ -5,3 +5,5 @@ export const NOTIFICATION_QUEUE = "notification-queue"; export const EMAIL_QUEUE = "email-queue"; export const CATCHUP_QUEUE = "catchup-queue"; + +export const XP_QUEUE = "xp-queue"; diff --git a/src/shared/constants/xp.ts b/src/shared/constants/xp.ts new file mode 100644 index 00000000..1a56194f --- /dev/null +++ b/src/shared/constants/xp.ts @@ -0,0 +1,73 @@ +import { ContentType, FavoriteType, XpReason } from "@prisma/generated/enums"; + +// Curva fechada e inversível: xpForLevel(levelFromXp(x)) <= x < xpForLevel(levelFromXp(x) + 1). +// Sem tabela de níveis — o level é sempre derivado do XP acumulado, nunca armazenado como verdade. +export const XP_CURVE_EXPONENT = 1.6; + +export const GLOBAL_XP_BASE = 100; + +// Base menor por mídia porque o XP é fatiado entre os 6 ContentType. +export const CONTENT_XP_BASE = 60; + +// Streak: bônus cresce com a sequência mas satura em 7 dias. +export const STREAK_BASE_XP = 10; +export const STREAK_STEP_XP = 5; +export const STREAK_MAX_MULTIPLIER = 7; + +// Levels que concedem medalha automática. +export const LEVEL_MILESTONES = [10, 25, 50, 100] as const; + +export const LEVEL_MILESTONE_MEDAL_NAMES: Record = { + 10: "level-10", + 25: "level-25", + 50: "level-50", + 100: "level-100", +}; + +// Coins por level-up. Escala com o esforço de cada level. +export const COINS_PER_LEVEL = 10; + +type XpRule = { + amount: number; + // XP máximo daquele reason por dia UTC. null = sem teto (eventos once-ever). + dailyCap: number | null; +}; + +export const XP_RULES: Record = { + [XpReason.EpisodeWatched]: { amount: 5, dailyCap: 150 }, + [XpReason.ProgressStarted]: { amount: 10, dailyCap: 100 }, + [XpReason.ProgressCompleted]: { amount: 40, dailyCap: 400 }, + [XpReason.ReviewAdded]: { amount: 75, dailyCap: 225 }, + [XpReason.ListCreated]: { amount: 25, dailyCap: 75 }, + [XpReason.ListItemAdded]: { amount: 2, dailyCap: 50 }, + [XpReason.FavoriteAdded]: { amount: 5, dailyCap: 50 }, + [XpReason.Followed]: { amount: 3, dailyCap: 30 }, + [XpReason.CommentAdded]: { amount: 3, dailyCap: 30 }, + [XpReason.ReactionAdded]: { amount: 1, dailyCap: 10 }, + [XpReason.StreakBonus]: { amount: STREAK_BASE_XP, dailyCap: null }, + [XpReason.MissionCompleted]: { amount: 0, dailyCap: null }, +}; + +// sourceKey é a chave de idempotência (@@unique([userId, sourceKey])). Review e +// favorite são chaveados na MÍDIA, não no id da linha: apagar e reescrever a +// review do mesmo anime não paga XP de novo. +export const XP_SOURCE_KEYS = { + episode: (contentType: ContentType, mediaId: string, episode: number) => + `episode:${contentType}:${mediaId}:${episode}`, + progressStarted: (contentType: ContentType, mediaId: string) => `progress-started:${contentType}:${mediaId}`, + progressCompleted: (contentType: ContentType, mediaId: string) => `progress-completed:${contentType}:${mediaId}`, + progress: (reason: XpReason, contentType: ContentType, mediaId: string) => + reason === XpReason.ProgressCompleted + ? XP_SOURCE_KEYS.progressCompleted(contentType, mediaId) + : XP_SOURCE_KEYS.progressStarted(contentType, mediaId), + review: (contentType: ContentType, mediaId: string) => `review:${contentType}:${mediaId}`, + favorite: (kind: ContentType | FavoriteType, mediaId: string) => `favorite:${kind}:${mediaId}`, + list: (listId: string) => `list:${listId}`, + listItem: (listId: string, mediaId: string) => `list-item:${listId}:${mediaId}`, + follow: (followingId: string) => `follow:${followingId}`, + comment: (commentId: string) => `comment:${commentId}`, + reaction: (reactionId: string) => `reaction:${reactionId}`, + streak: (day: string) => `streak:${day}`, + mission: (missionId: string) => `mission:${missionId}`, + levelUp: (level: number) => `level-up:${level}`, +} as const; diff --git a/src/shared/decorators/roles.decorator.ts b/src/shared/decorators/roles.decorator.ts new file mode 100644 index 00000000..47f0238e --- /dev/null +++ b/src/shared/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from "@nestjs/common"; +import { UserRole } from "@prisma/generated/enums"; + +export const ROLES_KEY = "roles"; + +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/shared/guards/roles.guard.ts b/src/shared/guards/roles.guard.ts new file mode 100644 index 00000000..46942c93 --- /dev/null +++ b/src/shared/guards/roles.guard.ts @@ -0,0 +1,27 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { UserRole } from "@prisma/generated/enums"; +import { ERROR_CODES } from "@/shared/constants/error-codes"; +import { ROLES_KEY } from "@/shared/decorators/roles.decorator"; +import { AppException } from "@/shared/exceptions/app.exceptions"; + +// Roda depois do AuthGuard, que é quem popula request.session. +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const roles = this.reflector.getAllAndOverride(ROLES_KEY, [context.getHandler(), context.getClass()]); + + if (!roles || roles.length === 0) return true; + + const request = context.switchToHttp().getRequest<{ session?: { user?: { role?: UserRole } } }>(); + const role = request.session?.user?.role; + + if (!role || !roles.includes(role)) { + throw new AppException(ERROR_CODES.FORBIDDEN_ROLE); + } + + return true; + } +} diff --git a/src/shared/infra/queue/processors/xp.processor.ts b/src/shared/infra/queue/processors/xp.processor.ts new file mode 100644 index 00000000..1921ad4d --- /dev/null +++ b/src/shared/infra/queue/processors/xp.processor.ts @@ -0,0 +1,196 @@ +import { OnWorkerEvent, Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { ActivityType, CoinReason, NotificationType, XpReason } from "@prisma/generated/enums"; +import { Job } from "bullmq"; +import { CoinService } from "@/modules/coin/service/coin.service"; +import { CompletedMission, MissionService } from "@/modules/mission/service/mission.service"; +import { NotificationService } from "@/modules/notification/service/notification.service"; +import { GrantXpDto } from "@/modules/xp/dto/grant-xp.dto"; +import { XpService } from "@/modules/xp/service/xp.service"; +import { XP_JOB } from "@/shared/constants/job"; +import { XP_QUEUE } from "@/shared/constants/queue"; +import { COINS_PER_LEVEL, XP_SOURCE_KEYS } from "@/shared/constants/xp"; +import { QueueService } from "../queue.service"; + +export type XpJobData = GrantXpDto; + +// Orquestra tudo que acontece em volta de uma concessão de XP: missões, coins, +// medalhas, activity, notificação e streak. Os services continuam puros (só +// DatabaseService), o que evita dependência circular com o QueueModule. +@Processor(XP_QUEUE, { concurrency: 5 }) +export class XpProcessor extends WorkerHost { + private readonly logger = new Logger(XpProcessor.name); + + constructor( + private readonly xpService: XpService, + private readonly missionService: MissionService, + private readonly coinService: CoinService, + private readonly notificationService: NotificationService, + private readonly queueService: QueueService, + ) { + super(); + } + + async process(job: Job) { + if (job.name !== XP_JOB) { + throw new Error(`Unsupported xp job name: ${job.name}`); + } + + const data = job.data as XpJobData; + const result = await this.xpService.grantXp(data); + + // null = sourceKey repetido ou teto diário esgotado. Nada aconteceu, nada + // pra propagar (nem missão, nem streak). + if (!result) return; + + const completed = await this.missionService.advanceForXpReason(data.userId, result.reason, result.contentType); + + if (result.leveledUp) { + await this.handleLevelUp(data.userId, result.previousLevel, result.level, result.totalXp); + + completed.push(...(await this.missionService.advanceLevelReached(data.userId))); + } + + for (const entry of completed) { + await this.handleMissionCompleted(data.userId, entry); + } + + // StreakBonus é ele próprio uma concessão de XP: reentrar aqui viraria laço. + if (data.reason !== XpReason.StreakBonus) { + const streak = await this.xpService.touchStreak(data.userId); + + if (streak) { + await this.queueService.toXpJob({ + userId: data.userId, + reason: XpReason.StreakBonus, + sourceKey: XP_SOURCE_KEYS.streak(streak.dayKey), + amount: streak.bonusXp, + metadata: { currentStreak: streak.currentStreak }, + }); + } + } + } + + private async handleLevelUp(userId: string, previousLevel: number, level: number, totalXp: number) { + // Um salto pode cruzar mais de um level de uma vez; cada um paga o seu. + for (let crossed = previousLevel + 1; crossed <= level; crossed++) { + await this.coinService.grantCoins({ + userId, + reason: CoinReason.LevelUp, + sourceKey: XP_SOURCE_KEYS.levelUp(crossed), + amount: COINS_PER_LEVEL * crossed, + metadata: { level: crossed }, + }); + } + + const medals = await this.xpService.grantMilestoneMedals(userId, previousLevel, level); + + for (const { userMedal, medal } of medals) { + await this.queueService.toActivityJob({ + type: ActivityType.MedalEarned, + userId, + userMedalId: userMedal.id, + metadata: { id: medal.id, name: medal.name }, + }); + } + + await this.queueService.toActivityJob({ + type: ActivityType.LevelUp, + userId, + metadata: { id: XP_SOURCE_KEYS.levelUp(level), level, previousLevel, totalXp }, + }); + + await this.notificationService.createProgressionNotification({ + recipientId: userId, + type: NotificationType.LevelUp, + metadata: { + titleKey: "notifications:levelUp.title", + descriptionKey: "notifications:levelUp.description", + level, + previousLevel, + }, + }); + } + + private async handleMissionCompleted(userId: string, { mission, userMission }: CompletedMission) { + if (mission.xpReward > 0) { + await this.queueService.toXpJob({ + userId, + reason: XpReason.MissionCompleted, + sourceKey: XP_SOURCE_KEYS.mission(mission.id), + amount: mission.xpReward, + metadata: { missionKey: mission.key }, + }); + } + + await this.coinService.grantCoins({ + userId, + reason: CoinReason.MissionCompleted, + sourceKey: XP_SOURCE_KEYS.mission(mission.id), + amount: mission.coinReward, + metadata: { missionKey: mission.key }, + }); + + if (mission.medalId) { + await this.grantMissionMedal(userId, mission.medalId); + } + + await this.queueService.toActivityJob({ + type: ActivityType.MissionCompleted, + userId, + metadata: { id: userMission.id, missionId: mission.id, missionKey: mission.key, tier: mission.tier }, + }); + + await this.notificationService.createProgressionNotification({ + recipientId: userId, + type: NotificationType.MissionCompleted, + metadata: { + titleKey: "notifications:missionCompleted.title", + descriptionKey: "notifications:missionCompleted.description", + missionKey: mission.key, + tier: mission.tier, + }, + }); + } + + private async grantMissionMedal(userId: string, medalId: string) { + const userMedal = await this.xpService.grantMedal(userId, medalId); + + if (!userMedal) return; + + await this.queueService.toActivityJob({ + type: ActivityType.MedalEarned, + userId, + userMedalId: userMedal.id, + metadata: { id: medalId }, + }); + } + + @OnWorkerEvent("active") + onActive(job: Job) { + this.logger.log(`Processing job [${XP_QUEUE}] | job=${job.id} name=${job.name} attempt=${job.attemptsMade + 1}`); + } + + @OnWorkerEvent("completed") + onCompleted(job: Job) { + this.logger.log(`Job completed [${XP_QUEUE}] | job=${job.id} name=${job.name}`); + } + + @OnWorkerEvent("failed") + onFailed(job: Job | undefined, error: Error) { + if (!job) return; + + const maxAttempts = job.opts?.attempts ?? 1; + const willRetry = job.attemptsMade < maxAttempts; + + if (willRetry) { + this.logger.warn( + `Job failed, retrying [${XP_QUEUE}] | job=${job.id} name=${job.name} attempt=${job.attemptsMade}/${maxAttempts} error=${error.message}`, + ); + } else { + this.logger.error( + `Job removed from queue after max attempts [${XP_QUEUE}] | job=${job.id} name=${job.name} attempts=${job.attemptsMade}/${maxAttempts} error=${error.message}`, + ); + } + } +} diff --git a/src/shared/infra/queue/queue.module.ts b/src/shared/infra/queue/queue.module.ts index 8a53c23d..68314a6f 100644 --- a/src/shared/infra/queue/queue.module.ts +++ b/src/shared/infra/queue/queue.module.ts @@ -3,13 +3,17 @@ import { Global, Module } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { ActivityModule } from "@/modules/activity/activity.module"; import { CatchupModule } from "@/modules/catchup/catchup.module"; +import { CoinModule } from "@/modules/coin/coin.module"; +import { MissionModule } from "@/modules/mission/mission.module"; import { NotificationModule } from "@/modules/notification/notification.module"; -import { ACTIVITY_QUEUE, CATCHUP_QUEUE, EMAIL_QUEUE, NOTIFICATION_QUEUE } from "@/shared/constants/queue"; +import { XpModule } from "@/modules/xp/xp.module"; +import { ACTIVITY_QUEUE, CATCHUP_QUEUE, EMAIL_QUEUE, NOTIFICATION_QUEUE, XP_QUEUE } from "@/shared/constants/queue"; import { EmailModule } from "../email/email.module"; import { ActivityProcessor } from "./processors/activity.processor"; import { CatchupProcessor } from "./processors/catchup.processor"; import { EmailProcessor } from "./processors/email.processor"; import { NotificationProcessor } from "./processors/notification.processor"; +import { XpProcessor } from "./processors/xp.processor"; import { QueueService } from "./queue.service"; @Global() @@ -19,6 +23,9 @@ import { QueueService } from "./queue.service"; ActivityModule, NotificationModule, CatchupModule, + XpModule, + MissionModule, + CoinModule, BullModule.forRootAsync({ inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ @@ -40,9 +47,10 @@ import { QueueService } from "./queue.service"; BullModule.registerQueue({ name: NOTIFICATION_QUEUE }), BullModule.registerQueue({ name: EMAIL_QUEUE }), BullModule.registerQueue({ name: CATCHUP_QUEUE }), + BullModule.registerQueue({ name: XP_QUEUE }), ], controllers: [], - providers: [QueueService, ActivityProcessor, NotificationProcessor, EmailProcessor, CatchupProcessor], + providers: [QueueService, ActivityProcessor, NotificationProcessor, EmailProcessor, CatchupProcessor, XpProcessor], exports: [QueueService], }) export class QueueModule {} diff --git a/src/shared/infra/queue/queue.service.ts b/src/shared/infra/queue/queue.service.ts index 1983edca..0e07a7cd 100644 --- a/src/shared/infra/queue/queue.service.ts +++ b/src/shared/infra/queue/queue.service.ts @@ -9,6 +9,7 @@ import { CreateReactionNotificationDto, CreateSystemNotificationDto, } from "@/modules/notification/dto/notification.dto"; +import { GrantXpDto } from "@/modules/xp/dto/grant-xp.dto"; import { ACTIVITY_JOB, CATCHUP_DAILY_JOB, @@ -23,8 +24,9 @@ import { RESET_PASSWORD_JOB, SUBSCRIPTION_CANCELLED_JOB, WATCHED_ACTIVITY_JOB, + XP_JOB, } from "@/shared/constants/job"; -import { ACTIVITY_QUEUE, CATCHUP_QUEUE, EMAIL_QUEUE, NOTIFICATION_QUEUE } from "@/shared/constants/queue"; +import { ACTIVITY_QUEUE, CATCHUP_QUEUE, EMAIL_QUEUE, NOTIFICATION_QUEUE, XP_QUEUE } from "@/shared/constants/queue"; import { DeleteAccountEmailDto } from "../email/dto/delete-account-email.dto"; import { MagicLinkEmailDto } from "../email/dto/magic-link-email.dto"; import { PaymentFailedEmailDto } from "../email/dto/payment-failed-email.dto"; @@ -32,7 +34,12 @@ import { PaymentSuccessEmailDto } from "../email/dto/payment-success-email.dto"; import { ResetPasswordEmailDto } from "../email/dto/reset-password-email.dto"; import { SubscriptionCancelledEmailDto } from "../email/dto/subscription-cancelled-email.dto"; -type QueueName = typeof EMAIL_QUEUE | typeof ACTIVITY_QUEUE | typeof NOTIFICATION_QUEUE | typeof CATCHUP_QUEUE; +type QueueName = + | typeof EMAIL_QUEUE + | typeof ACTIVITY_QUEUE + | typeof NOTIFICATION_QUEUE + | typeof CATCHUP_QUEUE + | typeof XP_QUEUE; type JobName = | typeof ACTIVITY_JOB @@ -46,7 +53,8 @@ type JobName = | typeof PAYMENT_SUCCESS_JOB | typeof PAYMENT_FAILED_JOB | typeof SUBSCRIPTION_CANCELLED_JOB - | typeof CATCHUP_DAILY_JOB; + | typeof CATCHUP_DAILY_JOB + | typeof XP_JOB; const DEFAULT_CATCHUP_CRON = "0 4 * * *"; @@ -64,6 +72,8 @@ export class QueueService implements OnModuleInit { private readonly notificationQueue: Queue, @InjectQueue(CATCHUP_QUEUE) private readonly catchupQueue: Queue, + @InjectQueue(XP_QUEUE) + private readonly xpQueue: Queue, ) {} async onModuleInit() { @@ -98,6 +108,7 @@ export class QueueService implements OnModuleInit { [ACTIVITY_QUEUE]: this.activityQueue, [NOTIFICATION_QUEUE]: this.notificationQueue, [CATCHUP_QUEUE]: this.catchupQueue, + [XP_QUEUE]: this.xpQueue, } as const; const job = await queues[queueName].add(jobName, data, options); @@ -108,6 +119,10 @@ export class QueueService implements OnModuleInit { } } + async toXpJob(grantXpDto: GrantXpDto) { + await this.addJob(XP_QUEUE, XP_JOB, grantXpDto); + } + async toActivityJob(createActivityDto: CreateActivityDto) { await this.addJob(ACTIVITY_QUEUE, ACTIVITY_JOB, createActivityDto); } diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts index 5bfc172c..c675bd8b 100644 --- a/src/shared/utils/date.ts +++ b/src/shared/utils/date.ts @@ -40,3 +40,17 @@ export function endOfDayInTimeZone(date: Date, timeZone: string): Date { export function addDays(date: Date, days: number): Date { return new Date(date.getTime() + days * MS_PER_DAY); } + +export function startOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +// Chave YYYY-MM-DD do dia UTC. Usada como sourceKey de streak e como corte do teto diário. +export function utcDayKey(date: Date): string { + return startOfUtcDay(date).toISOString().slice(0, 10); +} + +// Dias inteiros entre dois instantes, comparando meia-noite UTC de cada um. +export function diffUtcDays(from: Date, to: Date): number { + return Math.round((startOfUtcDay(to).getTime() - startOfUtcDay(from).getTime()) / MS_PER_DAY); +} diff --git a/test/unit/coin/coin.service.spec.ts b/test/unit/coin/coin.service.spec.ts new file mode 100644 index 00000000..f9da5c8b --- /dev/null +++ b/test/unit/coin/coin.service.spec.ts @@ -0,0 +1,179 @@ +import { faker } from "@faker-js/faker"; +import { CoinReason } from "@prisma/generated/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CoinService } from "@/modules/coin/service/coin.service"; +import { AppException } from "@/shared/exceptions/app.exceptions"; + +const mockCoinLedgerCreate = vi.fn(); +const mockUserWalletUpsert = vi.fn(); +const mockUserWalletUpdateMany = vi.fn(); +const mockUserWalletFindUniqueOrThrow = vi.fn(); +const mockUserWalletFindUnique = vi.fn(); + +const tx = { + coinLedger: { create: mockCoinLedgerCreate }, + userWallet: { + upsert: mockUserWalletUpsert, + updateMany: mockUserWalletUpdateMany, + findUniqueOrThrow: mockUserWalletFindUniqueOrThrow, + }, +}; + +const mockDatabaseService = { + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + userWallet: { findUnique: mockUserWalletFindUnique }, +}; + +const uniqueViolation = Object.assign(new Error("unique"), { code: "P2002" }); + +describe("CoinService", () => { + let service: CoinService; + let userId: string; + + beforeEach(() => { + vi.clearAllMocks(); + + userId = faker.string.uuid(); + service = new CoinService(mockDatabaseService as any); + }); + + it("escreve no ledger e incrementa o saldo", async () => { + const amount = faker.number.int({ min: 1, max: 500 }); + + mockUserWalletUpsert.mockResolvedValueOnce({ balance: amount }); + + const result = await service.grantCoins({ + userId, + reason: CoinReason.LevelUp, + sourceKey: "level-up:2", + amount, + }); + + expect(result).toEqual({ amount, balance: amount }); + expect(mockUserWalletUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { balance: { increment: amount }, lifetimeEarned: { increment: amount } }, + }), + ); + }); + + it("o mesmo sourceKey paga uma vez só", async () => { + mockUserWalletUpsert.mockResolvedValueOnce({ balance: 10 }); + + const first = await service.grantCoins({ + userId, + reason: CoinReason.MissionCompleted, + sourceKey: "mission:1", + amount: 10, + }); + + mockCoinLedgerCreate.mockRejectedValueOnce(uniqueViolation); + + const second = await service.grantCoins({ + userId, + reason: CoinReason.MissionCompleted, + sourceKey: "mission:1", + amount: 10, + }); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + }); + + it("recompensa zero não vira linha no ledger", async () => { + const result = await service.grantCoins({ + userId, + reason: CoinReason.MissionCompleted, + sourceKey: "mission:2", + amount: 0, + }); + + expect(result).toBeNull(); + expect(mockCoinLedgerCreate).not.toHaveBeenCalled(); + }); + + it("carteira inexistente lê como zerada", async () => { + mockUserWalletFindUnique.mockResolvedValueOnce(null); + + const wallet = await service.getWalletByUserId(userId); + + expect(wallet).toEqual({ balance: 0, lifetimeEarned: 0, lifetimeSpent: 0 }); + }); + + it("debita o saldo com linha negativa no ledger", async () => { + const amount = faker.number.int({ min: 1, max: 500 }); + const remaining = faker.number.int({ min: 0, max: 100 }); + + mockUserWalletUpdateMany.mockResolvedValueOnce({ count: 1 }); + mockUserWalletFindUniqueOrThrow.mockResolvedValueOnce({ balance: remaining }); + + const result = await service.spendCoins({ + userId, + reason: CoinReason.Purchase, + sourceKey: "purchase:AvatarFrame:glow", + amount, + }); + + expect(result).toEqual({ amount, balance: remaining }); + expect(mockCoinLedgerCreate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ amount: -amount }) }), + ); + expect(mockUserWalletUpdateMany).toHaveBeenCalledWith({ + where: { userId, balance: { gte: amount } }, + data: { balance: { decrement: amount }, lifetimeSpent: { increment: amount } }, + }); + }); + + it("saldo insuficiente aborta sem debitar", async () => { + mockUserWalletUpdateMany.mockResolvedValueOnce({ count: 0 }); + + await expect( + service.spendCoins({ + userId, + reason: CoinReason.Purchase, + sourceKey: "purchase:ProfileTitle:legend", + amount: 400, + }), + ).rejects.toBeInstanceOf(AppException); + + expect(mockUserWalletFindUniqueOrThrow).not.toHaveBeenCalled(); + }); + + it("o mesmo sourceKey debita uma vez só", async () => { + mockCoinLedgerCreate.mockRejectedValueOnce(uniqueViolation); + + const result = await service.spendCoins({ + userId, + reason: CoinReason.Purchase, + sourceKey: "purchase:AvatarFrame:glow", + amount: 100, + }); + + expect(result).toBeNull(); + expect(mockUserWalletUpdateMany).not.toHaveBeenCalled(); + }); + + it("débito de zero não vira linha no ledger", async () => { + const result = await service.spendCoins({ + userId, + reason: CoinReason.Purchase, + sourceKey: "purchase:AvatarFrame:neon", + amount: 0, + }); + + expect(result).toBeNull(); + expect(mockCoinLedgerCreate).not.toHaveBeenCalled(); + }); + + it("usa o client de transacao recebido em vez de abrir outra", async () => { + mockUserWalletUpdateMany.mockResolvedValueOnce({ count: 1 }); + mockUserWalletFindUniqueOrThrow.mockResolvedValueOnce({ balance: 5 }); + + await service.spendCoins( + { userId, reason: CoinReason.Purchase, sourceKey: "purchase:BannerEffect:particles", amount: 280 }, + tx as any, + ); + + expect(mockDatabaseService.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/cosmetic/cosmetic.service.spec.ts b/test/unit/cosmetic/cosmetic.service.spec.ts new file mode 100644 index 00000000..7eb5c14f --- /dev/null +++ b/test/unit/cosmetic/cosmetic.service.spec.ts @@ -0,0 +1,190 @@ +import { faker } from "@faker-js/faker"; +import { CoinReason, CosmeticType } from "@prisma/generated/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CosmeticService } from "@/modules/cosmetic/service/cosmetic.service"; +import { AppException } from "@/shared/exceptions/app.exceptions"; + +const mockUserXpFindUnique = vi.fn(); +const mockUserMissionFindMany = vi.fn(); +const mockUserCosmeticFindMany = vi.fn(); +const mockUserCosmeticFindUnique = vi.fn(); +const mockUserCosmeticCreate = vi.fn(); +const mockProfileFindUnique = vi.fn(); + +const tx = { + userCosmetic: { create: mockUserCosmeticCreate }, +}; + +const mockDatabaseService = { + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + userXp: { findUnique: mockUserXpFindUnique }, + userMission: { findMany: mockUserMissionFindMany }, + userCosmetic: { findMany: mockUserCosmeticFindMany, findUnique: mockUserCosmeticFindUnique }, + profile: { findUnique: mockProfileFindUnique }, +}; + +const mockSpendCoins = vi.fn(); +const mockGetWalletByUserId = vi.fn(); + +const mockCoinService = { + spendCoins: mockSpendCoins, + getWalletByUserId: mockGetWalletByUserId, +}; + +describe("CosmeticService", () => { + let service: CosmeticService; + let userId: string; + + beforeEach(() => { + vi.clearAllMocks(); + + userId = faker.string.uuid(); + service = new CosmeticService(mockDatabaseService as any, mockCoinService as any); + + mockUserXpFindUnique.mockResolvedValue(null); + mockUserMissionFindMany.mockResolvedValue([]); + mockUserCosmeticFindMany.mockResolvedValue([]); + mockProfileFindUnique.mockResolvedValue(null); + }); + + it("item comprado aparece destravado e possuído no catálogo", async () => { + mockUserCosmeticFindMany.mockResolvedValue([{ type: CosmeticType.AvatarFrame, key: "glow" }]); + + const catalog = await service.getCatalog(userId); + + const glow = catalog.avatarFrames.find((item) => item.key === "glow"); + const neon = catalog.avatarFrames.find((item) => item.key === "neon"); + + expect(glow).toMatchObject({ unlocked: true, owned: true }); + expect(neon).toMatchObject({ unlocked: false, owned: false }); + }); + + it("catálogo marca o cosmético equipado no perfil", async () => { + mockProfileFindUnique.mockResolvedValue({ + color: "#10b981", + avatarFrame: null, + title: "collector", + bannerEffect: null, + }); + + const catalog = await service.getCatalog(userId); + + expect(catalog.profileColors.find((item) => item.key === "emerald")).toMatchObject({ equipped: true }); + expect(catalog.profileTitles.find((item) => item.key === "collector")).toMatchObject({ equipped: true }); + expect(catalog.avatarFrames.find((item) => item.key === "none")).toMatchObject({ equipped: true }); + }); + + it("cores de missão ficam livres no catálogo sem missão completa", async () => { + const catalog = await service.getCatalog(userId); + + expect(catalog.profileColors.find((item) => item.key === "otaku")).toMatchObject({ unlocked: true }); + expect(catalog.profileColors.find((item) => item.key === "dusk")).toMatchObject({ unlocked: false }); + }); + + it("assertProfileColorUnlocked aceita qualquer hex custom", async () => { + await expect(service.assertProfileColorUnlocked(userId, faker.color.rgb())).resolves.toBeUndefined(); + }); + + it("assertProfileColorUnlocked rejeita string que não é hex nem gradiente", async () => { + await expect(service.assertProfileColorUnlocked(userId, faker.string.alphanumeric(10))).rejects.toBeInstanceOf( + AppException, + ); + }); + + it("assertProfileColorUnlocked rejeita gradiente não possuído", async () => { + await expect(service.assertProfileColorUnlocked(userId, "gradient:dusk")).rejects.toBeInstanceOf(AppException); + }); + + it("assertProfileColorUnlocked aceita gradiente possuído", async () => { + mockUserCosmeticFindMany.mockResolvedValue([{ type: CosmeticType.ProfileColor, key: "dusk" }]); + + await expect(service.assertProfileColorUnlocked(userId, "gradient:dusk")).resolves.toBeUndefined(); + }); + + it("compra de gradiente debita o preço do catálogo", async () => { + mockUserCosmeticFindUnique.mockResolvedValue(null); + mockSpendCoins.mockResolvedValue({ amount: 200, balance: 0 }); + mockGetWalletByUserId.mockResolvedValue({ balance: 0, lifetimeEarned: 200, lifetimeSpent: 200 }); + + await service.purchaseCosmetic(userId, { type: CosmeticType.ProfileColor, key: "dusk" }); + + expect(mockSpendCoins).toHaveBeenCalledWith(expect.objectContaining({ amount: 200 }), tx); + expect(mockUserCosmeticCreate).toHaveBeenCalledWith({ + data: { userId, type: CosmeticType.ProfileColor, key: "dusk" }, + }); + }); + + it("assertCosmeticUnlocked rejeita key fora do catálogo", async () => { + await expect( + service.assertCosmeticUnlocked(userId, CosmeticType.AvatarFrame, faker.string.alphanumeric(10)), + ).rejects.toBeInstanceOf(AppException); + }); + + it("assertCosmeticUnlocked rejeita item comprável não possuído", async () => { + await expect(service.assertCosmeticUnlocked(userId, CosmeticType.ProfileTitle, "legend")).rejects.toBeInstanceOf( + AppException, + ); + }); + + it("assertCosmeticUnlocked aceita item possuído", async () => { + mockUserCosmeticFindMany.mockResolvedValue([{ type: CosmeticType.ProfileTitle, key: "legend" }]); + + await expect(service.assertCosmeticUnlocked(userId, CosmeticType.ProfileTitle, "legend")).resolves.toBeUndefined(); + }); + + it("assertCosmeticUnlocked sempre aceita none", async () => { + await expect(service.assertCosmeticUnlocked(userId, CosmeticType.BannerEffect, "none")).resolves.toBeUndefined(); + }); + + it("não vende item que não é de compra", async () => { + await expect( + service.purchaseCosmetic(userId, { type: CosmeticType.ProfileColor, key: "emerald" }), + ).rejects.toBeInstanceOf(AppException); + + expect(mockSpendCoins).not.toHaveBeenCalled(); + }); + + it("não vende item já possuído", async () => { + mockUserCosmeticFindUnique.mockResolvedValue({ id: faker.string.uuid() }); + + await expect( + service.purchaseCosmetic(userId, { type: CosmeticType.AvatarFrame, key: "glow" }), + ).rejects.toBeInstanceOf(AppException); + + expect(mockSpendCoins).not.toHaveBeenCalled(); + }); + + it("compra debita o preço do catálogo e grava o inventário na mesma transação", async () => { + mockUserCosmeticFindUnique.mockResolvedValue(null); + mockSpendCoins.mockResolvedValue({ amount: 100, balance: 50 }); + mockGetWalletByUserId.mockResolvedValue({ balance: 50, lifetimeEarned: 150, lifetimeSpent: 100 }); + + const result = await service.purchaseCosmetic(userId, { type: CosmeticType.AvatarFrame, key: "glow" }); + + expect(mockSpendCoins).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + reason: CoinReason.Purchase, + sourceKey: `purchase:${CosmeticType.AvatarFrame}:glow`, + amount: 100, + }), + tx, + ); + expect(mockUserCosmeticCreate).toHaveBeenCalledWith({ + data: { userId, type: CosmeticType.AvatarFrame, key: "glow" }, + }); + expect(result.wallet.balance).toBe(50); + expect(result.cosmetic).toMatchObject({ key: "glow", owned: true, unlocked: true }); + }); + + it("corrida perdida no sourceKey vira já possuído", async () => { + mockUserCosmeticFindUnique.mockResolvedValue(null); + mockSpendCoins.mockResolvedValue(null); + + await expect( + service.purchaseCosmetic(userId, { type: CosmeticType.AvatarFrame, key: "glow" }), + ).rejects.toBeInstanceOf(AppException); + + expect(mockUserCosmeticCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/mission/mission.service.spec.ts b/test/unit/mission/mission.service.spec.ts new file mode 100644 index 00000000..2f4cdd73 --- /dev/null +++ b/test/unit/mission/mission.service.spec.ts @@ -0,0 +1,180 @@ +import { faker } from "@faker-js/faker"; +import { ContentType, MissionMetric, MissionTier, XpReason } from "@prisma/generated/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MissionService } from "@/modules/mission/service/mission.service"; + +const mockMissionFindMany = vi.fn(); +const mockUserMissionUpsert = vi.fn(); +const mockUserMissionUpdate = vi.fn(); +const mockXpLedgerFindMany = vi.fn(); +const mockUserXpFindUnique = vi.fn(); +const mockUserContentXpFindUnique = vi.fn(); + +const mockDatabaseService = { + mission: { findMany: mockMissionFindMany }, + userMission: { upsert: mockUserMissionUpsert, update: mockUserMissionUpdate }, + xpLedger: { findMany: mockXpLedgerFindMany }, + userXp: { findUnique: mockUserXpFindUnique }, + userContentXp: { findUnique: mockUserContentXpFindUnique }, +}; + +function buildMission(overrides: Partial> = {}) { + return { + id: faker.string.uuid(), + key: faker.helpers.slugify(faker.word.words(2)).toLowerCase(), + metric: MissionMetric.EpisodesWatched, + contentType: null, + target: 10, + xpReward: 50, + coinReward: 5, + cosmeticKey: null, + medalId: null, + tier: MissionTier.Bronze, + position: 0, + hidden: false, + active: true, + ...overrides, + }; +} + +describe("MissionService", () => { + let service: MissionService; + let userId: string; + + beforeEach(() => { + vi.resetAllMocks(); + + userId = faker.string.uuid(); + service = new MissionService(mockDatabaseService as any); + + mockMissionFindMany.mockResolvedValue([]); + }); + + describe("advanceForXpReason", () => { + it("incrementa a missão de contador e não fecha antes da meta", async () => { + const mission = buildMission({ target: 10 }); + + mockMissionFindMany.mockResolvedValueOnce([mission]); + mockUserMissionUpsert.mockResolvedValueOnce({ id: faker.string.uuid(), progress: 3, completedAt: null }); + + const completed = await service.advanceForXpReason(userId, XpReason.EpisodeWatched, ContentType.Anime); + + expect(completed).toEqual([]); + expect(mockUserMissionUpsert).toHaveBeenCalledWith( + expect.objectContaining({ update: { progress: { increment: 1 } } }), + ); + }); + + it("fecha a missão quando a meta é batida", async () => { + const mission = buildMission({ target: 10 }); + const userMissionId = faker.string.uuid(); + + mockMissionFindMany.mockResolvedValueOnce([mission]); + mockUserMissionUpsert.mockResolvedValueOnce({ id: userMissionId, progress: 10, completedAt: null }); + mockUserMissionUpdate.mockResolvedValueOnce({ id: userMissionId, progress: 10, completedAt: new Date() }); + + const completed = await service.advanceForXpReason(userId, XpReason.EpisodeWatched, ContentType.Anime); + + expect(completed).toHaveLength(1); + expect(completed[0]?.mission.id).toBe(mission.id); + }); + + it("missão já concluída não fecha de novo", async () => { + mockMissionFindMany.mockResolvedValueOnce([buildMission({ target: 10 })]); + mockUserMissionUpsert.mockResolvedValueOnce({ id: faker.string.uuid(), progress: 20, completedAt: new Date() }); + + const completed = await service.advanceForXpReason(userId, XpReason.EpisodeWatched, ContentType.Anime); + + expect(completed).toEqual([]); + expect(mockUserMissionUpdate).not.toHaveBeenCalled(); + }); + + it("evento sem contentType só alimenta missões globais", async () => { + await service.advanceForXpReason(userId, XpReason.Followed, null); + + expect(mockMissionFindMany).toHaveBeenCalledWith({ + where: { metric: MissionMetric.UsersFollowed, active: true, contentType: null }, + }); + }); + + it("evento de uma mídia alimenta a missão global e a daquela mídia", async () => { + await service.advanceForXpReason(userId, XpReason.EpisodeWatched, ContentType.Anime); + + expect(mockMissionFindMany).toHaveBeenCalledWith({ + where: { + metric: MissionMetric.EpisodesWatched, + active: true, + OR: [{ contentType: null }, { contentType: ContentType.Anime }], + }, + }); + }); + + it("reason sem métrica mapeada não toca em missão nenhuma", async () => { + const completed = await service.advanceForXpReason(userId, XpReason.MissionCompleted, null); + + expect(completed).toEqual([]); + expect(mockMissionFindMany).not.toHaveBeenCalled(); + }); + + it("ContentTypesReviewed fecha com as 6 mídias", async () => { + const mission = buildMission({ metric: MissionMetric.ContentTypesReviewed, target: 6 }); + const userMissionId = faker.string.uuid(); + + // Primeira chamada: métrica de contador (ReviewsWritten). Segunda: a derivada. + mockMissionFindMany.mockResolvedValueOnce([]).mockResolvedValueOnce([mission]); + mockXpLedgerFindMany.mockResolvedValueOnce( + [ + ContentType.Anime, + ContentType.Manga, + ContentType.TVShow, + ContentType.Movie, + ContentType.Game, + ContentType.Book, + ].map((contentType) => ({ contentType })), + ); + mockUserMissionUpsert.mockResolvedValueOnce({ id: userMissionId, progress: 6, completedAt: null }); + mockUserMissionUpdate.mockResolvedValueOnce({ id: userMissionId, progress: 6, completedAt: new Date() }); + + const completed = await service.advanceForXpReason(userId, XpReason.ReviewAdded, ContentType.Book); + + expect(completed).toHaveLength(1); + expect(mockUserMissionUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { progress: 6 } })); + }); + + it("StreakReached mede o maior streak, não o atual", async () => { + mockMissionFindMany.mockResolvedValueOnce([buildMission({ metric: MissionMetric.StreakReached, target: 30 })]); + mockUserXpFindUnique.mockResolvedValueOnce({ currentStreak: 2, longestStreak: 12 }); + mockUserMissionUpsert.mockResolvedValueOnce({ id: faker.string.uuid(), progress: 12, completedAt: null }); + + await service.advanceForXpReason(userId, XpReason.StreakBonus, null); + + expect(mockUserMissionUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { progress: 12 } })); + }); + }); + + describe("advanceLevelReached", () => { + it("mede o level global quando a missão não tem contentType", async () => { + mockMissionFindMany.mockResolvedValueOnce([buildMission({ metric: MissionMetric.LevelReached, target: 10 })]); + mockUserXpFindUnique.mockResolvedValueOnce({ level: 7 }); + mockUserMissionUpsert.mockResolvedValueOnce({ id: faker.string.uuid(), progress: 7, completedAt: null }); + + const completed = await service.advanceLevelReached(userId); + + expect(completed).toEqual([]); + expect(mockUserMissionUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { progress: 7 } })); + }); + + it("mede o level da mídia quando a missão tem contentType", async () => { + mockMissionFindMany.mockResolvedValueOnce([ + buildMission({ metric: MissionMetric.LevelReached, target: 5, contentType: ContentType.Game }), + ]); + mockUserContentXpFindUnique.mockResolvedValueOnce({ xp: 5000 }); + mockUserMissionUpsert.mockResolvedValueOnce({ id: faker.string.uuid(), progress: 1, completedAt: null }); + + await service.advanceLevelReached(userId); + + expect(mockUserContentXpFindUnique).toHaveBeenCalled(); + expect(mockUserXpFindUnique).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/services/comment.service.spec.ts b/test/unit/services/comment.service.spec.ts index c18112aa..7219dc07 100644 --- a/test/unit/services/comment.service.spec.ts +++ b/test/unit/services/comment.service.spec.ts @@ -5,6 +5,7 @@ import { AppException } from "@/shared/exceptions/app.exceptions"; const mockCommentCreate = vi.fn(); const mockCommentFindUnique = vi.fn(); const mockToCommentNotificationJob = vi.fn(); +const mockToXpJob = vi.fn(); const mockDatabaseService = { comment: { @@ -15,6 +16,7 @@ const mockDatabaseService = { const mockQueueService = { toCommentNotificationJob: mockToCommentNotificationJob, + toXpJob: mockToXpJob, }; describe("CommentService", () => { diff --git a/test/unit/xp/xp-level.util.spec.ts b/test/unit/xp/xp-level.util.spec.ts new file mode 100644 index 00000000..7d9a20a2 --- /dev/null +++ b/test/unit/xp/xp-level.util.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { contentLevelFromXp, levelFromXp, levelProgress, xpForLevel } from "@/modules/xp/service/xp-level.util"; +import { CONTENT_XP_BASE, GLOBAL_XP_BASE } from "@/shared/constants/xp"; + +describe("xp-level.util", () => { + it("level 1 custa 0 xp", () => { + expect(xpForLevel(1)).toBe(0); + expect(xpForLevel(0)).toBe(0); + expect(levelFromXp(0)).toBe(1); + expect(levelFromXp(-10)).toBe(1); + }); + + it("a curva é estritamente crescente", () => { + for (let level = 2; level <= 200; level++) { + expect(xpForLevel(level)).toBeGreaterThan(xpForLevel(level - 1)); + } + }); + + it("levelFromXp é a inversa exata de xpForLevel", () => { + for (let level = 1; level <= 200; level++) { + expect(levelFromXp(xpForLevel(level))).toBe(level); + } + }); + + it("um xp abaixo do limiar ainda é o level anterior", () => { + for (let level = 2; level <= 200; level++) { + expect(levelFromXp(xpForLevel(level) - 1)).toBe(level - 1); + } + }); + + it("vale para as duas bases", () => { + for (const base of [GLOBAL_XP_BASE, CONTENT_XP_BASE]) { + for (let level = 1; level <= 200; level++) { + expect(levelFromXp(xpForLevel(level, base), base)).toBe(level); + } + } + }); + + it("a base por mídia progride mais rápido que a global", () => { + expect(contentLevelFromXp(3000)).toBeGreaterThan(levelFromXp(3000)); + }); + + it("levelProgress reporta a fatia dentro do level atual", () => { + const atFloor = levelProgress(xpForLevel(10)); + + expect(atFloor.level).toBe(10); + expect(atFloor.currentLevelXp).toBe(0); + expect(atFloor.percentage).toBe(0); + expect(atFloor.nextLevelXp).toBe(xpForLevel(11) - xpForLevel(10)); + + const midway = levelProgress(xpForLevel(10) + Math.floor(atFloor.nextLevelXp / 2)); + + expect(midway.level).toBe(10); + expect(midway.percentage).toBeGreaterThan(45); + expect(midway.percentage).toBeLessThan(55); + }); +}); diff --git a/test/unit/xp/xp.service.spec.ts b/test/unit/xp/xp.service.spec.ts new file mode 100644 index 00000000..4410b10d --- /dev/null +++ b/test/unit/xp/xp.service.spec.ts @@ -0,0 +1,220 @@ +import { faker } from "@faker-js/faker"; +import { ContentType, XpReason } from "@prisma/generated/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { XpService } from "@/modules/xp/service/xp.service"; +import { xpForLevel } from "@/modules/xp/service/xp-level.util"; +import { XP_RULES } from "@/shared/constants/xp"; + +const mockXpLedgerCreate = vi.fn(); +const mockXpLedgerAggregate = vi.fn(); +const mockUserXpUpsert = vi.fn(); +const mockUserXpUpdate = vi.fn(); +const mockUserXpFindUnique = vi.fn(); +const mockUserContentXpUpsert = vi.fn(); +const mockUserContentXpUpdate = vi.fn(); +const mockMedalFindMany = vi.fn(); +const mockUserMedalCreate = vi.fn(); + +const tx = { + xpLedger: { create: mockXpLedgerCreate, aggregate: mockXpLedgerAggregate }, + userXp: { upsert: mockUserXpUpsert, update: mockUserXpUpdate }, + userContentXp: { upsert: mockUserContentXpUpsert, update: mockUserContentXpUpdate }, +}; + +const mockDatabaseService = { + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + userXp: { findUnique: mockUserXpFindUnique, upsert: mockUserXpUpsert }, + medal: { findMany: mockMedalFindMany }, + userMedal: { create: mockUserMedalCreate }, +}; + +const uniqueViolation = Object.assign(new Error("unique"), { code: "P2002" }); + +describe("XpService", () => { + let service: XpService; + let userId: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + + userId = faker.string.uuid(); + service = new XpService(mockDatabaseService as any); + + mockXpLedgerAggregate.mockResolvedValue({ _sum: { amount: 0 } }); + }); + + describe("grantXp", () => { + it("concede o valor da tabela e devolve o level recalculado", async () => { + const amount = XP_RULES[XpReason.ReviewAdded].amount; + + mockUserXpUpsert.mockResolvedValueOnce({ totalXp: amount, level: 1 }); + mockUserContentXpUpsert.mockResolvedValueOnce({ xp: amount, level: 1 }); + + const result = await service.grantXp({ + userId, + reason: XpReason.ReviewAdded, + sourceKey: `review:Anime:${faker.string.uuid()}`, + contentType: ContentType.Anime, + }); + + expect(result?.amount).toBe(amount); + expect(result?.totalXp).toBe(amount); + expect(mockXpLedgerCreate).toHaveBeenCalledOnce(); + }); + + it("o mesmo sourceKey duas vezes paga uma só", async () => { + const sourceKey = `episode:Anime:${faker.string.uuid()}:1`; + + mockUserXpUpsert.mockResolvedValueOnce({ totalXp: 5, level: 1 }); + + const first = await service.grantXp({ userId, reason: XpReason.EpisodeWatched, sourceKey }); + + mockXpLedgerCreate.mockRejectedValueOnce(uniqueViolation); + + const second = await service.grantXp({ userId, reason: XpReason.EpisodeWatched, sourceKey }); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + }); + + it("o teto diário corta o excedente", async () => { + const { amount, dailyCap } = XP_RULES[XpReason.EpisodeWatched]; + + // Faltam 2 XP para o teto: a concessão de 5 é cortada em 2. + mockXpLedgerAggregate.mockResolvedValueOnce({ _sum: { amount: (dailyCap as number) - 2 } }); + mockUserXpUpsert.mockResolvedValueOnce({ totalXp: dailyCap as number, level: 1 }); + + const result = await service.grantXp({ + userId, + reason: XpReason.EpisodeWatched, + sourceKey: `episode:Anime:${faker.string.uuid()}:2`, + }); + + expect(amount).toBeGreaterThan(2); + expect(result?.amount).toBe(2); + }); + + it("teto esgotado não escreve no ledger", async () => { + mockXpLedgerAggregate.mockResolvedValueOnce({ _sum: { amount: XP_RULES[XpReason.EpisodeWatched].dailyCap } }); + + const result = await service.grantXp({ + userId, + reason: XpReason.EpisodeWatched, + sourceKey: `episode:Anime:${faker.string.uuid()}:3`, + }); + + expect(result).toBeNull(); + expect(mockXpLedgerCreate).not.toHaveBeenCalled(); + }); + + it("skipDailyCap ignora o teto (backfill)", async () => { + mockUserXpUpsert.mockResolvedValueOnce({ totalXp: 5, level: 1 }); + + const result = await service.grantXp({ + userId, + reason: XpReason.EpisodeWatched, + sourceKey: `episode:Anime:${faker.string.uuid()}:4`, + skipDailyCap: true, + }); + + expect(result?.amount).toBe(XP_RULES[XpReason.EpisodeWatched].amount); + expect(mockXpLedgerAggregate).not.toHaveBeenCalled(); + }); + + it("marca leveledUp quando o total cruza o limiar", async () => { + const amount = XP_RULES[XpReason.ReviewAdded].amount; + const totalXp = xpForLevel(4) + 1; + + mockUserXpUpsert.mockResolvedValueOnce({ totalXp, level: 3 }); + + const result = await service.grantXp({ + userId, + reason: XpReason.ReviewAdded, + sourceKey: `review:Game:${faker.string.uuid()}`, + amount, + }); + + expect(result?.leveledUp).toBe(true); + expect(result?.level).toBe(4); + expect(result?.previousLevel).toBeLessThan(4); + }); + }); + + describe("touchStreak", () => { + it("conta ontem → hoje como sequência", async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + + mockUserXpFindUnique.mockResolvedValueOnce({ lastActiveDate: yesterday, currentStreak: 3, longestStreak: 5 }); + + const result = await service.touchStreak(userId); + + expect(result?.currentStreak).toBe(4); + expect(result?.longestStreak).toBe(5); + }); + + it("reseta quando há buraco na sequência", async () => { + const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + mockUserXpFindUnique.mockResolvedValueOnce({ lastActiveDate: lastWeek, currentStreak: 9, longestStreak: 9 }); + + const result = await service.touchStreak(userId); + + expect(result?.currentStreak).toBe(1); + expect(result?.longestStreak).toBe(9); + }); + + it("não conta duas vezes no mesmo dia", async () => { + mockUserXpFindUnique.mockResolvedValueOnce({ lastActiveDate: new Date(), currentStreak: 2, longestStreak: 2 }); + + const result = await service.touchStreak(userId); + + expect(result).toBeNull(); + expect(mockUserXpUpsert).not.toHaveBeenCalled(); + }); + + it("o bônus cresce com a sequência e satura", async () => { + const at3 = new Date(Date.now() - 24 * 60 * 60 * 1000); + + mockUserXpFindUnique.mockResolvedValueOnce({ lastActiveDate: at3, currentStreak: 2, longestStreak: 2 }); + const small = await service.touchStreak(userId); + + mockUserXpFindUnique.mockResolvedValueOnce({ lastActiveDate: at3, currentStreak: 40, longestStreak: 40 }); + const saturated = await service.touchStreak(userId); + + mockUserXpFindUnique.mockResolvedValueOnce({ lastActiveDate: at3, currentStreak: 400, longestStreak: 400 }); + const stillSaturated = await service.touchStreak(userId); + + expect(saturated?.bonusXp).toBeGreaterThan(small?.bonusXp as number); + expect(stillSaturated?.bonusXp).toBe(saturated?.bonusXp); + }); + }); + + describe("grantMilestoneMedals", () => { + it("concede só as medalhas dos marcos cruzados agora", async () => { + mockMedalFindMany.mockResolvedValueOnce([{ id: faker.string.uuid(), name: "level-10" }]); + mockUserMedalCreate.mockResolvedValueOnce({ id: faker.string.uuid() }); + + const granted = await service.grantMilestoneMedals(userId, 9, 11); + + expect(granted).toHaveLength(1); + expect(mockMedalFindMany).toHaveBeenCalledWith({ where: { name: { in: ["level-10"] } } }); + }); + + it("não consulta nada quando nenhum marco foi cruzado", async () => { + const granted = await service.grantMilestoneMedals(userId, 11, 12); + + expect(granted).toEqual([]); + expect(mockMedalFindMany).not.toHaveBeenCalled(); + }); + + it("quem já tem a medalha não recebe de novo", async () => { + mockMedalFindMany.mockResolvedValueOnce([{ id: faker.string.uuid(), name: "level-25" }]); + mockUserMedalCreate.mockRejectedValueOnce(uniqueViolation); + + const granted = await service.grantMilestoneMedals(userId, 24, 25); + + expect(granted).toEqual([]); + }); + }); +});