diff --git a/cron/analytics.js b/cron/analytics.js new file mode 100644 index 0000000..4e43948 --- /dev/null +++ b/cron/analytics.js @@ -0,0 +1,282 @@ +import mongoose from "mongoose"; + +const permitSchema = new mongoose.Schema({ + tenantId: { + type: String, + required: true, + }, + pid: { + type: String, + unique: true, + }, + permitNumber: String, + county: Object, + client: { + type: mongoose.Types.ObjectId, + ref: "organization", + }, + clientData: Object, + permitDate: Date, + stage: new mongoose.Schema( + { + pipeline: Array, + currentStage: Number, + }, + { _id: false } + ), + status: String, + manualStatus: String, + cleanStatus: String, + permitType: String, + utility: String, + assignedTo: { + type: mongoose.Types.ObjectId, + ref: "user", + }, + link: String, + address: Object, + recordType: String, + description: String, + applicationDetails: Object, + applicationInfo: Object, + applicationInfoTable: Object, + conditions: Array, + ownerDetails: String, + parcelInfo: Object, + paymentData: Object, + professionalsList: Array, + inspections: Object, + createdAt: Date, + createdBy: { + type: mongoose.Types.ObjectId, + ref: "user", + }, + newProcessingStatus: Array, + newPayment: Array, + newConditions: Array, + professionals: Object, + recordId: String, + relatedRecords: Object, + accelaStatus: String, + openDate: Date, + lastUpdateDate: Date, + statusUpdated: Date, + issuedDate: Date, + communityName: String, + lot: String, + block: String, + jobNumber: String, + startDate: Date, + history: Array, +}).index({ tenantId: 1, permitNumber: 1 }, { unique: true }); + +const permitModel = mongoose.model("permit", permitSchema); +const processedModel = mongoose.model("processed", permitSchema, "processed"); + +const analyticsModel = mongoose.model( + "analytics", + new mongoose.Schema( + { + tenantId: String, + }, + { strict: false } + ), + "analytics" +); + +let combinedPermits = []; + +function dateDiff(date1, date2, unit = "days") { + const d1 = new Date(date1); + const d2 = new Date(date2); + + const diffMs = Math.abs(d2 - d1); + + const conversions = { + milliseconds: diffMs, + seconds: diffMs / 1000, + minutes: diffMs / (1000 * 60), + hours: diffMs / (1000 * 60 * 60), + days: diffMs / (1000 * 60 * 60 * 24), + weeks: diffMs / (1000 * 60 * 60 * 24 * 7), + months: diffMs / (1000 * 60 * 60 * 24 * 30.44), + years: diffMs / (1000 * 60 * 60 * 24 * 365.25), + }; + + if (conversions.hasOwnProperty(unit)) { + return conversions[unit]; + } else { + throw new Error( + `Invalid unit: ${unit}. Use one of: ${Object.keys(conversions).join( + ", " + )}` + ); + } +} + +async function getSubmissionMetrics() { + const groupByCountySubmissions = {}; + const groupByClientSubmissions = {}; + + for (const permit of combinedPermits) { + try { + const dateStr = permit.openDate.toISOString().split("T")[0]; + + if (!groupByClientSubmissions[dateStr]) + groupByClientSubmissions[dateStr] = {}; + + if (!groupByCountySubmissions[dateStr]) + groupByCountySubmissions[dateStr] = {}; + + if (!groupByClientSubmissions[dateStr][permit.clientData?.name]) + groupByClientSubmissions[dateStr][permit.clientData?.name] = 0; + + groupByClientSubmissions[dateStr][permit.clientData?.name]++; + + if (!groupByCountySubmissions[dateStr][permit.county?.name]) + groupByCountySubmissions[dateStr][permit.county?.name] = 0; + + groupByCountySubmissions[dateStr][permit.county?.name]++; + } catch (err) { + console.log(err); + } + } + + return { + groupByClientSubmissions, + groupByCountySubmissions, + }; +} + +function calculateAverages(arr) { + if (!Array.isArray(arr)) { + throw new TypeError("Input must be an array"); + } + + if (arr.length === 0) { + return { + min: null, + max: null, + median: null, + average: null, + sum: null, + count: 0, + }; + } + + // Filter out non-numeric values and convert valid numbers + const cleanArr = arr.map(Number).filter((n) => !isNaN(n)); + + if (cleanArr.length === 0) { + return { + min: null, + max: null, + median: null, + average: null, + sum: null, + count: 0, + }; + } + + // Sort array for median calculation (slice to avoid mutating original) + const sorted = [...cleanArr].sort((a, b) => a - b); + const average = sum / count; + + // Calculate median + const middle = Math.floor(count / 2); + let median; + if (count % 2 === 0) { + median = (sorted[middle - 1] + sorted[middle]) / 2; + } else { + median = sorted[middle]; + } + + return { + min: sorted[0], + max: sorted[count - 1], + median, + average, + }; +} + +async function getApprovalMetrics() { + const approvedPermits = combinedPermits.filter( + (item) => item.accelaStatus === "APPROVED" + ); + + const approvalDurationClient = {}; + const approvalDurationCounty = {}; + + for (const permit of approvedPermits) { + const diff = dateDiff(permit.issuedDate, permit.openDate); + + if (!approvalDurationClient[permit.clientData?.name]) + approvalDurationClient[permit.clientData?.name] = []; + approvalDurationClient[permit.clientData?.name]?.push(diff); + + if (!approvalDurationCounty[permit.county?.name]) + approvalDurationClient[permit.county?.name] = []; + approvalDurationCounty[permit.county?.name]?.push(diff); + } + + for (const client in approvalDurationClient) { + approvalDurationClient[client] = calculateAverages( + approvalDurationClient[client] + ); + } + + for (const county in approvalDurationCounty) { + approvalDurationCounty[county] = calculateAverages( + approvalDurationCounty[county] + ); + } + + return { + totalApproved: approvedPermits.length, + approvalDurationClient, + approvalDurationCounty, + }; +} + +(async () => { + await mongoose.connect(process.env.DB_URI); + + const startDate = new Date(Date.now() - 3600 * 24 * 30 * 1000); + const endDate = new Date(); + + const recentPermits = await permitModel + .find({ + openDate: { $gte: startDate, $lte: endDate }, + }) + .select( + "clientData county openDate issuedDate accelaStatus status manualStatus cleanStatus" + ); + + const recentProcessed = await processedModel + .find({ + openDate: { $gte: startDate, $lte: endDate }, + }) + .select( + "clientData county openDate issuedDate accelaStatus status manualStatus cleanStatus" + ); + + combinedPermits = [...recentPermits, ...recentProcessed]; + + const submissionsByOrg = await getSubmissionMetrics(); + const approvedCount = await getApprovalMetrics(); + + const analytics = { + ...submissionsByOrg, + ...approvedCount, + }; + + await analyticsModel.findOneAndUpdate( + { tenantId: "arf4w59nzduytv7" }, + analytics, + { + upsert: true, + } + ); + + await mongoose.connection.close(); +})().catch((err) => console.log(err)); diff --git a/scripts/cron.sh b/scripts/cron.sh index bd0f91a..1030058 100644 --- a/scripts/cron.sh +++ b/scripts/cron.sh @@ -16,3 +16,4 @@ cd "$WORKDIR" || { echo "Failed to change directory to $WORKDIR"; exit 1; } node --env-file=.env "$CRON_DIR/archive.js" node --env-file=.env "$CRON_DIR/configUpdate.js" +node --env-file=.env "$CRON_DIR/analytics.js" \ No newline at end of file diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts new file mode 100644 index 0000000..79da2dc --- /dev/null +++ b/src/analytics/analytics.controller.ts @@ -0,0 +1,15 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { getAnalytics } from "./analytics.service"; + +export async function getAnalyticsHandler( + req: FastifyRequest, + res: FastifyReply +) { + try { + const analytics = await getAnalytics(req.user); + if (!analytics) return res.code(404).send({ error: "resource not found" }); + return res.code(200).send(analytics); + } catch (err) { + return err; + } +} diff --git a/src/analytics/analytics.routes.ts b/src/analytics/analytics.routes.ts new file mode 100644 index 0000000..c1b78d9 --- /dev/null +++ b/src/analytics/analytics.routes.ts @@ -0,0 +1,13 @@ +import { FastifyInstance } from "fastify"; +import { getAnalyticsHandler } from "./analytics.controller"; + +export async function analyticsRoutes(fastify: FastifyInstance) { + fastify.get( + "", + { + config: { requiredClaims: ["analytics:read"] }, + preHandler: [fastify.authorize], + }, + getAnalyticsHandler + ); +} diff --git a/src/analytics/analytics.schema.ts b/src/analytics/analytics.schema.ts new file mode 100644 index 0000000..3fec79c --- /dev/null +++ b/src/analytics/analytics.schema.ts @@ -0,0 +1,12 @@ +import mongoose from "mongoose"; + +export const analyticsModel = mongoose.model( + "analytics", + new mongoose.Schema( + { + tenantId: String, + }, + { strict: false } + ), + "analytics" +); diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts new file mode 100644 index 0000000..711f505 --- /dev/null +++ b/src/analytics/analytics.service.ts @@ -0,0 +1,6 @@ +import { AuthenticatedUser } from "../auth"; +import { analyticsModel } from "./analytics.schema"; + +export async function getAnalytics(user: AuthenticatedUser) { + return await analyticsModel.findOne({ tenantId: user.tenantId }); +} diff --git a/src/routes.ts b/src/routes.ts index d3eea02..eb001c0 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -16,6 +16,7 @@ import { processedRoutes } from "./processed/processed.route"; import { ctaskRoutes } from "./ctask/ctask.route"; import { paymentRoutes } from "./payments/payment.route"; import { alertRoutes } from "./alert/alert.route"; +import { analyticsRoutes } from "./analytics/analytics.routes"; export default async function routes(fastify: FastifyInstance) { fastify.addHook("preHandler", authHandler); @@ -34,5 +35,6 @@ export default async function routes(fastify: FastifyInstance) { fastify.register(processedRoutes, { prefix: "/processed" }); fastify.register(paymentRoutes, { prefix: "/payments" }); fastify.register(alertRoutes, { prefix: "/alerts" }); + fastify.register(analyticsRoutes, { prefix: "/analytics" }); fastify.register(realTimeRoutes); } diff --git a/src/utils/claims.ts b/src/utils/claims.ts index 3184eab..e31a188 100644 --- a/src/utils/claims.ts +++ b/src/utils/claims.ts @@ -37,4 +37,5 @@ export type Claim = | "note:delete" | "payment:read" | "alert:read" - | "alert:write"; + | "alert:write" + | "analytics:read"; diff --git a/src/utils/roles.ts b/src/utils/roles.ts index eadbe7a..a3ade8f 100644 --- a/src/utils/roles.ts +++ b/src/utils/roles.ts @@ -39,6 +39,7 @@ export const rules: Record< "payment:read", "alert:read", "alert:write", + "analytics:read", ], hiddenFields: { orgs: ["__v"], @@ -79,6 +80,7 @@ export const rules: Record< "payment:read", "alert:read", "alert:write", + "analytics:read", ], hiddenFields: { orgs: ["__v"], @@ -111,6 +113,7 @@ export const rules: Record< "note:delete", "alert:read", "alert:write", + "analytics:read", ], hiddenFields: { orgs: ["__v"],