diff --git a/backend/prisma/migrations/20260725033000_add_notification_preference/migration.sql b/backend/prisma/migrations/20260725033000_add_notification_preference/migration.sql new file mode 100644 index 00000000..70979b11 --- /dev/null +++ b/backend/prisma/migrations/20260725033000_add_notification_preference/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "NotificationPreference" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tipReceived" BOOLEAN NOT NULL DEFAULT true, + "goalReached" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId"); + +-- AddForeignKey +ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 26eef106..90e92cbd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,57 +15,47 @@ datasource db { /// A creator/user identified by their Stellar wallet address. /// Extended by credit, tips and other modules in later issues. model User { - id String @id @default(cuid()) - stellarAddress String @unique - username String? @unique - displayName String? - bio String? - imageUrl String? - avatarCid String? - xHandle String? - role String @default("user") - scopes String[] @default([]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - apiKeys ApiKey[] - refreshTokens RefreshToken[] - goals Goal[] - sentTips Tip[] @relation("SentTips") - receivedTips Tip[] @relation("ReceivedTips") - leaderboardSnapshots LeaderboardSnapshot[] - streak Streak? - notifications Notification[] - - tipperSubscriptions Subscription[] @relation("SubscriptionTipper") - creatorSubscriptions Subscription[] @relation("SubscriptionCreator") + id String @id @default(cuid()) + stellarAddress String @unique + username String? @unique + displayName String? + bio String? + imageUrl String? + avatarCid String? + xHandle String? + role String @default("user") + scopes String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt /// Soft-delete marker: non-null means the record is logically deleted. - deletedAt DateTime? - apiKeys ApiKey[] - refreshTokens RefreshToken[] - goals Goal[] - sentTips Tip[] @relation("SentTips") - receivedTips Tip[] @relation("ReceivedTips") - tipperSubscriptions Subscription[] @relation("SubscriptionTipper") - creatorSubscriptions Subscription[] @relation("SubscriptionCreator") - creditScore CreditScore? - creditScoreHistory CreditScoreHistory[] - withdrawals Withdrawal[] - notifications Notification[] - leaderboardSnapshots LeaderboardSnapshot[] - streak Streak? + deletedAt DateTime? + apiKeys ApiKey[] + refreshTokens RefreshToken[] + goals Goal[] + sentTips Tip[] @relation("SentTips") + receivedTips Tip[] @relation("ReceivedTips") + tipperSubscriptions Subscription[] @relation("SubscriptionTipper") + creatorSubscriptions Subscription[] @relation("SubscriptionCreator") + creditScore CreditScore? + creditScoreHistory CreditScoreHistory[] + withdrawals Withdrawal[] + notifications Notification[] + notificationPreference NotificationPreference? + leaderboardSnapshots LeaderboardSnapshot[] + streak Streak? @@index([createdAt]) } /// API key for service/admin access and webhook integrations. model ApiKey { - id String @id @default(cuid()) - hashedKey String @unique + id String @id @default(cuid()) + hashedKey String @unique scopes String[] createdById String - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt /// Soft-delete marker: non-null means the record is logically deleted. deletedAt DateTime? @@ -98,8 +88,8 @@ model Tip { updatedAt DateTime @updatedAt refund Refund? - sender User? @relation("SentTips", fields: [fromAddress], references: [stellarAddress]) - receiver User? @relation("ReceivedTips", fields: [toAddress], references: [stellarAddress]) + sender User? @relation("SentTips", fields: [fromAddress], references: [stellarAddress]) + receiver User? @relation("ReceivedTips", fields: [toAddress], references: [stellarAddress]) @@index([toAddress, createdAt]) @@index([fromAddress, createdAt]) @@ -189,6 +179,18 @@ model Notification { @@index([createdAt]) } +/// 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 +} + /// Cached X (Twitter) account metrics. model XAccount { id String @id @default(cuid()) @@ -200,13 +202,6 @@ model XAccount { updatedAt DateTime @updatedAt } -/// Status of a tip transaction. -enum TipStatus { - CONFIRMED - PENDING - REFUNDED -} - /// Leaderboard period tracked by snapshots. enum Period { WEEKLY @@ -244,16 +239,16 @@ model Streak { /// Created when a wallet requests a sign-in nonce; deleted (or expired) after verification. /// TTL is enforced by the application layer via AUTH_CHALLENGE_TTL_SECONDS. model AuthChallenge { - id String @id @default(cuid()) + id String @id @default(cuid()) /// The wallet address this challenge was issued for. - stellarAddress String + stellarAddress String /// Random nonce the wallet must sign to prove ownership. - challenge String @unique + challenge String @unique /// Network this challenge is bound to (TESTNET, FUTURENET, MAINNET). - network String + network String /// UTC timestamp after which the challenge is no longer valid. - expiresAt DateTime - usedAt DateTime? + expiresAt DateTime + usedAt DateTime? @@index([stellarAddress]) @@index([expiresAt]) @@ -420,5 +415,3 @@ model AuditLog { @@index([action]) @@index([createdAt]) } - - diff --git a/backend/src/app.ts b/backend/src/app.ts index 53b81c1f..eaa9502a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,18 +4,7 @@ import helmet from 'helmet'; import pinoHttp from 'pino-http'; import swaggerUi from 'swagger-ui-express'; import { env } from './config/env.js'; -import { - errorHandler, - notFoundHandler, -} from "./common/middleware/errorHandler.js"; -import { logger } from "./common/utils/logger.js"; -import { openApiDocument } from "./docs/openapi.js"; -import { authRouter } from "./modules/auth/auth.routes.js"; -import { profilesRouter } from "./modules/profiles/profiles.routes.js"; -import { creditRouter } from "./modules/credit/credit.routes.js"; -import { leaderboardRouter } from "./modules/leaderboard/leaderboard.routes.js"; -import { xRouter } from "./modules/x/x.routes.js"; -} from './common/middleware/errorHandler.js'; +import { errorHandler, notFoundHandler } from './common/middleware/errorHandler.js'; import { logger } from './common/utils/logger.js'; import { openApiDocument } from './docs/openapi.js'; import { requestId } from './common/middleware/requestId.js'; @@ -67,10 +56,6 @@ export function createApp(): Express { app.use(`${env.API_BASE_PATH}/profiles`, profilesRouter); app.use(`${env.API_BASE_PATH}/credit`, creditRouter); app.use(`${env.API_BASE_PATH}/leaderboard`, leaderboardRouter); - app.use(`${env.API_BASE_PATH}/x`, xRouter); - // app.use(`${env.API_BASE_PATH}/tips`, tipsRouter); - // ... (one issue per module) - // ───────────────────────────────────────────────────────────── app.use(`${env.API_BASE_PATH}/ipfs`, ipfsRouter); app.use(`${env.API_BASE_PATH}/tips`, tipsRouter); app.use(`${env.API_BASE_PATH}/withdrawals`, withdrawalsRouter); diff --git a/backend/src/common/errors/AppError.ts b/backend/src/common/errors/AppError.ts index fb874b60..4ffa16e0 100644 --- a/backend/src/common/errors/AppError.ts +++ b/backend/src/common/errors/AppError.ts @@ -40,11 +40,6 @@ export class ConflictError extends AppError { super(409, message, "CONFLICT"); } } -export class ServiceUnavailableError extends AppError { - constructor(message = "Service unavailable") { - super(503, message, "SERVICE_UNAVAILABLE"); - } -} export class BadGatewayError extends AppError { constructor(message = 'Bad gateway', details?: unknown) { diff --git a/backend/src/indexer/indexer.integration.test.ts b/backend/src/indexer/indexer.integration.test.ts index e4b1ab0a..70fdd5c0 100644 --- a/backend/src/indexer/indexer.integration.test.ts +++ b/backend/src/indexer/indexer.integration.test.ts @@ -36,6 +36,7 @@ const { mockUserUpsert, mockGoalUpsert, mockGoalUpdateMany, + mockGoalFindUnique, mockSubUpsert, mockSubUpdateMany, mockTipUpsert, @@ -44,10 +45,12 @@ const { mockCreditScoreUpsert, mockCreditScoreHistoryUpsert, mockPublishProjection, + mockCreateNotification, } = vi.hoisted(() => ({ mockUserUpsert: vi.fn(), mockGoalUpsert: vi.fn(), mockGoalUpdateMany: vi.fn(), + mockGoalFindUnique: vi.fn(), mockSubUpsert: vi.fn(), mockSubUpdateMany: vi.fn(), mockTipUpsert: vi.fn(), @@ -56,12 +59,13 @@ const { mockCreditScoreUpsert: vi.fn(), mockCreditScoreHistoryUpsert: vi.fn(), mockPublishProjection: vi.fn(), + mockCreateNotification: vi.fn(), })); vi.mock('../db/prisma.js', () => ({ prisma: { user: { upsert: mockUserUpsert }, - goal: { upsert: mockGoalUpsert, updateMany: mockGoalUpdateMany }, + goal: { upsert: mockGoalUpsert, updateMany: mockGoalUpdateMany, findUnique: mockGoalFindUnique }, subscription: { upsert: mockSubUpsert, updateMany: mockSubUpdateMany }, tip: { upsert: mockTipUpsert }, eventLog: { findFirst: mockEventLogFindFirst, create: mockEventLogCreate }, @@ -74,6 +78,10 @@ vi.mock('./realtime-publisher.js', () => ({ publishProjection: mockPublishProjection, })); +vi.mock('../modules/notifications/notifications.service.js', () => ({ + createNotification: mockCreateNotification, +})); + // ── Helpers ─────────────────────────────────────────────────────────────────── import { projectEvent } from './projections.js'; @@ -105,11 +113,13 @@ beforeEach(() => { mockTipUpsert.mockResolvedValue({}); mockGoalUpsert.mockResolvedValue({}); mockGoalUpdateMany.mockResolvedValue({ count: 1 }); + mockGoalFindUnique.mockResolvedValue(null); mockSubUpsert.mockResolvedValue({}); mockSubUpdateMany.mockResolvedValue({ count: 1 }); mockCreditScoreUpsert.mockResolvedValue({}); mockCreditScoreHistoryUpsert.mockResolvedValue({}); mockPublishProjection.mockResolvedValue(undefined); + mockCreateNotification.mockResolvedValue(null); }); // ── Fixture event page ──────────────────────────────────────────────────────── diff --git a/backend/src/indexer/projections.test.ts b/backend/src/indexer/projections.test.ts index a2b505ad..0b369554 100644 --- a/backend/src/indexer/projections.test.ts +++ b/backend/src/indexer/projections.test.ts @@ -6,6 +6,7 @@ const { mockUserUpsert, mockGoalUpsert, mockGoalUpdateMany, + mockGoalFindUnique, mockSubUpsert, mockSubUpdateMany, mockTipUpsert, @@ -14,10 +15,12 @@ const { mockCreditScoreUpsert, mockCreditScoreHistoryUpsert, mockPublishProjection, + mockCreateNotification, } = vi.hoisted(() => ({ mockUserUpsert: vi.fn(), mockGoalUpsert: vi.fn(), mockGoalUpdateMany: vi.fn(), + mockGoalFindUnique: vi.fn(), mockSubUpsert: vi.fn(), mockSubUpdateMany: vi.fn(), mockTipUpsert: vi.fn(), @@ -26,12 +29,13 @@ const { mockCreditScoreUpsert: vi.fn(), mockCreditScoreHistoryUpsert: vi.fn(), mockPublishProjection: vi.fn(), + mockCreateNotification: vi.fn(), })); vi.mock('../db/prisma.js', () => ({ prisma: { user: { upsert: mockUserUpsert }, - goal: { upsert: mockGoalUpsert, updateMany: mockGoalUpdateMany }, + goal: { upsert: mockGoalUpsert, updateMany: mockGoalUpdateMany, findUnique: mockGoalFindUnique }, subscription: { upsert: mockSubUpsert, updateMany: mockSubUpdateMany }, tip: { upsert: mockTipUpsert }, eventLog: { findFirst: mockEventLogFindFirst, create: mockEventLogCreate }, @@ -44,6 +48,10 @@ vi.mock('./realtime-publisher.js', () => ({ publishProjection: mockPublishProjection, })); +vi.mock('../modules/notifications/notifications.service.js', () => ({ + createNotification: mockCreateNotification, +})); + /** Build a decoded event; `value` is the positional payload tuple. */ const event = (topic: string, value: unknown, overrides: Partial = {}): DecodedEvent => ({ ledger: 100, @@ -87,6 +95,8 @@ beforeEach(() => { mockEventLogCreate.mockResolvedValue({}); mockGoalUpsert.mockResolvedValue({}); mockGoalUpdateMany.mockResolvedValue({ count: 1 }); + mockGoalFindUnique.mockResolvedValue(null); + mockCreateNotification.mockResolvedValue(null); mockSubUpsert.mockResolvedValue({}); mockSubUpdateMany.mockResolvedValue({ count: 1 }); mockCreditScoreUpsert.mockResolvedValue({}); @@ -227,6 +237,23 @@ describe('projectEvent — goals (#899)', () => { expect(mockGoalUpsert.mock.calls[0][0].update).toEqual(mockGoalUpsert.mock.calls[1][0].update); }); + it('notifies the creator when the goal transitions into COMPLETED (#964)', async () => { + mockGoalFindUnique.mockResolvedValue(null); + await projectEvent(event('goal_reached', [ADDR_A, '1000', '1000'])); + expect(mockCreateNotification).toHaveBeenCalledWith( + 'u_' + ADDR_A, + 'goal_reached', + expect.objectContaining({ targetStroops: '1000', raisedStroops: '1000' }), + ); + }); + + it('does not re-notify when replaying an already-COMPLETED goal', async () => { + mockGoalFindUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ status: 'COMPLETED' }); + await projectEvent(event('goal_reached', [ADDR_A, '1000', '1000'])); + await projectEvent(event('goal_reached', [ADDR_A, '1000', '1000'])); + expect(mockCreateNotification).toHaveBeenCalledTimes(1); + }); + it('cancels a goal via updateMany (no-op when absent)', async () => { await projectEvent(event('goal_cancel', ADDR_A)); expect(mockGoalUpdateMany).toHaveBeenCalledWith({ diff --git a/backend/src/indexer/projections.ts b/backend/src/indexer/projections.ts index ef031884..579536bc 100644 --- a/backend/src/indexer/projections.ts +++ b/backend/src/indexer/projections.ts @@ -3,6 +3,7 @@ import { prisma } from '../db/prisma.js'; import { logger } from '../common/utils/logger.js'; import type { DecodedEvent } from './sorobanClient.js'; import { publishProjection } from './realtime-publisher.js'; +import * as notificationsService from '../modules/notifications/notifications.service.js'; /** Event topics that represent an on-chain tip. */ const TIP_TOPICS = new Set(['tip', 'tip_sent']); @@ -314,6 +315,8 @@ async function projectGoalReached(event: DecodedEvent): Promise { const userId = await ensureUserId(creator); + const existing = await prisma.goal.findUnique({ where: { id: goalId(userId) } }); + await prisma.goal.upsert({ where: { id: goalId(userId) }, create: { @@ -326,6 +329,19 @@ async function projectGoalReached(event: DecodedEvent): Promise { }, update: { targetStroops, raisedStroops, status: 'COMPLETED' }, }); + + // Only notify on the transition into COMPLETED, so replaying this event never + // creates duplicate notifications. + if (!existing || existing.status !== 'COMPLETED') { + try { + await notificationsService.createNotification(userId, 'goal_reached', { + targetStroops: targetStroops.toString(), + raisedStroops: raisedStroops.toString(), + }); + } catch (err) { + logger.error({ err, userId }, 'Failed to notify creator of goal reached'); + } + } } /** Project a `("goal", "cancel")` event — data `(creator,)`. */ diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts index 2dad987f..89b04b21 100644 --- a/backend/src/modules/notifications/notifications.controller.ts +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -1,5 +1,9 @@ import type { Request, Response, NextFunction } from 'express'; -import { notificationsQuerySchema, notificationIdParamSchema } from './notifications.schema.js'; +import { + notificationsQuerySchema, + notificationIdParamSchema, + updateNotificationPreferencesSchema, +} from './notifications.schema.js'; import * as notificationsService from './notifications.service.js'; export async function list( @@ -60,3 +64,46 @@ export async function markAllRead( next(err); } } + +export async function getUnreadCount( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const userId = req.auth!.userId; + const result = await notificationsService.getUnreadCount(userId); + res.status(200).json({ data: result }); + } catch (err) { + next(err); + } +} + +export async function getPreferences( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const userId = req.auth!.userId; + const result = await notificationsService.getPreferences(userId); + res.status(200).json({ data: result }); + } catch (err) { + next(err); + } +} + +export async function updatePreferences( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const userId = req.auth!.userId; + const patch = updateNotificationPreferencesSchema.parse(req.body); + const result = await notificationsService.updatePreferences(userId, patch); + res.status(200).json({ data: result }); + } catch (err) { + next(err); + } +} diff --git a/backend/src/modules/notifications/notifications.routes.ts b/backend/src/modules/notifications/notifications.routes.ts index a2a23cff..f0d8c65f 100644 --- a/backend/src/modules/notifications/notifications.routes.ts +++ b/backend/src/modules/notifications/notifications.routes.ts @@ -9,6 +9,9 @@ export const notificationsRouter = Router(); notificationsRouter.use(requireAuth); notificationsRouter.get('/', notificationsController.list); +notificationsRouter.get('/unread-count', notificationsController.getUnreadCount); +notificationsRouter.get('/preferences', notificationsController.getPreferences); +notificationsRouter.patch('/preferences', notificationsController.updatePreferences); notificationsRouter.get('/:id', notificationsController.getById); notificationsRouter.patch('/:id/read', notificationsController.markRead); notificationsRouter.post('/read-all', notificationsController.markAllRead); @@ -27,6 +30,16 @@ const notificationSchema = { required: ['id', 'type', 'payload', 'readAt', 'createdAt'], }; +const notificationPreferenceSchema = { + type: 'object', + properties: { + tipReceived: { type: 'boolean', example: true }, + goalReached: { type: 'boolean', example: true }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + required: ['tipReceived', 'goalReached', 'updatedAt'], +}; + mergeOpenApiPaths({ [`${base}`]: { get: { @@ -83,6 +96,93 @@ mergeOpenApiPaths({ }, }, }, + [`${base}/unread-count`]: { + get: { + tags: ['Notifications'], + summary: 'Get unread notification count', + description: 'Returns the number of unread, non-deleted notifications for the authenticated user.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Unread count', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { count: { type: 'integer' } }, + required: ['count'], + }, + }, + required: ['data'], + }, + }, + }, + }, + '401': { description: 'Unauthorized' }, + }, + }, + }, + [`${base}/preferences`]: { + get: { + tags: ['Notifications'], + summary: 'Get notification preferences', + description: 'Returns the authenticated user\'s notification type toggles, defaulting to all-enabled.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Notification preferences', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { data: notificationPreferenceSchema }, + required: ['data'], + }, + }, + }, + }, + '401': { description: 'Unauthorized' }, + }, + }, + patch: { + tags: ['Notifications'], + summary: 'Update notification preferences', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + tipReceived: { type: 'boolean' }, + goalReached: { type: 'boolean' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Updated notification preferences', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { data: notificationPreferenceSchema }, + required: ['data'], + }, + }, + }, + }, + '400': { description: 'Invalid request body' }, + '401': { description: 'Unauthorized' }, + }, + }, + }, [`${base}/{id}`]: { get: { tags: ['Notifications'], diff --git a/backend/src/modules/notifications/notifications.schema.ts b/backend/src/modules/notifications/notifications.schema.ts index 33ea80a6..0773072a 100644 --- a/backend/src/modules/notifications/notifications.schema.ts +++ b/backend/src/modules/notifications/notifications.schema.ts @@ -13,5 +13,17 @@ export const notificationIdParamSchema = z.object({ id: z.string().min(1), }); +export const updateNotificationPreferencesSchema = z + .object({ + tipReceived: z.boolean().optional(), + goalReached: z.boolean().optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one preference must be provided', + }); + export type NotificationsQuery = z.infer; export type NotificationIdParam = z.infer; +export type UpdateNotificationPreferencesInput = z.infer< + typeof updateNotificationPreferencesSchema +>; diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index f760830d..14827aa1 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -1,6 +1,21 @@ +import type { Prisma } from '@prisma/client'; import { prisma } from '../../db/prisma.js'; import { NotFoundError } from '../../common/errors/AppError.js'; -import type { NotificationListResponse, NotificationResponse } from './notifications.types.js'; +import { emitNotificationCreated } from '../../realtime/index.js'; +import type { UpdateNotificationPreferencesInput } from './notifications.schema.js'; +import type { + NotificationListResponse, + NotificationPreferenceResponse, + NotificationResponse, + NotificationType, + UnreadCountResponse, +} from './notifications.types.js'; + +/** Maps a notification type to the preference field gating its delivery. */ +const PREFERENCE_FIELD_BY_TYPE: Record = { + tip_received: 'tipReceived', + goal_reached: 'goalReached', +}; function formatNotification(n: { id: string; @@ -94,3 +109,78 @@ export async function markAllAsRead(userId: string): Promise<{ count: number }> return { count: result.count }; } + +/** GET /notifications/unread-count — count of unread, non-deleted notifications. */ +export async function getUnreadCount(userId: string): Promise { + const count = await prisma.notification.count({ + where: { userId, readAt: null, deletedAt: null }, + }); + + return { count }; +} + +function formatPreferences(pref: { + tipReceived: boolean; + goalReached: boolean; + updatedAt: Date; +}): NotificationPreferenceResponse { + return { + tipReceived: pref.tipReceived, + goalReached: pref.goalReached, + updatedAt: pref.updatedAt.toISOString(), + }; +} + +/** GET /notifications/preferences — defaults to all-enabled when no row exists yet. */ +export async function getPreferences(userId: string): Promise { + const pref = await prisma.notificationPreference.findUnique({ where: { userId } }); + if (!pref) { + return { tipReceived: true, goalReached: true, updatedAt: new Date(0).toISOString() }; + } + return formatPreferences(pref); +} + +/** PATCH /notifications/preferences — upserts the caller's preference row. */ +export async function updatePreferences( + userId: string, + patch: UpdateNotificationPreferencesInput, +): Promise { + const pref = await prisma.notificationPreference.upsert({ + where: { userId }, + create: { userId, ...patch }, + update: patch, + }); + return formatPreferences(pref); +} + +/** + * Create a notification for a user and broadcast it over the realtime gateway, + * unless the user has disabled this notification type in their preferences. + * Used by the tip and goal modules to notify creators of relevant events. + */ +export async function createNotification( + userId: string, + type: NotificationType, + payload: Record, +): Promise { + const preferenceField = PREFERENCE_FIELD_BY_TYPE[type]; + const pref = await prisma.notificationPreference.findUnique({ where: { userId } }); + if (pref && !pref[preferenceField]) { + return null; + } + + const notification = await prisma.notification.create({ + data: { userId, type, payload: payload as Prisma.InputJsonValue }, + }); + + const formatted = formatNotification(notification); + emitNotificationCreated({ + id: formatted.id, + userId, + type: formatted.type, + payload: formatted.payload, + createdAt: formatted.createdAt, + }); + + return formatted; +} diff --git a/backend/src/modules/notifications/notifications.test.ts b/backend/src/modules/notifications/notifications.test.ts index d83e8118..7c27f2af 100644 --- a/backend/src/modules/notifications/notifications.test.ts +++ b/backend/src/modules/notifications/notifications.test.ts @@ -6,17 +6,33 @@ import { getNotification, markAsRead, markAllAsRead, + getUnreadCount, + getPreferences, + updatePreferences, + createNotification, } from './notifications.service.js'; -const { mockFindMany, mockCount, mockFindFirst, mockUpdate, mockUpdateMany } = vi.hoisted( - () => ({ - mockFindMany: vi.fn(), - mockCount: vi.fn(), - mockFindFirst: vi.fn(), - mockUpdate: vi.fn(), - mockUpdateMany: vi.fn(), - }), -); +const { + mockFindMany, + mockCount, + mockFindFirst, + mockUpdate, + mockUpdateMany, + mockCreate, + mockPrefFindUnique, + mockPrefUpsert, + mockEmitNotificationCreated, +} = vi.hoisted(() => ({ + mockFindMany: vi.fn(), + mockCount: vi.fn(), + mockFindFirst: vi.fn(), + mockUpdate: vi.fn(), + mockUpdateMany: vi.fn(), + mockCreate: vi.fn(), + mockPrefFindUnique: vi.fn(), + mockPrefUpsert: vi.fn(), + mockEmitNotificationCreated: vi.fn(), +})); vi.mock('../../db/prisma.js', () => ({ prisma: { @@ -26,6 +42,11 @@ vi.mock('../../db/prisma.js', () => ({ findFirst: mockFindFirst, update: mockUpdate, updateMany: mockUpdateMany, + create: mockCreate, + }, + notificationPreference: { + findUnique: mockPrefFindUnique, + upsert: mockPrefUpsert, }, $disconnect: vi.fn(), }, @@ -37,6 +58,10 @@ vi.mock('../../db/redis.js', () => ({ }, })); +vi.mock('../../realtime/index.js', () => ({ + emitNotificationCreated: mockEmitNotificationCreated, +})); + function mockAuth() { const jwt = require('jsonwebtoken'); const token = jwt.sign( @@ -340,3 +365,219 @@ describe('POST /api/v1/notifications/read-all', () => { expect(res.status).toBe(401); }); }); + +describe('getUnreadCount', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the unread, non-deleted count for the user', async () => { + mockCount.mockResolvedValue(4); + + const result = await getUnreadCount('user-1'); + + expect(result).toEqual({ count: 4 }); + expect(mockCount).toHaveBeenCalledWith({ + where: { userId: 'user-1', readAt: null, deletedAt: null }, + }); + }); +}); + +describe('getPreferences', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('defaults to all-enabled when no preference row exists', async () => { + mockPrefFindUnique.mockResolvedValue(null); + + const result = await getPreferences('user-1'); + + expect(result.tipReceived).toBe(true); + expect(result.goalReached).toBe(true); + }); + + it('returns the stored preference row when it exists', async () => { + const updatedAt = new Date('2026-07-24T12:00:00.000Z'); + mockPrefFindUnique.mockResolvedValue({ + tipReceived: false, + goalReached: true, + updatedAt, + }); + + const result = await getPreferences('user-1'); + + expect(result).toEqual({ + tipReceived: false, + goalReached: true, + updatedAt: updatedAt.toISOString(), + }); + }); +}); + +describe('updatePreferences', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + 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 }); + + const result = await updatePreferences('user-1', { tipReceived: false }); + + expect(result.tipReceived).toBe(false); + expect(mockPrefUpsert).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + create: { userId: 'user-1', tipReceived: false }, + update: { tipReceived: false }, + }); + }); +}); + +describe('createNotification', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates and emits a notification when no preference row exists', async () => { + mockPrefFindUnique.mockResolvedValue(null); + const createdAt = new Date('2026-07-25T12:00:00.000Z'); + mockCreate.mockResolvedValue({ + id: 'notif-1', + type: 'tip_received', + payload: { amount: '100' }, + readAt: null, + createdAt, + }); + + const result = await createNotification('user-1', 'tip_received', { amount: '100' }); + + expect(result).not.toBeNull(); + expect(mockCreate).toHaveBeenCalledWith({ + data: { userId: 'user-1', type: 'tip_received', payload: { amount: '100' } }, + }); + expect(mockEmitNotificationCreated).toHaveBeenCalledWith({ + id: 'notif-1', + userId: 'user-1', + type: 'tip_received', + payload: { amount: '100' }, + createdAt: createdAt.toISOString(), + }); + }); + + it('skips creation when the user disabled this notification type', async () => { + mockPrefFindUnique.mockResolvedValue({ tipReceived: false, goalReached: true }); + + const result = await createNotification('user-1', 'tip_received', { amount: '100' }); + + expect(result).toBeNull(); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockEmitNotificationCreated).not.toHaveBeenCalled(); + }); + + it('creates when the notification type is still enabled', async () => { + mockPrefFindUnique.mockResolvedValue({ tipReceived: false, goalReached: true }); + const createdAt = new Date('2026-07-25T12:00:00.000Z'); + mockCreate.mockResolvedValue({ + id: 'notif-2', + type: 'goal_reached', + payload: {}, + readAt: null, + createdAt, + }); + + const result = await createNotification('user-1', 'goal_reached', {}); + + expect(result).not.toBeNull(); + expect(mockCreate).toHaveBeenCalled(); + }); +}); + +describe('GET /api/v1/notifications/unread-count', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the unread count for the authenticated user', async () => { + mockCount.mockResolvedValue(2); + + const app = createApp(); + const token = mockAuth(); + const res = await request(app) + .get('/api/v1/notifications/unread-count') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data.count).toBe(2); + }); + + it('returns 401 without auth', async () => { + const app = createApp(); + const res = await request(app).get('/api/v1/notifications/unread-count'); + + expect(res.status).toBe(401); + }); +}); + +describe('GET /api/v1/notifications/preferences', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns default preferences when none are stored', async () => { + mockPrefFindUnique.mockResolvedValue(null); + + const app = createApp(); + const token = mockAuth(); + const res = await request(app) + .get('/api/v1/notifications/preferences') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual( + expect.objectContaining({ tipReceived: true, goalReached: true }), + ); + }); +}); + +describe('PATCH /api/v1/notifications/preferences', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('updates preferences for the authenticated user', async () => { + const updatedAt = new Date('2026-07-25T12:00:00.000Z'); + mockPrefUpsert.mockResolvedValue({ tipReceived: false, goalReached: true, updatedAt }); + + const app = createApp(); + const token = mockAuth(); + const res = await request(app) + .patch('/api/v1/notifications/preferences') + .set('Authorization', `Bearer ${token}`) + .send({ tipReceived: false }); + + expect(res.status).toBe(200); + expect(res.body.data.tipReceived).toBe(false); + }); + + it('returns 400 for an empty request body', async () => { + const app = createApp(); + const token = mockAuth(); + const res = await request(app) + .patch('/api/v1/notifications/preferences') + .set('Authorization', `Bearer ${token}`) + .send({}); + + expect(res.status).toBe(400); + }); + + it('returns 401 without auth', async () => { + const app = createApp(); + const res = await request(app) + .patch('/api/v1/notifications/preferences') + .send({ tipReceived: false }); + + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/modules/notifications/notifications.types.ts b/backend/src/modules/notifications/notifications.types.ts index 2db475e7..c9d598d6 100644 --- a/backend/src/modules/notifications/notifications.types.ts +++ b/backend/src/modules/notifications/notifications.types.ts @@ -15,3 +15,16 @@ export interface NotificationListResponse { hasMore: boolean; }; } + +/** Notification type discriminators used by the createNotification triggers. */ +export type NotificationType = 'tip_received' | 'goal_reached'; + +export interface UnreadCountResponse { + count: number; +} + +export interface NotificationPreferenceResponse { + tipReceived: boolean; + goalReached: boolean; + updatedAt: string; +} diff --git a/backend/src/modules/tips/tips.service.ts b/backend/src/modules/tips/tips.service.ts index e8a80eaa..e7ae68e3 100644 --- a/backend/src/modules/tips/tips.service.ts +++ b/backend/src/modules/tips/tips.service.ts @@ -5,6 +5,7 @@ import { prisma } from '../../db/prisma.js'; import { BadRequestError, NotFoundError } from '../../common/errors/AppError.js'; import { logger } from '../../common/utils/logger.js'; import { TipStatus } from '../../types/enums.js'; +import * as notificationsService from '../notifications/notifications.service.js'; import type { RecordTipInput } from './tips.schema.js'; import { serializeTip } from './tips.serializer.js'; import type { TipResponseDto, TipAggregateByCreatorDto } from './tips.dto.js'; @@ -228,6 +229,7 @@ export async function recordTip(input: RecordTipInput): Promise message: input.message, }, }); + await notifyCreatorOfTip(tip); return serializeTip(tip); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { @@ -238,6 +240,35 @@ export async function recordTip(input: RecordTipInput): Promise } } +/** + * Notify the receiving creator that they got a new tip. Best-effort: only fires + * for creators with an off-chain User row, skips self-tips, and never lets a + * notification failure block the tip recording itself. + */ +async function notifyCreatorOfTip(tip: { + id: string; + fromAddress: string; + toAddress: string; + amountStroops: bigint; + message: string | null; +}): Promise { + if (tip.fromAddress === tip.toAddress) return; + + try { + const receiver = await prisma.user.findUnique({ where: { stellarAddress: tip.toAddress } }); + if (!receiver) return; + + await notificationsService.createNotification(receiver.id, 'tip_received', { + tipId: tip.id, + from: tip.fromAddress, + amountStroops: tip.amountStroops.toString(), + message: tip.message, + }); + } catch (err) { + logger.error({ err, tipId: tip.id }, 'Failed to notify creator of new tip'); + } +} + /** * PATCH /tips/:txHash/confirm — transition a tip from PENDING to CONFIRMED. * Idempotent: calling on an already-CONFIRMED tip is a no-op. diff --git a/backend/src/modules/tips/tips.test.ts b/backend/src/modules/tips/tips.test.ts index 85464cc2..3028c5d8 100644 --- a/backend/src/modules/tips/tips.test.ts +++ b/backend/src/modules/tips/tips.test.ts @@ -14,6 +14,8 @@ const { mockCreate, mockUpdate, mockGroupBy, + mockUserFindUnique, + mockCreateNotification, } = vi.hoisted(() => ({ mockGetAccount: vi.fn(), mockSimulateTransaction: vi.fn(), @@ -25,6 +27,8 @@ const { mockCreate: vi.fn(), mockUpdate: vi.fn(), mockGroupBy: vi.fn(), + mockUserFindUnique: vi.fn(), + mockCreateNotification: vi.fn(), })); vi.mock('@stellar/stellar-sdk', () => { @@ -96,10 +100,17 @@ vi.mock('../../db/prisma.js', () => ({ update: mockUpdate, groupBy: mockGroupBy, }, + user: { + findUnique: mockUserFindUnique, + }, $disconnect: vi.fn(), }, })); +vi.mock('../notifications/notifications.service.js', () => ({ + createNotification: mockCreateNotification, +})); + // ── Helpers ──────────────────────────────────────────────────────────────── const now = new Date('2026-06-29T00:00:00.000Z'); @@ -605,6 +616,62 @@ describe('POST /api/v1/tips — dedupe by txHash', () => { expect(mockCreate).toHaveBeenCalledTimes(1); }); + it('notifies the receiving creator when they have an off-chain account', async () => { + mockFindUnique.mockResolvedValue(null); + mockCreate.mockResolvedValue(tipRow); + mockUserFindUnique.mockResolvedValue({ id: 'user-to' }); + + const app = createApp(); + const res = await request(app).post('/api/v1/tips').send(validBody); + + expect(res.status).toBe(200); + expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { stellarAddress: to } }); + expect(mockCreateNotification).toHaveBeenCalledWith( + 'user-to', + 'tip_received', + expect.objectContaining({ tipId: tipRow.id, from, amountStroops: '1000000' }), + ); + }); + + it('skips notifying when the recipient has no off-chain account', async () => { + mockFindUnique.mockResolvedValue(null); + mockCreate.mockResolvedValue(tipRow); + mockUserFindUnique.mockResolvedValue(null); + + const app = createApp(); + const res = await request(app).post('/api/v1/tips').send(validBody); + + expect(res.status).toBe(200); + expect(mockCreateNotification).not.toHaveBeenCalled(); + }); + + it('skips notifying for a self-tip', async () => { + mockFindUnique.mockResolvedValue(null); + mockCreate.mockResolvedValue(makeTipRow({ fromAddress: to })); + + const app = createApp(); + const res = await request(app) + .post('/api/v1/tips') + .send({ ...validBody, fromAddress: to }); + + expect(res.status).toBe(200); + expect(mockUserFindUnique).not.toHaveBeenCalled(); + expect(mockCreateNotification).not.toHaveBeenCalled(); + }); + + it('does not fail the request when notifying the creator throws', async () => { + mockFindUnique.mockResolvedValue(null); + mockCreate.mockResolvedValue(tipRow); + mockUserFindUnique.mockResolvedValue({ id: 'user-to' }); + mockCreateNotification.mockRejectedValue(new Error('boom')); + + const app = createApp(); + const res = await request(app).post('/api/v1/tips').send(validBody); + + expect(res.status).toBe(200); + expect(res.body.data.txHash).toBe('abc123txhash'); + }); + it('returns the existing tip without a duplicate insert when txHash already exists', async () => { mockFindUnique.mockResolvedValue(tipRow); @@ -613,6 +680,7 @@ describe('POST /api/v1/tips — dedupe by txHash', () => { expect(res.status).toBe(200); expect(res.body.data.txHash).toBe('abc123txhash'); expect(mockCreate).not.toHaveBeenCalled(); + expect(mockCreateNotification).not.toHaveBeenCalled(); }); }); diff --git a/backend/src/modules/x/x.test.ts b/backend/src/modules/x/x.test.ts index f5143ede..f15f7e53 100644 --- a/backend/src/modules/x/x.test.ts +++ b/backend/src/modules/x/x.test.ts @@ -41,7 +41,7 @@ const mockXApiResponseLowActivity = { // Mock global fetch const mockFetch = vi.fn(); -(globalThis as { fetch: typeof mockFetch }).fetch = mockFetch; +(globalThis as unknown as { fetch: typeof mockFetch }).fetch = mockFetch; describe("X Integration Service", () => { beforeEach(() => {