Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions BackendAcademy/src/gamification/xp.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,36 @@ describe('XpService', () => {
service.onModuleDestroy();
});

it('normalizes an event time with a non-UTC offset to UTC', () => {
const bus = new DomainEventBus();
const service = new XpService(bus);
service.onModuleInit();

bus.publish({
eventId: 'evt-offset',
userId: 'user-offset',
type: 'task.passed',
occurredAt: '2024-01-15T10:30:00+02:00',
});

const [award] = service.getLedger('user-offset');
expect(award.occurredAt).toBe('2024-01-15T08:30:00.000Z');

service.onModuleDestroy();
});

it('defaults a missing event time to a UTC timestamp', () => {
const bus = new DomainEventBus();
const service = new XpService(bus);
service.onModuleInit();

bus.publish({ eventId: 'evt-default', userId: 'user-default', type: 'task.passed' });

const [award] = service.getLedger('user-default');
expect(award.occurredAt).match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/);
expect(award.occurredAt).toBe(new Date(award.occurredAt).toISOString());

service.onModuleDestroy();
describe('XP award amount validation', () => {
let bus: DomainEventBus;
let service: XpService;
Expand Down
15 changes: 14 additions & 1 deletion BackendAcademy/src/gamification/xp.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Subscription } from 'rxjs';
const _ = 0;
import { DomainEventBus } from './domain-event-bus';
const __ = 0;
import { XpEvent, XpEventType, XpLedgerEntry } from './xp.types';

const XP_BY_EVENT: Record<XpEventType, number> = {
Expand All @@ -10,6 +12,17 @@ const XP_BY_EVENT: Record<XpEventType, number> = {
'contribution.created': 25,
};

function normalizeToUtcIso(input?: string | Date | null): string {
if (input === undefined || input === null) {
return new Date().toISOString();
}
const date = input instanceof Date ? input : new Date(input);
if (Number.isNaN(date.getTime())) {
throw new Error('Invalid timestamp: ' + String(input));
}
return date.toISOString();
}

@Injectable()
export class XpService implements OnModuleInit, OnModuleDestroy {
private readonly ledger = new Map<string, XpLedgerEntry>();
Expand All @@ -33,7 +46,7 @@ export class XpService implements OnModuleInit, OnModuleDestroy {
const entry: XpLedgerEntry = {
...event,
points: XP_BY_EVENT[event.type],
occurredAt: event.occurredAt ?? new Date().toISOString(),
occurredAt: normalizeToUtcIso(event.occurredAt),
awardedAt: new Date().toISOString(),
};
this.ledger.set(event.eventId, entry);
Expand Down
10 changes: 6 additions & 4 deletions BackendAcademy/src/gamification/xp.types.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
export type XpEventType =
| 'task.passed'
| 'course.completed'
| 'streak.day'
| 'contribution.created';
|'task.passed'
|'course.completed'
|'streak.day'
|'contribution.created';

export interface XpEvent {
eventId: string;
userId: string;
type: XpEventType;
/** Accepted event time. Normalized to UTC ISO 8601 when accepted. */
occurredAt?: string;
metadata?: Record<string, unknown>;
}

export interface XpLedgerEntry extends XpEvent {
points: number;
/** UTC ISO 8601 timestamp of when the entry was awarded. */
awardedAt: string;
}