diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..2122d21 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Enable Biome formatter and format the codebase (bulk reformat — not a logic change) +4cae2a854b235f35cd80befa9b7450dc71f5dd97 diff --git a/biome.json b/biome.json index abee27d..5ba1a35 100644 --- a/biome.json +++ b/biome.json @@ -5,7 +5,7 @@ "ignoreUnknown": false, "includes": ["**", "!build/**", "!.agent/**", "!src/evolution-types/**"] }, - "formatter": { "enabled": false }, + "formatter": { "enabled": true, "indentStyle": "tab", "lineWidth": 100 }, "assist": { "enabled": false }, "linter": { "enabled": true, diff --git a/commitlint.config.js b/commitlint.config.js index 3f5e287..fa584fb 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1 +1 @@ -export default { extends: ['@commitlint/config-conventional'] }; +export default { extends: ["@commitlint/config-conventional"] }; diff --git a/package.json b/package.json index ada8206..594729f 100644 --- a/package.json +++ b/package.json @@ -1,56 +1,57 @@ { - "name": "evolution-api", - "version": "1.0.50", - "type": "module", - "scripts": { - "test": "bun test", - "test:coverage": "bun test --coverage", - "dev": "bun run --watch src/index.ts", - "migration:cosmetics:run": "bun run src/scripts/run-cosmetics-migrations.ts", - "migration:cosmetics:revert": "bun run src/scripts/run-cosmetics-migrations.ts --revert", - "seed:cosmetics": "bun run src/scripts/seed-cosmetics.ts", - "lint": "biome lint .", - "lint:fix": "biome lint --write .", - "prepare": "if [ \"$NODE_ENV\" != \"production\" ]; then husky install; fi", - "build": "tsc" - }, - "dependencies": { - "@elysiajs/bearer": "^1.4.4", - "@elysiajs/cors": "^1.4.2", - "@elysiajs/swagger": "^1.3.1", - "@sendgrid/mail": "^8.1.6", - "bcrypt": "^6.0.0", - "dotenv": "^17.2.3", - "elysia": "^1.4.29", - "elysia-rate-limit": "^4.5.0", - "jsonwebtoken": "^9.0.3", - "pg": "^8.18.0", - "pino": "^10.3.0", - "pino-pretty": "^13.1.3", - "resend": "6.4.2", - "typeorm": "^0.3.28" - }, - "devDependencies": { - "@biomejs/biome": "2.5.0", - "@commitlint/cli": "^20.4.1", - "@commitlint/config-conventional": "^20.4.1", - "@faker-js/faker": "^10.2.0", - "@types/autocannon": "^7.12.7", - "@types/jsonwebtoken": "^9.0.10", - "autocannon": "^8.0.0", - "bun-types": "^1.3.8", - "husky": "^9.1.7", - "install": "^0.13.0", - "lint-staged": "^16.2.7", - "typescript": "5.9.3" - }, - "overrides": { - "axios": "^1.18.0", - "form-data": "^4.0.6", - "follow-redirects": "^1.16.0" - }, - "module": "src/index.js", - "lint-staged": { - "*.{js,ts}": "biome lint --write --no-errors-on-unmatched" - } + "name": "evolution-api", + "version": "1.0.50", + "type": "module", + "scripts": { + "test": "bun test", + "test:coverage": "bun test --coverage", + "dev": "bun run --watch src/index.ts", + "migration:cosmetics:run": "bun run src/scripts/run-cosmetics-migrations.ts", + "migration:cosmetics:revert": "bun run src/scripts/run-cosmetics-migrations.ts --revert", + "seed:cosmetics": "bun run src/scripts/seed-cosmetics.ts", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "prepare": "if [ \"$NODE_ENV\" != \"production\" ]; then husky install; fi", + "build": "tsc" + }, + "dependencies": { + "@elysiajs/bearer": "^1.4.4", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/swagger": "^1.3.1", + "@sendgrid/mail": "^8.1.6", + "bcrypt": "^6.0.0", + "dotenv": "^17.2.3", + "elysia": "^1.4.29", + "elysia-rate-limit": "^4.5.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.18.0", + "pino": "^10.3.0", + "pino-pretty": "^13.1.3", + "resend": "6.4.2", + "typeorm": "^0.3.28" + }, + "devDependencies": { + "@biomejs/biome": "2.5.0", + "@commitlint/cli": "^20.4.1", + "@commitlint/config-conventional": "^20.4.1", + "@faker-js/faker": "^10.2.0", + "@types/autocannon": "^7.12.7", + "@types/jsonwebtoken": "^9.0.10", + "autocannon": "^8.0.0", + "bun-types": "^1.3.8", + "husky": "^9.1.7", + "install": "^0.13.0", + "lint-staged": "^16.2.7", + "typescript": "5.9.3" + }, + "overrides": { + "axios": "^1.18.0", + "form-data": "^4.0.6", + "follow-redirects": "^1.16.0" + }, + "module": "src/index.js", + "lint-staged": { + "*.{js,ts}": "biome check --write --no-errors-on-unmatched" + } } diff --git a/src/config/index.ts b/src/config/index.ts index 1fae783..5ade122 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -16,7 +16,10 @@ export const config = { sendgrid: { apiKey: ensureEnvVariable(process.env.SENDGRID_API_KEY as string, "SENDGRID_API_KEY"), from: ensureEnvVariable(process.env.SENDGRID_FROM_EMAIL as string, "SENDGRID_FROM_EMAIL"), - templateId: ensureEnvVariable(process.env.SENDGRID_TEMPLATE_ID as string, "SENDGRID_TEMPLATE_ID"), + templateId: ensureEnvVariable( + process.env.SENDGRID_TEMPLATE_ID as string, + "SENDGRID_TEMPLATE_ID", + ), }, resend: { apiKey: ensureEnvVariable(process.env.RESEND_API_KEY as string, "RESEND_API_KEY"), @@ -31,7 +34,10 @@ export const config = { }, r2: { accessKeyId: ensureEnvVariable(process.env.R2_ACCESS_KEY_ID as string, "R2_ACCESS_KEY_ID"), - secretAccessKey: ensureEnvVariable(process.env.R2_SECRET_ACCESS_KEY as string, "R2_SECRET_ACCESS_KEY"), + secretAccessKey: ensureEnvVariable( + process.env.R2_SECRET_ACCESS_KEY as string, + "R2_SECRET_ACCESS_KEY", + ), bucket: ensureEnvVariable(process.env.R2_BUCKET as string, "R2_BUCKET"), endpoint: ensureEnvVariable(process.env.R2_ENDPOINT as string, "R2_ENDPOINT"), signedUrlTtlSeconds: Number(process.env.R2_SIGNED_URL_TTL ?? "600"), @@ -39,25 +45,34 @@ export const config = { season: Number(ensureEnvVariable(process.env.SEASON as string, "SEASON")), tournaments: { apiUrl: ensureEnvVariable(process.env.TOURNAMENTS_API_URL as string, "TOURNAMENTS_API_URL"), - webhookUrl: ensureEnvVariable(process.env.TOURNAMENTS_WEBHOOK_URL as string, "TOURNAMENTS_WEBHOOK_URL"), + webhookUrl: ensureEnvVariable( + process.env.TOURNAMENTS_WEBHOOK_URL as string, + "TOURNAMENTS_WEBHOOK_URL", + ), }, passwordRecovery: { defaultResetUrl: "https://evolutionygo.com/reset-account-password?token={token}", frontends: [ - { origin: "https://evolutionygo.com", template: "https://evolutionygo.com/reset-account-password?token={token}" }, - { origin: "https://evoduel.com", template: "https://evoduel.com/#/reset-account-password?token={token}" }, + { + origin: "https://evolutionygo.com", + template: "https://evolutionygo.com/reset-account-password?token={token}", + }, + { + origin: "https://evoduel.com", + template: "https://evoduel.com/#/reset-account-password?token={token}", + }, ...(isProduction ? [] : [ - { - origin: "http://localhost:5173", - template: "http://localhost:5173/#/reset-account-password?token={token}", - }, - { - origin: "http://localhost:4321", - template: "http://localhost:4321/reset-account-password?token={token}", - } - ]), + { + origin: "http://localhost:5173", + template: "http://localhost:5173/#/reset-account-password?token={token}", + }, + { + origin: "http://localhost:4321", + template: "http://localhost:4321/reset-account-password?token={token}", + }, + ]), ], }, }; diff --git a/src/migrations/1780873543822-create_cosmetics_tables.ts b/src/migrations/1780873543822-create_cosmetics_tables.ts index 34eade6..46d5b1c 100644 --- a/src/migrations/1780873543822-create_cosmetics_tables.ts +++ b/src/migrations/1780873543822-create_cosmetics_tables.ts @@ -7,49 +7,49 @@ export class CreateCosmeticsTables1780873543822 implements MigrationInterface { await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); await queryRunner.query( - `CREATE TYPE "cosmetic_type_enum" AS ENUM('SLEEVE', 'PLAYMAT', 'CARD_BACK', 'AVATAR', 'SUMMON_EFFECT', 'MUSIC', 'TITLE')` + `CREATE TYPE "cosmetic_type_enum" AS ENUM('SLEEVE', 'PLAYMAT', 'CARD_BACK', 'AVATAR', 'SUMMON_EFFECT', 'MUSIC', 'TITLE')`, ); await queryRunner.query( - `CREATE TYPE "cosmetic_tier_enum" AS ENUM('STANDARD', 'REGISTERED', 'DONOR')` + `CREATE TYPE "cosmetic_tier_enum" AS ENUM('STANDARD', 'REGISTERED', 'DONOR')`, ); await queryRunner.query(`CREATE TYPE "grant_type_enum" AS ENUM('TIER', 'COSMETIC')`); await queryRunner.query( - `CREATE TYPE "entitlement_source_enum" AS ENUM('REGISTRATION', 'DONATION', 'PURCHASE', 'CAMPAIGN')` + `CREATE TYPE "entitlement_source_enum" AS ENUM('REGISTRATION', 'DONATION', 'PURCHASE', 'CAMPAIGN')`, ); await queryRunner.query( - `CREATE TABLE "cosmetics" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" "cosmetic_type_enum" NOT NULL, "tier" "cosmetic_tier_enum" NOT NULL, "asset_ref" character varying NOT NULL, "display_name" character varying NOT NULL, "active" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_cosmetics_id" PRIMARY KEY ("id"))` + `CREATE TABLE "cosmetics" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" "cosmetic_type_enum" NOT NULL, "tier" "cosmetic_tier_enum" NOT NULL, "asset_ref" character varying NOT NULL, "display_name" character varying NOT NULL, "active" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_cosmetics_id" PRIMARY KEY ("id"))`, ); await queryRunner.query( - `CREATE TABLE "user_loadouts" ("user_id" character varying NOT NULL, "cosmetic_type" "cosmetic_type_enum" NOT NULL, "cosmetic_id" uuid NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_user_loadouts" PRIMARY KEY ("user_id", "cosmetic_type"))` + `CREATE TABLE "user_loadouts" ("user_id" character varying NOT NULL, "cosmetic_type" "cosmetic_type_enum" NOT NULL, "cosmetic_id" uuid NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_user_loadouts" PRIMARY KEY ("user_id", "cosmetic_type"))`, ); await queryRunner.query( - `CREATE TABLE "entitlements" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" character varying NOT NULL, "grant_type" "grant_type_enum" NOT NULL, "grant_value" character varying NOT NULL, "source" "entitlement_source_enum" NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_entitlements_id" PRIMARY KEY ("id"))` + `CREATE TABLE "entitlements" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" character varying NOT NULL, "grant_type" "grant_type_enum" NOT NULL, "grant_value" character varying NOT NULL, "source" "entitlement_source_enum" NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_entitlements_id" PRIMARY KEY ("id"))`, ); await queryRunner.query( - `CREATE INDEX "IDX_entitlements_user_id" ON "entitlements" ("user_id")` + `CREATE INDEX "IDX_entitlements_user_id" ON "entitlements" ("user_id")`, ); // Foreign keys to the shared users table (users.id is varchar). Declared in raw // SQL so the cosmetics DataSource never needs to map the shared UserProfileEntity. await queryRunner.query( - `ALTER TABLE "user_loadouts" ADD CONSTRAINT "FK_user_loadouts_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + `ALTER TABLE "user_loadouts" ADD CONSTRAINT "FK_user_loadouts_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "user_loadouts" ADD CONSTRAINT "FK_user_loadouts_cosmetic" FOREIGN KEY ("cosmetic_id") REFERENCES "cosmetics"("id") ON DELETE RESTRICT ON UPDATE NO ACTION` + `ALTER TABLE "user_loadouts" ADD CONSTRAINT "FK_user_loadouts_cosmetic" FOREIGN KEY ("cosmetic_id") REFERENCES "cosmetics"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "entitlements" ADD CONSTRAINT "FK_entitlements_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + `ALTER TABLE "entitlements" ADD CONSTRAINT "FK_entitlements_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "entitlements" DROP CONSTRAINT "FK_entitlements_user"`); await queryRunner.query( - `ALTER TABLE "user_loadouts" DROP CONSTRAINT "FK_user_loadouts_cosmetic"` + `ALTER TABLE "user_loadouts" DROP CONSTRAINT "FK_user_loadouts_cosmetic"`, ); await queryRunner.query(`ALTER TABLE "user_loadouts" DROP CONSTRAINT "FK_user_loadouts_user"`); diff --git a/src/migrations/1781016036903-set_sleeve_tiers.ts b/src/migrations/1781016036903-set_sleeve_tiers.ts index aa3e1f1..23664e4 100644 --- a/src/migrations/1781016036903-set_sleeve_tiers.ts +++ b/src/migrations/1781016036903-set_sleeve_tiers.ts @@ -9,11 +9,7 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class SetSleeveTiers1781016036903 implements MigrationInterface { name = "SetSleeveTiers1781016036903"; - private readonly refs = [ - "sleeves/baby-frog/", - "sleeves/kagura/", - "sleeves/mystical-witch/", - ]; + private readonly refs = ["sleeves/baby-frog/", "sleeves/kagura/", "sleeves/mystical-witch/"]; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( diff --git a/src/modules/catalog/application/standardCosmetics.ts b/src/modules/catalog/application/standardCosmetics.ts index 2b43fe5..a4bef4c 100644 --- a/src/modules/catalog/application/standardCosmetics.ts +++ b/src/modules/catalog/application/standardCosmetics.ts @@ -16,19 +16,94 @@ export interface StandardCosmeticSeed { // tier of an already-seeded cosmetic, so changing an existing tier needs a // data migration (see SetSleeveTiers). export const STANDARD_COSMETICS: StandardCosmeticSeed[] = [ - { type: CosmeticType.SLEEVE, tier: CosmeticTier.REGISTERED, assetRef: "sleeves/baby-frog/", displayName: "Baby Frog" }, - { type: CosmeticType.SLEEVE, tier: CosmeticTier.STANDARD, assetRef: "sleeves/classic/", displayName: "Classic" }, - { type: CosmeticType.SLEEVE, tier: CosmeticTier.REGISTERED, assetRef: "sleeves/kagura/", displayName: "Kagura" }, - { type: CosmeticType.SLEEVE, tier: CosmeticTier.REGISTERED, assetRef: "sleeves/mystical-witch/", displayName: "Mystical Witch" }, - { type: CosmeticType.SLEEVE, tier: CosmeticTier.STANDARD, assetRef: "sleeves/evolution/", displayName: "Evolution" }, - { type: CosmeticType.SLEEVE, tier: CosmeticTier.STANDARD, assetRef: "sleeves/evolution-black/", displayName: "Evolution Black" }, - { type: CosmeticType.PLAYMAT, tier: CosmeticTier.STANDARD, assetRef: "playmats/pallet-covered-a/", displayName: "Pallet Covered A" }, - { type: CosmeticType.PLAYMAT, tier: CosmeticTier.STANDARD, assetRef: "playmats/pallet-covered-b/", displayName: "Pallet Covered B" }, - { type: CosmeticType.PLAYMAT, tier: CosmeticTier.STANDARD, assetRef: "playmats/pallet-wood/", displayName: "Pallet Wood" }, - { type: CosmeticType.PLAYMAT, tier: CosmeticTier.STANDARD, assetRef: "playmats/plaque/", displayName: "Plaque" }, - { type: CosmeticType.AVATAR, tier: CosmeticTier.REGISTERED, assetRef: "avatars/baby-frog/", displayName: "Baby Frog" }, - { type: CosmeticType.AVATAR, tier: CosmeticTier.REGISTERED, assetRef: "avatars/kagura/", displayName: "Kagura" }, - { type: CosmeticType.AVATAR, tier: CosmeticTier.REGISTERED, assetRef: "avatars/mystical-witch/", displayName: "Mystical Witch" }, - { type: CosmeticType.AVATAR, tier: CosmeticTier.STANDARD, assetRef: "avatars/evolution/", displayName: "Evolution" }, - { type: CosmeticType.AVATAR, tier: CosmeticTier.STANDARD, assetRef: "avatars/evolution-black/", displayName: "Evolution Black" }, + { + type: CosmeticType.SLEEVE, + tier: CosmeticTier.REGISTERED, + assetRef: "sleeves/baby-frog/", + displayName: "Baby Frog", + }, + { + type: CosmeticType.SLEEVE, + tier: CosmeticTier.STANDARD, + assetRef: "sleeves/classic/", + displayName: "Classic", + }, + { + type: CosmeticType.SLEEVE, + tier: CosmeticTier.REGISTERED, + assetRef: "sleeves/kagura/", + displayName: "Kagura", + }, + { + type: CosmeticType.SLEEVE, + tier: CosmeticTier.REGISTERED, + assetRef: "sleeves/mystical-witch/", + displayName: "Mystical Witch", + }, + { + type: CosmeticType.SLEEVE, + tier: CosmeticTier.STANDARD, + assetRef: "sleeves/evolution/", + displayName: "Evolution", + }, + { + type: CosmeticType.SLEEVE, + tier: CosmeticTier.STANDARD, + assetRef: "sleeves/evolution-black/", + displayName: "Evolution Black", + }, + { + type: CosmeticType.PLAYMAT, + tier: CosmeticTier.STANDARD, + assetRef: "playmats/pallet-covered-a/", + displayName: "Pallet Covered A", + }, + { + type: CosmeticType.PLAYMAT, + tier: CosmeticTier.STANDARD, + assetRef: "playmats/pallet-covered-b/", + displayName: "Pallet Covered B", + }, + { + type: CosmeticType.PLAYMAT, + tier: CosmeticTier.STANDARD, + assetRef: "playmats/pallet-wood/", + displayName: "Pallet Wood", + }, + { + type: CosmeticType.PLAYMAT, + tier: CosmeticTier.STANDARD, + assetRef: "playmats/plaque/", + displayName: "Plaque", + }, + { + type: CosmeticType.AVATAR, + tier: CosmeticTier.REGISTERED, + assetRef: "avatars/baby-frog/", + displayName: "Baby Frog", + }, + { + type: CosmeticType.AVATAR, + tier: CosmeticTier.REGISTERED, + assetRef: "avatars/kagura/", + displayName: "Kagura", + }, + { + type: CosmeticType.AVATAR, + tier: CosmeticTier.REGISTERED, + assetRef: "avatars/mystical-witch/", + displayName: "Mystical Witch", + }, + { + type: CosmeticType.AVATAR, + tier: CosmeticTier.STANDARD, + assetRef: "avatars/evolution/", + displayName: "Evolution", + }, + { + type: CosmeticType.AVATAR, + tier: CosmeticTier.STANDARD, + assetRef: "avatars/evolution-black/", + displayName: "Evolution Black", + }, ]; diff --git a/src/modules/catalog/domain/Cosmetic.ts b/src/modules/catalog/domain/Cosmetic.ts index 92d7725..45ee7ba 100644 --- a/src/modules/catalog/domain/Cosmetic.ts +++ b/src/modules/catalog/domain/Cosmetic.ts @@ -48,7 +48,14 @@ export class Cosmetic { displayName: string; active: boolean; }): Cosmetic { - return new Cosmetic(data.id, data.type, data.tier, data.assetRef, data.displayName, data.active); + return new Cosmetic( + data.id, + data.type, + data.tier, + data.assetRef, + data.displayName, + data.active, + ); } toPrimitives(): { diff --git a/src/modules/entitlements/application/EntitlementsGatekeeper.ts b/src/modules/entitlements/application/EntitlementsGatekeeper.ts index b3b05d8..867cf0b 100644 --- a/src/modules/entitlements/application/EntitlementsGatekeeper.ts +++ b/src/modules/entitlements/application/EntitlementsGatekeeper.ts @@ -18,9 +18,7 @@ export class EntitlementsGatekeeper { const isDonor = granted.some( (e) => - e.grantType === GrantType.TIER && - e.grantValue === CosmeticTier.DONOR && - e.isActiveAt(at), + e.grantType === GrantType.TIER && e.grantValue === CosmeticTier.DONOR && e.isActiveAt(at), ); const cosmeticIds = new Set( diff --git a/src/modules/loadout/application/EquipCosmetic.ts b/src/modules/loadout/application/EquipCosmetic.ts index 8695ef2..8f818f3 100644 --- a/src/modules/loadout/application/EquipCosmetic.ts +++ b/src/modules/loadout/application/EquipCosmetic.ts @@ -27,7 +27,9 @@ export class EquipCosmetic { throw new NotFoundError(`Cosmetic ${cosmeticId} not found`); } if (cosmetic.type !== cosmeticType) { - throw new InvalidArgumentError(`Cosmetic ${cosmeticId} cannot be equipped in the ${cosmeticType} slot`); + throw new InvalidArgumentError( + `Cosmetic ${cosmeticId} cannot be equipped in the ${cosmeticType} slot`, + ); } // Access is always decided by the gate — never compared here (RFC §8). if (!(await this.gatekeeper.canUse(userId, cosmetic))) { diff --git a/src/modules/loadout/domain/Loadout.ts b/src/modules/loadout/domain/Loadout.ts index 092aea1..2a68f94 100644 --- a/src/modules/loadout/domain/Loadout.ts +++ b/src/modules/loadout/domain/Loadout.ts @@ -29,6 +29,9 @@ export class Loadout { } items(): LoadoutItem[] { - return Array.from(this.equipped, ([cosmeticType, cosmeticId]) => ({ cosmeticType, cosmeticId })); + return Array.from(this.equipped, ([cosmeticType, cosmeticId]) => ({ + cosmeticType, + cosmeticId, + })); } } diff --git a/src/modules/stats/application/GetBestPlayerOfLastCompletedWeek.ts b/src/modules/stats/application/GetBestPlayerOfLastCompletedWeek.ts index 05a729d..36a8f8e 100644 --- a/src/modules/stats/application/GetBestPlayerOfLastCompletedWeek.ts +++ b/src/modules/stats/application/GetBestPlayerOfLastCompletedWeek.ts @@ -2,9 +2,9 @@ import { PeriodUserStats } from "../domain/PeriodUserStats"; import { UserStatsRepository } from "../domain/UserStatsRepository"; export class GetBestPlayerOfLastCompletedWeek { - constructor(private readonly repository: UserStatsRepository) {} + constructor(private readonly repository: UserStatsRepository) {} - async get(): Promise { - return this.repository.getBestPlayerOfLastCompletedWeek(); - } -} \ No newline at end of file + async get(): Promise { + return this.repository.getBestPlayerOfLastCompletedWeek(); + } +} diff --git a/src/modules/stats/application/GetGlobalStats.ts b/src/modules/stats/application/GetGlobalStats.ts index eb3f985..377576a 100644 --- a/src/modules/stats/application/GetGlobalStats.ts +++ b/src/modules/stats/application/GetGlobalStats.ts @@ -1,23 +1,29 @@ -import { BanListBreakdown, ChartData, DailyDuelStat, GlobalStats, GlobalStatsRepository } from "../domain/GlobalStats"; +import { + BanListBreakdown, + ChartData, + DailyDuelStat, + GlobalStats, + GlobalStatsRepository, +} from "../domain/GlobalStats"; export interface GlobalStatsResponse { - stats: GlobalStats; - historical: ChartData[]; - banListBreakdown: BanListBreakdown[]; - dailyDuels: DailyDuelStat[]; + stats: GlobalStats; + historical: ChartData[]; + banListBreakdown: BanListBreakdown[]; + dailyDuels: DailyDuelStat[]; } export class GetGlobalStats { - constructor(private readonly repository: GlobalStatsRepository) { } + constructor(private readonly repository: GlobalStatsRepository) {} - async execute(season: number): Promise { - const [stats, historical, banListBreakdown, dailyDuels] = await Promise.all([ - this.repository.getGlobalStats(season), - this.repository.getDuelsPerSeason(), - this.repository.getDuelsPerBanList(season), - this.repository.getDailyDuels(season) - ]); + async execute(season: number): Promise { + const [stats, historical, banListBreakdown, dailyDuels] = await Promise.all([ + this.repository.getGlobalStats(season), + this.repository.getDuelsPerSeason(), + this.repository.getDuelsPerBanList(season), + this.repository.getDailyDuels(season), + ]); - return { stats, historical, banListBreakdown, dailyDuels }; - } + return { stats, historical, banListBreakdown, dailyDuels }; + } } diff --git a/src/modules/stats/domain/GlobalStats.ts b/src/modules/stats/domain/GlobalStats.ts index 30f8b08..10e482c 100644 --- a/src/modules/stats/domain/GlobalStats.ts +++ b/src/modules/stats/domain/GlobalStats.ts @@ -1,30 +1,30 @@ export interface GlobalStats { - totalDuels: number; - activeBanLists: number; - avgDuelsPerBanList: number; + totalDuels: number; + activeBanLists: number; + avgDuelsPerBanList: number; } export interface ChartData { - name: string; - value: number; + name: string; + value: number; } export interface BanListBreakdown { - banListName: string; - totalDuels: number; - percentage: number; - popularity: number; // 0-100 scale for UI + banListName: string; + totalDuels: number; + percentage: number; + popularity: number; // 0-100 scale for UI } export interface DailyDuelStat { - date: string; - banListName: string; - count: number; + date: string; + banListName: string; + count: number; } export interface GlobalStatsRepository { - getGlobalStats(season: number): Promise; - getDuelsPerSeason(): Promise; - getDuelsPerBanList(season: number): Promise; - getDailyDuels(season: number): Promise; + getGlobalStats(season: number): Promise; + getDuelsPerSeason(): Promise; + getDuelsPerBanList(season: number): Promise; + getDailyDuels(season: number): Promise; } diff --git a/src/modules/stats/domain/PeriodUserStats.ts b/src/modules/stats/domain/PeriodUserStats.ts index a2bc7f2..a48e65c 100644 --- a/src/modules/stats/domain/PeriodUserStats.ts +++ b/src/modules/stats/domain/PeriodUserStats.ts @@ -1,47 +1,47 @@ export class PeriodUserStats { - public readonly userId: string; - public readonly username: string; - public readonly points: number; - public readonly wins: number; - public readonly losses: number; - public readonly from: string; - public readonly to: string; + public readonly userId: string; + public readonly username: string; + public readonly points: number; + public readonly wins: number; + public readonly losses: number; + public readonly from: string; + public readonly to: string; - private constructor({ - userId, - username, - points, - wins, - losses, - from, - to, - }: { - userId: string; - username: string; - points: number; - wins: number; - losses: number; - from: string; - to: string; - }) { - this.userId = userId; - this.username = username; - this.points = points; - this.wins = wins; - this.losses = losses; - this.from = from; - this.to = to; - } + private constructor({ + userId, + username, + points, + wins, + losses, + from, + to, + }: { + userId: string; + username: string; + points: number; + wins: number; + losses: number; + from: string; + to: string; + }) { + this.userId = userId; + this.username = username; + this.points = points; + this.wins = wins; + this.losses = losses; + this.from = from; + this.to = to; + } - static from(data: { - userId: string; - username: string; - points: number; - wins: number; - losses: number; - from: string; - to: string; - }): PeriodUserStats { - return new PeriodUserStats(data); - } -} \ No newline at end of file + static from(data: { + userId: string; + username: string; + points: number; + wins: number; + losses: number; + from: string; + to: string; + }): PeriodUserStats { + return new PeriodUserStats(data); + } +} diff --git a/src/modules/stats/infrastructure/GlobalStatsPostgresRepository.ts b/src/modules/stats/infrastructure/GlobalStatsPostgresRepository.ts index 6eec52c..71e5887 100644 --- a/src/modules/stats/infrastructure/GlobalStatsPostgresRepository.ts +++ b/src/modules/stats/infrastructure/GlobalStatsPostgresRepository.ts @@ -1,41 +1,50 @@ import { dataSource } from "../../../evolution-types/src/data-source"; -import { BanListBreakdown, ChartData, DailyDuelStat, GlobalStats, GlobalStatsRepository } from "../domain/GlobalStats"; +import { + BanListBreakdown, + ChartData, + DailyDuelStat, + GlobalStats, + GlobalStatsRepository, +} from "../domain/GlobalStats"; interface StatsRow { - season?: number; - ban_list_name?: string; - total_duels: number; + season?: number; + ban_list_name?: string; + total_duels: number; } interface DailyStatsRow { - date: string; - ban_list_name: string; - count: number; + date: string; + ban_list_name: string; + count: number; } export class GlobalStatsPostgresRepository implements GlobalStatsRepository { - async getGlobalStats(season: number): Promise { - const result = await dataSource.query(` + async getGlobalStats(season: number): Promise { + const result = await dataSource.query( + ` SELECT COALESCE(SUM(total_duels), 0)::int as total_duels, COUNT(DISTINCT ban_list_name)::int as active_banlists FROM stats_daily_summary WHERE season = $1 - `, [season]); + `, + [season], + ); - const totalDuels = result[0]?.total_duels || 0; - const activeBanLists = result[0]?.active_banlists || 0; - const avgDuelsPerBanList = activeBanLists > 0 ? Math.floor(totalDuels / activeBanLists) : 0; + const totalDuels = result[0]?.total_duels || 0; + const activeBanLists = result[0]?.active_banlists || 0; + const avgDuelsPerBanList = activeBanLists > 0 ? Math.floor(totalDuels / activeBanLists) : 0; - return { - totalDuels, - activeBanLists, - avgDuelsPerBanList - }; - } + return { + totalDuels, + activeBanLists, + avgDuelsPerBanList, + }; + } - async getDuelsPerSeason(): Promise { - const result = await dataSource.query(` + async getDuelsPerSeason(): Promise { + const result = await dataSource.query(` SELECT season, SUM(total_duels)::int as total_duels @@ -44,14 +53,15 @@ export class GlobalStatsPostgresRepository implements GlobalStatsRepository { ORDER BY season ASC `); - return result.map((row: StatsRow) => ({ - name: `Season ${row.season}`, - value: row.total_duels - })); - } + return result.map((row: StatsRow) => ({ + name: `Season ${row.season}`, + value: row.total_duels, + })); + } - async getDuelsPerBanList(season: number): Promise { - const result = await dataSource.query(` + async getDuelsPerBanList(season: number): Promise { + const result = await dataSource.query( + ` SELECT ban_list_name, SUM(total_duels)::int as total_duels @@ -59,20 +69,32 @@ export class GlobalStatsPostgresRepository implements GlobalStatsRepository { WHERE season = $1 GROUP BY ban_list_name ORDER BY total_duels DESC - `, [season]); + `, + [season], + ); - const totalSeasonDuels = result.reduce((sum: number, row: StatsRow) => sum + row.total_duels, 0); + const totalSeasonDuels = result.reduce( + (sum: number, row: StatsRow) => sum + row.total_duels, + 0, + ); - return result.map((row: StatsRow) => ({ - banListName: row.ban_list_name!, - totalDuels: row.total_duels, - percentage: totalSeasonDuels > 0 ? parseFloat(((row.total_duels / totalSeasonDuels) * 100).toFixed(1)) : 0, - popularity: totalSeasonDuels > 0 ? Math.min(100, Math.round((row.total_duels / totalSeasonDuels) * 100 * 3)) : 0 // Scale for UI bar - })); - } + return result.map((row: StatsRow) => ({ + banListName: row.ban_list_name!, + totalDuels: row.total_duels, + percentage: + totalSeasonDuels > 0 + ? parseFloat(((row.total_duels / totalSeasonDuels) * 100).toFixed(1)) + : 0, + popularity: + totalSeasonDuels > 0 + ? Math.min(100, Math.round((row.total_duels / totalSeasonDuels) * 100 * 3)) + : 0, // Scale for UI bar + })); + } - async getDailyDuels(season: number): Promise { - const result = await dataSource.query(` + async getDailyDuels(season: number): Promise { + const result = await dataSource.query( + ` SELECT date::text as date, ban_list_name, @@ -80,12 +102,14 @@ export class GlobalStatsPostgresRepository implements GlobalStatsRepository { FROM stats_daily_summary WHERE season = $1 ORDER BY date ASC - `, [season]); + `, + [season], + ); - return result.map((row: DailyStatsRow) => ({ - date: row.date, - banListName: row.ban_list_name, - count: row.count - })); - } + return result.map((row: DailyStatsRow) => ({ + date: row.date, + banListName: row.ban_list_name, + count: row.count, + })); + } } diff --git a/src/modules/stats/infrastructure/StatsController.ts b/src/modules/stats/infrastructure/StatsController.ts index c2e7aeb..43d026b 100644 --- a/src/modules/stats/infrastructure/StatsController.ts +++ b/src/modules/stats/infrastructure/StatsController.ts @@ -3,12 +3,12 @@ import { GlobalStatsPostgresRepository } from "./GlobalStatsPostgresRepository"; import { config } from "../../../config"; export class StatsController { - async getGlobalStats(context: { query: { season?: string } }) { - const season = context.query.season ? parseInt(context.query.season) : config.season; + async getGlobalStats(context: { query: { season?: string } }) { + const season = context.query.season ? parseInt(context.query.season) : config.season; - const repository = new GlobalStatsPostgresRepository(); - const useCase = new GetGlobalStats(repository); + const repository = new GlobalStatsPostgresRepository(); + const useCase = new GetGlobalStats(repository); - return await useCase.execute(season); - } + return await useCase.execute(season); + } } diff --git a/src/modules/stats/infrastructure/UserStatsPostgresRepository.ts b/src/modules/stats/infrastructure/UserStatsPostgresRepository.ts index c4f197a..2a07401 100644 --- a/src/modules/stats/infrastructure/UserStatsPostgresRepository.ts +++ b/src/modules/stats/infrastructure/UserStatsPostgresRepository.ts @@ -6,7 +6,12 @@ import { UserStats } from "../domain/UserStats"; import { UserStatsRepository } from "../domain/UserStatsRepository"; export class UserStatsPostgresRepository implements UserStatsRepository { - async find(userId: string, banListName: string, season: number, label?: string): Promise { + async find( + userId: string, + banListName: string, + season: number, + label?: string, + ): Promise { const subQuery = dataSource .createQueryBuilder() .select([ @@ -48,7 +53,9 @@ export class UserStatsPostgresRepository implements UserStatsRepository { .leftJoin("user_achievements", "ua", "ua.user_id = rp.user_id") .leftJoin("achievements", "a", "a.id = ua.achievement_id") .where("rp.user_id = :userId", { userId }) - .groupBy("rp.username, rp.user_id, rp.points, rp.wins, rp.losses, rp.banListName, rp.win_rate, rp.position") + .groupBy( + "rp.username, rp.user_id, rp.points, rp.wins, rp.losses, rp.banListName, rp.win_rate, rp.position", + ) .setParameters({ ...subQuery.getParameters(), label: label ? JSON.stringify([label]) : null, @@ -116,7 +123,9 @@ export class UserStatsPostgresRepository implements UserStatsRepository { .setParameters({ banListName }) .getRawMany(); - return leaderboard.map((item) => UserStats.from({ ...item, userId: item.userid, winRate: item.winrate })); + return leaderboard.map((item) => + UserStats.from({ ...item, userId: item.userid, winRate: item.winrate }), + ); } async getBestPlayerOfLastCompletedWeek(): Promise { @@ -174,16 +183,16 @@ export class UserStatsPostgresRepository implements UserStatsRepository { WHERE r.rank = 1; `); - return response.map((item) => PeriodUserStats.from({ - userId: item?.user_id, - username: item?.username, - points: item?.total_points, - wins: item?.wins, - losses: item?.losses, - from: item?.week_start, - to: item?.week_end - })); - + return response.map((item) => + PeriodUserStats.from({ + userId: item?.user_id, + username: item?.username, + points: item?.total_points, + wins: item?.wins, + losses: item?.losses, + from: item?.week_start, + to: item?.week_end, + }), + ); } - } diff --git a/src/modules/ticket/application/IssueGameTicket.ts b/src/modules/ticket/application/IssueGameTicket.ts index 73d03ba..751ec0e 100644 --- a/src/modules/ticket/application/IssueGameTicket.ts +++ b/src/modules/ticket/application/IssueGameTicket.ts @@ -2,7 +2,7 @@ import { GameTicket } from "../domain/GameTicket"; import { RankedTicketRepository } from "../domain/RankedTicketRepository"; export class IssueGameTicket { - constructor(private readonly repository: RankedTicketRepository) { } + constructor(private readonly repository: RankedTicketRepository) {} async issue({ userId }: { userId: string }): Promise<{ ticket: string }> { const ticket = GameTicket.generate(); diff --git a/src/modules/tournaments/application/CreateTournamentProxyUseCase.ts b/src/modules/tournaments/application/CreateTournamentProxyUseCase.ts index 4f7bd61..f33f915 100644 --- a/src/modules/tournaments/application/CreateTournamentProxyUseCase.ts +++ b/src/modules/tournaments/application/CreateTournamentProxyUseCase.ts @@ -1,62 +1,62 @@ export interface CreateTournamentInput { - name: string; - discipline: string; - format: string; - status: string; - participantType: string; - allowMixedParticipants: boolean; - maxParticipants: number; - description?: string; - startAt?: string; - endAt?: string; - location?: string; - banlist?: string; // e.g., "Edison", "TCG", "OCG", "Goat" + name: string; + discipline: string; + format: string; + status: string; + participantType: string; + allowMixedParticipants: boolean; + maxParticipants: number; + description?: string; + startAt?: string; + endAt?: string; + location?: string; + banlist?: string; // e.g., "Edison", "TCG", "OCG", "Goat" } interface Tournament { - id: string; - name: string; - description?: string | null; - discipline: string; - format: string; - status: string; - allowMixedParticipants: boolean; - participantType?: string | null; - maxParticipants?: number | null; - startAt?: string | null; - endAt?: string | null; - location?: string | null; - webhookUrl?: string | null; - metadata: Record; + id: string; + name: string; + description?: string | null; + discipline: string; + format: string; + status: string; + allowMixedParticipants: boolean; + participantType?: string | null; + maxParticipants?: number | null; + startAt?: string | null; + endAt?: string | null; + location?: string | null; + webhookUrl?: string | null; + metadata: Record; } export class CreateTournamentProxyUseCase { - constructor( - private readonly tournamentsApiUrl: string, - private readonly webhookUrl: string - ) { } + constructor( + private readonly tournamentsApiUrl: string, + private readonly webhookUrl: string, + ) {} - async execute(input: CreateTournamentInput): Promise { - const { banlist, ...tournamentFields } = input; + async execute(input: CreateTournamentInput): Promise { + const { banlist, ...tournamentFields } = input; - const tournamentData = { - ...tournamentFields, - webhookUrl: this.webhookUrl, - status: "PUBLISHED", - metadata: banlist ? { banlist } : {} - }; + const tournamentData = { + ...tournamentFields, + webhookUrl: this.webhookUrl, + status: "PUBLISHED", + metadata: banlist ? { banlist } : {}, + }; - const response = await fetch(`${this.tournamentsApiUrl}/tournaments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(tournamentData), - }); + const response = await fetch(`${this.tournamentsApiUrl}/tournaments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(tournamentData), + }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to create tournament: ${response.status} ${text}`); - } + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create tournament: ${response.status} ${text}`); + } - return await response.json() as Tournament; - } + return (await response.json()) as Tournament; + } } diff --git a/src/modules/tournaments/application/GetRankingUseCase.ts b/src/modules/tournaments/application/GetRankingUseCase.ts index 3e62fdb..7b97214 100644 --- a/src/modules/tournaments/application/GetRankingUseCase.ts +++ b/src/modules/tournaments/application/GetRankingUseCase.ts @@ -2,9 +2,9 @@ import { TournamentRankingRepository } from "../domain/TournamentRankingReposito import { RankingWithUser } from "../domain/RankingWithUser"; export class GetRankingUseCase { - constructor(private readonly repository: TournamentRankingRepository) { } + constructor(private readonly repository: TournamentRankingRepository) {} - async execute(limit: number = 10): Promise { - return this.repository.getTopRankings(limit); - } + async execute(limit: number = 10): Promise { + return this.repository.getTopRankings(limit); + } } diff --git a/src/modules/tournaments/application/TournamentEnrollmentUseCase.ts b/src/modules/tournaments/application/TournamentEnrollmentUseCase.ts index 54994dd..5519ce1 100644 --- a/src/modules/tournaments/application/TournamentEnrollmentUseCase.ts +++ b/src/modules/tournaments/application/TournamentEnrollmentUseCase.ts @@ -3,22 +3,30 @@ import { NotFoundError } from "src/shared/errors/NotFoundError"; import { TournamentRepository } from "../domain/TournamentRepository"; export class TournamentEnrollmentUseCase { - constructor(private readonly userRepository: UserRepository, private readonly tournamentRepository: TournamentRepository) { } + constructor( + private readonly userRepository: UserRepository, + private readonly tournamentRepository: TournamentRepository, + ) {} - async execute({ userId, tournamentId }: { userId: string; tournamentId: string }): Promise { - const user = await this.userRepository.findById(userId) - if (!user) { - throw new NotFoundError(`User with id: ${userId} not found`) - } + async execute({ userId, tournamentId }: { userId: string; tournamentId: string }): Promise { + const user = await this.userRepository.findById(userId); + if (!user) { + throw new NotFoundError(`User with id: ${userId} not found`); + } - if (!user.participantId) { - const participantId = await this.tournamentRepository.createUserTournament({ displayName: user.username, email: user.email }); - await this.userRepository.updateParticipantId(userId, participantId); - await this.tournamentRepository.enrollTournament({ tournamentId, participantId }); - return - } + if (!user.participantId) { + const participantId = await this.tournamentRepository.createUserTournament({ + displayName: user.username, + email: user.email, + }); + await this.userRepository.updateParticipantId(userId, participantId); + await this.tournamentRepository.enrollTournament({ tournamentId, participantId }); + return; + } - await this.tournamentRepository.enrollTournament({ tournamentId, participantId: user.participantId }); - - } -} \ No newline at end of file + await this.tournamentRepository.enrollTournament({ + tournamentId, + participantId: user.participantId, + }); + } +} diff --git a/src/modules/tournaments/application/TournamentWithdrawalUseCase.ts b/src/modules/tournaments/application/TournamentWithdrawalUseCase.ts index 478e978..6f92c00 100644 --- a/src/modules/tournaments/application/TournamentWithdrawalUseCase.ts +++ b/src/modules/tournaments/application/TournamentWithdrawalUseCase.ts @@ -3,19 +3,21 @@ import { NotFoundError } from "src/shared/errors/NotFoundError"; import { TournamentRepository } from "../domain/TournamentRepository"; export class TournamentWithdrawalUseCase { - constructor(private readonly userRepository: UserRepository, private readonly tournamentRepository: TournamentRepository) { } + constructor( + private readonly userRepository: UserRepository, + private readonly tournamentRepository: TournamentRepository, + ) {} - async execute({ userId, tournamentId }: { userId: string; tournamentId: string }): Promise { - const user = await this.userRepository.findById(userId) - if (!user) { - throw new NotFoundError(`User with id: ${userId} not found`) - } + async execute({ userId, tournamentId }: { userId: string; tournamentId: string }): Promise { + const user = await this.userRepository.findById(userId); + if (!user) { + throw new NotFoundError(`User with id: ${userId} not found`); + } - if (!user.participantId) { - throw new Error("User is not a participant") - } + if (!user.participantId) { + throw new Error("User is not a participant"); + } - await this.tournamentRepository.withdrawTournament(tournamentId, user.participantId); - - } + await this.tournamentRepository.withdrawTournament(tournamentId, user.participantId); + } } diff --git a/src/modules/tournaments/application/UpdateRankingUseCase.ts b/src/modules/tournaments/application/UpdateRankingUseCase.ts index dd08eea..2198824 100644 --- a/src/modules/tournaments/application/UpdateRankingUseCase.ts +++ b/src/modules/tournaments/application/UpdateRankingUseCase.ts @@ -6,136 +6,137 @@ import { config } from "src/config"; // Fixed points distribution by position const POINTS_BY_POSITION: Record = { - 1: 10, // 1st place - 2: 7, // 2nd place - 3: 5, // 3rd place - 4: 3, // 4th place - 5: 2, // 5th-8th place - 6: 2, - 7: 2, - 8: 2, + 1: 10, // 1st place + 2: 7, // 2nd place + 3: 5, // 3rd place + 4: 3, // 4th place + 5: 2, // 5th-8th place + 6: 2, + 7: 2, + 8: 2, }; interface MatchParticipant { - participantId: string; - score: number | null; - result: "win" | "loss" | "draw" | null; + participantId: string; + score: number | null; + result: "win" | "loss" | "draw" | null; } interface Match { - id: string; - roundNumber: number; - completedAt: string | null; - participants: MatchParticipant[]; + id: string; + roundNumber: number; + completedAt: string | null; + participants: MatchParticipant[]; } interface RankingEntry { - participantId: string; - position: number; + participantId: string; + position: number; } export class UpdateRankingUseCase { - constructor( - private readonly repository: TournamentRankingRepository, - private readonly userRepository: UserRepository, - private readonly tournamentsApiUrl: string, - private readonly logger: Logger - ) { } - - async execute(input: { tournamentId: string }): Promise { - this.logger.info(`[UpdateRanking] Processing tournament: ${input.tournamentId}`); - - // Fetch all matches from tournaments service - const matches = await this.fetchTournamentMatches(input.tournamentId); - this.logger.info(`[UpdateRanking] Found ${matches.length} matches`); - - // Calculate final rankings - const rankings = this.calculateRankings(matches); - this.logger.debug(`[UpdateRanking] Calculated rankings: ${JSON.stringify(rankings)}`); - - - // Update points for each participant - for (const ranking of rankings) { - const points = POINTS_BY_POSITION[ranking.position] || 1; - const isWinner = ranking.position === 1; - - // Find user by participantId - const user = await this.userRepository.findByParticipantId(ranking.participantId); - if (!user) { - this.logger.error(`[UpdateRanking] User not found for participant ${ranking.participantId}`); - continue; - } - - this.logger.info(`[UpdateRanking] Updating user ${user.id}: position ${ranking.position}, points ${points}`); - - - // Get or create ranking - let userRanking = await this.repository.findByUserId(user.id); - - if (!userRanking) { - userRanking = TournamentRanking.createNew({ - userId: user.id, - points, - tournamentsWon: isWinner ? 1 : 0, - tournamentsPlayed: 1, - season: config.season.toString(), - }); - - } else { - userRanking = userRanking.addPoints(points); - userRanking = userRanking.incrementTournamentsPlayed(); - if (isWinner) { - userRanking = userRanking.incrementTournamentsWon(); - } - } - - await this.repository.save(userRanking); - this.logger.info(`[UpdateRanking] Saved ranking for user ${user.id}`); - } - } - - private async fetchTournamentMatches(tournamentId: string): Promise { - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${tournamentId}/matches`); - if (!response.ok) { - throw new Error(`Failed to fetch matches: ${response.status}`); - } - return await response.json(); - } - - private calculateRankings(matches: Match[]): RankingEntry[] { - // For single elimination, we can determine positions from the bracket structure - // The winner of the final (highest round) is 1st - // The loser of the final is 2nd - // The losers of semi-finals are tied 3rd - // etc. - - const maxRound = Math.max(...matches.map(m => m.roundNumber)); - const rankings: RankingEntry[] = []; - - // Process rounds from final to first - for (let round = maxRound; round >= 1; round--) { - const roundMatches = matches.filter(m => m.roundNumber === round && m.completedAt); - - for (const match of roundMatches) { - const winner = match.participants.find(p => p.result === "win"); - const loser = match.participants.find(p => p.result === "loss"); - - if (round === maxRound) { - // Final match - if (winner) rankings.push({ participantId: winner.participantId, position: 1 }); - if (loser) rankings.push({ participantId: loser.participantId, position: 2 }); - } else { - // For earlier rounds, losers get positions based on round - // Semi-final losers: 3rd place - // Quarter-final losers: 5th place - const position = Math.pow(2, maxRound - round) + 1; - if (loser && !rankings.find(r => r.participantId === loser.participantId)) { - rankings.push({ participantId: loser.participantId, position }); - } - } - } - } - - return rankings; - } + constructor( + private readonly repository: TournamentRankingRepository, + private readonly userRepository: UserRepository, + private readonly tournamentsApiUrl: string, + private readonly logger: Logger, + ) {} + + async execute(input: { tournamentId: string }): Promise { + this.logger.info(`[UpdateRanking] Processing tournament: ${input.tournamentId}`); + + // Fetch all matches from tournaments service + const matches = await this.fetchTournamentMatches(input.tournamentId); + this.logger.info(`[UpdateRanking] Found ${matches.length} matches`); + + // Calculate final rankings + const rankings = this.calculateRankings(matches); + this.logger.debug(`[UpdateRanking] Calculated rankings: ${JSON.stringify(rankings)}`); + + // Update points for each participant + for (const ranking of rankings) { + const points = POINTS_BY_POSITION[ranking.position] || 1; + const isWinner = ranking.position === 1; + + // Find user by participantId + const user = await this.userRepository.findByParticipantId(ranking.participantId); + if (!user) { + this.logger.error( + `[UpdateRanking] User not found for participant ${ranking.participantId}`, + ); + continue; + } + + this.logger.info( + `[UpdateRanking] Updating user ${user.id}: position ${ranking.position}, points ${points}`, + ); + + // Get or create ranking + let userRanking = await this.repository.findByUserId(user.id); + + if (!userRanking) { + userRanking = TournamentRanking.createNew({ + userId: user.id, + points, + tournamentsWon: isWinner ? 1 : 0, + tournamentsPlayed: 1, + season: config.season.toString(), + }); + } else { + userRanking = userRanking.addPoints(points); + userRanking = userRanking.incrementTournamentsPlayed(); + if (isWinner) { + userRanking = userRanking.incrementTournamentsWon(); + } + } + + await this.repository.save(userRanking); + this.logger.info(`[UpdateRanking] Saved ranking for user ${user.id}`); + } + } + + private async fetchTournamentMatches(tournamentId: string): Promise { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${tournamentId}/matches`); + if (!response.ok) { + throw new Error(`Failed to fetch matches: ${response.status}`); + } + return await response.json(); + } + + private calculateRankings(matches: Match[]): RankingEntry[] { + // For single elimination, we can determine positions from the bracket structure + // The winner of the final (highest round) is 1st + // The loser of the final is 2nd + // The losers of semi-finals are tied 3rd + // etc. + + const maxRound = Math.max(...matches.map((m) => m.roundNumber)); + const rankings: RankingEntry[] = []; + + // Process rounds from final to first + for (let round = maxRound; round >= 1; round--) { + const roundMatches = matches.filter((m) => m.roundNumber === round && m.completedAt); + + for (const match of roundMatches) { + const winner = match.participants.find((p) => p.result === "win"); + const loser = match.participants.find((p) => p.result === "loss"); + + if (round === maxRound) { + // Final match + if (winner) rankings.push({ participantId: winner.participantId, position: 1 }); + if (loser) rankings.push({ participantId: loser.participantId, position: 2 }); + } else { + // For earlier rounds, losers get positions based on round + // Semi-final losers: 3rd place + // Quarter-final losers: 5th place + const position = Math.pow(2, maxRound - round) + 1; + if (loser && !rankings.find((r) => r.participantId === loser.participantId)) { + rankings.push({ participantId: loser.participantId, position }); + } + } + } + } + + return rankings; + } } diff --git a/src/modules/tournaments/domain/RankingWithUser.ts b/src/modules/tournaments/domain/RankingWithUser.ts index 9e907c1..fa96521 100644 --- a/src/modules/tournaments/domain/RankingWithUser.ts +++ b/src/modules/tournaments/domain/RankingWithUser.ts @@ -1,10 +1,10 @@ export interface RankingWithUser { - userId: string; - points: number; - tournamentsWon: number; - tournamentsPlayed: number; - user: { - username: string; - email: string; - } | null; + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + user: { + username: string; + email: string; + } | null; } diff --git a/src/modules/tournaments/domain/TournamentRanking.ts b/src/modules/tournaments/domain/TournamentRanking.ts index 65555e7..f7e9677 100644 --- a/src/modules/tournaments/domain/TournamentRanking.ts +++ b/src/modules/tournaments/domain/TournamentRanking.ts @@ -1,108 +1,108 @@ export interface TournamentRankingProps { - userId: string; - points: number; - tournamentsWon: number; - tournamentsPlayed: number; - lastUpdated: Date; + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + lastUpdated: Date; } export class TournamentRanking { - private constructor( - private readonly userId: string, - private readonly _points: number, - private readonly _tournamentsWon: number, - private readonly _tournamentsPlayed: number, - private readonly _season: string - ) { } + private constructor( + private readonly userId: string, + private readonly _points: number, + private readonly _tournamentsWon: number, + private readonly _tournamentsPlayed: number, + private readonly _season: string, + ) {} - static createNew(props: { - userId: string; - points: number; - tournamentsWon: number; - tournamentsPlayed: number; - season: string; - }): TournamentRanking { - return new TournamentRanking( - props.userId, - props.points, - props.tournamentsWon, - props.tournamentsPlayed, - props.season, - ); - } + static createNew(props: { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + season: string; + }): TournamentRanking { + return new TournamentRanking( + props.userId, + props.points, + props.tournamentsWon, + props.tournamentsPlayed, + props.season, + ); + } - static fromPrimitives(props: { - userId: string; - points: number; - tournamentsWon: number; - tournamentsPlayed: number; - season: string; - }): TournamentRanking { - return new TournamentRanking( - props.userId, - props.points, - props.tournamentsWon, - props.tournamentsPlayed, - props.season - ); - } + static fromPrimitives(props: { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + season: string; + }): TournamentRanking { + return new TournamentRanking( + props.userId, + props.points, + props.tournamentsWon, + props.tournamentsPlayed, + props.season, + ); + } - addPoints(points: number): TournamentRanking { - return new TournamentRanking( - this.userId, - this._points + points, - this._tournamentsWon, - this._tournamentsPlayed, - this._season - ); - } + addPoints(points: number): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points + points, + this._tournamentsWon, + this._tournamentsPlayed, + this._season, + ); + } - incrementTournamentsPlayed(): TournamentRanking { - return new TournamentRanking( - this.userId, - this._points, - this._tournamentsWon, - this._tournamentsPlayed + 1, - this._season - ); - } + incrementTournamentsPlayed(): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points, + this._tournamentsWon, + this._tournamentsPlayed + 1, + this._season, + ); + } - incrementTournamentsWon(): TournamentRanking { - return new TournamentRanking( - this.userId, - this._points, - this._tournamentsWon + 1, - this._tournamentsPlayed, - this._season - ); - } + incrementTournamentsWon(): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points, + this._tournamentsWon + 1, + this._tournamentsPlayed, + this._season, + ); + } - get points(): number { - return this._points; - } + get points(): number { + return this._points; + } - get tournamentsWon(): number { - return this._tournamentsWon; - } + get tournamentsWon(): number { + return this._tournamentsWon; + } - get tournamentsPlayed(): number { - return this._tournamentsPlayed; - } + get tournamentsPlayed(): number { + return this._tournamentsPlayed; + } - get season(): string { - return this._season; - } + get season(): string { + return this._season; + } - getUserId(): string { - return this.userId; - } + getUserId(): string { + return this.userId; + } - toPrimitives() { - return { - userId: this.userId, - points: this._points, - tournamentsWon: this._tournamentsWon, - tournamentsPlayed: this._tournamentsPlayed, - }; - } + toPrimitives() { + return { + userId: this.userId, + points: this._points, + tournamentsWon: this._tournamentsWon, + tournamentsPlayed: this._tournamentsPlayed, + }; + } } diff --git a/src/modules/tournaments/domain/TournamentRankingRepository.ts b/src/modules/tournaments/domain/TournamentRankingRepository.ts index dbf49c9..ffa47f7 100644 --- a/src/modules/tournaments/domain/TournamentRankingRepository.ts +++ b/src/modules/tournaments/domain/TournamentRankingRepository.ts @@ -2,7 +2,7 @@ import { TournamentRanking } from "./TournamentRanking"; import { RankingWithUser } from "./RankingWithUser"; export interface TournamentRankingRepository { - findByUserId(userId: string): Promise; - save(ranking: TournamentRanking): Promise; - getTopRankings(limit: number): Promise; + findByUserId(userId: string): Promise; + save(ranking: TournamentRanking): Promise; + getTopRankings(limit: number): Promise; } diff --git a/src/modules/tournaments/domain/TournamentRepository.ts b/src/modules/tournaments/domain/TournamentRepository.ts index 855d142..649e3a9 100644 --- a/src/modules/tournaments/domain/TournamentRepository.ts +++ b/src/modules/tournaments/domain/TournamentRepository.ts @@ -1,12 +1,28 @@ export interface TournamentRepository { - createUserTournament({ displayName, email }: { displayName: string; email: string; }): Promise; - enrollTournament({ tournamentId, participantId }: { tournamentId: string; participantId: string; }): Promise; - withdrawTournament(tournamentId: string, participantId: string): Promise; - editMatchResult(tournamentId: string, matchId: string, participants: Array<{ participantId: string; score: number; result?: string }>): Promise; - annulMatchResult(tournamentId: string, matchId: string): Promise; - publishTournament(tournamentId: string): Promise; - startTournament(tournamentId: string): Promise; - completeTournament(tournamentId: string): Promise; - cancelTournament(tournamentId: string): Promise; - confirmTournamentEntry(tournamentId: string, participantId: string): Promise; -} \ No newline at end of file + createUserTournament({ + displayName, + email, + }: { + displayName: string; + email: string; + }): Promise; + enrollTournament({ + tournamentId, + participantId, + }: { + tournamentId: string; + participantId: string; + }): Promise; + withdrawTournament(tournamentId: string, participantId: string): Promise; + editMatchResult( + tournamentId: string, + matchId: string, + participants: Array<{ participantId: string; score: number; result?: string }>, + ): Promise; + annulMatchResult(tournamentId: string, matchId: string): Promise; + publishTournament(tournamentId: string): Promise; + startTournament(tournamentId: string): Promise; + completeTournament(tournamentId: string): Promise; + cancelTournament(tournamentId: string): Promise; + confirmTournamentEntry(tournamentId: string, participantId: string): Promise; +} diff --git a/src/modules/tournaments/infrastructure/TournamentController.ts b/src/modules/tournaments/infrastructure/TournamentController.ts index 6a61ad9..a402c59 100644 --- a/src/modules/tournaments/infrastructure/TournamentController.ts +++ b/src/modules/tournaments/infrastructure/TournamentController.ts @@ -2,7 +2,10 @@ import { Elysia, t } from "elysia"; import { bearer } from "@elysiajs/bearer"; import { UpdateRankingUseCase } from "../application/UpdateRankingUseCase"; import { GetRankingUseCase } from "../application/GetRankingUseCase"; -import { CreateTournamentInput, CreateTournamentProxyUseCase } from "../application/CreateTournamentProxyUseCase"; +import { + CreateTournamentInput, + CreateTournamentProxyUseCase, +} from "../application/CreateTournamentProxyUseCase"; import { TournamentEnrollmentUseCase } from "../application/TournamentEnrollmentUseCase"; import { TournamentWithdrawalUseCase } from "../application/TournamentWithdrawalUseCase"; import { JWT } from "src/shared/JWT"; @@ -12,443 +15,533 @@ import { config } from "src/config"; import { MatchResultRequestSchema } from "./swagger-schemas"; export class TournamentController { - private readonly tournamentsApiUrl: string; + private readonly tournamentsApiUrl: string; - constructor( - private readonly updateRanking: UpdateRankingUseCase, - private readonly getRanking: GetRankingUseCase, - private readonly createTournament: CreateTournamentProxyUseCase, - private readonly tournamentEnrollmentUseCase: TournamentEnrollmentUseCase, - private readonly tournamentWithdrawalUseCase: TournamentWithdrawalUseCase, - private readonly jwt: JWT - ) { - this.tournamentsApiUrl = config.tournaments.apiUrl; + constructor( + private readonly updateRanking: UpdateRankingUseCase, + private readonly getRanking: GetRankingUseCase, + private readonly createTournament: CreateTournamentProxyUseCase, + private readonly tournamentEnrollmentUseCase: TournamentEnrollmentUseCase, + private readonly tournamentWithdrawalUseCase: TournamentWithdrawalUseCase, + private readonly jwt: JWT, + ) { + this.tournamentsApiUrl = config.tournaments.apiUrl; + } - } + routes(app: Elysia) { + return app.group("/tournaments", (app) => + app + .use(bearer()) + .get( + "/", + async () => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments`); - routes(app: Elysia) { - return app.group("/tournaments", (app) => - app - .use(bearer()) - .get("/", async () => { - const response = await fetch(`${this.tournamentsApiUrl}/tournaments`); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to get tournaments: ${response.status} ${text}`); + } - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to get tournaments: ${response.status} ${text}`); - } + return response.json(); + }, + { + detail: { + tags: ["Lightning Tournaments"], + summary: "Get all tournaments", + description: "Retrieves a list of all tournaments from the tournaments service", + responses: { + 200: { + description: "Tournaments retrieved successfully", + content: { + "application/json": { + example: [ + { + id: "tournament-001", + name: "Tournament 1", + status: "open", + }, + { + id: "tournament-002", + name: "Tournament 2", + status: "closed", + }, + ], + }, + }, + }, + }, + }, + }, + ) + .post( + "/webhook", + async ({ body }) => { + const { tournamentId } = body as { + tournamentId: string; + winnerId: string; + completedAt: string; + }; + // Process all participants' rankings based on final positions + await this.updateRanking.execute({ tournamentId }); + return { success: true }; + }, + { + detail: { + tags: ["Tournaments"], + summary: "Tournament completion webhook", + description: + "Webhook endpoint called when a tournament is completed to update rankings", + responses: { + 200: { + description: "Rankings updated successfully", + content: { + "application/json": { + example: { success: true }, + }, + }, + }, + }, + }, + body: t.Object({ + winnerId: t.String(), + tournamentId: t.String(), + completedAt: t.String(), + }), + }, + ) + .get( + "/ranking", + async ({ query }) => { + const limit = query.limit ? parseInt(query.limit as string) : 10; + const rankings = await this.getRanking.execute(limit); + return rankings; // Already plain objects with user data + }, + { + detail: { + tags: ["Lightning Tournaments"], + summary: "Get lightning tournament ranking", + description: "Retrieves the top players ranking for lightning tournaments", + responses: { + 200: { + description: "Ranking retrieved successfully", + content: { + "application/json": { + example: [ + { + userId: "user-1", + username: "Player1", + email: "player1@example.com", + points: 150, + tournamentsWon: 5, + tournamentsPlayed: 20, + }, + ], + }, + }, + }, + }, + }, + query: t.Object({ + limit: t.Optional(t.String()), + }), + }, + ) + .post( + "/", + async ({ body, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to create tournaments"); + } + const tournament = await this.createTournament.execute(body as CreateTournamentInput); + return tournament; + }, + { + detail: { + tags: ["Lightning Tournaments"], + summary: "Create lightning tournament", + description: "Creates a new lightning tournament. Requires admin privileges.", + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: "Tournament created successfully", + content: { + "application/json": { + example: { + id: "tournament-123", + name: "Weekly Lightning", + discipline: "Yu-Gi-Oh!", + format: "Single Elimination", + status: "PUBLISHED", + participantType: "SINGLE", + allowMixedParticipants: false, + maxParticipants: 8, + description: "Weekly Lightning Tournament", + startAt: "2025-11-24T11:33:08-04:00", + endAt: "2025-11-24T11:33:08-04:00", + location: "Online", + banlist: "TCG", + }, + }, + }, + }, + 401: { description: "Unauthorized - Admin role required" }, + }, + }, + body: t.Object({ + name: t.String({ minLength: 1 }), + discipline: t.String({ minLength: 1 }), + format: t.String({ minLength: 1 }), + status: t.String({ minLength: 1 }), + participantType: t.String({ minLength: 1 }), + allowMixedParticipants: t.Boolean(), + maxParticipants: t.Number({ minimum: 1 }), + description: t.Optional(t.String()), + startAt: t.Optional(t.String()), + endAt: t.Optional(t.String()), + location: t.Optional(t.String()), + banlist: t.Optional(t.String()), + }), + }, + ) + .post( + "/:tournamentId/enroll", + async ({ params, bearer }) => { + const { id } = this.jwt.decode(bearer as string) as { id: string }; + await this.tournamentEnrollmentUseCase.execute({ + userId: id, + tournamentId: params.tournamentId, + }); + return { success: true }; + }, + { + detail: { + tags: ["Lightning Tournaments"], + summary: "Enroll in tournament", + description: "Enrolls a user in a lightning tournament", + responses: { + 200: { + description: "User enrolled successfully", + content: { + "application/json": { + example: { success: true }, + }, + }, + }, + 404: { description: "User or tournament not found" }, + 409: { description: "User already enrolled or tournament full" }, + }, + }, + }, + ) + .post( + "/:tournamentId/withdraw", + async ({ params, bearer }) => { + const { id } = this.jwt.decode(bearer as string) as { id: string }; + await this.tournamentWithdrawalUseCase.execute({ + userId: id, + tournamentId: params.tournamentId, + }); + return { success: true }; + }, + { + detail: { + tags: ["Lightning Tournaments"], + summary: "Withdraw from tournament", + description: "Withdraws a user from a lightning tournament", + responses: { + 200: { + description: "User withdrawn successfully", + content: { + "application/json": { + example: { success: true }, + }, + }, + }, + 404: { description: "User, tournament, or enrollment not found" }, + }, + }, + }, + ) + .get( + "/:tournamentId/bracket", + async ({ params }) => { + const response = await fetch( + `${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket`, + ); - return response.json(); - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Get all tournaments', - description: 'Retrieves a list of all tournaments from the tournaments service', - responses: { - 200: { - description: 'Tournaments retrieved successfully', - content: { - 'application/json': { - example: [ - { - id: 'tournament-001', - name: 'Tournament 1', - status: 'open' - }, - { - id: 'tournament-002', - name: 'Tournament 2', - status: 'closed' - } - ] - } - } - } - } - } - }) - .post("/webhook", async ({ body }) => { - const { tournamentId } = body as { tournamentId: string; winnerId: string; completedAt: string }; - // Process all participants' rankings based on final positions - await this.updateRanking.execute({ tournamentId }); - return { success: true }; - }, { - detail: { - tags: ['Tournaments'], - summary: 'Tournament completion webhook', - description: 'Webhook endpoint called when a tournament is completed to update rankings', - responses: { - 200: { - description: 'Rankings updated successfully', - content: { - 'application/json': { - example: { success: true } - } - } - } - } - }, - body: t.Object({ - winnerId: t.String(), - tournamentId: t.String(), - completedAt: t.String(), - }) - }) - .get("/ranking", async ({ query }) => { - const limit = query.limit ? parseInt(query.limit as string) : 10; - const rankings = await this.getRanking.execute(limit); - return rankings; // Already plain objects with user data - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Get lightning tournament ranking', - description: 'Retrieves the top players ranking for lightning tournaments', - responses: { - 200: { - description: 'Ranking retrieved successfully', - content: { - 'application/json': { - example: [ - { - userId: 'user-1', - username: 'Player1', - email: 'player1@example.com', - points: 150, - tournamentsWon: 5, - tournamentsPlayed: 20 - } - ] - } - } - } - } - }, - query: t.Object({ - limit: t.Optional(t.String()) - }) - }) - .post("/", async ({ body, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to create tournaments"); - } - const tournament = await this.createTournament.execute(body as CreateTournamentInput); - return tournament; - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Create lightning tournament', - description: 'Creates a new lightning tournament. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Tournament created successfully', - content: { - 'application/json': { - example: { - id: 'tournament-123', - name: 'Weekly Lightning', - discipline: 'Yu-Gi-Oh!', - format: 'Single Elimination', - status: 'PUBLISHED', - participantType: 'SINGLE', - allowMixedParticipants: false, - maxParticipants: 8, - description: 'Weekly Lightning Tournament', - startAt: '2025-11-24T11:33:08-04:00', - endAt: '2025-11-24T11:33:08-04:00', - location: 'Online', - banlist: 'TCG', - } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' } - } - }, - body: t.Object({ - name: t.String({ minLength: 1 }), - discipline: t.String({ minLength: 1 }), - format: t.String({ minLength: 1 }), - status: t.String({ minLength: 1 }), - participantType: t.String({ minLength: 1 }), - allowMixedParticipants: t.Boolean(), - maxParticipants: t.Number({ minimum: 1 }), - description: t.Optional(t.String()), - startAt: t.Optional(t.String()), - endAt: t.Optional(t.String()), - location: t.Optional(t.String()), - banlist: t.Optional(t.String()), - }) - }) - .post("/:tournamentId/enroll", async ({ params, bearer }) => { - const { id } = this.jwt.decode(bearer as string) as { id: string }; - await this.tournamentEnrollmentUseCase.execute({ userId: id, tournamentId: params.tournamentId }); - return { success: true }; - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Enroll in tournament', - description: 'Enrolls a user in a lightning tournament', - responses: { - 200: { - description: 'User enrolled successfully', - content: { - 'application/json': { - example: { success: true } - } - } - }, - 404: { description: 'User or tournament not found' }, - 409: { description: 'User already enrolled or tournament full' } - } - }, - }) - .post("/:tournamentId/withdraw", async ({ params, bearer }) => { - const { id } = this.jwt.decode(bearer as string) as { id: string }; - await this.tournamentWithdrawalUseCase.execute({ userId: id, tournamentId: params.tournamentId }); - return { success: true }; - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Withdraw from tournament', - description: 'Withdraws a user from a lightning tournament', - responses: { - 200: { - description: 'User withdrawn successfully', - content: { - 'application/json': { - example: { success: true } - } - } - }, - 404: { description: 'User, tournament, or enrollment not found' } - } - }, - }) - .get("/:tournamentId/bracket", async ({ params }) => { - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket`); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to fetch bracket: ${response.status} ${text}`); + } - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to fetch bracket: ${response.status} ${text}`); - } + return response.json(); + }, + { + detail: { + tags: ["Bracket Management"], + summary: "Get tournament bracket", + description: + "Retrieves the complete bracket structure including participant display names", + responses: { + 200: { + description: "Bracket retrieved successfully", + content: { + "application/json": { + example: { + tournamentId: "tournament-001", + rounds: [ + { + roundNumber: 1, + matches: [ + { + id: "match-1", + tournamentId: "tournament-001", + roundNumber: 1, + participants: [ + { + participantId: "p1", + displayName: "Player1", + score: 2, + result: "win", + }, + { + participantId: "p2", + displayName: "Player2", + score: 1, + result: "loss", + }, + ], + completedAt: "2025-11-24T10:00:00Z", + }, + ], + }, + ], + }, + }, + }, + }, + 404: { description: "Tournament or bracket not found" }, + }, + }, + }, + ) + .post( + "/:tournamentId/bracket", + async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to generate brackets"); + } - return response.json(); - }, { - detail: { - tags: ['Bracket Management'], - summary: 'Get tournament bracket', - description: 'Retrieves the complete bracket structure including participant display names', - responses: { - 200: { - description: 'Bracket retrieved successfully', - content: { - 'application/json': { - example: { - tournamentId: 'tournament-001', - rounds: [ - { - roundNumber: 1, - matches: [ - { - id: 'match-1', - tournamentId: 'tournament-001', - roundNumber: 1, - participants: [ - { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, - { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } - ], - completedAt: '2025-11-24T10:00:00Z' - } - ] - } - ] - } - } - } - }, - 404: { description: 'Tournament or bracket not found' } - } - } - }) - .post("/:tournamentId/bracket", async ({ params, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to generate brackets"); - } + const response = await fetch( + `${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate-full`, + { + method: "POST", + }, + ); - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate-full`, { - method: 'POST', - }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to generate bracket: ${response.status} ${text}`); + } - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to generate bracket: ${response.status} ${text}`); - } + return response.json(); + }, + { + detail: { + tags: ["Bracket Management"], + summary: "Generate full tournament bracket", + description: + "Generates the complete bracket structure for a tournament. Requires admin privileges.", + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: "Bracket generated successfully", + content: { + "application/json": { + example: { + tournamentId: "tournament-001", + rounds: [ + { + roundNumber: 1, + matches: [ + { + id: "match-1", + tournamentId: "tournament-001", + roundNumber: 1, + matchNumber: 1, + participants: [ + { + participantId: "p1", + displayName: "Player1", + score: null, + result: null, + }, + { + participantId: "p2", + displayName: "Player2", + score: null, + result: null, + }, + ], + completedAt: null, + }, + ], + }, + ], + }, + }, + }, + }, + 401: { description: "Unauthorized - Admin role required" }, + 404: { description: "Tournament not found" }, + }, + }, + }, + ) + .post( + "/:tournamentId/matches/:matchId/result", + async ({ params, body, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to record match results"); + } - return response.json(); - }, { - detail: { - tags: ['Bracket Management'], - summary: 'Generate full tournament bracket', - description: 'Generates the complete bracket structure for a tournament. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Bracket generated successfully', - content: { - 'application/json': { - example: { - tournamentId: 'tournament-001', - rounds: [ - { - roundNumber: 1, - matches: [ - { - id: 'match-1', - tournamentId: 'tournament-001', - roundNumber: 1, - matchNumber: 1, - participants: [ - { participantId: 'p1', displayName: 'Player1', score: null, result: null }, - { participantId: 'p2', displayName: 'Player2', score: null, result: null } - ], - completedAt: null - } - ] - } - ] - } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament not found' } - } - } - }) - .post("/:tournamentId/matches/:matchId/result", async ({ params, body, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to record match results"); - } + const response = await fetch( + `${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to record match result: ${response.status} ${text}`); + } - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to record match result: ${response.status} ${text}`); - } + return response.json(); + }, + { + detail: { + tags: ["Match Management"], + summary: "Record match result", + description: + "Records the result of a match with participant scores. Requires admin privileges.", + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: "Match result recorded successfully", + content: { + "application/json": { + example: { + id: "match-1", + tournamentId: "tournament-001", + roundNumber: 1, + participants: [ + { participantId: "p1", displayName: "Player1", score: 2, result: "win" }, + { participantId: "p2", displayName: "Player2", score: 1, result: "loss" }, + ], + completedAt: "2025-11-24T10:00:00Z", + }, + }, + }, + }, + 401: { description: "Unauthorized - Admin role required" }, + 404: { description: "Tournament or match not found" }, + }, + }, + body: MatchResultRequestSchema, + }, + ) + .delete( + "/:tournamentId/matches/:matchId/result", + async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to annul match results"); + } - return response.json(); - }, { - detail: { - tags: ['Match Management'], - summary: 'Record match result', - description: 'Records the result of a match with participant scores. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Match result recorded successfully', - content: { - 'application/json': { - example: { - id: 'match-1', - tournamentId: 'tournament-001', - roundNumber: 1, - participants: [ - { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, - { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } - ], - completedAt: '2025-11-24T10:00:00Z' - } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament or match not found' } - } - }, - body: MatchResultRequestSchema - }) - .delete("/:tournamentId/matches/:matchId/result", async ({ params, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to annul match results"); - } + const response = await fetch( + `${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, + { + method: "DELETE", + }, + ); - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, { - method: 'DELETE', - }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to annul match result: ${response.status} ${text}`); + } - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to annul match result: ${response.status} ${text}`); - } + return { message: "Match result annulled" }; + }, + { + detail: { + tags: ["Match Management"], + summary: "Annul match result", + description: "Deletes/annuls a match result. Requires admin privileges.", + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: "Match result annulled successfully", + content: { + "application/json": { + example: { message: "Match result annulled" }, + }, + }, + }, + 401: { description: "Unauthorized - Admin role required" }, + 404: { description: "Tournament or match not found" }, + }, + }, + }, + ) + .get( + "/:tournamentId/entries", + async ({ params }) => { + const response = await fetch( + `${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/entries`, + ); - return { message: "Match result annulled" }; - }, { - detail: { - tags: ['Match Management'], - summary: 'Annul match result', - description: 'Deletes/annuls a match result. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Match result annulled successfully', - content: { - 'application/json': { - example: { message: 'Match result annulled' } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament or match not found' } - } - } - }) - .get("/:tournamentId/entries", async ({ params }) => { - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/entries`); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to get entries: ${response.status} ${text}`); + } - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to get entries: ${response.status} ${text}`); - } - - return response.json(); - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Get tournament entries', - description: 'Retrieves all entries for a specific tournament.', - responses: { - 200: { - description: 'Entries retrieved successfully', - content: { - 'application/json': { - example: { - entries: [ - { - id: '123', - userId: '456', - tournamentId: '789', - createdAt: '2023-01-01T00:00:00.000Z', - updatedAt: '2023-01-01T00:00:00.000Z' - } - ] - } - } - } - }, - 404: { description: 'Tournament not found' } - } - } - }) - ); - } + return response.json(); + }, + { + detail: { + tags: ["Lightning Tournaments"], + summary: "Get tournament entries", + description: "Retrieves all entries for a specific tournament.", + responses: { + 200: { + description: "Entries retrieved successfully", + content: { + "application/json": { + example: { + entries: [ + { + id: "123", + userId: "456", + tournamentId: "789", + createdAt: "2023-01-01T00:00:00.000Z", + updatedAt: "2023-01-01T00:00:00.000Z", + }, + ], + }, + }, + }, + }, + 404: { description: "Tournament not found" }, + }, + }, + }, + ), + ); + } } diff --git a/src/modules/tournaments/infrastructure/TournamentGateway.ts b/src/modules/tournaments/infrastructure/TournamentGateway.ts index 5c0738f..f072403 100644 --- a/src/modules/tournaments/infrastructure/TournamentGateway.ts +++ b/src/modules/tournaments/infrastructure/TournamentGateway.ts @@ -2,138 +2,177 @@ import { config } from "src/config"; import { TournamentRepository } from "../domain/TournamentRepository"; export class TournamentGateway implements TournamentRepository { - async createUserTournament({ displayName, email }: { displayName: string; email: string; }): Promise { - const createPlayerResponse = await fetch(`${config.tournaments.apiUrl}/players`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ displayName, email }), - }); - - if (!createPlayerResponse.ok) { - const text = await createPlayerResponse.text(); - throw new Error(`Failed to create player: ${createPlayerResponse.status} ${text}`); - } - - const player = await createPlayerResponse.json(); - - const response = await fetch(`${config.tournaments.apiUrl}/participants`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'PLAYER', referenceId: player.id, displayName }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to create participant: ${response.status} ${text}`); - } - - const participant = await response.json(); - return participant.id; - } - - async enrollTournament({ tournamentId, participantId }: { tournamentId: string; participantId: string; }): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ participantId, status: 'CONFIRMED' }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to create entry: ${response.status} ${text}`); - } - - return; - } - - async withdrawTournament(tournamentId: string, participantId: string): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries/${participantId}`, { - method: 'DELETE', - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to withdraw from tournament: ${response.status} ${text}`); - } - } - - async editMatchResult(tournamentId: string, matchId: string, participants: Array<{ participantId: string; score: number; result?: string }>): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/matches/${matchId}/result`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ participants }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to edit match result: ${response.status} ${text}`); - } - } - - async annulMatchResult(tournamentId: string, matchId: string): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/matches/${matchId}/result`, { - method: 'DELETE', - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to annul match result: ${response.status} ${text}`); - } - } - - async publishTournament(tournamentId: string): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/publish`, { - method: 'PUT', - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to publish tournament: ${response.status} ${text}`); - } - } - - async startTournament(tournamentId: string): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/start`, { - method: 'PUT', - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to start tournament: ${response.status} ${text}`); - } - } - - async completeTournament(tournamentId: string): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/complete`, { - method: 'PUT', - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to complete tournament: ${response.status} ${text}`); - } - } - - async cancelTournament(tournamentId: string): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/cancel`, { - method: 'PUT', - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to cancel tournament: ${response.status} ${text}`); - } - } - - async confirmTournamentEntry(tournamentId: string, participantId: string): Promise { - const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries/${participantId}/confirm`, { - method: 'PUT', - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to confirm tournament entry: ${response.status} ${text}`); - } - } - -} \ No newline at end of file + async createUserTournament({ + displayName, + email, + }: { + displayName: string; + email: string; + }): Promise { + const createPlayerResponse = await fetch(`${config.tournaments.apiUrl}/players`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName, email }), + }); + + if (!createPlayerResponse.ok) { + const text = await createPlayerResponse.text(); + throw new Error(`Failed to create player: ${createPlayerResponse.status} ${text}`); + } + + const player = await createPlayerResponse.json(); + + const response = await fetch(`${config.tournaments.apiUrl}/participants`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "PLAYER", referenceId: player.id, displayName }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create participant: ${response.status} ${text}`); + } + + const participant = await response.json(); + return participant.id; + } + + async enrollTournament({ + tournamentId, + participantId, + }: { + tournamentId: string; + participantId: string; + }): Promise { + const response = await fetch( + `${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ participantId, status: "CONFIRMED" }), + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create entry: ${response.status} ${text}`); + } + + return; + } + + async withdrawTournament(tournamentId: string, participantId: string): Promise { + const response = await fetch( + `${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries/${participantId}`, + { + method: "DELETE", + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to withdraw from tournament: ${response.status} ${text}`); + } + } + + async editMatchResult( + tournamentId: string, + matchId: string, + participants: Array<{ participantId: string; score: number; result?: string }>, + ): Promise { + const response = await fetch( + `${config.tournaments.apiUrl}/tournaments/${tournamentId}/matches/${matchId}/result`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ participants }), + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to edit match result: ${response.status} ${text}`); + } + } + + async annulMatchResult(tournamentId: string, matchId: string): Promise { + const response = await fetch( + `${config.tournaments.apiUrl}/tournaments/${tournamentId}/matches/${matchId}/result`, + { + method: "DELETE", + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to annul match result: ${response.status} ${text}`); + } + } + + async publishTournament(tournamentId: string): Promise { + const response = await fetch( + `${config.tournaments.apiUrl}/tournaments/${tournamentId}/publish`, + { + method: "PUT", + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to publish tournament: ${response.status} ${text}`); + } + } + + async startTournament(tournamentId: string): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/start`, { + method: "PUT", + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to start tournament: ${response.status} ${text}`); + } + } + + async completeTournament(tournamentId: string): Promise { + const response = await fetch( + `${config.tournaments.apiUrl}/tournaments/${tournamentId}/complete`, + { + method: "PUT", + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to complete tournament: ${response.status} ${text}`); + } + } + + async cancelTournament(tournamentId: string): Promise { + const response = await fetch( + `${config.tournaments.apiUrl}/tournaments/${tournamentId}/cancel`, + { + method: "PUT", + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to cancel tournament: ${response.status} ${text}`); + } + } + + async confirmTournamentEntry(tournamentId: string, participantId: string): Promise { + const response = await fetch( + `${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries/${participantId}/confirm`, + { + method: "PUT", + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to confirm tournament entry: ${response.status} ${text}`); + } + } +} diff --git a/src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts b/src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts index 5ac4080..35b60e2 100644 --- a/src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts +++ b/src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts @@ -5,68 +5,70 @@ import { TournamentRankingRepository } from "../domain/TournamentRankingReposito import { RankingWithUser } from "../domain/RankingWithUser"; export class TournamentRankingPostgresRepository implements TournamentRankingRepository { - async findByUserId(userId: string): Promise { - const repository = dataSource.getRepository(LightningRankingEntity); - const entity = await repository.findOne({ - where: { userId }, - relations: ["user"] - }); + async findByUserId(userId: string): Promise { + const repository = dataSource.getRepository(LightningRankingEntity); + const entity = await repository.findOne({ + where: { userId }, + relations: ["user"], + }); - if (!entity) { - return null; - } + if (!entity) { + return null; + } - return TournamentRanking.fromPrimitives({ - userId: entity.userId, - points: entity.points, - tournamentsWon: entity.tournamentsWon, - tournamentsPlayed: entity.tournamentsPlayed, - season: entity.season, - }); - } + return TournamentRanking.fromPrimitives({ + userId: entity.userId, + points: entity.points, + tournamentsWon: entity.tournamentsWon, + tournamentsPlayed: entity.tournamentsPlayed, + season: entity.season, + }); + } - async save(ranking: TournamentRanking): Promise { - const repository = dataSource.getRepository(LightningRankingEntity); + async save(ranking: TournamentRanking): Promise { + const repository = dataSource.getRepository(LightningRankingEntity); - const existingEntity = await repository.findOne({ - where: { userId: ranking.getUserId() } - }); + const existingEntity = await repository.findOne({ + where: { userId: ranking.getUserId() }, + }); - if (existingEntity) { - existingEntity.points = ranking.points; - existingEntity.tournamentsWon = ranking.tournamentsWon; - existingEntity.tournamentsPlayed = ranking.tournamentsPlayed; - existingEntity.season = ranking.season; - await repository.save(existingEntity); - } else { - const newEntity = repository.create({ - userId: ranking.getUserId(), - points: ranking.points, - tournamentsWon: ranking.tournamentsWon, - tournamentsPlayed: ranking.tournamentsPlayed, - season: ranking.season, - }); - await repository.save(newEntity); - } - } + if (existingEntity) { + existingEntity.points = ranking.points; + existingEntity.tournamentsWon = ranking.tournamentsWon; + existingEntity.tournamentsPlayed = ranking.tournamentsPlayed; + existingEntity.season = ranking.season; + await repository.save(existingEntity); + } else { + const newEntity = repository.create({ + userId: ranking.getUserId(), + points: ranking.points, + tournamentsWon: ranking.tournamentsWon, + tournamentsPlayed: ranking.tournamentsPlayed, + season: ranking.season, + }); + await repository.save(newEntity); + } + } - async getTopRankings(limit: number): Promise { - const repository = dataSource.getRepository(LightningRankingEntity); - const rankings = await repository.find({ - order: { points: "DESC" }, - take: limit, - relations: ["user"] - }); + async getTopRankings(limit: number): Promise { + const repository = dataSource.getRepository(LightningRankingEntity); + const rankings = await repository.find({ + order: { points: "DESC" }, + take: limit, + relations: ["user"], + }); - return rankings.map(entity => ({ - userId: entity.userId, - points: entity.points, - tournamentsWon: entity.tournamentsWon, - tournamentsPlayed: entity.tournamentsPlayed, - user: entity.user ? { - username: entity.user.username, - email: entity.user.email - } : null - })); - } + return rankings.map((entity) => ({ + userId: entity.userId, + points: entity.points, + tournamentsWon: entity.tournamentsWon, + tournamentsPlayed: entity.tournamentsPlayed, + user: entity.user + ? { + username: entity.user.username, + email: entity.user.email, + } + : null, + })); + } } diff --git a/src/modules/tournaments/infrastructure/swagger-schemas.ts b/src/modules/tournaments/infrastructure/swagger-schemas.ts index 38ade7a..b50de6d 100644 --- a/src/modules/tournaments/infrastructure/swagger-schemas.ts +++ b/src/modules/tournaments/infrastructure/swagger-schemas.ts @@ -7,30 +7,38 @@ import { t } from "elysia"; /** * Schema for recording or editing match results */ -export const MatchResultRequestSchema = t.Object({ - participants: t.Array(t.Object({ - participantId: t.String({ - description: 'Unique identifier of the participant', - examples: ['participant-123'] - }), - score: t.Number({ - description: 'Score achieved by the participant in the match', - examples: [2] - }), - }), { - description: 'Array of participants with their scores', - minItems: 2, - maxItems: 2 - }) -}, { - description: 'Match result data with participant scores', - examples: [{ - participants: [ - { participantId: 'participant-123', score: 2 }, - { participantId: 'participant-456', score: 1 } - ] - }] -}); +export const MatchResultRequestSchema = t.Object( + { + participants: t.Array( + t.Object({ + participantId: t.String({ + description: "Unique identifier of the participant", + examples: ["participant-123"], + }), + score: t.Number({ + description: "Score achieved by the participant in the match", + examples: [2], + }), + }), + { + description: "Array of participants with their scores", + minItems: 2, + maxItems: 2, + }, + ), + }, + { + description: "Match result data with participant scores", + examples: [ + { + participants: [ + { participantId: "participant-123", score: 2 }, + { participantId: "participant-456", score: 1 }, + ], + }, + ], + }, +); // ============================================================================ // Response Schemas @@ -39,192 +47,218 @@ export const MatchResultRequestSchema = t.Object({ /** * Generic message response schema */ -export const MessageResponseSchema = t.Object({ - message: t.String({ - description: 'Response message', - examples: ['Tournament published'] - }) -}, { - description: 'Generic success message response' -}); +export const MessageResponseSchema = t.Object( + { + message: t.String({ + description: "Response message", + examples: ["Tournament published"], + }), + }, + { + description: "Generic success message response", + }, +); /** * Player information schema */ -export const PlayerSchema = t.Object({ - id: t.String({ - description: 'Unique player identifier', - examples: ['player-123'] - }), - displayName: t.String({ - description: 'Display name of the player', - examples: ['JohnDoe'] - }), - userId: t.Optional(t.String({ - description: 'Associated user ID', - examples: ['user-456'] - })) -}, { - description: 'Player information' -}); +export const PlayerSchema = t.Object( + { + id: t.String({ + description: "Unique player identifier", + examples: ["player-123"], + }), + displayName: t.String({ + description: "Display name of the player", + examples: ["JohnDoe"], + }), + userId: t.Optional( + t.String({ + description: "Associated user ID", + examples: ["user-456"], + }), + ), + }, + { + description: "Player information", + }, +); /** * Participant information schema */ -export const ParticipantSchema = t.Object({ - id: t.String({ - description: 'Unique participant identifier', - examples: ['participant-789'] - }), - playerId: t.String({ - description: 'Associated player ID', - examples: ['player-123'] - }), - tournamentId: t.String({ - description: 'Tournament ID', - examples: ['tournament-001'] - }), - displayName: t.String({ - description: 'Display name for this tournament', - examples: ['JohnDoe'] - }), - status: t.String({ - description: 'Participant status', - examples: ['confirmed', 'pending', 'withdrawn'] - }) -}, { - description: 'Tournament participant information' -}); +export const ParticipantSchema = t.Object( + { + id: t.String({ + description: "Unique participant identifier", + examples: ["participant-789"], + }), + playerId: t.String({ + description: "Associated player ID", + examples: ["player-123"], + }), + tournamentId: t.String({ + description: "Tournament ID", + examples: ["tournament-001"], + }), + displayName: t.String({ + description: "Display name for this tournament", + examples: ["JohnDoe"], + }), + status: t.String({ + description: "Participant status", + examples: ["confirmed", "pending", "withdrawn"], + }), + }, + { + description: "Tournament participant information", + }, +); /** * Match participant schema (within a match) */ -export const MatchParticipantSchema = t.Object({ - participantId: t.String({ - description: 'Participant identifier', - examples: ['participant-123'] - }), - displayName: t.Optional(t.String({ - description: 'Participant display name', - examples: ['JohnDoe'] - })), - score: t.Union([t.Number(), t.Null()], { - description: 'Participant score (null if match not completed)', - examples: [2, null] - }), - result: t.Union([ - t.Literal('win'), - t.Literal('loss'), - t.Literal('draw'), - t.Null() - ], { - description: 'Match result for this participant', - examples: ['win', 'loss', null] - }) -}, { - description: 'Participant data within a match' -}); +export const MatchParticipantSchema = t.Object( + { + participantId: t.String({ + description: "Participant identifier", + examples: ["participant-123"], + }), + displayName: t.Optional( + t.String({ + description: "Participant display name", + examples: ["JohnDoe"], + }), + ), + score: t.Union([t.Number(), t.Null()], { + description: "Participant score (null if match not completed)", + examples: [2, null], + }), + result: t.Union([t.Literal("win"), t.Literal("loss"), t.Literal("draw"), t.Null()], { + description: "Match result for this participant", + examples: ["win", "loss", null], + }), + }, + { + description: "Participant data within a match", + }, +); /** * Match schema */ -export const MatchSchema = t.Object({ - id: t.String({ - description: 'Unique match identifier', - examples: ['match-001'] - }), - tournamentId: t.String({ - description: 'Tournament identifier', - examples: ['tournament-001'] - }), - roundNumber: t.Number({ - description: 'Round number in the tournament', - examples: [1, 2, 3] - }), - matchNumber: t.Optional(t.Number({ - description: 'Match number within the round', - examples: [1] - })), - participants: t.Array(MatchParticipantSchema, { - description: 'Participants in this match', - minItems: 2, - maxItems: 2 - }), - completedAt: t.Union([t.String(), t.Null()], { - description: 'ISO timestamp when match was completed', - examples: ['2025-11-24T10:00:00Z', null] - }) -}, { - description: 'Match information' -}); +export const MatchSchema = t.Object( + { + id: t.String({ + description: "Unique match identifier", + examples: ["match-001"], + }), + tournamentId: t.String({ + description: "Tournament identifier", + examples: ["tournament-001"], + }), + roundNumber: t.Number({ + description: "Round number in the tournament", + examples: [1, 2, 3], + }), + matchNumber: t.Optional( + t.Number({ + description: "Match number within the round", + examples: [1], + }), + ), + participants: t.Array(MatchParticipantSchema, { + description: "Participants in this match", + minItems: 2, + maxItems: 2, + }), + completedAt: t.Union([t.String(), t.Null()], { + description: "ISO timestamp when match was completed", + examples: ["2025-11-24T10:00:00Z", null], + }), + }, + { + description: "Match information", + }, +); /** * Bracket round schema */ -export const BracketRoundSchema = t.Object({ - roundNumber: t.Number({ - description: 'Round number', - examples: [1, 2, 3] - }), - matches: t.Array(MatchSchema, { - description: 'Matches in this round' - }) -}, { - description: 'Tournament bracket round' -}); +export const BracketRoundSchema = t.Object( + { + roundNumber: t.Number({ + description: "Round number", + examples: [1, 2, 3], + }), + matches: t.Array(MatchSchema, { + description: "Matches in this round", + }), + }, + { + description: "Tournament bracket round", + }, +); /** * Bracket schema */ -export const BracketSchema = t.Object({ - tournamentId: t.String({ - description: 'Tournament identifier', - examples: ['tournament-001'] - }), - rounds: t.Array(BracketRoundSchema, { - description: 'All rounds in the bracket' - }) -}, { - description: 'Complete tournament bracket structure', - examples: [{ - tournamentId: 'tournament-001', - rounds: [ - { - roundNumber: 1, - matches: [ - { - id: 'match-1', - tournamentId: 'tournament-001', - roundNumber: 1, - matchNumber: 1, - participants: [ - { participantId: 'p1', displayName: 'Player1', score: null, result: null }, - { participantId: 'p2', displayName: 'Player2', score: null, result: null } - ], - completedAt: null - } - ] - } - ] - }] -}); +export const BracketSchema = t.Object( + { + tournamentId: t.String({ + description: "Tournament identifier", + examples: ["tournament-001"], + }), + rounds: t.Array(BracketRoundSchema, { + description: "All rounds in the bracket", + }), + }, + { + description: "Complete tournament bracket structure", + examples: [ + { + tournamentId: "tournament-001", + rounds: [ + { + roundNumber: 1, + matches: [ + { + id: "match-1", + tournamentId: "tournament-001", + roundNumber: 1, + matchNumber: 1, + participants: [ + { participantId: "p1", displayName: "Player1", score: null, result: null }, + { participantId: "p2", displayName: "Player2", score: null, result: null }, + ], + completedAt: null, + }, + ], + }, + ], + }, + ], + }, +); /** * Array of matches response */ export const MatchesArraySchema = t.Array(MatchSchema, { - description: 'Array of tournament matches', - examples: [[ - { - id: 'match-1', - tournamentId: 'tournament-001', - roundNumber: 1, - matchNumber: 1, - participants: [ - { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, - { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } - ], - completedAt: '2025-11-24T10:00:00Z' - } - ]] + description: "Array of tournament matches", + examples: [ + [ + { + id: "match-1", + tournamentId: "tournament-001", + roundNumber: 1, + matchNumber: 1, + participants: [ + { participantId: "p1", displayName: "Player1", score: 2, result: "win" }, + { participantId: "p2", displayName: "Player2", score: 1, result: "loss" }, + ], + completedAt: "2025-11-24T10:00:00Z", + }, + ], + ], }); diff --git a/src/modules/user/application/UserBanUser.ts b/src/modules/user/application/UserBanUser.ts index f6fe988..8c3dd35 100644 --- a/src/modules/user/application/UserBanUser.ts +++ b/src/modules/user/application/UserBanUser.ts @@ -3,26 +3,26 @@ import { UserBan } from "../domain/UserBan"; import { v4 as uuidv4 } from "uuid"; export class UserBanUser { - constructor(private readonly userBanRepository: UserBanRepository) {} + constructor(private readonly userBanRepository: UserBanRepository) {} - async execute(params: { - userId: string; - reason: string; - bannedBy: string; - expiresAt?: Date; - }): Promise { - const now = new Date(); - await this.userBanRepository.finishActiveBan(params.userId, now); - const ban = UserBan.create({ - id: uuidv4(), - userId: params.userId, - reason: params.reason, - bannedAt: now, - expiresAt: params.expiresAt, - bannedBy: params.bannedBy, - createdAt: now, - updatedAt: now, - }); - await this.userBanRepository.banUser(ban); - } -} \ No newline at end of file + async execute(params: { + userId: string; + reason: string; + bannedBy: string; + expiresAt?: Date; + }): Promise { + const now = new Date(); + await this.userBanRepository.finishActiveBan(params.userId, now); + const ban = UserBan.create({ + id: uuidv4(), + userId: params.userId, + reason: params.reason, + bannedAt: now, + expiresAt: params.expiresAt, + bannedBy: params.bannedBy, + createdAt: now, + updatedAt: now, + }); + await this.userBanRepository.banUser(ban); + } +} diff --git a/src/modules/user/application/UserForgotPassword.ts b/src/modules/user/application/UserForgotPassword.ts index 3ce4370..313e72d 100644 --- a/src/modules/user/application/UserForgotPassword.ts +++ b/src/modules/user/application/UserForgotPassword.ts @@ -12,7 +12,7 @@ export class UserForgotPassword { private readonly jwt: JWT, private readonly logger: Logger, private readonly resetLinkBuilder: ResetPasswordLinkBuilder, - ) { } + ) {} async forgotPassword({ email, diff --git a/src/modules/user/application/UserGetActiveBan.ts b/src/modules/user/application/UserGetActiveBan.ts index ab09727..dffa026 100644 --- a/src/modules/user/application/UserGetActiveBan.ts +++ b/src/modules/user/application/UserGetActiveBan.ts @@ -2,9 +2,9 @@ import { UserBanRepository } from "../domain/UserBanRepository"; import { UserBan } from "../domain/UserBan"; export class UserGetActiveBan { - constructor(private readonly userBanRepository: UserBanRepository) {} + constructor(private readonly userBanRepository: UserBanRepository) {} - async execute(userId: string): Promise { - return this.userBanRepository.findActiveBanByUserId(userId); - } -} \ No newline at end of file + async execute(userId: string): Promise { + return this.userBanRepository.findActiveBanByUserId(userId); + } +} diff --git a/src/modules/user/application/UserGetBanHistory.ts b/src/modules/user/application/UserGetBanHistory.ts index 9b11f3c..ad98549 100644 --- a/src/modules/user/application/UserGetBanHistory.ts +++ b/src/modules/user/application/UserGetBanHistory.ts @@ -2,9 +2,9 @@ import { UserBanRepository } from "../domain/UserBanRepository"; import { UserBan } from "../domain/UserBan"; export class UserGetBanHistory { - constructor(private readonly userBanRepository: UserBanRepository) {} + constructor(private readonly userBanRepository: UserBanRepository) {} - async execute(userId: string): Promise { - return this.userBanRepository.getBansByUserId(userId); - } -} \ No newline at end of file + async execute(userId: string): Promise { + return this.userBanRepository.getBansByUserId(userId); + } +} diff --git a/src/modules/user/application/UserRegister.ts b/src/modules/user/application/UserRegister.ts index 68898ac..3daf501 100644 --- a/src/modules/user/application/UserRegister.ts +++ b/src/modules/user/application/UserRegister.ts @@ -17,9 +17,19 @@ export class UserRegister { private readonly logger: Logger, private readonly emailSender: EmailSender, private readonly jwt: JWT, - ) { } + ) {} - async register({ id, email, username, password }: { id: string; email: string; username: string; password: string }): Promise { + async register({ + id, + email, + username, + password, + }: { + id: string; + email: string; + username: string; + password: string; + }): Promise { this.logger.info(`Creating new user ${email}`); const existingUser = await this.repository.findByEmailOrUsername(email, username); @@ -32,7 +42,14 @@ export class UserRegister { const gamePassword = GamePassword.generate(); const gamePasswordHashed = await this.hash.hash(gamePassword.value); - return this.registerWithSecurePassword({ id, email, username, password, gamePassword: gamePassword.value, gamePasswordHashed }); + return this.registerWithSecurePassword({ + id, + email, + username, + password, + gamePassword: gamePassword.value, + gamePasswordHashed, + }); } private async registerWithSecurePassword({ @@ -49,7 +66,13 @@ export class UserRegister { password: string; gamePassword: string; gamePasswordHashed: string; - }): Promise<{ id: string; username: string; email: string; token: string; gamePassword: string }> { + }): Promise<{ + id: string; + username: string; + email: string; + token: string; + gamePassword: string; + }> { const securePassword = SecurePassword.create(password); const securePasswordHashed = await this.hash.hash(securePassword.value); diff --git a/src/modules/user/application/UserUnbanUser.ts b/src/modules/user/application/UserUnbanUser.ts index d3068fd..32bb5a2 100644 --- a/src/modules/user/application/UserUnbanUser.ts +++ b/src/modules/user/application/UserUnbanUser.ts @@ -1,9 +1,9 @@ import { UserBanRepository } from "../domain/UserBanRepository"; export class UserUnbanUser { - constructor(private readonly userBanRepository: UserBanRepository) {} + constructor(private readonly userBanRepository: UserBanRepository) {} - async execute(userId: string): Promise { - await this.userBanRepository.unbanUser(userId); - } -} \ No newline at end of file + async execute(userId: string): Promise { + await this.userBanRepository.unbanUser(userId); + } +} diff --git a/src/modules/user/domain/GamePassword.ts b/src/modules/user/domain/GamePassword.ts index d5221ce..541e4f6 100644 --- a/src/modules/user/domain/GamePassword.ts +++ b/src/modules/user/domain/GamePassword.ts @@ -1,6 +1,7 @@ export class GamePassword { private static readonly LENGTH = 4; - private static readonly CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static readonly CHARSET = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private constructor(public readonly value: string) {} diff --git a/src/modules/user/domain/ResetPasswordLinkBuilder.ts b/src/modules/user/domain/ResetPasswordLinkBuilder.ts index 4ab90ae..81149b7 100644 --- a/src/modules/user/domain/ResetPasswordLinkBuilder.ts +++ b/src/modules/user/domain/ResetPasswordLinkBuilder.ts @@ -12,12 +12,25 @@ export class ResetPasswordLinkBuilder { entries: FrontendResetEntry[], private readonly defaultTemplate: string, ) { - this.entries = entries.map((entry) => ({ origin: this.normalize(entry.origin), template: entry.template })); + this.entries = entries.map((entry) => ({ + origin: this.normalize(entry.origin), + template: entry.template, + })); } - build({ origin, referer, token }: { origin?: string | null; referer?: string | null; token: string }): string { + build({ + origin, + referer, + token, + }: { + origin?: string | null; + referer?: string | null; + token: string; + }): string { const requestOrigin = this.resolveOrigin(origin, referer); - const entry = requestOrigin ? this.entries.find((candidate) => candidate.origin === requestOrigin) : undefined; + const entry = requestOrigin + ? this.entries.find((candidate) => candidate.origin === requestOrigin) + : undefined; const template = entry?.template ?? this.defaultTemplate; return template.replaceAll("{token}", token); diff --git a/src/modules/user/domain/User.ts b/src/modules/user/domain/User.ts index 4805d34..b3b1bc2 100644 --- a/src/modules/user/domain/User.ts +++ b/src/modules/user/domain/User.ts @@ -63,7 +63,15 @@ export class User { if (!password.trim()) { throw new InvalidArgumentError(`password cannot be empty`); } - return new User({ id, username, email, password, securePassword: securePassword ?? null, role, participantId: null }); + return new User({ + id, + username, + email, + password, + securePassword: securePassword ?? null, + role, + participantId: null, + }); } static from(data: { diff --git a/src/modules/user/domain/UserBan.ts b/src/modules/user/domain/UserBan.ts index 459175a..c68af54 100644 --- a/src/modules/user/domain/UserBan.ts +++ b/src/modules/user/domain/UserBan.ts @@ -1,65 +1,65 @@ export class UserBan { - public readonly id: string; - public readonly userId: string; - public readonly reason: string; - public readonly bannedAt: Date; - public readonly expiresAt?: Date; - public readonly bannedBy: string; - public readonly createdAt: Date; - public readonly updatedAt: Date; + public readonly id: string; + public readonly userId: string; + public readonly reason: string; + public readonly bannedAt: Date; + public readonly expiresAt?: Date; + public readonly bannedBy: string; + public readonly createdAt: Date; + public readonly updatedAt: Date; - private constructor(params: { - id: string; - userId: string; - reason: string; - bannedAt: Date; - expiresAt?: Date; - bannedBy: string; - createdAt: Date; - updatedAt: Date; - }) { - this.id = params.id; - this.userId = params.userId; - this.reason = params.reason; - this.bannedAt = params.bannedAt; - this.expiresAt = params.expiresAt; - this.bannedBy = params.bannedBy; - this.createdAt = params.createdAt; - this.updatedAt = params.updatedAt; - } + private constructor(params: { + id: string; + userId: string; + reason: string; + bannedAt: Date; + expiresAt?: Date; + bannedBy: string; + createdAt: Date; + updatedAt: Date; + }) { + this.id = params.id; + this.userId = params.userId; + this.reason = params.reason; + this.bannedAt = params.bannedAt; + this.expiresAt = params.expiresAt; + this.bannedBy = params.bannedBy; + this.createdAt = params.createdAt; + this.updatedAt = params.updatedAt; + } - static create(params: { - id: string; - userId: string; - reason: string; - bannedAt: Date; - expiresAt?: Date; - bannedBy: string; - createdAt: Date; - updatedAt: Date; - }): UserBan { - return new UserBan(params); - } + static create(params: { + id: string; + userId: string; + reason: string; + bannedAt: Date; + expiresAt?: Date; + bannedBy: string; + createdAt: Date; + updatedAt: Date; + }): UserBan { + return new UserBan(params); + } - static from(data: { - id: string; - userId: string; - reason: string; - bannedAt: Date; - expiresAt?: Date; - bannedBy: string; - createdAt: Date; - updatedAt: Date; - }): UserBan { - return new UserBan({ - id: data.id, - userId: data.userId, - reason: data.reason, - bannedAt: data.bannedAt, - expiresAt: data.expiresAt, - bannedBy: data.bannedBy, - createdAt: data.createdAt, - updatedAt: data.updatedAt - }); - } -} \ No newline at end of file + static from(data: { + id: string; + userId: string; + reason: string; + bannedAt: Date; + expiresAt?: Date; + bannedBy: string; + createdAt: Date; + updatedAt: Date; + }): UserBan { + return new UserBan({ + id: data.id, + userId: data.userId, + reason: data.reason, + bannedAt: data.bannedAt, + expiresAt: data.expiresAt, + bannedBy: data.bannedBy, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + }); + } +} diff --git a/src/modules/user/domain/UserBanRepository.ts b/src/modules/user/domain/UserBanRepository.ts index 24fb5b0..4f701b2 100644 --- a/src/modules/user/domain/UserBanRepository.ts +++ b/src/modules/user/domain/UserBanRepository.ts @@ -1,9 +1,9 @@ import { UserBan } from "./UserBan"; export interface UserBanRepository { - banUser(ban: UserBan): Promise; - findActiveBanByUserId(userId: string): Promise; - unbanUser(userId: string): Promise; - getBansByUserId(userId: string): Promise; - finishActiveBan(userId: string, finishedAt: Date): Promise; -} \ No newline at end of file + banUser(ban: UserBan): Promise; + findActiveBanByUserId(userId: string): Promise; + unbanUser(userId: string): Promise; + getBansByUserId(userId: string): Promise; + finishActiveBan(userId: string, finishedAt: Date): Promise; +} diff --git a/src/modules/user/infrastructure/UserBanPostgresRepository.ts b/src/modules/user/infrastructure/UserBanPostgresRepository.ts index b87482a..054d451 100644 --- a/src/modules/user/infrastructure/UserBanPostgresRepository.ts +++ b/src/modules/user/infrastructure/UserBanPostgresRepository.ts @@ -5,104 +5,108 @@ import { UserBanRepository } from "../domain/UserBanRepository"; import { UserProfileEntity } from "../../../evolution-types/src/entities/UserProfileEntity"; export class UserBanPostgresRepository implements UserBanRepository { - async banUser(ban: UserBan): Promise { - const repository = dataSource.getRepository(UserBanEntity); - const userRepository = dataSource.getRepository(UserProfileEntity); - const user = await userRepository.findOneOrFail({ where: { id: ban.userId } }); - const bannedBy = await userRepository.findOneOrFail({ where: { id: ban.bannedBy } }); - const entity = repository.create({ - id: ban.id, - user, - reason: ban.reason, - bannedAt: ban.bannedAt, - expiresAt: ban.expiresAt, - bannedBy, - createdAt: ban.createdAt, - updatedAt: ban.updatedAt, - }); - await repository.save(entity); + async banUser(ban: UserBan): Promise { + const repository = dataSource.getRepository(UserBanEntity); + const userRepository = dataSource.getRepository(UserProfileEntity); + const user = await userRepository.findOneOrFail({ where: { id: ban.userId } }); + const bannedBy = await userRepository.findOneOrFail({ where: { id: ban.bannedBy } }); + const entity = repository.create({ + id: ban.id, + user, + reason: ban.reason, + bannedAt: ban.bannedAt, + expiresAt: ban.expiresAt, + bannedBy, + createdAt: ban.createdAt, + updatedAt: ban.updatedAt, + }); + await repository.save(entity); - user.deletedAt = new Date(); - await userRepository.save(user); - } + user.deletedAt = new Date(); + await userRepository.save(user); + } - async findActiveBanByUserId(userId: string): Promise { - const repository = dataSource.getRepository(UserBanEntity); - const now = new Date(); - const entity = await repository - .createQueryBuilder("ban") - .leftJoinAndSelect("ban.user", "user") - .leftJoinAndSelect("ban.bannedBy", "bannedBy") - .where("ban.user = :userId", { userId }) - .andWhere("(ban.expiresAt IS NULL OR ban.expiresAt > :now)", { now }) - .orderBy("ban.bannedAt", "DESC") - .getOne(); - return entity ? UserBan.from({ - id: entity.id, - userId: entity.user.id, - reason: entity.reason, - bannedAt: entity.bannedAt, - expiresAt: entity.expiresAt, - bannedBy: entity.bannedBy.id, - createdAt: entity.createdAt, - updatedAt: entity.updatedAt, - }) : null; - } + async findActiveBanByUserId(userId: string): Promise { + const repository = dataSource.getRepository(UserBanEntity); + const now = new Date(); + const entity = await repository + .createQueryBuilder("ban") + .leftJoinAndSelect("ban.user", "user") + .leftJoinAndSelect("ban.bannedBy", "bannedBy") + .where("ban.user = :userId", { userId }) + .andWhere("(ban.expiresAt IS NULL OR ban.expiresAt > :now)", { now }) + .orderBy("ban.bannedAt", "DESC") + .getOne(); + return entity + ? UserBan.from({ + id: entity.id, + userId: entity.user.id, + reason: entity.reason, + bannedAt: entity.bannedAt, + expiresAt: entity.expiresAt, + bannedBy: entity.bannedBy.id, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }) + : null; + } - async unbanUser(userId: string): Promise { - const repository = dataSource.getRepository(UserBanEntity); - const now = new Date(); - const activeBan = await repository - .createQueryBuilder("ban") - .leftJoinAndSelect("ban.user", "user") - .where("ban.user = :userId", { userId }) - .andWhere("(ban.expiresAt IS NULL OR ban.expiresAt > :now)", { now }) - .orderBy("ban.bannedAt", "DESC") - .getOne(); - if (activeBan) { - activeBan.expiresAt = now; - await repository.save(activeBan); - } + async unbanUser(userId: string): Promise { + const repository = dataSource.getRepository(UserBanEntity); + const now = new Date(); + const activeBan = await repository + .createQueryBuilder("ban") + .leftJoinAndSelect("ban.user", "user") + .where("ban.user = :userId", { userId }) + .andWhere("(ban.expiresAt IS NULL OR ban.expiresAt > :now)", { now }) + .orderBy("ban.bannedAt", "DESC") + .getOne(); + if (activeBan) { + activeBan.expiresAt = now; + await repository.save(activeBan); + } - const userRepository = dataSource.getRepository(UserProfileEntity); - const user = await userRepository.findOne({ where: { id: userId }, withDeleted: true }); - if (user) { - await userRepository.restore(user.id); - } - } + const userRepository = dataSource.getRepository(UserProfileEntity); + const user = await userRepository.findOne({ where: { id: userId }, withDeleted: true }); + if (user) { + await userRepository.restore(user.id); + } + } - async getBansByUserId(userId: string): Promise { - const repository = dataSource.getRepository(UserBanEntity); - const entities = await repository.find({ - where: { user: { id: userId } }, - relations: ["user", "bannedBy"], - order: { bannedAt: "DESC" }, - }); - return entities.map((userBan) => UserBan.from({ - id: userBan.id, - userId: userBan.user.id, - reason: userBan.reason, - bannedAt: userBan.bannedAt, - expiresAt: userBan.expiresAt, - bannedBy: userBan.bannedBy.id, - createdAt: userBan.createdAt, - updatedAt: userBan.updatedAt, - })); - } + async getBansByUserId(userId: string): Promise { + const repository = dataSource.getRepository(UserBanEntity); + const entities = await repository.find({ + where: { user: { id: userId } }, + relations: ["user", "bannedBy"], + order: { bannedAt: "DESC" }, + }); + return entities.map((userBan) => + UserBan.from({ + id: userBan.id, + userId: userBan.user.id, + reason: userBan.reason, + bannedAt: userBan.bannedAt, + expiresAt: userBan.expiresAt, + bannedBy: userBan.bannedBy.id, + createdAt: userBan.createdAt, + updatedAt: userBan.updatedAt, + }), + ); + } - async finishActiveBan(userId: string, finishedAt: Date): Promise { - const repository = dataSource.getRepository(UserBanEntity); - const now = finishedAt; - const activeBan = await repository - .createQueryBuilder("ban") - .leftJoinAndSelect("ban.user", "user") - .where("ban.user = :userId", { userId }) - .andWhere("(ban.expiresAt IS NULL OR ban.expiresAt > :now)", { now }) - .orderBy("ban.bannedAt", "DESC") - .getOne(); - if (activeBan) { - activeBan.expiresAt = finishedAt; - await repository.save(activeBan); - } - } -} \ No newline at end of file + async finishActiveBan(userId: string, finishedAt: Date): Promise { + const repository = dataSource.getRepository(UserBanEntity); + const now = finishedAt; + const activeBan = await repository + .createQueryBuilder("ban") + .leftJoinAndSelect("ban.user", "user") + .where("ban.user = :userId", { userId }) + .andWhere("(ban.expiresAt IS NULL OR ban.expiresAt > :now)", { now }) + .orderBy("ban.bannedAt", "DESC") + .getOne(); + if (activeBan) { + activeBan.expiresAt = finishedAt; + await repository.save(activeBan); + } + } +} diff --git a/src/modules/wrapped/application/GetSeasonWrappedData.ts b/src/modules/wrapped/application/GetSeasonWrappedData.ts index dd604a8..b634b16 100644 --- a/src/modules/wrapped/application/GetSeasonWrappedData.ts +++ b/src/modules/wrapped/application/GetSeasonWrappedData.ts @@ -4,20 +4,20 @@ import type { WrappedRepository } from "../domain/WrappedRepository"; import { config } from "../../../config"; export class GetSeasonWrappedData { - constructor(private readonly repository: WrappedRepository) { } + constructor(private readonly repository: WrappedRepository) {} - async execute(seasonId: number, playerId: string): Promise { - // Prevent accessing wrapped data for the current/active season - if (seasonId >= config.season) { - throw new Error(`Season ${seasonId} Wrapped is not available yet.`); - } + async execute(seasonId: number, playerId: string): Promise { + // Prevent accessing wrapped data for the current/active season + if (seasonId >= config.season) { + throw new Error(`Season ${seasonId} Wrapped is not available yet.`); + } - const data = await this.repository.getSeasonWrappedData(seasonId, playerId); + const data = await this.repository.getSeasonWrappedData(seasonId, playerId); - if (!data) { - throw new Error(`No data found for player ${playerId} in season ${seasonId}`); - } + if (!data) { + throw new Error(`No data found for player ${playerId} in season ${seasonId}`); + } - return data; - } + return data; + } } diff --git a/src/modules/wrapped/application/ThemeStrategyFactory.ts b/src/modules/wrapped/application/ThemeStrategyFactory.ts index d7647e0..fa5de78 100644 --- a/src/modules/wrapped/application/ThemeStrategyFactory.ts +++ b/src/modules/wrapped/application/ThemeStrategyFactory.ts @@ -1,20 +1,20 @@ import type { IThemeStrategy } from "../domain/IThemeStrategy"; export class ThemeStrategyFactory { - private strategies: Map = new Map(); + private strategies: Map = new Map(); - register(name: string, strategy: IThemeStrategy): void { - this.strategies.set(name, strategy); - } + register(name: string, strategy: IThemeStrategy): void { + this.strategies.set(name, strategy); + } - get(name: string): IThemeStrategy { - const strategy = this.strategies.get(name); - if (!strategy) { - // Fallback to dark theme if requested theme doesn't exist - const dark = this.strategies.get("dark"); - if (!dark) throw new Error("Default 'dark' theme not registered"); - return dark; - } - return strategy; - } + get(name: string): IThemeStrategy { + const strategy = this.strategies.get(name); + if (!strategy) { + // Fallback to dark theme if requested theme doesn't exist + const dark = this.strategies.get("dark"); + if (!dark) throw new Error("Default 'dark' theme not registered"); + return dark; + } + return strategy; + } } diff --git a/src/modules/wrapped/domain/Achievement.ts b/src/modules/wrapped/domain/Achievement.ts index b0e5578..8d2ff42 100644 --- a/src/modules/wrapped/domain/Achievement.ts +++ b/src/modules/wrapped/domain/Achievement.ts @@ -1,7 +1,7 @@ export interface Achievement { - id: number; - name: string; - description: string; - icon: string; - unlockedAt: Date; + id: number; + name: string; + description: string; + icon: string; + unlockedAt: Date; } diff --git a/src/modules/wrapped/domain/BanListStats.ts b/src/modules/wrapped/domain/BanListStats.ts index 731bcd8..c7abc95 100644 --- a/src/modules/wrapped/domain/BanListStats.ts +++ b/src/modules/wrapped/domain/BanListStats.ts @@ -1,17 +1,17 @@ export class BanListStats { - constructor( - public readonly banListName: string, - public readonly matches: number, - public readonly wins: number, - public readonly losses: number, - public readonly draws: number, - public readonly winrate: number, - public readonly topMatchup: string | null = null, - ) { } + constructor( + public readonly banListName: string, + public readonly matches: number, + public readonly wins: number, + public readonly losses: number, + public readonly draws: number, + public readonly winrate: number, + public readonly topMatchup: string | null = null, + ) {} - getFlavor(): string { - if (this.winrate >= 70) return "En esta banlist estabas on fire 🔥"; - if (this.winrate <= 40) return "En esta banlist sufriste un poco 😅"; - return "En esta banlist te mantuviste competitivo 💪"; - } + getFlavor(): string { + if (this.winrate >= 70) return "En esta banlist estabas on fire 🔥"; + if (this.winrate <= 40) return "En esta banlist sufriste un poco 😅"; + return "En esta banlist te mantuviste competitivo 💪"; + } } diff --git a/src/modules/wrapped/domain/ExtraStats.ts b/src/modules/wrapped/domain/ExtraStats.ts index 8fbc02b..f0b5550 100644 --- a/src/modules/wrapped/domain/ExtraStats.ts +++ b/src/modules/wrapped/domain/ExtraStats.ts @@ -1,5 +1,5 @@ export interface ExtraStats { - mostPlayedBanList: string | null; - uniqueOpponents: number; - bestDay: string | null; + mostPlayedBanList: string | null; + uniqueOpponents: number; + bestDay: string | null; } diff --git a/src/modules/wrapped/domain/IThemeStrategy.ts b/src/modules/wrapped/domain/IThemeStrategy.ts index cf04691..1d27fec 100644 --- a/src/modules/wrapped/domain/IThemeStrategy.ts +++ b/src/modules/wrapped/domain/IThemeStrategy.ts @@ -1,30 +1,30 @@ import type { SeasonWrapped } from "./SeasonWrapped"; export interface ThemePhrases { - coverTitle: string; - coverSubtitle: string; - statsTitle: string; - statsSubtitle: string; - rivalsTitle: string; - rivalsSubtitle: string; - achievementsTitle: string; - achievementsSubtitle: string; - summaryTitle: string; - summarySubtitle: string; - [key: string]: string; + coverTitle: string; + coverSubtitle: string; + statsTitle: string; + statsSubtitle: string; + rivalsTitle: string; + rivalsSubtitle: string; + achievementsTitle: string; + achievementsSubtitle: string; + summaryTitle: string; + summarySubtitle: string; + [key: string]: string; } export interface GenerateOptions { - locale: string; - theme: string; - includeMatchList: boolean; - singlePage?: boolean; + locale: string; + theme: string; + includeMatchList: boolean; + singlePage?: boolean; } export interface IThemeStrategy { - getName(): string; - getStylesheet(): string; - getBackground(): string; - getPhrases(data: SeasonWrapped): ThemePhrases; - renderSpecialSections(data: SeasonWrapped, options: GenerateOptions, background: string): string; + getName(): string; + getStylesheet(): string; + getBackground(): string; + getPhrases(data: SeasonWrapped): ThemePhrases; + renderSpecialSections(data: SeasonWrapped, options: GenerateOptions, background: string): string; } diff --git a/src/modules/wrapped/domain/Nemesis.ts b/src/modules/wrapped/domain/Nemesis.ts index 9069c15..1ee25ae 100644 --- a/src/modules/wrapped/domain/Nemesis.ts +++ b/src/modules/wrapped/domain/Nemesis.ts @@ -1,13 +1,13 @@ export class Nemesis { - constructor( - public readonly playerId: string, - public readonly playerName: string, - public readonly playerAvatar: string | null, - public readonly totalMatches: number, - public readonly wins: number, - public readonly losses: number, - public readonly winrate: number, - ) { } + constructor( + public readonly playerId: string, + public readonly playerName: string, + public readonly playerAvatar: string | null, + public readonly totalMatches: number, + public readonly wins: number, + public readonly losses: number, + public readonly winrate: number, + ) {} } export type Victim = Nemesis; diff --git a/src/modules/wrapped/domain/PlayerRanking.ts b/src/modules/wrapped/domain/PlayerRanking.ts index b831ebd..4d8b239 100644 --- a/src/modules/wrapped/domain/PlayerRanking.ts +++ b/src/modules/wrapped/domain/PlayerRanking.ts @@ -1,14 +1,14 @@ export interface PlayerRanking { - position: number; - totalPlayers: number; - points: number; - rankBadge: string; + position: number; + totalPlayers: number; + points: number; + rankBadge: string; } export function calculateRankBadge(position: number): string { - if (position === 1) return "Champion"; - if (position <= 10) return "Grandmaster"; - if (position <= 50) return "Master"; - if (position <= 100) return "Diamond"; - return "Challenger"; + if (position === 1) return "Champion"; + if (position <= 10) return "Grandmaster"; + if (position <= 50) return "Master"; + if (position <= 100) return "Diamond"; + return "Challenger"; } diff --git a/src/modules/wrapped/domain/PlayerSeasonStats.ts b/src/modules/wrapped/domain/PlayerSeasonStats.ts index 79a40bf..21c68bc 100644 --- a/src/modules/wrapped/domain/PlayerSeasonStats.ts +++ b/src/modules/wrapped/domain/PlayerSeasonStats.ts @@ -1,20 +1,20 @@ export class PlayerSeasonStats { - constructor( - public readonly totalMatches: number, - public readonly wins: number, - public readonly losses: number, - public readonly draws: number, - public readonly winrate: number, - public readonly bestWinStreak: number, - public readonly worstLoseStreak: number, - public readonly avgMatchesPerDay: number, - public readonly avgMatchesPerWeek: number, - public readonly firstMatchDate: Date | null, - public readonly lastMatchDate: Date | null, - public readonly activeDays: number, - ) { } + constructor( + public readonly totalMatches: number, + public readonly wins: number, + public readonly losses: number, + public readonly draws: number, + public readonly winrate: number, + public readonly bestWinStreak: number, + public readonly worstLoseStreak: number, + public readonly avgMatchesPerDay: number, + public readonly avgMatchesPerWeek: number, + public readonly firstMatchDate: Date | null, + public readonly lastMatchDate: Date | null, + public readonly activeDays: number, + ) {} - static createEmpty(): PlayerSeasonStats { - return new PlayerSeasonStats(0, 0, 0, 0, 0, 0, 0, 0, 0, null, null, 0); - } + static createEmpty(): PlayerSeasonStats { + return new PlayerSeasonStats(0, 0, 0, 0, 0, 0, 0, 0, 0, null, null, 0); + } } diff --git a/src/modules/wrapped/domain/SeasonWrapped.ts b/src/modules/wrapped/domain/SeasonWrapped.ts index 837915b..962b771 100644 --- a/src/modules/wrapped/domain/SeasonWrapped.ts +++ b/src/modules/wrapped/domain/SeasonWrapped.ts @@ -6,24 +6,24 @@ import type { PlayerRanking } from "./PlayerRanking"; import type { PlayerSeasonStats } from "./PlayerSeasonStats"; export class SeasonWrapped { - constructor( - public readonly playerId: string, - public readonly playerName: string, - public readonly playerAvatar: string | null, - public readonly seasonId: number, - public readonly seasonName: string, - public readonly seasonDates: { start: Date; end: Date }, - public readonly globalStats: PlayerSeasonStats, - public readonly banListStats: BanListStats[], - public readonly nemesis: Nemesis | null, - public readonly victim: Victim | null, - public readonly mostPlayedOpponent: Nemesis | null, - public readonly achievements: Achievement[], - public readonly ranking: PlayerRanking, - public readonly extraStats: ExtraStats, - ) { } + constructor( + public readonly playerId: string, + public readonly playerName: string, + public readonly playerAvatar: string | null, + public readonly seasonId: number, + public readonly seasonName: string, + public readonly seasonDates: { start: Date; end: Date }, + public readonly globalStats: PlayerSeasonStats, + public readonly banListStats: BanListStats[], + public readonly nemesis: Nemesis | null, + public readonly victim: Victim | null, + public readonly mostPlayedOpponent: Nemesis | null, + public readonly achievements: Achievement[], + public readonly ranking: PlayerRanking, + public readonly extraStats: ExtraStats, + ) {} - isEmpty(): boolean { - return this.globalStats.totalMatches === 0; - } + isEmpty(): boolean { + return this.globalStats.totalMatches === 0; + } } diff --git a/src/modules/wrapped/domain/WrappedRepository.ts b/src/modules/wrapped/domain/WrappedRepository.ts index 420ef51..21df1f5 100644 --- a/src/modules/wrapped/domain/WrappedRepository.ts +++ b/src/modules/wrapped/domain/WrappedRepository.ts @@ -1,5 +1,5 @@ import type { SeasonWrapped } from "./SeasonWrapped"; export interface WrappedRepository { - getSeasonWrappedData(seasonId: number, playerId: string): Promise; + getSeasonWrappedData(seasonId: number, playerId: string): Promise; } diff --git a/src/modules/wrapped/infrastructure/WrappedController.ts b/src/modules/wrapped/infrastructure/WrappedController.ts index 53e5b9a..6be006d 100644 --- a/src/modules/wrapped/infrastructure/WrappedController.ts +++ b/src/modules/wrapped/infrastructure/WrappedController.ts @@ -3,43 +3,43 @@ import { WrappedPostgresRepository } from "./WrappedPostgresRepository"; // Domain errors export class NotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "NotFoundError"; - } + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } } export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ValidationError"; - } + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } } export class WrappedController { - async getData(context: { params: { seasonId: string; playerId: string } }) { - const seasonId = parseInt(context.params.seasonId, 10); - const { playerId } = context.params; + async getData(context: { params: { seasonId: string; playerId: string } }) { + const seasonId = parseInt(context.params.seasonId, 10); + const { playerId } = context.params; - // Validation - if (isNaN(seasonId) || seasonId < 1) { - throw new ValidationError("Season ID must be a valid positive integer"); - } + // Validation + if (isNaN(seasonId) || seasonId < 1) { + throw new ValidationError("Season ID must be a valid positive integer"); + } - if (!playerId || !/^[a-f0-9-]{36}$/i.test(playerId)) { - throw new ValidationError("Player ID must be a valid UUID"); - } + if (!playerId || !/^[a-f0-9-]{36}$/i.test(playerId)) { + throw new ValidationError("Player ID must be a valid UUID"); + } - const repository = new WrappedPostgresRepository(); - const useCase = new GetSeasonWrappedData(repository); + const repository = new WrappedPostgresRepository(); + const useCase = new GetSeasonWrappedData(repository); - const result = await useCase.execute(seasonId, playerId); + const result = await useCase.execute(seasonId, playerId); - // Check if data exists - if (!result || result.globalStats.totalMatches === 0) { - throw new NotFoundError(`No wrapped data found for player ${playerId} in season ${seasonId}`); - } + // Check if data exists + if (!result || result.globalStats.totalMatches === 0) { + throw new NotFoundError(`No wrapped data found for player ${playerId} in season ${seasonId}`); + } - return JSON.parse(JSON.stringify(result)); - } + return JSON.parse(JSON.stringify(result)); + } } diff --git a/src/modules/wrapped/infrastructure/WrappedPostgresRepository.ts b/src/modules/wrapped/infrastructure/WrappedPostgresRepository.ts index 1cc3b8e..ae11562 100644 --- a/src/modules/wrapped/infrastructure/WrappedPostgresRepository.ts +++ b/src/modules/wrapped/infrastructure/WrappedPostgresRepository.ts @@ -9,83 +9,82 @@ import { SeasonWrapped } from "../domain/SeasonWrapped"; import type { WrappedRepository } from "../domain/WrappedRepository"; export class WrappedPostgresRepository implements WrappedRepository { - async getSeasonWrappedData(seasonId: number, playerId: string): Promise { - // Check if player exists - const player = await dataSource.query( - "SELECT id, username, avatar FROM users WHERE id = $1 AND deleted_at IS NULL", - [playerId], - ); - - if (!player || player.length === 0) { - return null; - } - - const playerData = player[0]; - - // Get global stats - const globalStats = await this.getGlobalStats(seasonId, playerId); - - // If no matches, return empty wrapped - if (globalStats.totalMatches === 0) { - return new SeasonWrapped( - playerId, - playerData.username, - playerData.avatar, - seasonId, - `Season ${seasonId}`, - { start: new Date(), end: new Date() }, - globalStats, - [], - null, - null, - null, // mostPlayedOpponent - [], // achievements - { position: 0, totalPlayers: 0, points: 0, rankBadge: "Challenger" }, - { mostPlayedBanList: null, uniqueOpponents: 0, bestDay: null }, - ); - } - - // Get stats per ban list - const banListStats = await this.getBanListStats(seasonId, playerId); - - // Get nemesis and victim - const nemesis = await this.getNemesis(seasonId, playerId); - const victim = await this.getVictim(seasonId, playerId); - - // Get achievements - const achievements = await this.getAchievements(seasonId, playerId); - - // Get ranking - const ranking = await this.getRanking(seasonId, playerId); - - // Get extra stats - const extraStats = await this.getExtraStats(seasonId, playerId, banListStats); - - return new SeasonWrapped( - playerId, - playerData.username, - playerData.avatar, - seasonId, - `Season ${seasonId}`, - { - start: globalStats.firstMatchDate ?? new Date(), - end: globalStats.lastMatchDate ?? new Date(), - }, - globalStats, - banListStats, - nemesis, - victim, - null, // mostPlayedOpponent - achievements, - ranking, - extraStats, - ); - } - - private async getGlobalStats(seasonId: number, playerId: string): Promise { - - const result = await dataSource.query( - ` + async getSeasonWrappedData(seasonId: number, playerId: string): Promise { + // Check if player exists + const player = await dataSource.query( + "SELECT id, username, avatar FROM users WHERE id = $1 AND deleted_at IS NULL", + [playerId], + ); + + if (!player || player.length === 0) { + return null; + } + + const playerData = player[0]; + + // Get global stats + const globalStats = await this.getGlobalStats(seasonId, playerId); + + // If no matches, return empty wrapped + if (globalStats.totalMatches === 0) { + return new SeasonWrapped( + playerId, + playerData.username, + playerData.avatar, + seasonId, + `Season ${seasonId}`, + { start: new Date(), end: new Date() }, + globalStats, + [], + null, + null, + null, // mostPlayedOpponent + [], // achievements + { position: 0, totalPlayers: 0, points: 0, rankBadge: "Challenger" }, + { mostPlayedBanList: null, uniqueOpponents: 0, bestDay: null }, + ); + } + + // Get stats per ban list + const banListStats = await this.getBanListStats(seasonId, playerId); + + // Get nemesis and victim + const nemesis = await this.getNemesis(seasonId, playerId); + const victim = await this.getVictim(seasonId, playerId); + + // Get achievements + const achievements = await this.getAchievements(seasonId, playerId); + + // Get ranking + const ranking = await this.getRanking(seasonId, playerId); + + // Get extra stats + const extraStats = await this.getExtraStats(seasonId, playerId, banListStats); + + return new SeasonWrapped( + playerId, + playerData.username, + playerData.avatar, + seasonId, + `Season ${seasonId}`, + { + start: globalStats.firstMatchDate ?? new Date(), + end: globalStats.lastMatchDate ?? new Date(), + }, + globalStats, + banListStats, + nemesis, + victim, + null, // mostPlayedOpponent + achievements, + ranking, + extraStats, + ); + } + + private async getGlobalStats(seasonId: number, playerId: string): Promise { + const result = await dataSource.query( + ` SELECT COUNT(*)::int AS total_matches, COUNT(*) FILTER (WHERE winner = true)::int AS wins, @@ -105,40 +104,40 @@ export class WrappedPostgresRepository implements WrappedRepository { AND anulled = false AND deleted_at IS NULL `, - [seasonId, playerId], - ); - - const stats = result[0]; - - // Calculate streaks - const streaks = await this.calculateStreaks(seasonId, playerId); - - // Calculate avg matches per day and week - const avgMatchesPerDay = stats.active_days > 0 ? stats.total_matches / stats.active_days : 0; - const avgMatchesPerWeek = avgMatchesPerDay * 7; - - return new PlayerSeasonStats( - stats.total_matches, - stats.wins, - stats.losses, - stats.draws, - Math.round(stats.winrate * 10) / 10, - streaks.bestWinStreak, - streaks.worstLoseStreak, - Math.round(avgMatchesPerDay * 10) / 10, - Math.round(avgMatchesPerWeek * 10) / 10, - stats.first_match, - stats.last_match, - stats.active_days, - ); - } - - private async calculateStreaks( - seasonId: number, - playerId: string, - ): Promise<{ bestWinStreak: number; worstLoseStreak: number }> { - const matches = await dataSource.query( - ` + [seasonId, playerId], + ); + + const stats = result[0]; + + // Calculate streaks + const streaks = await this.calculateStreaks(seasonId, playerId); + + // Calculate avg matches per day and week + const avgMatchesPerDay = stats.active_days > 0 ? stats.total_matches / stats.active_days : 0; + const avgMatchesPerWeek = avgMatchesPerDay * 7; + + return new PlayerSeasonStats( + stats.total_matches, + stats.wins, + stats.losses, + stats.draws, + Math.round(stats.winrate * 10) / 10, + streaks.bestWinStreak, + streaks.worstLoseStreak, + Math.round(avgMatchesPerDay * 10) / 10, + Math.round(avgMatchesPerWeek * 10) / 10, + stats.first_match, + stats.last_match, + stats.active_days, + ); + } + + private async calculateStreaks( + seasonId: number, + playerId: string, + ): Promise<{ bestWinStreak: number; worstLoseStreak: number }> { + const matches = await dataSource.query( + ` SELECT winner FROM matches WHERE season = $1 @@ -147,32 +146,32 @@ export class WrappedPostgresRepository implements WrappedRepository { AND deleted_at IS NULL ORDER BY date ASC `, - [seasonId, playerId], - ); - - let bestWinStreak = 0; - let currentWinStreak = 0; - let worstLoseStreak = 0; - let currentLoseStreak = 0; - - for (const match of matches) { - if (match.winner) { - currentWinStreak++; - currentLoseStreak = 0; - bestWinStreak = Math.max(bestWinStreak, currentWinStreak); - } else { - currentLoseStreak++; - currentWinStreak = 0; - worstLoseStreak = Math.max(worstLoseStreak, currentLoseStreak); - } - } - - return { bestWinStreak, worstLoseStreak }; - } - - private async getBanListStats(seasonId: number, playerId: string): Promise { - const results = await dataSource.query( - ` + [seasonId, playerId], + ); + + let bestWinStreak = 0; + let currentWinStreak = 0; + let worstLoseStreak = 0; + let currentLoseStreak = 0; + + for (const match of matches) { + if (match.winner) { + currentWinStreak++; + currentLoseStreak = 0; + bestWinStreak = Math.max(bestWinStreak, currentWinStreak); + } else { + currentLoseStreak++; + currentWinStreak = 0; + worstLoseStreak = Math.max(worstLoseStreak, currentLoseStreak); + } + } + + return { bestWinStreak, worstLoseStreak }; + } + + private async getBanListStats(seasonId: number, playerId: string): Promise { + const results = await dataSource.query( + ` SELECT ban_list_name, COUNT(*)::int AS matches, @@ -192,27 +191,27 @@ export class WrappedPostgresRepository implements WrappedRepository { GROUP BY ban_list_name ORDER BY matches DESC `, - [seasonId, playerId], - ); - - return results.map( - // biome-ignore lint/suspicious/noExplicitAny: raw SQL query rows are untyped - (row: any) => - new BanListStats( - row.ban_list_name, - row.matches, - row.wins, - row.losses, - row.draws, - Math.round(row.winrate * 10) / 10, - null, // topMatchup not implemented yet - ), - ); - } - - private async getNemesis(seasonId: number, playerId: string): Promise { - const results = await dataSource.query( - ` + [seasonId, playerId], + ); + + return results.map( + // biome-ignore lint/suspicious/noExplicitAny: raw SQL query rows are untyped + (row: any) => + new BanListStats( + row.ban_list_name, + row.matches, + row.wins, + row.losses, + row.draws, + Math.round(row.winrate * 10) / 10, + null, // topMatchup not implemented yet + ), + ); + } + + private async getNemesis(seasonId: number, playerId: string): Promise { + const results = await dataSource.query( + ` WITH opponent_stats AS ( SELECT UNNEST(string_to_array(opponent_ids, ',')) AS opponent_id, @@ -244,28 +243,28 @@ export class WrappedPostgresRepository implements WrappedRepository { ORDER BY os.losses DESC, os.total_matches DESC LIMIT 1 `, - [seasonId, playerId], - ); - - if (results.length === 0) { - return null; - } - - const nemesis = results[0]; - return new Nemesis( - nemesis.opponent_id, - nemesis.opponent_name, - nemesis.opponent_avatar, - nemesis.total_matches, - nemesis.wins, - nemesis.losses, - Math.round(nemesis.winrate * 10) / 10, - ); - } - - private async getVictim(seasonId: number, playerId: string): Promise { - const results = await dataSource.query( - ` + [seasonId, playerId], + ); + + if (results.length === 0) { + return null; + } + + const nemesis = results[0]; + return new Nemesis( + nemesis.opponent_id, + nemesis.opponent_name, + nemesis.opponent_avatar, + nemesis.total_matches, + nemesis.wins, + nemesis.losses, + Math.round(nemesis.winrate * 10) / 10, + ); + } + + private async getVictim(seasonId: number, playerId: string): Promise { + const results = await dataSource.query( + ` WITH opponent_stats AS ( SELECT UNNEST(string_to_array(opponent_ids, ',')) AS opponent_id, @@ -297,28 +296,28 @@ export class WrappedPostgresRepository implements WrappedRepository { ORDER BY os.wins DESC, os.total_matches DESC LIMIT 1 `, - [seasonId, playerId], - ); - - if (results.length === 0) { - return null; - } - - const victim = results[0]; - return new Nemesis( - victim.opponent_id, - victim.opponent_name, - victim.opponent_avatar, - victim.total_matches, - victim.wins, - victim.losses, - Math.round(victim.winrate * 10) / 10, - ); - } - - private async getAchievements(seasonId: number, playerId: string): Promise { - const results = await dataSource.query( - ` + [seasonId, playerId], + ); + + if (results.length === 0) { + return null; + } + + const victim = results[0]; + return new Nemesis( + victim.opponent_id, + victim.opponent_name, + victim.opponent_avatar, + victim.total_matches, + victim.wins, + victim.losses, + Math.round(victim.winrate * 10) / 10, + ); + } + + private async getAchievements(seasonId: number, playerId: string): Promise { + const results = await dataSource.query( + ` SELECT a.id, a.name, @@ -331,37 +330,37 @@ export class WrappedPostgresRepository implements WrappedRepository { AND ua.season = $2 ORDER BY ua.unlocked_at DESC `, - [playerId, seasonId], - ); - - // biome-ignore lint/suspicious/noExplicitAny: raw SQL query rows are untyped - return results.map((row: any) => ({ - id: row.id, - name: row.name, - description: row.description, - icon: row.icon, - unlockedAt: row.unlocked_at, - })); - } - - private async getRanking(seasonId: number, playerId: string): Promise { - // Get total points for this player - const playerPoints = await dataSource.query( - ` + [playerId, seasonId], + ); + + // biome-ignore lint/suspicious/noExplicitAny: raw SQL query rows are untyped + return results.map((row: any) => ({ + id: row.id, + name: row.name, + description: row.description, + icon: row.icon, + unlockedAt: row.unlocked_at, + })); + } + + private async getRanking(seasonId: number, playerId: string): Promise { + // Get total points for this player + const playerPoints = await dataSource.query( + ` SELECT points as total_points FROM player_stats WHERE user_id = $1 AND season = $2 AND ban_list_name = 'Global' `, - [playerId, seasonId], - ); + [playerId, seasonId], + ); - const points = playerPoints[0]?.total_points ?? 0; + const points = playerPoints[0]?.total_points ?? 0; - // Get ranking position - const ranking = await dataSource.query( - ` + // Get ranking position + const ranking = await dataSource.query( + ` WITH player_totals AS ( SELECT user_id, @@ -383,41 +382,40 @@ export class WrappedPostgresRepository implements WrappedRepository { FROM ranked WHERE user_id = $2 `, - [seasonId, playerId], - ); - - if (ranking.length === 0) { - return { - position: 0, - totalPlayers: 0, - points, - rankBadge: "Challenger", - }; - } - - const position = ranking[0].position; - const totalPlayers = ranking[0].total_players; - - return { - position, - totalPlayers, - points, - rankBadge: calculateRankBadge(position), - }; - } - - private async getExtraStats( - seasonId: number, - playerId: string, - banListStats: BanListStats[], - ): Promise { - // Most played ban list - const mostPlayedBanList = - banListStats.length > 0 ? banListStats[0].banListName : null; - - // Unique opponents - const uniqueOpponents = await dataSource.query( - ` + [seasonId, playerId], + ); + + if (ranking.length === 0) { + return { + position: 0, + totalPlayers: 0, + points, + rankBadge: "Challenger", + }; + } + + const position = ranking[0].position; + const totalPlayers = ranking[0].total_players; + + return { + position, + totalPlayers, + points, + rankBadge: calculateRankBadge(position), + }; + } + + private async getExtraStats( + seasonId: number, + playerId: string, + banListStats: BanListStats[], + ): Promise { + // Most played ban list + const mostPlayedBanList = banListStats.length > 0 ? banListStats[0].banListName : null; + + // Unique opponents + const uniqueOpponents = await dataSource.query( + ` WITH unnested_opponents AS ( SELECT UNNEST(string_to_array(opponent_ids, ',')) AS opponent_id FROM matches @@ -430,12 +428,12 @@ export class WrappedPostgresRepository implements WrappedRepository { SELECT COUNT(DISTINCT opponent_id)::int AS unique_opponents FROM unnested_opponents `, - [seasonId, playerId], - ); + [seasonId, playerId], + ); - // Best day of the week - const bestDayResult = await dataSource.query( - ` + // Best day of the week + const bestDayResult = await dataSource.query( + ` SELECT TO_CHAR(date, 'Day') AS day_name, COUNT(*) FILTER (WHERE winner = true)::int AS wins, @@ -454,15 +452,15 @@ export class WrappedPostgresRepository implements WrappedRepository { ORDER BY winrate DESC, total DESC LIMIT 1 `, - [seasonId, playerId], - ); + [seasonId, playerId], + ); - const bestDay = bestDayResult.length > 0 ? bestDayResult[0].day_name.trim() : null; + const bestDay = bestDayResult.length > 0 ? bestDayResult[0].day_name.trim() : null; - return { - mostPlayedBanList, - uniqueOpponents: uniqueOpponents[0]?.unique_opponents ?? 0, - bestDay, - }; - } + return { + mostPlayedBanList, + uniqueOpponents: uniqueOpponents[0]?.unique_opponents ?? 0, + bestDay, + }; + } } diff --git a/src/modules/wrapped/infrastructure/templates/styles.css b/src/modules/wrapped/infrastructure/templates/styles.css index a7ce4a4..22b5869 100644 --- a/src/modules/wrapped/infrastructure/templates/styles.css +++ b/src/modules/wrapped/infrastructure/templates/styles.css @@ -10,7 +10,7 @@ --space-3xl: calc(var(--space-unit) * 10); /* Typography */ - --font-primary: 'Inter', system-ui, -apple-system, sans-serif; + --font-primary: "Inter", system-ui, -apple-system, sans-serif; /* Font Sizes */ --text-xs: 12px; @@ -140,7 +140,7 @@ body { } /* Ensure content is above backgrounds */ -.page>*:not(.page-bg-decoration) { +.page > *:not(.page-bg-decoration) { position: relative; z-index: 1; } @@ -199,7 +199,7 @@ body { } .page-title::after { - content: ''; + content: ""; display: block; width: 60px; height: 4px; @@ -215,7 +215,9 @@ body { padding: var(--space-xl); position: relative; overflow: hidden; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); border: 1px solid var(--border-subtle); } @@ -464,14 +466,14 @@ body { font-weight: 900; line-height: 1.1; margin: 0 0 var(--space-md) 0; - color: #FFFFFF !important; + color: #ffffff !important; /* Force white, no gradients */ text-transform: uppercase; letter-spacing: -0.02em; text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); /* Add shadow for depth instead of gradient */ background: transparent !important; - -webkit-text-fill-color: #FFFFFF !important; + -webkit-text-fill-color: #ffffff !important; padding: 10px; } @@ -698,11 +700,13 @@ body { gap: 24px; position: relative; overflow: hidden; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); } .achievement-card::before { - content: ''; + content: ""; position: absolute; top: 0; left: 0; @@ -856,7 +860,7 @@ body { padding: 16px !important; } - .rival-card>div:last-child { + .rival-card > div:last-child { flex-direction: row !important; width: 100%; justify-content: flex-start; @@ -898,13 +902,15 @@ body { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); cursor: pointer; z-index: 1000; - transition: transform 0.2s, background 0.2s; + transition: + transform 0.2s, + background 0.2s; text-decoration: none; } .download-fab:hover { transform: scale(1.1); - background: #2563EB; + background: #2563eb; } .download-fab svg { @@ -916,4 +922,4 @@ body { .download-fab { display: none !important; } -} \ No newline at end of file +} diff --git a/src/modules/wrapped/infrastructure/templates/templateRenderer.ts b/src/modules/wrapped/infrastructure/templates/templateRenderer.ts index 2271086..9407343 100644 --- a/src/modules/wrapped/infrastructure/templates/templateRenderer.ts +++ b/src/modules/wrapped/infrastructure/templates/templateRenderer.ts @@ -9,43 +9,48 @@ const __dirname = dirname(__filename); // Helper function to load optimized images as base64 function getImageAsBase64(filename: string): string { - try { - const imagePath = join(__dirname, 'optimized', filename); - if (!existsSync(imagePath)) return ''; - const imageBuffer = readFileSync(imagePath); - const base64 = imageBuffer.toString('base64'); - return `data:image/png;base64,${base64}`; - } catch (error) { - console.error(`Failed to load image ${filename}:`, error); - return ''; - } + try { + const imagePath = join(__dirname, "optimized", filename); + if (!existsSync(imagePath)) return ""; + const imageBuffer = readFileSync(imagePath); + const base64 = imageBuffer.toString("base64"); + return `data:image/png;base64,${base64}`; + } catch (error) { + console.error(`Failed to load image ${filename}:`, error); + return ""; + } } // Pre-load optimized Yu-Gi-Oh! themed images const images = { - decorative1: getImageAsBase64('yugioh_dragon_background.png'), // Dragon artwork - decorative2: getImageAsBase64('yugioh_monster_background.png'), // Monster artwork - decorative3: getImageAsBase64('yugioh_battlefield_background.png'), // Battlefield scene - decorative4: getImageAsBase64('yugioh_cards_background.png'), // Cards artwork - icon: getImageAsBase64('yugioh_chapter_icon.png'), // Small chapter icon + decorative1: getImageAsBase64("yugioh_dragon_background.png"), // Dragon artwork + decorative2: getImageAsBase64("yugioh_monster_background.png"), // Monster artwork + decorative3: getImageAsBase64("yugioh_battlefield_background.png"), // Battlefield scene + decorative4: getImageAsBase64("yugioh_cards_background.png"), // Cards artwork + icon: getImageAsBase64("yugioh_chapter_icon.png"), // Small chapter icon }; -export function renderTemplate(data: SeasonWrapped, options: GenerateOptions, themeStrategy: IThemeStrategy): string { - const styles = readFileSync(join(__dirname, "styles.css"), "utf-8"); - const themeCss = getSeasonTheme(data.seasonId); - const themeStylesheet = themeStrategy.getStylesheet(); - const phrases = themeStrategy.getPhrases(data); - - // Select theme background or random monster - let randomMonster = themeStrategy.getBackground(); - if (!randomMonster) { - const monsterImages = [images.decorative1, images.decorative2].filter(Boolean); - randomMonster = monsterImages[Math.floor(Math.random() * monsterImages.length)] || images.decorative1; - } - - const specialSections = themeStrategy.renderSpecialSections(data, options, randomMonster); - - return ` +export function renderTemplate( + data: SeasonWrapped, + options: GenerateOptions, + themeStrategy: IThemeStrategy, +): string { + const styles = readFileSync(join(__dirname, "styles.css"), "utf-8"); + const themeCss = getSeasonTheme(data.seasonId); + const themeStylesheet = themeStrategy.getStylesheet(); + const phrases = themeStrategy.getPhrases(data); + + // Select theme background or random monster + let randomMonster = themeStrategy.getBackground(); + if (!randomMonster) { + const monsterImages = [images.decorative1, images.decorative2].filter(Boolean); + randomMonster = + monsterImages[Math.floor(Math.random() * monsterImages.length)] || images.decorative1; + } + + const specialSections = themeStrategy.renderSpecialSections(data, options, randomMonster); + + return ` @@ -92,7 +97,7 @@ export function renderTemplate(data: SeasonWrapped, options: GenerateOptions, th ${renderGlobalStatsPage(data, options, randomMonster, phrases)} ${renderBanListPages(data, options, randomMonster, phrases)} ${renderChartsPage(data, options, randomMonster, phrases)} - ${(data.nemesis || data.victim) ? renderRivalsPage(data, options, randomMonster, phrases) : ""} + ${data.nemesis || data.victim ? renderRivalsPage(data, options, randomMonster, phrases) : ""} ${specialSections} ${renderRankingPage(data, options, randomMonster, phrases)} ${renderSummaryPage(data, options, randomMonster, phrases)} @@ -102,21 +107,26 @@ export function renderTemplate(data: SeasonWrapped, options: GenerateOptions, th } // Single-page compact version for evaluation -export function renderSinglePageTemplate(data: SeasonWrapped, options: GenerateOptions, themeStrategy: IThemeStrategy): string { - const styles = readFileSync(join(__dirname, "styles.css"), "utf-8"); - const singlePageStyles = readFileSync(join(__dirname, "styles_single_page.css"), "utf-8"); - const themeCss = getSeasonTheme(data.seasonId); - const themeStylesheet = themeStrategy.getStylesheet(); - const phrases = themeStrategy.getPhrases(data); - - // Select theme background or random monster - let randomMonster = themeStrategy.getBackground(); - if (!randomMonster) { - const monsterImages = [images.decorative1, images.decorative2].filter(Boolean); - randomMonster = monsterImages[Math.floor(Math.random() * monsterImages.length)] || images.decorative1; - } - - return ` +export function renderSinglePageTemplate( + data: SeasonWrapped, + options: GenerateOptions, + themeStrategy: IThemeStrategy, +): string { + const styles = readFileSync(join(__dirname, "styles.css"), "utf-8"); + const singlePageStyles = readFileSync(join(__dirname, "styles_single_page.css"), "utf-8"); + const themeCss = getSeasonTheme(data.seasonId); + const themeStylesheet = themeStrategy.getStylesheet(); + const phrases = themeStrategy.getPhrases(data); + + // Select theme background or random monster + let randomMonster = themeStrategy.getBackground(); + if (!randomMonster) { + const monsterImages = [images.decorative1, images.decorative2].filter(Boolean); + randomMonster = + monsterImages[Math.floor(Math.random() * monsterImages.length)] || images.decorative1; + } + + return ` @@ -135,7 +145,7 @@ export function renderSinglePageTemplate(data: SeasonWrapped, options: GenerateO ${renderGlobalStatsPage(data, options, randomMonster, phrases)} ${renderBanListPages(data, options, randomMonster, phrases)} ${renderChartsPage(data, options, randomMonster, phrases)} - ${(data.nemesis || data.victim) ? renderRivalsPage(data, options, randomMonster, phrases) : ""} + ${data.nemesis || data.victim ? renderRivalsPage(data, options, randomMonster, phrases) : ""} ${data.achievements.length > 0 ? renderAchievementsPage(data, options, randomMonster, phrases) : ""} ${renderRankingPage(data, options, randomMonster, phrases)} @@ -144,7 +154,7 @@ export function renderSinglePageTemplate(data: SeasonWrapped, options: GenerateO } function renderHeader(title: string, seasonName: string, phrases: ThemePhrases): string { - return ` + return `
@@ -155,12 +165,15 @@ function renderHeader(title: string, seasonName: string, phrases: ThemePhrases): `; } -function renderCoverPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string, phrases: ThemePhrases): string { - - - return ` +function renderCoverPage( + data: SeasonWrapped, + options: GenerateOptions, + randomMonster: string, + phrases: ThemePhrases, +): string { + return `
- ${randomMonster ? `
` : ''} + ${randomMonster ? `
` : ""}
@@ -184,16 +197,21 @@ function renderCoverPage(data: SeasonWrapped, options: GenerateOptions, randomMo `; } -function renderGlobalStatsPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string, phrases: ThemePhrases): string { - const stats = data.globalStats; +function renderGlobalStatsPage( + data: SeasonWrapped, + options: GenerateOptions, + randomMonster: string, + phrases: ThemePhrases, +): string { + const stats = data.globalStats; - return ` + return `
- ${randomMonster ? `
` : ''} + ${randomMonster ? `
` : ""} ${renderHeader("Season Overview", data.seasonName, phrases)}
- ${images.icon ? `` : ''} + ${images.icon ? `` : ""} ${phrases.chapter1 || (options.locale === "es" ? "CAPÍTULO 1" : "CHAPTER 1")}

${phrases.statsTitle}

@@ -246,19 +264,24 @@ function renderGlobalStatsPage(data: SeasonWrapped, options: GenerateOptions, ra `; } -function renderBanListPages(data: SeasonWrapped, options: GenerateOptions, randomMonster: string, phrases: ThemePhrases): string { - if (data.banListStats.length === 0) return ""; +function renderBanListPages( + data: SeasonWrapped, + options: GenerateOptions, + randomMonster: string, + phrases: ThemePhrases, +): string { + if (data.banListStats.length === 0) return ""; - // Take top 3 banlists to fit on one page if possible, or paginate - const topBanlist = data.banListStats[0]; + // Take top 3 banlists to fit on one page if possible, or paginate + const topBanlist = data.banListStats[0]; - return ` + return `
- ${randomMonster ? `
` : ''} + ${randomMonster ? `
` : ""} ${renderHeader("Formats", data.seasonName, phrases)}
- ${images.icon ? `` : ''} + ${images.icon ? `` : ""} ${phrases.chapter2 || (options.locale === "es" ? "CAPÍTULO 2" : "CHAPTER 2")}

${phrases.statsTitle}

@@ -281,26 +304,36 @@ function renderBanListPages(data: SeasonWrapped, options: GenerateOptions, rando
- ${data.banListStats.slice(1, 3).map(bl => ` + ${data.banListStats + .slice(1, 3) + .map( + (bl) => `
${escapeHtml(bl.banListName)}
${bl.winrate}%
${bl.matches} matches
- `).join('')} + `, + ) + .join("")}
`; } -function renderRivalsPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string, phrases: ThemePhrases): string { - return ` +function renderRivalsPage( + data: SeasonWrapped, + options: GenerateOptions, + randomMonster: string, + phrases: ThemePhrases, +): string { + return `
- ${randomMonster ? `
` : ''} + ${randomMonster ? `
` : ""} ${renderHeader("Rivals", data.seasonName, phrases)}
- ${images.icon ? `` : ''} + ${images.icon ? `` : ""} ${options.locale === "es" ? "CAPÍTULO 3" : "CHAPTER 3"}

${phrases.rivalsTitle}

@@ -310,7 +343,9 @@ function renderRivalsPage(data: SeasonWrapped, options: GenerateOptions, randomM
- ${data.nemesis ? ` + ${ + data.nemesis + ? `
@@ -331,9 +366,13 @@ function renderRivalsPage(data: SeasonWrapped, options: GenerateOptions, randomM ${data.nemesis.losses} ${options.locale === "es" ? "derrotas" : "losses"}
- ` : ""} + ` + : "" + } - ${data.victim ? ` + ${ + data.victim + ? `
@@ -354,17 +393,22 @@ function renderRivalsPage(data: SeasonWrapped, options: GenerateOptions, randomM ${data.victim.wins} ${options.locale === "es" ? "victorias" : "wins"}
- ` : ""} + ` + : "" + } ${(() => { - // Determine arch-rival (most frequent opponent) - const archRival = data.nemesis && data.victim - ? (data.nemesis.totalMatches >= data.victim.totalMatches ? data.nemesis : data.victim) - : data.nemesis || data.victim; + // Determine arch-rival (most frequent opponent) + const archRival = + data.nemesis && data.victim + ? data.nemesis.totalMatches >= data.victim.totalMatches + ? data.nemesis + : data.victim + : data.nemesis || data.victim; - if (!archRival) return ""; + if (!archRival) return ""; - return ` + return `
@@ -385,21 +429,26 @@ function renderRivalsPage(data: SeasonWrapped, options: GenerateOptions, randomM
`; - })()} + })()}
`; } -function renderChartsPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string, phrases: ThemePhrases): string { - if (data.banListStats.length === 0) return ""; +function renderChartsPage( + data: SeasonWrapped, + options: GenerateOptions, + randomMonster: string, + phrases: ThemePhrases, +): string { + if (data.banListStats.length === 0) return ""; - // Calculate max matches for scaling - const maxMatches = Math.max(...data.banListStats.map(bl => bl.matches)); + // Calculate max matches for scaling + const maxMatches = Math.max(...data.banListStats.map((bl) => bl.matches)); - return ` + return `
- ${randomMonster ? `
` : ''} + ${randomMonster ? `
` : ""} ${renderHeader("Evolution", data.seasonName, phrases)}
${options.locale === "es" ? "CAPÍTULO 4" : "CHAPTER 4"}
@@ -408,7 +457,9 @@ function renderChartsPage(data: SeasonWrapped, options: GenerateOptions, randomM

Matches Distribution

- ${data.banListStats.map(bl => ` + ${data.banListStats + .map( + (bl) => `
${escapeHtml(bl.banListName)} @@ -418,30 +469,41 @@ function renderChartsPage(data: SeasonWrapped, options: GenerateOptions, randomM
- `).join('')} + `, + ) + .join("")}
`; } -function renderAchievementsPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string, phrases: ThemePhrases): string { - if (data.achievements.length === 0) return ""; +function renderAchievementsPage( + data: SeasonWrapped, + options: GenerateOptions, + randomMonster: string, + phrases: ThemePhrases, +): string { + if (data.achievements.length === 0) return ""; - return ` + return `
- ${randomMonster ? `
` : ''} + ${randomMonster ? `
` : ""} ${renderHeader("Logros", data.seasonName, phrases)}
${options.locale === "es" ? "CAPÍTULO 5" : "CHAPTER 5"}

${phrases.achievementsTitle}

- ${data.achievements.map(ach => ` + ${data.achievements + .map( + (ach) => `
- ${ach.icon && ach.icon.startsWith('http') - ? `` - : '🏆'} + ${ + ach.icon && ach.icon.startsWith("http") + ? `` + : "🏆" + }
@@ -452,22 +514,33 @@ function renderAchievementsPage(data: SeasonWrapped, options: GenerateOptions, r

${escapeHtml(ach.description)}

- ${ach.unlockedAt ? ` + ${ + ach.unlockedAt + ? `
- ${new Date(ach.unlockedAt).toLocaleDateString(options.locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + ${new Date(ach.unlockedAt).toLocaleDateString(options.locale === "es" ? "es-ES" : "en-US", { month: "short", day: "numeric", year: "numeric" })}
- ` : ''} + ` + : "" + }
- `).join('')} + `, + ) + .join("")}
`; } -function renderRankingPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string, phrases: ThemePhrases): string { - return ` +function renderRankingPage( + data: SeasonWrapped, + options: GenerateOptions, + randomMonster: string, + phrases: ThemePhrases, +): string { + return `
- ${randomMonster ? `
` : ''} + ${randomMonster ? `
` : ""} ${renderHeader("Ranking", data.seasonName, phrases)}
${options.locale === "es" ? "FINAL" : "FINALE"}
@@ -483,7 +556,7 @@ function renderRankingPage(data: SeasonWrapped, options: GenerateOptions, random

- Top ${(data.ranking.position / data.ranking.totalPlayers * 100).toFixed(0)}% of ${data.ranking.totalPlayers} players + Top ${((data.ranking.position / data.ranking.totalPlayers) * 100).toFixed(0)}% of ${data.ranking.totalPlayers} players

@@ -492,28 +565,41 @@ function renderRankingPage(data: SeasonWrapped, options: GenerateOptions, random
- ${data.extraStats.uniqueOpponents > 0 ? ` + ${ + data.extraStats.uniqueOpponents > 0 + ? `
Unique Opponents
${data.extraStats.uniqueOpponents}
- ${data.extraStats.bestDay ? ` + ${ + data.extraStats.bestDay + ? `
Lucky Day
${escapeHtml(data.extraStats.bestDay)}
- ` : ""} + ` + : "" + }
- ` : ""} + ` + : "" + }
`; } -function renderSummaryPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string, phrases: ThemePhrases): string { - return ` +function renderSummaryPage( + data: SeasonWrapped, + options: GenerateOptions, + randomMonster: string, + phrases: ThemePhrases, +): string { + return `
- ${randomMonster ? `
` : ''} + ${randomMonster ? `
` : ""}
@@ -539,28 +625,44 @@ function renderSummaryPage(data: SeasonWrapped, options: GenerateOptions, random
- ${data.globalStats.bestWinStreak > 0 ? ` + ${ + data.globalStats.bestWinStreak > 0 + ? `
🔥 ${data.globalStats.bestWinStreak} ${options.locale === "es" ? "racha de victorias" : "win streak"}
- ` : ''} + ` + : "" + } - ${data.ranking ? ` + ${ + data.ranking + ? `
🏆 #${data.ranking.position} ${options.locale === "es" ? "en el ranking" : "in rankings"} · ${data.ranking.rankBadge}
- ` : ''} + ` + : "" + } - ${data.extraStats?.mostPlayedBanList ? ` + ${ + data.extraStats?.mostPlayedBanList + ? `
🎮 ${options.locale === "es" ? "Formato favorito:" : "Favorite format:"} ${data.extraStats.mostPlayedBanList}
- ` : ''} + ` + : "" + }
- ${(data.nemesis || data.victim) ? ` + ${ + data.nemesis || data.victim + ? `
- ${data.nemesis ? ` + ${ + data.nemesis + ? `
👻
@@ -569,9 +671,13 @@ function renderSummaryPage(data: SeasonWrapped, options: GenerateOptions, random
${data.nemesis.wins}W / ${data.nemesis.losses}L
- ` : ''} + ` + : "" + } - ${data.victim ? ` + ${ + data.victim + ? `
🎯
@@ -580,15 +686,19 @@ function renderSummaryPage(data: SeasonWrapped, options: GenerateOptions, random
${data.victim.wins}W / ${data.victim.losses}L
- ` : ''} + ` + : "" + }
- ` : ''} + ` + : "" + }
@@ -596,57 +706,57 @@ function renderSummaryPage(data: SeasonWrapped, options: GenerateOptions, random } function getInitialsAvatar(name: string): string { - return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%231e293b'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='white' font-size='40' font-family='sans-serif' font-weight='bold'%3E${name.charAt(0).toUpperCase()}%3C/text%3E%3C/svg%3E`; + return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%231e293b'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='white' font-size='40' font-family='sans-serif' font-weight='bold'%3E${name.charAt(0).toUpperCase()}%3C/text%3E%3C/svg%3E`; } function escapeHtml(text: string): string { - const map: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }; - return text.replace(/[&<>"']/g, (char) => map[char] || char); + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (char) => map[char] || char); } function getBanListFlavor(winrate: number): string { - if (winrate >= 70) return "En esta banlist estabas on fire 🔥"; - if (winrate <= 40) return "En esta banlist sufriste un poco 😅"; - return "En esta banlist te mantuviste competitivo 💪"; + if (winrate >= 70) return "En esta banlist estabas on fire 🔥"; + if (winrate <= 40) return "En esta banlist sufriste un poco 😅"; + return "En esta banlist te mantuviste competitivo 💪"; } function getSeasonTheme(seasonId: number): string { - const themes: Record = { - // Season 3: Wind/Nature - Refined Teal/Forest - 3: { - accent: '#2DD4BF', - bgBase: '#041010', - bgCard: '#0A1F1F' - }, - // Season 4: Fire/Invasion - Refined Muted Coral/Maroon - 4: { - accent: '#F87171', - bgBase: '#110707', - bgCard: '#1F0D0D' - }, - // Season 5: Water/Abyss - Refined Midnight/Cyan - 5: { - accent: '#38BDF8', - bgBase: '#050C14', - bgCard: '#0D1B2A' - }, - // Season 6: Current/Tech - Refined Indigo/Slate - 6: { - accent: '#818CF8', - bgBase: '#0A0F1E', - bgCard: '#161B33' - } - }; - - const theme = themes[seasonId] || themes[6]; // Default to Season 6 style - - return ` + const themes: Record = { + // Season 3: Wind/Nature - Refined Teal/Forest + 3: { + accent: "#2DD4BF", + bgBase: "#041010", + bgCard: "#0A1F1F", + }, + // Season 4: Fire/Invasion - Refined Muted Coral/Maroon + 4: { + accent: "#F87171", + bgBase: "#110707", + bgCard: "#1F0D0D", + }, + // Season 5: Water/Abyss - Refined Midnight/Cyan + 5: { + accent: "#38BDF8", + bgBase: "#050C14", + bgCard: "#0D1B2A", + }, + // Season 6: Current/Tech - Refined Indigo/Slate + 6: { + accent: "#818CF8", + bgBase: "#0A0F1E", + bgCard: "#161B33", + }, + }; + + const theme = themes[seasonId] || themes[6]; // Default to Season 6 style + + return ` :root { --color-accent: ${theme.accent}; --color-accent-glow: ${theme.accent}80; diff --git a/src/modules/wrapped/infrastructure/themes/AbstractThemeStrategy.ts b/src/modules/wrapped/infrastructure/themes/AbstractThemeStrategy.ts index a9b0d55..db060fc 100644 --- a/src/modules/wrapped/infrastructure/themes/AbstractThemeStrategy.ts +++ b/src/modules/wrapped/infrastructure/themes/AbstractThemeStrategy.ts @@ -8,43 +8,47 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export abstract class AbstractThemeStrategy implements IThemeStrategy { - abstract getName(): string; + abstract getName(): string; - getStylesheet(): string { - return ""; // Override in concrete classes if needed - } + getStylesheet(): string { + return ""; // Override in concrete classes if needed + } - getBackground(): string { - return ""; - } + getBackground(): string { + return ""; + } - getPhrases(data: SeasonWrapped): ThemePhrases { - return { - coverTitle: "EVOLUTION WRAPPED", - coverSubtitle: `Temporada ${data.seasonId}`, - statsTitle: "RESUMEN DE TEMPORADA", - statsSubtitle: "Tus números en el campo de batalla", - rivalsTitle: "ARCHI-RIVAL", - rivalsSubtitle: "El duelo nunca termina", - achievementsTitle: "LOGROS OBTENIDOS", - achievementsSubtitle: "Tu legado en Evolution", - summaryTitle: "RESUMEN FINAL", - summarySubtitle: "¡Nos vemos en el próximo duelo!" - }; - } + getPhrases(data: SeasonWrapped): ThemePhrases { + return { + coverTitle: "EVOLUTION WRAPPED", + coverSubtitle: `Temporada ${data.seasonId}`, + statsTitle: "RESUMEN DE TEMPORADA", + statsSubtitle: "Tus números en el campo de batalla", + rivalsTitle: "ARCHI-RIVAL", + rivalsSubtitle: "El duelo nunca termina", + achievementsTitle: "LOGROS OBTENIDOS", + achievementsSubtitle: "Tu legado en Evolution", + summaryTitle: "RESUMEN FINAL", + summarySubtitle: "¡Nos vemos en el próximo duelo!", + }; + } - renderSpecialSections(_data: SeasonWrapped, _options: GenerateOptions, _background: string): string { - return ""; - } + renderSpecialSections( + _data: SeasonWrapped, + _options: GenerateOptions, + _background: string, + ): string { + return ""; + } - protected getImageAsBase64(filename: string): string { - const imagesPath = join(__dirname, "..", "templates", "optimized"); - const filePath = join(imagesPath, filename); - if (existsSync(filePath)) { - const buffer = readFileSync(filePath); - const extension = filename.split('.').pop(); - return `data:image/${extension};base64,${buffer.toString('base64')}`; - } - return ""; - } + protected getImageAsBase64(filename: string): string { + const imagesPath = join(__dirname, "..", "templates", "optimized"); + const filePath = join(imagesPath, filename); + if (existsSync(filePath)) { + const buffer = readFileSync(filePath); + const extension = filename.split(".").pop(); + return `data:image/${extension};base64,${buffer.toString("base64")}`; + } + return ""; + } } diff --git a/src/modules/wrapped/infrastructure/themes/DarkThemeStrategy.ts b/src/modules/wrapped/infrastructure/themes/DarkThemeStrategy.ts index de811a0..4e1cc09 100644 --- a/src/modules/wrapped/infrastructure/themes/DarkThemeStrategy.ts +++ b/src/modules/wrapped/infrastructure/themes/DarkThemeStrategy.ts @@ -1,12 +1,12 @@ import { AbstractThemeStrategy } from "./AbstractThemeStrategy"; export class DarkThemeStrategy extends AbstractThemeStrategy { - getName(): string { - return "dark"; - } + getName(): string { + return "dark"; + } - getStylesheet(): string { - return ` + getStylesheet(): string { + return ` :root { --color-bg: #0f172a; --color-surface: #1e293b; @@ -16,5 +16,5 @@ export class DarkThemeStrategy extends AbstractThemeStrategy { --color-border: #334155; } `; - } + } } diff --git a/src/modules/wrapped/infrastructure/themes/LightThemeStrategy.ts b/src/modules/wrapped/infrastructure/themes/LightThemeStrategy.ts index 7c99c8c..be48f01 100644 --- a/src/modules/wrapped/infrastructure/themes/LightThemeStrategy.ts +++ b/src/modules/wrapped/infrastructure/themes/LightThemeStrategy.ts @@ -1,12 +1,12 @@ import { AbstractThemeStrategy } from "./AbstractThemeStrategy"; export class LightThemeStrategy extends AbstractThemeStrategy { - getName(): string { - return "light"; - } + getName(): string { + return "light"; + } - getStylesheet(): string { - return ` + getStylesheet(): string { + return ` :root { --color-bg: #f8fafc; --color-surface: #ffffff; @@ -16,5 +16,5 @@ export class LightThemeStrategy extends AbstractThemeStrategy { --color-border: #e2e8f0; } `; - } + } } diff --git a/src/modules/wrapped/infrastructure/themes/ValentineThemeStrategy.ts b/src/modules/wrapped/infrastructure/themes/ValentineThemeStrategy.ts index 3ac2138..596e5fb 100644 --- a/src/modules/wrapped/infrastructure/themes/ValentineThemeStrategy.ts +++ b/src/modules/wrapped/infrastructure/themes/ValentineThemeStrategy.ts @@ -3,12 +3,12 @@ import type { SeasonWrapped } from "../../domain/SeasonWrapped"; import type { ThemePhrases } from "../../domain/IThemeStrategy"; export class ValentineThemeStrategy extends AbstractThemeStrategy { - getName(): string { - return "valentines"; - } + getName(): string { + return "valentines"; + } - getStylesheet(): string { - return ` + getStylesheet(): string { + return ` :root { --bg-base: #0f0506; /* Very dark deep red */ --bg-card: #1a0a0b; /* Slightly lighter dark red surface */ @@ -73,109 +73,109 @@ export class ValentineThemeStrategy extends AbstractThemeStrategy { background: rgba(0, 0, 0, 0.3) !important; } `; - } - - getBackground(): string { - return this.getImageAsBase64("black_rose_dragon.png"); - } - - getPhrases(data: SeasonWrapped): ThemePhrases { - const base = super.getPhrases(data); - - const lovePhrases = [ - "¡Si aún nadie te lo ha dicho, feliz día del amor y la amistad!", - "Más que un duelista, eres un rompecorazones.", - "Tu Deck y tú: Una historia de amor mejor que Crepúsculo.", - "Activaste mi carta trampa: ¡Amor Incondicional!", - "¿Tu corazón tiene 8000 LP? Porque el mío bajó a 0 al verte jugar.", - "Ni el Dragón Blanco de Ojos Azules brilla tanto como tu sonrisa (o tu winrate).", - "Eres el 'Polimerización' de mi vida: nos haces uno solo.", - "Si fueras una carta, serías Prohibida... por exceso de facha.", - "Mi Deck late por ti más fuerte que un combo de 20 minutos.", - "No necesito el Corazón de las Cartas si tengo el tuyo." - ]; - - const statsPhrases = [ - "Tus números enamoran (aunque a los Ban Lists no tanto).", - "Duelista por fuera, poeta por dentro.", - "Repartiendo amor y combos por igual.", - "Tus Life Points bajan, pero mi cariño por tus jugadas sube.", - "¿Quién necesita Tinder si tienes este Win Rate?", - "Tus victorias son la flecha de Cupido en mi ranking.", - "Analizando tu pasión: 50% Skill, 50% Suerte, 100% Amor.", - "Incluso Exodia envidia lo completo que eres.", - "Trazando el camino del amor... un duelo a la vez.", - "Tus estadísticas dicen: ¡CÁSATE CONMIGO! (o al menos jueguen otra)." - ]; - - const rivalPhrases = [ - "Love is a Battlefield... y aquí perdiste contra este.", - "Tu Archi-Rival o tu 'Enemies to Lovers' arc.", - "Relación complicada: Se dan con todo en el campo.", - "Tu media naranja... de destrucción masiva.", - "Tóxicos, pero apasionados. El duelo nunca termina.", - "¿Rivalidad o tensión sexual? El log no miente.", - "Ni el odio ni el amor son tan fuertes como este 2-0.", - "Tu destino está ligado a este duelista... por los siglos de los siglos.", - "El roce hace el cariño... y las negaciones hacen el drama.", - "Tu amor platónico (porque platónicamente lo quieres ver fuera del torneo)." - ]; - - const achievementPhrases = [ - "Coleccionando triunfos y suspiros.", - "Logros que llegan directo al corazón.", - "Tu legado es puro amor al arte del duelo.", - "Brillando más que una carta holográfica en San Valentín.", - "Desbloqueaste el logro más difícil: ¡Caerle bien a todos!", - "Tus trofeos son los pétalos de una rosa de victoria.", - "Logros obtenidos con sudor, lágrimas y mucho cariño.", - "Cada medalla es un 'te quiero' de la comunidad.", - "Tu vitrina está llena, pero siempre hay espacio para más amor.", - "Nivel de Duelista: Enamorado de la victoria." - ]; - - const summaryPhrases = [ - "¡Nos vemos en el próximo duelo, Cupido de las cartas!", - "Que tus robos sean siempre de corazón.", - "Duelo terminado, pero el amor por el juego sigue.", - "Sigue robando corazones (y victorias).", - "Game Over? No, ¡Love Start!", - "Gracias por compartir tu pasión con nosotros.", - "Tu viaje continúa, ¡llénalo de duelos y abrazos!", - "Recuerda: la mejor jugada es la que se hace con amigos.", - "Nos vemos en el próximo turno de la vida.", - "¡Hasta la próxima, leyenda del romance!" - ]; - - const coverTitles = [ - "EVOLUTION VALENTINE", - "CORAZÓN DE LAS CARTAS (Y DEL MÍO)", - "8000 LP DE PURA PASIÓN", - "LOVE IS A BATTLEFIELD (CON TRAPAS)", - "TÚ, YO Y UN DUELO NOCHE", - "¡TE ELIJO A TI! (ESPERA, JUEGO EQUIVOCADO)", - "ROBANDO EL CORAZÓN DE LAS CARTAS", - "CUPIDO DUELISTA: EDICIÓN LIMITADA", - "MI DECK DE AMOR: TOP TIER", - "ROMPECORAZONES EN TURNO 1", - "AMOR A PRIMERA JUGADA", - "DIME QUE ME AMAS (O QUE NO TIENES ASH)" - ]; - - const random = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)]; - - return { - ...base, - coverTitle: random(coverTitles), - coverSubtitle: random(lovePhrases), - statsTitle: "PASIÓN POR EL DUELO", - statsSubtitle: random(statsPhrases), - rivalsTitle: "LOVE & WAR", - rivalsSubtitle: random(rivalPhrases), - achievementsTitle: "TU LEGADO DE AMOR", - achievementsSubtitle: random(achievementPhrases), - summaryTitle: "RESUMEN CON AMOR", - summarySubtitle: random(summaryPhrases) - }; - } + } + + getBackground(): string { + return this.getImageAsBase64("black_rose_dragon.png"); + } + + getPhrases(data: SeasonWrapped): ThemePhrases { + const base = super.getPhrases(data); + + const lovePhrases = [ + "¡Si aún nadie te lo ha dicho, feliz día del amor y la amistad!", + "Más que un duelista, eres un rompecorazones.", + "Tu Deck y tú: Una historia de amor mejor que Crepúsculo.", + "Activaste mi carta trampa: ¡Amor Incondicional!", + "¿Tu corazón tiene 8000 LP? Porque el mío bajó a 0 al verte jugar.", + "Ni el Dragón Blanco de Ojos Azules brilla tanto como tu sonrisa (o tu winrate).", + "Eres el 'Polimerización' de mi vida: nos haces uno solo.", + "Si fueras una carta, serías Prohibida... por exceso de facha.", + "Mi Deck late por ti más fuerte que un combo de 20 minutos.", + "No necesito el Corazón de las Cartas si tengo el tuyo.", + ]; + + const statsPhrases = [ + "Tus números enamoran (aunque a los Ban Lists no tanto).", + "Duelista por fuera, poeta por dentro.", + "Repartiendo amor y combos por igual.", + "Tus Life Points bajan, pero mi cariño por tus jugadas sube.", + "¿Quién necesita Tinder si tienes este Win Rate?", + "Tus victorias son la flecha de Cupido en mi ranking.", + "Analizando tu pasión: 50% Skill, 50% Suerte, 100% Amor.", + "Incluso Exodia envidia lo completo que eres.", + "Trazando el camino del amor... un duelo a la vez.", + "Tus estadísticas dicen: ¡CÁSATE CONMIGO! (o al menos jueguen otra).", + ]; + + const rivalPhrases = [ + "Love is a Battlefield... y aquí perdiste contra este.", + "Tu Archi-Rival o tu 'Enemies to Lovers' arc.", + "Relación complicada: Se dan con todo en el campo.", + "Tu media naranja... de destrucción masiva.", + "Tóxicos, pero apasionados. El duelo nunca termina.", + "¿Rivalidad o tensión sexual? El log no miente.", + "Ni el odio ni el amor son tan fuertes como este 2-0.", + "Tu destino está ligado a este duelista... por los siglos de los siglos.", + "El roce hace el cariño... y las negaciones hacen el drama.", + "Tu amor platónico (porque platónicamente lo quieres ver fuera del torneo).", + ]; + + const achievementPhrases = [ + "Coleccionando triunfos y suspiros.", + "Logros que llegan directo al corazón.", + "Tu legado es puro amor al arte del duelo.", + "Brillando más que una carta holográfica en San Valentín.", + "Desbloqueaste el logro más difícil: ¡Caerle bien a todos!", + "Tus trofeos son los pétalos de una rosa de victoria.", + "Logros obtenidos con sudor, lágrimas y mucho cariño.", + "Cada medalla es un 'te quiero' de la comunidad.", + "Tu vitrina está llena, pero siempre hay espacio para más amor.", + "Nivel de Duelista: Enamorado de la victoria.", + ]; + + const summaryPhrases = [ + "¡Nos vemos en el próximo duelo, Cupido de las cartas!", + "Que tus robos sean siempre de corazón.", + "Duelo terminado, pero el amor por el juego sigue.", + "Sigue robando corazones (y victorias).", + "Game Over? No, ¡Love Start!", + "Gracias por compartir tu pasión con nosotros.", + "Tu viaje continúa, ¡llénalo de duelos y abrazos!", + "Recuerda: la mejor jugada es la que se hace con amigos.", + "Nos vemos en el próximo turno de la vida.", + "¡Hasta la próxima, leyenda del romance!", + ]; + + const coverTitles = [ + "EVOLUTION VALENTINE", + "CORAZÓN DE LAS CARTAS (Y DEL MÍO)", + "8000 LP DE PURA PASIÓN", + "LOVE IS A BATTLEFIELD (CON TRAPAS)", + "TÚ, YO Y UN DUELO NOCHE", + "¡TE ELIJO A TI! (ESPERA, JUEGO EQUIVOCADO)", + "ROBANDO EL CORAZÓN DE LAS CARTAS", + "CUPIDO DUELISTA: EDICIÓN LIMITADA", + "MI DECK DE AMOR: TOP TIER", + "ROMPECORAZONES EN TURNO 1", + "AMOR A PRIMERA JUGADA", + "DIME QUE ME AMAS (O QUE NO TIENES ASH)", + ]; + + const random = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)]; + + return { + ...base, + coverTitle: random(coverTitles), + coverSubtitle: random(lovePhrases), + statsTitle: "PASIÓN POR EL DUELO", + statsSubtitle: random(statsPhrases), + rivalsTitle: "LOVE & WAR", + rivalsSubtitle: random(rivalPhrases), + achievementsTitle: "TU LEGADO DE AMOR", + achievementsSubtitle: random(achievementPhrases), + summaryTitle: "RESUMEN CON AMOR", + summarySubtitle: random(summaryPhrases), + }; + } } diff --git a/src/server/guards/bandGuard.ts b/src/server/guards/bandGuard.ts index 9b80606..84ad853 100644 --- a/src/server/guards/bandGuard.ts +++ b/src/server/guards/bandGuard.ts @@ -6,22 +6,22 @@ import { config } from "../../config"; import { JWT } from "../../shared/JWT"; export const banGuard = { - async beforeHandle({ bearer }) { - const bearerToken = bearer as string | undefined; - if (!bearerToken) throw new AuthenticationError("No token provided"); - const jwt = new JWT(config.jwt); - let userId: string; - try { - const decoded = jwt.decode(bearerToken) as { id: string }; - userId = decoded.id; - } catch { - throw new AuthenticationError("Invalid token"); - } - const userBanRepository = new UserBanPostgresRepository(); - const getActiveBan = new UserGetActiveBan(userBanRepository); - const activeBan = await getActiveBan.execute(userId); - if (activeBan) { - throw new ForbiddenError("User is banned"); - } - } + async beforeHandle({ bearer }) { + const bearerToken = bearer as string | undefined; + if (!bearerToken) throw new AuthenticationError("No token provided"); + const jwt = new JWT(config.jwt); + let userId: string; + try { + const decoded = jwt.decode(bearerToken) as { id: string }; + userId = decoded.id; + } catch { + throw new AuthenticationError("Invalid token"); + } + const userBanRepository = new UserBanPostgresRepository(); + const getActiveBan = new UserGetActiveBan(userBanRepository); + const activeBan = await getActiveBan.execute(userId); + if (activeBan) { + throw new ForbiddenError("User is banned"); + } + }, }; diff --git a/src/server/routes/ban-list-router.ts b/src/server/routes/ban-list-router.ts index 2b6e22c..5b2600d 100644 --- a/src/server/routes/ban-list-router.ts +++ b/src/server/routes/ban-list-router.ts @@ -13,32 +13,32 @@ export const banListRouter = new Elysia({ prefix: "ban-lists" }).get( }, { detail: { - tags: ['Ban Lists'], - summary: 'Get ban lists', - description: 'Retrieves all ban lists for a specific season', + tags: ["Ban Lists"], + summary: "Get ban lists", + description: "Retrieves all ban lists for a specific season", responses: { 200: { - description: 'Ban lists retrieved successfully', + description: "Ban lists retrieved successfully", content: { - 'application/json': { + "application/json": { example: [ { - id: 'banlist-1', - name: 'Edison', + id: "banlist-1", + name: "Edison", season: 1, - description: 'Edison format ban list' + description: "Edison format ban list", }, { - id: 'banlist-2', - name: 'TCG', + id: "banlist-2", + name: "TCG", season: 1, - description: 'TCG format ban list' - } - ] - } - } - } - } + description: "TCG format ban list", + }, + ], + }, + }, + }, + }, }, query: t.Object({ season: t.Number({ default: config.season }), diff --git a/src/server/routes/leaderboard-router.ts b/src/server/routes/leaderboard-router.ts index 633ea7b..3fe0783 100644 --- a/src/server/routes/leaderboard-router.ts +++ b/src/server/routes/leaderboard-router.ts @@ -8,50 +8,52 @@ import { GetBestPlayerOfLastCompletedWeek } from "../../modules/stats/applicatio const userStatsRepository = new UserStatsPostgresRepository(); -export const leaderboardRouter = new Elysia({ prefix: "/stats" }).get( - "/", - async ({ query }) => { - return new UserStatsLeaderboardGetter(userStatsRepository).get(query); - }, - { - detail: { - tags: ['Leaderboard'], - summary: 'Get leaderboard', - description: 'Retrieves paginated leaderboard with player rankings for a specific season and ban list', - responses: { - 200: { - description: 'Leaderboard retrieved successfully', - content: { - 'application/json': { - example: { - data: [ - { - userId: 'user-1', - username: 'Player1', - email: 'player1@example.com', - points: 150, - tournamentsWon: 5, - tournamentsPlayed: 20, - rank: 1 - } - ], - total: 100, - page: 1, - limit: 100 - } - } - } - } - } +export const leaderboardRouter = new Elysia({ prefix: "/stats" }) + .get( + "/", + async ({ query }) => { + return new UserStatsLeaderboardGetter(userStatsRepository).get(query); + }, + { + detail: { + tags: ["Leaderboard"], + summary: "Get leaderboard", + description: + "Retrieves paginated leaderboard with player rankings for a specific season and ban list", + responses: { + 200: { + description: "Leaderboard retrieved successfully", + content: { + "application/json": { + example: { + data: [ + { + userId: "user-1", + username: "Player1", + email: "player1@example.com", + points: 150, + tournamentsWon: 5, + tournamentsPlayed: 20, + rank: 1, + }, + ], + total: 100, + page: 1, + limit: 100, + }, + }, + }, + }, + }, + }, + query: t.Object({ + page: t.Number({ default: 1, minimum: 1 }), + limit: t.Number({ default: 100, maximum: 100 }), + banListName: t.String({ default: "Global" }), + season: t.Number({ default: config.season }), + }), }, - query: t.Object({ - page: t.Number({ default: 1, minimum: 1 }), - limit: t.Number({ default: 100, maximum: 100 }), - banListName: t.String({ default: "Global" }), - season: t.Number({ default: config.season }), - }), - }, -) + ) .get( "/player-of-the-week", async () => { @@ -59,29 +61,29 @@ export const leaderboardRouter = new Elysia({ prefix: "/stats" }).get( }, { detail: { - tags: ['Leaderboard'], - summary: 'Get player of the week', - description: 'Retrieves the best player from the last completed week', + tags: ["Leaderboard"], + summary: "Get player of the week", + description: "Retrieves the best player from the last completed week", responses: { 200: { - description: 'Player of the week retrieved successfully', + description: "Player of the week retrieved successfully", content: { - 'application/json': { + "application/json": { example: { - userId: 'user-123', - username: 'TopPlayer', - email: 'topplayer@example.com', + userId: "user-123", + username: "TopPlayer", + email: "topplayer@example.com", points: 50, tournamentsWon: 3, tournamentsPlayed: 5, weekNumber: 45, - year: 2025 - } - } - } + year: 2025, + }, + }, + }, }, - 404: { description: 'No player found for last week' } - } - } - } + 404: { description: "No player found for last week" }, + }, + }, + }, ); diff --git a/src/server/routes/me-cosmetics-router.ts b/src/server/routes/me-cosmetics-router.ts index 66992e7..46ce284 100644 --- a/src/server/routes/me-cosmetics-router.ts +++ b/src/server/routes/me-cosmetics-router.ts @@ -20,25 +20,23 @@ const getCosmeticsCatalog = new GetCosmeticsCatalog( gatekeeper, ); -export const meCosmeticsRouter = new Elysia({ prefix: "/me/cosmetics" }) - .use(bearer()) - .get( - "/", - ({ bearer, query }) => { - const { id } = jwt.decode(bearer as string) as { id: string }; - return getCosmeticsCatalog.run({ type: query.type, tier: query.tier }, id); +export const meCosmeticsRouter = new Elysia({ prefix: "/me/cosmetics" }).use(bearer()).get( + "/", + ({ bearer, query }) => { + const { id } = jwt.decode(bearer as string) as { id: string }; + return getCosmeticsCatalog.run({ type: query.type, tier: query.tier }, id); + }, + { + query: t.Object({ + type: t.Optional(t.Enum(CosmeticType)), + tier: t.Optional(t.Enum(CosmeticTier)), + }), + detail: { + tags: ["Cosmetics"], + summary: "List my cosmetics catalog", + description: + "Personalized catalog of cosmetics visible to the authenticated user. Includes cosmetics covered by the user's tier or individual COSMETIC grants. Each item includes a manifest of short-lived signed URLs.", + security: [{ bearerAuth: [] }], }, - { - query: t.Object({ - type: t.Optional(t.Enum(CosmeticType)), - tier: t.Optional(t.Enum(CosmeticTier)), - }), - detail: { - tags: ["Cosmetics"], - summary: "List my cosmetics catalog", - description: - "Personalized catalog of cosmetics visible to the authenticated user. Includes cosmetics covered by the user's tier or individual COSMETIC grants. Each item includes a manifest of short-lived signed URLs.", - security: [{ bearerAuth: [] }], - }, - }, - ); + }, +); diff --git a/src/server/routes/stats-router.ts b/src/server/routes/stats-router.ts index 10161b2..a77fc0a 100644 --- a/src/server/routes/stats-router.ts +++ b/src/server/routes/stats-router.ts @@ -1,31 +1,34 @@ import { Elysia, t } from "elysia"; import { StatsController } from "../../modules/stats/infrastructure/StatsController"; -export const statsRouter = new Elysia() - .group("/historical-stats", (app) => - app.get("/", ({ query }) => new StatsController().getGlobalStats({ query: query as { season?: string } }), { - detail: { - tags: ['Statistics'], - summary: 'Get global statistics', - description: 'Retrieves global statistics, historical charts, and daily usage.', - responses: { - 200: { - description: 'Statistics retrieved successfully', - content: { - 'application/json': { - example: { - stats: { totalDuels: 1000, activeBanLists: 5, avgDuelsPerBanList: 200 }, - historical: [{ name: 'Season 1', value: 100 }], - banListBreakdown: [{ banListName: 'TCG', totalDuels: 100 }], - dailyDuels: [{ date: '2023-01-01', count: 10 }] - } - } - } - } - } - }, - query: t.Object({ - season: t.Optional(t.String()) - }) - }) - ); +export const statsRouter = new Elysia().group("/historical-stats", (app) => + app.get( + "/", + ({ query }) => new StatsController().getGlobalStats({ query: query as { season?: string } }), + { + detail: { + tags: ["Statistics"], + summary: "Get global statistics", + description: "Retrieves global statistics, historical charts, and daily usage.", + responses: { + 200: { + description: "Statistics retrieved successfully", + content: { + "application/json": { + example: { + stats: { totalDuels: 1000, activeBanLists: 5, avgDuelsPerBanList: 200 }, + historical: [{ name: "Season 1", value: 100 }], + banListBreakdown: [{ banListName: "TCG", totalDuels: 100 }], + dailyDuels: [{ date: "2023-01-01", count: 10 }], + }, + }, + }, + }, + }, + }, + query: t.Object({ + season: t.Optional(t.String()), + }), + }, + ), +); diff --git a/src/server/routes/ticket-router.ts b/src/server/routes/ticket-router.ts index 29e7a0d..b5442d4 100644 --- a/src/server/routes/ticket-router.ts +++ b/src/server/routes/ticket-router.ts @@ -25,7 +25,8 @@ export const ticketRouter = new Elysia({ prefix: "/game-tickets" }) detail: { tags: ["Ranked"], summary: "Issue ranked game ticket", - description: "Issues a single-use ticket for the authenticated user to join a ranked game", + description: + "Issues a single-use ticket for the authenticated user to join a ranked game", security: [{ bearerAuth: [] }], responses: { 200: { diff --git a/src/server/routes/tournament-router.ts b/src/server/routes/tournament-router.ts index 4dfdff7..b78e170 100644 --- a/src/server/routes/tournament-router.ts +++ b/src/server/routes/tournament-router.ts @@ -17,31 +17,35 @@ const repository = new TournamentRankingPostgresRepository(); const userRepository = new UserPostgresRepository(); const tournamentRepository = new TournamentGateway(); const updateRanking = new UpdateRankingUseCase( - repository, - userRepository, - config.tournaments.apiUrl, - logger + repository, + userRepository, + config.tournaments.apiUrl, + logger, ); const getRanking = new GetRankingUseCase(repository); -const tournamentEnrollmentUseCase = new TournamentEnrollmentUseCase(userRepository, tournamentRepository); -const tournamentWithdrawalUseCase = new TournamentWithdrawalUseCase(userRepository, tournamentRepository); -const jwt = new JWT(config.jwt) +const tournamentEnrollmentUseCase = new TournamentEnrollmentUseCase( + userRepository, + tournamentRepository, +); +const tournamentWithdrawalUseCase = new TournamentWithdrawalUseCase( + userRepository, + tournamentRepository, +); +const jwt = new JWT(config.jwt); // Webhook URL will be dynamically generated in the controller based on request origin const createTournament = new CreateTournamentProxyUseCase( - config.tournaments.apiUrl, - config.tournaments.webhookUrl, + config.tournaments.apiUrl, + config.tournaments.webhookUrl, ); const controller = new TournamentController( - updateRanking, - getRanking, - createTournament, - tournamentEnrollmentUseCase, - tournamentWithdrawalUseCase, - jwt + updateRanking, + getRanking, + createTournament, + tournamentEnrollmentUseCase, + tournamentWithdrawalUseCase, + jwt, ); -export const tournamentRouter = new Elysia().use( - controller.routes(new Elysia()) -); +export const tournamentRouter = new Elysia().use(controller.routes(new Elysia())); diff --git a/src/server/routes/user-router.ts b/src/server/routes/user-router.ts index f6ee8fb..d1d4f67 100644 --- a/src/server/routes/user-router.ts +++ b/src/server/routes/user-router.ts @@ -52,34 +52,37 @@ export const userRouter = new Elysia({ prefix: "/users" }) "/register", async ({ body }) => { const id = randomUUID(); - return new UserRegister(userRepository, hash, logger, emailSender, jwt).register({ ...body, id }); + return new UserRegister(userRepository, hash, logger, emailSender, jwt).register({ + ...body, + id, + }); }, { detail: { - tags: ['Authentication'], - summary: 'Register new user', - description: 'Creates a new user account with a strong password and sends a welcome email', + tags: ["Authentication"], + summary: "Register new user", + description: "Creates a new user account with a strong password and sends a welcome email", responses: { 200: { - description: 'User registered successfully', + description: "User registered successfully", content: { - 'application/json': { + "application/json": { example: { - id: 'uuid-123', - username: 'player1', - email: 'player1@example.com', - token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' - } - } - } + id: "uuid-123", + username: "player1", + email: "player1@example.com", + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }, + }, + }, }, - 409: { description: 'User already exists' } - } + 409: { description: "User already exists" }, + }, }, body: t.Object({ - username: t.String({ minLength: 1, maxLength: 14, pattern: '^.*\\S.*$' }), - email: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), - password: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), + username: t.String({ minLength: 1, maxLength: 14, pattern: "^.*\\S.*$" }), + email: t.String({ minLength: 1, pattern: "^.*\\S.*$" }), + password: t.String({ minLength: 1, pattern: "^.*\\S.*$" }), }), }, ) @@ -90,38 +93,44 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['Authentication'], - summary: 'User login', - description: 'Authenticates a user and returns a JWT token', + tags: ["Authentication"], + summary: "User login", + description: "Authenticates a user and returns a JWT token", responses: { 200: { - description: 'Login successful', + description: "Login successful", content: { - 'application/json': { + "application/json": { example: { - token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", user: { - id: 'user-123', - username: 'player1', - email: 'player1@example.com' - } - } - } - } + id: "user-123", + username: "player1", + email: "player1@example.com", + }, + }, + }, + }, }, - 401: { description: 'Invalid credentials' } - } + 401: { description: "Invalid credentials" }, + }, }, body: t.Object({ - email: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), - password: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), + email: t.String({ minLength: 1, pattern: "^.*\\S.*$" }), + password: t.String({ minLength: 1, pattern: "^.*\\S.*$" }), }), }, ) .post( "/forgot-password", async ({ body, request }) => { - return new UserForgotPassword(userRepository, emailSender, jwt, logger, resetPasswordLinkBuilder).forgotPassword({ + return new UserForgotPassword( + userRepository, + emailSender, + jwt, + logger, + resetPasswordLinkBuilder, + ).forgotPassword({ ...body, origin: request.headers.get("origin"), referer: request.headers.get("referer"), @@ -129,23 +138,23 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['Authentication'], - summary: 'Request password reset', - description: 'Sends a password reset email to the user', + tags: ["Authentication"], + summary: "Request password reset", + description: "Sends a password reset email to the user", responses: { 200: { - description: 'Reset email sent successfully', + description: "Reset email sent successfully", content: { - 'application/json': { - example: { message: 'Password reset email sent' } - } - } + "application/json": { + example: { message: "Password reset email sent" }, + }, + }, }, - 404: { description: 'User not found' } - } + 404: { description: "User not found" }, + }, }, body: t.Object({ - email: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), + email: t.String({ minLength: 1, pattern: "^.*\\S.*$" }), }), }, ) @@ -158,20 +167,20 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['Authentication'], - summary: 'Validate reset token', - description: 'Validates a password reset token', + tags: ["Authentication"], + summary: "Validate reset token", + description: "Validates a password reset token", responses: { 200: { - description: 'Token is valid', + description: "Token is valid", content: { - 'application/json': { - example: { valid: true, email: 'user@example.com' } - } - } + "application/json": { + example: { valid: true, email: "user@example.com" }, + }, + }, }, - 401: { description: 'Invalid or expired token' } - } + 401: { description: "Invalid or expired token" }, + }, }, query: t.Object({ token: t.String(), @@ -181,26 +190,29 @@ export const userRouter = new Elysia({ prefix: "/users" }) .get( "/username-availability", async ({ query }) => { - return new UserUsernameAvailabilityChecker(userRepository).check({ username: query.username }); + return new UserUsernameAvailabilityChecker(userRepository).check({ + username: query.username, + }); }, { detail: { - tags: ['User Management'], - summary: 'Check username availability', - description: 'Checks whether a username is available so the frontend can validate it before submitting a change or registration', + tags: ["User Management"], + summary: "Check username availability", + description: + "Checks whether a username is available so the frontend can validate it before submitting a change or registration", responses: { 200: { - description: 'Availability resolved successfully', + description: "Availability resolved successfully", content: { - 'application/json': { - example: { available: true } - } - } - } - } + "application/json": { + example: { available: true }, + }, + }, + }, + }, }, query: t.Object({ - username: t.String({ minLength: 1, maxLength: 14, pattern: '^.*\\S.*$' }), + username: t.String({ minLength: 1, maxLength: 14, pattern: "^.*\\S.*$" }), }), }, ) @@ -211,34 +223,41 @@ export const userRouter = new Elysia({ prefix: "/users" }) if (!token) { throw new AuthenticationError("No token provided"); } - return new UserAccountPasswordReset(userRepository, hash, emailSender, logger, jwt).resetPassword({ + return new UserAccountPasswordReset( + userRepository, + hash, + emailSender, + logger, + jwt, + ).resetPassword({ token, newPassword: body.password, }); }, { detail: { - tags: ['Authentication'], - summary: 'Reset account password', - description: 'Resets the strong account password using a valid reset token', + tags: ["Authentication"], + summary: "Reset account password", + description: "Resets the strong account password using a valid reset token", security: [{ bearerAuth: [] }], responses: { 200: { - description: 'Account password reset successfully; returns a fresh session for auto-login', + description: + "Account password reset successfully; returns a fresh session for auto-login", content: { - 'application/json': { + "application/json": { example: { - id: 'user-123', - username: 'player1', - token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - migrated: true - } - } - } + id: "user-123", + username: "player1", + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + migrated: true, + }, + }, + }, }, - 400: { description: 'Password does not meet the policy' }, - 401: { description: 'Invalid or expired token' } - } + 400: { description: "Password does not meet the policy" }, + 401: { description: "Invalid or expired token" }, + }, }, body: t.Object({ password: t.String({ minLength: 1 }), @@ -255,27 +274,27 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['User Management'], - summary: 'Get user statistics', - description: 'Retrieves user statistics for a specific ban list and season', + tags: ["User Management"], + summary: "Get user statistics", + description: "Retrieves user statistics for a specific ban list and season", responses: { 200: { - description: 'Statistics retrieved successfully', + description: "Statistics retrieved successfully", content: { - 'application/json': { + "application/json": { example: { - userId: 'user-123', - banListName: 'Global', + userId: "user-123", + banListName: "Global", season: 1, wins: 15, losses: 5, - winRate: 0.75 - } - } - } + winRate: 0.75, + }, + }, + }, }, - 404: { description: 'User not found' } - } + 404: { description: "User not found" }, + }, }, query: t.Object({ banListName: t.String({ default: "Global" }), @@ -298,31 +317,31 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['User Management'], - summary: 'Get user matches', - description: 'Retrieves paginated match history for a user', + tags: ["User Management"], + summary: "Get user matches", + description: "Retrieves paginated match history for a user", responses: { 200: { - description: 'Matches retrieved successfully', + description: "Matches retrieved successfully", content: { - 'application/json': { + "application/json": { example: { data: [ { - id: 'match-1', - date: '2025-11-24T10:00:00Z', - opponent: 'Player2', - result: 'win' - } + id: "match-1", + date: "2025-11-24T10:00:00Z", + opponent: "Player2", + result: "win", + }, ], total: 50, page: 1, - limit: 100 - } - } - } - } - } + limit: 100, + }, + }, + }, + }, + }, }, query: t.Object({ page: t.Number({ default: 1, minimum: 1 }), @@ -344,28 +363,31 @@ export const userRouter = new Elysia({ prefix: "/users" }) "/change-username", async ({ body, bearer }) => { const { id } = jwt.decode(bearer as string) as { id: string }; - return new UserUsernameUpdater(userRepository).updateUsername({ ...(body as { username: string }), id }); + return new UserUsernameUpdater(userRepository).updateUsername({ + ...(body as { username: string }), + id, + }); }, { detail: { - tags: ['User Management'], - summary: 'Change username', - description: 'Changes the username for the authenticated user', + tags: ["User Management"], + summary: "Change username", + description: "Changes the username for the authenticated user", security: [{ bearerAuth: [] }], responses: { 200: { - description: 'Username changed successfully', + description: "Username changed successfully", content: { - 'application/json': { - example: { message: 'Username updated successfully' } - } - } + "application/json": { + example: { message: "Username updated successfully" }, + }, + }, }, - 409: { description: 'Username already taken' } - } + 409: { description: "Username already taken" }, + }, }, body: t.Object({ - username: t.String({ minLength: 1, maxLength: 14, pattern: '^.*\\S.*$' }), + username: t.String({ minLength: 1, maxLength: 14, pattern: "^.*\\S.*$" }), }), }, ) @@ -377,21 +399,22 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['User Management'], - summary: 'Generate game password', - description: 'Regenerates the 4-character game password used to connect through other ygopro clients and returns it once', + tags: ["User Management"], + summary: "Generate game password", + description: + "Regenerates the 4-character game password used to connect through other ygopro clients and returns it once", security: [{ bearerAuth: [] }], responses: { 200: { - description: 'Game password generated successfully', + description: "Game password generated successfully", content: { - 'application/json': { - example: { gamePassword: 'Xy3z' } - } - } + "application/json": { + example: { gamePassword: "Xy3z" }, + }, + }, }, - 404: { description: 'User not found' } - } + 404: { description: "User not found" }, + }, }, }, ) @@ -406,15 +429,16 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['Authentication'], - summary: 'Set account password (upgrade)', - description: 'Sets the strong account password for a user that signed in with mustUpgrade, and returns a fresh token', + tags: ["Authentication"], + summary: "Set account password (upgrade)", + description: + "Sets the strong account password for a user that signed in with mustUpgrade, and returns a fresh token", security: [{ bearerAuth: [] }], responses: { - 200: { description: 'Account password set successfully' }, - 400: { description: 'Password does not meet the policy' }, - 409: { description: 'User already has an account password' } - } + 200: { description: "Account password set successfully" }, + 400: { description: "Password does not meet the policy" }, + 409: { description: "User already has an account password" }, + }, }, body: t.Object({ password: t.String({ minLength: 1 }), @@ -425,29 +449,35 @@ export const userRouter = new Elysia({ prefix: "/users" }) "/change-account-password", async ({ body, bearer }) => { const { id } = jwt.decode(bearer as string) as { id: string }; - return new UserAccountPasswordUpdater(userRepository, hash, logger, emailSender).updatePassword({ + return new UserAccountPasswordUpdater( + userRepository, + hash, + logger, + emailSender, + ).updatePassword({ ...(body as { currentPassword: string; newPassword: string }), id, }); }, { detail: { - tags: ['Authentication'], - summary: 'Change account password', - description: 'Changes the strong account password of the authenticated user, verifying the current one', + tags: ["Authentication"], + summary: "Change account password", + description: + "Changes the strong account password of the authenticated user, verifying the current one", security: [{ bearerAuth: [] }], responses: { - 200: { description: 'Account password changed successfully' }, - 400: { description: 'Password does not meet the policy' }, - 401: { description: 'Wrong current password' } - } + 200: { description: "Account password changed successfully" }, + 400: { description: "Password does not meet the policy" }, + 401: { description: "Wrong current password" }, + }, }, body: t.Object({ currentPassword: t.String({ minLength: 1 }), newPassword: t.String({ minLength: 1 }), }), }, - ) + ), ) // Admin Endpoints (NOT protected by banGuard) @@ -468,22 +498,23 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['User Bans'], - summary: 'Ban user', - description: 'Bans a user with a reason and optional expiration date. Requires admin privileges.', + tags: ["User Bans"], + summary: "Ban user", + description: + "Bans a user with a reason and optional expiration date. Requires admin privileges.", security: [{ bearerAuth: [] }], responses: { 200: { - description: 'User banned successfully', + description: "User banned successfully", content: { - 'application/json': { - example: { success: true } - } - } + "application/json": { + example: { success: true }, + }, + }, }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'User not found' } - } + 401: { description: "Unauthorized - Admin role required" }, + 404: { description: "User not found" }, + }, }, params: t.Object({ userId: t.String() }), body: t.Object({ @@ -504,22 +535,22 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['User Bans'], - summary: 'Unban user', - description: 'Removes an active ban from a user. Requires admin privileges.', + tags: ["User Bans"], + summary: "Unban user", + description: "Removes an active ban from a user. Requires admin privileges.", security: [{ bearerAuth: [] }], responses: { 200: { - description: 'User unbanned successfully', + description: "User unbanned successfully", content: { - 'application/json': { - example: { success: true } - } - } + "application/json": { + example: { success: true }, + }, + }, }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'User or ban not found' } - } + 401: { description: "Unauthorized - Admin role required" }, + 404: { description: "User or ban not found" }, + }, }, params: t.Object({ userId: t.String() }), }, @@ -552,32 +583,32 @@ export const userRouter = new Elysia({ prefix: "/users" }) }, { detail: { - tags: ['User Bans'], - summary: 'Get ban history', - description: 'Retrieves the complete ban history for a user. Requires admin privileges.', + tags: ["User Bans"], + summary: "Get ban history", + description: "Retrieves the complete ban history for a user. Requires admin privileges.", security: [{ bearerAuth: [] }], responses: { 200: { - description: 'Ban history retrieved successfully', + description: "Ban history retrieved successfully", content: { - 'application/json': { + "application/json": { example: { history: [ { - id: 'ban-123', - reason: 'Inappropriate behavior', - bannedAt: '2025-11-24T10:00:00Z', - unbannedAt: '2025-11-25T10:00:00Z', - isActive: false - } - ] - } - } - } + id: "ban-123", + reason: "Inappropriate behavior", + bannedAt: "2025-11-24T10:00:00Z", + unbannedAt: "2025-11-25T10:00:00Z", + isActive: false, + }, + ], + }, + }, + }, }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'User not found' } - } + 401: { description: "Unauthorized - Admin role required" }, + 404: { description: "User not found" }, + }, }, params: t.Object({ userId: t.String() }), }, diff --git a/src/server/routes/wrapped-router.ts b/src/server/routes/wrapped-router.ts index 9bbb9c4..c420601 100644 --- a/src/server/routes/wrapped-router.ts +++ b/src/server/routes/wrapped-router.ts @@ -1,7 +1,11 @@ import { Elysia, t } from "elysia"; import { bearer } from "@elysiajs/bearer"; import { rateLimit } from "elysia-rate-limit"; -import { WrappedController, NotFoundError, ValidationError } from "../../modules/wrapped/infrastructure/WrappedController"; +import { + WrappedController, + NotFoundError, + ValidationError, +} from "../../modules/wrapped/infrastructure/WrappedController"; import { JWT } from "../../shared/JWT"; import { config } from "../../config"; import { UnauthorizedError } from "../../shared/errors/UnauthorizedError"; @@ -14,225 +18,234 @@ const jwt = new JWT(config.jwt); * Authorizes access to wrapped data * Allows access if user is the owner OR has admin role */ -function authorizeWrappedAccess( - bearerToken: string | undefined, - playerId: string -): void { - if (!bearerToken) { - throw new UnauthorizedError("Authentication required to access wrapped data"); - } - - const decoded = jwt.decode(bearerToken) as { id: string; role: string }; - const isOwner = decoded.id === playerId; - const isAdmin = decoded.role === UserProfileRole.ADMIN; - - if (!isOwner && !isAdmin) { - throw new UnauthorizedError( - "You can only access your own wrapped data or must be an admin" - ); - } +function authorizeWrappedAccess(bearerToken: string | undefined, playerId: string): void { + if (!bearerToken) { + throw new UnauthorizedError("Authentication required to access wrapped data"); + } + + const decoded = jwt.decode(bearerToken) as { id: string; role: string }; + const isOwner = decoded.id === playerId; + const isAdmin = decoded.role === UserProfileRole.ADMIN; + + if (!isOwner && !isAdmin) { + throw new UnauthorizedError("You can only access your own wrapped data or must be an admin"); + } } -export const wrappedRouter = new Elysia() - .use(bearer()) - .group("/seasons", (app) => - app - .use( - rateLimit({ - duration: 60000, - max: 100, - }) - ) - // HTML endpoint - .get( - "/:seasonId/wrapped/:playerId/html", - async ({ params, query, bearer, set }) => { - try { - // Authorization: Only owner or admin can access - authorizeWrappedAccess(bearer, params.playerId); - - const result = await controller.getData({ - params: { - seasonId: params.seasonId, - playerId: params.playerId, - }, - }); - - const { renderTemplate } = await import("../../modules/wrapped/infrastructure/templates/templateRenderer"); - const { ThemeStrategyFactory } = await import("../../modules/wrapped/application/ThemeStrategyFactory"); - const { DarkThemeStrategy } = await import("../../modules/wrapped/infrastructure/themes/DarkThemeStrategy"); - const { LightThemeStrategy } = await import("../../modules/wrapped/infrastructure/themes/LightThemeStrategy"); - const { ValentineThemeStrategy } = await import("../../modules/wrapped/infrastructure/themes/ValentineThemeStrategy"); - - const themeFactory = new ThemeStrategyFactory(); - themeFactory.register("dark", new DarkThemeStrategy()); - themeFactory.register("light", new LightThemeStrategy()); - themeFactory.register("valentines", new ValentineThemeStrategy()); - - const strategy = themeFactory.get(query.theme || "dark"); - - const locale = (query.locale || "es") as string; - const html = renderTemplate(result, { - locale, - theme: query.theme || "dark", - includeMatchList: false, - }, strategy); - - set.headers["Content-Type"] = "text/html"; - return html; - } catch (error) { - if (error instanceof UnauthorizedError) { - set.status = 401; - return { - error: "UnauthorizedError", - message: error.message - }; - } - - if (error instanceof ValidationError) { - set.status = 422; - return { - error: "ValidationError", - message: error.message - }; - } - - if (error instanceof NotFoundError) { - set.status = 404; - return { - error: "NotFoundError", - message: error.message - }; - } - - set.status = 500; - return { - error: "Failed to generate HTML", - details: error instanceof Error ? error.message : "Unknown error" - }; - } - }, - { - params: t.Object({ - seasonId: t.String({ description: "ID of the season (e.g., 6)" }), - playerId: t.String({ description: "UUID of the player" }), - }), - query: t.Object({ - locale: t.Optional(t.String({ description: "Language code (es, en)", default: "es" })), - theme: t.Optional(t.String({ description: "Color theme (dark, light)", default: "dark" })), - }), - detail: { - tags: ["Wrapped"], - summary: "Get Season Wrapped HTML", - description: "Returns the HTML view of a player's season wrapped. Protected: Owner or Admin only.", - } - } - ) - - // JSON endpoint (for debugging) - .get( - "/:seasonId/wrapped/:playerId", - async ({ params, bearer, set }) => { - try { - // Authorization: Only owner or admin can access - authorizeWrappedAccess(bearer, params.playerId); - - const data = await controller.getData({ - params: { - seasonId: params.seasonId, - playerId: params.playerId, - }, - }); - - // Success response with caching - set.status = 200; - set.headers["Content-Type"] = "application/json"; - set.headers["Cache-Control"] = "public, max-age=3600"; - - return data; - } catch (error) { - if (error instanceof UnauthorizedError) { - set.status = 401; - return { - error: "UnauthorizedError", - message: error.message - }; - } - - if (error instanceof ValidationError) { - set.status = 422; - return { - error: "ValidationError", - message: error.message - }; - } - - if (error instanceof NotFoundError) { - set.status = 404; - return { - error: "NotFoundError", - message: error.message - }; - } - - console.error("Wrapped data fetch error:", error); - set.status = 500; - return { - error: "InternalServerError", - message: "Failed to fetch wrapped data", - details: error instanceof Error ? error.message : "Unknown error" - }; - } - }, - { - detail: { - tags: ["Season Wrapped"], - summary: "Get season wrapped data (JSON)", - description: - "Returns the raw season wrapped data as JSON for debugging and validation", - responses: { - 200: { - description: "Data retrieved successfully", - content: { - "application/json": { - example: { - playerId: "e3b02258-4c7c-41d6-b317-bc78f52a7e84", - playerName: "PlayerOne", - seasonId: 5, - globalStats: { - totalMatches: 150, - wins: 85, - losses: 65, - winrate: 56.7, - }, - banListStats: [], - achievements: [], - }, - }, - }, - }, - 401: { - description: "Unauthorized - Authentication required or insufficient permissions", - }, - 404: { - description: "Player or season not found", - }, - 422: { - description: "Validation error - Invalid season ID or player ID format", - }, - }, - security: [{ bearerAuth: [] }], - }, - params: t.Object({ - seasonId: t.String({ - description: "Season ID", - examples: ["5"], - }), - playerId: t.String({ - description: "Player UUID", - examples: ["e3b02258-4c7c-41d6-b317-bc78f52a7e84"], - }), - }), - }, - ), - ); +export const wrappedRouter = new Elysia().use(bearer()).group("/seasons", (app) => + app + .use( + rateLimit({ + duration: 60000, + max: 100, + }), + ) + // HTML endpoint + .get( + "/:seasonId/wrapped/:playerId/html", + async ({ params, query, bearer, set }) => { + try { + // Authorization: Only owner or admin can access + authorizeWrappedAccess(bearer, params.playerId); + + const result = await controller.getData({ + params: { + seasonId: params.seasonId, + playerId: params.playerId, + }, + }); + + const { renderTemplate } = await import( + "../../modules/wrapped/infrastructure/templates/templateRenderer" + ); + const { ThemeStrategyFactory } = await import( + "../../modules/wrapped/application/ThemeStrategyFactory" + ); + const { DarkThemeStrategy } = await import( + "../../modules/wrapped/infrastructure/themes/DarkThemeStrategy" + ); + const { LightThemeStrategy } = await import( + "../../modules/wrapped/infrastructure/themes/LightThemeStrategy" + ); + const { ValentineThemeStrategy } = await import( + "../../modules/wrapped/infrastructure/themes/ValentineThemeStrategy" + ); + + const themeFactory = new ThemeStrategyFactory(); + themeFactory.register("dark", new DarkThemeStrategy()); + themeFactory.register("light", new LightThemeStrategy()); + themeFactory.register("valentines", new ValentineThemeStrategy()); + + const strategy = themeFactory.get(query.theme || "dark"); + + const locale = (query.locale || "es") as string; + const html = renderTemplate( + result, + { + locale, + theme: query.theme || "dark", + includeMatchList: false, + }, + strategy, + ); + + set.headers["Content-Type"] = "text/html"; + return html; + } catch (error) { + if (error instanceof UnauthorizedError) { + set.status = 401; + return { + error: "UnauthorizedError", + message: error.message, + }; + } + + if (error instanceof ValidationError) { + set.status = 422; + return { + error: "ValidationError", + message: error.message, + }; + } + + if (error instanceof NotFoundError) { + set.status = 404; + return { + error: "NotFoundError", + message: error.message, + }; + } + + set.status = 500; + return { + error: "Failed to generate HTML", + details: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + { + params: t.Object({ + seasonId: t.String({ description: "ID of the season (e.g., 6)" }), + playerId: t.String({ description: "UUID of the player" }), + }), + query: t.Object({ + locale: t.Optional(t.String({ description: "Language code (es, en)", default: "es" })), + theme: t.Optional( + t.String({ description: "Color theme (dark, light)", default: "dark" }), + ), + }), + detail: { + tags: ["Wrapped"], + summary: "Get Season Wrapped HTML", + description: + "Returns the HTML view of a player's season wrapped. Protected: Owner or Admin only.", + }, + }, + ) + + // JSON endpoint (for debugging) + .get( + "/:seasonId/wrapped/:playerId", + async ({ params, bearer, set }) => { + try { + // Authorization: Only owner or admin can access + authorizeWrappedAccess(bearer, params.playerId); + + const data = await controller.getData({ + params: { + seasonId: params.seasonId, + playerId: params.playerId, + }, + }); + + // Success response with caching + set.status = 200; + set.headers["Content-Type"] = "application/json"; + set.headers["Cache-Control"] = "public, max-age=3600"; + + return data; + } catch (error) { + if (error instanceof UnauthorizedError) { + set.status = 401; + return { + error: "UnauthorizedError", + message: error.message, + }; + } + + if (error instanceof ValidationError) { + set.status = 422; + return { + error: "ValidationError", + message: error.message, + }; + } + + if (error instanceof NotFoundError) { + set.status = 404; + return { + error: "NotFoundError", + message: error.message, + }; + } + + console.error("Wrapped data fetch error:", error); + set.status = 500; + return { + error: "InternalServerError", + message: "Failed to fetch wrapped data", + details: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + { + detail: { + tags: ["Season Wrapped"], + summary: "Get season wrapped data (JSON)", + description: "Returns the raw season wrapped data as JSON for debugging and validation", + responses: { + 200: { + description: "Data retrieved successfully", + content: { + "application/json": { + example: { + playerId: "e3b02258-4c7c-41d6-b317-bc78f52a7e84", + playerName: "PlayerOne", + seasonId: 5, + globalStats: { + totalMatches: 150, + wins: 85, + losses: 65, + winrate: 56.7, + }, + banListStats: [], + achievements: [], + }, + }, + }, + }, + 401: { + description: "Unauthorized - Authentication required or insufficient permissions", + }, + 404: { + description: "Player or season not found", + }, + 422: { + description: "Validation error - Invalid season ID or player ID format", + }, + }, + security: [{ bearerAuth: [] }], + }, + params: t.Object({ + seasonId: t.String({ + description: "Season ID", + examples: ["5"], + }), + playerId: t.String({ + description: "Player UUID", + examples: ["e3b02258-4c7c-41d6-b317-bc78f52a7e84"], + }), + }), + }, + ), +); diff --git a/src/server/server.ts b/src/server/server.ts index 771be3e..ec48a8d 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -21,7 +21,6 @@ import { tournamentRouter } from "./routes/tournament-router"; import { userRouter } from "./routes/user-router"; import { wrappedRouter } from "./routes/wrapped-router"; - export class Server { private readonly app: Elysia; private readonly logger: Logger; @@ -29,75 +28,77 @@ export class Server { constructor(logger: Logger) { this.app = new Elysia() .use(cors()) - .use(swagger({ - documentation: { - info: { - title: 'Evolution API - Tournaments', - version: '1.0.0', - description: 'API for managing tournaments, matches, participants, and leaderboards' - }, - tags: [ - { - name: 'Authentication', - description: 'User authentication and registration endpoints' - }, - { - name: 'User Management', - description: 'User profile and account management' - }, - { - name: 'User Bans', - description: 'User ban management (Admin only)' - }, - { - name: 'Leaderboard', - description: 'Rankings and statistics endpoints' - }, - { - name: 'Ban Lists', - description: 'Game ban list information' - }, - { - name: 'Tournaments', - description: 'Tournament management and enrollment' + .use( + swagger({ + documentation: { + info: { + title: "Evolution API - Tournaments", + version: "1.0.0", + description: "API for managing tournaments, matches, participants, and leaderboards", }, - { - name: 'Players & Participants', - description: 'Endpoints for querying player and participant information' + tags: [ + { + name: "Authentication", + description: "User authentication and registration endpoints", + }, + { + name: "User Management", + description: "User profile and account management", + }, + { + name: "User Bans", + description: "User ban management (Admin only)", + }, + { + name: "Leaderboard", + description: "Rankings and statistics endpoints", + }, + { + name: "Ban Lists", + description: "Game ban list information", + }, + { + name: "Tournaments", + description: "Tournament management and enrollment", + }, + { + name: "Players & Participants", + description: "Endpoints for querying player and participant information", + }, + { + name: "Bracket Management", + description: "Endpoints for generating and retrieving tournament brackets", + }, + { + name: "Match Management", + description: "Endpoints for managing match results and match data", + }, + { + name: "Season Wrapped", + description: "Season summary reports and statistics visualization", + }, + { + name: "Statistics", + description: "Global statistics and historical data", + }, + { + name: "Cosmetics", + description: "Cosmetics catalog and customization", + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "JWT token obtained from authentication endpoint", + }, + }, }, - { - name: 'Bracket Management', - description: 'Endpoints for generating and retrieving tournament brackets' - }, - { - name: 'Match Management', - description: 'Endpoints for managing match results and match data' - }, - { - name: 'Season Wrapped', - description: 'Season summary reports and statistics visualization' - }, - { - name: 'Statistics', - description: 'Global statistics and historical data' - }, - { - name: 'Cosmetics', - description: 'Cosmetics catalog and customization' - } - ], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'JWT token obtained from authentication endpoint' - } - } - } - } - })) + }, + }), + ) .onError(({ error, set }) => { if (error instanceof ConflictError) { set.status = 409; @@ -133,8 +134,7 @@ export class Server { .use(cosmeticsRouter) .use(meCosmeticsRouter) .use(loadoutRouter) - .use(publicLoadoutRouter) - + .use(publicLoadoutRouter); }); this.logger = logger; } diff --git a/src/shared/email/EmailTemplate.ts b/src/shared/email/EmailTemplate.ts index 5c8a869..779de28 100644 --- a/src/shared/email/EmailTemplate.ts +++ b/src/shared/email/EmailTemplate.ts @@ -4,9 +4,15 @@ export interface BrandedEmailInput { cta?: { label: string; url: string }; } -export function renderBrandedEmail({ heading, paragraphs, cta }: BrandedEmailInput): { html: string; text: string } { +export function renderBrandedEmail({ heading, paragraphs, cta }: BrandedEmailInput): { + html: string; + text: string; +} { const paragraphsHtml = paragraphs - .map((p) => `

${p}

`) + .map( + (p) => + `

${p}

`, + ) .join("\n\t\t\t\t\t\t\t"); const ctaHtml = cta diff --git a/tests/setup.ts b/tests/setup.ts index 0df3ba1..1d2989f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,2 +1,2 @@ import dotenv from "dotenv"; -dotenv.config({ path: ".env.test" }); \ No newline at end of file +dotenv.config({ path: ".env.test" }); diff --git a/tests/unit/modules/auth/application/UserAuth.test.ts b/tests/unit/modules/auth/application/UserAuth.test.ts index ac0fc09..6b8f0af 100644 --- a/tests/unit/modules/auth/application/UserAuth.test.ts +++ b/tests/unit/modules/auth/application/UserAuth.test.ts @@ -9,100 +9,100 @@ import { UserMother } from "../../users/mothers/UserMother"; import type { UserRepository } from "../../../../../src/modules/user/domain/UserRepository"; describe("UserAuth", () => { - let userAuth: UserAuth; - let repository: UserRepository; - let hash: Hash; - let jwt: JWT; - let user: User; - let request: { email: string; password: string }; - - beforeEach(async () => { - hash = new Hash(); - jwt = new JWT({ issuer: "issuer", secret: "secret" }); - - repository = { - create: async () => undefined, - findByEmailOrUsername: async () => null, - findByEmail: async () => null, - findByUsername: async () => null, - findById: async () => null, - update: async () => undefined, - updateParticipantId: async () => undefined, - findByParticipantId: async () => null, - }; - - userAuth = new UserAuth(repository, hash, jwt); - request = UserAuthRequestMother.create(); - - const hashedPassword = await hash.hash(request.password); - user = UserMother.create({ password: hashedPassword, email: request.email }); - }); - - it("Should login success if data is correct", async () => { - spyOn(repository, "findByEmail").mockResolvedValue(user); - - const response = await userAuth.login(request); - - expect(repository.findByEmail).toHaveBeenCalledTimes(1); - expect(repository.findByEmail).toHaveBeenCalledWith(request.email); - - expect(response).toHaveProperty("token"); - expect(response).toHaveProperty("username", user.username); - expect(response).toHaveProperty("id", user.id); - }); - - it("logs in a user that has not migrated yet with the game password and flags mustUpgrade", async () => { - // user from beforeEach has no account password (securePassword null) and a game password. - spyOn(repository, "findByEmail").mockResolvedValue(user); - - const response = await userAuth.login(request); - - expect(response).toHaveProperty("token"); - expect(response).toHaveProperty("mustUpgrade", true); - }); - - it("logs in a migrated user with the account password and does not require an upgrade", async () => { - const accountPassword = "BlueEyes7"; - const migratedUser = UserMother.create({ - email: request.email, - securePassword: await hash.hash(accountPassword), - }); - spyOn(repository, "findByEmail").mockResolvedValue(migratedUser); - - const response = await userAuth.login({ email: request.email, password: accountPassword }); - - expect(response).toHaveProperty("token"); - expect(response).toHaveProperty("mustUpgrade", false); - }); - - it("rejects the game password once the user has an account password", async () => { - const gamePassword = "ab12"; - const migratedUser = UserMother.create({ - email: request.email, - password: await hash.hash(gamePassword), - securePassword: await hash.hash("StrongPass1"), - }); - spyOn(repository, "findByEmail").mockResolvedValue(migratedUser); - - await expect(userAuth.login({ email: request.email, password: gamePassword })).rejects.toThrowError( - new AuthenticationError("Wrong email or password"), - ); - }); - - it("Should throw an AuthenticationError if user does not exist", async () => { - spyOn(repository, "findByEmail").mockResolvedValue(null); - - await expect(userAuth.login(request)).rejects.toThrowError( - new AuthenticationError("Wrong email or password"), - ); - }); - - it("Should throw an AuthenticationError if password is invalid", async () => { - spyOn(repository, "findByEmail").mockResolvedValue(user); - request.password = "InvalidPassword"; - - await expect(userAuth.login(request)).rejects.toThrowError( - new AuthenticationError("Wrong email or password"), - ); - }); + let userAuth: UserAuth; + let repository: UserRepository; + let hash: Hash; + let jwt: JWT; + let user: User; + let request: { email: string; password: string }; + + beforeEach(async () => { + hash = new Hash(); + jwt = new JWT({ issuer: "issuer", secret: "secret" }); + + repository = { + create: async () => undefined, + findByEmailOrUsername: async () => null, + findByEmail: async () => null, + findByUsername: async () => null, + findById: async () => null, + update: async () => undefined, + updateParticipantId: async () => undefined, + findByParticipantId: async () => null, + }; + + userAuth = new UserAuth(repository, hash, jwt); + request = UserAuthRequestMother.create(); + + const hashedPassword = await hash.hash(request.password); + user = UserMother.create({ password: hashedPassword, email: request.email }); + }); + + it("Should login success if data is correct", async () => { + spyOn(repository, "findByEmail").mockResolvedValue(user); + + const response = await userAuth.login(request); + + expect(repository.findByEmail).toHaveBeenCalledTimes(1); + expect(repository.findByEmail).toHaveBeenCalledWith(request.email); + + expect(response).toHaveProperty("token"); + expect(response).toHaveProperty("username", user.username); + expect(response).toHaveProperty("id", user.id); + }); + + it("logs in a user that has not migrated yet with the game password and flags mustUpgrade", async () => { + // user from beforeEach has no account password (securePassword null) and a game password. + spyOn(repository, "findByEmail").mockResolvedValue(user); + + const response = await userAuth.login(request); + + expect(response).toHaveProperty("token"); + expect(response).toHaveProperty("mustUpgrade", true); + }); + + it("logs in a migrated user with the account password and does not require an upgrade", async () => { + const accountPassword = "BlueEyes7"; + const migratedUser = UserMother.create({ + email: request.email, + securePassword: await hash.hash(accountPassword), + }); + spyOn(repository, "findByEmail").mockResolvedValue(migratedUser); + + const response = await userAuth.login({ email: request.email, password: accountPassword }); + + expect(response).toHaveProperty("token"); + expect(response).toHaveProperty("mustUpgrade", false); + }); + + it("rejects the game password once the user has an account password", async () => { + const gamePassword = "ab12"; + const migratedUser = UserMother.create({ + email: request.email, + password: await hash.hash(gamePassword), + securePassword: await hash.hash("StrongPass1"), + }); + spyOn(repository, "findByEmail").mockResolvedValue(migratedUser); + + await expect( + userAuth.login({ email: request.email, password: gamePassword }), + ).rejects.toThrowError(new AuthenticationError("Wrong email or password")); + }); + + it("Should throw an AuthenticationError if user does not exist", async () => { + spyOn(repository, "findByEmail").mockResolvedValue(null); + + await expect(userAuth.login(request)).rejects.toThrowError( + new AuthenticationError("Wrong email or password"), + ); + }); + + it("Should throw an AuthenticationError if password is invalid", async () => { + spyOn(repository, "findByEmail").mockResolvedValue(user); + request.password = "InvalidPassword"; + + await expect(userAuth.login(request)).rejects.toThrowError( + new AuthenticationError("Wrong email or password"), + ); + }); }); diff --git a/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts b/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts index 1aa28ee..7ff4899 100644 --- a/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts +++ b/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts @@ -76,10 +76,16 @@ function fakeRepo(cosmetics: Cosmetic[]): CosmeticRepository { }; } -function gatekeeperWith(entitlements: Entitlement[]): { gk: EntitlementsGatekeeper; callCount: () => number } { +function gatekeeperWith(entitlements: Entitlement[]): { + gk: EntitlementsGatekeeper; + callCount: () => number; +} { let calls = 0; const repo: EntitlementRepository = { - findByUserId: async () => { calls++; return entitlements; }, + findByUserId: async () => { + calls++; + return entitlements; + }, save: async () => undefined, }; return { gk: new EntitlementsGatekeeper(repo), callCount: () => calls }; diff --git a/tests/unit/modules/catalog/application/SeedStandardCosmetics.test.ts b/tests/unit/modules/catalog/application/SeedStandardCosmetics.test.ts index c9e47bc..13675d5 100644 --- a/tests/unit/modules/catalog/application/SeedStandardCosmetics.test.ts +++ b/tests/unit/modules/catalog/application/SeedStandardCosmetics.test.ts @@ -5,7 +5,10 @@ import { STANDARD_COSMETICS } from "../../../../../src/modules/catalog/applicati import { Cosmetic } from "../../../../../src/modules/catalog/domain/Cosmetic"; import { CosmeticRepository } from "../../../../../src/modules/catalog/domain/CosmeticRepository"; -function fakeRepository(existing: Cosmetic[]): { repository: CosmeticRepository; saved: Cosmetic[] } { +function fakeRepository(existing: Cosmetic[]): { + repository: CosmeticRepository; + saved: Cosmetic[]; +} { const saved: Cosmetic[] = []; const repository: CosmeticRepository = { findAll: async () => existing, diff --git a/tests/unit/modules/entitlements/application/EntitlementsGatekeeper.test.ts b/tests/unit/modules/entitlements/application/EntitlementsGatekeeper.test.ts index 6063328..efa4c60 100644 --- a/tests/unit/modules/entitlements/application/EntitlementsGatekeeper.test.ts +++ b/tests/unit/modules/entitlements/application/EntitlementsGatekeeper.test.ts @@ -10,10 +10,16 @@ import { EntitlementsGatekeeper } from "../../../../../src/modules/entitlements/ const NOW = new Date("2026-06-08T00:00:00Z"); -function makeRepo(entitlements: Entitlement[]): { repo: EntitlementRepository; callCount: () => number } { +function makeRepo(entitlements: Entitlement[]): { + repo: EntitlementRepository; + callCount: () => number; +} { let calls = 0; const repo: EntitlementRepository = { - findByUserId: async () => { calls++; return entitlements; }, + findByUserId: async () => { + calls++; + return entitlements; + }, save: async () => undefined, }; return { repo, callCount: () => calls }; diff --git a/tests/unit/modules/loadout/application/EquipCosmetic.test.ts b/tests/unit/modules/loadout/application/EquipCosmetic.test.ts index c076261..d21eafd 100644 --- a/tests/unit/modules/loadout/application/EquipCosmetic.test.ts +++ b/tests/unit/modules/loadout/application/EquipCosmetic.test.ts @@ -54,7 +54,11 @@ describe("EquipCosmetic", () => { it("equips a cosmetic the user is entitled to", async () => { const { equip, saved } = build({ found: cosmetic(CosmeticTier.REGISTERED) }); - await equip.run({ userId: "user-1", cosmeticType: CosmeticType.SLEEVE, cosmeticId: "cosmetic-1" }); + await equip.run({ + userId: "user-1", + cosmeticType: CosmeticType.SLEEVE, + cosmeticId: "cosmetic-1", + }); expect(saved).toHaveLength(1); expect(saved[0].equippedCosmeticId(CosmeticType.SLEEVE)).toBe("cosmetic-1"); @@ -96,9 +100,16 @@ describe("EquipCosmetic", () => { source: EntitlementSource.PURCHASE, expiresAt: null, }); - const { equip, saved } = build({ found: cosmetic(CosmeticTier.DONOR), entitlements: [cosmeticGrant] }); + const { equip, saved } = build({ + found: cosmetic(CosmeticTier.DONOR), + entitlements: [cosmeticGrant], + }); - await equip.run({ userId: "user-1", cosmeticType: CosmeticType.SLEEVE, cosmeticId: "cosmetic-1" }); + await equip.run({ + userId: "user-1", + cosmeticType: CosmeticType.SLEEVE, + cosmeticId: "cosmetic-1", + }); expect(saved).toHaveLength(1); expect(saved[0].equippedCosmeticId(CosmeticType.SLEEVE)).toBe("cosmetic-1"); diff --git a/tests/unit/modules/matches/application/MatchesGetter.test.ts b/tests/unit/modules/matches/application/MatchesGetter.test.ts index cb3030e..e3bf928 100644 --- a/tests/unit/modules/matches/application/MatchesGetter.test.ts +++ b/tests/unit/modules/matches/application/MatchesGetter.test.ts @@ -13,7 +13,7 @@ describe("MatchGetter", () => { beforeEach(() => { repository = { get: async () => [], - } + }; matchesGetter = new MatchesGetter(repository); matches = [MatchMother.create(), MatchMother.create(), MatchMother.create()]; }); diff --git a/tests/unit/modules/matches/mothers/MatchesGetterRequestMother.ts b/tests/unit/modules/matches/mothers/MatchesGetterRequestMother.ts index 1765bf6..4977e07 100644 --- a/tests/unit/modules/matches/mothers/MatchesGetterRequestMother.ts +++ b/tests/unit/modules/matches/mothers/MatchesGetterRequestMother.ts @@ -2,7 +2,13 @@ import { faker } from "@faker-js/faker"; export class MatchesGetterRequestMother { static create( - params?: Partial<{ userId: string; banListName: string; limit: number; page: number; season: number }>, + params?: Partial<{ + userId: string; + banListName: string; + limit: number; + page: number; + season: number; + }>, ): { userId: string; banListName: string; diff --git a/tests/unit/modules/stats/application/UserStatsFinder.test.ts b/tests/unit/modules/stats/application/UserStatsFinder.test.ts index 7684b2b..850adad 100644 --- a/tests/unit/modules/stats/application/UserStatsFinder.test.ts +++ b/tests/unit/modules/stats/application/UserStatsFinder.test.ts @@ -23,7 +23,7 @@ describe("UserStatsFinder", () => { }); it("Should return user stats when they exist for the given user and ban list", async () => { - spyOn(repository, 'find').mockResolvedValue(userStats); + spyOn(repository, "find").mockResolvedValue(userStats); const response = await userStatsFinder.find({ userId: userStats.userId, banListName: "Global", @@ -35,17 +35,20 @@ describe("UserStatsFinder", () => { }); it("Should default to the 'Global' ban list when none is specified", async () => { - spyOn(repository, 'find').mockResolvedValue(userStats); - const response = await userStatsFinder.find({ userId: userStats.userId, season: config.season }); + spyOn(repository, "find").mockResolvedValue(userStats); + const response = await userStatsFinder.find({ + userId: userStats.userId, + season: config.season, + }); expect(repository.find).toHaveBeenCalledTimes(1); expect(repository.find).toHaveBeenCalledWith(userStats.userId, "Global", config.season); expect(response).toEqual(userStats.toJson()); }); it("Should throw NotFoundError when stats are not found for the given user", async () => { - spyOn(repository, 'find').mockResolvedValue(null); - expect(userStatsFinder.find({ userId: userStats.userId, season: config.season })).rejects.toThrow( - new NotFoundError(`Stats for user with id ${userStats.userId} not found.`), - ); + spyOn(repository, "find").mockResolvedValue(null); + expect( + userStatsFinder.find({ userId: userStats.userId, season: config.season }), + ).rejects.toThrow(new NotFoundError(`Stats for user with id ${userStats.userId} not found.`)); }); }); diff --git a/tests/unit/modules/stats/application/UserStatsLeaderboardGetter.test.ts b/tests/unit/modules/stats/application/UserStatsLeaderboardGetter.test.ts index d642edc..4c23434 100644 --- a/tests/unit/modules/stats/application/UserStatsLeaderboardGetter.test.ts +++ b/tests/unit/modules/stats/application/UserStatsLeaderboardGetter.test.ts @@ -23,7 +23,7 @@ describe("LeaderboardGetter", () => { it("Should be able to get stats postgres", async () => { const params = { page: 1, banListName: "Global", limit: 1, season: config.season }; - spyOn(repository, 'leaderboard').mockResolvedValue(userStats); + spyOn(repository, "leaderboard").mockResolvedValue(userStats); const response = await leaderboardGetter.get(params); expect(repository.leaderboard).toHaveBeenCalledTimes(1); expect(repository.leaderboard).toHaveBeenCalledWith(params); diff --git a/tests/unit/modules/ticket/application/IssueGameTicket.test.ts b/tests/unit/modules/ticket/application/IssueGameTicket.test.ts index c99df2c..c655c06 100644 --- a/tests/unit/modules/ticket/application/IssueGameTicket.test.ts +++ b/tests/unit/modules/ticket/application/IssueGameTicket.test.ts @@ -20,7 +20,9 @@ describe("IssueGameTicket", () => { const result = await issuer.issue({ userId: "user-123" }); - expect(result.ticket).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + expect(result.ticket).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); expect(saveSpy).toHaveBeenCalledTimes(1); const [savedTicket, savedUserId] = saveSpy.mock.calls[0]; diff --git a/tests/unit/modules/users/application/UserAccountPasswordReset.test.ts b/tests/unit/modules/users/application/UserAccountPasswordReset.test.ts index 0db8daa..c1de45d 100644 --- a/tests/unit/modules/users/application/UserAccountPasswordReset.test.ts +++ b/tests/unit/modules/users/application/UserAccountPasswordReset.test.ts @@ -81,30 +81,39 @@ describe("UserAccountPasswordReset", () => { const existingUserToken = jwt.generate({ id: existingUser.id }); spyOn(repository, "findById").mockResolvedValue(existingUser); - const session = await reset.resetPassword({ token: existingUserToken, newPassword: "NewPass2024" }); + const session = await reset.resetPassword({ + token: existingUserToken, + newPassword: "NewPass2024", + }); expect(session.migrated).toBe(false); }); it("throws when no token is provided", async () => { - expect(reset.resetPassword({ token: "", newPassword: "NewPass2024" })).rejects.toThrow(AuthenticationError); + expect(reset.resetPassword({ token: "", newPassword: "NewPass2024" })).rejects.toThrow( + AuthenticationError, + ); }); it("throws when the token is invalid", async () => { - expect(reset.resetPassword({ token: "not-a-valid-token", newPassword: "NewPass2024" })).rejects.toThrow( - AuthenticationError, - ); + expect( + reset.resetPassword({ token: "not-a-valid-token", newPassword: "NewPass2024" }), + ).rejects.toThrow(AuthenticationError); }); it("rejects a weak new password", async () => { spyOn(repository, "findById").mockResolvedValue(user); - expect(reset.resetPassword({ token, newPassword: "weak" })).rejects.toThrow(InvalidArgumentError); + expect(reset.resetPassword({ token, newPassword: "weak" })).rejects.toThrow( + InvalidArgumentError, + ); }); it("throws when the user does not exist", async () => { spyOn(repository, "findById").mockResolvedValue(null); - expect(reset.resetPassword({ token, newPassword: "NewPass2024" })).rejects.toThrow(NotFoundError); + expect(reset.resetPassword({ token, newPassword: "NewPass2024" })).rejects.toThrow( + NotFoundError, + ); }); }); diff --git a/tests/unit/modules/users/application/UserAccountPasswordUpdater.test.ts b/tests/unit/modules/users/application/UserAccountPasswordUpdater.test.ts index 571143d..cbb82ba 100644 --- a/tests/unit/modules/users/application/UserAccountPasswordUpdater.test.ts +++ b/tests/unit/modules/users/application/UserAccountPasswordUpdater.test.ts @@ -47,7 +47,11 @@ describe("UserAccountPasswordUpdater", () => { const updateSpy = spyOn(repository, "update"); const emailSpy = spyOn(emailSender, "send"); - await updater.updatePassword({ id: migratedUser.id, currentPassword, newPassword: "NewPass2024" }); + await updater.updatePassword({ + id: migratedUser.id, + currentPassword, + newPassword: "NewPass2024", + }); expect(updateSpy).toHaveBeenCalledTimes(1); const updatedUser = updateSpy.mock.calls[0][0] as User; @@ -59,7 +63,11 @@ describe("UserAccountPasswordUpdater", () => { spyOn(repository, "findById").mockResolvedValue(migratedUser); expect( - updater.updatePassword({ id: migratedUser.id, currentPassword: "WrongPass9", newPassword: "NewPass2024" }), + updater.updatePassword({ + id: migratedUser.id, + currentPassword: "WrongPass9", + newPassword: "NewPass2024", + }), ).rejects.toThrow(AuthenticationError); }); @@ -68,7 +76,11 @@ describe("UserAccountPasswordUpdater", () => { spyOn(repository, "findById").mockResolvedValue(unmigrated); expect( - updater.updatePassword({ id: unmigrated.id, currentPassword: "whatever1", newPassword: "NewPass2024" }), + updater.updatePassword({ + id: unmigrated.id, + currentPassword: "whatever1", + newPassword: "NewPass2024", + }), ).rejects.toThrow(AuthenticationError); }); @@ -84,7 +96,11 @@ describe("UserAccountPasswordUpdater", () => { spyOn(repository, "findById").mockResolvedValue(null); expect( - updater.updatePassword({ id: "missing-user-id", currentPassword, newPassword: "NewPass2024" }), + updater.updatePassword({ + id: "missing-user-id", + currentPassword, + newPassword: "NewPass2024", + }), ).rejects.toThrow(NotFoundError); }); }); diff --git a/tests/unit/modules/users/application/UserBanUser.test.ts b/tests/unit/modules/users/application/UserBanUser.test.ts index 9a05fbe..d77395d 100644 --- a/tests/unit/modules/users/application/UserBanUser.test.ts +++ b/tests/unit/modules/users/application/UserBanUser.test.ts @@ -3,44 +3,45 @@ import { UserBanUser } from "../../../../../src/modules/user/application/UserBan import { UserBanRepository } from "../../../../../src/modules/user/domain/UserBanRepository"; import { UserMother } from "../mothers/UserMother"; - describe("UserBanUser", () => { - let repository: UserBanRepository; - let userBanUser: UserBanUser; + let repository: UserBanRepository; + let userBanUser: UserBanUser; - beforeEach(() => { - repository = { - banUser: async () => undefined, - findActiveBanByUserId: async () => null, - unbanUser: async () => undefined, - getBansByUserId: async () => [], - finishActiveBan: async () => undefined, - } - userBanUser = new UserBanUser(repository); - }); + beforeEach(() => { + repository = { + banUser: async () => undefined, + findActiveBanByUserId: async () => null, + unbanUser: async () => undefined, + getBansByUserId: async () => [], + finishActiveBan: async () => undefined, + }; + userBanUser = new UserBanUser(repository); + }); - it("Should ban a user by calling banUser in the repository", async () => { - const user = UserMother.create(); - const admin = UserMother.create(); - const reason = "Inappropriate conduct"; - const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24); // 1 día + it("Should ban a user by calling banUser in the repository", async () => { + const user = UserMother.create(); + const admin = UserMother.create(); + const reason = "Inappropriate conduct"; + const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24); // 1 día const banSpy = spyOn(repository, "banUser"); - await userBanUser.execute({ - userId: user.id, - reason, - bannedBy: admin.id, - expiresAt, - }); + await userBanUser.execute({ + userId: user.id, + reason, + bannedBy: admin.id, + expiresAt, + }); - expect(banSpy).toHaveBeenCalled(); - expect(banSpy).toHaveBeenCalledTimes(1); - expect(banSpy).toHaveBeenCalledWith(expect.objectContaining({ - userId: user.id, - reason, - bannedBy: admin.id, - expiresAt, - })); - }); -}); \ No newline at end of file + expect(banSpy).toHaveBeenCalled(); + expect(banSpy).toHaveBeenCalledTimes(1); + expect(banSpy).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.id, + reason, + bannedBy: admin.id, + expiresAt, + }), + ); + }); +}); diff --git a/tests/unit/modules/users/application/UserForgotPassword.test.ts b/tests/unit/modules/users/application/UserForgotPassword.test.ts index a191fb1..aa3528c 100644 --- a/tests/unit/modules/users/application/UserForgotPassword.test.ts +++ b/tests/unit/modules/users/application/UserForgotPassword.test.ts @@ -35,8 +35,14 @@ describe("UserForgotPassword", () => { logger = new Pino(); resetLinkBuilder = new ResetPasswordLinkBuilder( [ - { origin: "https://evolutionygo.com", template: "https://evolutionygo.com/reset-password?token={token}" }, - { origin: "https://evoduel.com", template: "https://evoduel.com/#/reset-account-password?token={token}" }, + { + origin: "https://evolutionygo.com", + template: "https://evolutionygo.com/reset-password?token={token}", + }, + { + origin: "https://evoduel.com", + template: "https://evoduel.com/#/reset-account-password?token={token}", + }, ], "https://evolutionygo.com/reset-password?token={token}", ); @@ -49,7 +55,11 @@ describe("UserForgotPassword", () => { spyOn(repository, "findByEmail").mockResolvedValue(user); const sendSpy = spyOn(emailSender, "send").mockResolvedValue(); - await forgot.forgotPassword({ email: user.email, origin: "https://evoduel.com", referer: null }); + await forgot.forgotPassword({ + email: user.email, + origin: "https://evoduel.com", + referer: null, + }); expect(sendSpy).toHaveBeenCalledTimes(1); const emailData = sendSpy.mock.calls[0][1]; @@ -61,7 +71,11 @@ describe("UserForgotPassword", () => { spyOn(repository, "findByEmail").mockResolvedValue(user); const sendSpy = spyOn(emailSender, "send").mockResolvedValue(); - await forgot.forgotPassword({ email: user.email, origin: null, referer: "https://evolutionygo.com/" }); + await forgot.forgotPassword({ + email: user.email, + origin: null, + referer: "https://evolutionygo.com/", + }); const emailData = sendSpy.mock.calls[0][1]; expect(emailData.html).toContain("https://evolutionygo.com/reset-password?token="); diff --git a/tests/unit/modules/users/application/UserGetActiveBan.test.ts b/tests/unit/modules/users/application/UserGetActiveBan.test.ts index e7fc612..37ec6bf 100644 --- a/tests/unit/modules/users/application/UserGetActiveBan.test.ts +++ b/tests/unit/modules/users/application/UserGetActiveBan.test.ts @@ -4,33 +4,33 @@ import { UserBanRepository } from "../../../../../src/modules/user/domain/UserBa import { UserBanMother } from "../mothers/UserBanMother"; describe("UserGetActiveBan", () => { - let repository: UserBanRepository; - let userGetActiveBan: UserGetActiveBan; + let repository: UserBanRepository; + let userGetActiveBan: UserGetActiveBan; - beforeEach(() => { - repository = { - banUser: async () => undefined, - findActiveBanByUserId: async () => null, - unbanUser: async () => undefined, - getBansByUserId: async () => [], - finishActiveBan: async () => undefined, - } - userGetActiveBan = new UserGetActiveBan(repository); - }); + beforeEach(() => { + repository = { + banUser: async () => undefined, + findActiveBanByUserId: async () => null, + unbanUser: async () => undefined, + getBansByUserId: async () => [], + finishActiveBan: async () => undefined, + }; + userGetActiveBan = new UserGetActiveBan(repository); + }); - it("Should return the active ban if it exists", async () => { - const userId = "user-id-123"; - const ban = UserBanMother.create(); - spyOn(repository, "findActiveBanByUserId").mockResolvedValue(ban); - const result = await userGetActiveBan.execute(userId); - expect(repository.findActiveBanByUserId).toHaveBeenCalledWith(userId); - expect(result).toBe(ban); - }); + it("Should return the active ban if it exists", async () => { + const userId = "user-id-123"; + const ban = UserBanMother.create(); + spyOn(repository, "findActiveBanByUserId").mockResolvedValue(ban); + const result = await userGetActiveBan.execute(userId); + expect(repository.findActiveBanByUserId).toHaveBeenCalledWith(userId); + expect(result).toBe(ban); + }); - it("Should return null if no ban is active", async () => { - const userId = "user-id-456"; - spyOn(repository, "findActiveBanByUserId").mockResolvedValue(null); - const result = await userGetActiveBan.execute(userId); - expect(result).toBeNull(); - }); -}); \ No newline at end of file + it("Should return null if no ban is active", async () => { + const userId = "user-id-456"; + spyOn(repository, "findActiveBanByUserId").mockResolvedValue(null); + const result = await userGetActiveBan.execute(userId); + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/modules/users/application/UserGetBanHistory.test.ts b/tests/unit/modules/users/application/UserGetBanHistory.test.ts index 9156681..8a0a1e7 100644 --- a/tests/unit/modules/users/application/UserGetBanHistory.test.ts +++ b/tests/unit/modules/users/application/UserGetBanHistory.test.ts @@ -4,29 +4,26 @@ import { UserBanRepository } from "../../../../../src/modules/user/domain/UserBa import { UserBanMother } from "../mothers/UserBanMother"; describe("UserGetBanHistory", () => { - let repository: UserBanRepository; - let userGetBanHistory: UserGetBanHistory; + let repository: UserBanRepository; + let userGetBanHistory: UserGetBanHistory; - beforeEach(() => { - repository = { - banUser: async () => undefined, - findActiveBanByUserId: async () => null, - unbanUser: async () => undefined, - getBansByUserId: async () => [], - finishActiveBan: async () => undefined, - } - userGetBanHistory = new UserGetBanHistory(repository); - }); + beforeEach(() => { + repository = { + banUser: async () => undefined, + findActiveBanByUserId: async () => null, + unbanUser: async () => undefined, + getBansByUserId: async () => [], + finishActiveBan: async () => undefined, + }; + userGetBanHistory = new UserGetBanHistory(repository); + }); - it("Should return the user's ban history", async () => { - const userId = "user-id-123"; - const bans = [ - UserBanMother.create(), - UserBanMother.create(), - ]; - spyOn(repository, "getBansByUserId").mockResolvedValue(bans); - const result = await userGetBanHistory.execute(userId); - expect(repository.getBansByUserId).toHaveBeenCalledWith(userId); - expect(result).toBe(bans); - }); -}); \ No newline at end of file + it("Should return the user's ban history", async () => { + const userId = "user-id-123"; + const bans = [UserBanMother.create(), UserBanMother.create()]; + spyOn(repository, "getBansByUserId").mockResolvedValue(bans); + const result = await userGetBanHistory.execute(userId); + expect(repository.getBansByUserId).toHaveBeenCalledWith(userId); + expect(result).toBe(bans); + }); +}); diff --git a/tests/unit/modules/users/application/UserRegister.test.ts b/tests/unit/modules/users/application/UserRegister.test.ts index 4244cb9..6789fd3 100644 --- a/tests/unit/modules/users/application/UserRegister.test.ts +++ b/tests/unit/modules/users/application/UserRegister.test.ts @@ -49,7 +49,10 @@ describe("UserRegister", () => { it("registers a new user with a strong password and returns a token", async () => { const repositoryCreateSpy = spyOn(repository, "create"); - const result = (await userRegister.register(request)) as { token: string; gamePassword: string }; + const result = (await userRegister.register(request)) as { + token: string; + gamePassword: string; + }; const createdUser = repositoryCreateSpy.mock.calls[0][0] as User; expect(typeof createdUser.securePassword).toBe("string"); @@ -83,13 +86,17 @@ describe("UserRegister", () => { }); it("rejects a weak password that does not meet the policy", async () => { - expect(userRegister.register({ ...request, password: "weak" })).rejects.toThrow(InvalidArgumentError); + expect(userRegister.register({ ...request, password: "weak" })).rejects.toThrow( + InvalidArgumentError, + ); }); it("errors if the user already exists", async () => { spyOn(repository, "findByEmailOrUsername").mockResolvedValue(UserMother.create()); expect(userRegister.register(request)).rejects.toThrow( - new ConflictError(`User with email ${request.email} or username ${request.username} already exists`), + new ConflictError( + `User with email ${request.email} or username ${request.username} already exists`, + ), ); }); }); diff --git a/tests/unit/modules/users/application/UserUnbanUser.test.ts b/tests/unit/modules/users/application/UserUnbanUser.test.ts index c31b7c0..d0e2f27 100644 --- a/tests/unit/modules/users/application/UserUnbanUser.test.ts +++ b/tests/unit/modules/users/application/UserUnbanUser.test.ts @@ -3,25 +3,25 @@ import { UserUnbanUser } from "../../../../../src/modules/user/application/UserU import { UserBanRepository } from "../../../../../src/modules/user/domain/UserBanRepository"; describe("UserUnbanUser", () => { - let repository: UserBanRepository; - let userUnbanUser: UserUnbanUser; + let repository: UserBanRepository; + let userUnbanUser: UserUnbanUser; - beforeEach(() => { - repository = { - banUser: async () => undefined, - findActiveBanByUserId: async () => null, - unbanUser: async () => undefined, - getBansByUserId: async () => [], - finishActiveBan: async () => undefined, - } - userUnbanUser = new UserUnbanUser(repository); - }); + beforeEach(() => { + repository = { + banUser: async () => undefined, + findActiveBanByUserId: async () => null, + unbanUser: async () => undefined, + getBansByUserId: async () => [], + finishActiveBan: async () => undefined, + }; + userUnbanUser = new UserUnbanUser(repository); + }); - it("Should unban (expire ban) by calling unbanUser in the repository", async () => { - const banId = "ban-id-123"; - const repositoryUnbanUserSpy = spyOn(repository, "unbanUser") - await userUnbanUser.execute(banId); - expect(repositoryUnbanUserSpy).toHaveBeenCalledTimes(1); - expect(repositoryUnbanUserSpy).toHaveBeenCalledWith(banId); - }); -}); \ No newline at end of file + it("Should unban (expire ban) by calling unbanUser in the repository", async () => { + const banId = "ban-id-123"; + const repositoryUnbanUserSpy = spyOn(repository, "unbanUser"); + await userUnbanUser.execute(banId); + expect(repositoryUnbanUserSpy).toHaveBeenCalledTimes(1); + expect(repositoryUnbanUserSpy).toHaveBeenCalledWith(banId); + }); +}); diff --git a/tests/unit/modules/users/application/UserUpgradePassword.test.ts b/tests/unit/modules/users/application/UserUpgradePassword.test.ts index 9163d7c..c1e57f7 100644 --- a/tests/unit/modules/users/application/UserUpgradePassword.test.ts +++ b/tests/unit/modules/users/application/UserUpgradePassword.test.ts @@ -52,7 +52,9 @@ describe("UserUpgradePassword", () => { spyOn(repository, "findById").mockResolvedValue(migratedUser); const updateSpy = spyOn(repository, "update"); - expect(upgrader.upgrade({ userId: migratedUser.id, password: "yugi2024" })).rejects.toThrow(ConflictError); + expect(upgrader.upgrade({ userId: migratedUser.id, password: "yugi2024" })).rejects.toThrow( + ConflictError, + ); expect(updateSpy).not.toHaveBeenCalled(); }); @@ -60,12 +62,16 @@ describe("UserUpgradePassword", () => { const user = UserMother.create({ securePassword: null }); spyOn(repository, "findById").mockResolvedValue(user); - expect(upgrader.upgrade({ userId: user.id, password: "weak" })).rejects.toThrow(InvalidArgumentError); + expect(upgrader.upgrade({ userId: user.id, password: "weak" })).rejects.toThrow( + InvalidArgumentError, + ); }); it("throws when the user does not exist", async () => { spyOn(repository, "findById").mockResolvedValue(null); - expect(upgrader.upgrade({ userId: "missing-user-id", password: "yugi2024" })).rejects.toThrow(NotFoundError); + expect(upgrader.upgrade({ userId: "missing-user-id", password: "yugi2024" })).rejects.toThrow( + NotFoundError, + ); }); }); diff --git a/tests/unit/modules/users/application/UserUsernameUpdater.test.ts b/tests/unit/modules/users/application/UserUsernameUpdater.test.ts index 98394e4..c6f1a8d 100644 --- a/tests/unit/modules/users/application/UserUsernameUpdater.test.ts +++ b/tests/unit/modules/users/application/UserUsernameUpdater.test.ts @@ -23,7 +23,7 @@ describe("User UsernameUpdater", () => { update: async () => undefined, updateParticipantId: async () => undefined, findByParticipantId: async () => null, - } + }; user = UserMother.create(); spyOn(repository, "findById").mockResolvedValue(user); userUsernameUpdater = new UserUsernameUpdater(repository); diff --git a/tests/unit/modules/users/domain/ResetPasswordLinkBuilder.test.ts b/tests/unit/modules/users/domain/ResetPasswordLinkBuilder.test.ts index 347604a..18fc109 100644 --- a/tests/unit/modules/users/domain/ResetPasswordLinkBuilder.test.ts +++ b/tests/unit/modules/users/domain/ResetPasswordLinkBuilder.test.ts @@ -5,8 +5,14 @@ import { ResetPasswordLinkBuilder } from "../../../../../src/modules/user/domain describe("ResetPasswordLinkBuilder", () => { const builder = new ResetPasswordLinkBuilder( [ - { origin: "https://evolutionygo.com", template: "https://evolutionygo.com/reset-password?token={token}" }, - { origin: "https://evoduel.com", template: "https://evoduel.com/#/reset-account-password?token={token}" }, + { + origin: "https://evolutionygo.com", + template: "https://evolutionygo.com/reset-password?token={token}", + }, + { + origin: "https://evoduel.com", + template: "https://evoduel.com/#/reset-account-password?token={token}", + }, ], "https://evolutionygo.com/reset-password?token={token}", ); @@ -18,13 +24,21 @@ describe("ResetPasswordLinkBuilder", () => { }); it("derives the origin from the referer when the origin header is absent", () => { - const link = builder.build({ origin: null, referer: "https://evolutionygo.com/", token: "abc" }); + const link = builder.build({ + origin: null, + referer: "https://evolutionygo.com/", + token: "abc", + }); expect(link).toBe("https://evolutionygo.com/reset-password?token=abc"); }); it("prefers the origin header over the referer", () => { - const link = builder.build({ origin: "https://evoduel.com", referer: "https://evolutionygo.com/", token: "abc" }); + const link = builder.build({ + origin: "https://evoduel.com", + referer: "https://evolutionygo.com/", + token: "abc", + }); expect(link).toBe("https://evoduel.com/#/reset-account-password?token=abc"); }); diff --git a/tests/unit/modules/users/mothers/UserBanMother.ts b/tests/unit/modules/users/mothers/UserBanMother.ts index e4764cd..ba2cc6f 100644 --- a/tests/unit/modules/users/mothers/UserBanMother.ts +++ b/tests/unit/modules/users/mothers/UserBanMother.ts @@ -3,16 +3,16 @@ import { faker } from "@faker-js/faker"; import { UserBan } from "../../../../../src/modules/user/domain/UserBan"; export class UserBanMother { - static create(params?: Partial): UserBan { - return UserBan.create({ - id: faker.string.uuid(), - userId: faker.string.uuid(), - reason: faker.lorem.sentence(), - bannedAt: faker.date.recent(), - bannedBy: faker.string.uuid(), - createdAt: faker.date.recent(), - updatedAt: faker.date.recent(), - ...params, - }); - } -} \ No newline at end of file + static create(params?: Partial): UserBan { + return UserBan.create({ + id: faker.string.uuid(), + userId: faker.string.uuid(), + reason: faker.lorem.sentence(), + bannedAt: faker.date.recent(), + bannedBy: faker.string.uuid(), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + ...params, + }); + } +} diff --git a/tests/unit/modules/users/mothers/UserRegisterRequestMother.ts b/tests/unit/modules/users/mothers/UserRegisterRequestMother.ts index f16fe7f..fcf0eb5 100644 --- a/tests/unit/modules/users/mothers/UserRegisterRequestMother.ts +++ b/tests/unit/modules/users/mothers/UserRegisterRequestMother.ts @@ -1,7 +1,9 @@ import { faker } from "@faker-js/faker"; export class UserRegisterRequestMother { - static create(params?: Partial<{ id: string; email: string; username: string; password: string }>): { + static create( + params?: Partial<{ id: string; email: string; username: string; password: string }>, + ): { id: string; email: string; username: string; diff --git a/tests/unit/modules/users/mothers/UserUsernameUpdaterRequestMother.ts b/tests/unit/modules/users/mothers/UserUsernameUpdaterRequestMother.ts index 8403d4b..2c007e9 100644 --- a/tests/unit/modules/users/mothers/UserUsernameUpdaterRequestMother.ts +++ b/tests/unit/modules/users/mothers/UserUsernameUpdaterRequestMother.ts @@ -1,7 +1,10 @@ import { faker } from "@faker-js/faker"; export class UserUsernameUpdaterRequestMother { - static create(params?: Partial<{ id: string; username: string }>): { id: string; username: string } { + static create(params?: Partial<{ id: string; username: string }>): { + id: string; + username: string; + } { return { id: faker.string.uuid(), username: faker.string.sample({ min: 1, max: 14 }), diff --git a/tsconfig.json b/tsconfig.json index 30960c9..fdb3281 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,28 +1,20 @@ { - "compilerOptions": { - "baseUrl": ".", - "outDir": "./build", - "target": "ES2021", - "module": "ES2022", - "moduleResolution": "node", - "types": [ - "bun-types" - ], - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "strict": false, - "strictNullChecks": true - }, - "include": [ - "src/**/*", - "tests/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file + "compilerOptions": { + "baseUrl": ".", + "outDir": "./build", + "target": "ES2021", + "module": "ES2022", + "moduleResolution": "node", + "types": ["bun-types"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strict": false, + "strictNullChecks": true + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +}