From 994d9d77024b8aa97af617fcd015b04443918ae3 Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Sat, 25 Jul 2026 22:38:28 +0100 Subject: [PATCH] feat(backend): notify on subscription charge, fix realtime room-broadcast tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #965: add a `subscription_charged` notification type, gated by a new per-user `subscriptionCharged` preference (default enabled), and fire it from the indexer's sub_exec projection when a charge is genuinely new (event-log gated, so replays never duplicate it). - #957: fix gateway.test.ts, which was signing tokens with a stale `sub` claim and asserting a stale error message — both leftovers from a prior auth/error-shape change that left the auth-handshake tests silently broken (one timing out, one failing). Add missing room-broadcast coverage for tip.created and notification.created, which had no tests. - #961 (POST /notifications/read-all) and #962 (notification creation service) were already fully implemented and tested on this branch by prior work; verified against the Definition of Done and left as-is. Closes #961, #962, #965, #957 --- .../migration.sql | 2 + backend/prisma/schema.prisma | 15 ++- backend/src/indexer/projections.test.ts | 21 +++ backend/src/indexer/projections.ts | 21 ++- .../notifications/notifications.routes.ts | 4 +- .../notifications/notifications.schema.ts | 1 + .../notifications/notifications.service.ts | 15 ++- .../notifications/notifications.test.ts | 74 ++++++++++- .../notifications/notifications.types.ts | 3 +- backend/src/realtime/gateway.test.ts | 123 +++++++++++++++++- 10 files changed, 257 insertions(+), 22 deletions(-) create mode 100644 backend/prisma/migrations/20260725120000_add_subscription_charged_preference/migration.sql diff --git a/backend/prisma/migrations/20260725120000_add_subscription_charged_preference/migration.sql b/backend/prisma/migrations/20260725120000_add_subscription_charged_preference/migration.sql new file mode 100644 index 00000000..5d9db693 --- /dev/null +++ b/backend/prisma/migrations/20260725120000_add_subscription_charged_preference/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "NotificationPreference" ADD COLUMN "subscriptionCharged" BOOLEAN NOT NULL DEFAULT true; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cc5cc581..12505168 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -182,13 +182,14 @@ model Notification { /// Per-user toggles controlling which notification types are created for them. /// Missing rows are treated as all-enabled (see notifications module defaults). model NotificationPreference { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - tipReceived Boolean @default(true) - goalReached Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tipReceived Boolean @default(true) + goalReached Boolean @default(true) + subscriptionCharged Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } /// Cached X (Twitter) account metrics. diff --git a/backend/src/indexer/projections.test.ts b/backend/src/indexer/projections.test.ts index 0b369554..b467f4fa 100644 --- a/backend/src/indexer/projections.test.ts +++ b/backend/src/indexer/projections.test.ts @@ -314,6 +314,27 @@ describe('projectEvent — subscriptions (#900)', () => { await projectEvent(event('sub_created', [ADDR_A, ADDR_B, 'nope', 7])); expect(mockSubUpsert).not.toHaveBeenCalled(); }); + + it('notifies the creator of a new charge (#965)', async () => { + mockEventLogFindFirst.mockResolvedValue(null); + await projectEvent(event('sub_exec', [ADDR_A, ADDR_B, '500'])); + expect(mockCreateNotification).toHaveBeenCalledWith('u_' + ADDR_B, 'subscription_charged', { + tipperId: 'u_' + ADDR_A, + amountStroops: '500', + }); + }); + + it('does not re-notify when replaying an already-logged charge (#965)', async () => { + mockEventLogFindFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'existing' }); + await projectEvent(event('sub_exec', [ADDR_A, ADDR_B, '500'])); + await projectEvent(event('sub_exec', [ADDR_A, ADDR_B, '500'])); + expect(mockCreateNotification).toHaveBeenCalledTimes(1); + }); + + it('skips notifying when the charge event is unparseable', async () => { + await projectEvent(event('sub_exec', [ADDR_A, ADDR_B, 'nope'])); + expect(mockCreateNotification).not.toHaveBeenCalled(); + }); }); describe('projectEvent — tip idempotency (#892)', () => { diff --git a/backend/src/indexer/projections.ts b/backend/src/indexer/projections.ts index 579536bc..cbab0696 100644 --- a/backend/src/indexer/projections.ts +++ b/backend/src/indexer/projections.ts @@ -16,7 +16,7 @@ const REFUND_TOPICS = new Set(['refund', 'tip_refund']); * the same event never produces a duplicate row. Topics are the canonical * `_`-joined names decoded from the contract's topic tuples (see `decodeTopic`). */ -const PROJECTIONS: Record Promise> = { +const PROJECTIONS: Record Promise> = { profile_register: projectProfileRegistered, profile_updated: projectProfileUpdated, goal_set: projectGoalSet, @@ -49,7 +49,7 @@ export async function projectEvent(event: DecodedEvent): Promise { const handler = PROJECTIONS[event.topic]; if (handler) { - await handler(event); + await handler(event, isNewEvent); } if (REFUND_TOPICS.has(event.topic)) { await projectRefund(event); @@ -392,8 +392,12 @@ async function projectSubscriptionCreated(event: DecodedEvent): Promise { * Project a `("sub", "exec")` event — data `(subscriber, creator, amount)`. This * confirms a successful recurring charge; the subscription is ensured ACTIVE and * its charged amount recorded. Per-charge history is out of scope (no table). + * + * Notifies the creator of the charge, but only for genuinely new events — + * `isNewEvent` (from the event log) gates this, since the upsert itself is + * idempotent and would otherwise re-notify on every replay of the same ledgers. */ -async function projectSubscriptionCharged(event: DecodedEvent): Promise { +async function projectSubscriptionCharged(event: DecodedEvent, isNewEvent: boolean): Promise { const [subscriber, creator, amount] = tupleArgs(event.value); const amountStroops = toBigInt(amount); if (typeof subscriber !== 'string' || typeof creator !== 'string' || amountStroops === null) { @@ -416,6 +420,17 @@ async function projectSubscriptionCharged(event: DecodedEvent): Promise { }, update: { amountStroops, status: 'ACTIVE' }, }); + + if (isNewEvent) { + try { + await notificationsService.createNotification(creatorId, 'subscription_charged', { + tipperId, + amountStroops: amountStroops.toString(), + }); + } catch (err) { + logger.error({ err, creatorId }, 'Failed to notify creator of subscription charge'); + } + } } /** Project a `("sub", "cancel")` event — data `(subscriber, creator)`. */ diff --git a/backend/src/modules/notifications/notifications.routes.ts b/backend/src/modules/notifications/notifications.routes.ts index f0d8c65f..256ee712 100644 --- a/backend/src/modules/notifications/notifications.routes.ts +++ b/backend/src/modules/notifications/notifications.routes.ts @@ -35,9 +35,10 @@ const notificationPreferenceSchema = { properties: { tipReceived: { type: 'boolean', example: true }, goalReached: { type: 'boolean', example: true }, + subscriptionCharged: { type: 'boolean', example: true }, updatedAt: { type: 'string', format: 'date-time' }, }, - required: ['tipReceived', 'goalReached', 'updatedAt'], + required: ['tipReceived', 'goalReached', 'subscriptionCharged', 'updatedAt'], }; mergeOpenApiPaths({ @@ -160,6 +161,7 @@ mergeOpenApiPaths({ properties: { tipReceived: { type: 'boolean' }, goalReached: { type: 'boolean' }, + subscriptionCharged: { type: 'boolean' }, }, }, }, diff --git a/backend/src/modules/notifications/notifications.schema.ts b/backend/src/modules/notifications/notifications.schema.ts index 0773072a..c12061e0 100644 --- a/backend/src/modules/notifications/notifications.schema.ts +++ b/backend/src/modules/notifications/notifications.schema.ts @@ -17,6 +17,7 @@ export const updateNotificationPreferencesSchema = z .object({ tipReceived: z.boolean().optional(), goalReached: z.boolean().optional(), + subscriptionCharged: z.boolean().optional(), }) .refine((data) => Object.keys(data).length > 0, { message: 'At least one preference must be provided', diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 14827aa1..bd070a8c 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -12,9 +12,13 @@ import type { } from './notifications.types.js'; /** Maps a notification type to the preference field gating its delivery. */ -const PREFERENCE_FIELD_BY_TYPE: Record = { +const PREFERENCE_FIELD_BY_TYPE: Record< + NotificationType, + 'tipReceived' | 'goalReached' | 'subscriptionCharged' +> = { tip_received: 'tipReceived', goal_reached: 'goalReached', + subscription_charged: 'subscriptionCharged', }; function formatNotification(n: { @@ -122,11 +126,13 @@ export async function getUnreadCount(userId: string): Promise { const pref = await prisma.notificationPreference.findUnique({ where: { userId } }); if (!pref) { - return { tipReceived: true, goalReached: true, updatedAt: new Date(0).toISOString() }; + return { + tipReceived: true, + goalReached: true, + subscriptionCharged: true, + updatedAt: new Date(0).toISOString(), + }; } return formatPreferences(pref); } diff --git a/backend/src/modules/notifications/notifications.test.ts b/backend/src/modules/notifications/notifications.test.ts index 7c27f2af..4a16c1ba 100644 --- a/backend/src/modules/notifications/notifications.test.ts +++ b/backend/src/modules/notifications/notifications.test.ts @@ -395,6 +395,7 @@ describe('getPreferences', () => { expect(result.tipReceived).toBe(true); expect(result.goalReached).toBe(true); + expect(result.subscriptionCharged).toBe(true); }); it('returns the stored preference row when it exists', async () => { @@ -402,6 +403,7 @@ describe('getPreferences', () => { mockPrefFindUnique.mockResolvedValue({ tipReceived: false, goalReached: true, + subscriptionCharged: false, updatedAt, }); @@ -410,6 +412,7 @@ describe('getPreferences', () => { expect(result).toEqual({ tipReceived: false, goalReached: true, + subscriptionCharged: false, updatedAt: updatedAt.toISOString(), }); }); @@ -422,7 +425,12 @@ describe('updatePreferences', () => { it('upserts the preference row with the given patch', async () => { const updatedAt = new Date('2026-07-25T12:00:00.000Z'); - mockPrefUpsert.mockResolvedValue({ tipReceived: false, goalReached: true, updatedAt }); + mockPrefUpsert.mockResolvedValue({ + tipReceived: false, + goalReached: true, + subscriptionCharged: true, + updatedAt, + }); const result = await updatePreferences('user-1', { tipReceived: false }); @@ -433,6 +441,25 @@ describe('updatePreferences', () => { update: { tipReceived: false }, }); }); + + it('upserts the subscriptionCharged preference', async () => { + const updatedAt = new Date('2026-07-25T12:00:00.000Z'); + mockPrefUpsert.mockResolvedValue({ + tipReceived: true, + goalReached: true, + subscriptionCharged: false, + updatedAt, + }); + + const result = await updatePreferences('user-1', { subscriptionCharged: false }); + + expect(result.subscriptionCharged).toBe(false); + expect(mockPrefUpsert).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + create: { userId: 'user-1', subscriptionCharged: false }, + update: { subscriptionCharged: false }, + }); + }); }); describe('createNotification', () => { @@ -492,6 +519,51 @@ describe('createNotification', () => { expect(result).not.toBeNull(); expect(mockCreate).toHaveBeenCalled(); }); + + it('creates and emits a subscription_charged notification (#965)', async () => { + mockPrefFindUnique.mockResolvedValue(null); + const createdAt = new Date('2026-07-25T12:00:00.000Z'); + mockCreate.mockResolvedValue({ + id: 'notif-3', + type: 'subscription_charged', + payload: { tipperId: 'user-2', amountStroops: '500' }, + readAt: null, + createdAt, + }); + + const result = await createNotification('user-1', 'subscription_charged', { + tipperId: 'user-2', + amountStroops: '500', + }); + + expect(result).not.toBeNull(); + expect(mockCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + type: 'subscription_charged', + payload: { tipperId: 'user-2', amountStroops: '500' }, + }, + }); + expect(mockEmitNotificationCreated).toHaveBeenCalledWith( + expect.objectContaining({ id: 'notif-3', type: 'subscription_charged' }), + ); + }); + + it('skips creation when the user disabled subscription_charged notifications', async () => { + mockPrefFindUnique.mockResolvedValue({ + tipReceived: true, + goalReached: true, + subscriptionCharged: false, + }); + + const result = await createNotification('user-1', 'subscription_charged', { + tipperId: 'user-2', + amountStroops: '500', + }); + + expect(result).toBeNull(); + expect(mockCreate).not.toHaveBeenCalled(); + }); }); describe('GET /api/v1/notifications/unread-count', () => { diff --git a/backend/src/modules/notifications/notifications.types.ts b/backend/src/modules/notifications/notifications.types.ts index c9d598d6..2c50c7d9 100644 --- a/backend/src/modules/notifications/notifications.types.ts +++ b/backend/src/modules/notifications/notifications.types.ts @@ -17,7 +17,7 @@ export interface NotificationListResponse { } /** Notification type discriminators used by the createNotification triggers. */ -export type NotificationType = 'tip_received' | 'goal_reached'; +export type NotificationType = 'tip_received' | 'goal_reached' | 'subscription_charged'; export interface UnreadCountResponse { count: number; @@ -26,5 +26,6 @@ export interface UnreadCountResponse { export interface NotificationPreferenceResponse { tipReceived: boolean; goalReached: boolean; + subscriptionCharged: boolean; updatedAt: string; } diff --git a/backend/src/realtime/gateway.test.ts b/backend/src/realtime/gateway.test.ts index 62a313c9..d2d5e3ae 100644 --- a/backend/src/realtime/gateway.test.ts +++ b/backend/src/realtime/gateway.test.ts @@ -4,10 +4,15 @@ import jwt from 'jsonwebtoken'; import { io as ioClient, type Socket as ClientSocket } from 'socket.io-client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { config } from '../config/index.js'; -import { initRealtime, emitBalanceUpdated } from './gateway.js'; - -function makeToken(payload: { sub: string; stellarAddress: string }): string { - return jwt.sign(payload, config.auth.jwtSecret, { expiresIn: '15m' }); +import { initRealtime, emitBalanceUpdated, emitTipCreated, emitNotificationCreated } from './gateway.js'; +import type { TipResponseDto } from '../modules/tips/tips.dto.js'; + +function makeToken(payload: { userId: string; stellarAddress: string }): string { + return jwt.sign( + { ...payload, role: 'user', scopes: [] }, + config.auth.jwtSecret, + { expiresIn: '15m' }, + ); } describe('balance.updated (issue #951)', () => { @@ -29,7 +34,7 @@ describe('balance.updated (issue #951)', () => { it('delivers balance.updated only to the balance owner, after they subscribe', async () => { const userId = 'user-1'; - const token = makeToken({ sub: userId, stellarAddress: 'GOWNER' }); + const token = makeToken({ userId, stellarAddress: 'GOWNER' }); clientSocket = ioClient(`http://localhost:${port}`, { auth: { token }, @@ -58,7 +63,7 @@ describe('balance.updated (issue #951)', () => { }); it('rejects subscribing to another user\'s balance room', async () => { - const token = makeToken({ sub: 'user-1', stellarAddress: 'GOWNER' }); + const token = makeToken({ userId: 'user-1', stellarAddress: 'GOWNER' }); clientSocket = ioClient(`http://localhost:${port}`, { auth: { token }, @@ -70,7 +75,7 @@ describe('balance.updated (issue #951)', () => { const errorEvent = new Promise((resolve) => clientSocket.on('error', resolve)); clientSocket.emit('subscribe:notifications', 'someone-elses-id'); - await expect(errorEvent).resolves.toMatchObject({ message: 'Forbidden' }); + await expect(errorEvent).resolves.toMatchObject({ code: 'FORBIDDEN' }); }); it('rejects a connection with no auth token', async () => { @@ -85,3 +90,107 @@ describe('balance.updated (issue #951)', () => { expect(err.message).toMatch(/token/i); }); }); + +describe('room broadcasts (issue #957)', () => { + let httpServer: ReturnType; + let port: number; + let clientSocket: ClientSocket; + + beforeEach(async () => { + httpServer = createServer(); + initRealtime(httpServer); + await new Promise((resolve) => httpServer.listen(0, resolve)); + port = (httpServer.address() as AddressInfo).port; + }); + + afterEach(() => { + clientSocket?.close(); + httpServer.close(); + }); + + it('delivers tip.created only to sockets subscribed to the creator room', async () => { + const token = makeToken({ userId: 'user-1', stellarAddress: 'GOWNER' }); + clientSocket = ioClient(`http://localhost:${port}`, { + auth: { token }, + transports: ['websocket'], + }); + + await new Promise((resolve) => clientSocket.on('connect', () => resolve())); + clientSocket.emit('subscribe:creator', 'GCREATOR'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const payload = new Promise((resolve) => clientSocket.on('tip.created', resolve)); + + const tip: TipResponseDto = { + id: 'tip-1', + txHash: 'tx-1', + ledger: 100, + fromAddress: 'GTIPPER', + toAddress: 'GCREATOR', + amountStroops: '5000000', + status: 'CONFIRMED', + message: null, + createdAt: new Date().toISOString(), + }; + emitTipCreated(tip); + + await expect(payload).resolves.toMatchObject({ id: 'tip-1', toAddress: 'GCREATOR' }); + }); + + it('does not deliver tip.created to a socket subscribed to a different creator room', async () => { + const token = makeToken({ userId: 'user-1', stellarAddress: 'GOWNER' }); + clientSocket = ioClient(`http://localhost:${port}`, { + auth: { token }, + transports: ['websocket'], + }); + + await new Promise((resolve) => clientSocket.on('connect', () => resolve())); + clientSocket.emit('subscribe:creator', 'GOTHERCREATOR'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + let received = false; + clientSocket.on('tip.created', () => { + received = true; + }); + + emitTipCreated({ + id: 'tip-2', + txHash: 'tx-2', + ledger: 101, + fromAddress: 'GTIPPER', + toAddress: 'GCREATOR', + amountStroops: '1000000', + status: 'CONFIRMED', + message: null, + createdAt: new Date().toISOString(), + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(received).toBe(false); + }); + + it('delivers notification.created only to the notified user\'s room', async () => { + const userId = 'user-1'; + const token = makeToken({ userId, stellarAddress: 'GOWNER' }); + clientSocket = ioClient(`http://localhost:${port}`, { + auth: { token }, + transports: ['websocket'], + }); + + await new Promise((resolve) => clientSocket.on('connect', () => resolve())); + clientSocket.emit('subscribe:notifications', userId); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const payload = new Promise((resolve) => clientSocket.on('notification.created', resolve)); + + emitNotificationCreated({ + id: 'notif-1', + userId, + type: 'subscription_charged', + payload: { amountStroops: '500' }, + createdAt: new Date().toISOString(), + }); + + await expect(payload).resolves.toMatchObject({ id: 'notif-1', userId, type: 'subscription_charged' }); + }); +});