Skip to content
Open
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
39 changes: 39 additions & 0 deletions description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Notification Preferences API

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
76 changes: 76 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
@@ -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 <JWT>`

**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 <JWT>`
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
}
}
```
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -33,6 +34,7 @@ import { RateLimitService } from './common/services/rate-limit.service';
HealthModule,
AuthModule,
SubscriptionsModule,
NotificationsModule,
],
controllers: [AppController],
providers: [AppService, RateLimitService],
Expand Down
105 changes: 105 additions & 0 deletions src/migrations/1769050000000-CreateNotificationPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
} from 'typeorm';

export class CreateNotificationPreferences1769050000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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');
}
}
}
22 changes: 22 additions & 0 deletions src/notifications/dtos/notification-preferences.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 23 additions & 0 deletions src/notifications/dtos/update-notification-preferences.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 43 additions & 0 deletions src/notifications/notification-preference.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading