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
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: 'Withdrawals', description: 'Withdrawal operations and balance queries' },
{ name: 'Notifications', description: 'In-app notifications for users' },
],
components: {
Expand Down
31 changes: 31 additions & 0 deletions backend/src/modules/notifications/notifications.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ notificationsRouter.get('/preferences', notificationsController.getPreferences);
notificationsRouter.patch('/preferences', notificationsController.updatePreferences);
notificationsRouter.get('/:id', notificationsController.getById);
notificationsRouter.patch('/:id/read', notificationsController.markRead);
notificationsRouter.post('/:id/read', notificationsController.markRead);
notificationsRouter.post('/read-all', notificationsController.markAllRead);

const base = `${env.API_BASE_PATH}/notifications`;
Expand Down Expand Up @@ -246,6 +247,36 @@ mergeOpenApiPaths({
'404': { description: 'Notification not found' },
},
},
post: {
tags: ['Notifications'],
summary: 'Mark a notification as read (POST)',
description: 'POST alias for PATCH /notifications/:id/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: {
Expand Down
92 changes: 92 additions & 0 deletions backend/src/modules/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,98 @@ describe('PATCH /api/v1/notifications/:id/read', () => {
expect(res.status).toBe(200);
expect(res.body.data.readAt).toBe(readAt.toISOString());
});

it('returns 404 for unknown notification', async () => {
mockFindFirst.mockResolvedValue(null);

const app = createApp();
const token = mockAuth();
const res = await request(app)
.patch('/api/v1/notifications/unknown/read')
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(404);
});

it('returns 401 without auth', async () => {
const app = createApp();
const res = await request(app).patch('/api/v1/notifications/notif-1/read');

expect(res.status).toBe(401);
});
});

describe('POST /api/v1/notifications/:id/read (#960)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('marks notification as read via POST', async () => {
const createdAt = new Date('2026-07-24T12:00:00.000Z');
mockFindFirst.mockResolvedValue({
id: 'notif-1',
type: 'tip_received',
payload: {},
readAt: null,
createdAt,
});
const readAt = new Date('2026-07-25T12:00:00.000Z');
mockUpdate.mockResolvedValue({
id: 'notif-1',
type: 'tip_received',
payload: {},
readAt,
createdAt,
});

const app = createApp();
const token = mockAuth();
const res = await request(app)
.post('/api/v1/notifications/notif-1/read')
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(200);
expect(res.body.data.readAt).toBe(readAt.toISOString());
expect(mockFindFirst).toHaveBeenCalledWith({
where: { id: 'notif-1', userId: 'user-1', deletedAt: null },
});
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: 'notif-1' },
data: { readAt: expect.any(Date) },
});
});

it('returns 404 for unknown notification', async () => {
mockFindFirst.mockResolvedValue(null);

const app = createApp();
const token = mockAuth();
const res = await request(app)
.post('/api/v1/notifications/unknown/read')
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(404);
expect(res.body.error.code).toBe('NOT_FOUND');
});

it('returns 401 without auth', async () => {
const app = createApp();
const res = await request(app).post('/api/v1/notifications/notif-1/read');

expect(res.status).toBe(401);
});

it('returns 404 for notification owned by another user', async () => {
mockFindFirst.mockResolvedValue(null);

const app = createApp();
const token = mockAuth();
const res = await request(app)
.post('/api/v1/notifications/notif-other/read')
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(404);
});
});

describe('POST /api/v1/notifications/read-all', () => {
Expand Down
199 changes: 199 additions & 0 deletions backend/src/modules/withdrawals/withdrawals.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Router } from 'express';
import { requireAuth } from '../../common/middleware/requireAuth.js';
import { env } from '../../config/env.js';
import { mergeOpenApiPaths } from '../../docs/openapi.js';
import * as withdrawalsController from './withdrawals.controller.js';

export const withdrawalsRouter = Router();
Expand All @@ -10,3 +12,200 @@ withdrawalsRouter.post('/submit', requireAuth, withdrawalsController.submitWithd

export const balancesRouter = Router();
balancesRouter.get('/me', requireAuth, withdrawalsController.getMyBalance);

const wdBase = `${env.API_BASE_PATH}/withdrawals`;
const balBase = `${env.API_BASE_PATH}/balances`;

const withdrawalSchema = {
type: 'object',
properties: {
id: { type: 'string', example: 'clxx1234567890abcdef' },
amount: { type: 'string', example: '1000000' },
fee: { type: 'string', example: '20000' },
txHash: { type: 'string', nullable: true, example: 'tx-hash-abc123' },
status: { type: 'string', enum: ['PENDING', 'CONFIRMED', 'FAILED'], example: 'PENDING' },
requestedAt: { type: 'string', format: 'date-time' },
confirmedAt: { type: 'string', format: 'date-time', nullable: true, example: null },
},
required: ['id', 'amount', 'fee', 'txHash', 'status', 'requestedAt', 'confirmedAt'],
};

const balanceSchema = {
type: 'object',
properties: {
stellarAddress: { type: 'string', example: 'GA...ADDRESS' },
totalReceived: { type: 'string', example: '5000000' },
totalWithdrawn: { type: 'string', example: '1000000' },
withdrawableBalance: { type: 'string', example: '4000000' },
},
required: ['stellarAddress', 'totalReceived', 'totalWithdrawn', 'withdrawableBalance'],
};

const preparedWithdrawalSchema = {
type: 'object',
properties: {
unsignedTxXdr: { type: 'string', example: 'AAAAAgAAAAA...' },
destination: { type: 'string', example: 'GA...ADDRESS' },
amount: { type: 'string', example: '1000000' },
fee: { type: 'string', example: '20000' },
netAmount: { type: 'string', example: '980000' },
contractId: { type: 'string', example: 'C...CONTRACT' },
networkPassphrase: { type: 'string', example: 'Test SDF Network ; September 2015' },
},
required: ['unsignedTxXdr', 'destination', 'amount', 'fee', 'netAmount', 'contractId', 'networkPassphrase'],
};

const submittedWithdrawalSchema = {
type: 'object',
properties: {
id: { type: 'string', example: 'clxx1234567890abcdef' },
txHash: { type: 'string', example: 'tx-hash-abc123' },
status: { type: 'string', enum: ['PENDING', 'CONFIRMED', 'FAILED'], example: 'PENDING' },
amount: { type: 'string', example: '1000000' },
fee: { type: 'string', example: '20000' },
netAmount: { type: 'string', example: '980000' },
},
required: ['id', 'txHash', 'status', 'amount', 'fee', 'netAmount'],
};

mergeOpenApiPaths({
[`${wdBase}/me`]: {
get: {
tags: ['Withdrawals'],
summary: 'Get withdrawal history',
description: 'Returns paginated withdrawal history for the authenticated user.',
security: [{ bearerAuth: [] }],
parameters: [
{
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 withdrawal history',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: { type: 'array', items: withdrawalSchema },
},
required: ['data'],
},
},
},
},
'401': { description: 'Unauthorized' },
},
},
},
[`${wdBase}/prepare`]: {
post: {
tags: ['Withdrawals'],
summary: 'Prepare a withdrawal',
description: 'Builds an unsigned Soroban transaction for the authenticated user to sign with their wallet.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
amount: { type: 'string', description: 'Amount in stroops (string of digits)' },
},
required: ['amount'],
},
},
},
},
responses: {
'200': {
description: 'Prepared unsigned transaction',
content: {
'application/json': {
schema: {
type: 'object',
properties: { data: preparedWithdrawalSchema },
required: ['data'],
},
},
},
},
'400': { description: 'Invalid amount or insufficient balance' },
'401': { description: 'Unauthorized' },
},
},
},
[`${wdBase}/submit`]: {
post: {
tags: ['Withdrawals'],
summary: 'Submit a signed withdrawal',
description: 'Broadcasts a wallet-signed withdrawal transaction and records it as PENDING. Idempotent by txHash.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
amount: { type: 'string', description: 'Amount in stroops (string of digits)' },
signedTxXdr: { type: 'string', description: 'Base64-encoded signed transaction XDR' },
},
required: ['amount', 'signedTxXdr'],
},
},
},
},
responses: {
'200': {
description: 'Withdrawal submitted or already recorded (idempotent)',
content: {
'application/json': {
schema: {
type: 'object',
properties: { data: submittedWithdrawalSchema },
required: ['data'],
},
},
},
},
'400': { description: 'Invalid input, insufficient balance, or network rejection' },
'401': { description: 'Unauthorized' },
},
},
},
[`${balBase}/me`]: {
get: {
tags: ['Withdrawals'],
summary: 'Get withdrawable balance',
description: 'Returns the withdrawable balance for the authenticated user.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Withdrawable balance details',
content: {
'application/json': {
schema: {
type: 'object',
properties: { data: balanceSchema },
required: ['data'],
},
},
},
},
'401': { description: 'Unauthorized' },
},
},
},
});
Loading
Loading