Skip to content
Merged
4 changes: 4 additions & 0 deletions src/app/api/users/[id]/notification-counts/route.ts
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 11 additions & 1 deletion src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const ROUTES = Object.freeze({
workspace: '/api/workspace',
media: '/api/media',
users: '/api/users',
notificationCounts: '/api/users/:id/notification-counts',
},
})

Expand All @@ -25,12 +26,21 @@ export type RouteRule =
*/
export const authorizedRoutes: Record<string, RouteRule[]> = {
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,
],
}
14 changes: 10 additions & 4 deletions src/features/auth/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '\\/')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be helpful if we add some docs to what this function actually does.

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))
}
Original file line number Diff line number Diff line change
@@ -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<NextResponse<APIResponse>> => {
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,
})
}
Original file line number Diff line number Diff line change
@@ -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<Record<NotificationEvent, keyof NotificationCountsDto>>)

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<NotificationCountsDto> {
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
}
}
10 changes: 10 additions & 0 deletions src/features/notification-counts/notification-counts.dto.ts
Original file line number Diff line number Diff line change
@@ -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<typeof NotificationCountsDtoSchema>
6 changes: 6 additions & 0 deletions src/features/notification-counts/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum NotificationEvent {
FORMS = 'formResponse.requested',
INVOICES = 'invoice.requested',
CONTRACTS = 'contract.requested',
}
export type NotificationCounts = Record<NotificationEvent, number>
10 changes: 10 additions & 0 deletions src/lib/assembly/assembly-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
InternalUserResponseSchema,
type InternalUsersResponse,
InternalUsersResponseSchema,
type NotificationsResponse,
NotificationsResponseSchema,
type Token,
TokenSchema,
type WorkspaceResponse,
Expand Down Expand Up @@ -119,6 +121,13 @@ export default class AssemblyClient {
return InternalUserResponseSchema.parse(await this.assembly.retrieveInternalUser({ id }))
}

async _getNotifications(
{ includeRead, recipientClientId }: Parameters<SDK['listNotifications']>[0] = { includeRead: false },
): Promise<NotificationsResponse> {
logger.info('AssemblyClient#_getNotifications', { includeRead, recipientClientId })
return NotificationsResponseSchema.parse(await this.assembly.listNotifications({ includeRead, recipientClientId }))
}

private wrapWithRetry<Args extends unknown[], R>(fn: (...args: Args) => Promise<R>): (...args: Args) => Promise<R> {
return (...args: Args): Promise<R> => withRetry(fn.bind(this), args)
}
Expand All @@ -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)
}
17 changes: 17 additions & 0 deletions src/lib/assembly/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,20 @@ export const InternalUsersResponseSchema = z.object({
data: z.array(InternalUserResponseSchema),
})
export type InternalUsersResponse = z.infer<typeof InternalUsersResponseSchema>

// 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(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also parse this event with NotificationEvent from notification-counts/types.ts? With this I think we don't have to do const key = this.eventMap[event as NotificationEvent] in notification-counts.service file right?

})
.array(),
})
export type NotificationsResponse = z.infer<typeof NotificationsResponseSchema>
14 changes: 10 additions & 4 deletions src/lib/with-error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse>
type RouteContext<P extends Record<string, string>> = { params: Promise<P> }
type SimpleHandler = (req: NextRequest) => Promise<NextResponse>
type ParamsHandler<P extends Record<string, string>> = (req: NextRequest, ctx: RouteContext<P>) => Promise<NextResponse>

/**
* Reusable utility that wraps a given request handler with a global error handler to standardize response structure
Expand All @@ -27,11 +29,15 @@ type RequestHandler = (req: NextRequest, params: unknown) => Promise<NextRespons
* @throws {ZodError} Captures and handles validation errors and responds with status 400 and the issue detail.
* @throws {APIError} Captures and handles APIError
*/
export const withErrorHandler = (handler: RequestHandler): RequestHandler => {
return async (req: NextRequest, params: unknown) => {
export function withErrorHandler(handler: SimpleHandler): SimpleHandler
export function withErrorHandler<P extends Record<string, string>>(handler: ParamsHandler<P>): ParamsHandler<P>
export function withErrorHandler<P extends Record<string, string>>(
handler: SimpleHandler | ParamsHandler<P>,
): SimpleHandler | ParamsHandler<P> {
return async (req: NextRequest, ctx?: RouteContext<P>) => {
// Execute the handler wrapped in a try... catch block
try {
return await handler(req, params)
return await (handler as ParamsHandler<P>)(req, ctx as RouteContext<P>)
} catch (error: unknown) {
// Build error API response and log error
let message: string | undefined
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@media/*": ["./src/features/media/*"],
"@settings/*": ["./src/features/settings/*"],
"@users/*": ["./src/features/users/*"],
"@notification-counts/*": ["./src/features/notification-counts/*"],
"@/*": ["./src/*"]
}
},
Expand Down