Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/api/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import type { Config } from 'jest'
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
transformIgnorePatterns: [
'node_modules/(?!(expo-server-sdk)/)'
],
moduleNameMapper: {
'expo-server-sdk': '<rootDir>/tests/__mocks__/expo-server-sdk.ts'
},
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"dependencies": {
"@prisma/client": "^5.22.0",
"cloudinary": "^2.9.0",
"expo-server-sdk": "^6.1.0",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"prisma": "^5.22.0"
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import unitsRouter from './routes/units'
import { enforceTenantContext } from './middleware/tenantContext'
import { errorHandler } from './middleware/error'
import { apiRateLimit } from './middleware/rateLimit'
import { initNotificationDispatcher } from './notifications/dispatcher'

const app = express()

Expand All @@ -28,6 +29,9 @@ app.use('/api/auth', deviceTokensRouter)
app.use('/api/societies', complaintsRouter)
app.use('/api/societies', unitsRouter)

// Initialize notification dispatcher
initNotificationDispatcher()

app.use(errorHandler)

export default app
32 changes: 32 additions & 0 deletions apps/api/src/events/emitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import EventEmitter from 'events'

export const appEvents = new EventEmitter()

// All application events in one place.
// Adding a new event = add one line here.
export const Events = {
// Complaints
COMPLAINT_CREATED: 'complaint.created',
COMPLAINT_RESOLVED: 'complaint.resolved',
COMPLAINT_REJECTED: 'complaint.rejected',

// Members
MEMBER_JOINED: 'member.joined',
MEMBER_DEACTIVATED: 'member.deactivated',

// Announcements (ready for when feature is built)
ANNOUNCEMENT_CREATED: 'announcement.created',

// Visitor management (ready for when feature is built)
VISITOR_AT_GATE: 'visitor.at_gate',
VISITOR_APPROVED: 'visitor.approved',
VISITOR_REJECTED: 'visitor.rejected',

// Emergency
EMERGENCY_DECLARED: 'emergency.declared',

// Units (V2)
UNIT_OWNERSHIP_CHANGED: 'unit.ownership_changed',
} as const

export type AppEvent = typeof Events[keyof typeof Events]
34 changes: 34 additions & 0 deletions apps/api/src/notifications/dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { appEvents, Events } from '../events/emitter'
import { notificationRules } from './rules'
import { sendPushToUsers } from '../utils/expoPush'

// ─────────────────────────────────────────────
// Dispatcher — never changes
// Listens to all events and processes them
// using the rules defined in rules.ts
// ─────────────────────────────────────────────
export const initNotificationDispatcher = (): void => {
Object.entries(notificationRules).forEach(([event, rule]) => {
if (!rule) return

appEvents.on(event, async (data: any) => {
try {
const userIds = await rule.recipients(data)

if (!userIds.length) return

const payload = rule.payload(data)

await sendPushToUsers(userIds, payload)
} catch (error) {
// Never crash the main request flow
// Notification failure is non-fatal
console.error(`Notification dispatch failed for ${event}:`, error)
}
})
})

console.log(
`Notification dispatcher initialized with ${Object.keys(notificationRules).length} rules`
)
}
122 changes: 122 additions & 0 deletions apps/api/src/notifications/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Events } from '../events/emitter'
import { PushPayload, getUserIdsByRole, getAllUserIds } from '../utils/expoPush'
import { prisma } from '../lib/prisma'

// ─────────────────────────────────────────────
// Rule definition type
// Each rule defines:
// recipients — who gets this notification
// payload — what they receive
// ─────────────────────────────────────────────
interface NotificationRule {
recipients: (data: any) => Promise<string[]>
payload: (data: any) => PushPayload
}

// ─────────────────────────────────────────────
// THE RULES FILE
// This is the only file you touch to:
// - Change who gets a notification
// - Change the message content
// - Add a new notification type
// - Disable a notification
// ─────────────────────────────────────────────
export const notificationRules: Partial<Record<string, NotificationRule>> = {

// ─────────────────────────────────────────────
// COMPLAINTS
// ─────────────────────────────────────────────
[Events.COMPLAINT_CREATED]: {
recipients: async (d) =>
getUserIdsByRole(d.orgId, ['Builder', 'Admin']),
payload: (d) => ({
title: 'New Complaint',
body: `${d.raisedBy} raised: ${d.title}`,
data: {
screen: 'ComplaintDetail',
complaintId: d.complaintId,
orgId: d.orgId,
}
})
},

[Events.COMPLAINT_RESOLVED]: {
recipients: async (d) => [d.raisedByUserId],
payload: (d) => ({
title: 'Complaint Resolved',
body: `Your complaint "${d.title}" has been resolved`,
data: {
screen: 'ComplaintDetail',
complaintId: d.complaintId,
orgId: d.orgId,
}
})
},

[Events.COMPLAINT_REJECTED]: {
recipients: async (d) => [d.raisedByUserId],
payload: (d) => ({
title: 'Complaint Update',
body: `Your complaint "${d.title}" could not be resolved`,
data: {
screen: 'ComplaintDetail',
complaintId: d.complaintId,
orgId: d.orgId,
}
})
},

// ─────────────────────────────────────────────
// MEMBERS
// ─────────────────────────────────────────────
[Events.MEMBER_JOINED]: {
recipients: async (d) =>
getUserIdsByRole(d.orgId, ['Builder', 'Admin']),
payload: (d) => ({
title: 'New Member',
body: `${d.name} has joined ${d.societyName}`,
data: {
screen: 'MemberDetail',
memberId: d.memberId,
orgId: d.orgId,
}
})
},

// ─────────────────────────────────────────────
// ANNOUNCEMENTS
// Ready for when announcements feature is built
// ─────────────────────────────────────────────
[Events.ANNOUNCEMENT_CREATED]: {
recipients: async (d) =>
getAllUserIds(d.orgId, d.createdByUserId),
payload: (d) => ({
title: d.societyName,
body: d.title,
priority: 'high' as const,
data: {
screen: 'Announcements',
orgId: d.orgId,
}
})
},

// ─────────────────────────────────────────────
// EMERGENCY
// High priority — goes to everyone
// ─────────────────────────────────────────────
[Events.EMERGENCY_DECLARED]: {
recipients: async (d) =>
getAllUserIds(d.orgId),
payload: (d) => ({
title: '🚨 Emergency Alert',
body: d.message,
priority: 'high' as const,
data: {
screen: 'Dashboard',
orgId: d.orgId,
}
})
},

}
76 changes: 26 additions & 50 deletions apps/api/src/routes/complaints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { requirePermission } from '../middleware/permission'
import { enforceTenantContext } from '../middleware/tenantContext'
import { sendSuccess, sendError, sendNotFound } from '../utils/response'
import { uploadMultipleImages } from '../utils/cloudinary'
import { sendPushNotification } from '../utils/notifications'
import { appEvents, Events } from '../events/emitter'

const router = Router()

Expand Down Expand Up @@ -81,30 +81,16 @@ router.post(
})

// Notify all admins and builders
const adminMembers = await prisma.membership.findMany({
where: {
orgId,
isActive: true,
role: {
name: { in: ['Builder', 'Admin'] }
}
},
select: { userId: true }
})

const raiser = await prisma.person.findFirst({
where: { userId: req.user!.userId }
})

const raiserName = raiser?.fullName || 'A resident'

for (const member of adminMembers) {
await sendPushNotification(
member.userId,
'New Complaint',
`${raiserName}: ${title}`
)
}
appEvents.emit(Events.COMPLAINT_CREATED, {
orgId,
complaintId: complaint.id,
title: complaint.title,
raisedBy: raiser?.fullName || 'A resident',
raisedByUserId: req.user!.userId,
})

return sendSuccess(res, {
id: complaint.id,
Expand Down Expand Up @@ -363,39 +349,29 @@ router.patch(

// Notify complainant if admin resolved/rejected
if (userId !== complaint.raisedBy) {
const notifTitle = status === 'RESOLVED'
? 'Complaint Resolved'
: 'Complaint Update'
const notifBody = status === 'RESOLVED'
? 'Your complaint has been resolved'
: 'Your complaint was not accepted'

await sendPushNotification(complaint.raisedBy, notifTitle, notifBody)
const event = status === 'RESOLVED'
? Events.COMPLAINT_RESOLVED
: Events.COMPLAINT_REJECTED
appEvents.emit(event, {
orgId,
complaintId: complaint.id,
title: complaint.title,
raisedByUserId: complaint.raisedBy,
})
}

// Notify admin if resident resolved own complaint
// Notify admins if resident resolved own complaint
if (userId === complaint.raisedBy && status === 'RESOLVED') {
const adminMembers = await prisma.membership.findMany({
where: {
orgId,
isActive: true,
role: { name: { in: ['Builder', 'Admin'] } }
},
select: { userId: true }
})

const raiser = await prisma.person.findFirst({
where: { userId }
})
const raiserName = raiser?.fullName ?? 'A resident'

for (const member of adminMembers) {
await sendPushNotification(
member.userId,
'Complaint Closed',
`${raiserName} marked their complaint as resolved`
)
}
appEvents.emit(Events.COMPLAINT_RESOLVED, {
orgId,
complaintId: complaint.id,
title: complaint.title,
raisedBy: raiser?.fullName ?? 'A resident',
raisedByUserId: userId,
selfResolved: true,
})
}

return sendSuccess(res, {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/routes/deviceTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Router, Response } from 'express'
import { prisma } from '../lib/prisma'
import { authenticate, AuthRequest } from '../middleware/auth'
import { sendSuccess, sendError } from '../utils/response'
import { cleanupOldTokens } from '../utils/notifications'
import { cleanupOldTokens } from '../utils/expoPush'


const router = Router()

Expand Down
Loading
Loading