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

+ @@ -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 }; }