From 47a949da8921c0e2e9e24bb66aa12eb31f0f78dd Mon Sep 17 00:00:00 2001 From: toroabduljalalyakubu Date: Sun, 30 Aug 2026 09:19:01 +0100 Subject: [PATCH] added validateandnormalize --- BackendAcademy/jest.config.ts | 2 +- .../src/analytics/analytics.service.ts | 9 +- .../src/users/dto/update-preferences.dto.ts | 119 +++++ BackendAcademy/src/users/users.controller.ts | 7 +- .../src/users/users.service.spec.ts | 432 +++++++++++++++++- BackendAcademy/src/users/users.service.ts | 61 +-- 6 files changed, 588 insertions(+), 42 deletions(-) create mode 100644 BackendAcademy/src/users/dto/update-preferences.dto.ts diff --git a/BackendAcademy/jest.config.ts b/BackendAcademy/jest.config.ts index fdc1bfa53..736cfc18f 100644 --- a/BackendAcademy/jest.config.ts +++ b/BackendAcademy/jest.config.ts @@ -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', diff --git a/BackendAcademy/src/analytics/analytics.service.ts b/BackendAcademy/src/analytics/analytics.service.ts index d2f41a320..e97a0d332 100644 --- a/BackendAcademy/src/analytics/analytics.service.ts +++ b/BackendAcademy/src/analytics/analytics.service.ts @@ -36,6 +36,9 @@ 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 + NOTIFICATION_BATCH_FLUSHED = 'notification_batch_flushed', + NOTIFICATION_DELIVERED = 'notification_delivered', } /** @@ -54,6 +57,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 = new Set( + Object.values(EventType), + ); + /** #394: History of reconciliation results for analytics */ private readonly reconciliationHistory: StateReconciliationResult[] = []; @@ -404,7 +412,6 @@ export class AnalyticsService { totalDiscrepanciesFound: totalDiscrepancies, }; } -} // ── Notification batching analytics (#386) ──────────────── diff --git a/BackendAcademy/src/users/dto/update-preferences.dto.ts b/BackendAcademy/src/users/dto/update-preferences.dto.ts new file mode 100644 index 000000000..3b2181a2a --- /dev/null +++ b/BackendAcademy/src/users/dto/update-preferences.dto.ts @@ -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` 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; +} diff --git a/BackendAcademy/src/users/users.controller.ts b/BackendAcademy/src/users/users.controller.ts index e55c1d520..685733940 100644 --- a/BackendAcademy/src/users/users.controller.ts +++ b/BackendAcademy/src/users/users.controller.ts @@ -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 { @@ -8,8 +9,8 @@ export class UsersController { @Put(':userId/preferences') async updatePreferences( @Param('userId') userId: string, - @Body() dto: UserPreferencesDto, - ) { + @Body() dto: UpdateUserPreferencesDto, + ): Promise> { return this.usersService.updatePreferences(userId, dto); } } diff --git a/BackendAcademy/src/users/users.service.spec.ts b/BackendAcademy/src/users/users.service.spec.ts index bdf6e7d58..78d6616ce 100644 --- a/BackendAcademy/src/users/users.service.spec.ts +++ b/BackendAcademy/src/users/users.service.spec.ts @@ -1,24 +1,434 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; import { UsersService } from './users.service'; +import { + UpdateUserPreferencesDto, + LearnerPreferencesDto, + TutorPreferencesDto, +} from './dto/update-preferences.dto'; +// --------------------------------------------------------------------------- +// Minimal stubs for the three injected services. +// Only the methods called by UsersService.deleteAccount() are stubbed because +// those are the only non-preference paths that touch the dependencies. +// --------------------------------------------------------------------------- +const makeStubs = () => ({ + onboardingService: { + findByUserId: jest.fn().mockResolvedValue(null), + remove: jest.fn().mockResolvedValue(undefined), + }, + analyticsService: { + getEventsByUserId: jest.fn().mockResolvedValue([]), + removeEventsByUserId: jest.fn().mockResolvedValue(undefined), + }, + socialService: { + getPostsByUserId: jest.fn().mockReturnValue([]), + deletePost: jest.fn(), + }, +}); + +// --------------------------------------------------------------------------- +// Helper: run class-validator on a plain object as a given DTO class. +// Mirrors what the global ValidationPipe does (transform + whitelist). +// --------------------------------------------------------------------------- +async function validateDto( + cls: new () => T, + plain: Record, +) { + const instance = plainToInstance(cls, plain); + return validate(instance, { whitelist: true, forbidNonWhitelisted: true }); +} + +// --------------------------------------------------------------------------- +// DTO validation tests +// --------------------------------------------------------------------------- +describe('UpdateUserPreferencesDto — DTO validation', () => { + describe('LearnerPreferencesDto', () => { + it('accepts an empty object (all fields optional)', async () => { + const errors = await validateDto(LearnerPreferencesDto, {}); + expect(errors).toHaveLength(0); + }); + + it('accepts every valid field at once', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + theme: 'dark', + email_alerts: true, + push_notifications: false, + marketing_updates: true, + displayName: 'Alice', + email: 'alice@example.com', + avatarUrl: 'https://cdn.example.com/avatar.png', + }); + expect(errors).toHaveLength(0); + }); + + describe('theme', () => { + it.each(['light', 'dark', 'system'])( + 'accepts allow-listed value "%s"', + async (value) => { + const errors = await validateDto(LearnerPreferencesDto, { theme: value }); + expect(errors).toHaveLength(0); + }, + ); + + it('rejects an unknown theme value', async () => { + const errors = await validateDto(LearnerPreferencesDto, { theme: 'midnight' }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('theme'); + }); + + it('rejects a numeric theme value', async () => { + const errors = await validateDto(LearnerPreferencesDto, { theme: 1 }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('theme'); + }); + }); + + describe('boolean flags', () => { + it.each(['email_alerts', 'push_notifications', 'marketing_updates'])( + '%s rejects a string value', + async (field) => { + const errors = await validateDto(LearnerPreferencesDto, { [field]: 'yes' }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe(field); + }, + ); + + it.each(['email_alerts', 'push_notifications', 'marketing_updates'])( + '%s rejects a numeric value', + async (field) => { + const errors = await validateDto(LearnerPreferencesDto, { [field]: 1 }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe(field); + }, + ); + }); + + describe('displayName', () => { + it('accepts a string within the 200-char limit', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + displayName: 'Alice Rust', + }); + expect(errors).toHaveLength(0); + }); + + it('rejects a string exceeding 200 characters', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + displayName: 'a'.repeat(201), + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('displayName'); + }); + + it('accepts a string of exactly 200 characters', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + displayName: 'a'.repeat(200), + }); + expect(errors).toHaveLength(0); + }); + + it('rejects a non-string value', async () => { + const errors = await validateDto(LearnerPreferencesDto, { displayName: 42 }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('displayName'); + }); + }); + + describe('email', () => { + it('accepts a valid email address', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + email: 'user@example.com', + }); + expect(errors).toHaveLength(0); + }); + + it('rejects a non-email string', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + email: 'not-an-email', + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('email'); + }); + }); + + describe('avatarUrl', () => { + it('accepts a valid HTTPS URL', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + avatarUrl: 'https://cdn.example.com/pic.jpg', + }); + expect(errors).toHaveLength(0); + }); + + it('rejects a plain string that is not a URL', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + avatarUrl: 'not a url', + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('avatarUrl'); + }); + }); + + describe('unknown keys', () => { + it('rejects an unexpected top-level key', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + theme: 'dark', + hackerField: 'payload', + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'hackerField')).toBe(true); + }); + + it('rejects a numeric arbitrary key', async () => { + const errors = await validateDto(LearnerPreferencesDto, { + injectedScore: 9999, + }); + expect(errors.length).toBeGreaterThan(0); + }); + }); + }); + + describe('TutorPreferencesDto', () => { + it('accepts an empty object', async () => { + const errors = await validateDto(TutorPreferencesDto, {}); + expect(errors).toHaveLength(0); + }); + + describe('availability', () => { + it.each(['weekdays', 'weekends', 'both', 'none'])( + 'accepts allow-listed value "%s"', + async (value) => { + const errors = await validateDto(TutorPreferencesDto, { availability: value }); + expect(errors).toHaveLength(0); + }, + ); + + it('rejects an unknown availability value', async () => { + const errors = await validateDto(TutorPreferencesDto, { + availability: 'anytime', + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('availability'); + }); + }); + + describe('sessionLanguage', () => { + it('accepts a short language string', async () => { + const errors = await validateDto(TutorPreferencesDto, { + sessionLanguage: 'English', + }); + expect(errors).toHaveLength(0); + }); + + it('rejects a string exceeding 200 characters', async () => { + const errors = await validateDto(TutorPreferencesDto, { + sessionLanguage: 'x'.repeat(201), + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('sessionLanguage'); + }); + + it('rejects a non-string value', async () => { + const errors = await validateDto(TutorPreferencesDto, { + sessionLanguage: true, + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('sessionLanguage'); + }); + }); + + it('rejects an unexpected key', async () => { + const errors = await validateDto(TutorPreferencesDto, { + availability: 'weekdays', + evilKey: 'bad', + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'evilKey')).toBe(true); + }); + }); + + describe('UpdateUserPreferencesDto (outer wrapper)', () => { + it('accepts an empty body (both sub-objects optional)', async () => { + const errors = await validateDto(UpdateUserPreferencesDto, {}); + expect(errors).toHaveLength(0); + }); + + it('accepts a payload with only learnerPreferences', async () => { + const errors = await validateDto(UpdateUserPreferencesDto, { + learnerPreferences: { theme: 'light' }, + }); + expect(errors).toHaveLength(0); + }); + + it('accepts a payload with only tutorPreferences', async () => { + const errors = await validateDto(UpdateUserPreferencesDto, { + tutorPreferences: { availability: 'weekdays' }, + }); + expect(errors).toHaveLength(0); + }); + + it('rejects an unknown top-level key', async () => { + const errors = await validateDto(UpdateUserPreferencesDto, { + learnerPreferences: { theme: 'dark' }, + rogue: 'value', + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'rogue')).toBe(true); + }); + + it('propagates nested validation errors from learnerPreferences', async () => { + const errors = await validateDto(UpdateUserPreferencesDto, { + learnerPreferences: { theme: 'invalid-theme' }, + }); + expect(errors.length).toBeGreaterThan(0); + const nested = errors.find((e) => e.property === 'learnerPreferences'); + expect(nested).toBeDefined(); + }); + + it('propagates nested validation errors from tutorPreferences', async () => { + const errors = await validateDto(UpdateUserPreferencesDto, { + tutorPreferences: { availability: 'whenever' }, + }); + expect(errors.length).toBeGreaterThan(0); + const nested = errors.find((e) => e.property === 'tutorPreferences'); + expect(nested).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Service-level tests +// --------------------------------------------------------------------------- describe('UsersService', () => { let service: UsersService; + let stubs: ReturnType; beforeEach(() => { - service = new UsersService(); + stubs = makeStubs(); + service = new UsersService( + stubs.onboardingService as never, + stubs.analyticsService as never, + stubs.socialService as never, + ); }); - it('updates learner and tutor preferences for a user', async () => { - const result = await service.updatePreferences('user-1', { - learnerPreferences: { theme: 'dark' }, - tutorPreferences: { availability: 'weekends' }, - }); + // ── updatePreferences ──────────────────────────────────────────────────── - expect(result).toEqual( - expect.objectContaining({ - userId: 'user-1', + describe('updatePreferences()', () => { + it('stores and returns learner and tutor preferences for a new user', async () => { + const result = await service.updatePreferences('user-1', { learnerPreferences: { theme: 'dark' }, tutorPreferences: { availability: 'weekends' }, - }), - ); + }); + + expect(result).toEqual( + expect.objectContaining({ + userId: 'user-1', + learnerPreferences: expect.objectContaining({ theme: 'dark' }), + tutorPreferences: expect.objectContaining({ availability: 'weekends' }), + }), + ); + }); + + it('merges a partial update onto existing learner preferences', async () => { + await service.updatePreferences('user-2', { + learnerPreferences: { theme: 'light', email_alerts: true }, + }); + + const result = await service.updatePreferences('user-2', { + learnerPreferences: { email_alerts: false }, + }); + + // email_alerts overwritten; theme retained from first call + expect(result.learnerPreferences?.email_alerts).toBe(false); + expect(result.learnerPreferences?.theme).toBe('light'); + }); + + it('does not bleed learner preferences across different user IDs', async () => { + await service.updatePreferences('user-a', { + learnerPreferences: { theme: 'dark' }, + }); + + const result = await service.updatePreferences('user-b', { + learnerPreferences: { theme: 'light' }, + }); + + expect(result.learnerPreferences?.theme).toBe('light'); + }); + + it('returns an empty preference object when neither sub-object is supplied', async () => { + const result = await service.updatePreferences('user-3', {}); + + expect(result).toEqual( + expect.objectContaining({ + userId: 'user-3', + learnerPreferences: {}, + tutorPreferences: {}, + }), + ); + }); + }); + + // ── getUserNotificationPreferences ─────────────────────────────────────── + + describe('getUserNotificationPreferences()', () => { + it('returns default-enabled notification preferences for a user with no stored prefs', async () => { + const prefs = await service.getUserNotificationPreferences('new-user'); + + expect(prefs).toEqual({ + userId: 'new-user', + email_alerts: true, + push_notifications: true, + marketing_updates: false, + }); + }); + + it('reflects stored boolean preferences accurately', async () => { + await service.updatePreferences('user-notif', { + learnerPreferences: { + email_alerts: false, + push_notifications: true, + marketing_updates: true, + }, + }); + + const prefs = await service.getUserNotificationPreferences('user-notif'); + + expect(prefs.email_alerts).toBe(false); + expect(prefs.push_notifications).toBe(true); + expect(prefs.marketing_updates).toBe(true); + }); + }); + + // ── getUserProfileFields ───────────────────────────────────────────────── + + describe('getUserProfileFields()', () => { + it('returns safe undefined defaults for a user with no stored prefs', async () => { + const fields = await service.getUserProfileFields('ghost-user'); + + expect(fields).toEqual({ + userId: 'ghost-user', + name: undefined, + email: undefined, + displayName: undefined, + avatarUrl: undefined, + }); + }); + + it('maps stored learner preference fields to profile fields', async () => { + await service.updatePreferences('user-profile', { + learnerPreferences: { + displayName: 'Bob', + email: 'bob@example.com', + avatarUrl: 'https://cdn.example.com/bob.png', + }, + }); + + const fields = await service.getUserProfileFields('user-profile'); + + expect(fields.name).toBe('Bob'); + expect(fields.displayName).toBe('Bob'); + expect(fields.email).toBe('bob@example.com'); + expect(fields.avatarUrl).toBe('https://cdn.example.com/bob.png'); + }); }); }); diff --git a/BackendAcademy/src/users/users.service.ts b/BackendAcademy/src/users/users.service.ts index 8988ca3be..a729d9571 100644 --- a/BackendAcademy/src/users/users.service.ts +++ b/BackendAcademy/src/users/users.service.ts @@ -2,16 +2,20 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnboardingService } from '../onboarding/onboarding.service'; import { AnalyticsService } from '../analytics/analytics.service'; import { SocialService } from '../social/social.service'; +import { + UpdateUserPreferencesDto, + LearnerPreferencesDto, + TutorPreferencesDto, +} from './dto/update-preferences.dto'; -export interface UserPreferencesDto { - learnerPreferences?: Record; - tutorPreferences?: Record; -} +// Re-export so existing callers that import UserPreferencesDto from this +// module continue to compile without changes. +export { UpdateUserPreferencesDto as UserPreferencesDto }; export interface UserPreferencesResponse { userId: string; - learnerPreferences?: Record; - tutorPreferences?: Record; + learnerPreferences?: LearnerPreferencesDto; + tutorPreferences?: TutorPreferencesDto; } export interface UserPrivilegeChangeEvent { @@ -75,24 +79,26 @@ export class UsersService { async updatePreferences( userId: string, - dto: UserPreferencesDto, + dto: UpdateUserPreferencesDto, ): Promise { - const existing = this.preferencesByUser.get(userId) || { + const existing = this.preferencesByUser.get(userId) ?? { userId, learnerPreferences: {}, tutorPreferences: {}, }; - const next = { - ...existing, - ...dto, + // Shallow-merge only the typed, allow-listed fields from each sub-DTO. + // Unknown keys are already rejected by the global ValidationPipe before + // this method is reached, so no further stripping is needed here. + const next: UserPreferencesResponse = { + userId, learnerPreferences: { - ...(existing.learnerPreferences || {}), - ...(dto.learnerPreferences || {}), + ...(existing.learnerPreferences ?? {}), + ...(dto.learnerPreferences ?? {}), }, tutorPreferences: { - ...(existing.tutorPreferences || {}), - ...(dto.tutorPreferences || {}), + ...(existing.tutorPreferences ?? {}), + ...(dto.tutorPreferences ?? {}), }, }; @@ -135,12 +141,12 @@ export class UsersService { async getUserNotificationPreferences( userId: string, ): Promise { - const prefs = this.preferencesByUser.get(userId); + const lp = this.preferencesByUser.get(userId)?.learnerPreferences; return { userId, - email_alerts: (prefs?.learnerPreferences?.['email_alerts'] as boolean) ?? true, - push_notifications: (prefs?.learnerPreferences?.['push_notifications'] as boolean) ?? true, - marketing_updates: (prefs?.learnerPreferences?.['marketing_updates'] as boolean) ?? false, + email_alerts: lp?.email_alerts ?? true, + push_notifications: lp?.push_notifications ?? true, + marketing_updates: lp?.marketing_updates ?? false, }; } @@ -151,15 +157,13 @@ export class UsersService { * never render broken or blank content (#387). */ async getUserProfileFields(userId: string): Promise { - const prefs = this.preferencesByUser.get(userId); + const lp = this.preferencesByUser.get(userId)?.learnerPreferences; return { userId, - name: (prefs?.learnerPreferences?.['displayName'] as string) || undefined, - email: (prefs?.learnerPreferences?.['email'] as string) || undefined, - displayName: - (prefs?.learnerPreferences?.['displayName'] as string) || undefined, - avatarUrl: - (prefs?.learnerPreferences?.['avatarUrl'] as string) || undefined, + name: lp?.displayName || undefined, + email: lp?.email || undefined, + displayName: lp?.displayName || undefined, + avatarUrl: lp?.avatarUrl || undefined, }; } @@ -230,6 +234,11 @@ export class UsersService { isDeleted(userId: string): boolean { return this.deletedUsers.has(userId); } + + /** + * Records an asset upload against a user for ownership tracking. + */ + recordAssetUpload(userId: string, assetId: string): void { if (!this.userUploads.has(userId)) { this.userUploads.set(userId, new Set()); }