Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
36cc7c8
docs: auth screen updates and design decisions
mohitgauniyal Apr 21, 2026
479f5bd
feat: EAS project setup, expo-dev-client installed
mohitgauniyal Apr 21, 2026
9e50385
fix: sync root package-lock.json for EAS build
mohitgauniyal Apr 21, 2026
28d3bd9
fix: include workspace deps in root lockfile
mohitgauniyal Apr 21, 2026
c1b7dca
fix: regenerate root lockfile with all workspace deps
mohitgauniyal Apr 21, 2026
7cc1be5
fix: update react to 19.2.5 to match EAS requirements
mohitgauniyal Apr 21, 2026
a66dc91
fix: revert react to 19.1.0, fix version mismatch
mohitgauniyal Apr 22, 2026
7637396
fix: align react version with react-native requirement
mohitgauniyal Apr 22, 2026
61fb849
fix: update react to 19.2.5 to match react-native requirement
mohitgauniyal Apr 22, 2026
430e0d4
fix: pin react and react-dom to 19.1.0 to resolve EAS build conflict
mohitgauniyal Apr 22, 2026
cdb250e
fix: force react and react-dom to 19.1.0 via root overrides for EAS b…
mohitgauniyal Apr 22, 2026
c314835
docs: react version pinning decision for EAS monorepo
mohitgauniyal Apr 22, 2026
2c47940
fix: remove __DEV__ guard from notification token registration
mohitgauniyal Apr 22, 2026
c93832c
fix: pass projectId to getExpoPushTokenAsync for EAS SDK 53+
mohitgauniyal Apr 22, 2026
68f40de
feat: Firebase Android config, fix notification token registration
mohitgauniyal Apr 22, 2026
7b34371
fix:use EAS secrect for google_services.json
mohitgauniyal Apr 22, 2026
2e20d1e
feat: event-driven notification system
mohitgauniyal Apr 22, 2026
5612466
fix: remove old notifications utility, add expo-server-sdk dependency
mohitgauniyal Apr 22, 2026
931b85b
config: migrate app.json to app.config.js with dynamic GOOGLE_SERVICE…
mohitgauniyal Apr 22, 2026
6cee67c
feat: add navigation ref, deep link map, and notification handlers
mohitgauniyal Apr 22, 2026
b18a8b2
feat: wire up notification tap handler and navigation ref in App
mohitgauniyal Apr 22, 2026
714c3c8
Merge pull request #59 from inspirebyte-tech/feature/notifications
mohitgauniyal Apr 23, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ yarn-error.log*
.DS_Store
*.pem
.claude/
apps/mobile/eas.json
apps/mobile/google-services.json
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