diff --git a/__tests__/notifications/dispatch.test.ts b/__tests__/notifications/dispatch.test.ts new file mode 100644 index 0000000..e4f1dd9 --- /dev/null +++ b/__tests__/notifications/dispatch.test.ts @@ -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 { + 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 '); + 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); + }); +}); diff --git a/app/api/escrow/dispute/route.ts b/app/api/escrow/dispute/route.ts index 9f94f61..0eea997 100644 --- a/app/api/escrow/dispute/route.ts +++ b/app/api/escrow/dispute/route.ts @@ -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 @@ -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, diff --git a/app/api/escrow/fund/route.ts b/app/api/escrow/fund/route.ts index 08e9138..d1ee423 100644 --- a/app/api/escrow/fund/route.ts +++ b/app/api/escrow/fund/route.ts @@ -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 @@ -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, diff --git a/app/api/escrow/refund/route.ts b/app/api/escrow/refund/route.ts index a7d7cbd..aa3b47f 100644 --- a/app/api/escrow/refund/route.ts +++ b/app/api/escrow/refund/route.ts @@ -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 @@ -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, diff --git a/app/api/escrow/release/route.ts b/app/api/escrow/release/route.ts index ecb6847..580cafb 100644 --- a/app/api/escrow/release/route.ts +++ b/app/api/escrow/release/route.ts @@ -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 @@ -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, diff --git a/app/api/milestones/[id]/submit/route.ts b/app/api/milestones/[id]/submit/route.ts index 3649195..a50bee6 100644 --- a/app/api/milestones/[id]/submit/route.ts +++ b/app/api/milestones/[id]/submit/route.ts @@ -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) => { @@ -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} @@ -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 }) diff --git a/components/dashboard/notification-item.tsx b/components/dashboard/notification-item.tsx index ff43455..dc24d5f 100644 --- a/components/dashboard/notification-item.tsx +++ b/components/dashboard/notification-item.tsx @@ -31,6 +31,14 @@ const NOTIFICATION_CONFIG: Record< description: (payload: Record) => string; } > = { + milestone_submitted: { + icon: ">", + color: + "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300", + label: "Milestone Submitted", + description: (payload) => + `Milestone "${(payload.milestoneName as string) || "Unnamed"}" was submitted for review`, + }, milestone_approved: { icon: "✓", color: @@ -69,6 +77,21 @@ const NOTIFICATION_CONFIG: Record< description: (payload) => `Escrow has been funded with ${(payload.amount as string) || "funds"}`, }, + escrow_refunded: { + icon: "<", + color: + "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300", + label: "Escrow Refunded", + description: (payload) => + `Escrow funds of ${(payload.amount as string) || "the contract"} were refunded to the client`, + }, + payment_received: { + icon: "$", + color: "bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300", + label: "Payment Received", + description: (payload) => + `You received ${(payload.amount as string) || "a payment"} for milestone "${(payload.milestoneName as string) || "Unnamed"}"`, + }, payment_released: { icon: "✓", color: @@ -77,6 +100,15 @@ const NOTIFICATION_CONFIG: Record< description: (payload) => `Payment of ${(payload.amount as string) || "funds"} has been released`, }, + wallet_activity: { + icon: "$", + color: "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300", + label: "Wallet Activity", + description: (payload) => + `${(payload.description as string) || "There was activity on your wallet"}${ + payload.amount ? ` (${payload.amount as string})` : "" + }`, + }, }; interface NotificationItemProps { diff --git a/env.example b/env.example index f4eebdd..6579222 100644 --- a/env.example +++ b/env.example @@ -25,3 +25,10 @@ ESCROW_ACCOUNT_ID=G... # ─── Optional ───────────────────────────────────────────────────────────────── # Override the freelancer dashboard API base URL (defaults to /api/freelancer/dashboard). # NEXT_PUBLIC_DASHBOARD_API_URL=https://your-domain.com + +# Email notifications. Without RESEND_API_KEY, emails are logged to the console +# (dev fallback) instead of being sent. +# RESEND_API_KEY=re_... +# EMAIL_FROM=TaskChain +# Set to "true" to turn off the email channel entirely (in-app still works). +# NOTIFICATIONS_EMAIL_DISABLED=true diff --git a/lib/email.ts b/lib/email.ts new file mode 100644 index 0000000..4f75fa7 --- /dev/null +++ b/lib/email.ts @@ -0,0 +1,97 @@ +// lib/email.ts +// +// Email delivery channel for notifications, behind a transport interface. +// When RESEND_API_KEY is set, emails go through the Resend HTTP API (plain +// fetch, no SDK dependency). Otherwise a console transport logs the message +// so development works without any provider configured. + +export interface EmailMessage { + to: string; + subject: string; + text: string; +} + +export interface EmailTransport { + readonly name: string; + send(message: EmailMessage): Promise; +} + +export class ConsoleEmailTransport implements EmailTransport { + readonly name = "console"; + + async send(message: EmailMessage): Promise { + console.log( + `[email:console] to=${message.to} subject=${JSON.stringify(message.subject)}\n${message.text}`, + ); + } +} + +const RESEND_API_URL = "https://api.resend.com/emails"; +// Dispatch is awaited on request paths, so a hung provider must not be able +// to stall a route response indefinitely. +const RESEND_TIMEOUT_MS = 10_000; + +export class ResendEmailTransport implements EmailTransport { + readonly name = "resend"; + + constructor( + private readonly apiKey: string, + private readonly from: string, + ) {} + + async send(message: EmailMessage): Promise { + const res = await fetch(RESEND_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: this.from, + to: [message.to], + subject: message.subject, + text: message.text, + }), + signal: AbortSignal.timeout(RESEND_TIMEOUT_MS), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Resend API error ${res.status}: ${body}`); + } + } +} + +let _transport: EmailTransport | null = null; + +export function getEmailTransport(): EmailTransport { + if (!_transport) { + const apiKey = process.env.RESEND_API_KEY; + _transport = apiKey + ? new ResendEmailTransport( + apiKey, + process.env.EMAIL_FROM ?? "TaskChain ", + ) + : new ConsoleEmailTransport(); + } + return _transport; +} + +// Test seam: inject a fake transport, or pass null to re-resolve from env. +export function setEmailTransport(transport: EmailTransport | null): void { + _transport = transport; +} + +/** + * Sends an email through the configured transport. Never throws: failures + * are logged and reported as `false` so notification dispatch can continue. + */ +export async function sendEmail(message: EmailMessage): Promise { + const transport = getEmailTransport(); + try { + await transport.send(message); + return true; + } catch (err) { + console.error(`[email:${transport.name}] send failed:`, err); + return false; + } +} diff --git a/lib/notifications.ts b/lib/notifications.ts index d648175..9c0777b 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -8,18 +8,21 @@ // DB snake_case ←→ JS camelCase (done manually — no ORM) import { sql } from "@/lib/db"; +import { sendEmail } from "@/lib/email"; // ─── Types ───────────────────────────────────────────────────────────────── export type NotificationType = | "milestone_approved" + | "milestone_submitted" | "funds_released" | "contract_created" | "dispute_raised" | "escrow_funded" | "escrow_refunded" | "payment_released" - | "payment_received"; + | "payment_received" + | "wallet_activity"; export interface Notification { id: string; @@ -200,3 +203,147 @@ export async function createNotification( return rowToNotification(rows[0] as Record); } + +// --- Message formatting ---------------------------------------------------- + +export interface NotificationContent { + title: string; + body: string; +} + +function str(payload: Record, key: string): string | null { + const value = payload[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +// Every NotificationType must have an entry, so adding a new event type +// forces a template to be written for it. +const CONTENT_BUILDERS: Record< + NotificationType, + (payload: Record) => NotificationContent +> = { + milestone_submitted: (p) => ({ + title: "Milestone submitted", + body: `Milestone "${str(p, "milestoneName") ?? "Unnamed"}" was submitted for your review.`, + }), + milestone_approved: (p) => ({ + title: "Milestone approved", + body: `Milestone "${str(p, "milestoneName") ?? "Unnamed"}" has been approved.`, + }), + funds_released: (p) => ({ + title: "Funds released", + body: `${str(p, "amount") ?? "Funds"} released for milestone "${str(p, "milestoneName") ?? "Unnamed"}".`, + }), + payment_received: (p) => ({ + title: "Payment received", + body: `You received ${str(p, "amount") ?? "a payment"} for milestone "${str(p, "milestoneName") ?? "Unnamed"}".`, + }), + payment_released: (p) => ({ + title: "Payment released", + body: `Payment of ${str(p, "amount") ?? "funds"} has been released.`, + }), + contract_created: (p) => ({ + title: "Contract created", + body: `New contract "${str(p, "contractName") ?? "Untitled"}" created.`, + }), + dispute_raised: (p) => ({ + title: "Dispute opened", + body: `A dispute has been opened on your contract: ${str(p, "reason") ?? "see the dispute page for details"}.`, + }), + escrow_funded: (p) => ({ + title: "Escrow funded", + body: `Escrow has been funded with ${str(p, "amount") ?? "funds"}. Work can begin.`, + }), + escrow_refunded: (p) => ({ + title: "Escrow refunded", + body: `Escrow funds of ${str(p, "amount") ?? "the contract"} were refunded to the client.`, + }), + wallet_activity: (p) => ({ + title: "Wallet activity", + body: `${str(p, "description") ?? "There was activity on your wallet"}${str(p, "amount") ? ` (${str(p, "amount")})` : ""}.`, + }), +}; + +export function buildNotificationContent( + type: NotificationType, + payload: Record = {}, +): NotificationContent { + return CONTENT_BUILDERS[type](payload); +} + +// --- Unified dispatch (in-app + email) ------------------------------------- + +export interface DispatchChannels { + inApp?: boolean; + email?: boolean; +} + +export interface DispatchResult { + inApp: boolean; + email: boolean; + notification: Notification | null; +} + +async function getUserEmail(userId: string): Promise { + const rows = (await sql` + SELECT email FROM users WHERE id = ${userId} LIMIT 1 + `) as Record[]; + + const email = rows[0]?.email; + return typeof email === "string" && email.length > 0 ? email : null; +} + +export function isEmailChannelEnabled(): boolean { + return process.env.NOTIFICATIONS_EMAIL_DISABLED !== "true"; +} + +/** + * Delivers a notification for an event through all enabled channels: + * persists an in-app notification and emails the user. Each channel fails + * independently and is only logged, so callers (route handlers) can invoke + * this after their main side-effect without risking the request. + */ +export async function dispatchNotification( + userId: string, + type: NotificationType, + payload: Record = {}, + channels: DispatchChannels = {}, +): Promise { + const wantInApp = channels.inApp ?? true; + const wantEmail = (channels.email ?? true) && isEmailChannelEnabled(); + + const result: DispatchResult = { inApp: false, email: false, notification: null }; + + if (wantInApp) { + try { + result.notification = await createNotification(userId, type, payload); + result.inApp = true; + } catch (err) { + console.error( + `[notifications] in-app dispatch failed (type=${type}, user=${userId}):`, + err, + ); + } + } + + if (wantEmail) { + try { + const email = await getUserEmail(userId); + if (email) { + const content = buildNotificationContent(type, payload); + result.email = await sendEmail({ + to: email, + subject: content.title, + text: content.body, + }); + } + } catch (err) { + console.error( + `[notifications] email dispatch failed (type=${type}, user=${userId}):`, + err, + ); + } + } + + return result; +}