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, ], } 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)) } 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..42a6fae --- /dev/null +++ b/src/features/notification-counts/lib/notification-counts.controller.ts @@ -0,0 +1,25 @@ +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' + +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 companyId = req.nextUrl.searchParams.get('companyId') + + const notificationCountService = NotificationsCountService.new(user) + 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 new file mode 100644 index 0000000..b58dcee --- /dev/null +++ b/src/features/notification-counts/lib/notification-counts.service.ts @@ -0,0 +1,53 @@ +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 = Object.freeze({ + [NotificationEvent.FORMS]: 'forms', + [NotificationEvent.INVOICES]: 'invoices', + [NotificationEvent.CONTRACTS]: 'contracts', + } as const satisfies Partial>) + + 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, + 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) + + const notificationCounts: NotificationCountsDto = { + forms: 0, + invoices: 0, + contracts: 0, + tasks: 0, + messages: 0, + } + + notifications.data.forEach(({ event, recipientCompanyId: notificationRecipientCompanyId }) => { + if (recipientCompanyId && notificationRecipientCompanyId !== recipientCompanyId) return + + 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 diff --git a/src/lib/assembly/assembly-client.ts b/src/lib/assembly/assembly-client.ts index 466248c..56a54b1 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, @@ -119,6 +121,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 +146,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 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 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/*"] } },