import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { MatrixClient } from './matrix.client'; /** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat * (one org, one unit), so this is the entire scope of what gets provisioned. */ const ORG_KEY = 'edr_freight'; const UNIT_KEY = 'edr_freight_app'; const SPACE_ALIAS = 'edr-freight'; const GENERAL_ALIAS = 'general'; interface PositionHolder { positionKey: string; positionName: string; userId: string; userName: string; } export interface ReconcileResult { rooms: number; joined: number; kicked: number; deactivated: number; } /** * Keeps Matrix rooms and their membership in sync with IAM's unit/position * tree. There is no local hook on "employee position changed" — IAM writes * happen inside the vendored @tria-plc/iamapi-common package — so this is a * reconcile loop, not an event handler: nightly, plus on-demand via * POST /chat/sync. * * Room identity is a deterministic alias (#dept-), not a stored * mapping table — resolved via the directory API, created on first miss. * Room membership is diffed against Matrix's own joined_members, not a local * snapshot — so a user removed from IAM disappears from chat on the very * next reconcile, with no extra state for this service to own. */ @Injectable() export class ChatProvisioningService { private readonly logger = new Logger(ChatProvisioningService.name); constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly matrix: MatrixClient, ) {} @Cron(CronExpression.EVERY_DAY_AT_3AM, { name: 'chat-provisioning-reconcile' }) async scheduledReconcile(): Promise { try { const result = await this.reconcile(); this.logger.log( `Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` + `${result.kicked} kicked, ${result.deactivated} deactivated`, ); } catch (err) { // Never throws into the scheduler — chat provisioning must not be able // to take down anything else on the cron registry. this.logger.error( `Chat reconcile failed: ${(err as Error).message}`, (err as Error).stack, ); } } private async currentHolders(): Promise { return this.dataSource.query( `SELECT p.key AS "positionKey", COALESCE(p.name->>'en', p.key) AS "positionName", e.user_id AS "userId", COALESCE(iu.name->>'en', iu.username, iu.email) AS "userName" FROM iam.employee_positions ep JOIN iam.employees e ON e.id = ep.employee_id JOIN iam.positions p ON p.id = ep.position_id JOIN iam.units u ON u.id = p.unit_id JOIN iam.organizations o ON o.id = u.organization_id JOIN iam.users iu ON iu.id = e.user_id WHERE ep.is_current = true AND e.is_current = true AND o.key = $1 AND u.key = $2`, [ORG_KEY, UNIT_KEY], ); } /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ private async syncMembership( roomId: string, desiredUserIds: Set, botMxid: string, ): Promise<{ joined: number; kicked: string[] }> { const current = await this.matrix.joinedMembers(roomId); const currentSet = new Set(current.filter((id) => id !== botMxid)); let joined = 0; for (const userId of desiredUserIds) { if (!currentSet.has(userId)) { await this.matrix.forceJoin(roomId, userId); joined += 1; } } const kicked: string[] = []; for (const userId of currentSet) { if (!desiredUserIds.has(userId)) { await this.matrix.kick(roomId, userId, 'No longer assigned to this room'); kicked.push(userId); } } return { joined, kicked }; } async reconcile(): Promise { const holders = await this.currentHolders(); const botMxid = await this.matrix.whoami(); const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { isSpace: true, }); const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { parentSpaceId: spaceId, }); const allUserIds = new Set(holders.map((h) => this.matrix.mxid(h.userId))); // Accounts are otherwise only created lazily on first JWT login (see // ChatSsoService) — force-joining someone who has never clicked "Chat" // yet 404s ("User not found") without this. const seenUserIds = new Set(); for (const h of holders) { const mxid = this.matrix.mxid(h.userId); if (seenUserIds.has(mxid)) continue; seenUserIds.add(mxid); await this.matrix.ensureUser(mxid, h.userName); } let rooms = 2; // space + general let joined = 0; let kicked = 0; // A user kicked from anything while holding zero current positions // anywhere in the unit (allUserIds spans every position) is a full // leaver, not just moved between positions — deactivate their account. const kickedUserIds = new Set(); const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid); joined += generalDiff.joined; kicked += generalDiff.kicked.length; generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); const byPosition = new Map }>(); for (const h of holders) { const entry = byPosition.get(h.positionKey) ?? { name: h.positionName, userIds: new Set(), }; entry.userIds.add(this.matrix.mxid(h.userId)); byPosition.set(h.positionKey, entry); } for (const [positionKey, { name, userIds }] of byPosition) { const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, { parentSpaceId: spaceId, }); rooms += 1; const diff = await this.syncMembership(roomId, userIds, botMxid); joined += diff.joined; kicked += diff.kicked.length; diff.kicked.forEach((uid) => kickedUserIds.add(uid)); } let deactivated = 0; for (const userId of kickedUserIds) { if (allUserIds.has(userId)) continue; // moved position, still current elsewhere try { await this.matrix.deactivateUser(userId); deactivated += 1; } catch (err) { this.logger.warn( `Failed to deactivate departed user ${userId}: ${(err as Error).message}`, ); } } return { rooms, joined, kicked, deactivated }; } }