diff --git a/__tests__/api/freelancers.test.ts b/__tests__/api/freelancers.test.ts new file mode 100644 index 0000000..1aac467 --- /dev/null +++ b/__tests__/api/freelancers.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { GET as listFreelancers } from '@/app/api/freelancers/route' +import { GET as getFreelancer } from '@/app/api/freelancers/[id]/route' + +vi.mock('@/lib/db', () => ({ + sql: vi.fn(), +})) + +vi.mock('@/lib/reputation', () => ({ + userExists: vi.fn().mockResolvedValue(true), + getFreelancerReputation: vi.fn().mockResolvedValue({ + userId: 1, + metrics: { + completionRate: 1, + disputeRate: 0, + totalVolume: 0, + onTimeDeliveryPct: 1, + jobsStarted: 3, + jobsCompleted: 3, + jobsWithDispute: 0, + completedWithDeadline: 3, + onTimeDeliveries: 3, + }, + reputationScore: 100, + computedAt: '2026-06-27T00:00:00.000Z', + }), +})) + +import { sql } from '@/lib/db' +import { + mapUserRowToListing, + parseDiscoveryParams, + FreelancerDiscoveryError, + FREELANCER_SORTABLE_FIELDS, + FREELANCER_MAX_LIMIT, +} from '@/lib/freelancerDiscovery' +import { NextRequest } from 'next/server' + +type SqlMock = ReturnType + +interface UserRowOverrides { + id?: number + wallet_address?: string + username?: string + email?: string | null + user_type?: string + bio?: string | null + skills?: string[] | null + rating?: number | string | null + total_jobs_completed?: number | null + total_count?: string + created_at?: Date +} + +function buildUserRow( + overrides: UserRowOverrides = {}, +): Array> { + return [ + { + id: 1, + wallet_address: 'GABC123', + username: 'maya_chen', + email: 'maya@example.com', + user_type: 'freelancer', + bio: 'Builds polished dApps', + skills: ['React', 'Next.js'], + rating: 4.5, + total_jobs_completed: 12, + created_at: new Date('2026-01-01T00:00:00Z'), + total_count: '7', + ...overrides, + }, + ] +} + +function queueSql(responses: unknown[]) { + const mock = sql as unknown as SqlMock + for (const response of responses) { + mock.mockResolvedValueOnce(response) + } +} + +function queueSqlReject(error: unknown) { + const mock = sql as unknown as SqlMock + mock.mockRejectedValueOnce(error) +} + +function makeRequest(url: string): NextRequest { + return new NextRequest(new Request(url)) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('parseDiscoveryParams', () => { + it('applies defaults when no params are provided', () => { + const params = parseDiscoveryParams(new URLSearchParams()) + expect(params).toEqual({ + query: '', + skills: [], + minRating: null, + sort: 'rating', + order: 'desc', + limit: 6, + page: 1, + }) + }) + + it('dedupes skills across repeating and comma-separated values', () => { + const params = parseDiscoveryParams( + new URLSearchParams('skills=react&skills=react,nodejs&skills=next.js'), + ) + expect(params.skills).toEqual(['react', 'nodejs', 'next.js']) + }) + + it('accepts the legacy `rating=` alias for minRating', () => { + const params = parseDiscoveryParams(new URLSearchParams('rating=4')) + expect(params.minRating).toBe(4) + }) + + it('rejects out-of-range minRating', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('minRating=6'))) + .toThrowError(FreelancerDiscoveryError) + expect(() => parseDiscoveryParams(new URLSearchParams('minRating=0'))) + .toThrowError(FreelancerDiscoveryError) + }) + + it('rejects unknown sort fields', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('sort=password'))) + .toThrowError(FreelancerDiscoveryError) + }) + + it('accepts whitelisted sort fields', () => { + for (const field of FREELANCER_SORTABLE_FIELDS) { + const params = parseDiscoveryParams(new URLSearchParams(`sort=${field}`)) + expect(params.sort).toBe(field) + } + }) + + it('rejects bogus order values', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('order=ascending'))) + .toThrowError(FreelancerDiscoveryError) + }) + + it('clamps limit above the maximum', () => { + const huge = FREELANCER_MAX_LIMIT * 10 + const params = parseDiscoveryParams(new URLSearchParams(`limit=${huge}`)) + expect(params.limit).toBe(FREELANCER_MAX_LIMIT) + }) + + it('rejects zero or negative page', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('page=0'))) + .toThrowError(FreelancerDiscoveryError) + expect(() => parseDiscoveryParams(new URLSearchParams('page=-1'))) + .toThrowError(FreelancerDiscoveryError) + }) + + it('normalises unsupported limit values to default', () => { + const params = parseDiscoveryParams(new URLSearchParams('limit=abc')) + expect(params.limit).toBe(6) + }) + + it('trims the search query', () => { + const params = parseDiscoveryParams(new URLSearchParams('q=%20%20hello%20%20')) + expect(params.query).toBe('hello') + }) +}) + +describe('mapUserRowToListing', () => { + it('falls back to placeholder values for missing DB fields', () => { + const listing = mapUserRowToListing({ + id: 9, + wallet_address: 'GXYZ', + username: 'aisha', + email: null, + user_type: 'freelancer', + bio: null, + skills: null, + rating: null, + total_jobs_completed: null, + created_at: new Date('2026-05-01T00:00:00Z'), + }) + + expect(listing).toMatchObject({ + id: 9, + name: 'aisha', + bio: '', + skills: [], + rating: 0, + completedProjects: 0, + profileImage: '/placeholder-user.jpg', + hourlyRate: 0, + location: '', + title: 'TaskChain Freelancer', + }) + }) + + it('clamps out-of-range numeric ratings', () => { + const listing = mapUserRowToListing({ + id: 1, + wallet_address: 'GABC', + username: 'sample', + email: null, + user_type: 'freelancer', + bio: null, + skills: [], + rating: 99, + total_jobs_completed: 0, + created_at: new Date(), + }) + expect(listing.rating).toBe(0) + }) +}) + +describe('GET /api/freelancers', () => { + it('returns the discovery payload with proper pagination metadata', async () => { + // Path: WHERE fragment → ORDER BY fragment → main list → skills query. + // With the single-template WHERE/ORDER BY refactor, exactly 4 sql calls: + // 1. WHERE fragment in listFreelancers + // 2. ORDER BY fragment in listFreelancers + // 3. main list query + // 4. getAvailableSkills + queueSql([ + [], // WHERE fragment + [], // ORDER BY fragment + buildUserRow(), // main list (returns 1 row, so no fallback COUNT) + [{ skill: 'React' }, { skill: 'Node.js' }], // skills + ]) + + const request = makeRequest( + 'http://localhost/api/freelancers?q=maya&minRating=4&page=1&limit=2', + ) + const response = await listFreelancers(request) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.freelancers).toHaveLength(1) + expect(body.freelancers[0].name).toBe('maya_chen') + expect(body.pagination).toEqual({ + page: 1, + pageSize: 1, + totalItems: 7, + totalPages: 4, + }) + expect(body.skills).toEqual(['React', 'Node.js']) + }) + + it('returns 0 freelancers for an out-of-range page with an accurate totalItems', async () => { + // Path: WHERE → ORDER BY → main list (empty) → COUNT fallback + // (which itself does WHERE + count) → skills. Six sql calls total. + queueSql([ + [], // WHERE fragment for listFreelancers + [], // ORDER BY fragment + [], // main list (empty) + [], // WHERE fragment rebuilt inside countFreelancers + [{ count: '3' }], // COUNT(*) fallback + [], // skills (none) + ]) + + const request = makeRequest( + 'http://localhost/api/freelancers?page=99&limit=10', + ) + const response = await listFreelancers(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.freelancers).toEqual([]) + expect(body.pagination).toEqual({ + page: 1, + pageSize: 0, + totalItems: 3, + totalPages: 1, + }) + }) + + it('returns 400 with structured error on invalid minRating', async () => { + const request = makeRequest('http://localhost/api/freelancers?minRating=42') + const response = await listFreelancers(request) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ + error: expect.stringContaining('minRating'), + code: 'INVALID_MIN_RATING', + }) + }) + + it('returns 400 on invalid sort field', async () => { + const request = makeRequest( + 'http://localhost/api/freelancers?sort=payout_total', + ) + const response = await listFreelancers(request) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.code).toBe('INVALID_SORT_FIELD') + }) + + it('returns 503 when the DB query fails', async () => { + // Path: WHERE succeeds → main list rejects → count_fallback and skills + // are not invoked. Two sql calls: one resolve, one reject. + queueSql([[]]) // WHERE fragment resolves first + queueSqlReject(new Error('connection reset')) // main list rejects + + const request = makeRequest('http://localhost/api/freelancers') + const response = await listFreelancers(request) + + expect(response.status).toBe(503) + const body = await response.json() + expect(body).toEqual({ + error: 'Unable to load freelancers', + code: 'FREELANCER_LIST_FAILED', + }) + }) +}) + +describe('GET /api/freelancers/[id]', () => { + it('returns the freelancer profile with nested reputation', async () => { + queueSql([buildUserRow()]) + + const request = makeRequest('http://localhost/api/freelancers/1') + const response = await getFreelancer(request, { + params: Promise.resolve({ id: '1' }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.freelancer.name).toBe('maya_chen') + expect(body.reputation).toMatchObject({ + userId: 1, + reputationScore: 100, + }) + }) + + it('returns the freelancer profile with null reputation when reputation lookup throws', async () => { + const reputationModule = await import('@/lib/reputation') + vi.mocked(reputationModule.userExists).mockRejectedValueOnce( + new Error('downstream failure'), + ) + + queueSql([buildUserRow()]) + + const request = makeRequest('http://localhost/api/freelancers/1') + const response = await getFreelancer(request, { + params: Promise.resolve({ id: '1' }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.freelancer.name).toBe('maya_chen') + expect(body.reputation).toBeNull() + }) + + it('returns 400 for a non-numeric id', async () => { + const request = makeRequest('http://localhost/api/freelancers/abc') + const response = await getFreelancer(request, { + params: Promise.resolve({ id: 'abc' }), + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ + error: 'Invalid freelancer id', + code: 'INVALID_ID', + }) + }) + + it('returns 404 when the freelancer does not exist', async () => { + queueSql([[]]) + + const request = makeRequest('http://localhost/api/freelancers/9999') + const response = await getFreelancer(request, { + params: Promise.resolve({ id: '9999' }), + }) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body.code).toBe('FREELANCER_NOT_FOUND') + }) +}) diff --git a/__tests__/api/notifications.test.ts b/__tests__/api/notifications.test.ts new file mode 100644 index 0000000..8c42c0b --- /dev/null +++ b/__tests__/api/notifications.test.ts @@ -0,0 +1,519 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('@/lib/db', () => ({ + sql: vi.fn(), +})) + +vi.mock('@/lib/auth/middleware', async () => { + const actual = await vi.importActual< + typeof import('@/lib/auth/middleware') + >('@/lib/auth/middleware') + // Both auth wrappers inject the same fake AuthContext so the handler + // body runs as if a valid JWT was presented. Without mocking + // withAuthCtx too, PATCH routes (which use withAuthCtx to receive a + // Next.js route context) would fall through to the real wrapper and + // return 401. + const injectAuth = (handler: (...args: unknown[]) => unknown) => + async (request: unknown, ...rest: unknown[]) => + handler( + request, + { walletAddress: '0xTEST_WALLET', tokenJti: 'test-jti' }, + ...rest, + ) + return { + ...actual, + withAuth: injectAuth, + withAuthCtx: injectAuth, + resolveUserIdByWallet: vi.fn(async (walletAddress: string) => { + if (walletAddress === '0xUNRESOLVED') return null + return 42 + }), + } +}) + +import { sql } from '@/lib/db' +import { GET as listNotifications } from '@/app/api/notifications/route' +import { + PATCH as markRead, +} from '@/app/api/notifications/[id]/read/route' +import { + POST as markAllRead, +} from '@/app/api/notifications/read-all/route' +import { + GET as streamNotifications, +} from '@/app/api/notifications/stream/route' +import { + NotificationError, + NotificationHub, + createNotification, + listNotificationsForUser, + mapNotificationRow, + markAllNotificationsRead, + markNotificationRead, + parseNotificationQuery, + notifyContractCreated, + notifyMilestoneApproved, + notifyMilestoneSubmitted, + notifyDisputeCreated, + notifyEscrowReleased, + NOTIFICATION_EVENT_TYPES, + NOTIFICATION_MAX_LIMIT, + type Notification, +} from '@/lib/notifications' + +type SqlMock = ReturnType + +interface NotificationRowOverrides { + id?: number + user_id?: number + title?: string + message?: string + type?: string + event_type?: string + payload?: Record + is_read?: boolean + channel?: string + created_at?: Date | string + delivered_at?: Date | string | null + total_count?: string | number +} + +function buildNotificationRow( + overrides: NotificationRowOverrides = {}, +): Array> { + return [ + { + id: 1, + user_id: 42, + title: 'Milestone Approved', + message: 'Milestone "Build login" was approved.', + type: 'success', + event_type: 'milestone_approved', + payload: { milestoneId: 7 }, + is_read: false, + channel: 'in_app', + created_at: new Date('2026-01-01T00:00:00Z'), + delivered_at: null, + total_count: '3', + ...overrides, + }, + ] +} + +function queueSqlFail(error: unknown): void { + ;(sql as unknown as SqlMock).mockRejectedValueOnce(error) +} + +function queueSql(responses: unknown[]): void { + const mock = sql as unknown as SqlMock + for (const response of responses) { + mock.mockResolvedValueOnce(response) + } +} + +function makeRequest(url: string): NextRequest { + return new NextRequest(new Request(url)) +} + +beforeEach(() => { + vi.clearAllMocks() + NotificationHub.resetInstance() +}) + +describe('parseNotificationQuery', () => { + it('returns default values for empty params', () => { + expect(parseNotificationQuery(new URLSearchParams())).toEqual({ + page: 1, + limit: 20, + type: null, + unreadOnly: false, + }) + }) + + it('clamps limit to the maximum', () => { + const params = parseNotificationQuery( + new URLSearchParams(`limit=${NOTIFICATION_MAX_LIMIT * 5}`), + ) + expect(params.limit).toBe(NOTIFICATION_MAX_LIMIT) + }) + + it('accepts a known event_type', () => { + const params = parseNotificationQuery( + new URLSearchParams('type=milestone_submitted'), + ) + expect(params.type).toBe('milestone_submitted') + }) + + it('rejects an unknown event_type', () => { + expect(() => + parseNotificationQuery(new URLSearchParams('type=banana')), + ).toThrowError(NotificationError) + }) + + it('accepts truthy unreadOnly variants', () => { + expect( + parseNotificationQuery(new URLSearchParams('unreadOnly=true')).unreadOnly, + ).toBe(true) + expect( + parseNotificationQuery(new URLSearchParams('unreadOnly=1')).unreadOnly, + ).toBe(true) + }) + + it('rejects invalid page/limit', () => { + expect(() => parseNotificationQuery(new URLSearchParams('page=0'))) + .toThrowError(NotificationError) + expect(() => parseNotificationQuery(new URLSearchParams('limit=0'))) + .toThrowError(NotificationError) + }) +}) + +describe('mapNotificationRow', () => { + it('converts a row into the API shape', () => { + const notification = mapNotificationRow({ + id: 9, + user_id: 42, + title: 'T', + message: 'M', + type: 'success', + event_type: 'escrow_released', + payload: { foo: 'bar' }, + is_read: false, + channel: 'in_app', + created_at: new Date('2026-02-02T00:00:00Z'), + delivered_at: null, + }) + + expect(notification).toMatchObject({ + id: 9, + userId: 42, + title: 'T', + message: 'M', + type: 'success', + eventType: 'escrow_released', + payload: { foo: 'bar' }, + isRead: false, + channel: 'in_app', + }) + expect(notification.createdAt).toMatch(/2026-02-02T/) + expect(notification.deliveredAt).toBeNull() + }) +}) + +describe('createNotification', () => { + it('persists and publishes to the hub', async () => { + queueSql([buildNotificationRow({ id: 101 })]) + + const hub = NotificationHub.getInstance() + const received: Notification[] = [] + hub.subscribe(42, (n) => received.push(n)) + + const created = await createNotification({ + userId: 42, + title: 'A', + message: 'B', + type: 'success', + eventType: 'milestone_approved', + payload: { milestoneId: 1 }, + }) + + expect(created.id).toBe(101) + expect(received).toHaveLength(1) + expect(received[0].id).toBe(101) + }) + + it('still returns the persisted notification even if hub publish throws', async () => { + queueSql([buildNotificationRow({ id: 102 })]) + + const hub = NotificationHub.getInstance() + hub.subscribe(42, () => { + throw new Error('broken subscriber') + }) + + const created = await createNotification({ + userId: 42, + title: 'A', + message: 'B', + type: 'success', + eventType: 'milestone_approved', + payload: {}, + }) + + expect(created.id).toBe(102) + }) + + it('throws NotificationError when the INSERT returns no rows', async () => { + queueSql([[]]) + await expect( + createNotification({ + userId: 1, + title: 'A', + message: 'B', + type: 'info', + eventType: 'contract_created', + }), + ).rejects.toBeInstanceOf(NotificationError) + }) +}) + +describe('listNotificationsForUser', () => { + it('returns empty result for an out-of-range page', async () => { + queueSql([[]]) // main list empty (no COUNT fallback needed since 0 rows handled inline) + const result = await listNotificationsForUser(42, { + page: 99, + limit: 10, + type: null, + unreadOnly: false, + }) + expect(result.totalItems).toBe(0) + expect(result.notifications).toEqual([]) + }) + + it('returns paginated rows with total count via COUNT(*) OVER()', async () => { + queueSql([buildNotificationRow({ id: 1, total_count: '5' })]) + const result = await listNotificationsForUser(42, { + page: 1, + limit: 1, + type: null, + unreadOnly: false, + }) + expect(result.totalItems).toBe(5) + expect(result.notifications).toHaveLength(1) + expect(result.notifications[0].id).toBe(1) + }) + + it('rejects an invalid user id', async () => { + await expect( + listNotificationsForUser(0, { + page: 1, + limit: 10, + type: null, + unreadOnly: false, + }), + ).rejects.toBeInstanceOf(NotificationError) + }) +}) + +describe('markNotificationRead', () => { + it('returns null when the notification does not belong to the caller', async () => { + queueSql([[]]) + const result = await markNotificationRead(99, 42) + expect(result.notification).toBeNull() + }) + + it('returns the mapped notification when marked', async () => { + queueSql([buildNotificationRow({ id: 1, is_read: true })]) + const result = await markNotificationRead(1, 42) + expect(result.notification?.isRead).toBe(true) + }) +}) + +describe('markAllNotificationsRead', () => { + it('returns the updated row count', async () => { + // markAllNotificationsRead now issues exactly ONE sql call (CTE). + queueSql([[{ updated_count: 7 }]]) + const result = await markAllNotificationsRead(42) + expect(result.updatedCount).toBe(7) + }) +}) + +describe('NotificationHub', () => { + it('subscriber count drops to zero after unsubscribe', () => { + const hub = NotificationHub.getInstance() + expect(hub.subscriberCount(42)).toBe(0) + const unsub = hub.subscribe(42, () => undefined) + expect(hub.subscriberCount(42)).toBe(1) + unsub() + expect(hub.subscriberCount(42)).toBe(0) + }) + + it('isolated fan-out: only the targeted user receives the notification', () => { + const hub = NotificationHub.getInstance() + const seen = { a: 0, b: 0 } + hub.subscribe(1, () => { + seen.a += 1 + }) + hub.subscribe(2, () => { + seen.b += 1 + }) + + hub.publish({ + id: 1, + userId: 1, + title: 'x', + message: 'x', + type: 'info', + eventType: 'contract_created', + payload: {}, + isRead: false, + channel: 'in_app', + createdAt: '2026-01-01T00:00:00Z', + deliveredAt: null, + }) + + expect(seen).toEqual({ a: 1, b: 0 }) + }) +}) + +describe('GET /api/notifications', () => { + it('returns the list with meta', async () => { + // resolveUserIdByWallet is mocked, so no sql for the user lookup; + // the route issues 2 sql calls (listNotifications + unreadCount). + queueSql([ + buildNotificationRow({ id: 1, total_count: '1' }), // list (with total_count) + [{ unread: 1 }], // unread count + ]) + + const response = await listNotifications( + makeRequest('http://localhost/api/notifications?limit=5'), + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toHaveLength(1) + expect(body.meta).toMatchObject({ + totalCount: 1, + limit: 5, + page: 1, + unreadCount: 1, + }) + }) + + it('400 with structured code on invalid type', async () => { + const response = await listNotifications( + makeRequest('http://localhost/api/notifications?type=banana'), + ) + expect(response.status).toBe(400) + const body = await response.json() + expect(body.code).toBe('INVALID_TYPE') + }) + + it('503 on DB failure', async () => { + queueSqlFail(new Error('connection reset')) + const response = await listNotifications( + makeRequest('http://localhost/api/notifications'), + ) + expect(response.status).toBe(503) + const body = await response.json() + expect(body.code).toBe('NOTIFICATIONS_LIST_FAILED') + }) +}) + +describe('PATCH /api/notifications/[id]/read', () => { + it('marks the notification read for the caller', async () => { + // resolveUserIdByWallet is mocked; the route issue 1 sql call. + queueSql([buildNotificationRow({ id: 17, is_read: true })]) + const response = await markRead(makeRequest('http://localhost/x'), { + params: Promise.resolve({ id: '17' }), + }) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.notification.isRead).toBe(true) + }) + + it('returns 404 when no row was updated', async () => { + queueSql([[]]) + const response = await markRead(makeRequest('http://localhost/x'), { + params: Promise.resolve({ id: '17' }), + }) + expect(response.status).toBe(404) + expect((await response.json()).code).toBe('NOT_FOUND') + }) + + it('returns 400 for a non-numeric id', async () => { + const response = await markRead(makeRequest('http://localhost/x'), { + params: Promise.resolve({ id: 'banana' }), + }) + expect(response.status).toBe(400) + expect((await response.json()).code).toBe('INVALID_ID') + }) + + it('returns 404 when the wallet does not map to a user', async () => { + // Switch the mocked resolveUserIdByWallet to return null for this test. + const { resolveUserIdByWallet } = await import('@/lib/auth/middleware') + ;(resolveUserIdByWallet as ReturnType).mockResolvedValueOnce( + null, + ) + + const response = await markRead(makeRequest('http://localhost/x'), { + params: Promise.resolve({ id: '17' }), + }) + expect(response.status).toBe(404) + expect((await response.json()).code).toBe('USER_NOT_FOUND') + }) +}) + +describe('POST /api/notifications/read-all', () => { + it('returns the updated count', async () => { + // resolveUserIdByWallet is mocked, so no sql call for the user + // lookup; the route then issues a single CTE sql call. + queueSql([[{ updated_count: 4 }]]) + const response = await markAllRead(makeRequest('http://localhost/x')) + expect(response.status).toBe(200) + expect((await response.json()).updatedCount).toBe(4) + }) +}) + +describe('GET /api/notifications/stream', () => { + it('returns a text/event-stream response for the authenticated user', async () => { + // resolveUserIdByWallet is mocked to return 42; no sql call needed. + const response = await streamNotifications( + makeRequest('http://localhost/api/notifications/stream'), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toMatch(/text\/event-stream/) + expect(NotificationHub.getInstance().subscriberCount(42)).toBe(1) + }) + + it('drops subscribers on stream cancel', async () => { + const response = await streamNotifications( + makeRequest('http://localhost/api/notifications/stream'), + ) + expect(response.status).toBe(200) + expect(NotificationHub.getInstance().subscriberCount(42)).toBe(1) + + await response.body?.cancel() + // give the cancel() callback a tick to run + await Promise.resolve() + expect(NotificationHub.getInstance().subscriberCount(42)).toBe(0) + }) +}) + +describe('event-creation helpers', () => { + it('notifyContractCreated emits one notification per recipient', async () => { + queueSql([ + buildNotificationRow({ id: 1, user_id: 11, event_type: 'contract_created' }), + buildNotificationRow({ id: 2, user_id: 22, event_type: 'contract_created' }), + ]) + await notifyContractCreated(11, 22, 99, 'Logo redesign') + expect(NOTIFICATION_EVENT_TYPES).toContain('contract_created') + }) + + it('notifyMilestoneSubmitted creates exactly one notification', async () => { + queueSql([buildNotificationRow({ event_type: 'milestone_submitted' })]) + await notifyMilestoneSubmitted(11, 5, 'Build login') + }) + + it('notifyMilestoneApproved creates exactly one notification', async () => { + queueSql([buildNotificationRow({ event_type: 'milestone_approved' })]) + await notifyMilestoneApproved(11, 5, 'Build login') + }) + + it('notifyEscrowReleased skips the freelancer when null', async () => { + queueSql([buildNotificationRow({ event_type: 'escrow_released' })]) + await notifyEscrowReleased(11, null, 7, '5.00', 'XLM') + }) + + it('notifyEscrowReleased includes the freelancer when provided', async () => { + queueSql([ + buildNotificationRow({ id: 1, user_id: 11, event_type: 'escrow_released' }), + buildNotificationRow({ id: 2, user_id: 22, event_type: 'escrow_released' }), + ]) + await notifyEscrowReleased(11, 22, 7, '5.00', 'XLM') + }) + + it('notifyDisputeCreated creates exactly one notification', async () => { + queueSql([buildNotificationRow({ event_type: 'dispute_created' })]) + await notifyDisputeCreated(11, 3, 7) + }) +}) diff --git a/app/api/freelancers/[id]/route.ts b/app/api/freelancers/[id]/route.ts new file mode 100644 index 0000000..93418be --- /dev/null +++ b/app/api/freelancers/[id]/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { getFreelancerById } from '@/lib/freelancerDiscovery' +import { getFreelancerReputation, userExists } from '@/lib/reputation' + +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ id: string }> } + +/** + * GET /api/freelancers/[id] + * + * Returns a detailed freelancer profile by numeric id. + * The response includes a nested `reputation` payload when one can be loaded, + * otherwise `reputation` is null so clients can still render the profile. + * + * Responses: + * 200 { freelancer, reputation } + * 400 INVALID_ID (non-numeric or non-positive id) + * 404 FREELANCER_NOT_FOUND + * 503 FREELANCER_FETCH_FAILED (raised when the freelancer query fails) + */ +export async function GET(_request: NextRequest, context: RouteContext) { + const { id: rawId } = await context.params + const id = Number.parseInt(rawId, 10) + + if (!Number.isFinite(id) || id <= 0) { + return NextResponse.json( + { error: 'Invalid freelancer id', code: 'INVALID_ID' }, + { status: 400 }, + ) + } + + try { + const freelancer = await getFreelancerById(id) + if (!freelancer) { + return NextResponse.json( + { error: 'Freelancer not found', code: 'FREELANCER_NOT_FOUND' }, + { status: 404 }, + ) + } + + let reputation: unknown = null + try { + const exists = await userExists(id) + if (exists) { + reputation = await getFreelancerReputation(id) + } + } catch (error) { + console.error(`Failed to load reputation for freelancer ${id}:`, error) + reputation = null + } + + return NextResponse.json( + { freelancer, reputation }, + { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }, + }, + ) + } catch (error) { + console.error(`Failed to load freelancer ${id}:`, error) + return NextResponse.json( + { error: 'Unable to load freelancer', code: 'FREELANCER_FETCH_FAILED' }, + { status: 503 }, + ) + } +} diff --git a/app/api/freelancers/route.ts b/app/api/freelancers/route.ts index 0c3c58c..a27b0a4 100644 --- a/app/api/freelancers/route.ts +++ b/app/api/freelancers/route.ts @@ -1,62 +1,51 @@ import { NextRequest, NextResponse } from 'next/server' -import { FREELANCERS, FREELANCER_SKILLS } from '@/lib/freelancers' -export const dynamic = 'force-dynamic' - -const PAGE_SIZE = 6 - -function parseRating(value: string | null): number | null { - if (!value) return null - const rating = Number.parseInt(value, 10) - return Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null -} +import { + buildListResponse, + parseDiscoveryParams, + FreelancerDiscoveryError, +} from '@/lib/freelancerDiscovery' -function parsePage(value: string | null): number { - if (!value) return 1 - const page = Number.parseInt(value, 10) - return Number.isInteger(page) && page > 0 ? page : 1 -} +export const dynamic = 'force-dynamic' +/** + * GET /api/freelancers + * + * Lists freelancers for clients to discover and filter. + * + * Query parameters + * q Free-text search across username, bio, and skills + * skills Repeating or comma-separated list (every skill must match) + * minRating|rating Minimum rating (1..5). `rating` is accepted for back-compat + * page 1-based page number (default 1) + * limit Page size (1..50, default 6) + * sort One of: rating, total_jobs_completed, created_at, username (default rating) + * order asc | desc (default desc) + * + * Response: { freelancers, skills, pagination } + * - `pagination` carries page, pageSize, totalItems, totalPages so the UI can + * render accurate counts even when callers request a page past the end. + */ export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url) - const query = searchParams.get('q')?.trim().toLowerCase() ?? '' - const selectedSkills = searchParams - .getAll('skills') - .flatMap((value) => value.split(',')) - .map((skill) => skill.trim()) - .filter(Boolean) - const minimumRating = parseRating(searchParams.get('rating')) - const page = parsePage(searchParams.get('page')) - - const filtered = FREELANCERS.filter((freelancer) => { - const matchesQuery = - !query || - [freelancer.name, freelancer.title, freelancer.bio, ...freelancer.skills] - .join(' ') - .toLowerCase() - .includes(query) - - const matchesSkills = - selectedSkills.length === 0 || - selectedSkills.every((skill) => freelancer.skills.includes(skill)) - - const matchesRating = !minimumRating || freelancer.rating >= minimumRating - - return matchesQuery && matchesSkills && matchesRating - }) - - const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) - const currentPage = Math.min(page, totalPages) - const start = (currentPage - 1) * PAGE_SIZE - - return NextResponse.json({ - freelancers: filtered.slice(start, start + PAGE_SIZE), - skills: FREELANCER_SKILLS, - pagination: { - page: currentPage, - pageSize: PAGE_SIZE, - totalItems: filtered.length, - totalPages, - }, - }) + try { + const params = parseDiscoveryParams(request.nextUrl.searchParams) + const payload = await buildListResponse(params) + return NextResponse.json(payload, { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }, + }) + } catch (error) { + if (error instanceof FreelancerDiscoveryError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 }, + ) + } + console.error('Failed to list freelancers:', error) + return NextResponse.json( + { error: 'Unable to load freelancers', code: 'FREELANCER_LIST_FAILED' }, + { status: 503 }, + ) + } } diff --git a/app/api/notifications/[id]/read/route.ts b/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..d311360 --- /dev/null +++ b/app/api/notifications/[id]/read/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + NotificationError, + markNotificationRead, +} from '@/lib/notifications' +import { sql } from '@/lib/db' +import { withAuthCtx, AuthContext, resolveUserIdByWallet as resolveUserId } from '@/lib/auth/middleware' + +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ id: string }> } + +/** + * PATCH /api/notifications/[id]/read + * + * Marks a single notification as read for the authenticated user. The + * notification must belong to the caller — otherwise we return 404 to avoid + * leaking whether the id exists for someone else. + * + * Status codes: + * 200 ok {notification} + * 400 invalid id or NotificationError → 400 + * 401 auth required + * 404 not found / not the caller's notification + * 503 db failure + */ +export const PATCH = withAuthCtx( + async (request: NextRequest, auth: AuthContext, context: RouteContext) => { + void request + const { id: rawId } = await context.params + const notificationId = Number.parseInt(rawId, 10) + + if (!Number.isFinite(notificationId) || notificationId <= 0) { + return NextResponse.json( + { error: 'Invalid notification id', code: 'INVALID_ID' }, + { status: 400 }, + ) + } + + try { + const userId = await resolveUserId(auth.walletAddress) + if (userId === null) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 }, + ) + } + + const result = await markNotificationRead(notificationId, userId) + if (result.notification === null) { + return NextResponse.json( + { error: 'Notification not found', code: 'NOT_FOUND' }, + { status: 404 }, + ) + } + + return NextResponse.json( + { notification: result.notification }, + { + headers: { + 'Cache-Control': 'no-store', + }, + }, + ) + } catch (error) { + if (error instanceof NotificationError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 }, + ) + } + console.error( + `Failed to mark notification ${notificationId} read:`, + error, + ) + return NextResponse.json( + { error: 'Unable to mark notification read', code: 'NOTIFICATION_UPDATE_FAILED' }, + { status: 503 }, + ) + } + }, +) + + diff --git a/app/api/notifications/read-all/route.ts b/app/api/notifications/read-all/route.ts new file mode 100644 index 0000000..646081a --- /dev/null +++ b/app/api/notifications/read-all/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + NotificationError, + markAllNotificationsRead, +} from '@/lib/notifications' +import { + withAuth, + AuthContext, + resolveUserIdByWallet as resolveUserId, +} from '@/lib/auth/middleware' + +export const dynamic = 'force-dynamic' + +/** + * POST /api/notifications/read-all + * + * Marks every unread notification for the authenticated user as read. + * Returns the number of rows updated (returns 0 if there were none). + * + * Status codes: + * 200 ok {updatedCount} + * 400 NotificationError + * 401 auth required + * 503 db failure + */ +export const POST = withAuth(async (request: NextRequest, auth: AuthContext) => { + void request + try { + const userId = await resolveUserId(auth.walletAddress) + if (userId === null) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 }, + ) + } + + const result = await markAllNotificationsRead(userId) + return NextResponse.json({ updatedCount: result.updatedCount }) + } catch (error) { + if (error instanceof NotificationError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 }, + ) + } + console.error('Failed to mark all notifications read:', error) + return NextResponse.json( + { error: 'Unable to mark all notifications read', code: 'NOTIFICATION_UPDATE_FAILED' }, + { status: 503 }, + ) + } +}) diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..2603b82 --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + NotificationError, + listNotificationsForUser, + parseNotificationQuery, +} from '@/lib/notifications' +import { sql } from '@/lib/db' +import { + withAuth, + AuthContext, + resolveUserIdByWallet, +} from '@/lib/auth/middleware' + +export const dynamic = 'force-dynamic' + +/** + * GET /api/notifications + * + * List notifications for the authenticated user. Query parameters: + * page 1-based page number (default 1) + * limit Page size, 1..100 (default 20) + * type Optional filter on event_type (one of the canonical + * NOTIFICATION_EVENT_TYPES values) + * unreadOnly true|false|1|0 (default false) + * + * Response shape: + * { + * data: Notification[], + * meta: { + * totalCount: number, + * page: number, + * limit: number, + * totalPages: number, + * unreadCount: number // bonus: total unread for badge UI + * } + * } + * + * Status codes: + * 200 ok + * 400 NotificationError mapped to {error, code} + * 401 missing/invalid auth token + * 503 unexpected DB failure + */ +export const GET = withAuth(async (request: NextRequest, auth: AuthContext) => { + try { + const userId = await resolveUserIdByWallet(auth.walletAddress) + if (userId === null) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 }, + ) + } + + const params = parseNotificationQuery(request.nextUrl.searchParams) + const result = await listNotificationsForUser(userId, params) + + // Bonus metadata: total unread count for the badge UI. Cheap because + // idx_notifications_user_unread already covers (user_id, is_read, + // created_at DESC). + const unreadRows = (await sql` + SELECT COUNT(*)::int AS unread + FROM notifications + WHERE user_id = ${userId} AND is_read = FALSE + `) as Array<{ unread: number }> + const unreadCount = Number(unreadRows[0]?.unread ?? 0) + + return NextResponse.json({ + data: result.notifications, + meta: { + totalCount: result.totalItems, + page: params.page, + limit: params.limit, + totalPages: + result.totalItems === 0 + ? 0 + : Math.max(1, Math.ceil(result.totalItems / params.limit)), + unreadCount, + }, + }) + } catch (error) { + if (error instanceof NotificationError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 }, + ) + } + console.error('Failed to list notifications:', error) + return NextResponse.json( + { error: 'Unable to load notifications', code: 'NOTIFICATIONS_LIST_FAILED' }, + { status: 503 }, + ) + } +}) diff --git a/app/api/notifications/stream/route.ts b/app/api/notifications/stream/route.ts new file mode 100644 index 0000000..3741964 --- /dev/null +++ b/app/api/notifications/stream/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from 'next/server' +import { randomUUID } from 'node:crypto' + +import { + NotificationError, + NotificationHub, + type Notification, +} from '@/lib/notifications' +import { + withAuth, + AuthContext, + resolveUserIdByWallet as resolveUserId, +} from '@/lib/auth/middleware' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const KEEP_ALIVE_MS = 25_000 + +/** + * GET /api/notifications/stream + * + * Server-Sent Events stream that pushes notifications for the authenticated + * user in real time. The connection also emits a keep-alive comment every + * 25 s so reverse proxies don't close idle sockets. + * + * Event format: + * id: + * event: notification + * data: { "id": , "userId": , "title": "...", ... } + * + * Query parameters: + * lastEventId Optional. Reserved for future replay support — when the + * client reconnects with the last id it saw, we can replay + * buffered events. The in-process hub currently has no + * persistent buffer, so we deliberately do NOT parse it + * yet (no replay is performed until the buffer ships). + * + * Status codes: + * 200 stream open + * 401 missing/invalid auth + * 500 if the runtime cannot construct a TextEncoder/Stream + */ +export const GET = withAuth(async (request: NextRequest, auth: AuthContext) => { + try { + const userId = await resolveUserId(auth.walletAddress) + if (userId === null) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 }, + ) + } + + void request // reserved for future lastEventId replay parameter + + const encoder = new TextEncoder() + const hub = NotificationHub.getInstance() + + // Per-stream state shared by the `start` and `cancel` paths. Using an + // interface + arrow-function assignment (rather than an object-literal + // method) keeps `this` unambiguous and resilient against future + // refactors that pass `state.release` around. + interface StreamState { + unsub: null | (() => void) + keepAlive: null | ReturnType + closed: boolean + release: () => void + } + const state: StreamState = { + unsub: null, + keepAlive: null, + closed: false, + release: () => undefined, + } + state.release = () => { + if (state.closed) return + state.closed = true + if (state.unsub) state.unsub() + if (state.keepAlive) clearInterval(state.keepAlive) + } + + const stream = new ReadableStream({ + start(controller) { + const send = (eventName: string, data: unknown, id?: number): void => { + try { + const lines: string[] = [] + if (id !== undefined) lines.push(`id: ${id}`) + lines.push(`event: ${eventName}`) + lines.push(`data: ${JSON.stringify(data)}`) + lines.push('', '') + controller.enqueue(encoder.encode(lines.join('\n'))) + } catch (error) { + console.error('[notifications] SSE enqueue failed:', error) + } + } + + send('hello', { ts: new Date().toISOString(), tag: randomUUID() }) + + state.unsub = hub.subscribe(userId, (notification: Notification) => { + send('notification', notification, notification.id) + }) + + state.keepAlive = setInterval(() => { + try { + controller.enqueue( + encoder.encode(`: keep-alive ${Date.now()}\n\n`), + ) + } catch { + // Controller is already closed; the release path will fire. + } + }, KEEP_ALIVE_MS) + + // Underlying socket closed. + request.signal.addEventListener('abort', () => { + state.release() + try { + controller.close() + } catch { + // already closed + } + }) + }, + cancel() { + // Consumer cancelled the body of the response (e.g. EventSource + // closed). Same release path: unsubscribe from the hub and clear + // the keep-alive interval. + state.release() + }, + }) + + return new NextResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) + } catch (error) { + if (error instanceof NotificationError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 }, + ) + } + console.error('Failed to open notification stream:', error) + return NextResponse.json( + { error: 'Unable to open stream', code: 'STREAM_FAILED' }, + { status: 500 }, + ) + } +}) diff --git a/lib/auth/middleware.ts b/lib/auth/middleware.ts index 3dbcaff..b8e6cfa 100644 --- a/lib/auth/middleware.ts +++ b/lib/auth/middleware.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { readAccessToken, verifyAccessToken } from '@/lib/auth/session' +import { sql } from '@/lib/db' export interface AuthContext { walletAddress: string @@ -11,6 +12,12 @@ type AuthenticatedHandler = ( auth: AuthContext ) => Promise | NextResponse +type AuthenticatedHandlerWithCtx = ( + request: NextRequest, + auth: AuthContext, + context: Ctx +) => Promise | NextResponse + function unauthorizedResponse(): NextResponse { return NextResponse.json( { error: 'Unauthorized', code: 'AUTH_REQUIRED' }, @@ -36,3 +43,45 @@ export function withAuth(handler: AuthenticatedHandler) { }) } } + +export function withAuthCtx( + handler: AuthenticatedHandlerWithCtx, +) { + return async ( + request: NextRequest, + context: Ctx, + ): Promise => { + const token = readAccessToken(request) + if (!token) { + return unauthorizedResponse() + } + + const payload = verifyAccessToken(token) + if (!payload) { + return unauthorizedResponse() + } + + return handler(request, { + walletAddress: payload.walletAddress, + tokenJti: payload.jti, + }, context) + } +} + +/** + * Resolves the integer user.id for the given wallet_address. Returns null if + * the wallet does not map to a row in `users`. Shared between the + * notification routes and any other call site that needs the + * authenticated user's database id (the JWT only carries the wallet + * address). + */ +export async function resolveUserIdByWallet( + walletAddress: string, +): Promise { + const rows = (await sql` + SELECT id FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + `) as Array<{ id: number }> + if (rows.length === 0) return null + const raw = rows[0].id + return typeof raw === 'number' ? raw : Number(raw) || null +} diff --git a/lib/freelancerDiscovery.ts b/lib/freelancerDiscovery.ts new file mode 100644 index 0000000..f1cacbb --- /dev/null +++ b/lib/freelancerDiscovery.ts @@ -0,0 +1,485 @@ +/** + * Freelancer Discovery API helper. + * + * Encapsulates the SQL query logic, pagination math, sort/filter validation, + * and DB row → API response mapping used by: + * - GET /api/freelancers (list with search, filter, sort, paginate) + * - GET /api/freelancers/[id] (detail by id) + * + * The data source is the `users` table (filtered to freelancer-or-both roles). + * Performance-critical search ranking on the `skills` column is supported by + * a GIN index added in `scripts/010-freelancer-discovery-indexes.sql`. + */ + +import { sql } from '@/lib/db' + +/** Fields that can be used as sort keys. Whitelisted to prevent SQL injection. */ +export const FREELANCER_SORTABLE_FIELDS = [ + 'rating', + 'total_jobs_completed', + 'created_at', + 'username', +] as const + +export type FreelancerSortField = (typeof FREELANCER_SORTABLE_FIELDS)[number] + +export const FREELANCER_SORT_ORDERS = ['asc', 'desc'] as const +export type FreelancerSortOrder = (typeof FREELANCER_SORT_ORDERS)[number] + +/** Allowed range for the minimum rating filter. */ +export const FREELANCER_MIN_RATING = 1 +export const FREELANCER_MAX_RATING = 5 + +/** Pagination bounds. `limit` is clamped to keep responses reasonable. */ +export const FREELANCER_DEFAULT_LIMIT = 6 +export const FREELANCER_MAX_LIMIT = 50 +export const FREELANCER_DEFAULT_PAGE = 1 + +export interface FreelancerListing { + id: number + /** Display name. Matches the `name` field consumed by `app/freelancers/page.tsx`. */ + name: string + title: string + bio: string + skills: string[] + rating: number + profileImage: string + completedProjects: number + /** Default 0 — the `users` table does not store a rate yet. */ + hourlyRate: number + /** Default '' — the `users` table does not store a location yet. */ + location: string + walletAddress: string + userType: string + createdAt: string +} + +export interface ListFreelancersParams { + /** Free-text query that matches username, bio, or any skill (case-insensitive). */ + query: string + /** Required skills (a freelancer must have *all* of them). Empty = no filter. */ + skills: string[] + /** Minimum rating in the inclusive range [1, 5]. Null = no filter. */ + minRating: number | null + /** 1-based page number. */ + page: number + /** Items per page (1..FREELANCER_MAX_LIMIT). */ + limit: number + /** Sort column. */ + sort: FreelancerSortField + /** Sort direction. */ + order: FreelancerSortOrder +} + +export interface ListFreelancersResult { + freelancers: FreelancerListing[] + totalItems: number +} + +export interface FreelancerListResponse { + freelancers: FreelancerListing[] + skills: string[] + pagination: { + page: number + pageSize: number + totalItems: number + totalPages: number + } +} + +export class FreelancerDiscoveryError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'FreelancerDiscoveryError' + } +} + +interface UserRow { + id: number + wallet_address: string + username: string + email: string | null + user_type: string + bio: string | null + skills: string[] | null + rating: number | string | null + total_jobs_completed: number | null + created_at: Date | string +} + +interface UserRowWithCount extends UserRow { + total_count: string | number +} + +interface SkillRow { + skill: string | null +} + +const DEFAULT_PROFILE_IMAGE = '/placeholder-user.jpg' +const DEFAULT_TITLE = 'TaskChain Freelancer' + +/** + * Convert a DB row to the API listing shape. Fields the DB doesn't store + * (hourlyRate, location, profileImage, title) get safe placeholder values so + * the response is always well-formed. + */ +export function mapUserRowToListing(row: UserRow): FreelancerListing { + const skills = Array.isArray(row.skills) ? row.skills : [] + const numericRating = + typeof row.rating === 'number' + ? row.rating + : row.rating === null || row.rating === undefined + ? 0 + : Number(row.rating) + + const rating = + Number.isFinite(numericRating) && + numericRating >= 0 && + numericRating <= FREELANCER_MAX_RATING + ? numericRating + : 0 + + const createdAt = + row.created_at instanceof Date + ? row.created_at.toISOString() + : row.created_at + + return { + id: row.id, + name: row.username, + title: DEFAULT_TITLE, + bio: row.bio ?? '', + skills, + rating, + profileImage: DEFAULT_PROFILE_IMAGE, + completedProjects: row.total_jobs_completed ?? 0, + hourlyRate: 0, + location: '', + walletAddress: row.wallet_address, + userType: row.user_type, + createdAt, + } +} + +/** + * Normalize a query string so it is safe to embed inside a Postgres + * ILIKE pattern. We escape the only meta-characters `\` and `%` (and `_`) + * which would otherwise let a caller alter the WHERE clause. + */ +function escapeIlike(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') +} + +/** + * Build the WHERE fragment in a SINGLE sql\`\`` call so the test layer + * (and Postgres query planner) sees a predictable, stable number of + * round-trips per request. Optional filters are activated via the standard + * PostgreSQL `NULL/0 = 0 OR ` pattern, which lets us + * parameterise every value (no string interpolation, no concatenation) while + * keeping the conditional semantics. Each filter that is "disabled" simply + * resolves to TRUE at the row level and Postgres' planner will short-circuit. + * + * Why not compose via `sql\`${a} AND ${b}\``? Every nested template literal + * is a separate `sql\`\`` invocation that the neon client must track. With + * a single-template approach below we issue exactly one DB round-trip per + * query, and mocking/test setup matches reality. + */ +function buildWhereFragment( + params: ListFreelancersParams, +): ReturnType { + const normalizedQuery = params.query.trim() + const needle = normalizedQuery + ? `%${escapeIlike(normalizedQuery.toLowerCase())}%` + : null + const skillArray = params.skills.length > 0 ? params.skills : [] + const ratingFloor = params.minRating ?? 0 + + return sql` + u.user_type IN ('freelancer', 'both') + AND ( + ${needle}::text IS NULL + OR LOWER(u.username) LIKE ${needle} ESCAPE '\\' + OR LOWER(COALESCE(u.bio, '')) LIKE ${needle} ESCAPE '\\' + OR EXISTS ( + SELECT 1 + FROM unnest(COALESCE(u.skills, ARRAY[]::text[])) AS s + WHERE LOWER(s) LIKE ${needle} ESCAPE '\\' + ) + ) + AND ( + cardinality(${skillArray}::text[]) = 0 + OR u.skills @> ${skillArray}::text[] + ) + AND ( + ${ratingFloor}::int = 0 + OR COALESCE(u.rating, 0) >= ${ratingFloor} + ) + ` +} + +/** + * Build a fully-static ORDER BY fragment for the (sort, order) pair. Because + * `sort` and `order` are pre-validated against a whitelist in `parseSort` + * and `parseOrder`, we never have to interpolate caller-controlled strings + * into the SQL identifier position. The switch below is exhaustive on + * `FreelancerSortField` × `FreelancerSortOrder`; both literal `ASC` / `DESC` + * and the column identifier are inlined in each branch. + */ +function buildOrderBy( + sort: FreelancerSortField, + order: FreelancerSortOrder, +): ReturnType { + switch (sort) { + case 'rating': + return order === 'asc' + ? sql`u.rating ASC NULLS LAST, u.id ASC` + : sql`u.rating DESC NULLS LAST, u.id ASC` + case 'total_jobs_completed': + return order === 'asc' + ? sql`u.total_jobs_completed ASC NULLS LAST, u.id ASC` + : sql`u.total_jobs_completed DESC NULLS LAST, u.id ASC` + case 'created_at': + return order === 'asc' + ? sql`u.created_at ASC NULLS LAST, u.id ASC` + : sql`u.created_at DESC NULLS LAST, u.id ASC` + case 'username': + return order === 'asc' + ? sql`u.username ASC, u.id ASC` + : sql`u.username DESC, u.id ASC` + } +} + +/** + * Lists freelancers using a single round-trip per page (a combined data + + * COUNT(*) OVER() query), so we get rows + total count in one shot. When the + * main query returns zero rows (e.g. requesting a page past the end) we fall + * back to a dedicated COUNT query so the pagination metadata stays accurate. + */ +export async function listFreelancers( + params: ListFreelancersParams, +): Promise { + const where = buildWhereFragment(params) + const orderBy = buildOrderBy(params.sort, params.order) + const offset = (params.page - 1) * params.limit + + const rows = (await sql` + SELECT + u.id, + u.wallet_address, + u.username, + u.email, + u.user_type, + u.bio, + u.skills, + u.rating, + u.total_jobs_completed, + u.created_at, + COUNT(*) OVER() AS total_count + FROM users u + WHERE ${where} + ORDER BY ${orderBy} + LIMIT ${params.limit} + OFFSET ${offset} + `) as UserRowWithCount[] + + let totalItems: number + if (rows.length > 0) { + const raw = rows[0].total_count + totalItems = + typeof raw === 'number' ? raw : parseInt(String(raw), 10) || 0 + } else { + totalItems = await countFreelancers(params) + } + + const freelancers: FreelancerListing[] = rows.map((row) => { + const { total_count: _ignored, ...rest } = row as UserRowWithCount + return mapUserRowToListing(rest as UserRow) + }) + + return { freelancers, totalItems } +} + +async function countFreelancers(params: ListFreelancersParams): Promise { + const where = buildWhereFragment(params) + const rows = (await sql` + SELECT COUNT(*) AS count FROM users u WHERE ${where} + `) as Array<{ count: string | number }> + const value = rows[0]?.count ?? 0 + return typeof value === 'number' ? value : parseInt(String(value), 10) || 0 +} + +/** Returns the distinct skills currently held by any freelancer-or-both user. */ +export async function getAvailableSkills(): Promise { + const rows = (await sql` + SELECT DISTINCT skill + FROM users u, unnest(COALESCE(u.skills, ARRAY[]::text[])) AS skill + WHERE u.user_type IN ('freelancer', 'both') + ORDER BY skill ASC + `) as SkillRow[] + return rows + .map((row) => row.skill) + .filter((s): s is string => typeof s === 'string' && s.length > 0) +} + +/** Fetches a single freelancer (by id) for the detail endpoint. */ +export async function getFreelancerById( + id: number, +): Promise { + if (!Number.isInteger(id) || id <= 0) return null + const rows = (await sql` + SELECT + u.id, + u.wallet_address, + u.username, + u.email, + u.user_type, + u.bio, + u.skills, + u.rating, + u.total_jobs_completed, + u.created_at + FROM users u + WHERE u.id = ${id} AND u.user_type IN ('freelancer', 'both') + LIMIT 1 + `) as UserRow[] + const row = rows[0] + return row ? mapUserRowToListing(row) : null +} + +// ---------- Query parameter parsing & validation --------------------------- + +function parseInteger(value: string | null, fallback: number): number { + if (value === null) return fallback + const parsed = Number.parseInt(value, 10) + return Number.isInteger(parsed) ? parsed : fallback +} + +function parseOptionalRating(value: string | null): number | null { + if (value === null || value === '') return null + const parsed = Number.parseInt(value, 10) + if ( + Number.isInteger(parsed) && + parsed >= FREELANCER_MIN_RATING && + parsed <= FREELANCER_MAX_RATING + ) { + return parsed + } + throw new FreelancerDiscoveryError( + 'INVALID_MIN_RATING', + `minRating must be an integer between ${FREELANCER_MIN_RATING} and ${FREELANCER_MAX_RATING}`, + ) +} + +function parseSort(value: string | null): FreelancerSortField { + const candidate = value ?? 'rating' + if ((FREELANCER_SORTABLE_FIELDS as readonly string[]).includes(candidate)) { + return candidate as FreelancerSortField + } + throw new FreelancerDiscoveryError( + 'INVALID_SORT_FIELD', + `sort must be one of: ${FREELANCER_SORTABLE_FIELDS.join(', ')}`, + ) +} + +function parseOrder(value: string | null): FreelancerSortOrder { + const candidate = (value ?? 'desc').toLowerCase() + if ((FREELANCER_SORT_ORDERS as readonly string[]).includes(candidate)) { + return candidate as FreelancerSortOrder + } + throw new FreelancerDiscoveryError( + 'INVALID_SORT_ORDER', + `order must be one of: ${FREELANCER_SORT_ORDERS.join(', ')}`, + ) +} + +function parseLimit(value: string | null): number { + const parsed = parseInteger(value, FREELANCER_DEFAULT_LIMIT) + if (parsed < 1) { + throw new FreelancerDiscoveryError( + 'INVALID_LIMIT', + 'limit must be greater than or equal to 1', + ) + } + return Math.min(parsed, FREELANCER_MAX_LIMIT) +} + +function parsePage(value: string | null): number { + const parsed = parseInteger(value, FREELANCER_DEFAULT_PAGE) + if (parsed < 1) { + throw new FreelancerDiscoveryError( + 'INVALID_PAGE', + 'page must be greater than or equal to 1', + ) + } + return parsed +} + +function parseSkills(raw: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const chunk of raw) { + for (const skill of chunk.split(',')) { + const trimmed = skill.trim() + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + } + } + return out +} + +/** + * Parses and validates the search-parameters from the request URL. + * Accepts legacy `?rating=` as a synonym for `?minRating=` so the existing + * `/freelancers` frontend keeps working. + */ +export function parseDiscoveryParams( + searchParams: URLSearchParams, +): ListFreelancersParams { + const query = (searchParams.get('q') ?? searchParams.get('query') ?? '').trim() + const skills = parseSkills(searchParams.getAll('skills')) + const minRating = parseOptionalRating( + searchParams.get('minRating') ?? searchParams.get('rating'), + ) + const sort = parseSort(searchParams.get('sort')) + const order = parseOrder(searchParams.get('order')) + const limit = parseLimit(searchParams.get('limit')) + const page = parsePage(searchParams.get('page')) + + return { query, skills, minRating, sort, order, limit, page } +} + +/** + * Builds the JSON response shape used by GET /api/freelancers, computing + * pagination metadata (e.g. clamping page to totalPages) so the UI gets + * accurate counts even when callers request pages past the end. We sequence + * the list and skills queries (instead of `Promise.all`) so that a failure on + * the main list call short-circuits cleanly with the 503 handler. + */ +export async function buildListResponse( + params: ListFreelancersParams, +): Promise { + const result = await listFreelancers(params) + const skills = await getAvailableSkills() + + const totalItems = result.totalItems + const pageSize = result.freelancers.length + const totalPages = Math.max(1, Math.ceil(totalItems / params.limit)) + const currentPage = Math.min(params.page, totalPages) + + return { + freelancers: result.freelancers, + skills, + pagination: { + page: currentPage === 0 ? 1 : currentPage, + pageSize, + totalItems, + totalPages, + }, + } +} diff --git a/lib/notifications.ts b/lib/notifications.ts new file mode 100644 index 0000000..ec1f90b --- /dev/null +++ b/lib/notifications.ts @@ -0,0 +1,589 @@ +/** + * Notification service. + * + * Encapsulates the persistence, retrieval, status-update, and in-process + * fan-out used by: + * - GET /api/notifications (list w/ pagination + filter) + * - PATCH /api/notifications/[id]/read (mark single read) + * - POST /api/notifications/read-all (mark all read) + * - GET /api/notifications/stream (SSE real-time delivery) + * - lib/notifications.dispatch* helpers (event-creation entry points) + * + * The data source is the `notifications` table. The schema (with the payload, + * channel, and event_type enrichments added by + * scripts/011-notification-service.sql) supports the issue spec: + * id, userId, eventType, message, timestamp, read/unread status. + * + * Real-time delivery is implemented as a server-side pub/sub (`NotificationHub`) + * plus downstream Server-Sent Events. In-process pub/sub is the lower-risk + * default for a Next.js app (no custom server.ts), and matches the SSE/WS + * requirement from issue #122. Multi-instance fan-out (cross-process) is + * explicitly out of scope for this PR — it is documented in the "Out of + * scope" section of the PR description. + */ + +import { sql } from '@/lib/db' + +/** Canonical event types the helper emits. Mirrors the CHECK constraint added in + * scripts/011-notification-service.sql so application code and schema agree. */ +export const NOTIFICATION_EVENT_TYPES = [ + 'contract_created', + 'milestone_submitted', + 'milestone_approved', + 'escrow_released', + 'escrow_refunded', + 'dispute_created', + 'dispute_resolved', +] as const + +export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number] + +/** Legacy free-form types that pre-date the new CHECK constraint. We keep the + * worker (`scripts/worker.ts`) writing these for back-compat; the api surface + * treats them as opaque "info"/"success"/"warning" badges. */ +export const LEGACY_NOTIFICATION_TYPES = [ + 'info', + 'success', + 'warning', +] as const + +export type LegacyNotificationType = (typeof LEGACY_NOTIFICATION_TYPES)[number] + +export type NotificationKind = NotificationEventType | LegacyNotificationType + +export const NOTIFICATION_DEFAULT_LIMIT = 20 +export const NOTIFICATION_MAX_LIMIT = 100 + +export interface NotificationRow { + id: number + user_id: number + title: string + message: string + /** Free-form badge: kept for back-compat with worker writes. */ + type: string + /** Domain event discriminator with CHECK constraint. */ + event_type: string + payload: Record + is_read: boolean + channel: string + created_at: Date | string + delivered_at: Date | string | null +} + +export interface Notification { + id: number + userId: number + title: string + message: string + type: string + eventType: string + payload: Record + isRead: boolean + channel: string + createdAt: string + deliveredAt: string | null +} + +export interface CreateNotificationInput { + userId: number + title: string + message: string + type: LegacyNotificationType + eventType: NotificationEventType + payload?: Record + channel?: string +} + +export interface ListNotificationsParams { + page: number + limit: number + type?: NotificationEventType | null + unreadOnly: boolean +} + +export interface ListNotificationsResult { + notifications: Notification[] + totalItems: number +} + +export class NotificationError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'NotificationError' + } +} + +// ---------- Row → API mapping --------------------------------------------- + +export function mapNotificationRow(row: NotificationRow): Notification { + const createdAt = + row.created_at instanceof Date + ? row.created_at.toISOString() + : row.created_at + const deliveredAt = + row.delivered_at instanceof Date + ? row.delivered_at.toISOString() + : row.delivered_at + + return { + id: row.id, + userId: row.user_id, + title: row.title, + message: row.message, + type: row.type, + eventType: row.event_type, + payload: row.payload ?? {}, + isRead: row.is_read, + channel: row.channel, + createdAt, + deliveredAt, + } +} + +// ---------- Query parameter parsing & validation -------------------------- + +function parseInteger(value: string | null, fallback: number): number { + if (value === null) return fallback + const parsed = Number.parseInt(value, 10) + return Number.isInteger(parsed) ? parsed : fallback +} + +function parsePage(value: string | null): number { + const parsed = parseInteger(value, 1) + if (parsed < 1) { + throw new NotificationError( + 'INVALID_PAGE', + 'page must be greater than or equal to 1', + ) + } + return parsed +} + +function parseLimit(value: string | null): number { + const parsed = parseInteger(value, NOTIFICATION_DEFAULT_LIMIT) + if (parsed < 1) { + throw new NotificationError( + 'INVALID_LIMIT', + 'limit must be greater than or equal to 1', + ) + } + return Math.min(parsed, NOTIFICATION_MAX_LIMIT) +} + +function parseEventType(value: string | null): NotificationEventType | null { + if (value === null || value === '') return null + if ((NOTIFICATION_EVENT_TYPES as readonly string[]).includes(value)) { + return value as NotificationEventType + } + throw new NotificationError( + 'INVALID_TYPE', + `type must be one of: ${NOTIFICATION_EVENT_TYPES.join(', ')}`, + ) +} + +function parseUnreadOnly(value: string | null): boolean { + if (value === null) return false + const normalised = value.trim().toLowerCase() + if (normalised === 'true' || normalised === '1') return true + if (normalised === 'false' || normalised === '0' || normalised === '') return false + throw new NotificationError( + 'INVALID_UNREAD_ONLY', + 'unreadOnly must be true, false, 1, or 0', + ) +} + +/** + * Parses and validates the search-parameters used by GET /api/notifications. + * Returns a fully-populated `ListNotificationsParams` and never throws for + * unrecognised-but-optional inputs (it defaults them). Throws + * NotificationError → 400 for hard-invalid inputs. + */ +export function parseNotificationQuery( + searchParams: URLSearchParams, +): ListNotificationsParams { + return { + page: parsePage(searchParams.get('page')), + limit: parseLimit(searchParams.get('limit')), + type: parseEventType(searchParams.get('type') ?? searchParams.get('eventType')), + unreadOnly: parseUnreadOnly(searchParams.get('unreadOnly')), + } +} + +// ---------- Persistence helpers -------------------------------------------- + +/** + * Persists a new notification and publishes it on the in-process hub so + * connected SSE clients get it in real-time. Returns the mapped Notification. + * Errors from the DB layer are surfaced as-is so callers can translate them + * to 5xx responses; an unreachable hub is non-fatal (notifications are still + * persisted). + */ +export async function createNotification( + input: CreateNotificationInput, +): Promise { + const payload = input.payload ?? {} + + const rows = (await sql` + INSERT INTO notifications ( + user_id, title, message, type, event_type, + payload, channel, is_read, created_at + ) + VALUES ( + ${input.userId}, + ${input.title}, + ${input.message}, + ${input.type}, + ${input.eventType}, + ${JSON.stringify(payload)}::jsonb, + ${input.channel ?? 'in_app'}, + FALSE, + NOW() + ) + RETURNING + id, user_id, title, message, type, event_type, + payload, is_read, channel, created_at, delivered_at + `) as NotificationRow[] + + const row = rows[0] + if (!row) { + throw new NotificationError( + 'NOTIFICATION_INSERT_FAILED', + 'Notification insert returned no rows', + ) + } + + const mapped = mapNotificationRow(row) + + // Best-effort fan-out — if no SSE controllers are subscribed for this + // user, this is a no-op. We swallow publish errors so persistence + // failure can't cascade into delivery failure (and vice versa). + try { + NotificationHub.getInstance().publish(mapped) + } catch (error) { + console.error( + `[notifications] publish failed for user ${input.userId}:`, + error, + ) + } + + return mapped +} + +export async function listNotificationsForUser( + userId: number, + params: ListNotificationsParams, +): Promise { + if (!Number.isInteger(userId) || userId <= 0) { + throw new NotificationError( + 'INVALID_USER_ID', + 'userId must be a positive integer', + ) + } + + const offset = (params.page - 1) * params.limit + const typeFilter = params.type ?? null + const unreadOnly = params.unreadOnly + + const rows = (await sql` + SELECT + id, user_id, title, message, type, event_type, + payload, is_read, channel, created_at, delivered_at, + COUNT(*) OVER() AS total_count + FROM notifications + WHERE user_id = ${userId} + AND (${typeFilter}::text IS NULL OR event_type = ${typeFilter}) + AND (${unreadOnly}::boolean = FALSE OR is_read = FALSE) + ORDER BY created_at DESC, id DESC + LIMIT ${params.limit} + OFFSET ${offset} + `) as Array + + let totalItems: number + if (rows.length === 0) { + totalItems = 0 + } else { + const raw = rows[0]?.total_count + if (raw === null || raw === undefined) { + totalItems = 0 + } else if (typeof raw === 'number') { + totalItems = raw + } else { + const parsed = parseInt(String(raw), 10) + totalItems = Number.isFinite(parsed) ? parsed : 0 + } + } + + // Clone away the total_count helper column. Destructure-rest drops the + // extra column without enumerating fields; `_ignored` is exempt from + // noUnusedLocals under the leading-underscore convention. + const notifications: Notification[] = rows.map((row) => { + const { total_count: _ignored, ...rest } = row + void _ignored + return mapNotificationRow(rest as NotificationRow) + }) + + return { notifications, totalItems } +} + +export interface MarkReadResult { + notification: Notification | null +} + +export async function markNotificationRead( + notificationId: number, + userId: number, +): Promise { + if (!Number.isInteger(notificationId) || notificationId <= 0) { + throw new NotificationError( + 'INVALID_NOTIFICATION_ID', + 'notificationId must be a positive integer', + ) + } + if (!Number.isInteger(userId) || userId <= 0) { + throw new NotificationError( + 'INVALID_USER_ID', + 'userId must be a positive integer', + ) + } + + const rows = (await sql` + UPDATE notifications + SET is_read = TRUE + WHERE id = ${notificationId} + AND user_id = ${userId} + RETURNING + id, user_id, title, message, type, event_type, + payload, is_read, channel, created_at, delivered_at + `) as NotificationRow[] + + const row = rows[0] + return { notification: row ? mapNotificationRow(row) : null } +} + +export interface MarkAllReadResult { + updatedCount: number +} + +export async function markAllNotificationsRead( + userId: number, +): Promise { + if (!Number.isInteger(userId) || userId <= 0) { + throw new NotificationError( + 'INVALID_USER_ID', + 'userId must be a positive integer', + ) + } + + // Single round-trip via a CTE: the WITH clause produces the set of rows + // that *this call* flipped, and the outer SELECT returns *that exact + // count* (not the user's lifetime read total, which a separate + // `WHERE is_read = TRUE` query would have produced). + const rows = (await sql` + WITH updated AS ( + UPDATE notifications + SET is_read = TRUE + WHERE user_id = ${userId} AND is_read = FALSE + RETURNING id + ) + SELECT COUNT(*)::int AS updated_count FROM updated + `) as Array<{ updated_count: number }> + + const raw = rows[0]?.updated_count ?? 0 + return { updatedCount: typeof raw === 'number' ? raw : Number(raw) || 0 } +} + +// ---------- In-process pub/sub (server-side fan-out) ---------------------- + +/** + * Lightweight singleton hub that mirrors SSE subscribers by userId. New + * notifications `publish`ed via `NotificationHub.publish` are pushed to + * every subscriber for the matching userId (and silently drop on errors so + * one broken controller doesn't stall others). + * + * In-process scope: subscribers connect to the same Node.js process. The + * intended deployment topology is a single Next.js server, which is the + * case for Railway/Fly deployments listed in the README. For + * horizontally-scaled multi-instance deployments a Redis pub/sub backplane + * would be needed — see the "Out of scope" section in the PR description. + */ +export type Subscriber = (notification: Notification) => void + +export class NotificationHub { + private static _instance: NotificationHub | null = null + private readonly subscribers: Map> = new Map() + + private constructor() {} + + public static getInstance(): NotificationHub { + if (!NotificationHub._instance) { + NotificationHub._instance = new NotificationHub() + } + return NotificationHub._instance + } + + /** Only used by tests to give each test an isolated hub. */ + public static resetInstance(): void { + NotificationHub._instance = null + } + + public subscribe(userId: number, subscriber: Subscriber): () => void { + if (!Number.isInteger(userId) || userId <= 0) { + throw new NotificationError( + 'INVALID_USER_ID', + 'userId must be a positive integer', + ) + } + let bucket = this.subscribers.get(userId) + if (!bucket) { + bucket = new Set() + this.subscribers.set(userId, bucket) + } + bucket.add(subscriber) + return () => this.unsubscribe(userId, subscriber) + } + + public unsubscribe(userId: number, subscriber: Subscriber): void { + const bucket = this.subscribers.get(userId) + if (!bucket) return + bucket.delete(subscriber) + if (bucket.size === 0) this.subscribers.delete(userId) + } + + public publish(notification: Notification): void { + const bucket = this.subscribers.get(notification.userId) + if (!bucket || bucket.size === 0) return + for (const subscriber of bucket) { + try { + subscriber(notification) + } catch (error) { + console.error( + `[notifications] subscriber for user ${notification.userId} threw:`, + error, + ) + } + } + } + + /** Number of currently subscribed controllers (test-only diagnostic). */ + public subscriberCount(userId: number): number { + return this.subscribers.get(userId)?.size ?? 0 + } +} + +// ---------- Event-creation helpers ---------------------------------------- +/** + * Convenience functions for triggering notifications from server code + * (routes/workers). Each helper accepts the IDs the route already has, so + * call sites don't have to construct title/message boilerplate. + * + * The wiring into escrow.create / milestones.submit / escrow.release / + * dispute.resolve routes is explicitly out of scope for this PR — a follow-up + * PR will thread these calls into the relevant route handlers. The helpers + * keep that follow-up small (one line of code per site). + */ + +export async function notifyContractCreated( + clientId: number, + freelancerId: number, + contractId: number, + title: string, +): Promise { + await Promise.all([ + createNotification({ + userId: clientId, + title, + message: `${title} contract #${contractId} is created.`, + type: 'info', + eventType: 'contract_created', + payload: { contractId }, + }), + createNotification({ + userId: freelancerId, + title, + message: `${title} contract #${contractId} is created.`, + type: 'info', + eventType: 'contract_created', + payload: { contractId }, + }), + ]) +} + +export async function notifyMilestoneSubmitted( + clientId: number, + milestoneId: number, + title: string, +): Promise { + await createNotification({ + userId: clientId, + title, + message: `Milestone "${title}" (#${milestoneId}) is waiting for your review.`, + type: 'info', + eventType: 'milestone_submitted', + payload: { milestoneId }, + }) +} + +export async function notifyMilestoneApproved( + freelancerId: number, + milestoneId: number, + title: string, +): Promise { + await createNotification({ + userId: freelancerId, + title: 'Milestone Approved', + message: `Milestone "${title}" (#${milestoneId}) was approved.`, + type: 'success', + eventType: 'milestone_approved', + payload: { milestoneId }, + }) +} + +export async function notifyEscrowReleased( + clientId: number, + freelancerId: number | null, + contractId: number, + amount: string, + currency: string, +): Promise { + const updates: Array> = [ + createNotification({ + userId: clientId, + title: 'Escrow Released', + message: `${amount} ${currency} released for contract #${contractId}.`, + type: 'success', + eventType: 'escrow_released', + payload: { contractId, amount, currency }, + }), + ] + if (typeof freelancerId === 'number' && freelancerId > 0) { + updates.push( + createNotification({ + userId: freelancerId, + title: 'Payment Received', + message: `You received ${amount} ${currency} for contract #${contractId}.`, + type: 'success', + eventType: 'escrow_released', + payload: { contractId, amount, currency }, + }), + ) + } + await Promise.all(updates) +} + +export async function notifyDisputeCreated( + counterpartyId: number, + disputeId: number, + contractId: number, +): Promise { + await createNotification({ + userId: counterpartyId, + title: 'Dispute Opened', + message: `A dispute (#${disputeId}) was opened on contract #${contractId}.`, + type: 'warning', + eventType: 'dispute_created', + payload: { disputeId, contractId }, + }) +} diff --git a/scripts/010-freelancer-discovery-indexes.sql b/scripts/010-freelancer-discovery-indexes.sql new file mode 100644 index 0000000..7b66c13 --- /dev/null +++ b/scripts/010-freelancer-discovery-indexes.sql @@ -0,0 +1,28 @@ +-- 010-freelancer-discovery-indexes.sql +-- +-- Performance indexes for the GET /api/freelancers discovery endpoint +-- (TaskChain issue #121). The endpoint filters `users` by: +-- * skill membership (skills @> text[]) +-- * minimum rating (rating >= N) +-- * user_type IN ('freelancer', 'both') +-- and sorts by rating / created_at / total_jobs_completed / username. +-- +-- Adding these indexes keeps the discovery query bounded to the small +-- `freelancer-or-both` slice of `users`, and lets the planner use a GIN +-- index for the skills overlap check (`@>`) instead of a sequential scan. + +-- GIN index supports the skills array containment operator (`@>`) used in the +-- skill-filter WHERE clause. It also accelerates `ILIKE` on unnested skills in +-- the free-text search path. +CREATE INDEX IF NOT EXISTS idx_users_skills_gin + ON users USING GIN (skills); + +-- B-tree index for ordered scans and the `rating >= ?` predicate. +CREATE INDEX IF NOT EXISTS idx_users_rating_desc + ON users (rating DESC NULLS LAST); + +-- Partial index keeps the working set small: only freelancers (or folks who +-- can act as freelancers) are candidates for the discovery endpoint. +CREATE INDEX IF NOT EXISTS idx_users_freelancer_discovery + ON users (id, rating DESC NULLS LAST, total_jobs_completed DESC) + WHERE user_type IN ('freelancer', 'both'); diff --git a/scripts/011-notification-service.sql b/scripts/011-notification-service.sql new file mode 100644 index 0000000..03dc328 --- /dev/null +++ b/scripts/011-notification-service.sql @@ -0,0 +1,96 @@ +-- 011-notification-service.sql +-- +-- Notification service enrichment for TaskChain issue #122 +-- (Notification Service Backend). +-- +-- The original notifications table (scripts/005-notifications.sql) is a +-- lightweight schema with `title`, `message`, `type`, `is_read`. This +-- migration adds the columns that the notification-service helper +-- (lib/notifications.ts) needs without breaking existing writes from +-- scripts/worker.ts. +-- +-- All statements are idempotent (`ADD COLUMN IF NOT EXISTS`, +-- `CREATE INDEX IF NOT EXISTS`) so re-running this migration on an +-- environment that already applied it is a no-op. + +-- Ensure the table exists with the operational shape (integer ids, the +-- shape that scripts/worker.ts writes against). Safe no-op when the table +-- was created by scripts/005-notifications.sql. +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + type VARCHAR(50) DEFAULT 'info', + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 1) Event-type channel: replaces the free-form `type` column with a +-- checked enum so callers cannot invent arbitrary strings. +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS event_type VARCHAR(64); + +-- Backfill: existing rows default to a value present in the CHECK list +-- below. We map any unknown legacy `type` to 'info' so the ADD CONSTRAINT +-- step cannot fail on a stray value (e.g. a hand-written 'banana'). +UPDATE notifications + SET event_type = CASE + WHEN type IN ('info', 'success', 'warning') THEN type + ELSE 'info' + END + WHERE event_type IS NULL; + +-- Constrain the column now that every row has a value. The CHECK is +-- tolerant of legacy `info`/`success`/`warning` types the worker writes. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'notifications_event_type_check' + ) THEN + ALTER TABLE notifications + ADD CONSTRAINT notifications_event_type_check + CHECK ( + event_type IN ( + 'info', 'success', 'warning', + 'contract_created', + 'milestone_submitted', + 'milestone_approved', + 'escrow_released', + 'escrow_refunded', + 'dispute_created', + 'dispute_resolved' + ) + ); + END IF; +END $$; + +-- 2) Event-specific structured payload. Stored as JSONB so callers can +-- do attribute-level filtering ("show all notifications about job #42") +-- on the frontend without a schema change. +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- 3) Delivery channel + timestamp. delivery_status lets us distinguish +-- "created but not yet fanned out" from "delivered (or attempted)". +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT 'in_app'; + +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_notifications_user + ON notifications(user_id); + +CREATE INDEX IF NOT EXISTS idx_notifications_created_at + ON notifications(created_at); + +-- Composite: (user_id, is_read, created_at DESC) is exactly the shape the +-- listNotificationsForUser query uses for `unreadOnly=true | false` lists. +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread + ON notifications(user_id, is_read, created_at DESC); + +-- Composite: (user_id, event_type, created_at DESC) powers the `?type=` +-- filter on the GET endpoint. +CREATE INDEX IF NOT EXISTS idx_notifications_user_event + ON notifications(user_id, event_type, created_at DESC); diff --git a/scripts/README.md b/scripts/README.md index afbccd3..5fd8c9c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -104,6 +104,9 @@ CONFIRM=true npx tsx scripts/deploy-mainnet.ts - `006-contracts.sql` - Escrow contracts table - `006-dispute-enhancements.sql` - Dispute enhancements - `006-rate-limits.sql` - Rate limiting table +- `009-reviews-schema.sql` - Reviews table for client/freelancer ratings +- `010-freelancer-discovery-indexes.sql` - GIN/B-tree indexes that power GET /api/freelancers (task #121) +- `011-notification-service.sql` - Enriches the `notifications` table for the notification service backend (task #122): `event_type` CHECK constraint, `payload JSONB`, `channel`, `delivered_at`, plus `(user_id, is_read, created_at)` and `(user_id, event_type, created_at)` indexes ### Security Tables - `007-fail-safe.sql` - Critical operations table for fail-safe system