From 3525632ca3973a90b8031c03bd2541531a0d7779 Mon Sep 17 00:00:00 2001 From: Best-Web Date: Thu, 22 Oct 2026 11:38:01 +0000 Subject: [PATCH] feat: add daily login rewards calendar (#462) - Add LoginRewardCalendar entity (configurable cycle length, resetOnMissDays, JSONB rewardDays array) - Add PlayerLoginRewardClaim entity with unique (userId, calendarDate) constraint to enforce one claim per calendar day - Add PlayerLoginStreak entity to track each player's cycle position and last claim date - Implement DailyLoginRewardsService with: - Timezone-safe day boundaries via Intl.DateTimeFormat - Idempotent claimDailyReward (returns alreadyClaimed on duplicate) - Configurable missed-day detection and cycle reset logic - Calendar CRUD (create, update, list) - getStatus and getClaimHistory endpoints - Add DailyLoginRewardsController with JWT-protected player routes - Register DailyLoginRewardsModule in AppModule - 29 unit tests: all passing, lint clean, zero TS errors Closes #462 --- src/app.module.ts | 2 + .../daily-login-rewards.controller.ts | 113 ++++ .../daily-login-rewards.module.ts | 23 + .../daily-login-rewards.service.spec.ts | 623 ++++++++++++++++++ .../daily-login-rewards.service.ts | 360 ++++++++++ .../dto/daily-login-rewards.dto.ts | 119 ++++ .../entities/login-reward-calendar.entity.ts | 50 ++ .../player-login-reward-claim.entity.ts | 45 ++ .../entities/player-login-streak.entity.ts | 46 ++ 9 files changed, 1381 insertions(+) create mode 100644 src/daily-login-rewards/daily-login-rewards.controller.ts create mode 100644 src/daily-login-rewards/daily-login-rewards.module.ts create mode 100644 src/daily-login-rewards/daily-login-rewards.service.spec.ts create mode 100644 src/daily-login-rewards/daily-login-rewards.service.ts create mode 100644 src/daily-login-rewards/dto/daily-login-rewards.dto.ts create mode 100644 src/daily-login-rewards/entities/login-reward-calendar.entity.ts create mode 100644 src/daily-login-rewards/entities/player-login-reward-claim.entity.ts create mode 100644 src/daily-login-rewards/entities/player-login-streak.entity.ts diff --git a/src/app.module.ts b/src/app.module.ts index c985e77..7dbfc27 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CacheWarmingModule } from './cache-warming/cache-warming.module'; +import { DailyLoginRewardsModule } from './daily-login-rewards/daily-login-rewards.module'; import { CacheJob } from './cache-warming/entities/cache-job.entity'; import { Metric } from './cache-warming/entities/metric.entity'; import { PreloadData } from './cache-warming/entities/preload-data.entity'; @@ -51,6 +52,7 @@ import { SubscriptionsModule } from './subscriptions/subscriptions.module'; }), ScheduleModule.forRoot(), CacheWarmingModule, + DailyLoginRewardsModule, EventsModule, JobsModule, CdnModule, diff --git a/src/daily-login-rewards/daily-login-rewards.controller.ts b/src/daily-login-rewards/daily-login-rewards.controller.ts new file mode 100644 index 0000000..ef941b3 --- /dev/null +++ b/src/daily-login-rewards/daily-login-rewards.controller.ts @@ -0,0 +1,113 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ActiveUser } from '../auth/decorators/active-user.decorator'; +import { DailyLoginRewardsService } from './daily-login-rewards.service'; +import { + ClaimDailyRewardDto, + CreateCalendarDto, + UpdateCalendarDto, +} from './dto/daily-login-rewards.dto'; + +@Controller('daily-login-rewards') +export class DailyLoginRewardsController { + constructor( + private readonly dailyLoginRewardsService: DailyLoginRewardsService, + ) {} + + // ─── Calendar configuration (admin / service-to-service) ───────────────── + + /** + * POST /daily-login-rewards/calendars + * Creates a new reward calendar configuration. + */ + @Post('calendars') + createCalendar(@Body() dto: CreateCalendarDto) { + return this.dailyLoginRewardsService.createCalendar(dto); + } + + /** + * PATCH /daily-login-rewards/calendars/:calendarId + * Updates an existing calendar configuration. + */ + @Patch('calendars/:calendarId') + updateCalendar( + @Param('calendarId') calendarId: string, + @Body() dto: UpdateCalendarDto, + ) { + return this.dailyLoginRewardsService.updateCalendar(calendarId, dto); + } + + /** + * GET /daily-login-rewards/calendars + * Lists all calendar configurations. + */ + @Get('calendars') + findAllCalendars() { + return this.dailyLoginRewardsService.findAllCalendars(); + } + + // ─── Player endpoints (authenticated) ──────────────────────────────────── + + /** + * GET /daily-login-rewards/calendars/:calendarId/status + * Returns the authenticated player's current position in the reward calendar, + * including whether they have already claimed today. + */ + @Get('calendars/:calendarId/status') + @UseGuards(JwtAuthGuard) + getStatus( + @ActiveUser() user: { userId?: string; id?: string }, + @Param('calendarId') calendarId: string, + @Body() body: { timezone?: string }, + ) { + const userId = user?.userId ?? user?.id ?? ''; + return this.dailyLoginRewardsService.getStatus( + userId, + calendarId, + body?.timezone, + ); + } + + /** + * POST /daily-login-rewards/calendars/:calendarId/claim + * Claims today's login reward for the authenticated player. + * Idempotent: returns the existing claim when called multiple times on the + * same calendar day. + */ + @Post('calendars/:calendarId/claim') + @UseGuards(JwtAuthGuard) + claimDailyReward( + @ActiveUser() user: { userId?: string; id?: string }, + @Param('calendarId') calendarId: string, + @Body() dto: ClaimDailyRewardDto, + ) { + const userId = user?.userId ?? user?.id ?? ''; + return this.dailyLoginRewardsService.claimDailyReward( + userId, + calendarId, + dto, + ); + } + + /** + * GET /daily-login-rewards/calendars/:calendarId/history + * Returns the authenticated player's full claim history for the given calendar. + */ + @Get('calendars/:calendarId/history') + @UseGuards(JwtAuthGuard) + getClaimHistory( + @ActiveUser() user: { userId?: string; id?: string }, + @Param('calendarId') calendarId: string, + ) { + const userId = user?.userId ?? user?.id ?? ''; + return this.dailyLoginRewardsService.getClaimHistory(userId, calendarId); + } +} diff --git a/src/daily-login-rewards/daily-login-rewards.module.ts b/src/daily-login-rewards/daily-login-rewards.module.ts new file mode 100644 index 0000000..2d53f2e --- /dev/null +++ b/src/daily-login-rewards/daily-login-rewards.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { LoginRewardCalendar } from './entities/login-reward-calendar.entity'; +import { PlayerLoginRewardClaim } from './entities/player-login-reward-claim.entity'; +import { PlayerLoginStreak } from './entities/player-login-streak.entity'; +import { DailyLoginRewardsController } from './daily-login-rewards.controller'; +import { DailyLoginRewardsService } from './daily-login-rewards.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + LoginRewardCalendar, + PlayerLoginRewardClaim, + PlayerLoginStreak, + ]), + AuthModule, + ], + controllers: [DailyLoginRewardsController], + providers: [DailyLoginRewardsService], + exports: [DailyLoginRewardsService], +}) +export class DailyLoginRewardsModule {} diff --git a/src/daily-login-rewards/daily-login-rewards.service.spec.ts b/src/daily-login-rewards/daily-login-rewards.service.spec.ts new file mode 100644 index 0000000..765ed32 --- /dev/null +++ b/src/daily-login-rewards/daily-login-rewards.service.spec.ts @@ -0,0 +1,623 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import * as ServiceModule from './daily-login-rewards.service'; +import { + DailyLoginRewardsService, + daysBetween, + todayInTimezone, +} from './daily-login-rewards.service'; +import { LoginRewardCalendar } from './entities/login-reward-calendar.entity'; +import { PlayerLoginRewardClaim } from './entities/player-login-reward-claim.entity'; +import { PlayerLoginStreak } from './entities/player-login-streak.entity'; + +// ─── Mock factory ──────────────────────────────────────────────────────────── + +const mockRepo = () => ({ + create: jest.fn((d: object) => ({ ...d })), + save: jest.fn((d: object & { id?: string }) => + Promise.resolve({ ...d, id: d.id ?? 'generated-id' }), + ), + findOne: jest.fn(), + find: jest.fn(), + count: jest.fn(), +}); + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const CALENDAR_ID = 'cal-1'; +const USER_ID = 'user-1'; + +const makeCalendar = ( + overrides: Partial = {}, +): LoginRewardCalendar => ({ + id: CALENDAR_ID, + name: 'default', + cycleLength: 7, + resetOnMissDays: 1, + isActive: true, + rewardDays: [ + { day: 1, label: 'Day 1', reward: { coins: 10 } }, + { day: 2, label: 'Day 2', reward: { coins: 20 } }, + { day: 3, label: 'Day 3', reward: { coins: 30 } }, + { day: 7, label: 'Day 7 Bonus', reward: { coins: 100, item: 'gem' } }, + ], + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...overrides, +}); + +const makeStreak = ( + overrides: Partial = {}, +): PlayerLoginStreak => ({ + id: 'streak-1', + userId: USER_ID, + calendarId: CALENDAR_ID, + lastClaimDate: null, + currentDay: 1, + totalClaims: 0, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...overrides, +}); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('DailyLoginRewardsService', () => { + let service: DailyLoginRewardsService; + let calendarRepo: ReturnType; + let claimRepo: ReturnType; + let streakRepo: ReturnType; + + beforeEach(async () => { + calendarRepo = mockRepo(); + claimRepo = mockRepo(); + streakRepo = mockRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DailyLoginRewardsService, + { + provide: getRepositoryToken(LoginRewardCalendar), + useValue: calendarRepo, + }, + { + provide: getRepositoryToken(PlayerLoginRewardClaim), + useValue: claimRepo, + }, + { + provide: getRepositoryToken(PlayerLoginStreak), + useValue: streakRepo, + }, + ], + }).compile(); + + service = module.get(DailyLoginRewardsService); + }); + + // ─── Pure helpers ────────────────────────────────────────────────────────── + + describe('todayInTimezone (pure helper)', () => { + it('returns a YYYY-MM-DD formatted string', () => { + const result = todayInTimezone('UTC'); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('falls back to UTC for an invalid timezone', () => { + const utcResult = todayInTimezone('UTC'); + const invalidResult = todayInTimezone('Not/A/Timezone'); + expect(invalidResult).toBe(utcResult); + }); + + it('accepts valid IANA timezone strings', () => { + expect(() => todayInTimezone('America/New_York')).not.toThrow(); + expect(() => todayInTimezone('Asia/Tokyo')).not.toThrow(); + expect(() => todayInTimezone('Europe/London')).not.toThrow(); + }); + }); + + describe('daysBetween (pure helper)', () => { + it('returns 0 for the same date', () => { + expect(daysBetween('2026-10-01', '2026-10-01')).toBe(0); + }); + + it('returns 1 for consecutive days', () => { + expect(daysBetween('2026-10-01', '2026-10-02')).toBe(1); + }); + + it('returns the correct count across a month boundary', () => { + expect(daysBetween('2026-01-31', '2026-02-01')).toBe(1); + expect(daysBetween('2026-01-01', '2026-02-01')).toBe(31); + }); + + it('is symmetric (order does not matter)', () => { + expect(daysBetween('2026-10-05', '2026-10-01')).toBe( + daysBetween('2026-10-01', '2026-10-05'), + ); + }); + + it('handles a leap-year day', () => { + // 2028 is a leap year: Feb has 29 days, so Feb 28 → Mar 1 = 2 days + expect(daysBetween('2028-02-28', '2028-03-01')).toBe(2); + }); + }); + + // ─── createCalendar ──────────────────────────────────────────────────────── + + describe('createCalendar', () => { + it('creates and returns a saved calendar', async () => { + const dto = { + name: 'test', + cycleLength: 7, + rewardDays: [{ day: 1, label: 'Day 1', reward: { coins: 10 } }], + }; + calendarRepo.save.mockResolvedValue({ ...dto, id: 'new-id' }); + + const result = await service.createCalendar(dto); + + expect(calendarRepo.create).toHaveBeenCalled(); + expect(calendarRepo.save).toHaveBeenCalled(); + expect(result.id).toBe('new-id'); + }); + + it('throws BadRequestException when a rewardDay.day exceeds cycleLength', async () => { + await expect( + service.createCalendar({ + name: 'bad', + cycleLength: 5, + rewardDays: [{ day: 6, label: 'Out of bounds', reward: {} }], + }), + ).rejects.toThrow(BadRequestException); + }); + + it('defaults resetOnMissDays to 1 when not supplied', async () => { + calendarRepo.save.mockResolvedValue({ id: 'c1', resetOnMissDays: 1 } as { + id: string; + }); + + await service.createCalendar({ + name: 'def', + cycleLength: 7, + rewardDays: [], + }); + + const created = calendarRepo.create.mock.calls[0][0] as { + resetOnMissDays: number; + }; + expect(created.resetOnMissDays).toBe(1); + }); + }); + + // ─── claimDailyReward – first claim ─────────────────────────────────────── + + describe('claimDailyReward – first ever claim', () => { + const today = '2026-10-22'; + let todaySpy: jest.SpyInstance; + + beforeEach(() => { + todaySpy = jest + .spyOn(ServiceModule, 'todayInTimezone') + .mockReturnValue(today); + + calendarRepo.findOne.mockResolvedValue(makeCalendar()); + streakRepo.findOne.mockResolvedValue(null); + streakRepo.save.mockImplementation((s: PlayerLoginStreak) => + Promise.resolve({ ...s, id: 'streak-1' }), + ); + claimRepo.findOne.mockResolvedValue(null); + claimRepo.count.mockResolvedValue(0); + claimRepo.save.mockImplementation((c: PlayerLoginRewardClaim) => + Promise.resolve({ ...c, id: 'claim-1' }), + ); + }); + + afterEach(() => todaySpy.mockRestore()); + + it('returns success with dayNumber 1 and the day-1 reward', async () => { + const result = await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + expect(result.success).toBe(true); + expect(result.alreadyClaimed).toBe(false); + expect(result.cycleReset).toBe(false); + expect(result.dayNumber).toBe(1); + expect(result.reward).toEqual({ coins: 10 }); + expect(result.totalClaims).toBe(1); + }); + + it('persists the claim with calendarDate = today', async () => { + await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + const savedClaim = claimRepo.save.mock.calls[0][0] as { + calendarDate: string; + dayNumber: number; + userId: string; + }; + expect(savedClaim.calendarDate).toBe(today); + expect(savedClaim.dayNumber).toBe(1); + expect(savedClaim.userId).toBe(USER_ID); + }); + + it('increments totalClaims on the streak', async () => { + await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + const lastStreakSave = streakRepo.save.mock.calls[ + streakRepo.save.mock.calls.length - 1 + ][0] as { totalClaims: number; lastClaimDate: string }; + expect(lastStreakSave.totalClaims).toBe(1); + expect(lastStreakSave.lastClaimDate).toBe(today); + }); + }); + + // ─── claimDailyReward – idempotency ─────────────────────────────────────── + + describe('claimDailyReward – idempotency (already claimed today)', () => { + const today = '2026-10-22'; + let todaySpy: jest.SpyInstance; + + beforeEach(() => { + todaySpy = jest + .spyOn(ServiceModule, 'todayInTimezone') + .mockReturnValue(today); + + calendarRepo.findOne.mockResolvedValue(makeCalendar()); + claimRepo.findOne.mockResolvedValue({ + id: 'existing-claim', + userId: USER_ID, + calendarId: CALENDAR_ID, + calendarDate: today, + dayNumber: 1, + rewardSnapshot: { coins: 10 }, + }); + streakRepo.findOne.mockResolvedValue( + makeStreak({ lastClaimDate: today, currentDay: 1, totalClaims: 1 }), + ); + }); + + afterEach(() => todaySpy.mockRestore()); + + it('returns alreadyClaimed: true without saving a new claim', async () => { + const result = await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + expect(result.alreadyClaimed).toBe(true); + expect(result.success).toBe(true); + expect(result.dayNumber).toBe(1); + expect(claimRepo.save).not.toHaveBeenCalled(); + }); + }); + + // ─── claimDailyReward – consecutive day advance ─────────────────────────── + + describe('claimDailyReward – consecutive day advances the cycle', () => { + const yesterday = '2026-10-21'; + const today = '2026-10-22'; + let todaySpy: jest.SpyInstance; + + beforeEach(() => { + todaySpy = jest + .spyOn(ServiceModule, 'todayInTimezone') + .mockReturnValue(today); + + calendarRepo.findOne.mockResolvedValue(makeCalendar()); + claimRepo.findOne.mockResolvedValue(null); + claimRepo.count.mockResolvedValue(0); + claimRepo.save.mockImplementation((c: PlayerLoginRewardClaim) => + Promise.resolve({ ...c, id: 'claim-2' }), + ); + streakRepo.findOne.mockResolvedValue( + makeStreak({ lastClaimDate: yesterday, currentDay: 1, totalClaims: 1 }), + ); + streakRepo.save.mockImplementation((s: PlayerLoginStreak) => + Promise.resolve({ ...s }), + ); + }); + + afterEach(() => todaySpy.mockRestore()); + + it('advances to day 2 when last claim was yesterday', async () => { + const result = await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + expect(result.dayNumber).toBe(2); + expect(result.cycleReset).toBe(false); + expect(result.reward).toEqual({ coins: 20 }); + }); + }); + + // ─── claimDailyReward – cycle wraps around ──────────────────────────────── + + describe('claimDailyReward – cycle wraps at cycleLength', () => { + const yesterday = '2026-10-21'; + const today = '2026-10-22'; + let todaySpy: jest.SpyInstance; + + beforeEach(() => { + todaySpy = jest + .spyOn(ServiceModule, 'todayInTimezone') + .mockReturnValue(today); + + calendarRepo.findOne.mockResolvedValue(makeCalendar({ cycleLength: 7 })); + claimRepo.findOne.mockResolvedValue(null); + claimRepo.count.mockResolvedValue(0); + claimRepo.save.mockImplementation((c: PlayerLoginRewardClaim) => + Promise.resolve({ ...c, id: 'claim-wrap' }), + ); + // Player is on day 7 (last day of cycle) + streakRepo.findOne.mockResolvedValue( + makeStreak({ lastClaimDate: yesterday, currentDay: 7, totalClaims: 6 }), + ); + streakRepo.save.mockImplementation((s: PlayerLoginStreak) => + Promise.resolve({ ...s }), + ); + }); + + afterEach(() => todaySpy.mockRestore()); + + it('wraps back to day 1 after day 7', async () => { + const result = await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + expect(result.dayNumber).toBe(1); + expect(result.cycleReset).toBe(false); + }); + }); + + // ─── claimDailyReward – missed day resets cycle ─────────────────────────── + + describe('claimDailyReward – missed day resets the cycle', () => { + const twoDaysAgo = '2026-10-20'; + const today = '2026-10-22'; + let todaySpy: jest.SpyInstance; + + beforeEach(() => { + todaySpy = jest + .spyOn(ServiceModule, 'todayInTimezone') + .mockReturnValue(today); + + // resetOnMissDays: 1 — any missed day triggers reset + calendarRepo.findOne.mockResolvedValue( + makeCalendar({ resetOnMissDays: 1 }), + ); + claimRepo.findOne.mockResolvedValue(null); + claimRepo.count.mockResolvedValue(0); + claimRepo.save.mockImplementation((c: PlayerLoginRewardClaim) => + Promise.resolve({ ...c, id: 'claim-reset' }), + ); + // Player was on day 5, last claimed 2 days ago → missed 1 day + streakRepo.findOne.mockResolvedValue( + makeStreak({ + lastClaimDate: twoDaysAgo, + currentDay: 5, + totalClaims: 4, + }), + ); + streakRepo.save.mockImplementation((s: PlayerLoginStreak) => + Promise.resolve({ ...s }), + ); + }); + + afterEach(() => todaySpy.mockRestore()); + + it('resets cycleReset: true and goes back to day 1', async () => { + const result = await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + expect(result.cycleReset).toBe(true); + expect(result.dayNumber).toBe(1); + }); + + it('saves the claim at day 1 after a reset', async () => { + await service.claimDailyReward(USER_ID, CALENDAR_ID, { timezone: 'UTC' }); + + const savedClaim = claimRepo.save.mock.calls[0][0] as { + dayNumber: number; + }; + expect(savedClaim.dayNumber).toBe(1); + }); + }); + + // ─── claimDailyReward – configurable resetOnMissDays ───────────────────── + + describe('claimDailyReward – resetOnMissDays > 1', () => { + const fiveDaysAgo = '2026-10-17'; + const today = '2026-10-22'; + let todaySpy: jest.SpyInstance; + + beforeEach(() => { + todaySpy = jest + .spyOn(ServiceModule, 'todayInTimezone') + .mockReturnValue(today); + }); + + afterEach(() => todaySpy.mockRestore()); + + it('does NOT reset when missed days < resetOnMissDays', async () => { + // resetOnMissDays: 5 — player missed 4 days (fiveDaysAgo → today = gap of 5, missed = 4) + calendarRepo.findOne.mockResolvedValue( + makeCalendar({ resetOnMissDays: 5 }), + ); + claimRepo.findOne.mockResolvedValue(null); + claimRepo.count.mockResolvedValue(0); + claimRepo.save.mockImplementation((c: PlayerLoginRewardClaim) => + Promise.resolve({ ...c, id: 'claim-no-reset' }), + ); + streakRepo.findOne.mockResolvedValue( + makeStreak({ + lastClaimDate: fiveDaysAgo, + currentDay: 3, + totalClaims: 2, + }), + ); + streakRepo.save.mockImplementation((s: PlayerLoginStreak) => + Promise.resolve({ ...s }), + ); + + const result = await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + expect(result.cycleReset).toBe(false); + expect(result.dayNumber).toBe(4); // advanced from 3 + }); + + it('DOES reset when missed days >= resetOnMissDays', async () => { + // resetOnMissDays: 3 — player missed 4 days → should reset + calendarRepo.findOne.mockResolvedValue( + makeCalendar({ resetOnMissDays: 3 }), + ); + claimRepo.findOne.mockResolvedValue(null); + claimRepo.count.mockResolvedValue(0); + claimRepo.save.mockImplementation((c: PlayerLoginRewardClaim) => + Promise.resolve({ ...c, id: 'claim-reset2' }), + ); + streakRepo.findOne.mockResolvedValue( + makeStreak({ + lastClaimDate: fiveDaysAgo, + currentDay: 3, + totalClaims: 2, + }), + ); + streakRepo.save.mockImplementation((s: PlayerLoginStreak) => + Promise.resolve({ ...s }), + ); + + const result = await service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'UTC', + }); + + expect(result.cycleReset).toBe(true); + expect(result.dayNumber).toBe(1); + }); + }); + + // ─── claimDailyReward – inactive calendar ───────────────────────────────── + + describe('claimDailyReward – inactive calendar', () => { + it('throws BadRequestException when calendar is not active', async () => { + calendarRepo.findOne.mockResolvedValue(makeCalendar({ isActive: false })); + + await expect( + service.claimDailyReward(USER_ID, CALENDAR_ID, { timezone: 'UTC' }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── claimDailyReward – invalid timezone ────────────────────────────────── + + describe('claimDailyReward – invalid timezone', () => { + it('throws BadRequestException for an invalid timezone string', async () => { + calendarRepo.findOne.mockResolvedValue(makeCalendar()); + + await expect( + service.claimDailyReward(USER_ID, CALENDAR_ID, { + timezone: 'Bad/Zone', + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── findCalendarOrThrow ─────────────────────────────────────────────────── + + describe('findCalendarOrThrow', () => { + it('returns the calendar when found', async () => { + calendarRepo.findOne.mockResolvedValue(makeCalendar()); + const result = await service.findCalendarOrThrow(CALENDAR_ID); + expect(result.id).toBe(CALENDAR_ID); + }); + + it('throws NotFoundException when calendar does not exist', async () => { + calendarRepo.findOne.mockResolvedValue(null); + await expect(service.findCalendarOrThrow('non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ─── getStatus ──────────────────────────────────────────────────────────── + + describe('getStatus', () => { + const today = '2026-10-22'; + let todaySpy: jest.SpyInstance; + + beforeEach(() => { + todaySpy = jest + .spyOn(ServiceModule, 'todayInTimezone') + .mockReturnValue(today); + }); + + afterEach(() => todaySpy.mockRestore()); + + it('reports alreadyClaimedToday: true when a claim exists for today', async () => { + calendarRepo.findOne.mockResolvedValue(makeCalendar()); + streakRepo.findOne.mockResolvedValue( + makeStreak({ lastClaimDate: today, currentDay: 1, totalClaims: 1 }), + ); + claimRepo.count.mockResolvedValue(1); + + const status = await service.getStatus(USER_ID, CALENDAR_ID, 'UTC'); + + expect(status.alreadyClaimedToday).toBe(true); + expect(status.currentDay).toBe(1); + expect(status.todayDate).toBe(today); + }); + + it('reports alreadyClaimedToday: false when no claim today', async () => { + calendarRepo.findOne.mockResolvedValue(makeCalendar()); + streakRepo.findOne.mockResolvedValue(null); + streakRepo.save.mockImplementation((s: PlayerLoginStreak) => + Promise.resolve({ ...s, id: 'new-streak' }), + ); + claimRepo.count.mockResolvedValue(0); + + const status = await service.getStatus(USER_ID, CALENDAR_ID, 'UTC'); + + expect(status.alreadyClaimedToday).toBe(false); + }); + + it('shows the correct nextReward for the next unclaimed day', async () => { + calendarRepo.findOne.mockResolvedValue(makeCalendar()); + streakRepo.findOne.mockResolvedValue( + makeStreak({ lastClaimDate: null, currentDay: 1, totalClaims: 0 }), + ); + claimRepo.count.mockResolvedValue(0); + + const status = await service.getStatus(USER_ID, CALENDAR_ID, 'UTC'); + + // Day 1 reward: { coins: 10 } + expect(status.nextReward?.reward).toEqual({ coins: 10 }); + }); + }); + + // ─── getClaimHistory ────────────────────────────────────────────────────── + + describe('getClaimHistory', () => { + it('returns claims ordered by claimedAt DESC', async () => { + const claims = [ + { id: 'c2', calendarDate: '2026-10-22', dayNumber: 2 }, + { id: 'c1', calendarDate: '2026-10-21', dayNumber: 1 }, + ]; + claimRepo.find.mockResolvedValue(claims); + + const result = await service.getClaimHistory(USER_ID, CALENDAR_ID); + + expect(claimRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: USER_ID, calendarId: CALENDAR_ID }, + order: { claimedAt: 'DESC' }, + }), + ); + expect(result).toHaveLength(2); + }); + }); +}); diff --git a/src/daily-login-rewards/daily-login-rewards.service.ts b/src/daily-login-rewards/daily-login-rewards.service.ts new file mode 100644 index 0000000..114b09c --- /dev/null +++ b/src/daily-login-rewards/daily-login-rewards.service.ts @@ -0,0 +1,360 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LoginRewardCalendar } from './entities/login-reward-calendar.entity'; +import { PlayerLoginRewardClaim } from './entities/player-login-reward-claim.entity'; +import { PlayerLoginStreak } from './entities/player-login-streak.entity'; +import { + CalendarStatusResponse, + ClaimDailyRewardDto, + ClaimRewardResponse, + CreateCalendarDto, + UpdateCalendarDto, +} from './dto/daily-login-rewards.dto'; + +// ─── Timezone helpers ──────────────────────────────────────────────────────── + +/** + * Returns the current wall-clock date in the given IANA timezone as a + * 'YYYY-MM-DD' string. Falls back to UTC when the timezone string is invalid. + */ +export function todayInTimezone(timezone = 'UTC'): string { + try { + // Intl.DateTimeFormat is available in V8 / Node.js and gives us a locale- + // aware formatter that handles DST automatically. + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + // en-CA locale produces 'YYYY-MM-DD' – stable and predictable. + return formatter.format(new Date()); + } catch { + // Invalid timezone → fall back to UTC + return todayInTimezone('UTC'); + } +} + +/** + * Returns the number of full calendar days between two 'YYYY-MM-DD' strings. + * The result is always non-negative (uses absolute difference). + */ +export function daysBetween(a: string, b: string): number { + const msA = new Date(a + 'T00:00:00Z').getTime(); + const msB = new Date(b + 'T00:00:00Z').getTime(); + return Math.round(Math.abs(msB - msA) / 86_400_000); +} + +// ─── Service ───────────────────────────────────────────────────────────────── + +@Injectable() +export class DailyLoginRewardsService { + private readonly logger = new Logger(DailyLoginRewardsService.name); + + constructor( + @InjectRepository(LoginRewardCalendar) + private readonly calendarRepo: Repository, + + @InjectRepository(PlayerLoginRewardClaim) + private readonly claimRepo: Repository, + + @InjectRepository(PlayerLoginStreak) + private readonly streakRepo: Repository, + ) {} + + // ─── Calendar configuration ─────────────────────────────────────────────── + + async createCalendar(dto: CreateCalendarDto): Promise { + const maxDay = Math.max(0, ...dto.rewardDays.map((d) => d.day)); + if (maxDay > dto.cycleLength) { + throw new BadRequestException( + `rewardDays contains day ${maxDay} which exceeds cycleLength ${dto.cycleLength}`, + ); + } + + const calendar = this.calendarRepo.create({ + name: dto.name, + cycleLength: dto.cycleLength, + resetOnMissDays: dto.resetOnMissDays ?? 1, + rewardDays: dto.rewardDays, + isActive: dto.isActive ?? true, + }); + + return this.calendarRepo.save(calendar); + } + + async updateCalendar( + calendarId: string, + dto: UpdateCalendarDto, + ): Promise { + const calendar = await this.findCalendarOrThrow(calendarId); + + if (dto.cycleLength !== undefined) calendar.cycleLength = dto.cycleLength; + if (dto.resetOnMissDays !== undefined) + calendar.resetOnMissDays = dto.resetOnMissDays; + if (dto.rewardDays !== undefined) calendar.rewardDays = dto.rewardDays; + if (dto.isActive !== undefined) calendar.isActive = dto.isActive; + + return this.calendarRepo.save(calendar); + } + + async findAllCalendars(): Promise { + return this.calendarRepo.find({ order: { createdAt: 'ASC' } }); + } + + async findCalendarOrThrow(calendarId: string): Promise { + const calendar = await this.calendarRepo.findOne({ + where: { id: calendarId }, + }); + if (!calendar) { + throw new NotFoundException( + `LoginRewardCalendar with id "${calendarId}" not found`, + ); + } + return calendar; + } + + // ─── Player status ──────────────────────────────────────────────────────── + + /** + * Returns the player's current position in the reward calendar and whether + * they have already claimed today's reward. + */ + async getStatus( + userId: string, + calendarId: string, + timezone = 'UTC', + ): Promise { + const calendar = await this.findCalendarOrThrow(calendarId); + + const streak = await this.getOrCreateStreak(userId, calendarId); + const today = todayInTimezone(timezone); + + const alreadyClaimedToday = await this.hasClaimedToday( + userId, + calendarId, + today, + ); + + // Determine the day number the player would claim next (after potential reset) + const nextDayNumber = this.resolveNextDayNumber(streak, calendar, today); + const nextRewardDay = + calendar.rewardDays.find((r) => r.day === nextDayNumber) ?? null; + + return { + calendarId: calendar.id, + calendarName: calendar.name, + cycleLength: calendar.cycleLength, + currentDay: streak.currentDay, + lastClaimDate: streak.lastClaimDate, + todayDate: today, + alreadyClaimedToday, + nextReward: nextRewardDay, + }; + } + + // ─── Claim ──────────────────────────────────────────────────────────────── + + /** + * Awards the daily login reward for today. + * + * Idempotent: calling this multiple times on the same calendar day returns + * the same claim result with `alreadyClaimed: true` on subsequent calls. + * + * Timezone-safe: "today" is determined from the supplied IANA timezone string + * so that a player logging in at 11 PM in their local timezone always receives + * the correct day's reward, regardless of the server's clock timezone. + */ + async claimDailyReward( + userId: string, + calendarId: string, + dto: ClaimDailyRewardDto = {}, + ): Promise { + const timezone = dto.timezone ?? 'UTC'; + + // Validate timezone early – todayInTimezone falls back silently, but we + // want to surface an invalid timezone to the caller. + this.validateTimezone(timezone); + + const calendar = await this.findCalendarOrThrow(calendarId); + + if (!calendar.isActive) { + throw new BadRequestException( + `Calendar "${calendar.name}" is not currently active`, + ); + } + + const today = todayInTimezone(timezone); + + // ── Idempotency check ──────────────────────────────────────────────── + const existingClaim = await this.claimRepo.findOne({ + where: { userId, calendarId, calendarDate: today }, + }); + + if (existingClaim) { + const streak = await this.getOrCreateStreak(userId, calendarId); + return { + success: true, + alreadyClaimed: true, + cycleReset: false, + dayNumber: existingClaim.dayNumber, + reward: existingClaim.rewardSnapshot, + totalClaims: streak.totalClaims, + }; + } + + // ── Streak & reset logic ───────────────────────────────────────────── + let streak = await this.getOrCreateStreak(userId, calendarId); + let cycleReset = false; + + if (streak.lastClaimDate !== null) { + const daysSinceLast = daysBetween(streak.lastClaimDate, today); + + if (daysSinceLast === 0) { + // Same day — already handled above; defensive guard + throw new ConflictException('Already claimed today'); + } + + const missedDays = daysSinceLast - 1; // days skipped between last and today + if (missedDays >= calendar.resetOnMissDays) { + // Player missed enough days → reset cycle + streak.currentDay = 1; + cycleReset = true; + this.logger.debug( + `Cycle reset for user ${userId} on calendar ${calendarId}: missed ${missedDays} day(s)`, + ); + } else { + // Advance to next day in cycle + streak.currentDay = (streak.currentDay % calendar.cycleLength) + 1; + } + } + // else: first ever claim – stays at day 1 + + const dayNumber = streak.currentDay; + + // ── Resolve reward ─────────────────────────────────────────────────── + const rewardDay = calendar.rewardDays.find((r) => r.day === dayNumber); + const reward: Record = rewardDay?.reward ?? {}; + + // ── Persist claim (unique constraint guards concurrent requests) ────── + const claim = this.claimRepo.create({ + userId, + calendarId, + calendarDate: today, + dayNumber, + rewardSnapshot: reward, + }); + + await this.claimRepo.save(claim); + + // ── Update streak ──────────────────────────────────────────────────── + streak.lastClaimDate = today; + streak.totalClaims += 1; + streak = await this.streakRepo.save(streak); + + this.logger.debug( + `User ${userId} claimed day ${dayNumber} on calendar ${calendarId} (date: ${today})`, + ); + + return { + success: true, + alreadyClaimed: false, + cycleReset, + dayNumber, + reward, + totalClaims: streak.totalClaims, + }; + } + + // ─── Claim history ──────────────────────────────────────────────────────── + + async getClaimHistory( + userId: string, + calendarId: string, + ): Promise { + return this.claimRepo.find({ + where: { userId, calendarId }, + order: { claimedAt: 'DESC' }, + }); + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private async getOrCreateStreak( + userId: string, + calendarId: string, + ): Promise { + let streak = await this.streakRepo.findOne({ + where: { userId, calendarId }, + }); + + if (!streak) { + streak = this.streakRepo.create({ + userId, + calendarId, + lastClaimDate: null, + currentDay: 1, + totalClaims: 0, + }); + streak = await this.streakRepo.save(streak); + } + + return streak; + } + + private async hasClaimedToday( + userId: string, + calendarId: string, + today: string, + ): Promise { + const count = await this.claimRepo.count({ + where: { userId, calendarId, calendarDate: today }, + }); + return count > 0; + } + + /** + * Computes what day number the player WOULD receive if they claimed right now, + * taking potential resets into account – used only for status display. + */ + private resolveNextDayNumber( + streak: PlayerLoginStreak, + calendar: LoginRewardCalendar, + today: string, + ): number { + if (streak.lastClaimDate === null) { + return 1; + } + + const daysSinceLast = daysBetween(streak.lastClaimDate, today); + + if (daysSinceLast === 0) { + // Already claimed today – next would be day after + return (streak.currentDay % calendar.cycleLength) + 1; + } + + const missedDays = daysSinceLast - 1; + if (missedDays >= calendar.resetOnMissDays) { + return 1; + } + + return (streak.currentDay % calendar.cycleLength) + 1; + } + + private validateTimezone(timezone: string): void { + try { + Intl.DateTimeFormat(undefined, { timeZone: timezone }); + } catch { + throw new BadRequestException( + `Invalid IANA timezone: "${timezone}". Use a valid timezone such as "UTC" or "America/New_York".`, + ); + } + } +} diff --git a/src/daily-login-rewards/dto/daily-login-rewards.dto.ts b/src/daily-login-rewards/dto/daily-login-rewards.dto.ts new file mode 100644 index 0000000..5779db1 --- /dev/null +++ b/src/daily-login-rewards/dto/daily-login-rewards.dto.ts @@ -0,0 +1,119 @@ +import { + IsArray, + IsBoolean, + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +// ─── Shared sub-DTOs ──────────────────────────────────────────────────────── + +export class RewardDayDto { + @IsInt() + @Min(1) + day: number; + + @IsString() + @IsNotEmpty() + label: string; + + @IsObject() + reward: Record; +} + +// ─── Calendar management ──────────────────────────────────────────────────── + +export class CreateCalendarDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsInt() + @Min(1) + @Max(365) + cycleLength: number; + + /** + * How many consecutive missed days trigger a cycle reset. + * Default 1 = any missed day resets the cycle. + */ + @IsOptional() + @IsInt() + @Min(1) + resetOnMissDays?: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RewardDayDto) + rewardDays: RewardDayDto[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateCalendarDto { + @IsOptional() + @IsInt() + @Min(1) + @Max(365) + cycleLength?: number; + + @IsOptional() + @IsInt() + @Min(1) + resetOnMissDays?: number; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RewardDayDto) + rewardDays?: RewardDayDto[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +// ─── Claim ────────────────────────────────────────────────────────────────── + +export class ClaimDailyRewardDto { + /** + * IANA timezone string (e.g. "America/New_York"). + * Defaults to "UTC" when omitted. + * Used to determine which calendar day "today" maps to for the player. + */ + @IsOptional() + @IsString() + timezone?: string; +} + +// ─── Response shapes (plain objects, no class-validator needed) ───────────── + +export interface CalendarStatusResponse { + calendarId: string; + calendarName: string; + cycleLength: number; + currentDay: number; + lastClaimDate: string | null; + todayDate: string; + alreadyClaimedToday: boolean; + nextReward: RewardDayDto | null; +} + +export interface ClaimRewardResponse { + success: boolean; + /** True when the player had already claimed today (idempotent) */ + alreadyClaimed: boolean; + /** True when missed days caused a cycle reset before this claim */ + cycleReset: boolean; + dayNumber: number; + reward: Record; + totalClaims: number; +} diff --git a/src/daily-login-rewards/entities/login-reward-calendar.entity.ts b/src/daily-login-rewards/entities/login-reward-calendar.entity.ts new file mode 100644 index 0000000..2cf7e42 --- /dev/null +++ b/src/daily-login-rewards/entities/login-reward-calendar.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export interface RewardDay { + /** 1-based day number within the calendar cycle */ + day: number; + /** Human-readable label, e.g. "Day 7 Bonus" */ + label: string; + /** Arbitrary reward payload – coins, items, XP, etc. */ + reward: Record; +} + +@Entity('login_reward_calendars') +export class LoginRewardCalendar { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** Unique name / slug for this calendar configuration (e.g. "default", "vip") */ + @Column({ type: 'varchar', length: 128, unique: true }) + name: string; + + /** Total days in one complete cycle (e.g. 28 for a 4-week calendar) */ + @Column({ name: 'cycle_length', type: 'int', default: 28 }) + cycleLength: number; + + /** + * Number of consecutive days a player must miss before the streak is reset + * back to day 1. A value of 1 means any single missed calendar day resets. + */ + @Column({ name: 'reset_on_miss_days', type: 'int', default: 1 }) + resetOnMissDays: number; + + /** Ordered array describing the reward offered on each day of the cycle */ + @Column({ name: 'reward_days', type: 'jsonb', default: [] }) + rewardDays: RewardDay[]; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' }) + updatedAt: Date; +} diff --git a/src/daily-login-rewards/entities/player-login-reward-claim.entity.ts b/src/daily-login-rewards/entities/player-login-reward-claim.entity.ts new file mode 100644 index 0000000..c5f8f6a --- /dev/null +++ b/src/daily-login-rewards/entities/player-login-reward-claim.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * One row per successful daily-login-reward claim. + * The combination (userId, calendarDate) is unique to enforce one-claim-per-day. + */ +@Entity('player_login_reward_claims') +@Index(['userId', 'calendarDate'], { unique: true }) +@Index(['userId', 'calendarId']) +export class PlayerLoginRewardClaim { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'calendar_id', type: 'uuid' }) + calendarId: string; + + /** + * The canonical calendar date on which this claim was made. + * Stored as DATE (no time) in the player's resolved timezone so that + * "today" is always unambiguous regardless of when the server clock is read. + * Format: 'YYYY-MM-DD' + */ + @Column({ name: 'calendar_date', type: 'date' }) + calendarDate: string; + + /** Which cycle day (1-based) was claimed */ + @Column({ name: 'day_number', type: 'int' }) + dayNumber: number; + + /** Snapshot of the reward that was granted */ + @Column({ name: 'reward_snapshot', type: 'jsonb', default: {} }) + rewardSnapshot: Record; + + @CreateDateColumn({ name: 'claimed_at', type: 'timestamp with time zone' }) + claimedAt: Date; +} diff --git a/src/daily-login-rewards/entities/player-login-streak.entity.ts b/src/daily-login-rewards/entities/player-login-streak.entity.ts new file mode 100644 index 0000000..931555c --- /dev/null +++ b/src/daily-login-rewards/entities/player-login-streak.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * Tracks where a player is in the reward calendar cycle. + * One row per (userId, calendarId) pair. + */ +@Entity('player_login_streaks') +@Index(['userId', 'calendarId'], { unique: true }) +export class PlayerLoginStreak { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'calendar_id', type: 'uuid' }) + calendarId: string; + + /** + * The last calendar date the player successfully claimed a reward. + * Format: 'YYYY-MM-DD' (null = never claimed) + */ + @Column({ name: 'last_claim_date', type: 'date', nullable: true }) + lastClaimDate: string | null; + + /** Current position in the cycle (1-based). Resets to 1 after a missed day. */ + @Column({ name: 'current_day', type: 'int', default: 1 }) + currentDay: number; + + /** How many total days this player has claimed in this calendar ever */ + @Column({ name: 'total_claims', type: 'int', default: 0 }) + totalClaims: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' }) + updatedAt: Date; +}