diff --git a/.changeset/ripe-ghosts-bet.md b/.changeset/ripe-ghosts-bet.md
new file mode 100644
index 0000000000..50f3463080
--- /dev/null
+++ b/.changeset/ripe-ghosts-bet.md
@@ -0,0 +1,9 @@
+---
+---
+
+Add escalation and learning capture tools for Addie
+
+- New `escalate_to_admin` tool for capability gaps and requests needing human action
+- New `capture_learning` tool to flag valuable user insights for synthesis
+- Admin API endpoints for managing escalations
+- System prompt updates to prevent Addie from promising actions without tools
diff --git a/server/public/admin-addie.html b/server/public/admin-addie.html
index 39ea4b0fae..1cbf9a2f92 100644
--- a/server/public/admin-addie.html
+++ b/server/public/admin-addie.html
@@ -1603,6 +1603,7 @@
Conversation Threads
Flagged only
Unreviewed only
Has user feedback
+ Multi-turn only
Refresh
@@ -2439,6 +2440,7 @@ ${escapeHtml(doc.title)}
const flaggedOnly = document.getElementById('threads-flagged-only').checked;
const unreviewedOnly = document.getElementById('threads-unreviewed-only').checked;
const hasUserFeedback = document.getElementById('threads-has-feedback').checked;
+ const multiTurnOnly = document.getElementById('threads-multi-turn').checked;
const timeframe = document.getElementById('stats-timeframe').value;
const params = new URLSearchParams();
@@ -2447,6 +2449,7 @@ ${escapeHtml(doc.title)}
if (flaggedOnly) params.append('flagged_only', 'true');
if (unreviewedOnly) params.append('unreviewed_only', 'true');
if (hasUserFeedback) params.append('has_user_feedback', 'true');
+ if (multiTurnOnly) params.append('min_messages', '2');
// Add timeframe filter to threads query
if (timeframe !== 'all') {
@@ -4812,6 +4815,7 @@ c.name.toLowerCase().includes('escalation') || c.name.toLowerCase().includes('addie-admin')
+ );
+ return escalationChannel?.slack_channel_id || null;
+}
+
+/**
+ * Format and send escalation notification to Slack
+ */
+async function sendEscalationNotification(
+ escalationId: number,
+ channelId: string,
+ summary: string,
+ category: EscalationCategory,
+ priority: EscalationPriority,
+ context: {
+ userDisplayName?: string;
+ orgName?: string;
+ slackUserId?: string;
+ threadId?: string;
+ originalRequest?: string;
+ addieContext?: string;
+ }
+): Promise<{ ok: boolean; ts?: string }> {
+ const priorityEmoji: Record = {
+ low: '',
+ normal: '',
+ high: ':warning:',
+ urgent: ':rotating_light:',
+ };
+
+ const categoryLabel: Record = {
+ capability_gap: 'Capability Gap',
+ needs_human_action: 'Needs Human Action',
+ complex_request: 'Complex Request',
+ sensitive_topic: 'Sensitive Topic',
+ other: 'Other',
+ };
+
+ const userInfo = context.userDisplayName
+ ? `${context.userDisplayName}${context.orgName ? ` (${context.orgName})` : ''}`
+ : context.slackUserId
+ ? `<@${context.slackUserId}>`
+ : 'Unknown user';
+
+ const lines = [
+ `${priorityEmoji[priority]} *New Escalation #${escalationId}*`,
+ '',
+ `*From:* ${userInfo}`,
+ `*Category:* ${categoryLabel[category]}`,
+ `*Priority:* ${priority}`,
+ '',
+ `*Summary:* ${summary}`,
+ ];
+
+ if (context.originalRequest) {
+ lines.push('', `*Original Request:* ${context.originalRequest}`);
+ }
+
+ if (context.addieContext) {
+ lines.push('', `*Why Escalated:* ${context.addieContext}`);
+ }
+
+ if (context.threadId) {
+ lines.push('', ``);
+ }
+
+ return sendChannelMessage(channelId, { text: lines.join('\n') });
+}
+
+/**
+ * Create handlers for escalation and learning tools
+ */
+export function createEscalationToolHandlers(
+ memberContext: MemberContext | null,
+ slackUserId?: string,
+ threadId?: string
+): Map) => Promise> {
+ const handlers = new Map) => Promise>();
+
+ // ============================================
+ // ESCALATE TO ADMIN
+ // ============================================
+ handlers.set('escalate_to_admin', async (input) => {
+ // Validate required inputs
+ if (typeof input.summary !== 'string' || !input.summary.trim()) {
+ return 'Error: summary is required and must be a non-empty string';
+ }
+
+ const validCategories: EscalationCategory[] = [
+ 'capability_gap', 'needs_human_action', 'complex_request', 'sensitive_topic', 'other'
+ ];
+ if (!validCategories.includes(input.category as EscalationCategory)) {
+ return `Error: category must be one of: ${validCategories.join(', ')}`;
+ }
+
+ const validPriorities: EscalationPriority[] = ['low', 'normal', 'high', 'urgent'];
+ if (input.priority && !validPriorities.includes(input.priority as EscalationPriority)) {
+ return `Error: priority must be one of: ${validPriorities.join(', ')}`;
+ }
+
+ const summary = input.summary as string;
+ const category = input.category as EscalationCategory;
+ const priority = (input.priority as EscalationPriority) || 'normal';
+ const originalRequest = input.original_request as string | undefined;
+ const addieContext = input.addie_context as string | undefined;
+
+ // Get display name from slack_user or workos_user
+ const userDisplayName = memberContext?.slack_user?.display_name
+ ?? (memberContext?.workos_user?.first_name
+ ? `${memberContext.workos_user.first_name} ${memberContext.workos_user.last_name || ''}`.trim()
+ : undefined);
+ const orgName = memberContext?.organization?.name;
+
+ try {
+ // 1. Create escalation record
+ const escalation = await createEscalation({
+ thread_id: threadId,
+ slack_user_id: slackUserId,
+ workos_user_id: memberContext?.workos_user?.workos_user_id,
+ user_display_name: userDisplayName,
+ category,
+ priority,
+ summary,
+ original_request: originalRequest,
+ addie_context: addieContext,
+ });
+
+ logger.info(
+ { escalationId: escalation.id, category, priority, threadId },
+ 'Created escalation'
+ );
+
+ // 2. Flag the thread
+ if (threadId) {
+ const threadService = getThreadService();
+ await threadService.flagThread(threadId, `Escalation: ${category}`);
+ }
+
+ // 3. Send notification to escalation channel
+ const escalationChannelId = await getEscalationChannelId();
+ if (escalationChannelId) {
+ const result = await sendEscalationNotification(
+ escalation.id,
+ escalationChannelId,
+ summary,
+ category,
+ priority,
+ {
+ userDisplayName,
+ orgName,
+ slackUserId,
+ threadId,
+ originalRequest,
+ addieContext,
+ }
+ );
+
+ if (result.ok && result.ts) {
+ try {
+ await markNotificationSent(escalation.id, escalationChannelId, result.ts);
+ logger.info({ escalationId: escalation.id, channelId: escalationChannelId }, 'Sent escalation notification');
+ } catch (notifyError) {
+ logger.error(
+ { escalationId: escalation.id, error: notifyError },
+ 'Failed to record notification status - notification was sent but DB update failed'
+ );
+ }
+ }
+ } else {
+ logger.warn({ escalationId: escalation.id }, 'No escalation channel configured - notification not sent');
+ }
+
+ return `Escalation created (ID: ${escalation.id}). I've notified the AgenticAdvertising.org team and they'll follow up with you soon.`;
+ } catch (error) {
+ logger.error({ error, category, threadId }, 'Failed to create escalation');
+ return 'I tried to escalate this but encountered an error. Please reach out directly to the AgenticAdvertising.org team for help.';
+ }
+ });
+
+ // ============================================
+ // CAPTURE LEARNING
+ // ============================================
+ handlers.set('capture_learning', async (input) => {
+ // Validate required inputs
+ if (typeof input.topic !== 'string' || !input.topic.trim()) {
+ return 'Error: topic is required and must be a non-empty string';
+ }
+ if (typeof input.summary !== 'string' || !input.summary.trim()) {
+ return 'Error: summary is required and must be a non-empty string';
+ }
+
+ const topic = input.topic as string;
+ const summary = input.summary as string;
+ const whyValuable = input.why_valuable as string | undefined;
+
+ // Get display name from slack_user or workos_user
+ const authorDisplayName = memberContext?.slack_user?.display_name
+ ?? (memberContext?.workos_user?.first_name
+ ? `${memberContext.workos_user.first_name} ${memberContext.workos_user.last_name || ''}`.trim()
+ : undefined);
+ const authorOrgName = memberContext?.organization?.name;
+
+ try {
+ // Get recent conversation content for context
+ let content = summary;
+ if (threadId) {
+ const threadService = getThreadService();
+ const thread = await threadService.getThreadWithMessages(threadId);
+ if (thread?.messages && thread.messages.length > 0) {
+ // Get last few messages for context
+ const recentMessages = thread.messages.slice(-5);
+ content = recentMessages
+ .map((m) => `${m.role}: ${m.content}`)
+ .join('\n\n');
+ }
+ }
+
+ // Create insight source record
+ const addieDb = new AddieDatabase();
+ const source = await addieDb.createInsightSource({
+ source_type: 'conversation',
+ source_ref: threadId,
+ content,
+ topic,
+ author_name: authorDisplayName,
+ author_context: authorOrgName,
+ tagged_by: 'addie',
+ notes: whyValuable,
+ });
+
+ logger.info(
+ { insightId: source.id, topic, threadId, authorName: authorDisplayName },
+ 'Captured learning from conversation'
+ );
+
+ return `Learning captured (ID: ${source.id}). Thank you for sharing this insight - it helps us improve!`;
+ } catch (error) {
+ logger.error({ error, topic, threadId }, 'Failed to capture learning');
+ return 'I noted this insight but encountered an error saving it. The team may follow up.';
+ }
+ });
+
+ return handlers;
+}
diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts
index b15ddc7ec5..5d64745321 100644
--- a/server/src/addie/prompts.ts
+++ b/server/src/addie/prompts.ts
@@ -332,6 +332,19 @@ When asked "what's the latest news" - interpret as AD TECH news. Search for AdCP
- **Source attribution**: Always cite sources; link to documentation; distinguish fact from interpretation
- **GitHub issues**: When users report bugs, broken links, or feature requests, use draft_github_issue to help them create an issue. Important: Always include the full tool output (GitHub link, preview) in your response since users cannot see tool outputs directly.
+## Honesty About Actions
+
+You can only perform actions through your available tools. Before promising to do something:
+1. Check if you have a tool that actually performs that action
+2. If you don't have the tool, do NOT say "I'll do that" or "I'm doing X now"
+3. Instead, be honest: explain what you can help with and offer to escalate using \`escalate_to_admin\`
+
+Example:
+- BAD: "I'll post that announcement to the channel now" (when you have no posting tool)
+- GOOD: "I can't post to channels directly, but I can help you draft the message. Want me to escalate this to an admin who can post it for you?"
+
+When you genuinely can't help with something, use \`escalate_to_admin\` to create a tracked request for the team. This ensures nothing falls through the cracks.
+
## Fact-Checking (CRITICAL)
**NEVER invent or assume facts.** If you're unsure about something, use your tools to verify:
diff --git a/server/src/addie/thread-service.ts b/server/src/addie/thread-service.ts
index 931bf9bc44..3bb82c411b 100644
--- a/server/src/addie/thread-service.ts
+++ b/server/src/addie/thread-service.ts
@@ -209,6 +209,7 @@ export interface ThreadListFilters {
unreviewed_only?: boolean;
has_feedback?: boolean;
has_user_feedback?: boolean;
+ min_messages?: number;
since?: Date;
limit?: number;
offset?: number;
@@ -547,6 +548,11 @@ export class ThreadService {
conditions.push(`user_feedback_count > 0`);
}
+ if (filters.min_messages !== undefined && filters.min_messages > 0) {
+ conditions.push(`message_count >= $${paramIndex++}`);
+ params.push(filters.min_messages);
+ }
+
if (filters.since) {
conditions.push(`started_at >= $${paramIndex++}`);
params.push(filters.since);
diff --git a/server/src/db/escalation-db.ts b/server/src/db/escalation-db.ts
new file mode 100644
index 0000000000..cfb135f807
--- /dev/null
+++ b/server/src/db/escalation-db.ts
@@ -0,0 +1,270 @@
+/**
+ * Database layer for Addie escalations
+ * Tracks requests that Addie escalates to human admins
+ */
+
+import { query } from './client.js';
+
+// ============== Types ==============
+
+export type EscalationCategory =
+ | 'capability_gap'
+ | 'needs_human_action'
+ | 'complex_request'
+ | 'sensitive_topic'
+ | 'other';
+
+export type EscalationPriority = 'low' | 'normal' | 'high' | 'urgent';
+
+export type EscalationStatus =
+ | 'open'
+ | 'acknowledged'
+ | 'in_progress'
+ | 'resolved'
+ | 'wont_do'
+ | 'expired';
+
+export interface Escalation {
+ id: number;
+ thread_id: string | null;
+ message_id: string | null;
+ slack_user_id: string | null;
+ workos_user_id: string | null;
+ user_display_name: string | null;
+ category: EscalationCategory;
+ priority: EscalationPriority;
+ summary: string;
+ original_request: string | null;
+ addie_context: string | null;
+ notification_channel_id: string | null;
+ notification_sent_at: Date | null;
+ notification_message_ts: string | null;
+ status: EscalationStatus;
+ resolved_by: string | null;
+ resolved_at: Date | null;
+ resolution_notes: string | null;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface EscalationInput {
+ thread_id?: string;
+ message_id?: string;
+ slack_user_id?: string;
+ workos_user_id?: string;
+ user_display_name?: string;
+ category: EscalationCategory;
+ priority?: EscalationPriority;
+ summary: string;
+ original_request?: string;
+ addie_context?: string;
+}
+
+export interface EscalationFilters {
+ status?: EscalationStatus;
+ category?: EscalationCategory;
+ limit?: number;
+ offset?: number;
+}
+
+// ============== Escalation Operations ==============
+
+/**
+ * Create a new escalation
+ */
+export async function createEscalation(input: EscalationInput): Promise {
+ const result = await query(
+ `INSERT INTO addie_escalations (
+ thread_id, message_id, slack_user_id, workos_user_id, user_display_name,
+ category, priority, summary, original_request, addie_context
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ RETURNING *`,
+ [
+ input.thread_id || null,
+ input.message_id || null,
+ input.slack_user_id || null,
+ input.workos_user_id || null,
+ input.user_display_name || null,
+ input.category,
+ input.priority || 'normal',
+ input.summary,
+ input.original_request || null,
+ input.addie_context || null,
+ ]
+ );
+ return result.rows[0];
+}
+
+/**
+ * Get a single escalation by ID
+ */
+export async function getEscalation(id: number): Promise {
+ const result = await query(
+ `SELECT * FROM addie_escalations WHERE id = $1`,
+ [id]
+ );
+ return result.rows[0] || null;
+}
+
+/**
+ * List escalations with optional filters
+ */
+export async function listEscalations(filters: EscalationFilters = {}): Promise {
+ const conditions: string[] = [];
+ const params: unknown[] = [];
+ let paramIndex = 1;
+
+ if (filters.status) {
+ conditions.push(`status = $${paramIndex++}`);
+ params.push(filters.status);
+ }
+
+ if (filters.category) {
+ conditions.push(`category = $${paramIndex++}`);
+ params.push(filters.category);
+ }
+
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
+ const limit = filters.limit || 50;
+ const offset = filters.offset || 0;
+
+ // Assign parameter indexes explicitly for clarity
+ const limitParamIndex = paramIndex++;
+ const offsetParamIndex = paramIndex;
+ params.push(limit, offset);
+
+ const result = await query(
+ `SELECT * FROM addie_escalations
+ ${whereClause}
+ ORDER BY
+ CASE priority
+ WHEN 'urgent' THEN 1
+ WHEN 'high' THEN 2
+ WHEN 'normal' THEN 3
+ WHEN 'low' THEN 4
+ END,
+ created_at DESC
+ LIMIT $${limitParamIndex} OFFSET $${offsetParamIndex}`,
+ params
+ );
+ return result.rows;
+}
+
+/**
+ * Count escalations with filters (for pagination)
+ */
+export async function countEscalations(
+ filters: Omit = {}
+): Promise {
+ const conditions: string[] = [];
+ const params: unknown[] = [];
+ let paramIndex = 1;
+
+ if (filters.status) {
+ conditions.push(`status = $${paramIndex++}`);
+ params.push(filters.status);
+ }
+
+ if (filters.category) {
+ conditions.push(`category = $${paramIndex++}`);
+ params.push(filters.category);
+ }
+
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
+
+ const result = await query<{ count: string }>(
+ `SELECT COUNT(*) as count FROM addie_escalations ${whereClause}`,
+ params
+ );
+ return parseInt(result.rows[0]?.count || '0', 10);
+}
+
+/**
+ * Get open escalations count by category
+ */
+export async function getEscalationStats(): Promise<{
+ open: number;
+ resolved_today: number;
+ by_category: Record;
+}> {
+ const [openCount, resolvedToday, byCategory] = await Promise.all([
+ query<{ count: string }>(
+ `SELECT COUNT(*) as count FROM addie_escalations WHERE status = 'open'`
+ ),
+ query<{ count: string }>(
+ `SELECT COUNT(*) as count FROM addie_escalations
+ WHERE status = 'resolved' AND resolved_at > NOW() - INTERVAL '24 hours'`
+ ),
+ query<{ category: string; count: string }>(
+ `SELECT category, COUNT(*) as count FROM addie_escalations
+ WHERE status = 'open'
+ GROUP BY category`
+ ),
+ ]);
+
+ const byCategoryMap: Record = {};
+ for (const row of byCategory.rows) {
+ byCategoryMap[row.category] = parseInt(row.count, 10);
+ }
+
+ return {
+ open: parseInt(openCount.rows[0]?.count || '0', 10),
+ resolved_today: parseInt(resolvedToday.rows[0]?.count || '0', 10),
+ by_category: byCategoryMap,
+ };
+}
+
+/**
+ * Update escalation status
+ */
+export async function updateEscalationStatus(
+ id: number,
+ status: EscalationStatus,
+ resolvedBy?: string,
+ notes?: string
+): Promise {
+ const isResolved = status === 'resolved' || status === 'wont_do' || status === 'expired';
+
+ const result = await query(
+ `UPDATE addie_escalations
+ SET status = $2,
+ resolved_by = CASE WHEN $3::text IS NOT NULL THEN $3 ELSE resolved_by END,
+ resolved_at = CASE WHEN $4 THEN NOW() ELSE resolved_at END,
+ resolution_notes = CASE WHEN $5::text IS NOT NULL THEN $5 ELSE resolution_notes END,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *`,
+ [id, status, resolvedBy || null, isResolved, notes || null]
+ );
+ return result.rows[0] || null;
+}
+
+/**
+ * Mark notification as sent
+ */
+export async function markNotificationSent(
+ id: number,
+ channelId: string,
+ messageTs: string
+): Promise {
+ await query(
+ `UPDATE addie_escalations
+ SET notification_channel_id = $2,
+ notification_sent_at = NOW(),
+ notification_message_ts = $3,
+ updated_at = NOW()
+ WHERE id = $1`,
+ [id, channelId, messageTs]
+ );
+}
+
+/**
+ * Get escalations for a specific thread
+ */
+export async function getEscalationsForThread(threadId: string): Promise {
+ const result = await query(
+ `SELECT * FROM addie_escalations WHERE thread_id = $1 ORDER BY created_at DESC`,
+ [threadId]
+ );
+ return result.rows;
+}
diff --git a/server/src/db/migrations/167_addie_escalations.sql b/server/src/db/migrations/167_addie_escalations.sql
new file mode 100644
index 0000000000..a3fb2d2b4d
--- /dev/null
+++ b/server/src/db/migrations/167_addie_escalations.sql
@@ -0,0 +1,62 @@
+-- Addie Escalations System
+-- Tracks escalations when Addie encounters capability gaps or needs human help
+
+CREATE TABLE addie_escalations (
+ id SERIAL PRIMARY KEY,
+
+ -- Source tracking
+ thread_id UUID REFERENCES addie_threads(thread_id),
+ message_id UUID REFERENCES addie_thread_messages(message_id),
+
+ -- User who requested help
+ slack_user_id TEXT,
+ workos_user_id TEXT,
+ user_display_name TEXT,
+
+ -- Escalation details
+ category TEXT NOT NULL CHECK (category IN (
+ 'capability_gap', -- Addie can't do this action
+ 'needs_human_action', -- Requires human to take action
+ 'complex_request', -- Too complex for Addie
+ 'sensitive_topic', -- Needs human judgment
+ 'other'
+ )),
+ priority TEXT DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
+
+ -- Context
+ summary TEXT NOT NULL,
+ original_request TEXT,
+ addie_context TEXT,
+
+ -- Notification tracking
+ notification_channel_id TEXT,
+ notification_sent_at TIMESTAMP WITH TIME ZONE,
+ notification_message_ts TEXT,
+
+ -- Resolution workflow
+ status TEXT DEFAULT 'open' CHECK (status IN (
+ 'open',
+ 'acknowledged',
+ 'in_progress',
+ 'resolved',
+ 'wont_do',
+ 'expired'
+ )),
+ resolved_by TEXT,
+ resolved_at TIMESTAMP WITH TIME ZONE,
+ resolution_notes TEXT,
+
+ -- Audit
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+CREATE INDEX idx_escalations_status ON addie_escalations(status, created_at DESC);
+CREATE INDEX idx_escalations_thread ON addie_escalations(thread_id) WHERE thread_id IS NOT NULL;
+CREATE INDEX idx_escalations_user ON addie_escalations(slack_user_id) WHERE slack_user_id IS NOT NULL;
+
+-- Update trigger for updated_at
+CREATE TRIGGER update_escalations_updated_at
+ BEFORE UPDATE ON addie_escalations
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
diff --git a/server/src/routes/addie-admin.ts b/server/src/routes/addie-admin.ts
index 8264f0646b..f355bbf4b8 100644
--- a/server/src/routes/addie-admin.ts
+++ b/server/src/routes/addie-admin.ts
@@ -27,6 +27,15 @@ import {
resolveSlackUserDisplayName,
resolveSlackUserDisplayNames,
} from "../slack/client.js";
+import {
+ listEscalations,
+ countEscalations,
+ getEscalation,
+ updateEscalationStatus,
+ getEscalationStats,
+ type EscalationStatus,
+ type EscalationCategory,
+} from "../db/escalation-db.js";
const logger = createLogger("addie-admin-routes");
const addieDb = new AddieDatabase();
@@ -364,13 +373,14 @@ export function createAddieAdminRouter(): { pageRouter: Router; apiRouter: Route
apiRouter.get("/threads", requireAuth, requireAdmin, async (req, res) => {
try {
const threadService = getThreadService();
- const { channel, flagged_only, unreviewed_only, has_user_feedback, user_id, since, limit, offset } = req.query;
+ const { channel, flagged_only, unreviewed_only, has_user_feedback, min_messages, user_id, since, limit, offset } = req.query;
const threads = await threadService.listThreads({
channel: channel as ThreadChannel | undefined,
flagged_only: flagged_only === "true",
unreviewed_only: unreviewed_only === "true",
has_user_feedback: has_user_feedback === "true",
+ min_messages: min_messages ? parseInt(min_messages as string, 10) : undefined,
user_id: user_id as string | undefined,
since: since ? new Date(since as string) : undefined,
limit: limit ? parseInt(limit as string, 10) : 50,
@@ -2811,5 +2821,132 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be
}
});
+ // =========================================================================
+ // ESCALATION MANAGEMENT API (mounted at /api/admin/addie/escalations)
+ // =========================================================================
+
+ // GET /api/admin/addie/escalations - List escalations with optional filters
+ apiRouter.get("/escalations", requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const { status, category, limit, offset } = req.query;
+
+ const filters = {
+ status: status as EscalationStatus | undefined,
+ category: category as EscalationCategory | undefined,
+ };
+ const parsedLimit = limit ? parseInt(limit as string, 10) : 50;
+ const parsedOffset = offset ? parseInt(offset as string, 10) : 0;
+
+ const [escalations, totalCount, stats] = await Promise.all([
+ listEscalations({
+ ...filters,
+ limit: parsedLimit,
+ offset: parsedOffset,
+ }),
+ countEscalations(filters),
+ getEscalationStats(),
+ ]);
+
+ res.json({
+ escalations,
+ stats,
+ total: totalCount,
+ limit: parsedLimit,
+ offset: parsedOffset,
+ });
+ } catch (error) {
+ logger.error({ err: error }, "Error fetching escalations");
+ res.status(500).json({
+ error: "Internal server error",
+ message: "Unable to fetch escalations",
+ });
+ }
+ });
+
+ // GET /api/admin/addie/escalations/:id - Get single escalation with thread context
+ apiRouter.get("/escalations/:id", requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const id = parseNumericId(req.params.id);
+ if (!id) {
+ return res.status(400).json({ error: "Bad request", message: "Invalid ID" });
+ }
+
+ const escalation = await getEscalation(id);
+ if (!escalation) {
+ return res.status(404).json({ error: "Not found", message: "Escalation not found" });
+ }
+
+ // If escalation has a thread, get thread context
+ let threadContext = null;
+ if (escalation.thread_id) {
+ const threadService = getThreadService();
+ const thread = await threadService.getThreadWithMessages(escalation.thread_id);
+ if (thread) {
+ threadContext = {
+ thread_id: thread.thread_id,
+ channel: thread.channel,
+ created_at: thread.created_at,
+ messages: thread.messages?.slice(-10), // Last 10 messages
+ };
+ }
+ }
+
+ res.json({
+ escalation,
+ thread: threadContext,
+ });
+ } catch (error) {
+ logger.error({ err: error }, "Error fetching escalation");
+ res.status(500).json({
+ error: "Internal server error",
+ message: "Unable to fetch escalation",
+ });
+ }
+ });
+
+ // PATCH /api/admin/addie/escalations/:id - Update escalation status
+ apiRouter.patch("/escalations/:id", requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const id = parseNumericId(req.params.id);
+ if (!id) {
+ return res.status(400).json({ error: "Bad request", message: "Invalid ID" });
+ }
+
+ const { status, notes } = req.body;
+ if (!status) {
+ return res.status(400).json({ error: "Bad request", message: "Status is required" });
+ }
+
+ const validStatuses: EscalationStatus[] = [
+ 'open', 'acknowledged', 'in_progress', 'resolved', 'wont_do', 'expired'
+ ];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({ error: "Bad request", message: "Invalid status" });
+ }
+
+ // Get current user from session for resolved_by
+ const user = (req as unknown as { user?: { email?: string } }).user;
+ const resolvedBy = user?.email || 'admin';
+
+ const updated = await updateEscalationStatus(id, status, resolvedBy, notes);
+ if (!updated) {
+ return res.status(404).json({ error: "Not found", message: "Escalation not found" });
+ }
+
+ logger.info({ escalationId: id, status, resolvedBy }, "Escalation status updated");
+
+ res.json({
+ success: true,
+ escalation: updated,
+ });
+ } catch (error) {
+ logger.error({ err: error }, "Error updating escalation");
+ res.status(500).json({
+ error: "Internal server error",
+ message: "Unable to update escalation",
+ });
+ }
+ });
+
return { pageRouter, apiRouter };
}