From c0b80dbc2ab116a4d60a8f902bbef64f6bc50100 Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:05:38 +0545 Subject: [PATCH 01/10] chore(OUT-2986): add alias for notification-counts feature --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 29f865a..11f2597 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "@media/*": ["./src/features/media/*"], "@settings/*": ["./src/features/settings/*"], "@users/*": ["./src/features/users/*"], + "@notification-counts/*": ["./src/features/notification-counts/*"], "@/*": ["./src/*"] } }, From a14399116bbd059681c8d617dc002ae7dc8a4c6a Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:06:11 +0545 Subject: [PATCH 02/10] feat(OUT-2986): make current auth util support dynamic routes --- src/features/auth/lib/utils.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/features/auth/lib/utils.ts b/src/features/auth/lib/utils.ts index a5bfd3b..8357fd8 100644 --- a/src/features/auth/lib/utils.ts +++ b/src/features/auth/lib/utils.ts @@ -25,13 +25,19 @@ export const getSanitizedHeaders = (req: NextRequest) => { return sanitizedHeaders } -function matches(rule: RouteRule, pathname: string, method: string) { - if (typeof rule === 'string') return rule === pathname - if (rule.path !== pathname) return false +const pathMatches = (pattern: string, pathname: string): boolean => { + const regex = pattern.replace(/:[^/]+/g, '[^/]+').replace(/\//g, '\\/') + return new RegExp(`^${regex}$`).test(pathname) +} + +const matches = (rule: RouteRule, pathname: string, method: string) => { + const path = typeof rule === 'string' ? rule : rule.path + if (!pathMatches(path, pathname)) return false + if (typeof rule === 'string') return true if (!rule.methods) return true return rule.methods.includes(method as HttpMethod) } -export function isAuthorized(rules: RouteRule[], req: NextRequest) { +export const isAuthorized = (rules: RouteRule[], req: NextRequest) => { return rules.some((r) => matches(r, req.nextUrl.pathname, req.method)) } From ca307aa75b82bbe6cd3d42c0f58bc35cfe55d8e3 Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:06:27 +0545 Subject: [PATCH 03/10] feat(OUT-2986): add support to getNotifications --- src/lib/assembly/assembly-client.ts | 11 +++++++++++ src/lib/assembly/types.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/lib/assembly/assembly-client.ts b/src/lib/assembly/assembly-client.ts index 466248c..0607479 100644 --- a/src/lib/assembly/assembly-client.ts +++ b/src/lib/assembly/assembly-client.ts @@ -15,6 +15,8 @@ import { InternalUserResponseSchema, type InternalUsersResponse, InternalUsersResponseSchema, + type NotificationsResponse, + NotificationsResponseSchema, type Token, TokenSchema, type WorkspaceResponse, @@ -25,6 +27,7 @@ import { copilotApi } from 'copilot-node-sdk' import env from '@/config/env' import logger from '@/lib/logger' import { withRetry } from '@/lib/with-retry' +import { MAX_FETCH_ASSEMBLY_RESOURCES } from './constants' import { AssemblyInvalidTokenError } from './errors' export default class AssemblyClient { @@ -119,6 +122,13 @@ export default class AssemblyClient { return InternalUserResponseSchema.parse(await this.assembly.retrieveInternalUser({ id })) } + async _getNotifications( + { includeRead, recipientClientId }: Parameters[0] = { includeRead: false }, + ): Promise { + logger.info('AssemblyClient#_getNotifications', { includeRead, recipientClientId }) + return NotificationsResponseSchema.parse(await this.assembly.listNotifications({ includeRead, recipientClientId })) + } + private wrapWithRetry(fn: (...args: Args) => Promise): (...args: Args) => Promise { return (...args: Args): Promise => withRetry(fn.bind(this), args) } @@ -137,4 +147,5 @@ export default class AssemblyClient { getCompanyClients = this.wrapWithRetry(this._getCompanyClients) getInternalUsers = this.wrapWithRetry(this._getInternalUsers) getInternalUser = this.wrapWithRetry(this._getInternalUser) + getNotifications = this.wrapWithRetry(this._getNotifications) } diff --git a/src/lib/assembly/types.ts b/src/lib/assembly/types.ts index 68ca951..a03eeb6 100644 --- a/src/lib/assembly/types.ts +++ b/src/lib/assembly/types.ts @@ -115,3 +115,20 @@ export const InternalUsersResponseSchema = z.object({ data: z.array(InternalUserResponseSchema), }) export type InternalUsersResponse = z.infer + +// export const NotificationsResponseSchema = z.object({ +// id: z.string(), // Beware, don't set this to uuid because +// recipientClientId: z.uuid(), +// recipientCompanyId: z.uuid().nullish(), +// }) +export const NotificationsResponseSchema = z.object({ + data: z + .object({ + id: z.string(), + recipientClientId: z.uuid(), + recipientCompanyId: z.uuid().nullish(), + event: z.string(), + }) + .array(), +}) +export type NotificationsResponse = z.infer From 3a21ffbe228674f831a19b774ae43a6b40996c2c Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:07:02 +0545 Subject: [PATCH 04/10] fix(OUT-2986): please the typescript overlords --- src/lib/with-error-handler.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/with-error-handler.ts b/src/lib/with-error-handler.ts index 74d48a7..da77b0e 100644 --- a/src/lib/with-error-handler.ts +++ b/src/lib/with-error-handler.ts @@ -8,7 +8,9 @@ import type { StatusableError } from '@/errors/base-server.error' import { NotFoundError } from '@/errors/not-found.error' import logger from '@/lib/logger' -type RequestHandler = (req: NextRequest, params: unknown) => Promise +type RouteContext

> = { params: Promise

} +type SimpleHandler = (req: NextRequest) => Promise +type ParamsHandler

> = (req: NextRequest, ctx: RouteContext

) => Promise /** * Reusable utility that wraps a given request handler with a global error handler to standardize response structure @@ -27,11 +29,15 @@ type RequestHandler = (req: NextRequest, params: unknown) => Promise { - return async (req: NextRequest, params: unknown) => { +export function withErrorHandler(handler: SimpleHandler): SimpleHandler +export function withErrorHandler

>(handler: ParamsHandler

): ParamsHandler

+export function withErrorHandler

>( + handler: SimpleHandler | ParamsHandler

, +): SimpleHandler | ParamsHandler

{ + return async (req: NextRequest, ctx?: RouteContext

) => { // Execute the handler wrapped in a try... catch block try { - return await handler(req, params) + return await (handler as ParamsHandler

)(req, ctx as RouteContext

) } catch (error: unknown) { // Build error API response and log error let message: string | undefined From a49cdce5617cfe71b4e4fdd45e56720cdfca4bda Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:07:25 +0545 Subject: [PATCH 05/10] feat(OUT-2986): add support for getting notification counts for client --- .../lib/notification-counts.controller.ts | 23 +++++++++ .../lib/notification-counts.service.ts | 50 +++++++++++++++++++ .../notification-counts.dto.ts | 10 ++++ src/features/notification-counts/types.ts | 6 +++ 4 files changed, 89 insertions(+) create mode 100644 src/features/notification-counts/lib/notification-counts.controller.ts create mode 100644 src/features/notification-counts/lib/notification-counts.service.ts create mode 100644 src/features/notification-counts/notification-counts.dto.ts create mode 100644 src/features/notification-counts/types.ts diff --git a/src/features/notification-counts/lib/notification-counts.controller.ts b/src/features/notification-counts/lib/notification-counts.controller.ts new file mode 100644 index 0000000..7db74e5 --- /dev/null +++ b/src/features/notification-counts/lib/notification-counts.controller.ts @@ -0,0 +1,23 @@ +import { authenticateHeaders } from '@auth/lib/authenticate' +import { HttpStatusCode } from 'axios' +import { type NextRequest, NextResponse } from 'next/server' +import type { APIResponse } from '@/app/types' +import APIError from '@/errors/api.error' +import NotificationsCountService from './notification-counts.service' + +export const getAllUserNotificationCounts = async ( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise> => { + const user = authenticateHeaders(req.headers) + const clientId = (await params).id + + if (!clientId) throw new APIError('Client ID is required', HttpStatusCode.BadRequest) + + const notificationCountService = NotificationsCountService.new(user) + const notificationCounts = await notificationCountService.getNotificationCountsForClient(clientId) + + return NextResponse.json({ + data: notificationCounts, + }) +} diff --git a/src/features/notification-counts/lib/notification-counts.service.ts b/src/features/notification-counts/lib/notification-counts.service.ts new file mode 100644 index 0000000..737f6f2 --- /dev/null +++ b/src/features/notification-counts/lib/notification-counts.service.ts @@ -0,0 +1,50 @@ +import AssemblyClient from '@assembly/assembly-client' +import type { User } from '@auth/lib/user.entity' +import type { NotificationCountsDto } from '@notification-counts/notification-counts.dto' +import { NotificationEvent } from '@notification-counts/types' +import { HttpStatusCode } from 'axios' +import APIError from '@/errors/api.error' +import BaseService from '@/lib/core/base.service' + +export default class NotificationsCountService extends BaseService { + private readonly eventMap: Partial> = Object.freeze({ + [NotificationEvent.FORMS]: 'forms', + [NotificationEvent.INVOICES]: 'invoices', + [NotificationEvent.CONTRACTS]: 'contracts', + } as const) + + constructor( + readonly user: User, + readonly assembly: AssemblyClient, + ) { + super(user, assembly) + } + + static new(user: User) { + const assembly = new AssemblyClient(user.token) + return new NotificationsCountService(user, assembly) + } + + async getNotificationCountsForClient(recipientClientId: string): Promise { + const notifications = await this.assembly.getNotifications({ recipientClientId }) + if (!notifications || !notifications.data) + throw new APIError('Could not fetch notifications list from assembly', HttpStatusCode.InternalServerError) + + const notificationCounts: NotificationCountsDto = { + forms: 0, + invoices: 0, + contracts: 0, + tasks: 0, + messages: 0, + } + + this.eventMap['forms' as NotificationEvent] = 'invoices' + + notifications.data.forEach(({ event }) => { + const key = this.eventMap[event as NotificationEvent] + if (key) notificationCounts[key]++ + }) + + return notificationCounts + } +} diff --git a/src/features/notification-counts/notification-counts.dto.ts b/src/features/notification-counts/notification-counts.dto.ts new file mode 100644 index 0000000..3cc64cf --- /dev/null +++ b/src/features/notification-counts/notification-counts.dto.ts @@ -0,0 +1,10 @@ +import z from 'zod' + +export const NotificationCountsDtoSchema = z.object({ + forms: z.number(), + invoices: z.number(), + contracts: z.number(), + tasks: z.number(), + messages: z.number(), +}) +export type NotificationCountsDto = z.infer diff --git a/src/features/notification-counts/types.ts b/src/features/notification-counts/types.ts new file mode 100644 index 0000000..29db565 --- /dev/null +++ b/src/features/notification-counts/types.ts @@ -0,0 +1,6 @@ +export enum NotificationEvent { + FORMS = 'formResponse.requested', + INVOICES = 'invoice.requested', + CONTRACTS = 'contract.requested', +} +export type NotificationCounts = Record From 17ca373d3ba9b1b1ae4cc315fc2a2160727c78db Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:07:42 +0545 Subject: [PATCH 06/10] feat(OUT-2986): add route for getting client notification counts --- src/app/api/users/[id]/notification-counts/route.ts | 4 ++++ src/app/routes.ts | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 src/app/api/users/[id]/notification-counts/route.ts diff --git a/src/app/api/users/[id]/notification-counts/route.ts b/src/app/api/users/[id]/notification-counts/route.ts new file mode 100644 index 0000000..e76307b --- /dev/null +++ b/src/app/api/users/[id]/notification-counts/route.ts @@ -0,0 +1,4 @@ +import { getAllUserNotificationCounts } from '@notification-counts/lib/notification-counts.controller' +import { withErrorHandler } from '@/lib/with-error-handler' + +export const GET = withErrorHandler(getAllUserNotificationCounts) diff --git a/src/app/routes.ts b/src/app/routes.ts index d1a5f0c..d5b333f 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -7,6 +7,7 @@ export const ROUTES = Object.freeze({ workspace: '/api/workspace', media: '/api/media', users: '/api/users', + notificationCounts: '/api/users/:id/notification-counts', }, }) @@ -25,12 +26,21 @@ export type RouteRule = */ export const authorizedRoutes: Record = { public: [ROUTES.api.health], - internalUsers: [ROUTES.home, ROUTES.api.workspace, ROUTES.api.settings, ROUTES.api.media, ROUTES.api.users], + internalUsers: [ + ROUTES.home, + ROUTES.api.workspace, + ROUTES.api.settings, + ROUTES.api.media, + ROUTES.api.users, + ROUTES.api.notificationCounts, + ], clientUsers: [ ROUTES.client, { path: ROUTES.api.settings, methods: ['GET'], }, + // We have to implement further policy level auth so that client can only access its own notification counts + ROUTES.api.notificationCounts, ], } From c546616aec317294722fbbaef58ee054067f95cf Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:11:21 +0545 Subject: [PATCH 07/10] refactor(OUT-2986): make sure typescript catches consts --- .../notification-counts/lib/notification-counts.service.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/features/notification-counts/lib/notification-counts.service.ts b/src/features/notification-counts/lib/notification-counts.service.ts index 737f6f2..d7efe11 100644 --- a/src/features/notification-counts/lib/notification-counts.service.ts +++ b/src/features/notification-counts/lib/notification-counts.service.ts @@ -7,11 +7,11 @@ import APIError from '@/errors/api.error' import BaseService from '@/lib/core/base.service' export default class NotificationsCountService extends BaseService { - private readonly eventMap: Partial> = Object.freeze({ + private readonly eventMap = Object.freeze({ [NotificationEvent.FORMS]: 'forms', [NotificationEvent.INVOICES]: 'invoices', [NotificationEvent.CONTRACTS]: 'contracts', - } as const) + } as const satisfies Partial>) constructor( readonly user: User, @@ -38,8 +38,6 @@ export default class NotificationsCountService extends BaseService { messages: 0, } - this.eventMap['forms' as NotificationEvent] = 'invoices' - notifications.data.forEach(({ event }) => { const key = this.eventMap[event as NotificationEvent] if (key) notificationCounts[key]++ From 282cd9e4648ebc5698e072d1ee920afca39422b6 Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:11:51 +0545 Subject: [PATCH 08/10] refactor(OUT-2986): remove unused import --- src/lib/assembly/assembly-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/assembly/assembly-client.ts b/src/lib/assembly/assembly-client.ts index 0607479..56a54b1 100644 --- a/src/lib/assembly/assembly-client.ts +++ b/src/lib/assembly/assembly-client.ts @@ -27,7 +27,6 @@ import { copilotApi } from 'copilot-node-sdk' import env from '@/config/env' import logger from '@/lib/logger' import { withRetry } from '@/lib/with-retry' -import { MAX_FETCH_ASSEMBLY_RESOURCES } from './constants' import { AssemblyInvalidTokenError } from './errors' export default class AssemblyClient { From 98257a7c3308d18c89d9c3ae980cfd2e36032f1d Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:19:49 +0545 Subject: [PATCH 09/10] feat(OUT-2986): add multi-company support --- .../lib/notification-counts.controller.ts | 4 +++- .../lib/notification-counts.service.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/features/notification-counts/lib/notification-counts.controller.ts b/src/features/notification-counts/lib/notification-counts.controller.ts index 7db74e5..e972712 100644 --- a/src/features/notification-counts/lib/notification-counts.controller.ts +++ b/src/features/notification-counts/lib/notification-counts.controller.ts @@ -14,8 +14,10 @@ export const getAllUserNotificationCounts = async ( if (!clientId) throw new APIError('Client ID is required', HttpStatusCode.BadRequest) + const companyId = req.nextUrl.searchParams.get('companyId') + const notificationCountService = NotificationsCountService.new(user) - const notificationCounts = await notificationCountService.getNotificationCountsForClient(clientId) + const notificationCounts = await notificationCountService.getNotificationCountsForClient(clientId, companyId) return NextResponse.json({ data: notificationCounts, diff --git a/src/features/notification-counts/lib/notification-counts.service.ts b/src/features/notification-counts/lib/notification-counts.service.ts index d7efe11..b58dcee 100644 --- a/src/features/notification-counts/lib/notification-counts.service.ts +++ b/src/features/notification-counts/lib/notification-counts.service.ts @@ -25,7 +25,10 @@ export default class NotificationsCountService extends BaseService { return new NotificationsCountService(user, assembly) } - async getNotificationCountsForClient(recipientClientId: string): Promise { + async getNotificationCountsForClient( + recipientClientId: string, + recipientCompanyId: string | null, + ): Promise { const notifications = await this.assembly.getNotifications({ recipientClientId }) if (!notifications || !notifications.data) throw new APIError('Could not fetch notifications list from assembly', HttpStatusCode.InternalServerError) @@ -38,7 +41,9 @@ export default class NotificationsCountService extends BaseService { messages: 0, } - notifications.data.forEach(({ event }) => { + notifications.data.forEach(({ event, recipientCompanyId: notificationRecipientCompanyId }) => { + if (recipientCompanyId && notificationRecipientCompanyId !== recipientCompanyId) return + const key = this.eventMap[event as NotificationEvent] if (key) notificationCounts[key]++ }) From e514b7a6bda96a0186da662ce20411578306d2fb Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Thu, 5 Feb 2026 19:22:36 +0545 Subject: [PATCH 10/10] refactor(OUT-2986): clean code --- .../notification-counts/lib/notification-counts.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/notification-counts/lib/notification-counts.controller.ts b/src/features/notification-counts/lib/notification-counts.controller.ts index e972712..42a6fae 100644 --- a/src/features/notification-counts/lib/notification-counts.controller.ts +++ b/src/features/notification-counts/lib/notification-counts.controller.ts @@ -1,9 +1,9 @@ import { authenticateHeaders } from '@auth/lib/authenticate' +import NotificationsCountService from '@notification-counts/lib/notification-counts.service' import { HttpStatusCode } from 'axios' import { type NextRequest, NextResponse } from 'next/server' import type { APIResponse } from '@/app/types' import APIError from '@/errors/api.error' -import NotificationsCountService from './notification-counts.service' export const getAllUserNotificationCounts = async ( req: NextRequest,