Skip to content

Commit fb75c49

Browse files
committed
feat(backend): implement push notification delivery service via FCM
- Fix env.fcm as plain object (read at import time) → convert to getter so FCM_ENABLED is evaluated at runtime after .env is loaded - Scope stale token cleanup in sendToUser to user_address to avoid deleting tokens belonging to other users sharing the same device token - Fix push-notification spec mock: select().eq() now returns a Promise matching the real Supabase async chain instead of a plain object - Add @UsePipes(DeviceTokenSchema) to DELETE /notifications/device-token which was missing body validation - Add Swagger decorators (@apitags, @ApiBearerAuth, @apioperation, @apiresponse) to all notification endpoints Closes: push notification delivery service issue
1 parent 2ba191a commit fb75c49

4 files changed

Lines changed: 43 additions & 14 deletions

File tree

backend/src/api/rest/notifications/notifications.controller.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ import {
1010
HttpStatus,
1111
UsePipes,
1212
} from '@nestjs/common';
13+
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
1314
import { CurrentUser } from '../../../auth/decorators/current-user.decorator';
1415
import { NotificationsService } from './notifications.service';
1516
import { SubscribeSchema, type SubscribeDto } from './dto';
1617
import { DeviceTokenSchema, type DeviceTokenDto } from './dto/device-token.dto';
1718
import { createZodPipe } from '../raffles/pipes/zod-validation.pipe';
1819

20+
@ApiTags('Notifications')
21+
@ApiBearerAuth()
1922
@Controller('notifications')
2023
export class NotificationsController {
2124
constructor(private readonly notificationsService: NotificationsService) {}
@@ -25,6 +28,8 @@ export class NotificationsController {
2528
* Requires JWT (SIWS)
2629
*/
2730
@Post('subscribe')
31+
@ApiOperation({ summary: 'Subscribe to raffle notifications' })
32+
@ApiResponse({ status: 201, description: 'Subscription created or returned if already exists' })
2833
@UsePipes(new (createZodPipe(SubscribeSchema))())
2934
async subscribe(
3035
@Body() dto: SubscribeDto,
@@ -42,6 +47,8 @@ export class NotificationsController {
4247
* Requires JWT (SIWS)
4348
*/
4449
@Delete('subscribe/:raffleId')
50+
@ApiOperation({ summary: 'Unsubscribe from raffle notifications' })
51+
@ApiResponse({ status: 204, description: 'Unsubscribed successfully' })
4552
@HttpCode(HttpStatus.NO_CONTENT)
4653
async unsubscribe(
4754
@Param('raffleId', ParseIntPipe) raffleId: number,
@@ -55,6 +62,8 @@ export class NotificationsController {
5562
* Requires JWT (SIWS)
5663
*/
5764
@Get('subscriptions')
65+
@ApiOperation({ summary: 'Get all raffle subscriptions for the authenticated user' })
66+
@ApiResponse({ status: 200, description: 'List of subscriptions' })
5867
async getUserSubscriptions(@CurrentUser('address') userAddress: string) {
5968
return this.notificationsService.getUserSubscriptions(userAddress);
6069
}
@@ -64,6 +73,8 @@ export class NotificationsController {
6473
* Requires JWT (SIWS)
6574
*/
6675
@Post('device-token')
76+
@ApiOperation({ summary: 'Register a FCM device token for push notifications' })
77+
@ApiResponse({ status: 201, description: 'Token registered' })
6778
@UsePipes(new (createZodPipe(DeviceTokenSchema))())
6879
async registerDeviceToken(
6980
@Body() dto: DeviceTokenDto,
@@ -81,6 +92,9 @@ export class NotificationsController {
8192
* Requires JWT (SIWS)
8293
*/
8394
@Delete('device-token')
95+
@ApiOperation({ summary: 'Unregister a FCM device token' })
96+
@ApiResponse({ status: 200, description: 'Token removed' })
97+
@UsePipes(new (createZodPipe(DeviceTokenSchema))())
8498
async unregisterDeviceToken(
8599
@Body() dto: DeviceTokenDto,
86100
@CurrentUser('address') userAddress: string,

backend/src/config/env.config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ export const env = {
2727
domain: process.env.SIWS_DOMAIN ?? "tikka.io",
2828
};
2929
},
30-
fcm: {
31-
enabled: process.env.FCM_ENABLED === 'true',
32-
serviceAccountJson: process.env.FCM_SERVICE_ACCOUNT_JSON ?? undefined,
33-
serviceAccountPath: process.env.FCM_SERVICE_ACCOUNT_PATH ?? undefined,
30+
get fcm() {
31+
return {
32+
enabled: process.env.FCM_ENABLED === 'true',
33+
serviceAccountJson: process.env.FCM_SERVICE_ACCOUNT_JSON ?? undefined,
34+
serviceAccountPath: process.env.FCM_SERVICE_ACCOUNT_PATH ?? undefined,
35+
};
3436
},
3537
} as const;

backend/src/services/push-notification.service.spec.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,40 @@ import { Test, TestingModule } from '@nestjs/testing';
22
import { NotFoundException, InternalServerErrorException } from '@nestjs/common';
33
import { PushNotificationService } from './push-notification.service';
44
import { SUPABASE_CLIENT } from './supabase.provider';
5-
import { env } from '../config/env.config';
65

76
describe('PushNotificationService', () => {
87
let service: PushNotificationService;
9-
const mockSelect = jest.fn();
8+
9+
// Tracks what mockSelect should resolve to for the next call
10+
let selectResult: { data: unknown; error: unknown } = { data: [], error: null };
11+
12+
const mockEq = jest.fn();
13+
const mockIn = jest.fn();
1014
const mockSingle = jest.fn();
1115
const mockUpsert = jest.fn(() => ({ select: () => ({ single: mockSingle }) }));
12-
const mockDelete = jest.fn(() => ({ eq: jest.fn().mockReturnThis(), in: jest.fn().mockReturnThis() }));
16+
const mockDelete = jest.fn(() => ({
17+
eq: jest.fn().mockReturnThis(),
18+
in: jest.fn().mockResolvedValue({ error: null }),
19+
}));
20+
21+
// select('*').eq(...) must return a Promise
22+
const mockSelect = jest.fn(() => ({
23+
eq: jest.fn().mockResolvedValue(selectResult),
24+
}));
1325

1426
const supabaseMock = {
1527
from: jest.fn(() => ({
1628
upsert: mockUpsert,
1729
delete: mockDelete,
1830
select: mockSelect,
19-
eq: jest.fn().mockReturnThis(),
20-
in: jest.fn().mockReturnThis(),
31+
eq: mockEq,
32+
in: mockIn,
2133
})),
2234
};
2335

2436
beforeEach(async () => {
2537
jest.clearAllMocks();
38+
selectResult = { data: [], error: null };
2639

2740
const module: TestingModule = await Test.createTestingModule({
2841
providers: [
@@ -46,29 +59,28 @@ describe('PushNotificationService', () => {
4659
});
4760

4861
it('unregisters a device token', async () => {
49-
mockDelete.mockReturnValue({ eq: jest.fn().mockReturnThis(), in: jest.fn().mockReturnThis() });
5062
await service.unregisterDeviceToken('GABC', 'tok');
5163
expect(supabaseMock.from).toHaveBeenCalledWith('push_tokens');
5264
expect(mockDelete).toHaveBeenCalled();
5365
});
5466

5567
it('gets device tokens for a user', async () => {
56-
mockSelect.mockReturnValue({ eq: jest.fn().mockReturnThis(), data: [{ user_address: 'GABC', device_token: 'tok', platform: 'fcm' }], error: null });
68+
selectResult = { data: [{ user_address: 'GABC', device_token: 'tok', platform: 'fcm' }], error: null };
5769

5870
const tokens = await service.getDeviceTokens('GABC');
5971

6072
expect(tokens).toEqual([{ user_address: 'GABC', device_token: 'tok', platform: 'fcm' }]);
6173
expect(supabaseMock.from).toHaveBeenCalledWith('push_tokens');
74+
expect(mockSelect).toHaveBeenCalledWith('*');
6275
});
6376

6477
it('throws NotFoundException when sendToUser has no tokens', async () => {
65-
mockSelect.mockReturnValue({ eq: jest.fn().mockReturnThis(), data: [], error: null });
78+
selectResult = { data: [], error: null };
6679
await expect(service.sendToUser('GABC', { title: 'hi', body: 'hello' })).rejects.toThrow(NotFoundException);
6780
});
6881

6982
it('throws InternalServerErrorException when FCM is not configured', async () => {
70-
mockSelect.mockReturnValue({ eq: jest.fn().mockReturnThis(), data: [{ user_address: 'GABC', device_token: 'tok', platform: 'fcm' }], error: null });
71-
83+
selectResult = { data: [{ user_address: 'GABC', device_token: 'tok', platform: 'fcm' }], error: null };
7284
await expect(service.sendToUser('GABC', { title: 'hi', body: 'hello' })).rejects.toThrow(InternalServerErrorException);
7385
});
7486
});

backend/src/services/push-notification.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export class PushNotificationService {
176176
await this.client
177177
.from(TABLE)
178178
.delete()
179+
.eq('user_address', userAddress)
179180
.in('device_token', invalidTokens);
180181

181182
this.logger.log(`Removed ${invalidTokens.length} stale FCM token(s)`);

0 commit comments

Comments
 (0)