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
241 changes: 241 additions & 0 deletions __tests__/notifications/dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
dispatchNotification,
buildNotificationContent,
type NotificationType,
} from '@/lib/notifications';
import {
sendEmail,
getEmailTransport,
setEmailTransport,
ConsoleEmailTransport,
ResendEmailTransport,
type EmailMessage,
type EmailTransport,
} from '@/lib/email';
import { sql } from '@/lib/db';

vi.mock('@/lib/db', () => ({
sql: vi.fn(),
}));

class FakeTransport implements EmailTransport {
readonly name = 'fake';
sent: EmailMessage[] = [];
failWith: Error | null = null;

async send(message: EmailMessage): Promise<void> {
if (this.failWith) throw this.failWith;
this.sent.push(message);
}
}

const notificationRow = (type: string) => ({
id: 'n-1',
user_id: 'u-1',
type,
payload: {},
is_read: false,
created_at: new Date('2026-01-01T00:00:00Z'),
});

describe('buildNotificationContent', () => {
it('formats milestone_submitted', () => {
const content = buildNotificationContent('milestone_submitted', {
milestoneName: 'Design mockups',
});
expect(content.title).toBe('Milestone submitted');
expect(content.body).toContain('Design mockups');
});

it('formats funds_released', () => {
const content = buildNotificationContent('funds_released', {
amount: '500.00 USDC',
milestoneName: 'Phase 1',
});
expect(content.title).toBe('Funds released');
expect(content.body).toContain('500.00 USDC');
expect(content.body).toContain('Phase 1');
});

it('formats dispute_raised', () => {
const content = buildNotificationContent('dispute_raised', {
reason: 'Deliverables missing',
});
expect(content.title).toBe('Dispute opened');
expect(content.body).toContain('Deliverables missing');
});

it('formats wallet_activity', () => {
const content = buildNotificationContent('wallet_activity', {
description: 'Your wallet funded the escrow contract',
amount: '1000 USDC',
});
expect(content.title).toBe('Wallet activity');
expect(content.body).toContain('funded the escrow contract');
expect(content.body).toContain('1000 USDC');
});

it('falls back to generic wording when payload fields are missing', () => {
const types: NotificationType[] = [
'milestone_submitted',
'funds_released',
'dispute_raised',
'wallet_activity',
];
for (const type of types) {
const content = buildNotificationContent(type, {});
expect(content.title.length).toBeGreaterThan(0);
expect(content.body.length).toBeGreaterThan(0);
}
});
});

describe('dispatchNotification', () => {
let transport: FakeTransport;

beforeEach(() => {
vi.clearAllMocks();
transport = new FakeTransport();
setEmailTransport(transport);
delete process.env.NOTIFICATIONS_EMAIL_DISABLED;
});

afterEach(() => {
setEmailTransport(null);
});

it('persists in-app and sends email', async () => {
(sql as any)
.mockResolvedValueOnce([notificationRow('milestone_submitted')])
.mockResolvedValueOnce([{ email: 'client@example.com' }]);

const result = await dispatchNotification('u-1', 'milestone_submitted', {
milestoneName: 'Phase 1',
});

expect(result.inApp).toBe(true);
expect(result.email).toBe(true);
expect(result.notification?.type).toBe('milestone_submitted');
expect(transport.sent).toHaveLength(1);
expect(transport.sent[0].to).toBe('client@example.com');
expect(transport.sent[0].subject).toBe('Milestone submitted');
expect(transport.sent[0].text).toContain('Phase 1');
});

it('still persists in-app when email sending fails', async () => {
transport.failWith = new Error('smtp down');
(sql as any)
.mockResolvedValueOnce([notificationRow('funds_released')])
.mockResolvedValueOnce([{ email: 'client@example.com' }]);

const result = await dispatchNotification('u-1', 'funds_released', {
amount: '500.00 USDC',
});

expect(result.inApp).toBe(true);
expect(result.email).toBe(false);
});

it('still emails when in-app persistence fails', async () => {
(sql as any)
.mockRejectedValueOnce(new Error('db down'))
.mockResolvedValueOnce([{ email: 'client@example.com' }]);

const result = await dispatchNotification('u-1', 'dispute_raised', {
reason: 'No response',
});

expect(result.inApp).toBe(false);
expect(result.notification).toBeNull();
expect(result.email).toBe(true);
expect(transport.sent).toHaveLength(1);
});

it('skips email when the user has no email address', async () => {
(sql as any)
.mockResolvedValueOnce([notificationRow('wallet_activity')])
.mockResolvedValueOnce([]);

const result = await dispatchNotification('u-1', 'wallet_activity', {});

expect(result.inApp).toBe(true);
expect(result.email).toBe(false);
expect(transport.sent).toHaveLength(0);
});

it('respects channel overrides', async () => {
(sql as any).mockResolvedValueOnce([{ email: 'client@example.com' }]);

const result = await dispatchNotification(
'u-1',
'wallet_activity',
{},
{ inApp: false },
);

expect(result.inApp).toBe(false);
expect(result.email).toBe(true);
expect(sql).toHaveBeenCalledTimes(1);
});

it('respects NOTIFICATIONS_EMAIL_DISABLED', async () => {
process.env.NOTIFICATIONS_EMAIL_DISABLED = 'true';
(sql as any).mockResolvedValueOnce([notificationRow('escrow_funded')]);

const result = await dispatchNotification('u-1', 'escrow_funded', {});

expect(result.inApp).toBe(true);
expect(result.email).toBe(false);
expect(sql).toHaveBeenCalledTimes(1);
expect(transport.sent).toHaveLength(0);
});
});

describe('email transport selection', () => {
const originalKey = process.env.RESEND_API_KEY;

afterEach(() => {
if (originalKey === undefined) delete process.env.RESEND_API_KEY;
else process.env.RESEND_API_KEY = originalKey;
setEmailTransport(null);
});

it('falls back to the console transport when no provider is configured', () => {
delete process.env.RESEND_API_KEY;
setEmailTransport(null);
expect(getEmailTransport()).toBeInstanceOf(ConsoleEmailTransport);
});

it('uses the Resend transport when RESEND_API_KEY is set', () => {
process.env.RESEND_API_KEY = 're_test_key';
setEmailTransport(null);
expect(getEmailTransport()).toBeInstanceOf(ResendEmailTransport);
});

it('bounds Resend API calls with a timeout signal', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
try {
const resend = new ResendEmailTransport('re_key', 'T <t@t.t>');
await resend.send({ to: 'a@b.c', subject: 's', text: 't' });

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
} finally {
vi.unstubAllGlobals();
}
});

it('sendEmail returns false instead of throwing on transport failure', async () => {
const failing = new FakeTransport();
failing.failWith = new Error('boom');
setEmailTransport(failing);

await expect(
sendEmail({ to: 'a@b.c', subject: 's', text: 't' }),
).resolves.toBe(false);
});
});
14 changes: 14 additions & 0 deletions app/api/escrow/dispute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
escrowErrorToHttpStatus,
type DisputeRaisedBy,
} from '@/lib/escrow'
import { dispatchNotification } from '@/lib/notifications'

export const POST = withAuth(async (request: NextRequest, auth) => {
let body: Record<string, unknown>
Expand Down Expand Up @@ -63,6 +64,19 @@ export const POST = withAuth(async (request: NextRequest, auth) => {
responseDeadline: body.responseDeadline as string | undefined,
})

// Notify every contract party except whoever raised the dispute
const parties = [result.contract.clientId, result.contract.freelancerId]
.filter((id) => id !== userId)
await Promise.all(
parties.map((partyId) =>
dispatchNotification(partyId, 'dispute_raised', {
disputeId: result.dispute.id,
contractId: result.contract.id,
reason: result.dispute.reason,
})
)
)

return NextResponse.json(
{
disputeId: result.dispute.id,
Expand Down
16 changes: 16 additions & 0 deletions app/api/escrow/fund/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'
import { dispatchNotification } from '@/lib/notifications'

export const POST = withRbac('escrow:fund', async (request: NextRequest, auth: RbacContext) => {
let body: Record<string, unknown>
Expand All @@ -33,6 +34,21 @@ export const POST = withRbac('escrow:fund', async (request: NextRequest, auth: R
amount: body.amount as string,
})

const amount = `${body.amount} ${result.contract.currency}`
await Promise.all([
dispatchNotification(result.contract.clientId, 'wallet_activity', {
contractId: result.contract.id,
description: 'Your wallet funded the escrow contract',
amount,
txHash: result.contract.fundingTxHash,
}),
dispatchNotification(result.contract.freelancerId, 'escrow_funded', {
contractId: result.contract.id,
amount,
txHash: result.contract.fundingTxHash,
}),
])

return NextResponse.json({
contractId: result.contract.id,
escrowStatus: result.contract.escrowStatus,
Expand Down
16 changes: 16 additions & 0 deletions app/api/escrow/refund/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'
import { dispatchNotification } from '@/lib/notifications'

export const POST = withAnyRbac(['escrow:refund', 'admin:contracts_freeze'], async (request: NextRequest, auth: RbacContext) => {
let body: Record<string, unknown>
Expand All @@ -31,6 +32,21 @@ export const POST = withAnyRbac(['escrow:refund', 'admin:contracts_freeze'], asy
reason: body.reason as string,
})

const amount = `${result.contract.totalAmount} ${result.contract.currency}`
await Promise.all([
dispatchNotification(result.contract.clientId, 'wallet_activity', {
contractId: result.contract.id,
description: 'Escrow funds were refunded to your wallet',
amount,
txHash: result.refundTxHash,
}),
dispatchNotification(result.contract.freelancerId, 'escrow_refunded', {
contractId: result.contract.id,
amount,
txHash: result.refundTxHash,
}),
])

return NextResponse.json({
contractId: result.contract.id,
contractStatus: result.contract.status,
Expand Down
19 changes: 19 additions & 0 deletions app/api/escrow/release/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'
import { dispatchNotification } from '@/lib/notifications'

export const POST = withRbac('escrow:release', async (request: NextRequest, auth: RbacContext) => {
let body: Record<string, unknown>
Expand All @@ -31,6 +32,24 @@ export const POST = withRbac('escrow:release', async (request: NextRequest, auth
callerWalletAddress: auth.walletAddress,
})

const amount = `${result.milestone.amount} ${result.milestone.currency}`
await Promise.all([
dispatchNotification(result.contract.clientId, 'funds_released', {
contractId: result.contract.id,
milestoneId: result.milestone.id,
milestoneName: result.milestone.title,
amount,
txHash: result.releaseTxHash,
}),
dispatchNotification(result.contract.freelancerId, 'payment_received', {
contractId: result.contract.id,
milestoneId: result.milestone.id,
milestoneName: result.milestone.title,
amount,
txHash: result.releaseTxHash,
}),
])

return NextResponse.json({
milestoneId: result.milestone.id,
milestoneStatus: result.milestone.status,
Expand Down
9 changes: 8 additions & 1 deletion app/api/milestones/[id]/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { sql } from '@/lib/db'
import { dispatchNotification } from '@/lib/notifications'

// Only the contract freelancer can submit a milestone (status must be pending or in_progress)
export const POST = withRbac('milestone:submit', async (request: NextRequest, auth: RbacContext) => {
Expand All @@ -14,7 +15,7 @@ export const POST = withRbac('milestone:submit', async (request: NextRequest, au

// Fetch milestone with contract info to verify freelancer role
const [milestone] = await sql`
SELECT m.*, c.freelancer_id
SELECT m.*, c.freelancer_id, c.client_id
FROM milestones m
LEFT JOIN contracts c ON c.id = m.contract_id
WHERE m.id = ${id}
Expand Down Expand Up @@ -45,6 +46,12 @@ export const POST = withRbac('milestone:submit', async (request: NextRequest, au
RETURNING *
`

await dispatchNotification(milestone.client_id, 'milestone_submitted', {
milestoneId: updated.id,
milestoneName: updated.title,
contractId: milestone.contract_id,
})

return NextResponse.json({ milestone: updated })
} catch {
return NextResponse.json({ error: 'Failed to submit milestone', code: 'MILESTONE_SUBMIT_FAILED' }, { status: 500 })
Expand Down
Loading
Loading