import { Inject, Injectable, BadRequestException, NotFoundException } from "@nestjs/common"; import { eq, and, desc, sql } from "drizzle-orm"; import { schema, type Database } from "@courtmitra/db"; import { type Swarm, type SwarmWithEntity, type SwarmMember, type SwarmDossier, type SwarmOptInInput, type EntityResolutionInput, } from "@courtmitra/contracts"; import { canOptIn, computeContribution } from "@courtmitra/domain"; import { DB } from "../db/db.module.js"; import { WorkflowService } from "../workflow/inngest.module.js"; @Injectable() export class SwarmService { constructor( @Inject(DB) private readonly db: Database, @Inject(WorkflowService) private readonly workflow: WorkflowService, ) {} // ── Entity resolution ────────────────────────────────────────────────── /** Find or create an entity by normalized name + domain. */ async resolveEntity(input: EntityResolutionInput): Promise { const normalizedName = input.entityName.trim().toLowerCase(); const [existing] = await this.db .select() .from(schema.entities) .where( and( eq(schema.entities.normalizedName, normalizedName), eq(schema.entities.domain, input.domain), ), ) .limit(1); if (existing) return existing.id; const [created] = await this.db .insert(schema.entities) .values({ normalizedName, aliases: [input.entityName], domain: input.domain, identifiers: input.identifiers ?? null, }) .returning(); return created!.id; } // ── Swarm clustering ─────────────────────────────────────────────────── /** Find or create a swarm for an entity + domain. */ async findOrCreateSwarm(entityId: string, issueDomain: string): Promise { const [existing] = await this.db .select() .from(schema.swarms) .where( and( eq(schema.swarms.entityId, entityId), eq(schema.swarms.issueDomain, issueDomain), eq(schema.swarms.status, "active"), ), ) .limit(1); if (existing) return existing.id; const [created] = await this.db .insert(schema.swarms) .values({ entityId, issueDomain, caseCount: 0, status: "active", }) .returning(); return created!.id; } // ── Opt-in ────────────────────────────────────────────────────────────── /** Opt a mission into a swarm with consent. */ async optIn(missionId: string, swarmId: string, input: SwarmOptInInput): Promise { // Check mission exists and is eligible const [mission] = await this.db .select({ id: schema.missions.id, state: schema.missions.state }) .from(schema.missions) .where(eq(schema.missions.id, missionId)) .limit(1); if (!mission) throw new NotFoundException(`Mission not found: ${missionId}`); // Check swarm exists and is active const [swarm] = await this.db .select({ id: schema.swarms.id, status: schema.swarms.status }) .from(schema.swarms) .where(eq(schema.swarms.id, swarmId)) .limit(1); if (!swarm) throw new NotFoundException(`Swarm not found: ${swarmId}`); // Check if already a member const [existingMember] = await this.db .select() .from(schema.swarmMembers) .where( and( eq(schema.swarmMembers.swarmId, swarmId), eq(schema.swarmMembers.missionId, missionId), ), ) .limit(1); const check = canOptIn(mission, !!existingMember, swarm.status); if (!check.allowed) { throw new BadRequestException({ message: check.reason, code: check.code }); } // Get mission facts for contribution const [missionFull] = await this.db .select({ facts: schema.missions.facts }) .from(schema.missions) .where(eq(schema.missions.id, missionId)) .limit(1); const contribution = computeContribution( (missionFull?.facts as Record) ?? null, input.shareFields, input.excludePii, ); // Create membership const [member] = await this.db .insert(schema.swarmMembers) .values({ swarmId, missionId, consentGrantId: input.consentGrantId, contribution, }) .returning(); if (!member) throw new Error("Failed to create swarm membership"); // Increment case count await this.db .update(schema.swarms) .set({ caseCount: sql`${schema.swarms.caseCount} + 1`, updatedAt: new Date(), }) .where(eq(schema.swarms.id, swarmId)); // Emit event for re-clustering await this.workflow.send("swarm.member_joined", { swarmId, missionId, }); return { swarmId: member.swarmId, missionId: member.missionId, consentGrantId: member.consentGrantId, contribution: (member.contribution as Record) ?? null, joinedAt: member.joinedAt.toISOString(), }; } // ── Queries ───────────────────────────────────────────────────────────── /** List swarms with optional filters. */ async list(query: { domain?: string; entityId?: string; status?: string }): Promise { const conditions = []; if (query.domain) conditions.push(eq(schema.swarms.issueDomain, query.domain)); if (query.entityId) conditions.push(eq(schema.swarms.entityId, query.entityId)); if (query.status) conditions.push(eq(schema.swarms.status, query.status)); const where = conditions.length > 0 ? and(...conditions) : undefined; const rows = await this.db .select({ swarm: schema.swarms, entity: schema.entities, }) .from(schema.swarms) .innerJoin(schema.entities, eq(schema.swarms.entityId, schema.entities.id)) .where(where) .orderBy(desc(schema.swarms.caseCount)); return rows.map((r) => ({ id: r.swarm.id, entityId: r.swarm.entityId, issueDomain: r.swarm.issueDomain as Swarm["issueDomain"], patternSignature: (r.swarm.patternSignature as Record) ?? null, caseCount: r.swarm.caseCount, totalAmount: r.swarm.totalAmount ? Number(r.swarm.totalAmount) : null, status: r.swarm.status as Swarm["status"], createdAt: r.swarm.createdAt.toISOString(), updatedAt: r.swarm.updatedAt.toISOString(), entity: { id: r.entity.id, normalizedName: r.entity.normalizedName, aliases: r.entity.aliases ?? [], domain: r.entity.domain as SwarmWithEntity["entity"]["domain"], identifiers: (r.entity.identifiers as Record) ?? null, createdAt: r.entity.createdAt.toISOString(), }, })); } /** Get a single swarm by ID. */ async get(swarmId: string): Promise { const [row] = await this.db .select({ swarm: schema.swarms, entity: schema.entities, }) .from(schema.swarms) .innerJoin(schema.entities, eq(schema.swarms.entityId, schema.entities.id)) .where(eq(schema.swarms.id, swarmId)) .limit(1); if (!row) throw new NotFoundException(`Swarm not found: ${swarmId}`); return { id: row.swarm.id, entityId: row.swarm.entityId, issueDomain: row.swarm.issueDomain as Swarm["issueDomain"], patternSignature: (row.swarm.patternSignature as Record) ?? null, caseCount: row.swarm.caseCount, totalAmount: row.swarm.totalAmount ? Number(row.swarm.totalAmount) : null, status: row.swarm.status as Swarm["status"], createdAt: row.swarm.createdAt.toISOString(), updatedAt: row.swarm.updatedAt.toISOString(), entity: { id: row.entity.id, normalizedName: row.entity.normalizedName, aliases: row.entity.aliases ?? [], domain: row.entity.domain as SwarmWithEntity["entity"]["domain"], identifiers: (row.entity.identifiers as Record) ?? null, createdAt: row.entity.createdAt.toISOString(), }, }; } /** Get members of a swarm. */ async getMembers(swarmId: string): Promise { const rows = await this.db .select() .from(schema.swarmMembers) .where(eq(schema.swarmMembers.swarmId, swarmId)) .orderBy(desc(schema.swarmMembers.joinedAt)); return rows.map((r) => ({ swarmId: r.swarmId, missionId: r.missionId, consentGrantId: r.consentGrantId, contribution: (r.contribution as Record) ?? null, joinedAt: r.joinedAt.toISOString(), })); } /** Generate a public dossier for a swarm. */ async getDossier(swarmId: string): Promise { const swarm = await this.get(swarmId); // Get all contributions from members const members = await this.getMembers(swarmId); // Aggregate amounts const amounts = members .map((m) => (m.contribution as Record)?.["amount"]) .filter((a): a is number => typeof a === "number"); const totalAmount = amounts.reduce((sum, a) => sum + a, 0); // Build amount buckets const buckets = [ { range: "0-5,000", count: 0 }, { range: "5,000-25,000", count: 0 }, { range: "25,000-1,00,000", count: 0 }, { range: "1,00,000+", count: 0 }, ]; for (const a of amounts) { if (a < 5000) buckets[0]!.count++; else if (a < 25000) buckets[1]!.count++; else if (a < 100000) buckets[2]!.count++; else buckets[3]!.count++; } // Common evidence patterns const evidenceTypes = members .map((m) => (m.contribution as Record)?.["evidenceType"]) .filter((e): e is string => typeof e === "string"); const evidenceCounts = new Map(); for (const e of evidenceTypes) { evidenceCounts.set(e, (evidenceCounts.get(e) ?? 0) + 1); } const commonEvidencePatterns = [...evidenceCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([type]) => type); // Common routes const routes = members .map((m) => (m.contribution as Record)?.["route"]) .filter((r): r is string => typeof r === "string"); const routeCounts = new Map(); for (const r of routes) { routeCounts.set(r, (routeCounts.get(r) ?? 0) + 1); } const commonRoutes = [...routeCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([route]) => route); // Timeline const dates = members .map((m) => (m.contribution as Record)?.["date"]) .filter((d): d is string => typeof d === "string") .sort(); const monthlyCounts = new Map(); for (const d of dates) { const month = d.slice(0, 7); // "YYYY-MM" monthlyCounts.set(month, (monthlyCounts.get(month) ?? 0) + 1); } // Anonymized summaries const summaries = members .map((m) => (m.contribution as Record)?.["summary"]) .filter((s): s is string => typeof s === "string") .slice(0, 10); return { swarmId, entityName: swarm.entity.normalizedName, issueDomain: swarm.issueDomain, caseCount: swarm.caseCount, totalAmount, amountBuckets: buckets, commonEvidencePatterns, commonRoutes, timelineShape: { earliestCase: dates[0] ?? swarm.createdAt, latestCase: dates[dates.length - 1] ?? swarm.createdAt, monthlyCounts: [...monthlyCounts.entries()].map(([month, count]) => ({ month, count })), }, anonymizedSummaries: summaries, generatedAt: new Date().toISOString(), }; } }