import * as plugins from '../plugins.js'; import { Alert } from './classes.alert.js'; import { AlertRule } from './classes.alertrule.js'; import type { Reception } from './classes.reception.js'; const severityOrder: Record = { low: 1, medium: 2, high: 3, critical: 4, }; export class AlertManager { public receptionRef: Reception; public get db() { return this.receptionRef.db.smartdataDb; } public typedRouter = new plugins.typedrequest.TypedRouter(); public CAlert = plugins.smartdata.setDefaultManagerForDoc(this, Alert); public CAlertRule = plugins.smartdata.setDefaultManagerForDoc(this, AlertRule); constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'listPassportAlerts', async (requestArg) => { const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest( requestArg, { action: 'listPassportAlerts', } ); const alerts = await this.listAlertsForUser( passportDevice.data.userId, !!requestArg.includeDismissed ); return { alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })), }; } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getPassportAlertByHint', async (requestArg) => { const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest( requestArg, { action: 'getPassportAlertByHint', signedFields: [`hint_id=${requestArg.hintId}`], } ); const alert = await this.getAlertByHint(passportDevice.data.userId, requestArg.hintId); return { alert: alert ? { id: alert.id, data: alert.data } : undefined, }; } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'markPassportAlertSeen', async (requestArg) => { const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest( requestArg, { action: 'markPassportAlertSeen', signedFields: [`hint_id=${requestArg.hintId}`], } ); await this.markAlertSeen(passportDevice.data.userId, requestArg.hintId); return { success: true, }; } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'dismissPassportAlert', async (requestArg) => { const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest( requestArg, { action: 'dismissPassportAlert', signedFields: [`hint_id=${requestArg.hintId}`], } ); await this.dismissAlert(passportDevice.data.userId, requestArg.hintId); return { success: true, }; } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'upsertAlertRule', async (requestArg) => { const actorUserId = await this.verifyAlertRuleAccess( requestArg.jwt, requestArg.scope, requestArg.organizationId ); const rule = requestArg.ruleId ? await this.CAlertRule.getInstance({ id: requestArg.ruleId }) : new AlertRule(); if (!rule) { throw new plugins.typedrequest.TypedResponseError('Alert rule not found'); } rule.id = rule.id || plugins.smartunique.shortId(); rule.data = { scope: requestArg.scope, organizationId: requestArg.organizationId, eventType: requestArg.eventType, minimumSeverity: requestArg.minimumSeverity, recipientMode: requestArg.recipientMode, recipientUserIds: requestArg.recipientUserIds || [], push: requestArg.push, enabled: requestArg.enabled, createdByUserId: rule.data?.createdByUserId || actorUserId, createdAt: rule.data?.createdAt || Date.now(), updatedAt: Date.now(), }; await rule.save(); return { rule: { id: rule.id, data: rule.data, }, }; } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAlertRules', async (requestArg) => { await this.verifyAlertRuleAccess(requestArg.jwt, requestArg.scope || 'global', requestArg.organizationId); const rules = await this.CAlertRule.getInstances({}); return { rules: rules .filter((ruleArg) => { if (requestArg.scope && ruleArg.data.scope !== requestArg.scope) { return false; } if (requestArg.organizationId && ruleArg.data.organizationId !== requestArg.organizationId) { return false; } return true; }) .map((ruleArg) => ({ id: ruleArg.id, data: ruleArg.data })), }; } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteAlertRule', async (requestArg) => { const rule = await this.CAlertRule.getInstance({ id: requestArg.ruleId }); if (!rule) { throw new plugins.typedrequest.TypedResponseError('Alert rule not found'); } await this.verifyAlertRuleAccess(requestArg.jwt, rule.data.scope, rule.data.organizationId); await rule.delete(); return { success: true, }; } ) ); } private async verifyAlertRuleAccess( jwtArg: string, scopeArg: plugins.idpInterfaces.data.TAlertRuleScope, organizationIdArg?: string ) { const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg); if (!jwt) { throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); } if (scopeArg === 'global') { const user = await this.receptionRef.userManager.CUser.getInstance({ id: jwt.data.userId }); if (!user?.data?.isGlobalAdmin) { throw new plugins.typedrequest.TypedResponseError('Global admin privileges required'); } return jwt.data.userId; } if (!organizationIdArg) { throw new plugins.typedrequest.TypedResponseError('organizationId is required'); } const role = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId: jwt.data.userId, organizationId: organizationIdArg, }, }); if (!role || !role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) { throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required'); } return jwt.data.userId; } private async resolveGlobalAdminRecipients() { const users = await this.receptionRef.userManager.CUser.getInstances({}); return users.filter((userArg) => !!userArg.data.isGlobalAdmin); } private async resolveOrganizationAdminRecipients(organizationIdArg: string) { const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organizationIdArg); const adminUserIds = [...new Set( roles .filter((roleArg) => roleArg.data.roles.some((roleNameArg) => ['owner', 'admin'].includes(roleNameArg))) .map((roleArg) => roleArg.data.userId) )]; const users = await Promise.all( adminUserIds.map((userIdArg) => this.receptionRef.userManager.CUser.getInstance({ id: userIdArg })) ); return users.filter(Boolean); } private async resolveRuleRecipients(ruleArg: AlertRule) { switch (ruleArg.data.recipientMode) { case 'global_admins': return this.resolveGlobalAdminRecipients(); case 'org_admins': if (!ruleArg.data.organizationId) { return []; } return this.resolveOrganizationAdminRecipients(ruleArg.data.organizationId); case 'specific_users': if (!ruleArg.data.recipientUserIds?.length) { return []; } const users = await Promise.all( ruleArg.data.recipientUserIds.map((userIdArg) => this.receptionRef.userManager.CUser.getInstance({ id: userIdArg }) ) ); return users.filter(Boolean); } } private async getMatchingRules(optionsArg: { eventType: string; severity: plugins.idpInterfaces.data.TAlertSeverity; organizationId?: string; }) { const rules = await this.CAlertRule.getInstances({}); const matchingRules = rules.filter((ruleArg) => { if (!ruleArg.data.enabled) { return false; } if (ruleArg.data.eventType !== optionsArg.eventType) { return false; } if (ruleArg.data.scope === 'organization' && ruleArg.data.organizationId !== optionsArg.organizationId) { return false; } return severityOrder[optionsArg.severity] >= severityOrder[ruleArg.data.minimumSeverity]; }); if (matchingRules.length > 0) { return matchingRules; } if (optionsArg.eventType === 'global_admin_access') { return [this.createBuiltInRule('builtin-global-admin-access', { scope: 'global', eventType: 'global_admin_access', minimumSeverity: 'high', recipientMode: 'global_admins', })]; } if (optionsArg.eventType === 'global_app_credentials_regenerated') { return [this.createBuiltInRule('builtin-global-app-credentials-regenerated', { scope: 'global', eventType: 'global_app_credentials_regenerated', minimumSeverity: 'critical', recipientMode: 'global_admins', })]; } if (optionsArg.organizationId) { const organizationFallbackMap: Record< string, { minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity; } > = { org_app_connected: { minimumSeverity: 'medium' }, org_app_disconnected: { minimumSeverity: 'medium' }, org_invitation_created: { minimumSeverity: 'low' }, org_invitation_resent: { minimumSeverity: 'low' }, org_updated: { minimumSeverity: 'high' }, org_deleted: { minimumSeverity: 'critical' }, org_role_definition_updated: { minimumSeverity: 'medium' }, org_role_definition_deleted: { minimumSeverity: 'high' }, org_app_role_mappings_updated: { minimumSeverity: 'medium' }, org_member_removed: { minimumSeverity: 'high' }, org_member_roles_updated: { minimumSeverity: 'high' }, org_ownership_transferred: { minimumSeverity: 'critical' }, }; const fallbackConfig = organizationFallbackMap[optionsArg.eventType]; if (fallbackConfig) { return [this.createBuiltInRule(`builtin-${optionsArg.eventType}`, { scope: 'organization', organizationId: optionsArg.organizationId, eventType: optionsArg.eventType, minimumSeverity: fallbackConfig.minimumSeverity, recipientMode: 'org_admins', })]; } } return []; } private createBuiltInRule( ruleIdArg: string, optionsArg: { scope: plugins.idpInterfaces.data.TAlertRuleScope; organizationId?: string; eventType: string; minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity; recipientMode: plugins.idpInterfaces.data.TAlertRuleRecipientMode; } ) { const fallbackRule = new AlertRule(); fallbackRule.id = ruleIdArg; fallbackRule.data = { scope: optionsArg.scope, organizationId: optionsArg.organizationId, eventType: optionsArg.eventType, minimumSeverity: optionsArg.minimumSeverity, recipientMode: optionsArg.recipientMode, recipientUserIds: [], push: true, enabled: true, createdByUserId: 'system', createdAt: 0, updatedAt: 0, }; return fallbackRule; } public async createAlertsForEvent(optionsArg: { category: plugins.idpInterfaces.data.TAlertCategory; eventType: string; severity: plugins.idpInterfaces.data.TAlertSeverity; title: string; body: string; actorUserId?: string; organizationId?: string; relatedEntityId?: string; relatedEntityType?: string; }) { const matchingRules = await this.getMatchingRules(optionsArg); if (matchingRules.length === 0) { return []; } const recipientIds = new Set(); for (const rule of matchingRules) { const recipients = await this.resolveRuleRecipients(rule); for (const recipient of recipients) { recipientIds.add(recipient.id); } } const createdAlerts: Alert[] = []; for (const recipientUserId of recipientIds) { const alert = new Alert(); alert.id = plugins.smartunique.shortId(); alert.data = { recipientUserId, organizationId: optionsArg.organizationId, category: optionsArg.category, eventType: optionsArg.eventType, severity: optionsArg.severity, title: optionsArg.title, body: optionsArg.body, actorUserId: optionsArg.actorUserId, relatedEntityId: optionsArg.relatedEntityId, relatedEntityType: optionsArg.relatedEntityType, notification: { hintId: plugins.crypto.randomUUID(), status: 'pending', attemptCount: 0, createdAt: Date.now(), deliveredAt: null, seenAt: null, lastError: null, }, createdAt: Date.now(), seenAt: null, dismissedAt: null, }; await alert.save(); createdAlerts.push(alert); const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(recipientUserId); let delivered = false; for (const device of devices) { const result = await this.receptionRef.passportPushManager.deliverAlertHint(device, alert); delivered = delivered || result; } if (!delivered && devices.length === 0) { alert.data.notification = { ...alert.data.notification, status: 'failed', attemptCount: alert.data.notification.attemptCount + 1, lastError: 'Recipient has no active passport device', }; await alert.save(); } } return createdAlerts; } public async listAlertsForUser(userIdArg: string, includeDismissedArg = false) { const alerts = await this.CAlert.getInstances({ 'data.recipientUserId': userIdArg, }); return alerts .filter((alertArg) => includeDismissedArg || !alertArg.data.dismissedAt) .sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt); } public async getAlertByHint(userIdArg: string, hintIdArg: string) { return this.CAlert.getInstance({ 'data.recipientUserId': userIdArg, 'data.notification.hintId': hintIdArg, }); } public async markAlertSeen(userIdArg: string, hintIdArg: string) { const alert = await this.getAlertByHint(userIdArg, hintIdArg); if (!alert) { throw new plugins.typedrequest.TypedResponseError('Alert not found'); } alert.data.seenAt = Date.now(); alert.data.notification = { ...alert.data.notification, status: 'seen', seenAt: Date.now(), }; await alert.save(); return alert; } public async dismissAlert(userIdArg: string, hintIdArg: string) { const alert = await this.getAlertByHint(userIdArg, hintIdArg); if (!alert) { throw new plugins.typedrequest.TypedResponseError('Alert not found'); } alert.data.dismissedAt = Date.now(); if (!alert.data.seenAt) { alert.data.seenAt = Date.now(); } alert.data.notification = { ...alert.data.notification, status: 'seen', seenAt: alert.data.notification.seenAt || Date.now(), }; await alert.save(); return alert; } public async reDeliverPendingAlerts() { const alerts = await this.CAlert.getInstances({}); for (const alert of alerts) { if (alert.data.notification.status === 'sent' || alert.data.notification.status === 'seen') { continue; } const devices = await this.receptionRef.passportManager.getPassportDevicesForUser( alert.data.recipientUserId ); for (const device of devices) { await this.receptionRef.passportPushManager.deliverAlertHint(device, alert); } } } }