import { Logger } from '@nestjs/common'; import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { EventRecorder } from '@task-stream/api/shared/application'; import { UserDeactivatedEvent } from '@task-stream/api/identity/domain'; import { SessionCache } from '../ports/session-cache.port'; import { UserRegistry } from '../ports/user-registry.port'; /** Reacts to Pireon webhooks: `user.deactivated` and `token.revoked`. */ export class HandleIdentityWebhookCommand extends Command { constructor( public readonly event: string, public readonly data: Record, ) { super(); } } @CommandHandler(HandleIdentityWebhookCommand) export class HandleIdentityWebhookHandler implements ICommandHandler { private readonly logger = new Logger(HandleIdentityWebhookHandler.name); constructor( private readonly cache: SessionCache, private readonly registry: UserRegistry, private readonly events: EventRecorder, ) {} async execute({ event, data }: HandleIdentityWebhookCommand): Promise { const userId = data['user_id'] ?? data['userId'] ?? (data['user'] as { id?: unknown } | undefined)?.id; if (userId === undefined || userId === null) { this.logger.warn(`Webhook ${event} without user id, ignored`); return; } const id = String(userId); switch (event) { case 'user.deactivated': await Promise.all([this.deactivate(id), this.cache.evictUser(id)]); break; case 'token.revoked': // Only token hashes are cached, so drop every session of that user. await this.cache.evictUser(id); break; default: this.logger.debug(`Unhandled identity webhook ${event}`); } } private async deactivate(userId: string): Promise { const user = await this.registry.findById(userId); if (!user?.isActive) return; user.deactivate(); await this.registry.upsert(user); // Stored so other contexts react (the tracker unassigns their open tickets). for (const event of user.getUncommittedEvents()) { if (event instanceof UserDeactivatedEvent) await this.events.record(`identity-user-${userId}`, event); } user.uncommit(); } }