diff --git a/src/alert/alert.controller.ts b/src/alert/alert.controller.ts new file mode 100644 index 0000000..7eeada5 --- /dev/null +++ b/src/alert/alert.controller.ts @@ -0,0 +1,42 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { PageQueryParams } from "../pagination"; +import { getUnreadCount, getUserAlerts, markAsRead } from "./alert.service"; + +export async function listAlertsHandler( + req: FastifyRequest, + res: FastifyReply +) { + const params = req.query as PageQueryParams; + + try { + const alerts = await getUserAlerts(req.user, params); + return res.code(200).send(alerts); + } catch (err) { + return err; + } +} + +export async function markReadHandler(req: FastifyRequest, res: FastifyReply) { + const { alertId } = req.params as { alertId: string }; + + try { + const updatedAlert = await markAsRead(alertId, req.user); + if (updatedAlert == null) + return res.code(404).send({ error: "resource not found" }); + return res.code(200).send(updatedAlert); + } catch (err) { + return err; + } +} + +export async function getUnreadCountHandler( + req: FastifyRequest, + res: FastifyReply +) { + try { + const unreadCount = await getUnreadCount(req.user); + return res.code(200).send({ unreadCount }); + } catch (err) { + return err; + } +} diff --git a/src/alert/alert.route.ts b/src/alert/alert.route.ts new file mode 100644 index 0000000..ae725b8 --- /dev/null +++ b/src/alert/alert.route.ts @@ -0,0 +1,46 @@ +import { FastifyInstance } from "fastify"; +import { + getUnreadCountHandler, + listAlertsHandler, + markReadHandler, +} from "./alert.controller"; +import { $alert } from "./alert.schema"; + +export async function alertRoutes(fastify: FastifyInstance) { + fastify.get( + "", + { + schema: { + response: { 200: $alert("listAlertResponse") }, + }, + config: { requiredClaims: ["alert:read"] }, + preHandler: [fastify.authorize], + }, + listAlertsHandler + ); + + fastify.post( + "/:alertId/read", + { + schema: { + params: { + type: "object", + properties: { alertId: { type: "string" } }, + }, + response: { 200: $alert("alertResponse") }, + }, + config: { requiredClaims: ["alert:write"] }, + preHandler: [fastify.authorize], + }, + markReadHandler + ); + + fastify.get( + "/count", + { + config: { requiredClaims: ["alert:read"] }, + preHandler: [fastify.authorize], + }, + getUnreadCountHandler + ); +} diff --git a/src/alert/alert.schema.ts b/src/alert/alert.schema.ts new file mode 100644 index 0000000..e87bbb2 --- /dev/null +++ b/src/alert/alert.schema.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import mongoose from "mongoose"; +import { buildJsonSchemas } from "fastify-zod"; +import { pageQueryParams } from "../pagination"; + +export const alertsModel = mongoose.model( + "alert", + new mongoose.Schema({ + tenantId: String, + pid: { type: String, unique: true }, + title: { type: String, required: true }, + referenceId: String, + referenceCollection: String, + recipientType: { + type: String, + required: true, + enum: ["user", "team"], + }, + recipientId: { type: mongoose.Types.ObjectId, required: true }, + createdAt: { type: Date, default: Date.now }, + readBy: { + type: [String], + default: [], + }, + }) +); + +const alertResponse = z.object({ + pid: z.string(), + title: z.string(), + read: z.boolean(), + referenceId: z.string().optional(), + referenceCollection: z.string().optional(), + recipientType: z.enum(["user", "team"]), + createdAt: z.date(), +}); + +const listAlertResponse = z.object({ + alerts: z.array(alertResponse), + metadata: z.object({ + count: z.number(), + page: z.number(), + pageSize: z.number(), + }), +}); + +export const { schemas: alertSchemas, $ref: $alert } = buildJsonSchemas( + { + alertResponse, + listAlertResponse, + pageQueryParams, + }, + { $id: "alert" } +); diff --git a/src/alert/alert.service.ts b/src/alert/alert.service.ts new file mode 100644 index 0000000..02cfcb8 --- /dev/null +++ b/src/alert/alert.service.ts @@ -0,0 +1,110 @@ +import { AuthenticatedUser } from "../auth"; +import { PageQueryParams } from "../pagination"; +import { dbEvents } from "../realtime"; +import { generateId } from "../utils/id"; +import { alertsModel } from "./alert.schema"; + +export async function createAlert( + tenantId: string, + title: string, + recipientType: "user" | "team", + recipientId: string, + referenceId?: string, + referenceCollection?: string +) { + const newAlert = await alertsModel.create({ + tenantId, + pid: generateId(), + title, + recipientType, + recipientId, + referenceId, + referenceCollection, + }); + + dbEvents.emit( + "alert", + { + type: "insert", + collection: "alerts", + document: { + pid: newAlert.pid, + title, + recipientType, + recipientId, + referenceId, + referenceCollection, + createdAt: new Date(), + }, + }, + ["alert:read"] + ); +} + +export async function getUserAlerts( + user: AuthenticatedUser, + params: PageQueryParams +) { + const page = params.page || 1; + const pageSize = params.pageSize || 10; + + const filters: Array = [ + { recipientType: "user", recipientId: user.userId }, + ]; + + if (user.role == "client") + filters.push({ recipientType: "team", recipientId: user.orgId }); + else filters.push({ recipientType: "team" }); + + const alerts = await alertsModel + .find({ + $or: filters, + }) + .sort({ createdAt: -1 }) + .limit(pageSize) + .skip((page - 1) * pageSize); + + const modifiedAlerts = alerts.map((alert) => { + return { + ...alert.toObject(), + read: alert.readBy.includes(user.userId) ? true : false, + }; + }); + + const count = await alertsModel.countDocuments({ $or: filters }); + + return { alerts: modifiedAlerts, metadata: { count, page, pageSize } }; +} + +export async function markAsRead(alertId: string, user: AuthenticatedUser) { + const updatedAlert = await alertsModel.findOneAndUpdate( + { tenantId: user.tenantId, pid: alertId }, + { $addToSet: { readBy: user.userId } }, + { new: true } + ); + + if (!updatedAlert) return null; + + updatedAlert["read"] = updatedAlert.readBy.includes(user.userId) + ? true + : false; + + return updatedAlert; +} + +export async function getUnreadCount(user: AuthenticatedUser) { + const filters: Array = [ + { recipientType: "user", recipientId: user.userId }, + ]; + + if (user.role == "client") + filters.push({ recipientType: "team", recipientId: user.orgId }); + else filters.push({ recipientType: "team" }); + + const alerts = await alertsModel.find({ + $or: filters, + readBy: { $ne: user.userId }, + }); + + return alerts.length; +} diff --git a/src/realtime.ts b/src/realtime.ts index cf7b4b5..0a1892d 100644 --- a/src/realtime.ts +++ b/src/realtime.ts @@ -1,5 +1,4 @@ import { EventEmitter } from "stream"; -import { Claim } from "./utils/claims"; export type ChangeEvent = { type: "insert" | "update" | "delete"; @@ -7,4 +6,18 @@ export type ChangeEvent = { document?: Object; }; +export type AlertEvent = { + type: "insert"; + collection: "alerts"; + document: { + pid: string; + title: string; + recipientType: "user" | "team"; + recipientId: string; + referenceId?: string; + referenceCollection?: string; + createdAt: Date; + }; +}; + export const dbEvents = new EventEmitter(); diff --git a/src/realtime/realtime.route.ts b/src/realtime/realtime.route.ts index 211e2c1..0aa6939 100644 --- a/src/realtime/realtime.route.ts +++ b/src/realtime/realtime.route.ts @@ -1,5 +1,5 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { ChangeEvent, dbEvents } from "../realtime"; +import { AlertEvent, ChangeEvent, dbEvents } from "../realtime"; import { hasValidClaims } from "../auth"; import { Claim } from "../utils/claims"; @@ -20,6 +20,34 @@ export async function realTimeRoutes(fastify: FastifyInstance) { } }); + dbEvents.on("alert", (event: AlertEvent, requiredClaims: Claim[]) => { + console.log(event); + + if (hasValidClaims(req.user, requiredClaims)) { + let alertFlag = false; + + if ( + event.document.recipientType == "user" && + event.document.recipientId == req.user.userId + ) + alertFlag = true; + + if (event.document.recipientType == "team") { + if (req.user.role != "client") alertFlag = true; + + if ( + req.user.role == "client" && + event.document.recipientId == req.user.orgId + ) + alertFlag = true; + } + + if (alertFlag) { + res.raw.write(JSON.stringify(event)); + } + } + }); + req.raw.on("close", () => { res.raw.end(); }); diff --git a/src/routes.ts b/src/routes.ts index 079e10e..d3eea02 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -15,6 +15,7 @@ import { viewRoutes } from "./view/view.route"; import { processedRoutes } from "./processed/processed.route"; import { ctaskRoutes } from "./ctask/ctask.route"; import { paymentRoutes } from "./payments/payment.route"; +import { alertRoutes } from "./alert/alert.route"; export default async function routes(fastify: FastifyInstance) { fastify.addHook("preHandler", authHandler); @@ -32,5 +33,6 @@ export default async function routes(fastify: FastifyInstance) { fastify.register(viewRoutes, { prefix: "/views" }); fastify.register(processedRoutes, { prefix: "/processed" }); fastify.register(paymentRoutes, { prefix: "/payments" }); + fastify.register(alertRoutes, { prefix: "/alerts" }); fastify.register(realTimeRoutes); } diff --git a/src/rts/rts.controller.ts b/src/rts/rts.controller.ts index 911310b..d8a7438 100644 --- a/src/rts/rts.controller.ts +++ b/src/rts/rts.controller.ts @@ -50,7 +50,7 @@ export async function updateRtsHandler(req: FastifyRequest, res: FastifyReply) { const { rtsId } = req.params as { rtsId: string }; try { - const updatedRts = await updateRts(rtsId, input, req.user.tenantId); + const updatedRts = await updateRts(rtsId, input, req.user); if (!updatedRts) return res.code(404).send({ error: "resource not found" }); return res.code(200).send(updatedRts); diff --git a/src/rts/rts.service.ts b/src/rts/rts.service.ts index 2bdedc0..82c2227 100644 --- a/src/rts/rts.service.ts +++ b/src/rts/rts.service.ts @@ -11,6 +11,7 @@ import { getFilterObject, getSortObject, PageQueryParams } from "../pagination"; import { getUserWithoutPopulate } from "../user/user.service"; import mongoose from "mongoose"; import { rtsPipeline } from "../utils/pipeline"; +import { createAlert } from "../alert/alert.service"; export async function createRts( input: CreateRtsInput, @@ -38,6 +39,15 @@ export async function createRts( createdBy: user.userId ?? null, }); + await createAlert( + user.tenantId, + `New Ready to Submit added`, + "team", + newRts.client?.toString(), + newRts.pid, + "rts" + ); + return rtsModel .findById(newRts.id) .populate({ path: "county", select: "pid name avatar" }) @@ -192,15 +202,28 @@ export async function listRts( export async function updateRts( id: string, input: UpdateRtsInput, - tenantId: string + user: AuthenticatedUser ) { const updatedRts = await rtsModel - .findOneAndUpdate({ pid: id, tenantId: tenantId }, input, { new: true }) + .findOneAndUpdate({ pid: id, tenantId: user.tenantId }, input, { + new: true, + }) .populate({ path: "createdBy", select: "pid name avatar" }) .populate({ path: "county", select: "pid name avatar" }) .populate({ path: "client", select: "pid name avatar" }) .populate({ path: "assignedTo", select: "pid name avatar" }); + if (input.assignedTo) { + await createAlert( + user.tenantId, + `You are assigned to RTS`, + "user", + user.userId, + updatedRts.pid, + "rts" + ); + } + return updatedRts; } diff --git a/src/server.ts b/src/server.ts index 326879b..ffa58ca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,7 @@ import { viewSchemas } from "./view/view.schema"; import { processedSchemas } from "./processed/processed.schema"; import { cTaskSchemas } from "./ctask/ctask.schema"; import { paymentSchemas } from "./payments/payment.schema"; +import { alertSchemas } from "./alert/alert.schema"; const app = fastify({ logger: true, trustProxy: true }); @@ -59,6 +60,7 @@ for (const schema of [ ...viewSchemas, ...processedSchemas, ...paymentSchemas, + ...alertSchemas, ]) { app.addSchema(schema); } diff --git a/src/utils/claims.ts b/src/utils/claims.ts index 9e417d9..3184eab 100644 --- a/src/utils/claims.ts +++ b/src/utils/claims.ts @@ -35,4 +35,6 @@ export type Claim = | "note:read" | "note:write" | "note:delete" - | "payment:read"; + | "payment:read" + | "alert:read" + | "alert:write"; diff --git a/src/utils/roles.ts b/src/utils/roles.ts index 8514b87..eadbe7a 100644 --- a/src/utils/roles.ts +++ b/src/utils/roles.ts @@ -37,6 +37,8 @@ export const rules: Record< "note:write", "note:delete", "payment:read", + "alert:read", + "alert:write", ], hiddenFields: { orgs: ["__v"], @@ -75,6 +77,8 @@ export const rules: Record< "note:write", "note:delete", "payment:read", + "alert:read", + "alert:write", ], hiddenFields: { orgs: ["__v"], @@ -105,6 +109,8 @@ export const rules: Record< "note:read", "note:write", "note:delete", + "alert:read", + "alert:write", ], hiddenFields: { orgs: ["__v"], @@ -134,6 +140,8 @@ export const rules: Record< "ctask:delete", "note:read", "note:write", + "alert:read", + "alert:write", ], hiddenFields: { orgs: ["__v"],