Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NotificationAggregationService } from '../services/notification-aggregation.service';

describe('NotificationAggregationService', () => {
it('can be instantiated', () => {
const service = new NotificationAggregationService();

expect(service).toBeDefined();
});
});
12 changes: 12 additions & 0 deletions src/notifications/__tests__/notification-delivery.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NotificationDeliveryService } from '../services/notification-delivery.service';

describe('NotificationDeliveryService', () => {
it('can be instantiated', () => {
const service = new NotificationDeliveryService(
{} as any,
{} as any,
);

expect(service).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotificationPreference } from '../entities/notification-preference.entity';
import { NotificationPreferencesService } from '../services/notification-preferences.service';
import { NotificationType } from '../enums/notification-type.enum';

describe('NotificationPreferencesService', () => {
const repository = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};

let service: NotificationPreferencesService;

beforeEach(async () => {
jest.clearAllMocks();

const module = await Test.createTestingModule({
providers: [
NotificationPreferencesService,
{
provide: getRepositoryToken(NotificationPreference),
useValue: repository,
},
],
}).compile();

service = module.get(NotificationPreferencesService);
});

it('creates default preferences when none exist', async () => {
repository.findOne.mockResolvedValue(null);
repository.create.mockReturnValue({ userId: 'u1' });
repository.save.mockResolvedValue({ userId: 'u1' });

await service.getOrCreate('u1');

expect(repository.create).toHaveBeenCalledWith({ userId: 'u1' });
expect(repository.save).toHaveBeenCalled();
});

it('respects disabled notification preference', async () => {
repository.findOne.mockResolvedValue({
userId: 'u1',
questCompleted: false,
});

await expect(
service.isEnabled('u1', NotificationType.QUEST_COMPLETED),
).resolves.toBe(false);
});

it('returns true for enabled notification preference', async () => {
repository.findOne.mockResolvedValue({
userId: 'u1',
questCompleted: true,
});

await expect(
service.isEnabled('u1', NotificationType.QUEST_COMPLETED),
).resolves.toBe(true);
});
});
61 changes: 61 additions & 0 deletions src/notifications/__tests__/notifications.gateway.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NotificationsGateway } from '../gateways/notifications.gateway';

describe('NotificationsGateway', () => {
const auth = {
authenticate: jest.fn(),
};

const delivery = {
acknowledge: jest.fn(),
};

let gateway: NotificationsGateway;

beforeEach(() => {
gateway = new NotificationsGateway(
auth as any,
delivery as any,
);

(gateway as any).server = {
to: jest.fn().mockReturnThis(),
emit: jest.fn(),
};
});

it('tracks connected users', () => {
auth.authenticate.mockReturnValue('u1');

gateway.handleConnection({
id: 'socket-1',
handshake: {},
data: {},
join: jest.fn(),
} as any);

expect(gateway.isUserOnline('u1')).toBe(true);
});

it('removes users after their last socket disconnects', () => {
auth.authenticate.mockReturnValue('u1');

const client: any = {
id: 'socket-1',
handshake: {},
data: {},
join: jest.fn(),
disconnect: jest.fn(),
};

gateway.handleConnection(client);
gateway.handleDisconnect(client);

expect(gateway.isUserOnline('u1')).toBe(false);
});

it('does not deliver to offline users', () => {
const result = gateway.sendToUser('missing', {} as any);

expect(result).toBe(false);
});
});
13 changes: 13 additions & 0 deletions src/notifications/__tests__/notifications.processor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NotificationsProcessor } from '../processors/notifications.processor';

describe('NotificationsProcessor', () => {
it('can be instantiated', () => {
const processor = new NotificationsProcessor(
{} as any,
{} as any,
{} as any,
);

expect(processor).toBeDefined();
});
});
16 changes: 16 additions & 0 deletions src/notifications/__tests__/notifications.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NotificationsService } from '../services/notifications.service';

describe('NotificationsService', () => {
it('can be instantiated', () => {
const service = new NotificationsService(
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);

expect(service).toBeDefined();
});
});
21 changes: 21 additions & 0 deletions src/notifications/auth/notification-socket-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { Socket } from 'socket.io';

/**
* Replace this implementation with the repository's existing JWT/session
* authentication. The gateway must derive the user ID from a verified
* credential and must never trust an arbitrary client-supplied userId.
*/
@Injectable()
export class NotificationSocketAuth {
authenticate(client: Socket): string | null {
// Development scaffold only.
// Production: verify client.handshake.auth.token and return the
// authenticated subject/user ID.
const userId = client.handshake.auth?.userId;

return typeof userId === 'string' && userId.length > 0
? userId
: null;
}
}
95 changes: 95 additions & 0 deletions src/notifications/controllers/notifications.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
Req,
} from '@nestjs/common';

import { NotificationsService } from '../services/notifications.service';
import { NotificationPreferencesService } from '../services/notification-preferences.service';
import { NotificationQueryDto } from '../dto/notification-query.dto';
import { UpdateNotificationPreferencesDto } from '../dto/update-notification-preferences.dto';

/**
* Adapt req.user access to the authentication conventions already used
* by quest-service.
*/
@Controller('api/v1/notifications')
export class NotificationsController {
constructor(
private readonly notificationsService: NotificationsService,
private readonly preferencesService: NotificationPreferencesService,
) {}

@Get()
history(
@Req() req: any,
@Query() query: NotificationQueryDto,
) {
const userId = this.getUserId(req);

return this.notificationsService.history(
userId,
query.page,
query.limit,
);
}

@Get('unread-count')
unreadCount(@Req() req: any) {
return this.notificationsService.unreadCount(
this.getUserId(req),
);
}

@Patch(':id/read')
markRead(
@Req() req: any,
@Param('id') id: string,
) {
return this.notificationsService.markRead(
this.getUserId(req),
id,
);
}

@Patch('read-all')
markAllRead(@Req() req: any) {
return this.notificationsService.markAllRead(
this.getUserId(req),
);
}

@Get('preferences')
preferences(@Req() req: any) {
return this.preferencesService.getOrCreate(
this.getUserId(req),
);
}

@Patch('preferences')
updatePreferences(
@Req() req: any,
@Body() body: UpdateNotificationPreferencesDto,
) {
return this.preferencesService.update(
this.getUserId(req),
body,
);
}

private getUserId(req: any): string {
const userId = req.user?.id ?? req.user?.sub;

if (!userId) {
throw new Error(
'Authenticated user ID is required. Apply the project auth guard.',
);
}

return String(userId);
}
}
95 changes: 95 additions & 0 deletions src/notifications/db/001_create_notifications.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'notification_status'
) THEN
CREATE TYPE notification_status AS ENUM (
'PENDING',
'PROCESSING',
'DELIVERED',
'READ',
'FAILED'
);
END IF;

IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'notification_delivery_status'
) THEN
CREATE TYPE notification_delivery_status AS ENUM (
'PENDING',
'DELIVERED',
'FAILED'
);
END IF;

IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'notification_type'
) THEN
CREATE TYPE notification_type AS ENUM (
'QUEST_ASSIGNED',
'QUEST_COMPLETED',
'QUEST_APPROVED',
'QUEST_REJECTED',
'REWARD_RECEIVED',
'ACHIEVEMENT_UNLOCKED',
'LEVEL_UP',
'BADGE_EARNED',
'SYSTEM'
);
END IF;
END $$;

CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(128) NOT NULL,
type notification_type NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
data JSONB,
status notification_status NOT NULL DEFAULT 'PENDING',
read_at TIMESTAMPTZ,
deduplication_key VARCHAR(255) UNIQUE,
aggregation_key VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_notifications_user_created
ON notifications(user_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_notifications_user_status
ON notifications(user_id, status);

CREATE TABLE IF NOT EXISTS notification_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id UUID NOT NULL UNIQUE
REFERENCES notifications(id) ON DELETE CASCADE,
status notification_delivery_status NOT NULL DEFAULT 'PENDING',
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failure_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(128) NOT NULL UNIQUE,

quest_assigned BOOLEAN NOT NULL DEFAULT TRUE,
quest_completed BOOLEAN NOT NULL DEFAULT TRUE,
quest_approved BOOLEAN NOT NULL DEFAULT TRUE,
quest_rejected BOOLEAN NOT NULL DEFAULT TRUE,
reward_received BOOLEAN NOT NULL DEFAULT TRUE,
achievement_unlocked BOOLEAN NOT NULL DEFAULT TRUE,
level_up BOOLEAN NOT NULL DEFAULT TRUE,
badge_earned BOOLEAN NOT NULL DEFAULT TRUE,
system BOOLEAN NOT NULL DEFAULT TRUE,

created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Loading
Loading