mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
238 lines
8.4 KiB
TypeScript
238 lines
8.4 KiB
TypeScript
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-<positionKey>), 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<void> {
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Every current holder in the unit, or just one person's rows when `userId` is given. */
|
|
private async currentHolders(userId?: string): Promise<PositionHolder[]> {
|
|
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
|
|
${userId ? 'AND e.user_id = $3' : ''}`,
|
|
userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Put one person in their rooms right now.
|
|
*
|
|
* {@link reconcile} is nightly, so without this a new employee's first
|
|
* sign-in shows an empty client until 3AM — the SSO handoff creates their
|
|
* account but joins them to nothing. Called on every /chat/sso, so it is
|
|
* scoped to the one user (a full reconcile per click would be a room-count
|
|
* multiple of Matrix calls) and every step is get-or-create.
|
|
*
|
|
* Someone holding no current position in the unit joins nothing, by the same
|
|
* rule the reconcile uses — chat membership follows the org tree.
|
|
*/
|
|
async joinUserRooms(userId: string, displayName: string): Promise<number> {
|
|
const positions = await this.currentHolders(userId);
|
|
if (positions.length === 0) return 0;
|
|
|
|
const mxid = this.matrix.mxidFor(userId, displayName);
|
|
// The JWT login auto-registers too, but that happens after this runs and
|
|
// the admin join API 404s on an account that does not exist yet.
|
|
await this.matrix.ensureUser(mxid, displayName);
|
|
|
|
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
|
|
isSpace: true,
|
|
});
|
|
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
|
|
parentSpaceId: spaceId,
|
|
});
|
|
await this.matrix.ensureJoined(generalRoomId, mxid);
|
|
|
|
for (const position of positions) {
|
|
const roomId = await this.matrix.ensureRoom(
|
|
`dept-${position.positionKey}`,
|
|
position.positionName,
|
|
{ parentSpaceId: spaceId },
|
|
);
|
|
await this.matrix.ensureJoined(roomId, mxid);
|
|
}
|
|
|
|
return positions.length + 1;
|
|
}
|
|
|
|
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
|
|
private async syncMembership(
|
|
roomId: string,
|
|
desiredUserIds: Set<string>,
|
|
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.ensureJoined(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<ReconcileResult> {
|
|
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.mxidFor(h.userId, h.userName)),
|
|
);
|
|
|
|
// 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<string>();
|
|
for (const h of holders) {
|
|
const mxid = this.matrix.mxidFor(h.userId, h.userName);
|
|
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<string>();
|
|
|
|
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<string, { name: string; userIds: Set<string> }>();
|
|
for (const h of holders) {
|
|
const entry = byPosition.get(h.positionKey) ?? {
|
|
name: h.positionName,
|
|
userIds: new Set<string>(),
|
|
};
|
|
entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName));
|
|
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 };
|
|
}
|
|
}
|