From a749a6cebf908aa8963a954e653546f8a49b6126 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 19 Jun 2026 20:32:14 +0100 Subject: [PATCH 1/3] feat(notifications): add notification preferences API --- docs/notifications.md | 76 +++++++++ src/app.module.ts | 2 + ...050000000-CreateNotificationPreferences.ts | 105 ++++++++++++ .../dtos/notification-preferences.dto.ts | 22 +++ .../update-notification-preferences.dto.ts | 23 +++ .../notification-preference.entity.ts | 43 +++++ .../notifications.controller.spec.ts | 77 +++++++++ src/notifications/notifications.controller.ts | 64 ++++++++ src/notifications/notifications.module.ts | 13 ++ .../notifications.service.spec.ts | 154 ++++++++++++++++++ src/notifications/notifications.service.ts | 110 +++++++++++++ src/users/users.module.ts | 2 + src/users/users.service.ts | 3 + 13 files changed, 694 insertions(+) create mode 100644 docs/notifications.md create mode 100644 src/migrations/1769050000000-CreateNotificationPreferences.ts create mode 100644 src/notifications/dtos/notification-preferences.dto.ts create mode 100644 src/notifications/dtos/update-notification-preferences.dto.ts create mode 100644 src/notifications/notification-preference.entity.ts create mode 100644 src/notifications/notifications.controller.spec.ts create mode 100644 src/notifications/notifications.controller.ts create mode 100644 src/notifications/notifications.module.ts create mode 100644 src/notifications/notifications.service.spec.ts create mode 100644 src/notifications/notifications.service.ts diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..3d97eea --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,76 @@ +# Notification Preferences API + +## Overview + +The Notification Preferences API allows users to control which types of events they receive notifications for across various channels (e.g., email, in-app, push). + +## Entity Structure + +Each user has a 1:1 relation with the `NotificationPreference` entity. The following boolean preferences are available: + +- `newSubscriber`: Alerts when a new user subscribes to the user. Default: `true` +- `postFromSubscribedCreator`: Alerts when a creator the user is subscribed to posts new content. Default: `true` +- `securityAlerts`: Critical security alerts (e.g., login from new device, password change). Default: `true` +- `marketing`: Promotional and marketing communications. Default: `false` + +## Endpoints + +### 1. Get Preferences + +Retrieves the authenticated user's notification preferences. If the preferences do not exist yet, they are automatically created with default values. + +**Request:** +`GET /users/me/notification-preferences` +Headers: `Authorization: Bearer ` + +**Response:** +```json +{ + "newSubscriber": true, + "postFromSubscribedCreator": true, + "securityAlerts": true, + "marketing": false +} +``` + +### 2. Update Preferences + +Partially update the authenticated user's notification preferences. Invalid keys are rejected with a 400 Bad Request. + +**Request:** +`PATCH /users/me/notification-preferences` +Headers: `Authorization: Bearer ` +Body: +```json +{ + "marketing": true, + "newSubscriber": false +} +``` + +**Response:** +```json +{ + "newSubscriber": false, + "postFromSubscribedCreator": true, + "securityAlerts": true, + "marketing": true +} +``` + +## Internal Usage (for other modules) + +To check if a notification should be delivered to a user for a specific event, inject `NotificationsService` and use the `shouldNotify` method: + +```typescript +import { NotificationsService, NotificationEventType } from '../notifications/notifications.service'; + +constructor(private notificationsService: NotificationsService) {} + +async processEvent(userId: number, eventType: NotificationEventType) { + const shouldNotify = await this.notificationsService.shouldNotify(userId, eventType); + if (shouldNotify) { + // Deliver the notification + } +} +``` diff --git a/src/app.module.ts b/src/app.module.ts index f1ddde6..ee6b342 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { LoggerModule } from './logger/logger.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { HealthModule } from './monitoring/health.module'; import { RateLimitService } from './common/services/rate-limit.service'; +import { NotificationsModule } from './notifications/notifications.module'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { RateLimitService } from './common/services/rate-limit.service'; HealthModule, AuthModule, SubscriptionsModule, + NotificationsModule, ], controllers: [AppController], providers: [AppService, RateLimitService], diff --git a/src/migrations/1769050000000-CreateNotificationPreferences.ts b/src/migrations/1769050000000-CreateNotificationPreferences.ts new file mode 100644 index 0000000..e06663e --- /dev/null +++ b/src/migrations/1769050000000-CreateNotificationPreferences.ts @@ -0,0 +1,105 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from 'typeorm'; + +export class CreateNotificationPreferences1769050000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'notification_preferences', + columns: [ + { + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true, + generationStrategy: 'identity', + }, + { + name: 'userId', + type: 'int', + isUnique: true, + }, + { + name: 'newSubscriber', + type: 'boolean', + default: true, + }, + { + name: 'postFromSubscribedCreator', + type: 'boolean', + default: true, + }, + { + name: 'securityAlerts', + type: 'boolean', + default: true, + }, + { + name: 'marketing', + type: 'boolean', + default: false, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updated_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'notification_preferences', + new TableForeignKey({ + columnNames: ['userId'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'notification_preferences', + new TableIndex({ + name: 'IDX_NOTIFICATION_PREF_USER_ID', + columnNames: ['userId'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('notification_preferences'); + if (table) { + const foreignKey = table.foreignKeys.find( + (fk) => fk.columnNames.indexOf('userId') !== -1, + ); + if (foreignKey) { + await queryRunner.dropForeignKey( + 'notification_preferences', + foreignKey, + ); + } + + const index = table.indices.find( + (idx) => idx.name === 'IDX_NOTIFICATION_PREF_USER_ID', + ); + if (index) { + await queryRunner.dropIndex('notification_preferences', index); + } + + await queryRunner.dropTable('notification_preferences'); + } + } +} diff --git a/src/notifications/dtos/notification-preferences.dto.ts b/src/notifications/dtos/notification-preferences.dto.ts new file mode 100644 index 0000000..66258d9 --- /dev/null +++ b/src/notifications/dtos/notification-preferences.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class NotificationPreferencesDto { + @ApiProperty({ description: 'Preference for new subscriber notifications' }) + @Expose() + newSubscriber: boolean; + + @ApiProperty({ + description: 'Preference for post from subscribed creator notifications', + }) + @Expose() + postFromSubscribedCreator: boolean; + + @ApiProperty({ description: 'Preference for security alerts' }) + @Expose() + securityAlerts: boolean; + + @ApiProperty({ description: 'Preference for marketing communications' }) + @Expose() + marketing: boolean; +} diff --git a/src/notifications/dtos/update-notification-preferences.dto.ts b/src/notifications/dtos/update-notification-preferences.dto.ts new file mode 100644 index 0000000..387f125 --- /dev/null +++ b/src/notifications/dtos/update-notification-preferences.dto.ts @@ -0,0 +1,23 @@ +import { PartialType } from '@nestjs/swagger'; +import { NotificationPreferencesDto } from './notification-preferences.dto'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateNotificationPreferencesDto extends PartialType( + NotificationPreferencesDto, +) { + @IsOptional() + @IsBoolean() + newSubscriber?: boolean; + + @IsOptional() + @IsBoolean() + postFromSubscribedCreator?: boolean; + + @IsOptional() + @IsBoolean() + securityAlerts?: boolean; + + @IsOptional() + @IsBoolean() + marketing?: boolean; +} diff --git a/src/notifications/notification-preference.entity.ts b/src/notifications/notification-preference.entity.ts new file mode 100644 index 0000000..4f33c58 --- /dev/null +++ b/src/notifications/notification-preference.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../users/user.entity'; + +@Entity('notification_preferences') +export class NotificationPreference { + @PrimaryGeneratedColumn('identity') + id: number; + + @Index() + @Column({ type: 'int', unique: true }) + userId: number; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'boolean', default: true }) + newSubscriber: boolean; + + @Column({ type: 'boolean', default: true }) + postFromSubscribedCreator: boolean; + + @Column({ type: 'boolean', default: true }) + securityAlerts: boolean; + + @Column({ type: 'boolean', default: false }) + marketing: boolean; + + @CreateDateColumn({ type: 'timestamp' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp' }) + updated_at: Date; +} diff --git a/src/notifications/notifications.controller.spec.ts b/src/notifications/notifications.controller.spec.ts new file mode 100644 index 0000000..0d9755a --- /dev/null +++ b/src/notifications/notifications.controller.spec.ts @@ -0,0 +1,77 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + NotificationsController, + AuthenticatedRequest, +} from './notifications.controller'; +import { NotificationsService } from './notifications.service'; +import { NotificationPreferencesDto } from './dtos/notification-preferences.dto'; + +describe('NotificationsController', () => { + let controller: NotificationsController; + + const mockService = { + getPreferences: jest.fn(), + updatePreferences: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NotificationsController], + providers: [ + { + provide: NotificationsService, + useValue: mockService, + }, + ], + }).compile(); + + controller = module.get(NotificationsController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const mockRequest = { + user: { + userId: 1, + email: 'test@example.com', + username: 'test', + }, + } as unknown as AuthenticatedRequest; + + describe('getPreferences', () => { + it('should call service.getPreferences with userId', async () => { + const mockResult: NotificationPreferencesDto = { + newSubscriber: true, + postFromSubscribedCreator: true, + securityAlerts: true, + marketing: false, + }; + mockService.getPreferences.mockResolvedValue(mockResult); + + const result = await controller.getPreferences(mockRequest); + + expect(mockService.getPreferences).toHaveBeenCalledWith(1); + expect(result).toEqual(mockResult); + }); + }); + + describe('updatePreferences', () => { + it('should call service.updatePreferences with userId and dto', async () => { + const dto = { marketing: true }; + const mockResult: NotificationPreferencesDto = { + newSubscriber: true, + postFromSubscribedCreator: true, + securityAlerts: true, + marketing: true, + }; + mockService.updatePreferences.mockResolvedValue(mockResult); + + const result = await controller.updatePreferences(mockRequest, dto); + + expect(mockService.updatePreferences).toHaveBeenCalledWith(1, dto); + expect(result).toEqual(mockResult); + }); + }); +}); diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..e6b9944 --- /dev/null +++ b/src/notifications/notifications.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Get, Patch, Req, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { NotificationsService } from './notifications.service'; +import { NotificationPreferencesDto } from './dtos/notification-preferences.dto'; +import { UpdateNotificationPreferencesDto } from './dtos/update-notification-preferences.dto'; + +export interface AuthenticatedRequest extends Request { + user: { + userId: number; + email: string; + username: string; + }; +} + +@ApiTags('Notifications') +@ApiBearerAuth('JWT-auth') +@Controller('users/me/notification-preferences') +@UseGuards(JwtAuthGuard) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + @ApiOperation({ summary: 'Get current user notification preferences' }) + @ApiResponse({ + status: 200, + description: 'Notification preferences retrieved successfully', + type: NotificationPreferencesDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getPreferences( + @Req() req: AuthenticatedRequest, + ): Promise { + return this.notificationsService.getPreferences(req.user.userId); + } + + @Patch() + @ApiOperation({ + summary: 'Partially update current user notification preferences', + }) + @ApiBody({ type: UpdateNotificationPreferencesDto }) + @ApiResponse({ + status: 200, + description: 'Notification preferences updated successfully', + type: NotificationPreferencesDto, + }) + @ApiResponse({ status: 400, description: 'Invalid preference keys' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async updatePreferences( + @Req() req: AuthenticatedRequest, + @Body() updateDto: UpdateNotificationPreferencesDto, + ): Promise { + return this.notificationsService.updatePreferences( + req.user.userId, + updateDto, + ); + } +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..0998139 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationPreference } from './notification-preference.entity'; +import { NotificationsService } from './notifications.service'; +import { NotificationsController } from './notifications.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([NotificationPreference])], + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts new file mode 100644 index 0000000..010311e --- /dev/null +++ b/src/notifications/notifications.service.spec.ts @@ -0,0 +1,154 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + NotificationsService, + NotificationEventType, +} from './notifications.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotificationPreference } from './notification-preference.entity'; +import { BadRequestException } from '@nestjs/common'; +import { UpdateNotificationPreferencesDto } from './dtos/update-notification-preferences.dto'; + +describe('NotificationsService', () => { + let service: NotificationsService; + + const mockRepository = { + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationsService, + { + provide: getRepositoryToken(NotificationPreference), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(NotificationsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createDefaultPreferences', () => { + it('should return existing preferences if they exist', async () => { + const existingPref = { userId: 1, securityAlerts: true }; + mockRepository.findOneBy.mockResolvedValue(existingPref); + + const result = await service.createDefaultPreferences(1); + expect(result).toEqual(existingPref); + expect(mockRepository.create).not.toHaveBeenCalled(); + }); + + it('should create and save default preferences if none exist', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + const newPref = { + userId: 1, + securityAlerts: true, + newSubscriber: true, + postFromSubscribedCreator: true, + marketing: false, + }; + mockRepository.create.mockReturnValue(newPref); + mockRepository.save.mockResolvedValue(newPref); + + const result = await service.createDefaultPreferences(1); + + expect(mockRepository.create).toHaveBeenCalledWith({ + userId: 1, + newSubscriber: true, + postFromSubscribedCreator: true, + securityAlerts: true, + marketing: false, + }); + expect(mockRepository.save).toHaveBeenCalledWith(newPref); + expect(result).toEqual(newPref); + }); + }); + + describe('getPreferences', () => { + it('should return preferences and transform to DTO', async () => { + mockRepository.findOneBy.mockResolvedValue({ + id: 1, + userId: 1, + securityAlerts: true, + marketing: false, + }); + + const result = await service.getPreferences(1); + expect(result.securityAlerts).toBe(true); + expect(result.marketing).toBe(false); + }); + + it('should lazy-create preferences if missing', async () => { + mockRepository.findOneBy + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + const newPref = { + id: 1, + userId: 1, + securityAlerts: true, + marketing: false, + }; + mockRepository.create.mockReturnValue(newPref); + mockRepository.save.mockResolvedValue(newPref); + + const result = await service.getPreferences(1); + expect(mockRepository.save).toHaveBeenCalled(); + expect(result.securityAlerts).toBe(true); + }); + }); + + describe('updatePreferences', () => { + it('should partially update preferences', async () => { + const existing = { userId: 1, securityAlerts: true, marketing: false }; + mockRepository.findOneBy.mockResolvedValue(existing); + mockRepository.save.mockResolvedValue({ ...existing, marketing: true }); + + const result = await service.updatePreferences(1, { marketing: true }); + expect(mockRepository.save).toHaveBeenCalledWith({ + ...existing, + marketing: true, + }); + expect(result.marketing).toBe(true); + }); + + it('should throw BadRequestException for invalid keys', async () => { + const existing = { userId: 1, securityAlerts: true, marketing: false }; + mockRepository.findOneBy.mockResolvedValue(existing); + + await expect( + service.updatePreferences(1, { + invalidKey: true, + } as unknown as UpdateNotificationPreferencesDto), + ).rejects.toThrow(BadRequestException); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('shouldNotify', () => { + it('should return boolean for valid event type', async () => { + mockRepository.findOneBy.mockResolvedValue({ + userId: 1, + securityAlerts: true, + }); + + const result = await service.shouldNotify(1, 'securityAlerts'); + expect(result).toBe(true); + }); + + it('should throw BadRequestException for invalid event type', async () => { + await expect( + service.shouldNotify( + 1, + 'invalidEvent' as unknown as NotificationEventType, + ), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts new file mode 100644 index 0000000..b1292e0 --- /dev/null +++ b/src/notifications/notifications.service.ts @@ -0,0 +1,110 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationPreference } from './notification-preference.entity'; +import { NotificationPreferencesDto } from './dtos/notification-preferences.dto'; +import { UpdateNotificationPreferencesDto } from './dtos/update-notification-preferences.dto'; +import { plainToInstance } from 'class-transformer'; + +export type NotificationEventType = + | 'newSubscriber' + | 'postFromSubscribedCreator' + | 'securityAlerts' + | 'marketing'; + +@Injectable() +export class NotificationsService { + constructor( + @InjectRepository(NotificationPreference) + private readonly preferencesRepository: Repository, + ) {} + + async createDefaultPreferences( + userId: number, + ): Promise { + const existing = await this.preferencesRepository.findOneBy({ userId }); + if (existing) { + return existing; + } + + const preferences = this.preferencesRepository.create({ + userId, + newSubscriber: true, + postFromSubscribedCreator: true, + securityAlerts: true, + marketing: false, + }); + + return this.preferencesRepository.save(preferences); + } + + async getPreferences(userId: number): Promise { + let preferences = await this.preferencesRepository.findOneBy({ userId }); + + // Lazy-create on first GET if missing + if (!preferences) { + preferences = await this.createDefaultPreferences(userId); + } + + return plainToInstance(NotificationPreferencesDto, preferences, { + excludeExtraneousValues: true, + }); + } + + async updatePreferences( + userId: number, + updateDto: UpdateNotificationPreferencesDto, + ): Promise { + let preferences = await this.preferencesRepository.findOneBy({ userId }); + + if (!preferences) { + preferences = await this.createDefaultPreferences(userId); + } + + // Identify invalid keys not present in the entity + const allowedKeys = [ + 'newSubscriber', + 'postFromSubscribedCreator', + 'securityAlerts', + 'marketing', + ]; + const invalidKeys = Object.keys(updateDto).filter( + (key) => !allowedKeys.includes(key), + ); + if (invalidKeys.length > 0) { + throw new BadRequestException( + `Invalid preference keys: ${invalidKeys.join(', ')}`, + ); + } + + Object.assign(preferences, updateDto); + const updated = await this.preferencesRepository.save(preferences); + + return plainToInstance(NotificationPreferencesDto, updated, { + excludeExtraneousValues: true, + }); + } + + async shouldNotify( + userId: number, + eventType: NotificationEventType, + ): Promise { + const allowedEvents: NotificationEventType[] = [ + 'newSubscriber', + 'postFromSubscribedCreator', + 'securityAlerts', + 'marketing', + ]; + + if (!allowedEvents.includes(eventType)) { + throw new BadRequestException('Invalid event type'); + } + + let preferences = await this.preferencesRepository.findOneBy({ userId }); + if (!preferences) { + preferences = await this.createDefaultPreferences(userId); + } + + return preferences[eventType]; + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 1e8700b..1cfac54 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -8,11 +8,13 @@ import { RefreshToken } from '../auth/entities/refresh-token.entity'; import { UsersQueryService } from './services/users-query.service'; import { SearchService } from './services/search.service'; import { PermissionService } from './services/permission.service'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ TypeOrmModule.forFeature([User, RefreshToken]), CacheModule.register(), + NotificationsModule, ], providers: [ UsersService, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index d39fb7e..812d333 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -24,6 +24,7 @@ import { PermissionService, JwtPayload } from './services/permission.service'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import * as bcrypt from 'bcrypt'; +import { NotificationsService } from '../notifications/notifications.service'; @Injectable() export class UsersService { @@ -35,6 +36,7 @@ export class UsersService { private readonly queryService: UsersQueryService, private readonly searchService: SearchService, private readonly permissionService: PermissionService, + private readonly notificationsService: NotificationsService, @Inject(CACHE_MANAGER) private cacheManager: Cache, ) {} @@ -106,6 +108,7 @@ export class UsersService { // Update search text and invalidate caches await this.searchService.updateSearchTextForUser(savedUser.id); await this.invalidateUserRelatedCaches(savedUser.id); + await this.notificationsService.createDefaultPreferences(savedUser.id); return plainToInstance( UserResponseDto, From e23f4b9059003615da7f717670a2ff340f851d5f Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 19 Jun 2026 20:32:26 +0100 Subject: [PATCH 2/3] docs: add PR description file for issue #29 --- description.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 description.md diff --git a/description.md b/description.md new file mode 100644 index 0000000..de184e7 --- /dev/null +++ b/description.md @@ -0,0 +1,14 @@ +# Notification Preferences API + +This pull request implements the Notification Preferences API. + +## Changes +- Created `NotificationPreference` database entity and schema. +- Added database migration with default preferences. +- Created `GET /users/me/notification-preferences` endpoint. +- Created `PATCH /users/me/notification-preferences` endpoint for partial updates. +- Added `NotificationsService.shouldNotify` utility helper. +- Added hook to create default preferences during user signup/creation. +- Added comprehensive unit tests and Swagger documentation. + +closes #29 From 2508ce34d5fad7fba344fa37e4b818ed80ea3e81 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 19 Jun 2026 20:33:19 +0100 Subject: [PATCH 3/3] docs: expand PR description with detailed implementation information --- description.md | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/description.md b/description.md index de184e7..a81d43d 100644 --- a/description.md +++ b/description.md @@ -1,14 +1,39 @@ # Notification Preferences API -This pull request implements the Notification Preferences API. - -## Changes -- Created `NotificationPreference` database entity and schema. -- Added database migration with default preferences. -- Created `GET /users/me/notification-preferences` endpoint. -- Created `PATCH /users/me/notification-preferences` endpoint for partial updates. -- Added `NotificationsService.shouldNotify` utility helper. -- Added hook to create default preferences during user signup/creation. -- Added comprehensive unit tests and Swagger documentation. +This pull request implements the Notification Preferences API, enabling users to control their notification preferences independently. It includes the database schema, entity mappings, REST API endpoints, user signup hooks, comprehensive unit testing, and API documentation. + +## Proposed Changes + +### 1. Database Schema & Migration +- **Entity**: Created `NotificationPreference` (`notification_preferences` table) with the following fields: + - `id`: Auto-generated primary key (identity). + - `userId`: `int` linked to the `User` entity via a one-to-one relationship (`onDelete: 'CASCADE'`). Indexed for query performance. + - `newSubscriber`: `boolean`, default `true`. + - `postFromSubscribedCreator`: `boolean`, default `true`. + - `securityAlerts`: `boolean`, default `true`. + - `marketing`: `boolean`, default `false`. + - `created_at` / `updated_at`: Timestamps. +- **Migration**: Added database migration script `1769050000000-CreateNotificationPreferences.ts` with correct defaults and constraints. + +### 2. REST Endpoints (`/users/me/notification-preferences`) +- **GET**: Retrieves the authenticated user's notification preferences. If the preferences do not exist yet, they are automatically created with default values (lazy-creation). +- **PATCH**: Supports partial updates to the notification preferences. The endpoint identifies and rejects requests containing invalid keys with a `400 Bad Request` exception. +- Both endpoints are secured under `JwtAuthGuard` and utilize the `AuthenticatedRequest` context. + +### 3. User Signup Integration Hook +- Hooks into `UsersService.createUser` to automatically create default notification preferences immediately upon successful signup. + +### 4. Service Helper (`shouldNotify`) +- Implemented `NotificationsService.shouldNotify(userId, eventType)` for other system modules to query if they should deliver notifications for events like: + - `newSubscriber` + - `postFromSubscribedCreator` + - `securityAlerts` + - `marketing` +- Ensures invalid event types are rejected with a `BadRequestException`. + +### 5. Code Quality & Testing +- Added comprehensive unit tests in `notifications.service.spec.ts` and `notifications.controller.spec.ts`. +- Cleaned up unused imports/variables and corrected TypeScript/ESLint warnings (e.g., resolving `unbound-method` errors and adding type assertions for mock requests/arguments). +- Formatted the codebase utilizing Prettier. closes #29