diff --git a/README.md b/README.md index 93cffb6..876eed0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Each answer is posted as one card per person under a **per-date thread** in your - **Vacation & skip** β€” DM `vacation`/`back` for yourself, a πŸ–οΈ *Skip today* button on every prompt; away people aren't nagged or counted as missing. Optional **Google Calendar OOO sync** marks people away automatically. - **Pre-fill** β€” "yesterday" starts as your previous "today". - **Per-participant timezones** β€” prompts go out at 09:30 *their* time, reminder nudge before the deadline, late submissions flagged. -- **Blocker tracking & escalation** β€” blockers open automatically from answers, resolve on the next clean submission, surface via `blockers`/wrap-up/digest, and **DM a configured contact** when they stay open too long. +- **Blocker tracking, collaboration & escalation** β€” blockers open automatically from answers and can be **worked as items**: tag teammates (interactive DM card with Acknowledge / Update / Resolve), updates broadcast to everyone involved + a per-blocker thread, daily nudges until acknowledged, and escalation DMs when they go stale. Untagged blockers auto-resolve on the next clean submission; tagged ones need an explicit resolve. - **Anonymous mood** (`mood anon`) β€” cards hide who felt what; the wrap-up shows the team average. - **Web dashboard** β€” token-gated, server-rendered config + history UI baked into the same container (`DASHBOARD_TOKEN`). - **Insights** β€” `trends` (participation + mood over 4 weeks), weekly digest (`digest on`), CSV export endpoint. diff --git a/docs/guide/commands.md b/docs/guide/commands.md index 47bf7ff..edc5ecb 100644 --- a/docs/guide/commands.md +++ b/docs/guide/commands.md @@ -38,9 +38,32 @@ open to everyone in the space. | --- | --- | | `status` | Configuration + today's progress (submitted / pending / away) | | `trends` | Last 4 weeks: participation % and average mood | -| `blockers` | Open blockers with their age | +| `blockers` | Open blockers with ids, age, who's tagged, and update counts | | `export` | How to download a CSV of submissions | +## Working a blocker together (everyone) + +Blockers get an id (`blockers` shows them). Tag teammates to pull them in: + +| Command | Effect | +| --- | --- | +| `blocker 12 tag @Asha @Rohit` | Tagged people get an interactive DM card (βœ‹ Acknowledge Β· πŸ“ Add update Β· βœ… Resolve) | +| `blocker 12 update ` | Post an update β€” DMed to the reporter and everyone tagged, and added to the blocker's thread in the space | +| `blocker 12 resolve` | Close it (reporter, tagged people, or admins) | + +How collaboration changes the lifecycle: + +- **Tagging makes it explicit-resolve.** Untagged blockers still auto-resolve + when the reporter's next standup is blocker-free; once a blocker is tagged + or has updates, only an explicit resolve closes it β€” someone's clean + standup can't silently end a conversation others are having. +- **Acknowledge stops the nudges.** Tagged people get one DM per day (at run + close) until they hit βœ‹ Acknowledge; posting an update acknowledges + implicitly. The reporter is notified of every ack. +- **Everything is tracked.** Updates and resolution land in a per-blocker + thread in the team space, and open blockers keep appearing in wrap-ups, + digests, and escalation pings until resolved. + ## DM self-service Anyone can DM the bot directly: @@ -60,8 +83,9 @@ Anyone can DM the bot directly: "today" answer. - **Late submissions** after the deadline still post, flagged *late*; the wrap-up isn't recalculated and late entries can no longer be edited. -- **Blockers** auto-resolve when the same person submits a blocker-free - standup on a later day. With `escalate @user` configured, the contact gets +- **Untagged blockers** auto-resolve when the same person submits a + blocker-free standup on a later day; tagged ones need an explicit + `blocker resolve`. With `escalate @user` configured, the contact gets **one** DM per blocker once it has been open past the threshold. - **Calendar OOO** (when [enabled](./configuration)): participants with an *Out of office* event in Google Calendar are automatically marked away for diff --git a/src/adapters/fake/adapter.ts b/src/adapters/fake/adapter.ts index 5561369..3ace80b 100644 --- a/src/adapters/fake/adapter.ts +++ b/src/adapters/fake/adapter.ts @@ -1,11 +1,12 @@ import type { ChatAdapter } from '../../core/adapter.js'; -import type { Run, RunSummary, Standup, Submission } from '../../core/types.js'; +import type { Blocker, Run, RunSummary, Standup, Submission } from '../../core/types.js'; interface SentDm { - kind: 'prompt' | 'reminder' | 'text'; + kind: 'prompt' | 'reminder' | 'text' | 'blockerCard'; userName: string; standupId?: number; runId?: number; + blockerId?: number; text?: string; } @@ -89,4 +90,9 @@ export class FakeAdapter implements ChatAdapter { this.dms.push({ kind: 'text', userName, text }); this.log?.(`DM text β†’ ${userName}: ${text.split('\n')[0]}`); } + + async sendBlockerCard(userName: string, standup: Standup, blocker: Blocker, note: string): Promise { + this.dms.push({ kind: 'blockerCard', userName, standupId: standup.id, blockerId: blocker.id, text: note }); + this.log?.(`DM blocker card #${blocker.id} β†’ ${userName} (${note})`); + } } diff --git a/src/adapters/gchat/adapter.ts b/src/adapters/gchat/adapter.ts index 0c33b26..5e17b9a 100644 --- a/src/adapters/gchat/adapter.ts +++ b/src/adapters/gchat/adapter.ts @@ -1,8 +1,9 @@ import { auth as chatAuth, chat, type chat_v1 } from '@googleapis/chat'; import type { ChatAdapter } from '../../core/adapter.js'; import type { Repo } from '../../db/repo.js'; -import type { Run, RunSummary, Standup, Submission } from '../../core/types.js'; +import type { Blocker, Run, RunSummary, Standup, Submission } from '../../core/types.js'; import { + blockerCard, promptMessage, reminderMessage, submissionMessage, @@ -65,6 +66,11 @@ export class GoogleChatAdapter implements ChatAdapter { await this.client.spaces.messages.create({ parent: dm, requestBody: { text } }); } + async sendBlockerCard(userName: string, standup: Standup, blocker: Blocker, note: string): Promise { + const dm = await this.ensureDmSpace(userName); + await this.client.spaces.messages.create({ parent: dm, requestBody: blockerCard(standup, blocker, note) }); + } + private async postInThread( spaceName: string, threadKey: string, diff --git a/src/adapters/gchat/cards.ts b/src/adapters/gchat/cards.ts index ed145aa..10db1c3 100644 --- a/src/adapters/gchat/cards.ts +++ b/src/adapters/gchat/cards.ts @@ -6,6 +6,7 @@ import { MOOD_EMOJI, MOOD_LABEL, MOODS, + type Blocker, type Run, type RunSummary, type Standup, @@ -15,6 +16,10 @@ import { export const OPEN_DIALOG_FN = 'openStandupDialog'; export const SUBMIT_DIALOG_FN = 'submitStandup'; export const SKIP_TODAY_FN = 'skipToday'; +export const ACK_BLOCKER_FN = 'ackBlocker'; +export const RESOLVE_BLOCKER_FN = 'resolveBlocker'; +export const OPEN_BLOCKER_UPDATE_FN = 'openBlockerUpdate'; +export const SUBMIT_BLOCKER_UPDATE_FN = 'submitBlockerUpdate'; function humanDate(isoDate: string): string { return DateTime.fromISO(isoDate).toFormat('ccc, dd LLL yyyy'); @@ -154,6 +159,91 @@ export function standupDialog( }; } +/** Interactive DM card for someone tagged on (or nudged about) a blocker. */ +export function blockerCard(standup: Standup, blocker: Blocker, note: string) { + const param = [{ key: 'blockerId', value: String(blocker.id) }]; + return { + cardsV2: [ + { + cardId: `blocker-${blocker.id}`, + card: { + header: { title: `⚠️ Blocker #${blocker.id} β€” ${standup.name}`, subtitle: note }, + sections: [ + { + widgets: [ + { + decoratedText: { + topLabel: `Reported by ${blocker.displayName} on ${blocker.openedDate}`, + text: blocker.text, + wrapText: true, + }, + }, + { + buttonList: { + buttons: [ + { text: 'βœ‹ Acknowledge', onClick: { action: { function: ACK_BLOCKER_FN, parameters: param } } }, + { + text: 'πŸ“ Add update', + onClick: { + action: { function: OPEN_BLOCKER_UPDATE_FN, interaction: 'OPEN_DIALOG', parameters: param }, + }, + }, + { text: 'βœ… Resolve', onClick: { action: { function: RESOLVE_BLOCKER_FN, parameters: param } } }, + ], + }, + }, + ], + }, + ], + }, + }, + ], + }; +} + +/** Modal dialog for posting a blocker update. */ +export function blockerUpdateDialog(blockerId: number) { + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + dialog: { + body: { + sections: [ + { + widgets: [ + { + textInput: { + name: 'update', + label: 'What is the latest on this blocker?', + type: 'MULTIPLE_LINE', + }, + }, + { + buttonList: { + buttons: [ + { + text: 'Post update', + onClick: { + action: { + function: SUBMIT_BLOCKER_UPDATE_FN, + parameters: [{ key: 'blockerId', value: String(blockerId) }], + }, + }, + }, + ], + }, + }, + ], + }, + ], + }, + }, + }, + }, + }; +} + /** Plain text that opens the day's thread in the report space. */ export function threadParentText(standup: Standup, run: Run): string { return `πŸ“… *${standup.name}* β€” ${humanDate(run.date)}`; diff --git a/src/adapters/gchat/events.ts b/src/adapters/gchat/events.ts index 4a6f4fa..e697442 100644 --- a/src/adapters/gchat/events.ts +++ b/src/adapters/gchat/events.ts @@ -1,3 +1,4 @@ +import type { BlockerService } from '../../core/blocker-service.js'; import type { CommandHandler, Mention } from '../../core/commands.js'; import type { StandupService } from '../../core/standup-service.js'; import type { Repo } from '../../db/repo.js'; @@ -8,7 +9,17 @@ import { type Answer, type Mood, } from '../../core/types.js'; -import { OPEN_DIALOG_FN, SKIP_TODAY_FN, standupDialog, SUBMIT_DIALOG_FN } from './cards.js'; +import { + ACK_BLOCKER_FN, + blockerUpdateDialog, + OPEN_BLOCKER_UPDATE_FN, + OPEN_DIALOG_FN, + RESOLVE_BLOCKER_FN, + SKIP_TODAY_FN, + standupDialog, + SUBMIT_BLOCKER_UPDATE_FN, + SUBMIT_DIALOG_FN, +} from './cards.js'; /** * Routes Google Chat interaction events (MESSAGE, CARD_CLICKED, dialog @@ -19,6 +30,7 @@ export class EventRouter { constructor( private commands: CommandHandler, private service: StandupService, + private blockers: BlockerService, private repo: Repo, private tenantId: string, ) {} @@ -121,6 +133,47 @@ export class EventRouter { return { actionResponse: { type: 'UPDATE_MESSAGE' }, text: messages[result] }; } + const blockerId = Number(getParameter(event, 'blockerId')); + + if (fn === ACK_BLOCKER_FN) { + const result = await this.blockers.acknowledge(blockerId, user); + const messages = { + acked: 'βœ‹ Acknowledged β€” the reporter has been told you are on it. Use the buttons above to post updates or resolve.', + already_acked: 'You already acknowledged this blocker.', + not_tagged: "You aren't tagged on this blocker.", + not_found: 'This blocker is already resolved or no longer exists.', + }; + return { text: messages[result] }; + } + + if (fn === OPEN_BLOCKER_UPDATE_FN) { + if (!Number.isInteger(blockerId)) return dialogError('This blocker card is no longer valid.'); + return blockerUpdateDialog(blockerId); + } + + if (fn === SUBMIT_BLOCKER_UPDATE_FN) { + const text = getFormValue(event, 'update').trim(); + if (!text) return dialogError('Please write the update first.'); + const result = await this.blockers.addUpdate(blockerId, user, text); + const messages = { + ok: 'πŸ“ Update posted β€” everyone involved was notified.', + resolved: 'This blocker is already resolved.', + not_found: 'This blocker no longer exists.', + }; + return result === 'ok' ? dialogOk(messages.ok) : dialogError(messages[result]); + } + + if (fn === RESOLVE_BLOCKER_FN) { + const result = await this.blockers.resolve(blockerId, user); + const messages = { + resolved: 'βœ… Blocker resolved β€” everyone involved was notified.', + already_resolved: 'This blocker was already resolved.', + not_allowed: 'Only the reporter, tagged people, or a standup admin can resolve this blocker.', + not_found: 'This blocker no longer exists.', + }; + return { text: messages[result] }; + } + return {}; } diff --git a/src/core/adapter.ts b/src/core/adapter.ts index 54abe8f..e073281 100644 --- a/src/core/adapter.ts +++ b/src/core/adapter.ts @@ -1,4 +1,4 @@ -import type { Run, RunSummary, Standup, Submission } from './types.js'; +import type { Blocker, Run, RunSummary, Standup, Submission } from './types.js'; /** * Platform abstraction. The core never touches Google Chat (or Slack/Teams) @@ -31,4 +31,10 @@ export interface ChatAdapter { /** Plain-text direct message β€” used for blocker escalation pings. */ sendDm(userName: string, text: string): Promise; + + /** + * Interactive blocker card (Acknowledge / Add update / Resolve buttons), + * DMed when someone is tagged on a blocker or nudged about one. + */ + sendBlockerCard(userName: string, standup: Standup, blocker: Blocker, note: string): Promise; } diff --git a/src/core/blocker-service.ts b/src/core/blocker-service.ts new file mode 100644 index 0000000..6a85e9b --- /dev/null +++ b/src/core/blocker-service.ts @@ -0,0 +1,156 @@ +import { DateTime } from 'luxon'; +import type { ChatAdapter } from './adapter.js'; +import type { Mention } from './commands.js'; +import type { Repo } from '../db/repo.js'; +import type { Blocker, Standup } from './types.js'; + +export type AckResult = 'acked' | 'already_acked' | 'not_tagged' | 'not_found'; +export type UpdateResult = 'ok' | 'not_found' | 'resolved'; +export type ResolveResult = 'resolved' | 'already_resolved' | 'not_allowed' | 'not_found'; + +export function blockerThreadKey(blocker: Blocker): string { + return `blocker-${blocker.id}`; +} + +/** + * Collaboration on blockers: tag people (they get an interactive DM card), + * acknowledge, post updates (broadcast to everyone involved + the team + * space), and resolve explicitly. Tagged blockers are exempt from + * auto-resolve β€” see Repo.resolveBlockersFor. + */ +export class BlockerService { + constructor( + private repo: Repo, + private adapter: ChatAdapter, + private now: () => DateTime = () => DateTime.utc(), + ) {} + + /** Tag people on a blocker; each new tag gets a DM card. Returns a chat reply. */ + async tag(standup: Standup, blockerId: number, mentions: Mention[], taggedBy: Mention): Promise { + const blocker = await this.findIn(standup, blockerId); + if (!blocker) return `No open blocker #${blockerId} in *${standup.name}* β€” see \`blockers\`.`; + if (mentions.length === 0) return 'Mention who to tag, e.g. `blocker 12 tag @Asha`.'; + + const tagged: string[] = []; + for (const m of mentions) { + const fresh = await this.repo.tagBlocker({ + blockerId: blocker.id, + userName: m.userName, + displayName: m.displayName, + taggedBy: taggedBy.displayName, + at: this.now().toISO()!, + }); + if (!fresh) continue; + tagged.push(m.displayName); + try { + await this.adapter.sendBlockerCard( + m.userName, + standup, + blocker, + `${taggedBy.displayName} tagged you on this blocker.`, + ); + } catch { + // The tag still stands; the daily nudge will retry the DM. + } + } + if (tagged.length === 0) return 'Everyone mentioned was already tagged on this blocker.'; + + await this.adapter.postText( + standup.spaceName, + `🀝 ${taggedBy.displayName} tagged ${tagged.join(', ')} on blocker #${blocker.id}: "${blocker.text}" (${blocker.displayName})`, + blockerThreadKey(blocker), + ); + return `βœ… Tagged ${tagged.join(', ')} on blocker #${blocker.id} β€” they got a DM. It now needs an explicit \`blocker ${blocker.id} resolve\`.`; + } + + async acknowledge(blockerId: number, user: Mention): Promise { + const blocker = await this.repo.getBlockerById(blockerId); + if (!blocker || blocker.resolvedDate) return 'not_found'; + const tags = await this.repo.listBlockerTags(blockerId); + const mine = tags.find((t) => t.userName === user.userName); + if (!mine) return 'not_tagged'; + if (mine.acknowledgedAt) return 'already_acked'; + await this.repo.ackBlockerTag(blockerId, user.userName, this.now().toISO()!); + await this.notifyOwner(blocker, `βœ‹ ${user.displayName} acknowledged your blocker: "${blocker.text}"`); + return 'acked'; + } + + async addUpdate(blockerId: number, user: Mention, text: string): Promise { + const blocker = await this.repo.getBlockerById(blockerId); + if (!blocker) return 'not_found'; + if (blocker.resolvedDate) return 'resolved'; + await this.repo.addBlockerUpdate({ + blockerId, + userName: user.userName, + displayName: user.displayName, + text, + at: this.now().toISO()!, + }); + // Acknowledge implicitly β€” posting an update is stronger than an ack. + await this.repo.ackBlockerTag(blockerId, user.userName, this.now().toISO()!); + const standup = (await this.repo.getStandupById(blocker.standupId))!; + await this.broadcast( + standup, + blocker, + `πŸ“ Update on blocker #${blocker.id} ("${blocker.text}") from ${user.displayName}:\n${text}`, + user.userName, + ); + return 'ok'; + } + + /** Owner, tagged people, and standup admins may resolve. */ + async resolve(blockerId: number, user: Mention): Promise { + const blocker = await this.repo.getBlockerById(blockerId); + if (!blocker) return 'not_found'; + if (blocker.resolvedDate) return 'already_resolved'; + const standup = (await this.repo.getStandupById(blocker.standupId))!; + const tags = await this.repo.listBlockerTags(blockerId); + const allowed = + blocker.userName === user.userName || + tags.some((t) => t.userName === user.userName) || + (await this.repo.isAdmin(standup.id, user.userName)); + if (!allowed) return 'not_allowed'; + + const date = this.now().setZone(standup.timezone).toISODate()!; + if (!(await this.repo.resolveBlocker(blockerId, date, user.displayName))) return 'already_resolved'; + await this.broadcast( + standup, + blocker, + `βœ… Blocker #${blocker.id} resolved by ${user.displayName}: "${blocker.text}" (open since ${blocker.openedDate})`, + user.userName, + ); + return 'resolved'; + } + + /** DM everyone involved (owner + tagged, minus the author) and post to the blocker's thread. */ + private async broadcast(standup: Standup, blocker: Blocker, text: string, exceptUserName: string): Promise { + const recipients = new Map(); + recipients.set(blocker.userName, blocker.displayName); + for (const t of await this.repo.listBlockerTags(blocker.id)) { + recipients.set(t.userName, t.displayName); + } + recipients.delete(exceptUserName); + for (const userName of recipients.keys()) { + try { + await this.adapter.sendDm(userName, text); + } catch { + // Best-effort: the space thread still carries the update. + } + } + await this.adapter.postText(standup.spaceName, text, blockerThreadKey(blocker)); + } + + private async notifyOwner(blocker: Blocker, text: string): Promise { + try { + await this.adapter.sendDm(blocker.userName, text); + } catch { + // Non-critical notification. + } + } + + private async findIn(standup: Standup, blockerId: number): Promise { + const blocker = await this.repo.getBlockerById(blockerId); + if (!blocker || blocker.standupId !== standup.id || blocker.resolvedDate) return null; + return blocker; + } +} diff --git a/src/core/commands.ts b/src/core/commands.ts index 04d1a74..3ebca79 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -1,4 +1,5 @@ import { DateTime, IANAZone } from 'luxon'; +import type { BlockerService } from './blocker-service.js'; import type { Repo } from '../db/repo.js'; import { trendsText } from './insights.js'; import { @@ -41,16 +42,18 @@ const HELP = `*AsyncUp commands* (mention me in this space β€” prefix with \`# tag @user…\` / \`blocker update \` / \`blocker resolve\` β€” work a blocker together \`status\` Β· \`trends\` Β· \`blockers\` Β· \`export\` β€” insights`; /** Commands anyone in the space may run; everything else needs an admin. */ -const OPEN_COMMANDS = new Set(['help', 'status', 'trends', 'blockers', 'export']); +const OPEN_COMMANDS = new Set(['help', 'status', 'trends', 'blockers', 'blocker', 'export']); export class CommandHandler { constructor( private repo: Repo, private defaultTimezone: string, private now: () => DateTime = () => DateTime.utc(), + private blockerService: BlockerService | null = null, ) {} async handle(ctx: CommandContext): Promise { @@ -138,6 +141,8 @@ export class CommandHandler { return await trendsText(this.repo, standup, this.now()); case 'blockers': return this.blockers(standup); + case 'blocker': + return this.blockerCmd(standup, ctx, rest); case 'export': return this.exportInfo(standup); default: @@ -390,11 +395,54 @@ export class CommandHandler { const open = await this.repo.listOpenBlockers(standup.id); if (open.length === 0) return `βœ… No open blockers for *${standup.name}*.`; const today = this.now().setZone(standup.timezone); - const lines = open.map((b) => { + const lines: string[] = []; + for (const b of open) { const age = Math.max(0, Math.floor(today.diff(DateTime.fromISO(b.openedDate), 'days').days)); - return `⚠️ ${b.displayName}: ${b.text} _(${age}d old)_`; - }); - return `*Open blockers β€” ${standup.name}:*\n${lines.join('\n')}\nBlockers auto-resolve when the person submits a blocker-free standup.`; + const tags = await this.repo.listBlockerTags(b.id); + const updates = await this.repo.listBlockerUpdates(b.id); + let line = `⚠️ #${b.id} ${b.displayName}: ${b.text} _(${age}d old)_`; + if (tags.length > 0) { + line += ` Β· tagged: ${tags.map((t) => `${t.displayName}${t.acknowledgedAt ? ' βœ‹' : ''}`).join(', ')}`; + } + if (updates.length > 0) line += ` Β· ${updates.length} update${updates.length === 1 ? '' : 's'}`; + lines.push(line); + } + return ( + `*Open blockers β€” ${standup.name}:*\n${lines.join('\n')}\n` + + 'Work one with `blocker tag @user`, `blocker update `, `blocker resolve`. ' + + 'Untagged blockers also auto-resolve on the next blocker-free standup.' + ); + } + + private async blockerCmd(standup: Standup, ctx: CommandContext, rest: string[]): Promise { + if (!this.blockerService) return 'Blocker collaboration is not available.'; + const id = Number(rest[0]); + const sub = (rest[1] ?? '').toLowerCase(); + if (!Number.isInteger(id) || !['tag', 'update', 'resolve'].includes(sub)) { + return 'Usage: `blocker tag @user…` Β· `blocker update ` Β· `blocker resolve` β€” ids are shown by `blockers`.'; + } + if (sub === 'tag') { + return this.blockerService.tag(standup, id, ctx.mentions, ctx.sender); + } + if (sub === 'update') { + const text = rest.slice(2).join(' ').trim(); + if (!text) return 'Add the update text, e.g. `blocker 12 update keys requested from infra`.'; + const result = await this.blockerService.addUpdate(id, ctx.sender, text); + const messages = { + ok: `πŸ“ Update posted on blocker #${id} β€” everyone involved was notified.`, + resolved: `Blocker #${id} is already resolved.`, + not_found: `No blocker #${id}.`, + }; + return messages[result]; + } + const result = await this.blockerService.resolve(id, ctx.sender); + const messages = { + resolved: `βœ… Blocker #${id} resolved β€” everyone involved was notified.`, + already_resolved: `Blocker #${id} was already resolved.`, + not_allowed: 'Only the reporter, tagged people, or a standup admin can resolve this blocker.', + not_found: `No blocker #${id}.`, + }; + return messages[result]; } private exportInfo(standup: Standup): string { diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index e088c24..a2c5558 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -157,6 +157,12 @@ export class Scheduler { } } + try { + await this.nudgeUnackedBlockerTags(standup, run); + } catch (err) { + this.log(`blocker nudges failed for run ${run.id}: ${err}`); + } + if (standup.aiEnabled && this.ai) { try { const submissions = await this.repo.listSubmissions(run.id); @@ -190,6 +196,27 @@ export class Scheduler { } } + /** One DM per day per unacknowledged tag on an open blocker β€” stops on ack. */ + private async nudgeUnackedBlockerTags(standup: Standup, run: Run): Promise { + const localDate = (iso: string) => DateTime.fromISO(iso).setZone(standup.timezone).toISODate(); + for (const { tag, blocker } of await this.repo.listUnackedTags(standup.id)) { + // Tagged today β†’ they already got the card; nudged today β†’ done for today. + if (localDate(tag.taggedAt) === run.date) continue; + if (tag.lastNudgedAt && localDate(tag.lastNudgedAt) === run.date) continue; + try { + await this.adapter.sendBlockerCard( + tag.userName, + standup, + blocker, + `Reminder: ${tag.taggedBy} tagged you on this blocker β€” please acknowledge.`, + ); + await this.repo.markTagNudged(blocker.id, tag.userName, this.now().toISO()!); + } catch (err) { + this.log(`blocker nudge to ${tag.userName} failed: ${err}`); + } + } + } + private async escalateStaleBlockers(standup: Standup, run: Run): Promise { const today = DateTime.fromISO(run.date); const stale = (await this.repo.listOpenBlockers(standup.id)).filter((b) => { diff --git a/src/core/types.ts b/src/core/types.ts index e943f0b..812e617 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -141,9 +141,30 @@ export interface Blocker { openedDate: string; resolvedRunId: number | null; resolvedDate: string | null; + /** Display name of who resolved it, or "auto" for clean-submission auto-resolve. */ + resolvedBy: string | null; escalatedAt: string | null; } +export interface BlockerTag { + blockerId: number; + userName: string; + displayName: string; + taggedBy: string; + taggedAt: string; + acknowledgedAt: string | null; + lastNudgedAt: string | null; +} + +export interface BlockerUpdate { + id: number; + blockerId: number; + userName: string; + displayName: string; + text: string; + createdAt: string; +} + export interface RunSummary { standupName: string; date: string; diff --git a/src/db/repo.ts b/src/db/repo.ts index 7c5a907..f4af36d 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -3,6 +3,8 @@ import type { Admin, Answer, Blocker, + BlockerTag, + BlockerUpdate, Mood, Participant, Run, @@ -165,6 +167,29 @@ ALTER TABLE standups ADD COLUMN escalate_user_name TEXT; ALTER TABLE standups ADD COLUMN escalate_display_name TEXT; ALTER TABLE standups ADD COLUMN escalate_after_days INTEGER NOT NULL DEFAULT 2; ALTER TABLE blockers ADD COLUMN escalated_at TEXT; +`, + // 4 β€” blocker collaboration: tags, acknowledgments, updates, explicit resolve + ` +CREATE TABLE blocker_tags ( + blocker_id INTEGER NOT NULL REFERENCES blockers(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + tagged_by TEXT NOT NULL, + tagged_at TEXT NOT NULL, + acknowledged_at TEXT, + last_nudged_at TEXT, + PRIMARY KEY (blocker_id, user_name) +); +CREATE TABLE blocker_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + blocker_id INTEGER NOT NULL REFERENCES blockers(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL +); +ALTER TABLE blockers ADD COLUMN resolved_by TEXT; +CREATE INDEX idx_blockers_unresolved ON blockers(standup_id) WHERE resolved_date IS NULL; `, ]; @@ -272,6 +297,29 @@ CREATE TABLE user_emails ( // 2, 3 β€” already included in the initial Postgres schema above '', '', + // 4 β€” blocker collaboration (mirrors the SQLite migration) + ` +CREATE TABLE blocker_tags ( + blocker_id INTEGER NOT NULL REFERENCES blockers(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + tagged_by TEXT NOT NULL, + tagged_at TEXT NOT NULL, + acknowledged_at TEXT, + last_nudged_at TEXT, + PRIMARY KEY (blocker_id, user_name) +); +CREATE TABLE blocker_updates ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + blocker_id INTEGER NOT NULL REFERENCES blockers(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL +); +ALTER TABLE blockers ADD COLUMN resolved_by TEXT; +CREATE INDEX idx_blockers_unresolved ON blockers(standup_id) WHERE resolved_date IS NULL; +`, ]; function toStandup(row: any): Standup { @@ -359,10 +407,34 @@ function toBlocker(row: any): Blocker { openedDate: row.opened_date, resolvedRunId: row.resolved_run_id ?? null, resolvedDate: row.resolved_date ?? null, + resolvedBy: row.resolved_by ?? null, escalatedAt: row.escalated_at ?? null, }; } +function toBlockerTag(row: any): BlockerTag { + return { + blockerId: row.blocker_id, + userName: row.user_name, + displayName: row.display_name, + taggedBy: row.tagged_by, + taggedAt: row.tagged_at, + acknowledgedAt: row.acknowledged_at ?? null, + lastNudgedAt: row.last_nudged_at ?? null, + }; +} + +function toBlockerUpdate(row: any): BlockerUpdate { + return { + id: row.id, + blockerId: row.blocker_id, + userName: row.user_name, + displayName: row.display_name, + text: row.text, + createdAt: row.created_at, + }; +} + export class Repo { private constructor(private db: Driver) {} @@ -822,23 +894,126 @@ export class Repo { await this.db.run('DELETE FROM blockers WHERE opened_run_id = ? AND user_name = ?', [runId, userName]); } + /** + * Auto-resolve on a clean submission β€” but only "private" blockers. + * Tagged blockers and blockers with updates are collaborative and must be + * resolved explicitly so a clean standup can't silently close them. + */ async resolveBlockersFor(standupId: number, userName: string, runId: number, date: string): Promise { const result = await this.db.run( - `UPDATE blockers SET resolved_run_id = ?, resolved_date = ? - WHERE standup_id = ? AND user_name = ? AND resolved_run_id IS NULL AND opened_run_id != ?`, + `UPDATE blockers SET resolved_run_id = ?, resolved_date = ?, resolved_by = 'auto' + WHERE standup_id = ? AND user_name = ? AND resolved_date IS NULL AND opened_run_id != ? + AND NOT EXISTS (SELECT 1 FROM blocker_tags bt WHERE bt.blocker_id = blockers.id) + AND NOT EXISTS (SELECT 1 FROM blocker_updates bu WHERE bu.blocker_id = blockers.id)`, [runId, date, standupId, userName, runId], ); return result.changes; } + async resolveBlocker(id: number, date: string, by: string): Promise { + const result = await this.db.run( + 'UPDATE blockers SET resolved_date = ?, resolved_by = ? WHERE id = ? AND resolved_date IS NULL', + [date, by, id], + ); + return result.changes > 0; + } + + async getBlockerById(id: number): Promise { + const row = await this.db.get('SELECT * FROM blockers WHERE id = ?', [id]); + return row ? toBlocker(row) : null; + } + async listOpenBlockers(standupId: number): Promise { const rows = await this.db.all( - 'SELECT * FROM blockers WHERE standup_id = ? AND resolved_run_id IS NULL ORDER BY opened_date', + 'SELECT * FROM blockers WHERE standup_id = ? AND resolved_date IS NULL ORDER BY opened_date', [standupId], ); return rows.map(toBlocker); } + // --- blocker collaboration --- + + async tagBlocker(input: { + blockerId: number; + userName: string; + displayName: string; + taggedBy: string; + at: string; + }): Promise { + const existing = await this.db.get( + 'SELECT 1 FROM blocker_tags WHERE blocker_id = ? AND user_name = ?', + [input.blockerId, input.userName], + ); + if (existing) return false; + await this.db.run( + `INSERT INTO blocker_tags (blocker_id, user_name, display_name, tagged_by, tagged_at) + VALUES (?, ?, ?, ?, ?)`, + [input.blockerId, input.userName, input.displayName, input.taggedBy, input.at], + ); + return true; + } + + async listBlockerTags(blockerId: number): Promise { + const rows = await this.db.all( + 'SELECT * FROM blocker_tags WHERE blocker_id = ? ORDER BY tagged_at', + [blockerId], + ); + return rows.map(toBlockerTag); + } + + async ackBlockerTag(blockerId: number, userName: string, at: string): Promise { + const result = await this.db.run( + 'UPDATE blocker_tags SET acknowledged_at = ? WHERE blocker_id = ? AND user_name = ? AND acknowledged_at IS NULL', + [at, blockerId, userName], + ); + return result.changes > 0; + } + + async markTagNudged(blockerId: number, userName: string, at: string): Promise { + await this.db.run( + 'UPDATE blocker_tags SET last_nudged_at = ? WHERE blocker_id = ? AND user_name = ?', + [at, blockerId, userName], + ); + } + + /** Unacknowledged tags on open blockers β€” the daily-nudge worklist. */ + async listUnackedTags(standupId: number): Promise<{ tag: BlockerTag; blocker: Blocker }[]> { + const rows = await this.db.all( + `SELECT bt.*, b.id AS b_id FROM blocker_tags bt + JOIN blockers b ON b.id = bt.blocker_id + WHERE b.standup_id = ? AND b.resolved_date IS NULL AND bt.acknowledged_at IS NULL`, + [standupId], + ); + const result: { tag: BlockerTag; blocker: Blocker }[] = []; + for (const row of rows) { + const blocker = await this.getBlockerById(row.b_id); + if (blocker) result.push({ tag: toBlockerTag(row), blocker }); + } + return result; + } + + async addBlockerUpdate(input: { + blockerId: number; + userName: string; + displayName: string; + text: string; + at: string; + }): Promise { + await this.db.run( + `INSERT INTO blocker_updates (blocker_id, user_name, display_name, text, created_at) + VALUES (?, ?, ?, ?, ?)`, + [input.blockerId, input.userName, input.displayName, input.text, input.at], + ); + } + + async listBlockerUpdates(blockerId: number): Promise { + const rows = await this.db.all( + 'SELECT * FROM blocker_updates WHERE blocker_id = ? ORDER BY created_at', + [blockerId], + ); + return rows.map(toBlockerUpdate); + } + async countBlockersOpenedBetween(standupId: number, fromDate: string, toDate: string): Promise { const row = await this.db.get( 'SELECT COUNT(*) AS n FROM blockers WHERE standup_id = ? AND opened_date >= ? AND opened_date <= ?', diff --git a/src/index.ts b/src/index.ts index 22e791f..db83459 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { EventRouter } from './adapters/gchat/events.js'; import { createLlm } from './ai/llm.js'; import { AiSummarizer } from './ai/summarizer.js'; import { GoogleCalendarOoo } from './integrations/google-calendar.js'; +import { BlockerService } from './core/blocker-service.js'; import { CommandHandler } from './core/commands.js'; import { Scheduler } from './core/scheduler.js'; import { StandupService } from './core/standup-service.js'; @@ -32,8 +33,9 @@ const adapter = : new FakeAdapter((msg) => console.log(`[fake-adapter] ${msg}`)); const service = new StandupService(repo, adapter); -const commands = new CommandHandler(repo, config.defaultTimezone); -const router = new EventRouter(commands, service, repo, config.tenantId); +const blockerService = new BlockerService(repo, adapter); +const commands = new CommandHandler(repo, config.defaultTimezone, undefined, blockerService); +const router = new EventRouter(commands, service, blockerService, repo, config.tenantId); let verifier: ChatRequestVerifier | null = null; if (config.chatAudience) { diff --git a/tests/blockers.test.ts b/tests/blockers.test.ts new file mode 100644 index 0000000..919f59e --- /dev/null +++ b/tests/blockers.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; +import { EventRouter } from '../src/adapters/gchat/events.js'; +import type { Mention } from '../src/core/commands.js'; +import { ANSWERS, makeStack, seedStandup, TENANT, withBlocker } from './helpers.js'; + +const ADMIN = { userName: 'users/admin', displayName: 'Admin' }; +const ALICE = { userName: 'users/alice', displayName: 'Alice' }; +const BOB = { userName: 'users/bob', displayName: 'Bob' }; + +function ctx(text: string, mentions: Mention[] = [], sender: Mention = ADMIN) { + return { tenantId: TENANT, spaceName: 'spaces/team', text, mentions, sender }; +} + +async function seedWithBlocker(stack: Awaited>) { + const standup = await seedStandup(stack.repo); + await stack.repo.addAdmin(standup.id, ADMIN.userName, ADMIN.displayName); + const run = await stack.repo.createRun(standup.id, '2026-06-09', 'k1'); + await stack.service.submit(run.id, ALICE.userName, ALICE.displayName, withBlocker('Waiting on API keys')); + const blocker = (await stack.repo.listOpenBlockers(standup.id))[0]!; + return { standup, run, blocker }; +} + +describe('Blocker collaboration', () => { + it('tags people via command: DM card, space thread post, no duplicate tags', async () => { + const stack = await makeStack(); + const { blocker } = await seedWithBlocker(stack); + + const reply = await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob`, [BOB])); + expect(reply).toContain('Tagged Bob'); + expect(reply).toContain('explicit'); + + const cards = stack.adapter.dms.filter((d) => d.kind === 'blockerCard'); + expect(cards).toHaveLength(1); + expect(cards[0]!.userName).toBe(BOB.userName); + expect(cards[0]!.blockerId).toBe(blocker.id); + + const threadPosts = stack.adapter.posts.filter( + (p) => p.kind === 'text' && p.threadKey === `blocker-${blocker.id}`, + ); + expect(threadPosts).toHaveLength(1); + expect(threadPosts[0]!.text).toContain('tagged Bob'); + + expect(await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob`, [BOB]))).toContain( + 'already tagged', + ); + expect((await stack.repo.listBlockerTags(blocker.id))).toHaveLength(1); + }); + + it('exempts tagged blockers from auto-resolve; untagged ones still auto-resolve', async () => { + const stack = await makeStack(); + const { standup, blocker } = await seedWithBlocker(stack); + await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob`, [BOB])); + + // Bob also reports a blocker (untagged) + const run1 = (await stack.repo.getRun(standup.id, '2026-06-09'))!; + await stack.service.submit(run1.id, BOB.userName, BOB.displayName, withBlocker('Bob blocker')); + + // next day both submit clean + const run2 = await stack.repo.createRun(standup.id, '2026-06-10', 'k2'); + await stack.service.submit(run2.id, ALICE.userName, ALICE.displayName, ANSWERS); + await stack.service.submit(run2.id, BOB.userName, BOB.displayName, ANSWERS); + + const open = await stack.repo.listOpenBlockers(standup.id); + expect(open.map((b) => b.text)).toEqual(['Waiting on API keys']); // tagged one survives + }); + + it('acknowledge: records, notifies the owner, stops there for non-tagged users', async () => { + const stack = await makeStack(); + const { blocker } = await seedWithBlocker(stack); + await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob`, [BOB])); + + expect(await stack.blockers.acknowledge(blocker.id, BOB)).toBe('acked'); + expect((await stack.repo.listBlockerTags(blocker.id))[0]!.acknowledgedAt).not.toBeNull(); + const ownerDm = stack.adapter.dms.find( + (d) => d.kind === 'text' && d.userName === ALICE.userName && d.text?.includes('acknowledged'), + ); + expect(ownerDm).toBeDefined(); + + expect(await stack.blockers.acknowledge(blocker.id, BOB)).toBe('already_acked'); + expect(await stack.blockers.acknowledge(blocker.id, ADMIN)).toBe('not_tagged'); + }); + + it('updates broadcast to owner and tagged people (not the author) and post to the thread', async () => { + const stack = await makeStack(); + const { blocker } = await seedWithBlocker(stack); + await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob @Admin`, [BOB, ADMIN])); + stack.adapter.dms.length = 0; + + expect(await stack.blockers.addUpdate(blocker.id, BOB, 'Requested keys from infra')).toBe('ok'); + + const dms = stack.adapter.dms.filter((d) => d.kind === 'text'); + expect(dms.map((d) => d.userName).sort()).toEqual([ADMIN.userName, ALICE.userName]); // not Bob + expect(dms[0]!.text).toContain('Requested keys from infra'); + + const threadPosts = stack.adapter.posts.filter( + (p) => p.kind === 'text' && p.threadKey === `blocker-${blocker.id}`, + ); + expect(threadPosts.some((p) => p.text!.includes('Requested keys from infra'))).toBe(true); + + // posting an update acknowledges implicitly + const bobTag = (await stack.repo.listBlockerTags(blocker.id)).find((t) => t.userName === BOB.userName)!; + expect(bobTag.acknowledgedAt).not.toBeNull(); + }); + + it('explicit resolve: allowed for tagged/owner/admin, blocked for others, broadcasts once', async () => { + const stack = await makeStack(); + const { standup, blocker } = await seedWithBlocker(stack); + await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob`, [BOB])); + + const stranger = { userName: 'users/mallory', displayName: 'Mallory' }; + expect(await stack.blockers.resolve(blocker.id, stranger)).toBe('not_allowed'); + + expect(await stack.commands.handle(ctx(`blocker ${blocker.id} resolve`, [], BOB))).toContain( + 'resolved', + ); + const resolved = (await stack.repo.getBlockerById(blocker.id))!; + expect(resolved.resolvedDate).not.toBeNull(); + expect(resolved.resolvedBy).toBe(BOB.displayName); + expect(await stack.repo.listOpenBlockers(standup.id)).toHaveLength(0); + + expect(await stack.blockers.resolve(blocker.id, BOB)).toBe('already_resolved'); + }); + + it('nudges unacked tags once per day at run close, stops after ack', async () => { + const stack = await makeStack(); + const { blocker } = await seedWithBlocker(stack); + stack.clock.set('2026-06-09T12:00'); + await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob`, [BOB])); + stack.adapter.dms.length = 0; + + // next day's run closes β†’ one nudge + stack.clock.set('2026-06-10T09:30'); + await stack.scheduler.tick(); + stack.clock.set('2026-06-10T11:30'); + await stack.scheduler.tick(); + let nudges = stack.adapter.dms.filter((d) => d.kind === 'blockerCard'); + expect(nudges).toHaveLength(1); + expect(nudges[0]!.userName).toBe(BOB.userName); + expect(nudges[0]!.text).toContain('acknowledge'); + + // same day, another tick β†’ still one + await stack.scheduler.tick(); + expect(stack.adapter.dms.filter((d) => d.kind === 'blockerCard')).toHaveLength(1); + + // Bob acks β†’ no nudge the following day + await stack.blockers.acknowledge(blocker.id, BOB); + stack.clock.set('2026-06-11T09:30'); + await stack.scheduler.tick(); + stack.clock.set('2026-06-11T11:30'); + await stack.scheduler.tick(); + expect(stack.adapter.dms.filter((d) => d.kind === 'blockerCard')).toHaveLength(1); + }); + + it('card buttons work end-to-end through the event router', async () => { + const stack = await makeStack(); + const { blocker } = await seedWithBlocker(stack); + await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob`, [BOB])); + const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); + const user = { name: BOB.userName, displayName: BOB.displayName }; + + const ack: any = await router.handle({ + type: 'CARD_CLICKED', + common: { invokedFunction: 'ackBlocker', parameters: { blockerId: String(blocker.id) } }, + user, + }); + expect(ack.text).toContain('Acknowledged'); + + const dialog: any = await router.handle({ + type: 'CARD_CLICKED', + common: { invokedFunction: 'openBlockerUpdate', parameters: { blockerId: String(blocker.id) } }, + user, + }); + expect(dialog.actionResponse.type).toBe('DIALOG'); + expect(JSON.stringify(dialog)).toContain('submitBlockerUpdate'); + + const update: any = await router.handle({ + type: 'CARD_CLICKED', + isDialogEvent: true, + common: { + invokedFunction: 'submitBlockerUpdate', + parameters: { blockerId: String(blocker.id) }, + formInputs: { update: { stringInputs: { value: ['Keys arriving tomorrow'] } } }, + }, + user, + }); + expect(update.actionResponse.dialogAction.actionStatus.statusCode).toBe('OK'); + expect((await stack.repo.listBlockerUpdates(blocker.id))[0]!.text).toBe('Keys arriving tomorrow'); + + const resolve: any = await router.handle({ + type: 'CARD_CLICKED', + common: { invokedFunction: 'resolveBlocker', parameters: { blockerId: String(blocker.id) } }, + user, + }); + expect(resolve.text).toContain('resolved'); + }); + + it('lists blockers with ids, tags, acks, and update counts', async () => { + const stack = await makeStack(); + const { blocker } = await seedWithBlocker(stack); + await stack.commands.handle(ctx(`blocker ${blocker.id} tag @Bob`, [BOB])); + await stack.blockers.addUpdate(blocker.id, BOB, 'On it'); + + const listing = await stack.commands.handle(ctx('blockers')); + expect(listing).toContain(`#${blocker.id}`); + expect(listing).toContain('Bob βœ‹'); // update implies ack + expect(listing).toContain('1 update'); + }); +}); diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 9d90a06..23c2f89 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -8,7 +8,7 @@ let close: (() => void) | null = null; async function startServer(dashboardToken = 'dash-secret') { const stack = await makeStack(); - const router = new EventRouter(stack.commands, stack.service, stack.repo, TENANT); + const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); const app = createServer({ router, verifier: null, diff --git a/tests/events.test.ts b/tests/events.test.ts index c7ae68b..9de943d 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -4,7 +4,7 @@ import { ANSWERS, makeStack, seedStandup, TENANT } from './helpers.js'; async function makeRouter() { const stack = await makeStack(); - const router = new EventRouter(stack.commands, stack.service, stack.repo, TENANT); + const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); return { ...stack, router }; } diff --git a/tests/helpers.ts b/tests/helpers.ts index 6ee8284..907dccc 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,6 +1,7 @@ import { DateTime } from 'luxon'; import { FakeAdapter } from '../src/adapters/fake/adapter.js'; import { AiSummarizer } from '../src/ai/summarizer.js'; +import { BlockerService } from '../src/core/blocker-service.js'; import { CommandHandler } from '../src/core/commands.js'; import { Scheduler } from '../src/core/scheduler.js'; import { StandupService } from '../src/core/standup-service.js'; @@ -32,10 +33,11 @@ export async function makeStack(opts: { summarizer?: AiSummarizer | null } = {}) }; const service = new StandupService(repo, adapter, clock.now); + const blockers = new BlockerService(repo, adapter, clock.now); const scheduler = new Scheduler(repo, adapter, service, clock.now, () => {}, opts.summarizer ?? null); - const commands = new CommandHandler(repo, TZ, clock.now); + const commands = new CommandHandler(repo, TZ, clock.now, blockers); - return { repo, adapter, service, scheduler, commands, clock }; + return { repo, adapter, service, blockers, scheduler, commands, clock }; } export async function seedStandup(repo: Repo, opts: { deadlineTime?: string; spaceName?: string } = {}) { diff --git a/tests/round3.test.ts b/tests/round3.test.ts index bc53eba..9318fbb 100644 --- a/tests/round3.test.ts +++ b/tests/round3.test.ts @@ -182,7 +182,7 @@ describe('Blocker escalation', () => { describe('Email capture', () => { it('learns user emails from interaction events', async () => { const stack = await makeStack(); - const router = new EventRouter(stack.commands, stack.service, stack.repo, TENANT); + const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); await router.handle({ type: 'MESSAGE', space: { name: 'spaces/team', type: 'ROOM' }, diff --git a/tests/server.test.ts b/tests/server.test.ts index 080bb83..0b2c053 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -8,7 +8,7 @@ let close: (() => void) | null = null; async function startServer(opts: { tickToken?: string; exportToken?: string; dashboardToken?: string } = {}) { const stack = await makeStack(); - const router = new EventRouter(stack.commands, stack.service, stack.repo, TENANT); + const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); const app = createServer({ router, verifier: null,