Skip to content

Commit 46b5478

Browse files
authored
Merge pull request SaboStudios#201 from Xaxxoo/fetch-notification
feat: implement notifications module with CRUD operations and paginat…
2 parents 178779d + 45c1811 commit 46b5478

9 files changed

Lines changed: 1009 additions & 0 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// src/notifications/dto/get-notifications-query.dto.ts
2+
import { IsOptional, IsBoolean, IsEnum, IsInt, Min, Max } from 'class-validator';
3+
import { Transform, Type } from 'class-transformer';
4+
import { ApiPropertyOptional } from '@nestjs/swagger';
5+
import { NotificationType } from '../entities/notification.entity';
6+
7+
export class GetNotificationsQueryDto {
8+
@ApiPropertyOptional({ default: 1, description: 'Page number (1-based)' })
9+
@IsOptional()
10+
@Type(() => Number)
11+
@IsInt()
12+
@Min(1)
13+
page?: number = 1;
14+
15+
@ApiPropertyOptional({ default: 20, description: 'Items per page (max 100)' })
16+
@IsOptional()
17+
@Type(() => Number)
18+
@IsInt()
19+
@Min(1)
20+
@Max(100)
21+
limit?: number = 20;
22+
23+
@ApiPropertyOptional({ description: 'Filter by read status' })
24+
@IsOptional()
25+
@Transform(({ value }) => {
26+
if (value === 'true') return true;
27+
if (value === 'false') return false;
28+
return value;
29+
})
30+
@IsBoolean()
31+
isRead?: boolean;
32+
33+
@ApiPropertyOptional({
34+
enum: NotificationType,
35+
description: 'Filter by notification type',
36+
})
37+
@IsOptional()
38+
@IsEnum(NotificationType)
39+
type?: NotificationType;
40+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// src/notifications/entities/notification.entity.ts
2+
import {
3+
Entity,
4+
Column,
5+
PrimaryGeneratedColumn,
6+
CreateDateColumn,
7+
Index,
8+
} from 'typeorm';
9+
10+
export enum NotificationType {
11+
NEW_MESSAGE = 'new_message',
12+
MENTION = 'mention',
13+
TOKEN_RECEIVED = 'token_received',
14+
SYSTEM = 'system',
15+
ALERT = 'alert',
16+
}
17+
18+
@Entity('notifications')
19+
@Index('idx_notifications_user_unread', ['userId', 'isRead']) // composite — most common query pattern
20+
export class Notification {
21+
@PrimaryGeneratedColumn('uuid')
22+
id: string;
23+
24+
@Column({ name: 'user_id' })
25+
@Index('idx_notifications_user_id')
26+
userId: string;
27+
28+
@Column({ type: 'enum', enum: NotificationType, default: NotificationType.SYSTEM })
29+
@Index('idx_notifications_type')
30+
type: NotificationType;
31+
32+
@Column({ type: 'varchar', length: 255 })
33+
title: string;
34+
35+
@Column({ type: 'text' })
36+
content: string;
37+
38+
@Column({ name: 'is_read', default: false })
39+
@Index('idx_notifications_is_read')
40+
isRead: boolean;
41+
42+
@CreateDateColumn({ name: 'created_at' })
43+
createdAt: Date;
44+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// src/notifications/tests/notifications.controller.spec.ts
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
import { NotificationsController } from '../notifications.controller';
4+
import { NotificationsService } from '../notifications.service';
5+
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
6+
import { NotificationType } from '../entities/notification.entity';
7+
import { GetNotificationsQueryDto } from '../dto/get-notifications-query.dto';
8+
import {
9+
PaginatedNotificationsResponseDto,
10+
PaginationMetaDto,
11+
} from '../dto/paginated-notifications-response.dto';
12+
13+
// ─── Helpers ─────────────────────────────────────────────────────────────────
14+
15+
const mockUser = { sub: 'user-uuid-1', email: 'user@example.com' };
16+
17+
const makePaginatedResponse = (
18+
overrides: Partial<PaginatedNotificationsResponseDto> = {},
19+
): PaginatedNotificationsResponseDto => ({
20+
data: [
21+
{
22+
id: 'notif-uuid-1',
23+
userId: 'user-uuid-1',
24+
type: NotificationType.SYSTEM,
25+
title: 'Test',
26+
content: 'Test content',
27+
isRead: false,
28+
createdAt: new Date('2024-01-01T00:00:00.000Z'),
29+
},
30+
],
31+
meta: {
32+
page: 1,
33+
limit: 20,
34+
total: 1,
35+
totalPages: 1,
36+
hasNextPage: false,
37+
hasPreviousPage: false,
38+
},
39+
...overrides,
40+
});
41+
42+
// ─── Mock Service ─────────────────────────────────────────────────────────────
43+
44+
const mockNotificationsService = {
45+
findAllForUser: jest.fn(),
46+
getUnreadCount: jest.fn(),
47+
};
48+
49+
// ─── Tests ───────────────────────────────────────────────────────────────────
50+
51+
describe('NotificationsController', () => {
52+
let controller: NotificationsController;
53+
54+
beforeEach(async () => {
55+
jest.clearAllMocks();
56+
57+
const module: TestingModule = await Test.createTestingModule({
58+
controllers: [NotificationsController],
59+
providers: [
60+
{ provide: NotificationsService, useValue: mockNotificationsService },
61+
],
62+
})
63+
// Bypass JwtAuthGuard in unit tests — integration tests cover auth
64+
.overrideGuard(JwtAuthGuard)
65+
.useValue({ canActivate: () => true })
66+
.compile();
67+
68+
controller = module.get<NotificationsController>(NotificationsController);
69+
});
70+
71+
// ── GET /api/notifications ──────────────────────────────────────────────────
72+
73+
describe('getNotifications', () => {
74+
it('should call service.findAllForUser with user id and query params', async () => {
75+
const expected = makePaginatedResponse();
76+
mockNotificationsService.findAllForUser.mockResolvedValue(expected);
77+
78+
const query: GetNotificationsQueryDto = { page: 1, limit: 20 };
79+
const result = await controller.getNotifications(mockUser as any, query);
80+
81+
expect(mockNotificationsService.findAllForUser).toHaveBeenCalledWith(
82+
mockUser.sub,
83+
query,
84+
);
85+
expect(result).toBe(expected);
86+
});
87+
88+
it('should pass isRead filter to service', async () => {
89+
mockNotificationsService.findAllForUser.mockResolvedValue(
90+
makePaginatedResponse({ data: [] }),
91+
);
92+
93+
const query: GetNotificationsQueryDto = { page: 1, limit: 20, isRead: false };
94+
await controller.getNotifications(mockUser as any, query);
95+
96+
expect(mockNotificationsService.findAllForUser).toHaveBeenCalledWith(
97+
mockUser.sub,
98+
expect.objectContaining({ isRead: false }),
99+
);
100+
});
101+
102+
it('should pass type filter to service', async () => {
103+
mockNotificationsService.findAllForUser.mockResolvedValue(
104+
makePaginatedResponse({ data: [] }),
105+
);
106+
107+
const query: GetNotificationsQueryDto = {
108+
page: 1,
109+
limit: 20,
110+
type: NotificationType.MENTION,
111+
};
112+
await controller.getNotifications(mockUser as any, query);
113+
114+
expect(mockNotificationsService.findAllForUser).toHaveBeenCalledWith(
115+
mockUser.sub,
116+
expect.objectContaining({ type: NotificationType.MENTION }),
117+
);
118+
});
119+
120+
it('should return the paginated response from service', async () => {
121+
const expected = makePaginatedResponse();
122+
mockNotificationsService.findAllForUser.mockResolvedValue(expected);
123+
124+
const result = await controller.getNotifications(
125+
mockUser as any,
126+
{ page: 1, limit: 20 },
127+
);
128+
129+
expect(result.data).toHaveLength(1);
130+
expect(result.meta.total).toBe(1);
131+
});
132+
});
133+
134+
// ── GET /api/notifications/count ────────────────────────────────────────────
135+
136+
describe('getUnreadCount', () => {
137+
it('should call service.getUnreadCount with user id', async () => {
138+
mockNotificationsService.getUnreadCount.mockResolvedValue(5);
139+
140+
await controller.getUnreadCount(mockUser as any);
141+
142+
expect(mockNotificationsService.getUnreadCount).toHaveBeenCalledWith(
143+
mockUser.sub,
144+
);
145+
});
146+
147+
it('should return { count: number } shape', async () => {
148+
mockNotificationsService.getUnreadCount.mockResolvedValue(5);
149+
150+
const result = await controller.getUnreadCount(mockUser as any);
151+
152+
expect(result).toEqual({ count: 5 });
153+
});
154+
155+
it('should return count of 0 when no unread notifications', async () => {
156+
mockNotificationsService.getUnreadCount.mockResolvedValue(0);
157+
158+
const result = await controller.getUnreadCount(mockUser as any);
159+
160+
expect(result).toEqual({ count: 0 });
161+
});
162+
163+
it('should always wrap the number in a count object regardless of value', async () => {
164+
mockNotificationsService.getUnreadCount.mockResolvedValue(99);
165+
166+
const result = await controller.getUnreadCount(mockUser as any);
167+
168+
expect(result).toHaveProperty('count');
169+
expect(typeof result.count).toBe('number');
170+
expect(result.count).toBe(99);
171+
});
172+
});
173+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// src/notifications/notifications.controller.ts
2+
import {
3+
Controller,
4+
Get,
5+
Query,
6+
UseGuards,
7+
HttpCode,
8+
HttpStatus,
9+
} from '@nestjs/common';
10+
import {
11+
ApiTags,
12+
ApiOperation,
13+
ApiResponse,
14+
ApiBearerAuth,
15+
ApiQuery,
16+
} from '@nestjs/swagger';
17+
import { NotificationsService } from './notifications.service';
18+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
19+
import { CurrentUser } from '../auth/decorators/current-user.decorator';
20+
import { PaginatedNotificationsResponseDto } from './dto/paginated-notifications-response.dto';
21+
import { GetNotificationsQueryDto } from './dto/get-notifications-query.dto';
22+
import { UnreadCountResponseDto } from './dto/unread-count-response.dto';
23+
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
24+
25+
@ApiTags('Notifications')
26+
@ApiBearerAuth()
27+
@UseGuards(JwtAuthGuard)
28+
@Controller('api/notifications')
29+
export class NotificationsController {
30+
constructor(private readonly notificationsService: NotificationsService) {}
31+
32+
/**
33+
* GET /api/notifications
34+
* Returns a paginated list of notifications for the authenticated user.
35+
*/
36+
@Get()
37+
@HttpCode(HttpStatus.OK)
38+
@ApiOperation({
39+
summary: 'Get paginated notifications for the authenticated user',
40+
description:
41+
'Returns a paginated list of notifications. Supports filtering by read status and notification type.',
42+
})
43+
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
44+
@ApiQuery({ name: 'limit', required: false, type: Number, example: 20 })
45+
@ApiQuery({
46+
name: 'isRead',
47+
required: false,
48+
type: Boolean,
49+
description: 'Filter by read/unread status',
50+
})
51+
@ApiQuery({
52+
name: 'type',
53+
required: false,
54+
type: String,
55+
description: 'Filter by notification type',
56+
})
57+
@ApiResponse({
58+
status: 200,
59+
description: 'Paginated notifications list',
60+
type: PaginatedNotificationsResponseDto,
61+
})
62+
@ApiResponse({ status: 401, description: 'Unauthorized' })
63+
async getNotifications(
64+
@CurrentUser() user: JwtPayload,
65+
@Query() query: GetNotificationsQueryDto,
66+
): Promise<PaginatedNotificationsResponseDto> {
67+
return this.notificationsService.findAllForUser(user.sub, query);
68+
}
69+
70+
/**
71+
* GET /api/notifications/count
72+
* Returns the unread notification count for the authenticated user.
73+
*/
74+
@Get('count')
75+
@HttpCode(HttpStatus.OK)
76+
@ApiOperation({
77+
summary: 'Get unread notification count',
78+
description:
79+
'Returns a simple count object representing the number of unread notifications. Used for UI badge indicators.',
80+
})
81+
@ApiResponse({
82+
status: 200,
83+
description: 'Unread notification count',
84+
type: UnreadCountResponseDto,
85+
})
86+
@ApiResponse({ status: 401, description: 'Unauthorized' })
87+
async getUnreadCount(
88+
@CurrentUser() user: JwtPayload,
89+
): Promise<UnreadCountResponseDto> {
90+
const count = await this.notificationsService.getUnreadCount(user.sub);
91+
return { count };
92+
}
93+
}

0 commit comments

Comments
 (0)