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
5,318 changes: 3,651 additions & 1,667 deletions backend/package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { leaderboardRouter } from './modules/leaderboard/leaderboard.routes.js';
import { tipsRouter } from './modules/tips/tips.routes.js';
import { balancesRouter, withdrawalsRouter } from './modules/withdrawals/withdrawals.routes.js';
import { ipfsRouter } from './modules/ipfs/ipfs.routes.js';
import { xRouter } from './modules/x/x.routes.js';
import { notificationsRouter } from './modules/notifications/notifications.routes.js';

/** Builds and configures the Express application without starting a listener. */
export function createApp(): Express {
Expand Down Expand Up @@ -60,6 +62,8 @@ export function createApp(): Express {
app.use(`${env.API_BASE_PATH}/ipfs`, ipfsRouter);
app.use(`${env.API_BASE_PATH}/tips`, tipsRouter);
app.use(`${env.API_BASE_PATH}/withdrawals`, withdrawalsRouter);
app.use(`${env.API_BASE_PATH}/notifications`, notificationsRouter);
app.use(`${env.API_BASE_PATH}/x`, xRouter);
app.use(`${env.API_BASE_PATH}/balances`, balancesRouter);

app.use(notFoundHandler);
Expand Down
1 change: 1 addition & 0 deletions backend/src/docs/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const openApiDocument: OpenApiDocument = {
{ name: 'Profiles', description: 'Creator profile management' },
{ name: 'Tips', description: 'On-chain tipping operations' },
{ name: 'Leaderboard', description: 'Creator tip leaderboard with time windows' },
{ name: 'Notifications', description: 'In-app notifications for users' },
],
components: {
securitySchemes: {
Expand Down
62 changes: 62 additions & 0 deletions backend/src/modules/notifications/notifications.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Request, Response, NextFunction } from 'express';
import { notificationsQuerySchema, notificationIdParamSchema } from './notifications.schema.js';
import * as notificationsService from './notifications.service.js';

export async function list(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const userId = req.auth!.userId;
const { unreadOnly, limit, offset } = notificationsQuerySchema.parse(req.query);
const result = await notificationsService.listNotifications(userId, unreadOnly, limit, offset);
res.status(200).json(result);
} catch (err) {
next(err);
}
}

export async function getById(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const userId = req.auth!.userId;
const { id } = notificationIdParamSchema.parse(req.params);
const result = await notificationsService.getNotification(userId, id);
res.status(200).json({ data: result });
} catch (err) {
next(err);
}
}

export async function markRead(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const userId = req.auth!.userId;
const { id } = notificationIdParamSchema.parse(req.params);
const result = await notificationsService.markAsRead(userId, id);
res.status(200).json({ data: result });
} catch (err) {
next(err);
}
}

export async function markAllRead(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const userId = req.auth!.userId;
const result = await notificationsService.markAllAsRead(userId);
res.status(200).json({ data: result });
} catch (err) {
next(err);
}
}
176 changes: 176 additions & 0 deletions backend/src/modules/notifications/notifications.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Router } from 'express';
import * as notificationsController from './notifications.controller.js';
import { requireAuth } from '../auth/auth.middleware.js';
import { env } from '../../config/env.js';
import { mergeOpenApiPaths } from '../../docs/openapi.js';

export const notificationsRouter = Router();

notificationsRouter.use(requireAuth);

notificationsRouter.get('/', notificationsController.list);
notificationsRouter.get('/:id', notificationsController.getById);
notificationsRouter.patch('/:id/read', notificationsController.markRead);
notificationsRouter.post('/read-all', notificationsController.markAllRead);

const base = `${env.API_BASE_PATH}/notifications`;

const notificationSchema = {
type: 'object',
properties: {
id: { type: 'string', example: 'clxx1234567890abcdef' },
type: { type: 'string', example: 'tip_received' },
payload: { type: 'object', example: { amount: '100', from: 'alice' } },
readAt: { type: 'string', format: 'date-time', nullable: true, example: null },
createdAt: { type: 'string', format: 'date-time' },
},
required: ['id', 'type', 'payload', 'readAt', 'createdAt'],
};

mergeOpenApiPaths({
[`${base}`]: {
get: {
tags: ['Notifications'],
summary: 'List notifications',
description: 'Returns paginated notifications for the authenticated user.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'unreadOnly',
in: 'query',
required: false,
schema: { type: 'string', enum: ['true', 'false'], default: 'false' },
},
{
name: 'limit',
in: 'query',
required: false,
schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
},
{
name: 'offset',
in: 'query',
required: false,
schema: { type: 'integer', minimum: 0, default: 0 },
},
],
responses: {
'200': {
description: 'Paginated list of notifications',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: { type: 'array', items: notificationSchema },
pagination: {
type: 'object',
properties: {
limit: { type: 'integer' },
offset: { type: 'integer' },
total: { type: 'integer' },
hasMore: { type: 'boolean' },
},
required: ['limit', 'offset', 'total', 'hasMore'],
},
},
required: ['data', 'pagination'],
},
},
},
},
'401': { description: 'Unauthorized' },
},
},
},
[`${base}/{id}`]: {
get: {
tags: ['Notifications'],
summary: 'Get a notification',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
},
],
responses: {
'200': {
description: 'Notification found',
content: {
'application/json': {
schema: {
type: 'object',
properties: { data: notificationSchema },
required: ['data'],
},
},
},
},
'401': { description: 'Unauthorized' },
'404': { description: 'Notification not found' },
},
},
},
[`${base}/{id}/read`]: {
patch: {
tags: ['Notifications'],
summary: 'Mark a notification as read',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
},
],
responses: {
'200': {
description: 'Notification marked as read',
content: {
'application/json': {
schema: {
type: 'object',
properties: { data: notificationSchema },
required: ['data'],
},
},
},
},
'401': { description: 'Unauthorized' },
'404': { description: 'Notification not found' },
},
},
},
[`${base}/read-all`]: {
post: {
tags: ['Notifications'],
summary: 'Mark all notifications as read',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'All notifications marked as read',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: {
type: 'object',
properties: { count: { type: 'integer' } },
required: ['count'],
},
},
required: ['data'],
},
},
},
},
'401': { description: 'Unauthorized' },
},
},
},
});
17 changes: 17 additions & 0 deletions backend/src/modules/notifications/notifications.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from 'zod';

export const notificationsQuerySchema = z.object({
unreadOnly: z
.string()
.optional()
.transform((val) => val === 'true'),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});

export const notificationIdParamSchema = z.object({
id: z.string().min(1),
});

export type NotificationsQuery = z.infer<typeof notificationsQuerySchema>;
export type NotificationIdParam = z.infer<typeof notificationIdParamSchema>;
96 changes: 96 additions & 0 deletions backend/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { prisma } from '../../db/prisma.js';
import { NotFoundError } from '../../common/errors/AppError.js';
import type { NotificationListResponse, NotificationResponse } from './notifications.types.js';

function formatNotification(n: {
id: string;
type: string;
payload: unknown;
readAt: Date | null;
createdAt: Date;
}): NotificationResponse {
return {
id: n.id,
type: n.type,
payload: n.payload,
readAt: n.readAt?.toISOString() ?? null,
createdAt: n.createdAt.toISOString(),
};
}

export async function listNotifications(
userId: string,
unreadOnly: boolean,
limit: number,
offset: number,
): Promise<NotificationListResponse> {
const where = {
userId,
deletedAt: null,
...(unreadOnly ? { readAt: null } : {}),
};

const [rows, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: offset,
take: limit,
}),
prisma.notification.count({ where }),
]);

return {
data: rows.map(formatNotification),
pagination: {
limit,
offset,
total,
hasMore: offset + rows.length < total,
},
};
}

export async function getNotification(
userId: string,
notificationId: string,
): Promise<NotificationResponse> {
const notification = await prisma.notification.findFirst({
where: { id: notificationId, userId, deletedAt: null },
});

if (!notification) {
throw new NotFoundError('Notification not found');
}

return formatNotification(notification);
}

export async function markAsRead(
userId: string,
notificationId: string,
): Promise<NotificationResponse> {
const notification = await prisma.notification.findFirst({
where: { id: notificationId, userId, deletedAt: null },
});

if (!notification) {
throw new NotFoundError('Notification not found');
}

const updated = await prisma.notification.update({
where: { id: notificationId },
data: { readAt: new Date() },
});

return formatNotification(updated);
}

export async function markAllAsRead(userId: string): Promise<{ count: number }> {
const result = await prisma.notification.updateMany({
where: { userId, readAt: null, deletedAt: null },
data: { readAt: new Date() },
});

return { count: result.count };
}
Loading
Loading