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
2 changes: 1 addition & 1 deletion BackendAcademy/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const config: Config = {
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
'^.+\\.(t|j)s$': ['ts-jest', { isolatedModules: true }],
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
Expand Down
6 changes: 6 additions & 0 deletions BackendAcademy/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export enum EventType {
CONTRACT_RECONCILIATION_COMPLETED = 'contract_reconciliation_completed',
CONTRACT_REPLAY_STARTED = 'contract_replay_started',
CONTRACT_REPLAY_COMPLETED = 'contract_replay_completed',
// #386: Notification batching events
// #386: Notification delivery analytics
NOTIFICATION_BATCH_FLUSHED = 'notification_batch_flushed',
NOTIFICATION_DELIVERED = 'notification_delivered',
Expand All @@ -57,6 +58,11 @@ export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
private readonly events: AnalyticsEvent[] = [];

/** Allow-listed event types used by validateEventPayload(). */
static readonly VALID_EVENT_TYPES: ReadonlySet<EventType> = new Set(
Object.values(EventType),
);

/** #394: History of reconciliation results for analytics */
private readonly reconciliationHistory: StateReconciliationResult[] = [];

Expand Down
119 changes: 119 additions & 0 deletions BackendAcademy/src/users/dto/update-preferences.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {
IsBoolean,
IsEmail,
IsIn,
IsOptional,
IsString,
IsUrl,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';

/**
* Maximum character length applied to all free-text string preference fields.
* This prevents oversized payloads from being stored in preference records.
*/
const STRING_MAX_LENGTH = 200;

/**
* Allow-listed values for the `theme` preference.
* Extending the UI theme options must be done here and in the frontend.
*/
const ALLOWED_THEMES = ['light', 'dark', 'system'] as const;
export type ThemeValue = (typeof ALLOWED_THEMES)[number];

/**
* Learner-specific user preferences.
*
* All fields are optional so a PATCH-style partial update is supported.
* Only explicitly listed keys are accepted — the global ValidationPipe
* (`forbidNonWhitelisted: true`) will reject any unknown keys with 400.
*
* Allowed keys and their constraints:
* - `theme` — UI colour scheme; must be one of the allow-listed values
* - `email_alerts` — opt-in/out of email notification delivery
* - `push_notifications` — opt-in/out of push notification delivery
* - `marketing_updates` — opt-in/out of marketing communication
* - `displayName` — public display name; max 200 chars
* - `email` — contact email address; validated as RFC 5322 address
* - `avatarUrl` — URL of the user's avatar image; validated as URL
*/
export class LearnerPreferencesDto {
@IsOptional()
@IsIn(ALLOWED_THEMES)
theme?: ThemeValue;

@IsOptional()
@IsBoolean()
email_alerts?: boolean;

@IsOptional()
@IsBoolean()
push_notifications?: boolean;

@IsOptional()
@IsBoolean()
marketing_updates?: boolean;

@IsOptional()
@IsString()
@MaxLength(STRING_MAX_LENGTH)
displayName?: string;

@IsOptional()
@IsEmail()
email?: string;

@IsOptional()
@IsUrl()
avatarUrl?: string;
}

/**
* Allow-listed values for the tutor `availability` preference.
* Using an enum-like string union keeps the stored value normalised.
*/
const ALLOWED_AVAILABILITY = ['weekdays', 'weekends', 'both', 'none'] as const;
export type AvailabilityValue = (typeof ALLOWED_AVAILABILITY)[number];

/**
* Tutor-specific user preferences.
*
* All fields are optional so a PATCH-style partial update is supported.
* Only explicitly listed keys are accepted.
*
* Allowed keys and their constraints:
* - `availability` — teaching availability windows; must be one of the allow-listed values
* - `sessionLanguage` — preferred language for sessions; max 200 chars
*/
export class TutorPreferencesDto {
@IsOptional()
@IsIn(ALLOWED_AVAILABILITY)
availability?: AvailabilityValue;

@IsOptional()
@IsString()
@MaxLength(STRING_MAX_LENGTH)
sessionLanguage?: string;
}

/**
* Request body for PUT /users/:userId/preferences.
*
* Replaces the former `Record<string, unknown>` interface which allowed
* arbitrary keys, invalid value types, and oversized payloads to be stored.
* Validation is enforced by the global ValidationPipe
* (whitelist + forbidNonWhitelisted + forbidUnknownValues).
*/
export class UpdateUserPreferencesDto {
@IsOptional()
@ValidateNested()
@Type(() => LearnerPreferencesDto)
learnerPreferences?: LearnerPreferencesDto;

@IsOptional()
@ValidateNested()
@Type(() => TutorPreferencesDto)
tutorPreferences?: TutorPreferencesDto;
}
7 changes: 4 additions & 3 deletions BackendAcademy/src/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Body, Controller, Param, Put } from '@nestjs/common';
import { UsersService, UserPreferencesDto } from './users.service';
import { UsersService } from './users.service';
import { UpdateUserPreferencesDto } from './dto/update-preferences.dto';

@Controller('users')
export class UsersController {
Expand All @@ -8,8 +9,8 @@ export class UsersController {
@Put(':userId/preferences')
async updatePreferences(
@Param('userId') userId: string,
@Body() dto: UserPreferencesDto,
) {
@Body() dto: UpdateUserPreferencesDto,
): Promise<ReturnType<UsersService['updatePreferences']>> {
return this.usersService.updatePreferences(userId, dto);
}
}
Loading
Loading