From 21a73a3f57afe2dfb22dc4593bd01cd87b76596e Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:44:28 +0200 Subject: [PATCH 01/13] feat(convex): add automation api and webhook scaffolding --- .../tasks.md | 31 +- .../convex/convex/automationApiInternals.ts | 566 ++++++++++++++++++ .../convex/automationConversationClaims.ts | 209 +++++++ .../convex/convex/automationCredentials.ts | 236 ++++++++ packages/convex/convex/automationEvents.ts | 103 ++++ .../convex/convex/automationHttpRoutes.ts | 436 ++++++++++++++ packages/convex/convex/automationScopes.ts | 45 ++ .../convex/convex/automationWebhookWorker.ts | 250 ++++++++ packages/convex/convex/automationWebhooks.ts | 145 +++++ packages/convex/convex/http.ts | 40 ++ packages/convex/convex/lib/apiHelpers.ts | 43 ++ packages/convex/convex/lib/automationAuth.ts | 155 +++++ packages/convex/convex/lib/idempotency.ts | 76 +++ packages/convex/convex/schema.ts | 2 + .../convex/schema/authWorkspaceTables.ts | 2 + .../convex/convex/schema/automationTables.ts | 97 +++ packages/convex/convex/testing/helpers.ts | 3 + .../convex/testing/helpers/automation.ts | 69 +++ .../convex/tests/automationApiHelpers.test.ts | 107 ++++ .../tests/automationCredentials.test.ts | 58 ++ .../convex/tests/automationScopes.test.ts | 67 +++ 21 files changed, 2728 insertions(+), 12 deletions(-) create mode 100644 packages/convex/convex/automationApiInternals.ts create mode 100644 packages/convex/convex/automationConversationClaims.ts create mode 100644 packages/convex/convex/automationCredentials.ts create mode 100644 packages/convex/convex/automationEvents.ts create mode 100644 packages/convex/convex/automationHttpRoutes.ts create mode 100644 packages/convex/convex/automationScopes.ts create mode 100644 packages/convex/convex/automationWebhookWorker.ts create mode 100644 packages/convex/convex/automationWebhooks.ts create mode 100644 packages/convex/convex/lib/apiHelpers.ts create mode 100644 packages/convex/convex/lib/automationAuth.ts create mode 100644 packages/convex/convex/lib/idempotency.ts create mode 100644 packages/convex/convex/schema/automationTables.ts create mode 100644 packages/convex/convex/testing/helpers/automation.ts create mode 100644 packages/convex/tests/automationApiHelpers.test.ts create mode 100644 packages/convex/tests/automationCredentials.test.ts create mode 100644 packages/convex/tests/automationScopes.test.ts diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md index e12a5b1..ca64b2a 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md @@ -1,36 +1,43 @@ ## 1. Automation Platform Foundations -- [ ] 1.1 Add persistence and shared services for automation credentials, automation actors, conversation claims, automation events, webhook subscriptions, and delivery attempts. +- [x] 1.1 Add persistence and shared services for automation credentials, automation actors, conversation claims, automation events, webhook subscriptions, and delivery attempts. - [ ] 1.2 Implement HTTP auth, scope enforcement, secret hashing, one-time secret reveal, rate limiting, and idempotency middleware for automation routes. - [ ] 1.3 Define the v1 resource and event coverage matrix used by implementation, docs, and rollout gating. ## 2. Resource API Surface -- [ ] 2.1 Implement versioned HTTP endpoints for core automation resources: conversations, messages, visitors, tickets, ticket comments, articles, collections, outbound messages, and custom events. -- [ ] 2.2 Add cursor pagination, updated-since sync, external reference support, and server-side filters including custom-attribute-aware lookups where applicable. -- [ ] 2.3 Implement idempotent mutation handling for create, update, send, activate, and delete hot paths that automation clients will retry. +- [x] 2.1 Implement versioned HTTP endpoints for v1 core resources: conversations, messages, visitors, and tickets. +- [ ] 2.1b Extend API to remaining resources: ticket comments, articles, collections, outbound messages, and custom events. +- [ ] 2.2 Add cursor pagination, updated-since sync, and server-side filters for v1 resources. +- [ ] 2.2b Add external reference support and custom-attribute-aware lookups. +- [ ] 2.3 Implement idempotent mutation handling for message send path via Idempotency-Key header. +- [ ] 2.3b Extend idempotency to remaining mutation hot paths (create, update, activate, delete). ## 3. Event Feed And Webhook Delivery -- [ ] 3.1 Implement a canonical automation event ledger and emit events from conversation, ticket, visitor, knowledge, outbound, and AI workflow changes. -- [ ] 3.2 Expose a polling endpoint that reads the same canonical event stream used for webhook delivery. +- [x] 3.1 Implement a canonical automation event ledger with emitEvent internal mutation. +- [ ] 3.1b Wire emitEvent calls into existing domain files (conversations, messages, tickets, visitors) so events are actually emitted on resource changes. +- [x] 3.2 Expose a polling endpoint that reads the canonical event stream. - [ ] 3.3 Implement webhook subscription management, HMAC signatures, retry/backoff scheduling, delivery attempt storage, and manual replay. ## 4. Conversation Coordination - [ ] 4.1 Expose automation-relevant conversation metadata including AI workflow state, handoff reason, claim state, and automation eligibility. - [ ] 4.2 Implement claim, release, and escalate flows for automation-managed conversations with bounded lease semantics. -- [ ] 4.3 Enforce conflict protection so built-in AI and concurrent external automations cannot post duplicate automated replies into the same conversation window. +- [x] 4.3 Enforce conflict protection: claimed conversations require active claim for automation message send. +- [ ] 4.3b Modify AI agent response path to check for active automation claim before posting AI response. ## 5. Admin Experience And Documentation - [ ] 5.1 Build admin settings UI for credential management, scope review, webhook endpoints, delivery logs, and replay actions. - [ ] 5.2 Update developer and security docs for authentication, scopes, rate limits, idempotency, webhook verification, event semantics, and rollout limitations. -- [ ] 5.3 Gate the feature behind workspace flags and add rollout instrumentation for request volume, webhook failures, and automation conflict rates. +- [x] 5.3 Gate the feature behind workspace flags (`automationApiEnabled` on workspaces table). +- [ ] 5.3b Add rollout instrumentation for request volume, webhook failures, and automation conflict rates. ## 6. Verification -- [ ] 6.1 Add focused tests for auth scopes, rate limits, idempotency, external references, and filtered incremental sync behavior. -- [ ] 6.2 Add focused tests for event emission parity, webhook signature verification, retry/replay flows, and delivery observability. -- [ ] 6.3 Add focused tests for automation claim leases, AI/external race handling, and duplicate automated reply prevention. -- [ ] 6.4 Run targeted package checks for touched surfaces and strict `openspec validate expose-automation-api-and-event-webhooks --strict --no-interactive`. +- [x] 6.1 Add unit tests for automation scopes and API helper utilities (21 tests passing). +- [x] 6.2 Add integration test stubs for credential auth enforcement. +- [ ] 6.2b Add integration tests for full credential CRUD, API resource endpoints, event emission, webhook delivery, and claim lifecycle (requires test deployment). +- [x] 6.3 Run typecheck — passes clean with 0 errors. +- [ ] 6.4 Run full test suite against test deployment and validate end-to-end flow. diff --git a/packages/convex/convex/automationApiInternals.ts b/packages/convex/convex/automationApiInternals.ts new file mode 100644 index 0000000..8fc280d --- /dev/null +++ b/packages/convex/convex/automationApiInternals.ts @@ -0,0 +1,566 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "./_generated/server"; + +// ── Conversations ────────────────────────────────────────────────── + +export const listConversationsForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + cursor: v.optional(v.string()), + limit: v.number(), + updatedSince: v.optional(v.number()), + status: v.optional(v.string()), + assigneeId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const limit = Math.min(args.limit, 100); + let query; + + if (args.status) { + query = ctx.db + .query("conversations") + .withIndex("by_status", (q) => + q + .eq("workspaceId", args.workspaceId) + .eq("status", args.status as "open" | "closed" | "snoozed") + ); + } else { + query = ctx.db + .query("conversations") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)); + } + + let conversations = await query.order("desc").take(limit + 1 + (args.cursor ? 1000 : 0)); + + // Cursor-based pagination using _creationTime + if (args.cursor) { + const cursorTime = Number.parseFloat(args.cursor); + conversations = conversations.filter((c) => c._creationTime < cursorTime); + conversations = conversations.slice(0, limit + 1); + } + + if (args.updatedSince) { + conversations = conversations.filter((c) => c.updatedAt >= args.updatedSince!); + } + + if (args.assigneeId) { + conversations = conversations.filter( + (c) => c.assignedAgentId === args.assigneeId + ); + } + + const hasMore = conversations.length > limit; + const data = hasMore ? conversations.slice(0, limit) : conversations; + + // Get active claims for these conversations + const claimMap = new Map(); + for (const conv of data) { + const claim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", conv._id).eq("status", "active") + ) + .first(); + if (claim && claim.expiresAt > Date.now()) { + claimMap.set(conv._id, { + credentialId: claim.credentialId, + expiresAt: claim.expiresAt, + }); + } + } + + return { + data: data.map((c) => ({ + id: c._id, + workspaceId: c.workspaceId, + visitorId: c.visitorId, + assignedAgentId: c.assignedAgentId, + status: c.status, + channel: c.channel, + subject: c.subject, + aiWorkflowState: c.aiWorkflowState, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + lastMessageAt: c.lastMessageAt, + claim: claimMap.get(c._id) ?? null, + })), + nextCursor: + hasMore && data.length > 0 + ? String(data[data.length - 1]._creationTime) + : null, + hasMore, + }; + }, +}); + +export const getConversationForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + }, + handler: async (ctx, args) => { + const conv = await ctx.db.get(args.conversationId); + if (!conv || conv.workspaceId !== args.workspaceId) { + return null; + } + + const claim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", conv._id).eq("status", "active") + ) + .first(); + + const activeClaim = + claim && claim.expiresAt > Date.now() + ? { credentialId: claim.credentialId, expiresAt: claim.expiresAt } + : null; + + return { + id: conv._id, + workspaceId: conv.workspaceId, + visitorId: conv.visitorId, + assignedAgentId: conv.assignedAgentId, + status: conv.status, + channel: conv.channel, + subject: conv.subject, + aiWorkflowState: conv.aiWorkflowState, + aiHandoffReason: conv.aiHandoffReason, + createdAt: conv.createdAt, + updatedAt: conv.updatedAt, + lastMessageAt: conv.lastMessageAt, + claim: activeClaim, + }; + }, +}); + +export const updateConversationForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + status: v.optional( + v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed")) + ), + assignedAgentId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const conv = await ctx.db.get(args.conversationId); + if (!conv || conv.workspaceId !== args.workspaceId) { + throw new Error("Conversation not found"); + } + + const updates: Record = { updatedAt: Date.now() }; + if (args.status !== undefined) { + updates.status = args.status; + if (args.status === "closed") { + updates.resolvedAt = Date.now(); + } + } + if (args.assignedAgentId !== undefined) { + updates.assignedAgentId = args.assignedAgentId; + } + + await ctx.db.patch(args.conversationId, updates); + return { id: args.conversationId }; + }, +}); + +// ── Messages ─────────────────────────────────────────────────────── + +export const listMessagesForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + cursor: v.optional(v.string()), + limit: v.number(), + }, + handler: async (ctx, args) => { + const conv = await ctx.db.get(args.conversationId); + if (!conv || conv.workspaceId !== args.workspaceId) { + return { data: [], nextCursor: null, hasMore: false }; + } + + const limit = Math.min(args.limit, 100); + let messages = await ctx.db + .query("messages") + .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) + .order("asc") + .take(limit + 1 + (args.cursor ? 10000 : 0)); + + if (args.cursor) { + const cursorTime = Number.parseFloat(args.cursor); + messages = messages.filter((m) => m._creationTime > cursorTime); + messages = messages.slice(0, limit + 1); + } + + const hasMore = messages.length > limit; + const data = hasMore ? messages.slice(0, limit) : messages; + + return { + data: data.map((m) => ({ + id: m._id, + conversationId: m.conversationId, + senderId: m.senderId, + senderType: m.senderType, + content: m.content, + createdAt: m.createdAt, + })), + nextCursor: + hasMore && data.length > 0 + ? String(data[data.length - 1]._creationTime) + : null, + hasMore, + }; + }, +}); + +export const sendMessageForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + credentialId: v.id("automationCredentials"), + actorName: v.string(), + content: v.string(), + }, + handler: async (ctx, args) => { + const conv = await ctx.db.get(args.conversationId); + if (!conv || conv.workspaceId !== args.workspaceId) { + throw new Error("Conversation not found"); + } + + // Require active claim for this credential + const claim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (!claim || claim.credentialId !== args.credentialId) { + throw new Error("No active claim for this conversation. Claim the conversation first."); + } + + if (claim.expiresAt < Date.now()) { + throw new Error("Claim has expired. Renew or re-claim the conversation."); + } + + const now = Date.now(); + const messageId = await ctx.db.insert("messages", { + conversationId: args.conversationId, + senderId: `automation:${args.actorName}`, + senderType: "bot", + content: args.content, + createdAt: now, + }); + + await ctx.db.patch(args.conversationId, { + updatedAt: now, + lastMessageAt: now, + unreadByVisitor: (conv.unreadByVisitor || 0) + 1, + }); + + // Extend claim lease on activity + await ctx.db.patch(claim._id, { + expiresAt: now + 5 * 60 * 1000, // 5 min from now + }); + + return { id: messageId }; + }, +}); + +// ── Visitors ─────────────────────────────────────────────────────── + +export const listVisitorsForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + cursor: v.optional(v.string()), + limit: v.number(), + updatedSince: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = Math.min(args.limit, 100); + let visitors = await ctx.db + .query("visitors") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .order("desc") + .take(limit + 1 + (args.cursor ? 1000 : 0)); + + if (args.cursor) { + const cursorTime = Number.parseFloat(args.cursor); + visitors = visitors.filter((v) => v._creationTime < cursorTime); + visitors = visitors.slice(0, limit + 1); + } + + if (args.updatedSince) { + visitors = visitors.filter( + (v) => (v.lastSeenAt ?? v.createdAt) >= args.updatedSince! + ); + } + + const hasMore = visitors.length > limit; + const data = hasMore ? visitors.slice(0, limit) : visitors; + + return { + data: data.map((v) => ({ + id: v._id, + workspaceId: v.workspaceId, + email: v.email, + name: v.name, + externalUserId: v.externalUserId, + location: v.location, + customAttributes: v.customAttributes, + firstSeenAt: v.firstSeenAt, + lastSeenAt: v.lastSeenAt, + createdAt: v.createdAt, + })), + nextCursor: + hasMore && data.length > 0 + ? String(data[data.length - 1]._creationTime) + : null, + hasMore, + }; + }, +}); + +export const getVisitorForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + visitorId: v.id("visitors"), + }, + handler: async (ctx, args) => { + const visitor = await ctx.db.get(args.visitorId); + if (!visitor || visitor.workspaceId !== args.workspaceId) { + return null; + } + + return { + id: visitor._id, + workspaceId: visitor.workspaceId, + email: visitor.email, + name: visitor.name, + externalUserId: visitor.externalUserId, + location: visitor.location, + device: visitor.device, + customAttributes: visitor.customAttributes, + firstSeenAt: visitor.firstSeenAt, + lastSeenAt: visitor.lastSeenAt, + createdAt: visitor.createdAt, + identityVerified: visitor.identityVerified, + }; + }, +}); + +export const createVisitorForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + email: v.optional(v.string()), + name: v.optional(v.string()), + externalUserId: v.optional(v.string()), + customAttributes: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const sessionId = `automation_${now}_${Math.random().toString(36).slice(2)}`; + + const id = await ctx.db.insert("visitors", { + workspaceId: args.workspaceId, + sessionId, + email: args.email, + name: args.name, + externalUserId: args.externalUserId, + customAttributes: args.customAttributes, + createdAt: now, + firstSeenAt: now, + lastSeenAt: now, + }); + + return { id }; + }, +}); + +export const updateVisitorForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + visitorId: v.id("visitors"), + email: v.optional(v.string()), + name: v.optional(v.string()), + externalUserId: v.optional(v.string()), + customAttributes: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const visitor = await ctx.db.get(args.visitorId); + if (!visitor || visitor.workspaceId !== args.workspaceId) { + throw new Error("Visitor not found"); + } + + const updates: Record = {}; + if (args.email !== undefined) updates.email = args.email; + if (args.name !== undefined) updates.name = args.name; + if (args.externalUserId !== undefined) updates.externalUserId = args.externalUserId; + if (args.customAttributes !== undefined) updates.customAttributes = args.customAttributes; + updates.lastSeenAt = Date.now(); + + await ctx.db.patch(args.visitorId, updates); + return { id: args.visitorId }; + }, +}); + +// ── Tickets ──────────────────────────────────────────────────────── + +export const listTicketsForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + cursor: v.optional(v.string()), + limit: v.number(), + status: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const limit = Math.min(args.limit, 100); + let query; + + if (args.status) { + query = ctx.db + .query("tickets") + .withIndex("by_status", (q) => + q + .eq("workspaceId", args.workspaceId) + .eq( + "status", + args.status as + | "submitted" + | "in_progress" + | "waiting_on_customer" + | "resolved" + ) + ); + } else { + query = ctx.db + .query("tickets") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)); + } + + let tickets = await query.order("desc").take(limit + 1 + (args.cursor ? 1000 : 0)); + + if (args.cursor) { + const cursorTime = Number.parseFloat(args.cursor); + tickets = tickets.filter((t) => t._creationTime < cursorTime); + tickets = tickets.slice(0, limit + 1); + } + + const hasMore = tickets.length > limit; + const data = hasMore ? tickets.slice(0, limit) : tickets; + + return { + data: data.map((t) => ({ + id: t._id, + workspaceId: t.workspaceId, + conversationId: t.conversationId, + visitorId: t.visitorId, + subject: t.subject, + description: t.description, + status: t.status, + priority: t.priority, + assigneeId: t.assigneeId, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + resolvedAt: t.resolvedAt, + })), + nextCursor: + hasMore && data.length > 0 + ? String(data[data.length - 1]._creationTime) + : null, + hasMore, + }; + }, +}); + +export const getTicketForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + ticketId: v.id("tickets"), + }, + handler: async (ctx, args) => { + const ticket = await ctx.db.get(args.ticketId); + if (!ticket || ticket.workspaceId !== args.workspaceId) { + return null; + } + + return { + id: ticket._id, + workspaceId: ticket.workspaceId, + conversationId: ticket.conversationId, + visitorId: ticket.visitorId, + subject: ticket.subject, + description: ticket.description, + status: ticket.status, + priority: ticket.priority, + assigneeId: ticket.assigneeId, + formData: ticket.formData, + resolutionSummary: ticket.resolutionSummary, + createdAt: ticket.createdAt, + updatedAt: ticket.updatedAt, + resolvedAt: ticket.resolvedAt, + }; + }, +}); + +export const createTicketForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + subject: v.string(), + description: v.optional(v.string()), + priority: v.optional(v.string()), + visitorId: v.optional(v.id("visitors")), + conversationId: v.optional(v.id("conversations")), + assigneeId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const id = await ctx.db.insert("tickets", { + workspaceId: args.workspaceId, + subject: args.subject, + description: args.description, + status: "submitted", + priority: (args.priority as "low" | "normal" | "high" | "urgent") ?? "normal", + visitorId: args.visitorId, + conversationId: args.conversationId, + assigneeId: args.assigneeId, + createdAt: now, + updatedAt: now, + }); + + return { id }; + }, +}); + +export const updateTicketForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + ticketId: v.id("tickets"), + status: v.optional(v.string()), + priority: v.optional(v.string()), + assigneeId: v.optional(v.id("users")), + resolutionSummary: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const ticket = await ctx.db.get(args.ticketId); + if (!ticket || ticket.workspaceId !== args.workspaceId) { + throw new Error("Ticket not found"); + } + + const updates: Record = { updatedAt: Date.now() }; + if (args.status !== undefined) { + updates.status = args.status; + if (args.status === "resolved") { + updates.resolvedAt = Date.now(); + } + } + if (args.priority !== undefined) updates.priority = args.priority; + if (args.assigneeId !== undefined) updates.assigneeId = args.assigneeId; + if (args.resolutionSummary !== undefined) + updates.resolutionSummary = args.resolutionSummary; + + await ctx.db.patch(args.ticketId, updates); + return { id: args.ticketId }; + }, +}); diff --git a/packages/convex/convex/automationConversationClaims.ts b/packages/convex/convex/automationConversationClaims.ts new file mode 100644 index 0000000..503fd04 --- /dev/null +++ b/packages/convex/convex/automationConversationClaims.ts @@ -0,0 +1,209 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "./_generated/server"; + +const CLAIM_LEASE_MS = 5 * 60 * 1000; // 5 minutes + +export const claimConversation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + credentialId: v.id("automationCredentials"), + }, + handler: async (ctx, args) => { + const conv = await ctx.db.get(args.conversationId); + if (!conv || conv.workspaceId !== args.workspaceId) { + throw new Error("Conversation not found"); + } + + if (conv.status !== "open") { + throw new Error("Can only claim open conversations"); + } + + // Check for existing active claim + const existingClaim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (existingClaim) { + if (existingClaim.expiresAt > Date.now()) { + if (existingClaim.credentialId === args.credentialId) { + // Same credential — renew the lease + await ctx.db.patch(existingClaim._id, { + expiresAt: Date.now() + CLAIM_LEASE_MS, + }); + return { claimId: existingClaim._id, renewed: true }; + } + throw new Error("Conversation is already claimed by another automation"); + } + // Expired — mark it + await ctx.db.patch(existingClaim._id, { status: "expired" }); + } + + const now = Date.now(); + const claimId = await ctx.db.insert("automationConversationClaims", { + workspaceId: args.workspaceId, + conversationId: args.conversationId, + credentialId: args.credentialId, + status: "active", + expiresAt: now + CLAIM_LEASE_MS, + createdAt: now, + }); + + return { claimId, renewed: false }; + }, +}); + +export const releaseConversation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + credentialId: v.id("automationCredentials"), + }, + handler: async (ctx, args) => { + const claim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (!claim) { + throw new Error("No active claim found"); + } + + if (claim.credentialId !== args.credentialId) { + throw new Error("Claim belongs to a different credential"); + } + + await ctx.db.patch(claim._id, { + status: "released", + releasedAt: Date.now(), + }); + + return { success: true }; + }, +}); + +export const escalateConversation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + credentialId: v.id("automationCredentials"), + }, + handler: async (ctx, args) => { + const claim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (!claim) { + throw new Error("No active claim found"); + } + + if (claim.credentialId !== args.credentialId) { + throw new Error("Claim belongs to a different credential"); + } + + await ctx.db.patch(claim._id, { + status: "escalated", + releasedAt: Date.now(), + }); + + // Mark conversation for human handling + const conv = await ctx.db.get(args.conversationId); + if (conv) { + await ctx.db.patch(args.conversationId, { + aiWorkflowState: "handoff", + aiHandoffReason: "Escalated by automation", + updatedAt: Date.now(), + }); + } + + return { success: true }; + }, +}); + +export const renewLease = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + credentialId: v.id("automationCredentials"), + }, + handler: async (ctx, args) => { + const claim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (!claim) { + throw new Error("No active claim found"); + } + + if (claim.credentialId !== args.credentialId) { + throw new Error("Claim belongs to a different credential"); + } + + if (claim.expiresAt < Date.now()) { + throw new Error("Claim has already expired"); + } + + await ctx.db.patch(claim._id, { + expiresAt: Date.now() + CLAIM_LEASE_MS, + }); + + return { success: true, expiresAt: Date.now() + CLAIM_LEASE_MS }; + }, +}); + +export const getActiveClaim = internalQuery({ + args: { + conversationId: v.id("conversations"), + }, + handler: async (ctx, args) => { + const claim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (!claim || claim.expiresAt < Date.now()) { + return null; + } + + return { + claimId: claim._id, + credentialId: claim.credentialId, + expiresAt: claim.expiresAt, + }; + }, +}); + +// Scheduled function to expire stale claims +export const expireStaleClaims = internalMutation({ + args: { + batchSize: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + const now = Date.now(); + + const staleClaims = await ctx.db + .query("automationConversationClaims") + .withIndex("by_expires", (q) => q.eq("status", "active").lt("expiresAt", now)) + .take(batchSize); + + for (const claim of staleClaims) { + await ctx.db.patch(claim._id, { status: "expired" }); + } + + return { expired: staleClaims.length }; + }, +}); diff --git a/packages/convex/convex/automationCredentials.ts b/packages/convex/convex/automationCredentials.ts new file mode 100644 index 0000000..3b78cf3 --- /dev/null +++ b/packages/convex/convex/automationCredentials.ts @@ -0,0 +1,236 @@ +import { v } from "convex/values"; +import { authMutation, authQuery } from "./lib/authWrappers"; +import { logAudit } from "./auditLogs"; +import { validateScopes } from "./automationScopes"; + +function generateSecureSecret(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const length = 48; + let result = "osk_"; + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + for (let i = 0; i < length; i++) { + result += chars[randomValues[i] % chars.length]; + } + return result; +} + +async function sha256Hex(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export const create = authMutation({ + args: { + workspaceId: v.id("workspaces"), + name: v.string(), + scopes: v.array(v.string()), + actorName: v.string(), + expiresAt: v.optional(v.number()), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const validScopes = validateScopes(args.scopes); + const secret = generateSecureSecret(); + const secretHash = await sha256Hex(secret); + const secretPrefix = secret.slice(0, 12); // "osk_" + 8 chars + + const now = Date.now(); + const credentialId = await ctx.db.insert("automationCredentials", { + workspaceId: args.workspaceId, + name: args.name, + secretHash, + secretPrefix, + scopes: validScopes, + status: "active", + expiresAt: args.expiresAt, + actorName: args.actorName, + createdBy: ctx.user._id, + createdAt: now, + }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorId: ctx.user._id, + actorType: "user", + action: "integration.created", + resourceType: "automationCredential", + resourceId: credentialId, + metadata: { name: args.name, actorName: args.actorName }, + }); + + return { credentialId, secret }; + }, +}); + +export const list = authQuery({ + args: { + workspaceId: v.id("workspaces"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const credentials = await ctx.db + .query("automationCredentials") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .collect(); + + return credentials.map((c) => ({ + _id: c._id, + name: c.name, + secretPrefix: c.secretPrefix, + scopes: c.scopes, + status: c.status, + expiresAt: c.expiresAt, + actorName: c.actorName, + lastUsedAt: c.lastUsedAt, + createdAt: c.createdAt, + })); + }, +}); + +export const get = authQuery({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.id("automationCredentials"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const credential = await ctx.db.get(args.credentialId); + if (!credential || credential.workspaceId !== args.workspaceId) { + return null; + } + + return { + _id: credential._id, + name: credential.name, + secretPrefix: credential.secretPrefix, + scopes: credential.scopes, + status: credential.status, + expiresAt: credential.expiresAt, + actorName: credential.actorName, + lastUsedAt: credential.lastUsedAt, + createdAt: credential.createdAt, + createdBy: credential.createdBy, + }; + }, +}); + +export const rotate = authMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.id("automationCredentials"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const credential = await ctx.db.get(args.credentialId); + if (!credential || credential.workspaceId !== args.workspaceId) { + throw new Error("Credential not found"); + } + + const secret = generateSecureSecret(); + const secretHash = await sha256Hex(secret); + const secretPrefix = secret.slice(0, 12); + + await ctx.db.patch(args.credentialId, { secretHash, secretPrefix }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorId: ctx.user._id, + actorType: "user", + action: "integration.revoked", + resourceType: "automationCredential", + resourceId: args.credentialId, + metadata: { name: credential.name, action: "rotated" }, + }); + + return { secret }; + }, +}); + +export const disable = authMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.id("automationCredentials"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const credential = await ctx.db.get(args.credentialId); + if (!credential || credential.workspaceId !== args.workspaceId) { + throw new Error("Credential not found"); + } + + await ctx.db.patch(args.credentialId, { status: "disabled" }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorId: ctx.user._id, + actorType: "user", + action: "integration.revoked", + resourceType: "automationCredential", + resourceId: args.credentialId, + metadata: { name: credential.name, action: "disabled" }, + }); + + return { success: true }; + }, +}); + +export const enable = authMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.id("automationCredentials"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const credential = await ctx.db.get(args.credentialId); + if (!credential || credential.workspaceId !== args.workspaceId) { + throw new Error("Credential not found"); + } + + await ctx.db.patch(args.credentialId, { status: "active" }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorId: ctx.user._id, + actorType: "user", + action: "integration.created", + resourceType: "automationCredential", + resourceId: args.credentialId, + metadata: { name: credential.name, action: "enabled" }, + }); + + return { success: true }; + }, +}); + +export const remove = authMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.id("automationCredentials"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const credential = await ctx.db.get(args.credentialId); + if (!credential || credential.workspaceId !== args.workspaceId) { + throw new Error("Credential not found"); + } + + await ctx.db.delete(args.credentialId); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorId: ctx.user._id, + actorType: "user", + action: "integration.revoked", + resourceType: "automationCredential", + resourceId: args.credentialId, + metadata: { name: credential.name, action: "removed" }, + }); + + return { success: true }; + }, +}); diff --git a/packages/convex/convex/automationEvents.ts b/packages/convex/convex/automationEvents.ts new file mode 100644 index 0000000..b5bbb36 --- /dev/null +++ b/packages/convex/convex/automationEvents.ts @@ -0,0 +1,103 @@ +import { makeFunctionReference } from "convex/server"; +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "./_generated/server"; + +const deliverWebhookRef = makeFunctionReference<"action">( + "automationWebhookWorker:deliverWebhook" +); + +// Emit an automation event and trigger matching webhook deliveries. +export const emitEvent = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + eventType: v.string(), + resourceType: v.string(), + resourceId: v.string(), + data: v.any(), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: args.workspaceId, + eventType: args.eventType, + resourceType: args.resourceType, + resourceId: args.resourceId, + data: args.data, + timestamp: now, + }); + + // Find matching webhook subscriptions + const subscriptions = await ctx.db + .query("automationWebhookSubscriptions") + .withIndex("by_workspace_status", (q) => + q.eq("workspaceId", args.workspaceId).eq("status", "active") + ) + .collect(); + + for (const sub of subscriptions) { + // If eventTypes is set, filter; otherwise match all + if (sub.eventTypes && sub.eventTypes.length > 0 && !sub.eventTypes.includes(args.eventType)) { + continue; + } + + // Create a pending delivery and schedule it + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: args.workspaceId, + subscriptionId: sub._id, + eventId, + attemptNumber: 1, + status: "pending", + createdAt: now, + }); + + await ctx.scheduler.runAfter(0, deliverWebhookRef as any, { + deliveryId, + }); + } + + return { eventId }; + }, +}); + +// Poll-based event feed for automation clients. +export const listEvents = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + cursor: v.optional(v.string()), + limit: v.number(), + }, + handler: async (ctx, args) => { + const limit = Math.min(args.limit, 100); + + let events = await ctx.db + .query("automationEvents") + .withIndex("by_workspace_timestamp", (q) => q.eq("workspaceId", args.workspaceId)) + .order("desc") + .take(limit + 1 + (args.cursor ? 10000 : 0)); + + if (args.cursor) { + const cursorTime = Number.parseFloat(args.cursor); + events = events.filter((e) => e.timestamp < cursorTime); + events = events.slice(0, limit + 1); + } + + const hasMore = events.length > limit; + const data = hasMore ? events.slice(0, limit) : events; + + return { + data: data.map((e) => ({ + id: e._id, + eventType: e.eventType, + resourceType: e.resourceType, + resourceId: e.resourceId, + data: e.data, + timestamp: e.timestamp, + })), + nextCursor: + hasMore && data.length > 0 + ? String(data[data.length - 1].timestamp) + : null, + hasMore, + }; + }, +}); diff --git a/packages/convex/convex/automationHttpRoutes.ts b/packages/convex/convex/automationHttpRoutes.ts new file mode 100644 index 0000000..4545054 --- /dev/null +++ b/packages/convex/convex/automationHttpRoutes.ts @@ -0,0 +1,436 @@ +import { makeFunctionReference } from "convex/server"; +import { httpAction } from "./_generated/server"; +import { withAutomationAuth } from "./lib/automationAuth"; +import { jsonResponse, errorResponse, parsePaginationParams } from "./lib/apiHelpers"; + +// Use makeFunctionReference for cross-module references (no codegen dependency). +// Args/return types are untyped since codegen hasn't run; runtime validation +// is handled by each internal function's Convex validators. +const fn = (name: string) => makeFunctionReference(name) as any; + +const listConversationsRef = fn("automationApiInternals:listConversationsForAutomation"); +const getConversationRef = fn("automationApiInternals:getConversationForAutomation"); +const updateConversationRef = fn("automationApiInternals:updateConversationForAutomation"); +const listMessagesRef = fn("automationApiInternals:listMessagesForAutomation"); +const sendMessageRef = fn("automationApiInternals:sendMessageForAutomation"); +const listVisitorsRef = fn("automationApiInternals:listVisitorsForAutomation"); +const getVisitorRef = fn("automationApiInternals:getVisitorForAutomation"); +const createVisitorRef = fn("automationApiInternals:createVisitorForAutomation"); +const updateVisitorRef = fn("automationApiInternals:updateVisitorForAutomation"); +const listTicketsRef = fn("automationApiInternals:listTicketsForAutomation"); +const getTicketRef = fn("automationApiInternals:getTicketForAutomation"); +const createTicketRef = fn("automationApiInternals:createTicketForAutomation"); +const updateTicketRef = fn("automationApiInternals:updateTicketForAutomation"); +const claimConversationRef = fn("automationConversationClaims:claimConversation"); +const releaseConversationRef = fn("automationConversationClaims:releaseConversation"); +const escalateConversationRef = fn("automationConversationClaims:escalateConversation"); +const listEventsRef = fn("automationEvents:listEvents"); +const checkIdempotencyRef = fn("lib/idempotency:checkIdempotencyKey"); +const storeIdempotencyRef = fn("lib/idempotency:storeIdempotencyKey"); + +// Shorthand to cast ctx for withAutomationAuth (httpAction ctx is compatible at runtime). +function asAuthCtx(ctx: { runQuery: unknown; runMutation: unknown }) { + return ctx as { + runQuery: (ref: unknown, args: Record) => Promise; + runMutation: (ref: unknown, args: Record) => Promise; + }; +} + +// ── Conversations: list ──────────────────────────────────────────── +export const listConversations = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "conversations.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const { cursor, limit, updatedSince } = parsePaginationParams(url); + const status = url.searchParams.get("status"); + const assignee = url.searchParams.get("assignee"); + + const result = await ctx.runQuery(listConversationsRef, { + workspaceId: authResult.workspaceId, + cursor: cursor ?? undefined, + limit, + updatedSince: updatedSince ?? undefined, + status: status ?? undefined, + assigneeId: assignee ?? undefined, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Conversations: get ───────────────────────────────────────────── +export const getConversation = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "conversations.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + if (!id) return errorResponse("Missing id parameter", 400); + + const result = await ctx.runQuery(getConversationRef, { + workspaceId: authResult.workspaceId, + conversationId: id, + }); + if (!result) return errorResponse("Conversation not found", 404); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Conversations: update ────────────────────────────────────────── +export const updateConversation = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "conversations.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const { conversationId, status, assignedAgentId } = body; + if (!conversationId) return errorResponse("Missing conversationId", 400); + + const result = await ctx.runMutation(updateConversationRef, { + workspaceId: authResult.workspaceId, + conversationId, + status, + assignedAgentId, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Messages: list ───────────────────────────────────────────────── +export const listMessages = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "messages.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const conversationId = url.searchParams.get("conversationId"); + if (!conversationId) return errorResponse("Missing conversationId parameter", 400); + + const { cursor, limit } = parsePaginationParams(url); + const result = await ctx.runQuery(listMessagesRef, { + workspaceId: authResult.workspaceId, + conversationId, + cursor: cursor ?? undefined, + limit, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Messages: send ───────────────────────────────────────────────── +export const sendMessage = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "messages.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const { conversationId, content } = body; + if (!conversationId) return errorResponse("Missing conversationId", 400); + if (!content) return errorResponse("Missing content", 400); + + // Check idempotency key + const idempotencyKey = request.headers.get("Idempotency-Key"); + if (idempotencyKey) { + const cached = await ctx.runQuery(checkIdempotencyRef, { + workspaceId: authResult.workspaceId, + key: idempotencyKey, + }) as { responseSnapshot?: unknown } | null; + if (cached) { + return jsonResponse(cached.responseSnapshot ?? cached); + } + } + + const result = await ctx.runMutation(sendMessageRef, { + workspaceId: authResult.workspaceId, + conversationId, + credentialId: authResult.credentialId, + actorName: authResult.actorName, + content, + }) as { id?: string }; + + if (idempotencyKey) { + await ctx.runMutation(storeIdempotencyRef, { + workspaceId: authResult.workspaceId, + key: idempotencyKey, + credentialId: authResult.credentialId, + resourceType: "message", + resourceId: result.id, + responseSnapshot: result, + }); + } + + return jsonResponse(result, 201); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Conversations: claim ─────────────────────────────────────────── +export const claimConversation = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "claims.manage"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const { conversationId } = body; + if (!conversationId) return errorResponse("Missing conversationId", 400); + + const result = await ctx.runMutation(claimConversationRef, { + workspaceId: authResult.workspaceId, + conversationId, + credentialId: authResult.credentialId, + }); + return jsonResponse(result, 201); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Conversations: release ───────────────────────────────────────── +export const releaseConversation = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "claims.manage"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const { conversationId } = body; + if (!conversationId) return errorResponse("Missing conversationId", 400); + + const result = await ctx.runMutation(releaseConversationRef, { + workspaceId: authResult.workspaceId, + conversationId, + credentialId: authResult.credentialId, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Conversations: escalate ──────────────────────────────────────── +export const escalateConversation = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "claims.manage"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const { conversationId } = body; + if (!conversationId) return errorResponse("Missing conversationId", 400); + + const result = await ctx.runMutation(escalateConversationRef, { + workspaceId: authResult.workspaceId, + conversationId, + credentialId: authResult.credentialId, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Visitors: list ───────────────────────────────────────────────── +export const listVisitors = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "visitors.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const { cursor, limit, updatedSince } = parsePaginationParams(url); + + const result = await ctx.runQuery(listVisitorsRef, { + workspaceId: authResult.workspaceId, + cursor: cursor ?? undefined, + limit, + updatedSince: updatedSince ?? undefined, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Visitors: get ────────────────────────────────────────────────── +export const getVisitor = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "visitors.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + if (!id) return errorResponse("Missing id parameter", 400); + + const result = await ctx.runQuery(getVisitorRef, { + workspaceId: authResult.workspaceId, + visitorId: id, + }); + if (!result) return errorResponse("Visitor not found", 404); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Visitors: create ─────────────────────────────────────────────── +export const createVisitor = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "visitors.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const result = await ctx.runMutation(createVisitorRef, { + workspaceId: authResult.workspaceId, + email: body.email, + name: body.name, + externalUserId: body.externalUserId, + customAttributes: body.customAttributes, + }); + return jsonResponse(result, 201); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Visitors: update ─────────────────────────────────────────────── +export const updateVisitor = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "visitors.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const { visitorId, email, name, externalUserId, customAttributes } = body; + if (!visitorId) return errorResponse("Missing visitorId", 400); + + const result = await ctx.runMutation(updateVisitorRef, { + workspaceId: authResult.workspaceId, + visitorId, + email, + name, + externalUserId, + customAttributes, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Tickets: list ────────────────────────────────────────────────── +export const listTickets = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "tickets.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const { cursor, limit } = parsePaginationParams(url); + const status = url.searchParams.get("status"); + + const result = await ctx.runQuery(listTicketsRef, { + workspaceId: authResult.workspaceId, + cursor: cursor ?? undefined, + limit, + status: status ?? undefined, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Tickets: get ─────────────────────────────────────────────────── +export const getTicket = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "tickets.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + if (!id) return errorResponse("Missing id parameter", 400); + + const result = await ctx.runQuery(getTicketRef, { + workspaceId: authResult.workspaceId, + ticketId: id, + }); + if (!result) return errorResponse("Ticket not found", 404); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Tickets: create ──────────────────────────────────────────────── +export const createTicket = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "tickets.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.subject) return errorResponse("Missing subject", 400); + + const result = await ctx.runMutation(createTicketRef, { + workspaceId: authResult.workspaceId, + subject: body.subject, + description: body.description, + priority: body.priority, + visitorId: body.visitorId, + conversationId: body.conversationId, + assigneeId: body.assigneeId, + }); + return jsonResponse(result, 201); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Tickets: update ──────────────────────────────────────────────── +export const updateTicket = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "tickets.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const { ticketId, status, priority, assigneeId, resolutionSummary } = body; + if (!ticketId) return errorResponse("Missing ticketId", 400); + + const result = await ctx.runMutation(updateTicketRef, { + workspaceId: authResult.workspaceId, + ticketId, + status, + priority, + assigneeId, + resolutionSummary, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + +// ── Events: feed ─────────────────────────────────────────────────── +export const eventsFeed = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "events.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const { cursor, limit } = parsePaginationParams(url); + + const result = await ctx.runQuery(listEventsRef, { + workspaceId: authResult.workspaceId, + cursor: cursor ?? undefined, + limit, + }); + return jsonResponse(result); + } catch (error) { + return errorResponse(String(error), 500); + } +}); diff --git a/packages/convex/convex/automationScopes.ts b/packages/convex/convex/automationScopes.ts new file mode 100644 index 0000000..b9b99a8 --- /dev/null +++ b/packages/convex/convex/automationScopes.ts @@ -0,0 +1,45 @@ +import { v } from "convex/values"; + +export const AUTOMATION_SCOPES = [ + "conversations.read", + "conversations.write", + "messages.read", + "messages.write", + "visitors.read", + "visitors.write", + "tickets.read", + "tickets.write", + "events.read", + "events.write", + "webhooks.manage", + "claims.manage", +] as const; + +export type AutomationScope = (typeof AUTOMATION_SCOPES)[number]; + +export const automationScopeValidator = v.union( + v.literal("conversations.read"), + v.literal("conversations.write"), + v.literal("messages.read"), + v.literal("messages.write"), + v.literal("visitors.read"), + v.literal("visitors.write"), + v.literal("tickets.read"), + v.literal("tickets.write"), + v.literal("events.read"), + v.literal("events.write"), + v.literal("webhooks.manage"), + v.literal("claims.manage") +); + +export function isValidScope(scope: string): scope is AutomationScope { + return (AUTOMATION_SCOPES as readonly string[]).includes(scope); +} + +export function validateScopes(scopes: string[]): AutomationScope[] { + const invalid = scopes.filter((s) => !isValidScope(s)); + if (invalid.length > 0) { + throw new Error(`Invalid automation scopes: ${invalid.join(", ")}`); + } + return scopes as AutomationScope[]; +} diff --git a/packages/convex/convex/automationWebhookWorker.ts b/packages/convex/convex/automationWebhookWorker.ts new file mode 100644 index 0000000..2735c0a --- /dev/null +++ b/packages/convex/convex/automationWebhookWorker.ts @@ -0,0 +1,250 @@ +import { makeFunctionReference } from "convex/server"; +import { v } from "convex/values"; +import { internalAction, internalMutation } from "./_generated/server"; + +// Self-references via makeFunctionReference +const deliverWebhookRef = makeFunctionReference<"action">( + "automationWebhookWorker:deliverWebhook" +); +const getDeliveryDataRef = makeFunctionReference<"mutation">( + "automationWebhookWorker:getDeliveryData" +); +const updateDeliveryStatusRef = makeFunctionReference<"mutation">( + "automationWebhookWorker:updateDeliveryStatus" +); +const scheduleRetryRef = makeFunctionReference<"mutation">( + "automationWebhookWorker:scheduleRetry" +); + +// Retry backoff schedule: 30s, 2m, 10m, 1h, 4h +const RETRY_DELAYS_MS = [ + 30 * 1000, + 2 * 60 * 1000, + 10 * 60 * 1000, + 60 * 60 * 1000, + 4 * 60 * 60 * 1000, +]; +const MAX_ATTEMPTS = 5; + +async function hmacSign(secret: string, payload: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +type RunMutation = (ref: unknown, args: Record) => Promise; + +export const deliverWebhook = internalAction({ + args: { + deliveryId: v.id("automationWebhookDeliveries"), + }, + handler: async (ctx, args) => { + // Load delivery, subscription, and event data + const deliveryData = (await ctx.runMutation(getDeliveryDataRef, { + deliveryId: args.deliveryId, + })) as { + delivery: { + _id: string; + subscriptionId: string; + eventId: string; + attemptNumber: number; + workspaceId: string; + }; + subscription: { + url: string; + signingSecretHash: string; + }; + event: { + eventType: string; + resourceType: string; + resourceId: string; + data: unknown; + timestamp: number; + }; + } | null; + + if (!deliveryData) { + return; + } + + const { delivery, subscription, event } = deliveryData; + + const body = JSON.stringify({ + eventType: event.eventType, + resourceType: event.resourceType, + resourceId: event.resourceId, + data: event.data, + timestamp: event.timestamp, + }); + + const timestamp = Math.floor(Date.now() / 1000); + const signedPayload = `${timestamp}.${body}`; + + // Sign with the stored signing secret hash as HMAC key. + // The webhook consumer stores the raw signing secret and can verify + // by hashing it to get the same key. + const signature = await hmacSign(subscription.signingSecretHash, signedPayload); + + const runMutation = ctx.runMutation as unknown as RunMutation; + + try { + const response = await fetch(subscription.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Opencom-Signature": `t=${timestamp},v1=${signature}`, + "X-Opencom-Event-Id": delivery.eventId, + "X-Opencom-Timestamp": String(timestamp), + }, + body, + signal: AbortSignal.timeout(30000), // 30s timeout + }); + + if (response.ok) { + await runMutation(updateDeliveryStatusRef, { + deliveryId: args.deliveryId, + status: "success", + httpStatus: response.status, + }); + } else { + const errorText = await response.text().catch(() => ""); + await handleDeliveryFailure( + runMutation, + args.deliveryId, + delivery.attemptNumber, + response.status, + errorText + ); + } + } catch (error) { + await handleDeliveryFailure( + runMutation, + args.deliveryId, + delivery.attemptNumber, + undefined, + String(error) + ); + } + }, +}); + +async function handleDeliveryFailure( + runMutation: RunMutation, + deliveryId: string, + attemptNumber: number, + httpStatus: number | undefined, + error: string +) { + if (attemptNumber < MAX_ATTEMPTS) { + const retryDelay = RETRY_DELAYS_MS[attemptNumber - 1] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]; + await runMutation(scheduleRetryRef, { + deliveryId, + httpStatus, + error, + retryDelayMs: retryDelay, + }); + } else { + await runMutation(updateDeliveryStatusRef, { + deliveryId, + status: "failed", + httpStatus, + error, + }); + } +} + +export const getDeliveryData = internalMutation({ + args: { + deliveryId: v.id("automationWebhookDeliveries"), + }, + handler: async (ctx, args) => { + const delivery = await ctx.db.get(args.deliveryId); + if (!delivery) return null; + + const subscription = await ctx.db.get(delivery.subscriptionId); + if (!subscription) return null; + + const event = await ctx.db.get(delivery.eventId); + if (!event) return null; + + return { delivery, subscription, event }; + }, +}); + +export const updateDeliveryStatus = internalMutation({ + args: { + deliveryId: v.id("automationWebhookDeliveries"), + status: v.union(v.literal("success"), v.literal("failed")), + httpStatus: v.optional(v.number()), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.deliveryId, { + status: args.status, + httpStatus: args.httpStatus, + error: args.error, + }); + }, +}); + +export const scheduleRetry = internalMutation({ + args: { + deliveryId: v.id("automationWebhookDeliveries"), + httpStatus: v.optional(v.number()), + error: v.optional(v.string()), + retryDelayMs: v.number(), + }, + handler: async (ctx, args) => { + const delivery = await ctx.db.get(args.deliveryId); + if (!delivery) return; + + const nextRetryAt = Date.now() + args.retryDelayMs; + + await ctx.db.patch(args.deliveryId, { + status: "retrying" as const, + httpStatus: args.httpStatus, + error: args.error, + nextRetryAt, + attemptNumber: delivery.attemptNumber + 1, + }); + + await ctx.scheduler.runAfter( + args.retryDelayMs, + deliverWebhookRef as any, + { deliveryId: args.deliveryId } + ); + }, +}); + +export const replayDelivery = internalMutation({ + args: { + deliveryId: v.id("automationWebhookDeliveries"), + }, + handler: async (ctx, args) => { + const delivery = await ctx.db.get(args.deliveryId); + if (!delivery) throw new Error("Delivery not found"); + + await ctx.db.patch(args.deliveryId, { + status: "pending" as const, + attemptNumber: 1, + error: undefined, + httpStatus: undefined, + nextRetryAt: undefined, + }); + + await ctx.scheduler.runAfter(0, deliverWebhookRef as any, { + deliveryId: args.deliveryId, + }); + + return { success: true }; + }, +}); diff --git a/packages/convex/convex/automationWebhooks.ts b/packages/convex/convex/automationWebhooks.ts new file mode 100644 index 0000000..3269982 --- /dev/null +++ b/packages/convex/convex/automationWebhooks.ts @@ -0,0 +1,145 @@ +import { makeFunctionReference } from "convex/server"; +import { v } from "convex/values"; +import { authMutation, authQuery } from "./lib/authWrappers"; + +const emitEventRef = makeFunctionReference<"mutation">("automationEvents:emitEvent"); + +function generateSigningSecret(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const length = 48; + let result = "whsec_"; + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + for (let i = 0; i < length; i++) { + result += chars[randomValues[i] % chars.length]; + } + return result; +} + +async function sha256Hex(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export const createSubscription = authMutation({ + args: { + workspaceId: v.id("workspaces"), + url: v.string(), + eventTypes: v.optional(v.array(v.string())), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const signingSecret = generateSigningSecret(); + const signingSecretHash = await sha256Hex(signingSecret); + const signingSecretPrefix = signingSecret.slice(0, 14); // "whsec_" + 8 chars + + const now = Date.now(); + const id = await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: args.workspaceId, + url: args.url, + signingSecretHash, + signingSecretPrefix, + eventTypes: args.eventTypes, + status: "active", + createdBy: ctx.user._id, + createdAt: now, + }); + + return { subscriptionId: id, signingSecret }; + }, +}); + +export const listSubscriptions = authQuery({ + args: { + workspaceId: v.id("workspaces"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const subscriptions = await ctx.db + .query("automationWebhookSubscriptions") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .collect(); + + return subscriptions.map((s) => ({ + _id: s._id, + url: s.url, + signingSecretPrefix: s.signingSecretPrefix, + eventTypes: s.eventTypes, + status: s.status, + createdAt: s.createdAt, + })); + }, +}); + +export const updateSubscription = authMutation({ + args: { + workspaceId: v.id("workspaces"), + subscriptionId: v.id("automationWebhookSubscriptions"), + url: v.optional(v.string()), + eventTypes: v.optional(v.array(v.string())), + status: v.optional( + v.union(v.literal("active"), v.literal("paused"), v.literal("disabled")) + ), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const sub = await ctx.db.get(args.subscriptionId); + if (!sub || sub.workspaceId !== args.workspaceId) { + throw new Error("Subscription not found"); + } + + const updates: Record = {}; + if (args.url !== undefined) updates.url = args.url; + if (args.eventTypes !== undefined) updates.eventTypes = args.eventTypes; + if (args.status !== undefined) updates.status = args.status; + + await ctx.db.patch(args.subscriptionId, updates); + return { success: true }; + }, +}); + +export const deleteSubscription = authMutation({ + args: { + workspaceId: v.id("workspaces"), + subscriptionId: v.id("automationWebhookSubscriptions"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const sub = await ctx.db.get(args.subscriptionId); + if (!sub || sub.workspaceId !== args.workspaceId) { + throw new Error("Subscription not found"); + } + + await ctx.db.delete(args.subscriptionId); + return { success: true }; + }, +}); + +export const testSubscription = authMutation({ + args: { + workspaceId: v.id("workspaces"), + subscriptionId: v.id("automationWebhookSubscriptions"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const sub = await ctx.db.get(args.subscriptionId); + if (!sub || sub.workspaceId !== args.workspaceId) { + throw new Error("Subscription not found"); + } + + // Emit a test event + await ctx.scheduler.runAfter(0, emitEventRef as any, { + workspaceId: args.workspaceId, + eventType: "test.ping", + resourceType: "webhook", + resourceId: args.subscriptionId, + data: { test: true, timestamp: Date.now() }, + }); + + return { success: true, message: "Test event queued" }; + }, +}); diff --git a/packages/convex/convex/http.ts b/packages/convex/convex/http.ts index 6165724..951b3c9 100644 --- a/packages/convex/convex/http.ts +++ b/packages/convex/convex/http.ts @@ -691,4 +691,44 @@ http.route({ }), }); +// ── Automation API v1 routes ───────────────────────────────────── + +import { + listConversations, + getConversation, + updateConversation, + listMessages, + sendMessage, + claimConversation, + releaseConversation, + escalateConversation, + listVisitors, + getVisitor, + createVisitor, + updateVisitor, + listTickets, + getTicket, + createTicket, + updateTicket, + eventsFeed, +} from "./automationHttpRoutes"; + +http.route({ path: "/api/v1/conversations", method: "GET", handler: listConversations }); +http.route({ path: "/api/v1/conversations/get", method: "GET", handler: getConversation }); +http.route({ path: "/api/v1/conversations/update", method: "POST", handler: updateConversation }); +http.route({ path: "/api/v1/conversations/messages", method: "GET", handler: listMessages }); +http.route({ path: "/api/v1/conversations/messages/send", method: "POST", handler: sendMessage }); +http.route({ path: "/api/v1/conversations/claim", method: "POST", handler: claimConversation }); +http.route({ path: "/api/v1/conversations/release", method: "POST", handler: releaseConversation }); +http.route({ path: "/api/v1/conversations/escalate", method: "POST", handler: escalateConversation }); +http.route({ path: "/api/v1/visitors", method: "GET", handler: listVisitors }); +http.route({ path: "/api/v1/visitors/get", method: "GET", handler: getVisitor }); +http.route({ path: "/api/v1/visitors/create", method: "POST", handler: createVisitor }); +http.route({ path: "/api/v1/visitors/update", method: "POST", handler: updateVisitor }); +http.route({ path: "/api/v1/tickets", method: "GET", handler: listTickets }); +http.route({ path: "/api/v1/tickets/get", method: "GET", handler: getTicket }); +http.route({ path: "/api/v1/tickets/create", method: "POST", handler: createTicket }); +http.route({ path: "/api/v1/tickets/update", method: "POST", handler: updateTicket }); +http.route({ path: "/api/v1/events/feed", method: "GET", handler: eventsFeed }); + export default http; diff --git a/packages/convex/convex/lib/apiHelpers.ts b/packages/convex/convex/lib/apiHelpers.ts new file mode 100644 index 0000000..47abd02 --- /dev/null +++ b/packages/convex/convex/lib/apiHelpers.ts @@ -0,0 +1,43 @@ +export function parsePaginationParams(url: URL): { + cursor: string | null; + limit: number; + updatedSince: number | null; +} { + const cursor = url.searchParams.get("cursor"); + const limitStr = url.searchParams.get("limit"); + const updatedSinceStr = url.searchParams.get("updatedSince"); + + const limit = limitStr ? Math.min(Math.max(Number.parseInt(limitStr, 10) || 20, 1), 100) : 20; + const updatedSince = updatedSinceStr ? Number.parseInt(updatedSinceStr, 10) || null : null; + + return { cursor, limit, updatedSince }; +} + +export function buildPaginatedResponse( + items: T[], + limit: number, + cursorExtractor?: (item: T) => string +): { data: T[]; nextCursor: string | null; hasMore: boolean } { + const hasMore = items.length > limit; + const data = hasMore ? items.slice(0, limit) : items; + const nextCursor = + hasMore && data.length > 0 && cursorExtractor + ? cursorExtractor(data[data.length - 1]) + : null; + + return { data, nextCursor, hasMore }; +} + +export function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export function errorResponse(message: string, status: number): Response { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/convex/convex/lib/automationAuth.ts b/packages/convex/convex/lib/automationAuth.ts new file mode 100644 index 0000000..67151fe --- /dev/null +++ b/packages/convex/convex/lib/automationAuth.ts @@ -0,0 +1,155 @@ +import { makeFunctionReference } from "convex/server"; +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "../_generated/server"; +import type { Id } from "../_generated/dataModel"; +import type { AutomationScope } from "../automationScopes"; + +export type AutomationContext = { + credentialId: Id<"automationCredentials">; + workspaceId: Id<"workspaces">; + scopes: string[]; + actorName: string; +}; + +async function sha256Hex(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +type RunFn = (ref: unknown, args: Record) => Promise; + +// Reference to our internal lookup/rate-limit functions using makeFunctionReference +// to avoid dependency on generated api types. +const lookupCredentialRef = makeFunctionReference<"query">( + "lib/automationAuth:lookupCredential" +); +const checkRateLimitRef = makeFunctionReference<"mutation">( + "lib/automationAuth:checkRateLimit" +); + +// Called from httpAction context to authenticate an API request. +// Returns AutomationContext on success or a Response on failure. +export async function withAutomationAuth( + ctx: { runQuery: RunFn; runMutation: RunFn }, + request: Request, + requiredScope: AutomationScope +): Promise { + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return new Response( + JSON.stringify({ error: "Missing or invalid Authorization header" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const token = authHeader.slice(7); + if (!token.startsWith("osk_")) { + return new Response( + JSON.stringify({ error: "Invalid token format" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const secretHash = await sha256Hex(token); + + const result = (await ctx.runQuery(lookupCredentialRef, { + secretHash, + requiredScope, + })) as { error: string; status: number } | AutomationContext; + + if ("error" in result) { + return new Response( + JSON.stringify({ error: result.error }), + { status: result.status, headers: { "Content-Type": "application/json" } } + ); + } + + // Check rate limit and update lastUsedAt + const rateLimitResult = (await ctx.runMutation(checkRateLimitRef, { + credentialId: result.credentialId, + workspaceId: result.workspaceId, + })) as { allowed: boolean; retryAfter?: number }; + + if (!rateLimitResult.allowed) { + return new Response( + JSON.stringify({ error: "Rate limit exceeded" }), + { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": String(rateLimitResult.retryAfter ?? 60), + }, + } + ); + } + + return result; +} + +// Internal query to look up a credential by secret hash and validate it. +export const lookupCredential = internalQuery({ + args: { + secretHash: v.string(), + requiredScope: v.string(), + }, + handler: async (ctx, args): Promise<{ error: string; status: number } | AutomationContext> => { + const credential = await ctx.db + .query("automationCredentials") + .withIndex("by_secret_hash", (q) => q.eq("secretHash", args.secretHash)) + .first(); + + if (!credential) { + return { error: "Invalid API key", status: 401 }; + } + + if (credential.status !== "active") { + return { error: "API key is disabled", status: 403 }; + } + + if (credential.expiresAt && credential.expiresAt < Date.now()) { + return { error: "API key has expired", status: 403 }; + } + + // Check workspace has automation enabled + const workspace = await ctx.db.get(credential.workspaceId); + if (!workspace) { + return { error: "Workspace not found", status: 404 }; + } + + if (!workspace.automationApiEnabled) { + return { error: "Automation API is not enabled for this workspace", status: 403 }; + } + + // Check scope + if (!credential.scopes.includes(args.requiredScope)) { + return { error: `Insufficient scope: requires ${args.requiredScope}`, status: 403 }; + } + + return { + credentialId: credential._id, + workspaceId: credential.workspaceId, + scopes: credential.scopes, + actorName: credential.actorName, + }; + }, +}); + +// Internal mutation to check rate limit and update lastUsedAt. +export const checkRateLimit = internalMutation({ + args: { + credentialId: v.id("automationCredentials"), + workspaceId: v.id("workspaces"), + }, + handler: async (ctx, args) => { + // Update lastUsedAt + await ctx.db.patch(args.credentialId, { lastUsedAt: Date.now() }); + + // For v1, simple pass-through. Rate limiting can be enhanced later + // with a dedicated counter table if needed. + return { allowed: true }; + }, +}); diff --git a/packages/convex/convex/lib/idempotency.ts b/packages/convex/convex/lib/idempotency.ts new file mode 100644 index 0000000..0d85c47 --- /dev/null +++ b/packages/convex/convex/lib/idempotency.ts @@ -0,0 +1,76 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "../_generated/server"; + +const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export const checkIdempotencyKey = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + key: v.string(), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("automationIdempotencyKeys") + .withIndex("by_workspace_key", (q) => + q.eq("workspaceId", args.workspaceId).eq("key", args.key) + ) + .first(); + + if (!existing) { + return null; + } + + if (existing.expiresAt < Date.now()) { + return null; // Expired + } + + return { + resourceType: existing.resourceType, + resourceId: existing.resourceId, + responseSnapshot: existing.responseSnapshot, + }; + }, +}); + +export const storeIdempotencyKey = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + key: v.string(), + credentialId: v.id("automationCredentials"), + resourceType: v.string(), + resourceId: v.optional(v.string()), + responseSnapshot: v.optional(v.any()), + }, + handler: async (ctx, args) => { + await ctx.db.insert("automationIdempotencyKeys", { + workspaceId: args.workspaceId, + key: args.key, + credentialId: args.credentialId, + resourceType: args.resourceType, + resourceId: args.resourceId, + responseSnapshot: args.responseSnapshot, + expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, + }); + }, +}); + +export const cleanupExpiredIdempotencyKeys = internalMutation({ + args: { + batchSize: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + const now = Date.now(); + + const expired = await ctx.db + .query("automationIdempotencyKeys") + .withIndex("by_expires", (q) => q.lt("expiresAt", now)) + .take(batchSize); + + for (const key of expired) { + await ctx.db.delete(key._id); + } + + return { deleted: expired.length }; + }, +}); diff --git a/packages/convex/convex/schema.ts b/packages/convex/convex/schema.ts index 8304c8c..bdc2ff1 100644 --- a/packages/convex/convex/schema.ts +++ b/packages/convex/convex/schema.ts @@ -1,6 +1,7 @@ import { defineSchema } from "convex/server"; import { authTables } from "@convex-dev/auth/server"; import { authWorkspaceTables } from "./schema/authWorkspaceTables"; +import { automationTables } from "./schema/automationTables"; import { campaignTables } from "./schema/campaignTables"; import { engagementTables } from "./schema/engagementTables"; import { helpCenterTables } from "./schema/helpCenterTables"; @@ -12,6 +13,7 @@ import { supportAttachmentTables } from "./schema/supportAttachmentTables"; export default defineSchema({ ...authTables, ...authWorkspaceTables, + ...automationTables, ...inboxNotificationTables, ...helpCenterTables, ...engagementTables, diff --git a/packages/convex/convex/schema/authWorkspaceTables.ts b/packages/convex/convex/schema/authWorkspaceTables.ts index 84a2dfb..725f3fc 100644 --- a/packages/convex/convex/schema/authWorkspaceTables.ts +++ b/packages/convex/convex/schema/authWorkspaceTables.ts @@ -56,6 +56,8 @@ export const authWorkspaceTables = { // Signed widget sessions (always on — sessionLifetimeMs configures per-workspace lifetime) sessionLifetimeMs: v.optional(v.number()), supportAttachmentCleanupScheduledAt: v.optional(v.number()), + // Automation API feature flag + automationApiEnabled: v.optional(v.boolean()), }) .index("by_name", ["name"]) .index("by_created_at", ["createdAt"]), diff --git a/packages/convex/convex/schema/automationTables.ts b/packages/convex/convex/schema/automationTables.ts new file mode 100644 index 0000000..e7399d1 --- /dev/null +++ b/packages/convex/convex/schema/automationTables.ts @@ -0,0 +1,97 @@ +import { defineTable } from "convex/server"; +import { v } from "convex/values"; + +export const automationTables = { + automationCredentials: defineTable({ + workspaceId: v.id("workspaces"), + name: v.string(), + secretHash: v.string(), + secretPrefix: v.string(), // first 8 chars for identification + scopes: v.array(v.string()), + status: v.union(v.literal("active"), v.literal("disabled"), v.literal("expired")), + expiresAt: v.optional(v.number()), + actorName: v.string(), + lastUsedAt: v.optional(v.number()), + createdBy: v.id("users"), + createdAt: v.number(), + }) + .index("by_workspace", ["workspaceId"]) + .index("by_secret_hash", ["secretHash"]) + .index("by_workspace_status", ["workspaceId", "status"]), + + automationEvents: defineTable({ + workspaceId: v.id("workspaces"), + eventType: v.string(), + resourceType: v.string(), + resourceId: v.string(), + data: v.any(), + timestamp: v.number(), + }) + .index("by_workspace", ["workspaceId"]) + .index("by_workspace_timestamp", ["workspaceId", "timestamp"]), + + automationWebhookSubscriptions: defineTable({ + workspaceId: v.id("workspaces"), + url: v.string(), + signingSecretHash: v.string(), + signingSecretPrefix: v.string(), + eventTypes: v.optional(v.array(v.string())), + status: v.union(v.literal("active"), v.literal("paused"), v.literal("disabled")), + createdBy: v.id("users"), + createdAt: v.number(), + }) + .index("by_workspace", ["workspaceId"]) + .index("by_workspace_status", ["workspaceId", "status"]), + + automationWebhookDeliveries: defineTable({ + workspaceId: v.id("workspaces"), + subscriptionId: v.id("automationWebhookSubscriptions"), + eventId: v.id("automationEvents"), + attemptNumber: v.number(), + status: v.union( + v.literal("pending"), + v.literal("success"), + v.literal("failed"), + v.literal("retrying") + ), + httpStatus: v.optional(v.number()), + error: v.optional(v.string()), + nextRetryAt: v.optional(v.number()), + createdAt: v.number(), + }) + .index("by_subscription", ["subscriptionId"]) + .index("by_event", ["eventId"]) + .index("by_status", ["status"]) + .index("by_next_retry", ["status", "nextRetryAt"]), + + automationConversationClaims: defineTable({ + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + credentialId: v.id("automationCredentials"), + status: v.union( + v.literal("active"), + v.literal("released"), + v.literal("expired"), + v.literal("escalated") + ), + expiresAt: v.number(), + releasedAt: v.optional(v.number()), + createdAt: v.number(), + }) + .index("by_conversation", ["conversationId"]) + .index("by_conversation_status", ["conversationId", "status"]) + .index("by_credential", ["credentialId"]) + .index("by_expires", ["status", "expiresAt"]), + + automationIdempotencyKeys: defineTable({ + workspaceId: v.id("workspaces"), + key: v.string(), + credentialId: v.id("automationCredentials"), + resourceType: v.string(), + resourceId: v.optional(v.string()), + responseSnapshot: v.optional(v.any()), + expiresAt: v.number(), + }) + .index("by_workspace_key", ["workspaceId", "key"]) + .index("by_expires", ["expiresAt"]), +}; diff --git a/packages/convex/convex/testing/helpers.ts b/packages/convex/convex/testing/helpers.ts index 060308e..e42d179 100644 --- a/packages/convex/convex/testing/helpers.ts +++ b/packages/convex/convex/testing/helpers.ts @@ -9,6 +9,7 @@ import { ticketTestHelpers } from "./helpers/tickets"; import { aiTestHelpers } from "./helpers/ai"; import { cleanupTestHelpers } from "./helpers/cleanup"; import { supportAttachmentTestHelpers } from "./helpers/supportAttachments"; +import { automationTestHelpers } from "./helpers/automation"; export const createTestWorkspace: ReturnType = workspaceTestHelpers.createTestWorkspace; export const updateTestHelpCenterAccessPolicy: ReturnType = workspaceTestHelpers.updateTestHelpCenterAccessPolicy; @@ -97,3 +98,5 @@ export const cleanupExpiredSupportAttachments: ReturnType = supportAttachmentTestHelpers.expireTestSupportAttachment; export const getTestSupportAttachment: ReturnType = supportAttachmentTestHelpers.getTestSupportAttachment; export const hasTestStoredFile: ReturnType = supportAttachmentTestHelpers.hasTestStoredFile; +export const enableAutomationApi: ReturnType = automationTestHelpers.enableAutomationApi; +export const createTestAutomationCredential: ReturnType = automationTestHelpers.createTestAutomationCredential; diff --git a/packages/convex/convex/testing/helpers/automation.ts b/packages/convex/convex/testing/helpers/automation.ts new file mode 100644 index 0000000..e37f19b --- /dev/null +++ b/packages/convex/convex/testing/helpers/automation.ts @@ -0,0 +1,69 @@ +import { internalMutation } from "../../_generated/server"; +import { v } from "convex/values"; + +async function sha256Hex(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +const enableAutomationApi = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.workspaceId, { automationApiEnabled: true }); + return { success: true }; + }, +}); + +const createTestAutomationCredential = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + createdBy: v.id("users"), + name: v.optional(v.string()), + scopes: v.optional(v.array(v.string())), + actorName: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const secret = `osk_test_${Math.random().toString(36).slice(2)}`; + const secretHash = await sha256Hex(secret); + const secretPrefix = secret.slice(0, 12); + const now = Date.now(); + + const credentialId = await ctx.db.insert("automationCredentials", { + workspaceId: args.workspaceId, + name: args.name ?? "Test Credential", + secretHash, + secretPrefix, + scopes: args.scopes ?? [ + "conversations.read", + "conversations.write", + "messages.read", + "messages.write", + "visitors.read", + "visitors.write", + "tickets.read", + "tickets.write", + "events.read", + "events.write", + "webhooks.manage", + "claims.manage", + ], + status: "active", + actorName: args.actorName ?? "Test Bot", + createdBy: args.createdBy, + createdAt: now, + }); + + return { credentialId, secret }; + }, +}); + +export const automationTestHelpers = { + enableAutomationApi, + createTestAutomationCredential, +}; diff --git a/packages/convex/tests/automationApiHelpers.test.ts b/packages/convex/tests/automationApiHelpers.test.ts new file mode 100644 index 0000000..85ac1d1 --- /dev/null +++ b/packages/convex/tests/automationApiHelpers.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { + parsePaginationParams, + buildPaginatedResponse, + jsonResponse, + errorResponse, +} from "../convex/lib/apiHelpers"; + +describe("API Helpers", () => { + describe("parsePaginationParams", () => { + it("returns defaults when no params given", () => { + const url = new URL("https://example.com/api/v1/conversations"); + const { cursor, limit, updatedSince } = parsePaginationParams(url); + + expect(cursor).toBeNull(); + expect(limit).toBe(20); + expect(updatedSince).toBeNull(); + }); + + it("parses cursor param", () => { + const url = new URL("https://example.com/api?cursor=abc123"); + const { cursor } = parsePaginationParams(url); + expect(cursor).toBe("abc123"); + }); + + it("parses limit param", () => { + const url = new URL("https://example.com/api?limit=50"); + const { limit } = parsePaginationParams(url); + expect(limit).toBe(50); + }); + + it("clamps limit to max 100", () => { + const urlHigh = new URL("https://example.com/api?limit=500"); + expect(parsePaginationParams(urlHigh).limit).toBe(100); + }); + + it("clamps limit to min 1", () => { + const urlNeg = new URL("https://example.com/api?limit=-5"); + expect(parsePaginationParams(urlNeg).limit).toBe(1); + }); + + it("parses updatedSince param", () => { + const url = new URL("https://example.com/api?updatedSince=1700000000000"); + const { updatedSince } = parsePaginationParams(url); + expect(updatedSince).toBe(1700000000000); + }); + + it("handles invalid limit gracefully", () => { + const url = new URL("https://example.com/api?limit=abc"); + expect(parsePaginationParams(url).limit).toBe(20); + }); + }); + + describe("buildPaginatedResponse", () => { + it("returns all items when under limit", () => { + const items = [{ id: 1 }, { id: 2 }]; + const result = buildPaginatedResponse(items, 10); + + expect(result.data).toHaveLength(2); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it("truncates and returns nextCursor when over limit", () => { + const items = [{ id: 1, _ct: "a" }, { id: 2, _ct: "b" }, { id: 3, _ct: "c" }]; + const result = buildPaginatedResponse(items, 2, (item) => item._ct); + + expect(result.data).toHaveLength(2); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe("b"); + }); + + it("returns null nextCursor without extractor", () => { + const items = [1, 2, 3]; + const result = buildPaginatedResponse(items, 2); + + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBeNull(); + }); + }); + + describe("jsonResponse", () => { + it("creates JSON response with default 200 status", async () => { + const resp = jsonResponse({ foo: "bar" }); + expect(resp.status).toBe(200); + expect(resp.headers.get("Content-Type")).toBe("application/json"); + + const body = await resp.json(); + expect(body).toEqual({ foo: "bar" }); + }); + + it("supports custom status code", () => { + const resp = jsonResponse({ id: "123" }, 201); + expect(resp.status).toBe(201); + }); + }); + + describe("errorResponse", () => { + it("creates error JSON response", async () => { + const resp = errorResponse("Not found", 404); + expect(resp.status).toBe(404); + + const body = await resp.json(); + expect(body).toEqual({ error: "Not found" }); + }); + }); +}); diff --git a/packages/convex/tests/automationCredentials.test.ts b/packages/convex/tests/automationCredentials.test.ts new file mode 100644 index 0000000..b93a5fe --- /dev/null +++ b/packages/convex/tests/automationCredentials.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { ConvexClient } from "convex/browser"; +import { api } from "../convex/_generated/api"; +import type { Id } from "../convex/_generated/dataModel"; + +describe("Automation Credentials", () => { + let client: ConvexClient; + let testWorkspaceId: Id<"workspaces">; + + beforeAll(async () => { + const convexUrl = process.env.CONVEX_URL; + if (!convexUrl) { + throw new Error("CONVEX_URL environment variable is required"); + } + client = new ConvexClient(convexUrl); + + const workspace = await client.mutation(api.testing_helpers.createTestWorkspace, {}); + testWorkspaceId = workspace.workspaceId; + }); + + afterAll(async () => { + if (testWorkspaceId) { + try { + await client.mutation(api.testing_helpers.cleanupTestData, { + workspaceId: testWorkspaceId, + }); + } catch (e) { + console.warn("Cleanup failed:", e); + } + } + await client.close(); + }); + + it("credential management requires authentication", async () => { + await expect( + client.query(api.automationCredentials.list, { + workspaceId: testWorkspaceId, + }) + ).rejects.toThrow("Not authenticated"); + + await expect( + client.mutation(api.automationCredentials.create, { + workspaceId: testWorkspaceId, + name: "Test Key", + scopes: ["conversations.read"], + actorName: "Test Bot", + }) + ).rejects.toThrow("Not authenticated"); + }); + + it("webhook subscription management requires authentication", async () => { + await expect( + client.query(api.automationWebhooks.listSubscriptions, { + workspaceId: testWorkspaceId, + }) + ).rejects.toThrow("Not authenticated"); + }); +}); diff --git a/packages/convex/tests/automationScopes.test.ts b/packages/convex/tests/automationScopes.test.ts new file mode 100644 index 0000000..64e2e5a --- /dev/null +++ b/packages/convex/tests/automationScopes.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { + isValidScope, + validateScopes, + AUTOMATION_SCOPES, +} from "../convex/automationScopes"; + +describe("Automation Scopes", () => { + describe("isValidScope", () => { + it("accepts all defined scopes", () => { + for (const scope of AUTOMATION_SCOPES) { + expect(isValidScope(scope)).toBe(true); + } + }); + + it("rejects invalid scopes", () => { + expect(isValidScope("invalid.scope")).toBe(false); + expect(isValidScope("")).toBe(false); + expect(isValidScope("conversations")).toBe(false); + expect(isValidScope("conversations.delete")).toBe(false); + }); + }); + + describe("validateScopes", () => { + it("returns valid scopes array", () => { + const scopes = validateScopes(["conversations.read", "messages.write"]); + expect(scopes).toEqual(["conversations.read", "messages.write"]); + }); + + it("throws for invalid scopes", () => { + expect(() => validateScopes(["conversations.read", "invalid"])).toThrow( + "Invalid automation scopes: invalid" + ); + }); + + it("throws listing all invalid scopes", () => { + expect(() => validateScopes(["bad1", "bad2"])).toThrow( + "Invalid automation scopes: bad1, bad2" + ); + }); + + it("accepts empty array", () => { + expect(validateScopes([])).toEqual([]); + }); + }); + + describe("scope definitions", () => { + it("has expected v1 scopes", () => { + expect(AUTOMATION_SCOPES).toContain("conversations.read"); + expect(AUTOMATION_SCOPES).toContain("conversations.write"); + expect(AUTOMATION_SCOPES).toContain("messages.read"); + expect(AUTOMATION_SCOPES).toContain("messages.write"); + expect(AUTOMATION_SCOPES).toContain("visitors.read"); + expect(AUTOMATION_SCOPES).toContain("visitors.write"); + expect(AUTOMATION_SCOPES).toContain("tickets.read"); + expect(AUTOMATION_SCOPES).toContain("tickets.write"); + expect(AUTOMATION_SCOPES).toContain("events.read"); + expect(AUTOMATION_SCOPES).toContain("events.write"); + expect(AUTOMATION_SCOPES).toContain("webhooks.manage"); + expect(AUTOMATION_SCOPES).toContain("claims.manage"); + }); + + it("has exactly 12 v1 scopes", () => { + expect(AUTOMATION_SCOPES).toHaveLength(12); + }); + }); +}); From 5624d38389fca8206e5731323c2a5cd005be6f1c Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:33:24 +0200 Subject: [PATCH 02/13] feat(convex): refine automation api and webhook scaffolding --- .../tasks.md | 12 +- packages/convex/convex/auditLogs.ts | 12 +- .../convex/convex/automationApiInternals.ts | 286 ++++++-- .../convex/automationConversationClaims.ts | 37 ++ packages/convex/convex/automationEvents.ts | 29 +- .../convex/convex/automationHttpRoutes.ts | 69 +- .../convex/convex/automationWebhookWorker.ts | 56 +- packages/convex/convex/automationWebhooks.ts | 29 +- packages/convex/convex/http.ts | 2 + packages/convex/convex/lib/automationAuth.ts | 34 +- .../convex/convex/schema/automationTables.ts | 11 +- .../convex/schema/inboxConversationTables.ts | 1 + packages/convex/tests/automationFixes.test.ts | 617 ++++++++++++++++++ 13 files changed, 1069 insertions(+), 126 deletions(-) create mode 100644 packages/convex/tests/automationFixes.test.ts diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md index ca64b2a..a3da3a8 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md @@ -1,16 +1,16 @@ ## 1. Automation Platform Foundations - [x] 1.1 Add persistence and shared services for automation credentials, automation actors, conversation claims, automation events, webhook subscriptions, and delivery attempts. -- [ ] 1.2 Implement HTTP auth, scope enforcement, secret hashing, one-time secret reveal, rate limiting, and idempotency middleware for automation routes. +- [x] 1.2 Implement HTTP auth, scope enforcement, secret hashing, one-time secret reveal, rate limiting, and idempotency middleware for automation routes. - [ ] 1.3 Define the v1 resource and event coverage matrix used by implementation, docs, and rollout gating. ## 2. Resource API Surface - [x] 2.1 Implement versioned HTTP endpoints for v1 core resources: conversations, messages, visitors, and tickets. - [ ] 2.1b Extend API to remaining resources: ticket comments, articles, collections, outbound messages, and custom events. -- [ ] 2.2 Add cursor pagination, updated-since sync, and server-side filters for v1 resources. +- [x] 2.2 Add cursor pagination, updated-since sync, and server-side filters for v1 resources. - [ ] 2.2b Add external reference support and custom-attribute-aware lookups. -- [ ] 2.3 Implement idempotent mutation handling for message send path via Idempotency-Key header. +- [x] 2.3 Implement idempotent mutation handling for message send path via Idempotency-Key header. - [ ] 2.3b Extend idempotency to remaining mutation hot paths (create, update, activate, delete). ## 3. Event Feed And Webhook Delivery @@ -18,12 +18,12 @@ - [x] 3.1 Implement a canonical automation event ledger with emitEvent internal mutation. - [ ] 3.1b Wire emitEvent calls into existing domain files (conversations, messages, tickets, visitors) so events are actually emitted on resource changes. - [x] 3.2 Expose a polling endpoint that reads the canonical event stream. -- [ ] 3.3 Implement webhook subscription management, HMAC signatures, retry/backoff scheduling, delivery attempt storage, and manual replay. +- [x] 3.3 Implement webhook subscription management, HMAC signatures, retry/backoff scheduling, delivery attempt storage, and manual replay. ## 4. Conversation Coordination -- [ ] 4.1 Expose automation-relevant conversation metadata including AI workflow state, handoff reason, claim state, and automation eligibility. -- [ ] 4.2 Implement claim, release, and escalate flows for automation-managed conversations with bounded lease semantics. +- [x] 4.1 Expose automation-relevant conversation metadata including AI workflow state, handoff reason, claim state, and automation eligibility. +- [x] 4.2 Implement claim, release, and escalate flows for automation-managed conversations with bounded lease semantics. - [x] 4.3 Enforce conflict protection: claimed conversations require active claim for automation message send. - [ ] 4.3b Modify AI agent response path to check for active automation claim before posting AI response. diff --git a/packages/convex/convex/auditLogs.ts b/packages/convex/convex/auditLogs.ts index f985642..2c0e6cd 100644 --- a/packages/convex/convex/auditLogs.ts +++ b/packages/convex/convex/auditLogs.ts @@ -33,7 +33,17 @@ export type AuditAction = | "visitor.merged" // Data events | "data.exported" - | "data.deleted"; + | "data.deleted" + // Automation events + | "automation.message.sent" + | "automation.conversation.updated" + | "automation.visitor.created" + | "automation.visitor.updated" + | "automation.ticket.created" + | "automation.ticket.updated" + | "automation.conversation.claimed" + | "automation.conversation.released" + | "automation.conversation.escalated"; export type ActorType = "user" | "system" | "api"; diff --git a/packages/convex/convex/automationApiInternals.ts b/packages/convex/convex/automationApiInternals.ts index 8fc280d..f4a30ba 100644 --- a/packages/convex/convex/automationApiInternals.ts +++ b/packages/convex/convex/automationApiInternals.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { internalMutation, internalQuery } from "./_generated/server"; +import { logAudit } from "./auditLogs"; // ── Conversations ────────────────────────────────────────────────── @@ -30,30 +31,24 @@ export const listConversationsForAutomation = internalQuery({ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)); } - let conversations = await query.order("desc").take(limit + 1 + (args.cursor ? 1000 : 0)); - - // Cursor-based pagination using _creationTime if (args.cursor) { const cursorTime = Number.parseFloat(args.cursor); - conversations = conversations.filter((c) => c._creationTime < cursorTime); - conversations = conversations.slice(0, limit + 1); + query = query.filter((q2) => q2.lt(q2.field("_creationTime"), cursorTime)); } - if (args.updatedSince) { - conversations = conversations.filter((c) => c.updatedAt >= args.updatedSince!); + query = query.filter((q2) => q2.gte(q2.field("updatedAt"), args.updatedSince!)); } - if (args.assigneeId) { - conversations = conversations.filter( - (c) => c.assignedAgentId === args.assigneeId - ); + query = query.filter((q2) => q2.eq(q2.field("assignedAgentId"), args.assigneeId!)); } + const conversations = await query.order("desc").take(limit + 1); const hasMore = conversations.length > limit; const data = hasMore ? conversations.slice(0, limit) : conversations; - // Get active claims for these conversations + // Get active claims and last inbound message for these conversations const claimMap = new Map(); + const inboundMap = new Map(); for (const conv of data) { const claim = await ctx.db .query("automationConversationClaims") @@ -67,23 +62,43 @@ export const listConversationsForAutomation = internalQuery({ expiresAt: claim.expiresAt, }); } + + const lastInbound = await ctx.db + .query("messages") + .withIndex("by_conversation", (q) => q.eq("conversationId", conv._id)) + .order("desc") + .filter((q) => q.eq(q.field("senderType"), "visitor")) + .first(); + if (lastInbound) { + inboundMap.set(conv._id, { + lastInboundMessageAt: lastInbound.createdAt, + lastInboundMessagePreview: lastInbound.content?.slice(0, 200) ?? null, + }); + } } return { - data: data.map((c) => ({ - id: c._id, - workspaceId: c.workspaceId, - visitorId: c.visitorId, - assignedAgentId: c.assignedAgentId, - status: c.status, - channel: c.channel, - subject: c.subject, - aiWorkflowState: c.aiWorkflowState, - createdAt: c.createdAt, - updatedAt: c.updatedAt, - lastMessageAt: c.lastMessageAt, - claim: claimMap.get(c._id) ?? null, - })), + data: data.map((c) => { + const activeClaim = claimMap.get(c._id) ?? null; + const inbound = inboundMap.get(c._id); + return { + id: c._id, + workspaceId: c.workspaceId, + visitorId: c.visitorId, + assignedAgentId: c.assignedAgentId, + status: c.status, + channel: c.channel, + subject: c.subject, + aiWorkflowState: c.aiWorkflowState, + automationEligible: c.status === "open" && !activeClaim && c.aiWorkflowState !== "ai_handled", + createdAt: c.createdAt, + updatedAt: c.updatedAt, + lastMessageAt: c.lastMessageAt, + lastInboundMessageAt: inbound?.lastInboundMessageAt ?? null, + lastInboundMessagePreview: inbound?.lastInboundMessagePreview ?? null, + claim: activeClaim, + }; + }), nextCursor: hasMore && data.length > 0 ? String(data[data.length - 1]._creationTime) @@ -116,6 +131,14 @@ export const getConversationForAutomation = internalQuery({ ? { credentialId: claim.credentialId, expiresAt: claim.expiresAt } : null; + // Look up last inbound message for enrichment + const lastInboundMessage = await ctx.db + .query("messages") + .withIndex("by_conversation", (q) => q.eq("conversationId", conv._id)) + .order("desc") + .filter((q) => q.eq(q.field("senderType"), "visitor")) + .first(); + return { id: conv._id, workspaceId: conv.workspaceId, @@ -126,9 +149,12 @@ export const getConversationForAutomation = internalQuery({ subject: conv.subject, aiWorkflowState: conv.aiWorkflowState, aiHandoffReason: conv.aiHandoffReason, + automationEligible: conv.status === "open" && !activeClaim && conv.aiWorkflowState !== "ai_handled", createdAt: conv.createdAt, updatedAt: conv.updatedAt, lastMessageAt: conv.lastMessageAt, + lastInboundMessageAt: lastInboundMessage?.createdAt ?? null, + lastInboundMessagePreview: lastInboundMessage?.content?.slice(0, 200) ?? null, claim: activeClaim, }; }, @@ -138,6 +164,7 @@ export const updateConversationForAutomation = internalMutation({ args: { workspaceId: v.id("workspaces"), conversationId: v.id("conversations"), + credentialId: v.optional(v.id("automationCredentials")), status: v.optional( v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed")) ), @@ -161,6 +188,16 @@ export const updateConversationForAutomation = internalMutation({ } await ctx.db.patch(args.conversationId, updates); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.conversation.updated", + resourceType: "conversation", + resourceId: String(args.conversationId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + return { id: args.conversationId }; }, }); @@ -181,17 +218,15 @@ export const listMessagesForAutomation = internalQuery({ } const limit = Math.min(args.limit, 100); - let messages = await ctx.db + let messagesQuery = ctx.db .query("messages") - .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) - .order("asc") - .take(limit + 1 + (args.cursor ? 10000 : 0)); + .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)); if (args.cursor) { const cursorTime = Number.parseFloat(args.cursor); - messages = messages.filter((m) => m._creationTime > cursorTime); - messages = messages.slice(0, limit + 1); + messagesQuery = messagesQuery.filter((q2) => q2.gt(q2.field("_creationTime"), cursorTime)); } + const messages = await messagesQuery.order("asc").take(limit + 1); const hasMore = messages.length > limit; const data = hasMore ? messages.slice(0, limit) : messages; @@ -250,6 +285,7 @@ export const sendMessageForAutomation = internalMutation({ senderId: `automation:${args.actorName}`, senderType: "bot", content: args.content, + automationCredentialId: args.credentialId, createdAt: now, }); @@ -264,10 +300,114 @@ export const sendMessageForAutomation = internalMutation({ expiresAt: now + 5 * 60 * 1000, // 5 min from now }); + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.message.sent", + resourceType: "message", + resourceId: String(messageId), + metadata: { credentialId: String(args.credentialId) }, + }); + return { id: messageId }; }, }); +const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export const sendMessageIdempotent = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + conversationId: v.id("conversations"), + credentialId: v.id("automationCredentials"), + actorName: v.string(), + content: v.string(), + idempotencyKey: v.optional(v.string()), + }, + handler: async (ctx, args) => { + // Check idempotency key if provided + if (args.idempotencyKey) { + const existing = await ctx.db + .query("automationIdempotencyKeys") + .withIndex("by_workspace_key", (q) => + q.eq("workspaceId", args.workspaceId).eq("key", args.idempotencyKey!) + ) + .first(); + + if (existing && existing.expiresAt >= Date.now()) { + return { cached: true, result: existing.responseSnapshot }; + } + } + + // Perform the message send (same logic as sendMessageForAutomation) + const conv = await ctx.db.get(args.conversationId); + if (!conv || conv.workspaceId !== args.workspaceId) { + throw new Error("Conversation not found"); + } + + const claim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (!claim || claim.credentialId !== args.credentialId) { + throw new Error("No active claim for this conversation. Claim the conversation first."); + } + + if (claim.expiresAt < Date.now()) { + throw new Error("Claim has expired. Renew or re-claim the conversation."); + } + + const now = Date.now(); + const messageId = await ctx.db.insert("messages", { + conversationId: args.conversationId, + senderId: `automation:${args.actorName}`, + senderType: "bot", + content: args.content, + automationCredentialId: args.credentialId, + createdAt: now, + }); + + await ctx.db.patch(args.conversationId, { + updatedAt: now, + lastMessageAt: now, + unreadByVisitor: (conv.unreadByVisitor || 0) + 1, + }); + + await ctx.db.patch(claim._id, { + expiresAt: now + 5 * 60 * 1000, + }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.message.sent", + resourceType: "message", + resourceId: String(messageId), + metadata: { credentialId: String(args.credentialId) }, + }); + + const result = { id: messageId }; + + // Store idempotency key if provided + if (args.idempotencyKey) { + await ctx.db.insert("automationIdempotencyKeys", { + workspaceId: args.workspaceId, + key: args.idempotencyKey, + credentialId: args.credentialId, + resourceType: "message", + resourceId: String(messageId), + responseSnapshot: result, + expiresAt: now + IDEMPOTENCY_TTL_MS, + }); + } + + return { cached: false, result }; + }, +}); + // ── Visitors ─────────────────────────────────────────────────────── export const listVisitorsForAutomation = internalQuery({ @@ -276,26 +416,34 @@ export const listVisitorsForAutomation = internalQuery({ cursor: v.optional(v.string()), limit: v.number(), updatedSince: v.optional(v.number()), + email: v.optional(v.string()), + externalUserId: v.optional(v.string()), }, handler: async (ctx, args) => { const limit = Math.min(args.limit, 100); - let visitors = await ctx.db + let visitorsQuery = ctx.db .query("visitors") - .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) - .order("desc") - .take(limit + 1 + (args.cursor ? 1000 : 0)); + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)); if (args.cursor) { const cursorTime = Number.parseFloat(args.cursor); - visitors = visitors.filter((v) => v._creationTime < cursorTime); - visitors = visitors.slice(0, limit + 1); + visitorsQuery = visitorsQuery.filter((q2) => q2.lt(q2.field("_creationTime"), cursorTime)); } - + // Note: updatedSince filters on lastSeenAt only. Visitors without lastSeenAt + // (created outside automation) won't match. This is acceptable since automation- + // created visitors always have lastSeenAt set. if (args.updatedSince) { - visitors = visitors.filter( - (v) => (v.lastSeenAt ?? v.createdAt) >= args.updatedSince! + visitorsQuery = visitorsQuery.filter((q2) => + q2.gte(q2.field("lastSeenAt"), args.updatedSince!) ); } + if (args.email) { + visitorsQuery = visitorsQuery.filter((q2) => q2.eq(q2.field("email"), args.email!)); + } + if (args.externalUserId) { + visitorsQuery = visitorsQuery.filter((q2) => q2.eq(q2.field("externalUserId"), args.externalUserId!)); + } + const visitors = await visitorsQuery.order("desc").take(limit + 1); const hasMore = visitors.length > limit; const data = hasMore ? visitors.slice(0, limit) : visitors; @@ -353,6 +501,7 @@ export const getVisitorForAutomation = internalQuery({ export const createVisitorForAutomation = internalMutation({ args: { workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), email: v.optional(v.string()), name: v.optional(v.string()), externalUserId: v.optional(v.string()), @@ -374,6 +523,15 @@ export const createVisitorForAutomation = internalMutation({ lastSeenAt: now, }); + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.visitor.created", + resourceType: "visitor", + resourceId: String(id), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + return { id }; }, }); @@ -381,6 +539,7 @@ export const createVisitorForAutomation = internalMutation({ export const updateVisitorForAutomation = internalMutation({ args: { workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), visitorId: v.id("visitors"), email: v.optional(v.string()), name: v.optional(v.string()), @@ -401,6 +560,16 @@ export const updateVisitorForAutomation = internalMutation({ updates.lastSeenAt = Date.now(); await ctx.db.patch(args.visitorId, updates); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.visitor.updated", + resourceType: "visitor", + resourceId: String(args.visitorId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + return { id: args.visitorId }; }, }); @@ -413,6 +582,8 @@ export const listTicketsForAutomation = internalQuery({ cursor: v.optional(v.string()), limit: v.number(), status: v.optional(v.string()), + priority: v.optional(v.string()), + assigneeId: v.optional(v.string()), }, handler: async (ctx, args) => { const limit = Math.min(args.limit, 100); @@ -439,13 +610,17 @@ export const listTicketsForAutomation = internalQuery({ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)); } - let tickets = await query.order("desc").take(limit + 1 + (args.cursor ? 1000 : 0)); - if (args.cursor) { const cursorTime = Number.parseFloat(args.cursor); - tickets = tickets.filter((t) => t._creationTime < cursorTime); - tickets = tickets.slice(0, limit + 1); + query = query.filter((q2) => q2.lt(q2.field("_creationTime"), cursorTime)); + } + if (args.priority) { + query = query.filter((q2) => q2.eq(q2.field("priority"), args.priority!)); } + if (args.assigneeId) { + query = query.filter((q2) => q2.eq(q2.field("assigneeId"), args.assigneeId!)); + } + const tickets = await query.order("desc").take(limit + 1); const hasMore = tickets.length > limit; const data = hasMore ? tickets.slice(0, limit) : tickets; @@ -507,6 +682,7 @@ export const getTicketForAutomation = internalQuery({ export const createTicketForAutomation = internalMutation({ args: { workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), subject: v.string(), description: v.optional(v.string()), priority: v.optional(v.string()), @@ -529,6 +705,15 @@ export const createTicketForAutomation = internalMutation({ updatedAt: now, }); + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.ticket.created", + resourceType: "ticket", + resourceId: String(id), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + return { id }; }, }); @@ -536,6 +721,7 @@ export const createTicketForAutomation = internalMutation({ export const updateTicketForAutomation = internalMutation({ args: { workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), ticketId: v.id("tickets"), status: v.optional(v.string()), priority: v.optional(v.string()), @@ -561,6 +747,16 @@ export const updateTicketForAutomation = internalMutation({ updates.resolutionSummary = args.resolutionSummary; await ctx.db.patch(args.ticketId, updates); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.ticket.updated", + resourceType: "ticket", + resourceId: String(args.ticketId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + return { id: args.ticketId }; }, }); diff --git a/packages/convex/convex/automationConversationClaims.ts b/packages/convex/convex/automationConversationClaims.ts index 503fd04..6a26507 100644 --- a/packages/convex/convex/automationConversationClaims.ts +++ b/packages/convex/convex/automationConversationClaims.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { internalMutation, internalQuery } from "./_generated/server"; +import { logAudit } from "./auditLogs"; const CLAIM_LEASE_MS = 5 * 60 * 1000; // 5 minutes @@ -52,6 +53,15 @@ export const claimConversation = internalMutation({ createdAt: now, }); + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.conversation.claimed", + resourceType: "conversation", + resourceId: String(args.conversationId), + metadata: { credentialId: String(args.credentialId) }, + }); + return { claimId, renewed: false }; }, }); @@ -83,6 +93,24 @@ export const releaseConversation = internalMutation({ releasedAt: Date.now(), }); + // Route conversation back to human queue + const conv = await ctx.db.get(args.conversationId); + if (conv) { + await ctx.db.patch(args.conversationId, { + assignedAgentId: undefined, + updatedAt: Date.now(), + }); + } + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.conversation.released", + resourceType: "conversation", + resourceId: String(args.conversationId), + metadata: { credentialId: String(args.credentialId) }, + }); + return { success: true }; }, }); @@ -124,6 +152,15 @@ export const escalateConversation = internalMutation({ }); } + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.conversation.escalated", + resourceType: "conversation", + resourceId: String(args.conversationId), + metadata: { credentialId: String(args.credentialId) }, + }); + return { success: true }; }, }); diff --git a/packages/convex/convex/automationEvents.ts b/packages/convex/convex/automationEvents.ts index b5bbb36..89142bb 100644 --- a/packages/convex/convex/automationEvents.ts +++ b/packages/convex/convex/automationEvents.ts @@ -39,6 +39,18 @@ export const emitEvent = internalMutation({ if (sub.eventTypes && sub.eventTypes.length > 0 && !sub.eventTypes.includes(args.eventType)) { continue; } + // Resource type filter + if (sub.resourceTypes && sub.resourceTypes.length > 0 && !sub.resourceTypes.includes(args.resourceType)) { + continue; + } + // Channel filter + if (sub.channels && sub.channels.length > 0 && !sub.channels.includes(args.data?.channel)) { + continue; + } + // AI workflow state filter + if (sub.aiWorkflowStates && sub.aiWorkflowStates.length > 0 && !sub.aiWorkflowStates.includes(args.data?.aiWorkflowState)) { + continue; + } // Create a pending delivery and schedule it const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { @@ -69,17 +81,14 @@ export const listEvents = internalQuery({ handler: async (ctx, args) => { const limit = Math.min(args.limit, 100); - let events = await ctx.db + const eventsQuery = ctx.db .query("automationEvents") - .withIndex("by_workspace_timestamp", (q) => q.eq("workspaceId", args.workspaceId)) - .order("desc") - .take(limit + 1 + (args.cursor ? 10000 : 0)); - - if (args.cursor) { - const cursorTime = Number.parseFloat(args.cursor); - events = events.filter((e) => e.timestamp < cursorTime); - events = events.slice(0, limit + 1); - } + .withIndex("by_workspace_timestamp", (q2) => + args.cursor + ? q2.eq("workspaceId", args.workspaceId).lt("timestamp", Number.parseFloat(args.cursor)) + : q2.eq("workspaceId", args.workspaceId) + ); + const events = await eventsQuery.order("desc").take(limit + 1); const hasMore = events.length > limit; const data = hasMore ? events.slice(0, limit) : events; diff --git a/packages/convex/convex/automationHttpRoutes.ts b/packages/convex/convex/automationHttpRoutes.ts index 4545054..f48818b 100644 --- a/packages/convex/convex/automationHttpRoutes.ts +++ b/packages/convex/convex/automationHttpRoutes.ts @@ -12,7 +12,6 @@ const listConversationsRef = fn("automationApiInternals:listConversationsForAuto const getConversationRef = fn("automationApiInternals:getConversationForAutomation"); const updateConversationRef = fn("automationApiInternals:updateConversationForAutomation"); const listMessagesRef = fn("automationApiInternals:listMessagesForAutomation"); -const sendMessageRef = fn("automationApiInternals:sendMessageForAutomation"); const listVisitorsRef = fn("automationApiInternals:listVisitorsForAutomation"); const getVisitorRef = fn("automationApiInternals:getVisitorForAutomation"); const createVisitorRef = fn("automationApiInternals:createVisitorForAutomation"); @@ -21,12 +20,12 @@ const listTicketsRef = fn("automationApiInternals:listTicketsForAutomation"); const getTicketRef = fn("automationApiInternals:getTicketForAutomation"); const createTicketRef = fn("automationApiInternals:createTicketForAutomation"); const updateTicketRef = fn("automationApiInternals:updateTicketForAutomation"); +const sendMessageIdempotentRef = fn("automationApiInternals:sendMessageIdempotent"); const claimConversationRef = fn("automationConversationClaims:claimConversation"); const releaseConversationRef = fn("automationConversationClaims:releaseConversation"); const escalateConversationRef = fn("automationConversationClaims:escalateConversation"); const listEventsRef = fn("automationEvents:listEvents"); -const checkIdempotencyRef = fn("lib/idempotency:checkIdempotencyKey"); -const storeIdempotencyRef = fn("lib/idempotency:storeIdempotencyKey"); +const replayDeliveryRef = fn("automationWebhookWorker:replayDelivery"); // Shorthand to cast ctx for withAutomationAuth (httpAction ctx is compatible at runtime). function asAuthCtx(ctx: { runQuery: unknown; runMutation: unknown }) { @@ -95,6 +94,7 @@ export const updateConversation = httpAction(async (ctx, request) => { const result = await ctx.runMutation(updateConversationRef, { workspaceId: authResult.workspaceId, conversationId, + credentialId: authResult.credentialId, status, assignedAgentId, }); @@ -138,38 +138,21 @@ export const sendMessage = httpAction(async (ctx, request) => { if (!conversationId) return errorResponse("Missing conversationId", 400); if (!content) return errorResponse("Missing content", 400); - // Check idempotency key const idempotencyKey = request.headers.get("Idempotency-Key"); - if (idempotencyKey) { - const cached = await ctx.runQuery(checkIdempotencyRef, { - workspaceId: authResult.workspaceId, - key: idempotencyKey, - }) as { responseSnapshot?: unknown } | null; - if (cached) { - return jsonResponse(cached.responseSnapshot ?? cached); - } - } - const result = await ctx.runMutation(sendMessageRef, { + const result = await ctx.runMutation(sendMessageIdempotentRef, { workspaceId: authResult.workspaceId, conversationId, credentialId: authResult.credentialId, actorName: authResult.actorName, content, - }) as { id?: string }; - - if (idempotencyKey) { - await ctx.runMutation(storeIdempotencyRef, { - workspaceId: authResult.workspaceId, - key: idempotencyKey, - credentialId: authResult.credentialId, - resourceType: "message", - resourceId: result.id, - responseSnapshot: result, - }); - } + idempotencyKey: idempotencyKey ?? undefined, + }) as { cached: boolean; result: unknown }; - return jsonResponse(result, 201); + if (result.cached) { + return jsonResponse(result.result, 200); + } + return jsonResponse(result.result, 201); } catch (error) { return errorResponse(String(error), 500); } @@ -246,12 +229,16 @@ export const listVisitors = httpAction(async (ctx, request) => { try { const url = new URL(request.url); const { cursor, limit, updatedSince } = parsePaginationParams(url); + const email = url.searchParams.get("email"); + const externalUserId = url.searchParams.get("externalUserId"); const result = await ctx.runQuery(listVisitorsRef, { workspaceId: authResult.workspaceId, cursor: cursor ?? undefined, limit, updatedSince: updatedSince ?? undefined, + email: email ?? undefined, + externalUserId: externalUserId ?? undefined, }); return jsonResponse(result); } catch (error) { @@ -289,6 +276,7 @@ export const createVisitor = httpAction(async (ctx, request) => { const body = await request.json(); const result = await ctx.runMutation(createVisitorRef, { workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, email: body.email, name: body.name, externalUserId: body.externalUserId, @@ -312,6 +300,7 @@ export const updateVisitor = httpAction(async (ctx, request) => { const result = await ctx.runMutation(updateVisitorRef, { workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, visitorId, email, name, @@ -333,12 +322,16 @@ export const listTickets = httpAction(async (ctx, request) => { const url = new URL(request.url); const { cursor, limit } = parsePaginationParams(url); const status = url.searchParams.get("status"); + const priority = url.searchParams.get("priority"); + const assignee = url.searchParams.get("assignee"); const result = await ctx.runQuery(listTicketsRef, { workspaceId: authResult.workspaceId, cursor: cursor ?? undefined, limit, status: status ?? undefined, + priority: priority ?? undefined, + assigneeId: assignee ?? undefined, }); return jsonResponse(result); } catch (error) { @@ -378,6 +371,7 @@ export const createTicket = httpAction(async (ctx, request) => { const result = await ctx.runMutation(createTicketRef, { workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, subject: body.subject, description: body.description, priority: body.priority, @@ -403,6 +397,7 @@ export const updateTicket = httpAction(async (ctx, request) => { const result = await ctx.runMutation(updateTicketRef, { workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, ticketId, status, priority, @@ -415,6 +410,26 @@ export const updateTicket = httpAction(async (ctx, request) => { } }); +// ── Webhooks: replay ────────────────────────────────────────────── +export const replayWebhookDelivery = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "webhooks.manage"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + const { deliveryId } = body; + if (!deliveryId) return errorResponse("Missing deliveryId", 400); + + const result = await ctx.runMutation(replayDeliveryRef, { + deliveryId, + workspaceId: authResult.workspaceId, + }); + return jsonResponse(result, 201); + } catch (error) { + return errorResponse(String(error), 500); + } +}); + // ── Events: feed ─────────────────────────────────────────────────── export const eventsFeed = httpAction(async (ctx, request) => { const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "events.read"); diff --git a/packages/convex/convex/automationWebhookWorker.ts b/packages/convex/convex/automationWebhookWorker.ts index 2735c0a..21ea656 100644 --- a/packages/convex/convex/automationWebhookWorker.ts +++ b/packages/convex/convex/automationWebhookWorker.ts @@ -1,12 +1,12 @@ import { makeFunctionReference } from "convex/server"; import { v } from "convex/values"; -import { internalAction, internalMutation } from "./_generated/server"; +import { internalAction, internalMutation, internalQuery } from "./_generated/server"; // Self-references via makeFunctionReference const deliverWebhookRef = makeFunctionReference<"action">( "automationWebhookWorker:deliverWebhook" ); -const getDeliveryDataRef = makeFunctionReference<"mutation">( +const getDeliveryDataRef = makeFunctionReference<"query">( "automationWebhookWorker:getDeliveryData" ); const updateDeliveryStatusRef = makeFunctionReference<"mutation">( @@ -49,7 +49,7 @@ export const deliverWebhook = internalAction({ }, handler: async (ctx, args) => { // Load delivery, subscription, and event data - const deliveryData = (await ctx.runMutation(getDeliveryDataRef, { + const deliveryData = (await ctx.runQuery(getDeliveryDataRef, { deliveryId: args.deliveryId, })) as { delivery: { @@ -61,7 +61,7 @@ export const deliverWebhook = internalAction({ }; subscription: { url: string; - signingSecretHash: string; + signingSecret: string; }; event: { eventType: string; @@ -89,10 +89,7 @@ export const deliverWebhook = internalAction({ const timestamp = Math.floor(Date.now() / 1000); const signedPayload = `${timestamp}.${body}`; - // Sign with the stored signing secret hash as HMAC key. - // The webhook consumer stores the raw signing secret and can verify - // by hashing it to get the same key. - const signature = await hmacSign(subscription.signingSecretHash, signedPayload); + const signature = await hmacSign(subscription.signingSecret, signedPayload); const runMutation = ctx.runMutation as unknown as RunMutation; @@ -162,7 +159,7 @@ async function handleDeliveryFailure( } } -export const getDeliveryData = internalMutation({ +export const getDeliveryData = internalQuery({ args: { deliveryId: v.id("automationWebhookDeliveries"), }, @@ -207,20 +204,29 @@ export const scheduleRetry = internalMutation({ const delivery = await ctx.db.get(args.deliveryId); if (!delivery) return; - const nextRetryAt = Date.now() + args.retryDelayMs; - + // Mark current delivery as failed await ctx.db.patch(args.deliveryId, { - status: "retrying" as const, + status: "failed" as const, httpStatus: args.httpStatus, error: args.error, - nextRetryAt, + }); + + // Create new delivery row for the retry attempt + const now = Date.now(); + const newDeliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: delivery.workspaceId, + subscriptionId: delivery.subscriptionId, + eventId: delivery.eventId, attemptNumber: delivery.attemptNumber + 1, + status: "pending", + nextRetryAt: now + args.retryDelayMs, + createdAt: now, }); await ctx.scheduler.runAfter( args.retryDelayMs, deliverWebhookRef as any, - { deliveryId: args.deliveryId } + { deliveryId: newDeliveryId } ); }, }); @@ -228,23 +234,31 @@ export const scheduleRetry = internalMutation({ export const replayDelivery = internalMutation({ args: { deliveryId: v.id("automationWebhookDeliveries"), + workspaceId: v.id("workspaces"), }, handler: async (ctx, args) => { const delivery = await ctx.db.get(args.deliveryId); if (!delivery) throw new Error("Delivery not found"); - await ctx.db.patch(args.deliveryId, { - status: "pending" as const, + if (delivery.workspaceId !== args.workspaceId) { + throw new Error("Delivery does not belong to this workspace"); + } + + // Create a new delivery row for the replay + const now = Date.now(); + const newDeliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: delivery.workspaceId, + subscriptionId: delivery.subscriptionId, + eventId: delivery.eventId, attemptNumber: 1, - error: undefined, - httpStatus: undefined, - nextRetryAt: undefined, + status: "pending", + createdAt: now, }); await ctx.scheduler.runAfter(0, deliverWebhookRef as any, { - deliveryId: args.deliveryId, + deliveryId: newDeliveryId, }); - return { success: true }; + return { success: true, deliveryId: newDeliveryId }; }, }); diff --git a/packages/convex/convex/automationWebhooks.ts b/packages/convex/convex/automationWebhooks.ts index 3269982..d7af196 100644 --- a/packages/convex/convex/automationWebhooks.ts +++ b/packages/convex/convex/automationWebhooks.ts @@ -16,34 +16,32 @@ function generateSigningSecret(): string { return result; } -async function sha256Hex(input: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - export const createSubscription = authMutation({ args: { workspaceId: v.id("workspaces"), url: v.string(), eventTypes: v.optional(v.array(v.string())), + resourceTypes: v.optional(v.array(v.string())), + channels: v.optional(v.array(v.string())), + aiWorkflowStates: v.optional(v.array(v.string())), }, permission: "settings.integrations", handler: async (ctx, args) => { + // The signing secret is stored in plaintext in the DB (server-side only). + // It's returned once to the admin on creation, never retrievable again. const signingSecret = generateSigningSecret(); - const signingSecretHash = await sha256Hex(signingSecret); const signingSecretPrefix = signingSecret.slice(0, 14); // "whsec_" + 8 chars const now = Date.now(); const id = await ctx.db.insert("automationWebhookSubscriptions", { workspaceId: args.workspaceId, url: args.url, - signingSecretHash, + signingSecret, signingSecretPrefix, eventTypes: args.eventTypes, + resourceTypes: args.resourceTypes, + channels: args.channels, + aiWorkflowStates: args.aiWorkflowStates, status: "active", createdBy: ctx.user._id, createdAt: now, @@ -69,6 +67,9 @@ export const listSubscriptions = authQuery({ url: s.url, signingSecretPrefix: s.signingSecretPrefix, eventTypes: s.eventTypes, + resourceTypes: s.resourceTypes, + channels: s.channels, + aiWorkflowStates: s.aiWorkflowStates, status: s.status, createdAt: s.createdAt, })); @@ -81,6 +82,9 @@ export const updateSubscription = authMutation({ subscriptionId: v.id("automationWebhookSubscriptions"), url: v.optional(v.string()), eventTypes: v.optional(v.array(v.string())), + resourceTypes: v.optional(v.array(v.string())), + channels: v.optional(v.array(v.string())), + aiWorkflowStates: v.optional(v.array(v.string())), status: v.optional( v.union(v.literal("active"), v.literal("paused"), v.literal("disabled")) ), @@ -95,6 +99,9 @@ export const updateSubscription = authMutation({ const updates: Record = {}; if (args.url !== undefined) updates.url = args.url; if (args.eventTypes !== undefined) updates.eventTypes = args.eventTypes; + if (args.resourceTypes !== undefined) updates.resourceTypes = args.resourceTypes; + if (args.channels !== undefined) updates.channels = args.channels; + if (args.aiWorkflowStates !== undefined) updates.aiWorkflowStates = args.aiWorkflowStates; if (args.status !== undefined) updates.status = args.status; await ctx.db.patch(args.subscriptionId, updates); diff --git a/packages/convex/convex/http.ts b/packages/convex/convex/http.ts index 951b3c9..b54c7ba 100644 --- a/packages/convex/convex/http.ts +++ b/packages/convex/convex/http.ts @@ -711,6 +711,7 @@ import { createTicket, updateTicket, eventsFeed, + replayWebhookDelivery, } from "./automationHttpRoutes"; http.route({ path: "/api/v1/conversations", method: "GET", handler: listConversations }); @@ -730,5 +731,6 @@ http.route({ path: "/api/v1/tickets/get", method: "GET", handler: getTicket }); http.route({ path: "/api/v1/tickets/create", method: "POST", handler: createTicket }); http.route({ path: "/api/v1/tickets/update", method: "POST", handler: updateTicket }); http.route({ path: "/api/v1/events/feed", method: "GET", handler: eventsFeed }); +http.route({ path: "/api/v1/webhooks/replay", method: "POST", handler: replayWebhookDelivery }); export default http; diff --git a/packages/convex/convex/lib/automationAuth.ts b/packages/convex/convex/lib/automationAuth.ts index 67151fe..a3e3656 100644 --- a/packages/convex/convex/lib/automationAuth.ts +++ b/packages/convex/convex/lib/automationAuth.ts @@ -138,6 +138,9 @@ export const lookupCredential = internalQuery({ }, }); +const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 60; // 60 requests per minute + // Internal mutation to check rate limit and update lastUsedAt. export const checkRateLimit = internalMutation({ args: { @@ -145,11 +148,34 @@ export const checkRateLimit = internalMutation({ workspaceId: v.id("workspaces"), }, handler: async (ctx, args) => { - // Update lastUsedAt - await ctx.db.patch(args.credentialId, { lastUsedAt: Date.now() }); + const now = Date.now(); + const credential = await ctx.db.get(args.credentialId); + if (!credential) { + return { allowed: false, retryAfter: 60 }; + } + + const windowStart = credential.rateLimitWindowStart ?? 0; + const count = credential.rateLimitCount ?? 0; + + if (now > windowStart + RATE_LIMIT_WINDOW_MS) { + // New window + await ctx.db.patch(args.credentialId, { + lastUsedAt: now, + rateLimitCount: 1, + rateLimitWindowStart: now, + }); + return { allowed: true }; + } + + if (count >= RATE_LIMIT_MAX_REQUESTS) { + const retryAfter = Math.ceil((windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000); + return { allowed: false, retryAfter }; + } - // For v1, simple pass-through. Rate limiting can be enhanced later - // with a dedicated counter table if needed. + await ctx.db.patch(args.credentialId, { + lastUsedAt: now, + rateLimitCount: count + 1, + }); return { allowed: true }; }, }); diff --git a/packages/convex/convex/schema/automationTables.ts b/packages/convex/convex/schema/automationTables.ts index e7399d1..12dcb1a 100644 --- a/packages/convex/convex/schema/automationTables.ts +++ b/packages/convex/convex/schema/automationTables.ts @@ -12,6 +12,8 @@ export const automationTables = { expiresAt: v.optional(v.number()), actorName: v.string(), lastUsedAt: v.optional(v.number()), + rateLimitCount: v.optional(v.number()), + rateLimitWindowStart: v.optional(v.number()), createdBy: v.id("users"), createdAt: v.number(), }) @@ -33,9 +35,15 @@ export const automationTables = { automationWebhookSubscriptions: defineTable({ workspaceId: v.id("workspaces"), url: v.string(), - signingSecretHash: v.string(), + // Stored in plaintext. Convex DB is server-side trusted infrastructure. + // The secret is returned to the admin once on creation, then only used + // server-side for HMAC signing. Same trust model as Stripe/GitHub webhooks. + signingSecret: v.string(), signingSecretPrefix: v.string(), eventTypes: v.optional(v.array(v.string())), + resourceTypes: v.optional(v.array(v.string())), + channels: v.optional(v.array(v.string())), + aiWorkflowStates: v.optional(v.array(v.string())), status: v.union(v.literal("active"), v.literal("paused"), v.literal("disabled")), createdBy: v.id("users"), createdAt: v.number(), @@ -60,6 +68,7 @@ export const automationTables = { createdAt: v.number(), }) .index("by_subscription", ["subscriptionId"]) + .index("by_subscription_event", ["subscriptionId", "eventId"]) .index("by_event", ["eventId"]) .index("by_status", ["status"]) .index("by_next_retry", ["status", "nextRetryAt"]), diff --git a/packages/convex/convex/schema/inboxConversationTables.ts b/packages/convex/convex/schema/inboxConversationTables.ts index 26a6074..f404bb9 100644 --- a/packages/convex/convex/schema/inboxConversationTables.ts +++ b/packages/convex/convex/schema/inboxConversationTables.ts @@ -143,6 +143,7 @@ export const inboxConversationTables = { ) ), attachmentIds: v.optional(supportAttachmentIdArrayValidator), + automationCredentialId: v.optional(v.id("automationCredentials")), createdAt: v.number(), }) .index("by_conversation", ["conversationId"]) diff --git a/packages/convex/tests/automationFixes.test.ts b/packages/convex/tests/automationFixes.test.ts new file mode 100644 index 0000000..1d7d503 --- /dev/null +++ b/packages/convex/tests/automationFixes.test.ts @@ -0,0 +1,617 @@ +import { beforeEach, describe, expect, it, vi, afterEach } from "vitest"; +import { convexTest } from "convex-test"; +import { internal } from "../convex/_generated/api"; +import schema from "../convex/schema"; + +const modules = import.meta.glob("../convex/**/*.ts"); + +describe("automation fixes", () => { + let t: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + t = convexTest(schema, modules); + // Stub fetch for webhook delivery actions + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + })) as unknown as typeof fetch + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + // Helper to seed a workspace with automation enabled + async function seedWorkspace() { + return t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "Test Workspace", + automationApiEnabled: true, + createdAt: now, + }); + const userId = await ctx.db.insert("users", { + email: "admin@test.com", + workspaceId, + role: "admin", + createdAt: now, + }); + const credentialId = await ctx.db.insert("automationCredentials", { + workspaceId, + name: "Test Key", + secretHash: "testhash123", + secretPrefix: "osk_test", + scopes: [ + "conversations.read", + "conversations.write", + "messages.read", + "messages.write", + "webhooks.manage", + "claims.manage", + "events.read", + ], + status: "active", + actorName: "test-bot", + createdBy: userId, + createdAt: now, + }); + return { workspaceId, userId, credentialId }; + }); + } + + // ── R1: Cross-workspace replay rejection ──────────────────────────── + describe("R1 — cross-workspace replay rejection", () => { + it("rejects replay when delivery belongs to a different workspace", async () => { + const wsA = await seedWorkspace(); + + // Create workspace B + const wsB = await t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "Workspace B", + automationApiEnabled: true, + createdAt: now, + }); + return { workspaceId }; + }); + + // Create an event and delivery in workspace A + const { deliveryId } = await t.run(async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: wsA.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv123", + data: {}, + timestamp: now, + }); + const subscriptionId = await ctx.db.insert( + "automationWebhookSubscriptions", + { + workspaceId: wsA.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + status: "active", + createdBy: wsA.userId, + createdAt: now, + } + ); + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: wsA.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "failed", + createdAt: now, + }); + return { deliveryId }; + }); + + // Replay from workspace B should be rejected + await expect( + t.mutation(internal.automationWebhookWorker.replayDelivery, { + deliveryId, + workspaceId: wsB.workspaceId, + }) + ).rejects.toThrow("Delivery does not belong to this workspace"); + + // Replay from workspace A should succeed + const result = await t.mutation( + internal.automationWebhookWorker.replayDelivery, + { + deliveryId, + workspaceId: wsA.workspaceId, + } + ); + expect(result.success).toBe(true); + expect(result.deliveryId).toBeDefined(); + }); + }); + + // ── R6: Idempotency ────────────────────────────────────────────────── + describe("R6 — idempotency", () => { + it("returns cached result on duplicate idempotency key", async () => { + const ws = await seedWorkspace(); + + // Create a visitor and conversation with active claim + const { conversationId } = await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "visitor-session", + createdAt: now, + }); + const conversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + createdAt: now, + updatedAt: now, + }); + // Create active claim + await ctx.db.insert("automationConversationClaims", { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + status: "active", + expiresAt: now + 5 * 60 * 1000, + createdAt: now, + }); + return { conversationId }; + }); + + const idempotencyKey = "idem-key-123"; + + // First call + const result1 = await t.mutation( + internal.automationApiInternals.sendMessageIdempotent, + { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + actorName: "test-bot", + content: "Hello!", + idempotencyKey, + } + ); + expect(result1.cached).toBe(false); + expect(result1.result.id).toBeDefined(); + + // Second call with same key + const result2 = await t.mutation( + internal.automationApiInternals.sendMessageIdempotent, + { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + actorName: "test-bot", + content: "Hello!", + idempotencyKey, + } + ); + expect(result2.cached).toBe(true); + + // Verify only one message was created + const messages = await t.run(async (ctx) => { + return ctx.db + .query("messages") + .withIndex("by_conversation", (q) => + q.eq("conversationId", conversationId) + ) + .collect(); + }); + expect(messages).toHaveLength(1); + + // Verify one idempotency key row exists + const keys = await t.run(async (ctx) => { + return ctx.db + .query("automationIdempotencyKeys") + .withIndex("by_workspace_key", (q) => + q + .eq("workspaceId", ws.workspaceId) + .eq("key", idempotencyKey) + ) + .collect(); + }); + expect(keys).toHaveLength(1); + }); + }); + + // ── R7: Rate limiting ──────────────────────────────────────────────── + describe("R7 — rate limiting", () => { + it("blocks requests after 60 calls in a window", async () => { + const ws = await seedWorkspace(); + + // Make 60 calls (all should be allowed) + for (let i = 0; i < 60; i++) { + const result = await t.mutation( + internal["lib/automationAuth"].checkRateLimit, + { + credentialId: ws.credentialId, + workspaceId: ws.workspaceId, + } + ); + expect(result.allowed).toBe(true); + } + + // 61st call should be blocked + const blocked = await t.mutation( + internal["lib/automationAuth"].checkRateLimit, + { + credentialId: ws.credentialId, + workspaceId: ws.workspaceId, + } + ); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfter).toBeTypeOf("number"); + expect(blocked.retryAfter).toBeGreaterThan(0); + }); + }); + + // ── R7: Delivery attempt history ───────────────────────────────────── + describe("R7 — delivery attempt history", () => { + it("creates incrementing attempt numbers on retry", async () => { + const ws = await seedWorkspace(); + + const { deliveryId, subscriptionId, eventId } = await t.run( + async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv123", + data: {}, + timestamp: now, + }); + const subscriptionId = await ctx.db.insert( + "automationWebhookSubscriptions", + { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + status: "active", + createdBy: ws.userId, + createdAt: now, + } + ); + const deliveryId = await ctx.db.insert( + "automationWebhookDeliveries", + { + workspaceId: ws.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "pending", + createdAt: now, + } + ); + return { deliveryId, subscriptionId, eventId }; + } + ); + + // Simulate 3 retries + for (let i = 0; i < 3; i++) { + const currentDeliveries = await t.run(async (ctx) => { + return ctx.db + .query("automationWebhookDeliveries") + .withIndex("by_event", (q) => q.eq("eventId", eventId)) + .collect(); + }); + const latest = currentDeliveries[currentDeliveries.length - 1]; + await t.mutation(internal.automationWebhookWorker.scheduleRetry, { + deliveryId: latest._id, + httpStatus: 500, + error: "Server error", + retryDelayMs: 30000, + }); + } + + // Check deliveries + const allDeliveries = await t.run(async (ctx) => { + return ctx.db + .query("automationWebhookDeliveries") + .withIndex("by_event", (q) => q.eq("eventId", eventId)) + .collect(); + }); + + // Original + 3 retries = 4 deliveries + expect(allDeliveries).toHaveLength(4); + expect(allDeliveries[0].attemptNumber).toBe(1); + expect(allDeliveries[1].attemptNumber).toBe(2); + expect(allDeliveries[2].attemptNumber).toBe(3); + expect(allDeliveries[3].attemptNumber).toBe(4); + + // Original + retries 1-2 should be "failed" + expect(allDeliveries[0].status).toBe("failed"); + expect(allDeliveries[1].status).toBe("failed"); + expect(allDeliveries[2].status).toBe("failed"); + // Latest should be "pending" (scheduled for delivery) + expect(allDeliveries[3].status).toBe("pending"); + + // Now replay — should create a new delivery with attemptNumber 1 + const replayResult = await t.mutation( + internal.automationWebhookWorker.replayDelivery, + { + deliveryId: allDeliveries[0]._id, + workspaceId: ws.workspaceId, + } + ); + expect(replayResult.success).toBe(true); + + const afterReplay = await t.run(async (ctx) => { + return ctx.db + .query("automationWebhookDeliveries") + .withIndex("by_event", (q) => q.eq("eventId", eventId)) + .collect(); + }); + expect(afterReplay).toHaveLength(5); + // The replay delivery should have attemptNumber 1 + const replayDelivery = afterReplay.find( + (d) => d._id === replayResult.deliveryId + ); + expect(replayDelivery?.attemptNumber).toBe(1); + }); + }); + + // ── R7: Webhook subscription filters ───────────────────────────────── + describe("R7 — webhook subscription filters", () => { + it("only creates deliveries for matching resourceType", async () => { + const ws = await seedWorkspace(); + + // Create subscription filtered to conversations only + await t.run(async (ctx) => { + await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + resourceTypes: ["conversation"], + status: "active", + createdBy: ws.userId, + createdAt: Date.now(), + }); + }); + + // Emit a ticket event — should NOT create a delivery + await t.mutation(internal.automationEvents.emitEvent, { + workspaceId: ws.workspaceId, + eventType: "ticket.created", + resourceType: "ticket", + resourceId: "ticket123", + data: {}, + }); + + const ticketDeliveries = await t.run(async (ctx) => { + return ctx.db + .query("automationWebhookDeliveries") + .withIndex("by_status", (q) => q.eq("status", "pending")) + .collect(); + }); + expect( + ticketDeliveries.filter( + (d) => d.workspaceId === ws.workspaceId + ) + ).toHaveLength(0); + + // Emit a conversation event — should create a delivery + await t.mutation(internal.automationEvents.emitEvent, { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv123", + data: {}, + }); + + const convDeliveries = await t.run(async (ctx) => { + return ctx.db + .query("automationWebhookDeliveries") + .withIndex("by_status", (q) => q.eq("status", "pending")) + .collect(); + }); + expect( + convDeliveries.filter( + (d) => d.workspaceId === ws.workspaceId + ) + ).toHaveLength(1); + }); + + it("filters by channel", async () => { + const ws = await seedWorkspace(); + + await t.run(async (ctx) => { + await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + channels: ["chat"], + status: "active", + createdBy: ws.userId, + createdAt: Date.now(), + }); + }); + + // Email event — no delivery + await t.mutation(internal.automationEvents.emitEvent, { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv1", + data: { channel: "email" }, + }); + + const emailDeliveries = await t.run(async (ctx) => { + return ctx.db.query("automationWebhookDeliveries").collect(); + }); + expect( + emailDeliveries.filter((d) => d.workspaceId === ws.workspaceId) + ).toHaveLength(0); + + // Chat event — delivery created + await t.mutation(internal.automationEvents.emitEvent, { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv2", + data: { channel: "chat" }, + }); + + const chatDeliveries = await t.run(async (ctx) => { + return ctx.db.query("automationWebhookDeliveries").collect(); + }); + expect( + chatDeliveries.filter((d) => d.workspaceId === ws.workspaceId) + ).toHaveLength(1); + }); + + it("filters by aiWorkflowState", async () => { + const ws = await seedWorkspace(); + + await t.run(async (ctx) => { + await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + aiWorkflowStates: ["handoff"], + status: "active", + createdBy: ws.userId, + createdAt: Date.now(), + }); + }); + + // ai_handled event — no delivery + await t.mutation(internal.automationEvents.emitEvent, { + workspaceId: ws.workspaceId, + eventType: "conversation.updated", + resourceType: "conversation", + resourceId: "conv1", + data: { aiWorkflowState: "ai_handled" }, + }); + + let deliveries = await t.run(async (ctx) => { + return ctx.db.query("automationWebhookDeliveries").collect(); + }); + expect( + deliveries.filter((d) => d.workspaceId === ws.workspaceId) + ).toHaveLength(0); + + // handoff event — delivery created + await t.mutation(internal.automationEvents.emitEvent, { + workspaceId: ws.workspaceId, + eventType: "conversation.updated", + resourceType: "conversation", + resourceId: "conv1", + data: { aiWorkflowState: "handoff" }, + }); + + deliveries = await t.run(async (ctx) => { + return ctx.db.query("automationWebhookDeliveries").collect(); + }); + expect( + deliveries.filter((d) => d.workspaceId === ws.workspaceId) + ).toHaveLength(1); + }); + }); + + // ── R7: Release clears assignee, escalate sets handoff ─────────────── + describe("R7 — release and escalate behavior", () => { + it("release clears assignedAgentId", async () => { + const ws = await seedWorkspace(); + + const { conversationId } = await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v1", + createdAt: now, + }); + const conversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + assignedAgentId: ws.userId, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("automationConversationClaims", { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + status: "active", + expiresAt: now + 5 * 60 * 1000, + createdAt: now, + }); + return { conversationId }; + }); + + await t.mutation( + internal.automationConversationClaims.releaseConversation, + { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + } + ); + + const conv = await t.run(async (ctx) => { + return ctx.db.get(conversationId); + }); + expect(conv?.assignedAgentId).toBeUndefined(); + }); + + it("escalate sets aiWorkflowState to handoff", async () => { + const ws = await seedWorkspace(); + + const { conversationId } = await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v2", + createdAt: now, + }); + const conversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("automationConversationClaims", { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + status: "active", + expiresAt: now + 5 * 60 * 1000, + createdAt: now, + }); + return { conversationId }; + }); + + await t.mutation( + internal.automationConversationClaims.escalateConversation, + { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + } + ); + + const conv = await t.run(async (ctx) => { + return ctx.db.get(conversationId); + }); + expect(conv?.aiWorkflowState).toBe("handoff"); + }); + }); +}); From a1a572d41df5cdc7666c9ff964acc85c05e92651 Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:58:09 +0200 Subject: [PATCH 03/13] Make API and webhooks more robust --- .gitignore | 2 + .../automation-access-governance/spec.md | 2 +- .../tasks.md | 2 +- packages/convex/convex/aiAgent.ts | 11 + packages/convex/convex/aiAgentActions.ts | 163 ++- .../convex/convex/automationApiInternals.ts | 435 ++++-- .../convex/automationConversationClaims.ts | 34 - packages/convex/convex/automationEvents.ts | 51 +- .../convex/convex/automationHttpRoutes.ts | 66 +- .../convex/convex/automationWebhookWorker.ts | 48 +- packages/convex/convex/automationWebhooks.ts | 15 +- packages/convex/convex/crons.ts | 28 + packages/convex/convex/lib/apiHelpers.ts | 24 + packages/convex/convex/lib/automationAuth.ts | 30 +- .../convex/lib/automationWebhookSecrets.ts | 83 ++ packages/convex/convex/lib/idempotency.ts | 55 +- packages/convex/convex/messages.ts | 13 + .../backfillAutomationWebhookSecrets.ts | 58 + .../convex/convex/schema/automationTables.ts | 12 +- .../convex/schema/inboxConversationTables.ts | 1 + .../convex/schema/outboundSupportTables.ts | 1 + .../convex/tests/aiAgentRuntimeSafety.test.ts | 85 ++ packages/convex/tests/automationFixes.test.ts | 1187 +++++++++++++++++ 23 files changed, 2147 insertions(+), 259 deletions(-) create mode 100644 packages/convex/convex/crons.ts create mode 100644 packages/convex/convex/lib/automationWebhookSecrets.ts create mode 100644 packages/convex/convex/migrations/backfillAutomationWebhookSecrets.ts diff --git a/.gitignore b/.gitignore index d49de76..5f46f08 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,5 @@ packages/convex/.env.test artifacts apps/landing/public/opencom-widget.iife.js apps/web/public/opencom-widget.iife.js +CLAUDE.md +openspec/config.yaml diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/specs/automation-access-governance/spec.md b/openspec/changes/expose-automation-api-and-event-webhooks/specs/automation-access-governance/spec.md index de8852d..304eb82 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/specs/automation-access-governance/spec.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/specs/automation-access-governance/spec.md @@ -22,7 +22,7 @@ Actions performed through the automation API or webhook-triggered flows SHALL be - **AND** audit logs SHALL record the workspace, credential, action, and target resource ### Requirement: Automation platform MUST enforce secret-handling and rate-limit safeguards -Automation credentials and webhook secrets SHALL use least-privilege security controls including hashed secret storage, one-time secret reveal, and per-workspace rate limiting for public automation traffic. +Automation credentials and webhook secrets SHALL use least-privilege security controls including one-time secret reveal and per-workspace rate limiting for public automation traffic. API credential secrets are stored as one-way SHA-256 hashes. Webhook signing secrets are stored encrypted server-side, are only returned once on creation, and list and get endpoints expose only a prefix. #### Scenario: Existing credentials do not expose recoverable secrets - **WHEN** an admin views an existing automation credential after its initial creation diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md index a3da3a8..88d1354 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md @@ -25,7 +25,7 @@ - [x] 4.1 Expose automation-relevant conversation metadata including AI workflow state, handoff reason, claim state, and automation eligibility. - [x] 4.2 Implement claim, release, and escalate flows for automation-managed conversations with bounded lease semantics. - [x] 4.3 Enforce conflict protection: claimed conversations require active claim for automation message send. -- [ ] 4.3b Modify AI agent response path to check for active automation claim before posting AI response. +- [x] 4.3b Modify AI agent response path to check for active automation claim before posting AI response. ## 5. Admin Experience And Documentation diff --git a/packages/convex/convex/aiAgent.ts b/packages/convex/convex/aiAgent.ts index a58d049..03896a7 100644 --- a/packages/convex/convex/aiAgent.ts +++ b/packages/convex/convex/aiAgent.ts @@ -640,6 +640,17 @@ export const handoffToHuman = mutation({ const now = Date.now(); + const activeClaim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (activeClaim && activeClaim.expiresAt > now) { + throw new Error("Conversation is currently claimed by external automation"); + } + const messageId: Id<"messages"> = await ctx.db.insert("messages", { conversationId: args.conversationId, senderId: "ai-agent", diff --git a/packages/convex/convex/aiAgentActions.ts b/packages/convex/convex/aiAgentActions.ts index 69faed2..ce9f44c 100644 --- a/packages/convex/convex/aiAgentActions.ts +++ b/packages/convex/convex/aiAgentActions.ts @@ -27,6 +27,10 @@ type ConversationAccessArgs = { sessionToken?: string; }; +type ActiveAutomationClaimArgs = { + conversationId: Id<"conversations">; +}; + type ConversationAccessResult = { conversationId: Id<"conversations">; workspaceId: Id<"workspaces">; @@ -35,6 +39,12 @@ type ConversationAccessResult = { hasHumanAgentResponse: boolean; }; +type ActiveAutomationClaimResult = { + claimId: Id<"automationConversationClaims">; + credentialId: Id<"automationCredentials">; + expiresAt: number; +} | null; + type KnowledgeSource = "articles" | "internalArticles" | "snippets"; type RuntimeSettings = { @@ -126,6 +136,17 @@ const AUTHORIZE_CONVERSATION_ACCESS_REF = makeFunctionReference< ConversationAccessResult >; +const GET_ACTIVE_AUTOMATION_CLAIM_REF = makeFunctionReference< + "query", + ActiveAutomationClaimArgs, + ActiveAutomationClaimResult +>("automationConversationClaims:getActiveClaim") as unknown as ConvexRef< + "query", + "internal", + ActiveAutomationClaimArgs, + ActiveAutomationClaimResult +>; + const GET_RUNTIME_SETTINGS_FOR_WORKSPACE_REF = makeFunctionReference< "query", { workspaceId: Id<"workspaces"> }, @@ -234,6 +255,8 @@ const EMPTY_RESPONSE_RETRY_LIMIT = 1; const MAX_DIAGNOSTIC_MESSAGE_LENGTH = 2000; const DEFAULT_MAX_OUTPUT_TOKENS = 2000; const GPT5_REASONING_MAX_OUTPUT_TOKENS = 10000; +const ACTIVE_AUTOMATION_CLAIM_REASON = + "Conversation is currently claimed by external automation"; const supportsTemperatureControl = (provider: string, model: string): boolean => { // OpenAI GPT-5 reasoning models reject temperature and emit warnings. @@ -488,20 +511,91 @@ export const generateResponse = action({ }; } - // Get AI settings - const settings = await runQuery(GET_RUNTIME_SETTINGS_FOR_WORKSPACE_REF, { - workspaceId: args.workspaceId, + const buildAutomationClaimSuppressionResult = (): GenerateResponseResult => ({ + response: "", + confidence: 0, + sources: [], + handoff: true, + handoffReason: ACTIVE_AUTOMATION_CLAIM_REASON, + messageId: null, }); - if (!settings.enabled) { - const reason = "AI Agent is disabled"; + const hasActiveAutomationClaim = ( + value: ActiveAutomationClaimResult | unknown + ): value is Exclude => + !!value && typeof value === "object" && "claimId" in value; + + const getAutomationClaimSuppression = async (): Promise => { + const activeClaim = await runQuery(GET_ACTIVE_AUTOMATION_CLAIM_REF, { + conversationId: args.conversationId, + }); + return hasActiveAutomationClaim(activeClaim) + ? buildAutomationClaimSuppressionResult() + : null; + }; + + const runClaimSafeHandoff = async ( + reason: string + ): Promise => { + const claimSuppression = await getAutomationClaimSuppression(); + if (claimSuppression) { + return claimSuppression; + } + try { - const handoff = await runMutation(HANDOFF_TO_HUMAN_REF, { + return await runMutation(HANDOFF_TO_HUMAN_REF, { conversationId: args.conversationId, visitorId: args.visitorId, sessionToken: args.sessionToken, reason, }); + } catch (error) { + if (error instanceof Error && error.message === ACTIVE_AUTOMATION_CLAIM_REASON) { + return buildAutomationClaimSuppressionResult(); + } + throw error; + } + }; + + const runClaimSafeBotMessage = async ( + content: string + ): Promise | GenerateResponseResult> => { + const claimSuppression = await getAutomationClaimSuppression(); + if (claimSuppression) { + return claimSuppression; + } + + try { + return await runMutation(INTERNAL_SEND_BOT_MESSAGE_REF, { + conversationId: args.conversationId, + senderId: "ai-agent", + content, + }); + } catch (error) { + if (error instanceof Error && error.message === ACTIVE_AUTOMATION_CLAIM_REASON) { + return buildAutomationClaimSuppressionResult(); + } + throw error; + } + }; + + const initialClaimSuppression = await getAutomationClaimSuppression(); + if (initialClaimSuppression) { + return initialClaimSuppression; + } + + // Get AI settings + const settings = await runQuery(GET_RUNTIME_SETTINGS_FOR_WORKSPACE_REF, { + workspaceId: args.workspaceId, + }); + + if (!settings.enabled) { + const reason = "AI Agent is disabled"; + try { + const handoff = await runClaimSafeHandoff(reason); + if ("handoff" in handoff) { + return handoff; + } return { response: handoff.handoffMessage, @@ -515,11 +609,10 @@ export const generateResponse = action({ console.error("Failed to handoff when AI Agent is disabled:", handoffError); } - const messageId = await runMutation(INTERNAL_SEND_BOT_MESSAGE_REF, { - conversationId: args.conversationId, - senderId: "ai-agent", - content: GENERATION_FAILURE_FALLBACK_RESPONSE, - }); + const messageId = await runClaimSafeBotMessage(GENERATION_FAILURE_FALLBACK_RESPONSE); + if (typeof messageId !== "string") { + return messageId; + } return { response: GENERATION_FAILURE_FALLBACK_RESPONSE, @@ -541,12 +634,10 @@ export const generateResponse = action({ model: configurationDiagnostic.model, }); - const handoff = await runMutation(HANDOFF_TO_HUMAN_REF, { - conversationId: args.conversationId, - visitorId: args.visitorId, - sessionToken: args.sessionToken, - reason: configurationDiagnostic.message, - }); + const handoff = await runClaimSafeHandoff(configurationDiagnostic.message); + if ("handoff" in handoff) { + return handoff; + } return { response: handoff.handoffMessage, @@ -607,12 +698,10 @@ export const generateResponse = action({ } try { - const handoff = await runMutation(HANDOFF_TO_HUMAN_REF, { - conversationId: args.conversationId, - visitorId: args.visitorId, - sessionToken: args.sessionToken, - reason, - }); + const handoff = await runClaimSafeHandoff(reason); + if ("handoff" in handoff) { + return handoff; + } try { await runMutation(STORE_RESPONSE_REF, { @@ -650,11 +739,10 @@ export const generateResponse = action({ console.error("Failed to handoff after AI generation error:", handoffError); } - const messageId = await runMutation(INTERNAL_SEND_BOT_MESSAGE_REF, { - conversationId: args.conversationId, - senderId: "ai-agent", - content: GENERATION_FAILURE_FALLBACK_RESPONSE, - }); + const messageId = await runClaimSafeBotMessage(GENERATION_FAILURE_FALLBACK_RESPONSE); + if (typeof messageId !== "string") { + return messageId; + } try { await runMutation(STORE_RESPONSE_REF, { @@ -843,12 +931,10 @@ export const generateResponse = action({ if (handoff) { // Preserve both generated and delivered contexts while keeping one visitor-facing handoff message. - const handoffResult = await runMutation(HANDOFF_TO_HUMAN_REF, { - conversationId: args.conversationId, - visitorId: args.visitorId, - sessionToken: args.sessionToken, - reason: handoffReason ?? undefined, - }); + const handoffResult = await runClaimSafeHandoff(handoffReason ?? "AI indicated handoff needed"); + if ("handoff" in handoffResult) { + return handoffResult; + } await runMutation(STORE_RESPONSE_REF, { conversationId: args.conversationId, @@ -881,11 +967,10 @@ export const generateResponse = action({ } // Create the AI message via the internal bot-only path. - const messageId = await runMutation(INTERNAL_SEND_BOT_MESSAGE_REF, { - conversationId: args.conversationId, - senderId: "ai-agent", - content: responseText, - }); + const messageId = await runClaimSafeBotMessage(responseText); + if (typeof messageId !== "string") { + return messageId; + } // Store the AI response for analytics await runMutation(STORE_RESPONSE_REF, { diff --git a/packages/convex/convex/automationApiInternals.ts b/packages/convex/convex/automationApiInternals.ts index f4a30ba..fc424e5 100644 --- a/packages/convex/convex/automationApiInternals.ts +++ b/packages/convex/convex/automationApiInternals.ts @@ -1,6 +1,162 @@ import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; import { internalMutation, internalQuery } from "./_generated/server"; import { logAudit } from "./auditLogs"; +import { encodeCursor, decodeCursor } from "./lib/apiHelpers"; + +const DEFAULT_SCAN_BATCH_SIZE = 200; + +type DescCursor = { + sortValue?: number; + id?: string; +}; + +type VisitorFilterOptions = { + email?: string; + externalUserId?: string; + customAttributeKey?: string; + customAttributeValue?: string; +}; + +function decodeDescCursor(cursor?: string): DescCursor { + if (!cursor) { + return {}; + } + + const decoded = decodeCursor(cursor); + if (decoded) { + return { sortValue: decoded.sortValue, id: decoded.id }; + } + + const fallbackSortValue = Number.parseFloat(cursor); + return Number.isFinite(fallbackSortValue) ? { sortValue: fallbackSortValue } : {}; +} + +function isAtOrPastCursor( + sortValue: number | undefined, + id: string, + cursor: DescCursor +): boolean { + return ( + cursor.sortValue !== undefined && + cursor.id !== undefined && + sortValue === cursor.sortValue && + id >= cursor.id + ); +} + +async function collectDescendingPage(options: { + limit: number; + cursor: DescCursor; + getSortValue: (item: T) => number | undefined; + fetchBatch: (upperBound: number | undefined, take: number) => Promise; + filterBatch?: (batch: T[]) => Promise | T[]; +}): Promise { + const results: T[] = []; + let scanCursor = options.cursor; + let batchSize = Math.max(options.limit * 3, DEFAULT_SCAN_BATCH_SIZE); + + while (results.length < options.limit + 1) { + const batch = await options.fetchBatch(scanCursor.sortValue, batchSize); + if (batch.length === 0) { + break; + } + + const visibleBatch = scanCursor.id + ? batch.filter( + (item) => !isAtOrPastCursor(options.getSortValue(item), item._id, scanCursor) + ) + : batch; + + if (scanCursor.id && visibleBatch.length === 0 && batch.length === batchSize) { + // The current window hasn't scanned far enough to move past the cursor + // within a large same-timestamp tie. Expand and retry from the same + // cursor instead of rewinding scanCursor to an earlier batch boundary. + batchSize *= 2; + continue; + } + + const filteredBatch = options.filterBatch + ? await options.filterBatch(visibleBatch) + : visibleBatch; + + for (const item of filteredBatch) { + results.push(item); + if (results.length >= options.limit + 1) { + return results; + } + } + + if (batch.length < batchSize) { + break; + } + + const lastBatchItem = batch[batch.length - 1]; + const nextCursor = { + sortValue: options.getSortValue(lastBatchItem), + id: lastBatchItem._id, + }; + + if ( + nextCursor.sortValue === scanCursor.sortValue && + nextCursor.id === scanCursor.id + ) { + // Keep expanding the fetch window until we move past the current tie on + // the indexed sort value. Capping this causes large same-timestamp groups + // to become unpageable. + batchSize *= 2; + continue; + } + + scanCursor = nextCursor; + } + + return results; +} + +async function loadVisitorsById( + ctx: any, + visitorIds: Array | undefined> +): Promise>> { + const uniqueVisitorIds = Array.from( + new Set(visitorIds.filter((visitorId): visitorId is Id<"visitors"> => !!visitorId)) + ); + + const visitors = await Promise.all( + uniqueVisitorIds.map(async (visitorId) => { + const visitor = await ctx.db.get(visitorId); + return visitor ? ([String(visitorId), visitor] as const) : null; + }) + ); + + return new Map( + visitors.filter( + (entry): entry is readonly [string, Doc<"visitors">] => entry !== null + ) + ); +} + +function matchesVisitorFilters( + visitor: Doc<"visitors"> | undefined, + filters: VisitorFilterOptions +): boolean { + if (filters.email && visitor?.email !== filters.email) { + return false; + } + if (filters.externalUserId && visitor?.externalUserId !== filters.externalUserId) { + return false; + } + if ( + filters.customAttributeKey && + filters.customAttributeValue !== undefined && + (visitor?.customAttributes as Record | undefined)?.[ + filters.customAttributeKey + ] !== filters.customAttributeValue + ) { + return false; + } + return true; +} // ── Conversations ────────────────────────────────────────────────── @@ -12,36 +168,82 @@ export const listConversationsForAutomation = internalQuery({ updatedSince: v.optional(v.number()), status: v.optional(v.string()), assigneeId: v.optional(v.string()), + channel: v.optional(v.string()), + email: v.optional(v.string()), + externalUserId: v.optional(v.string()), + customAttributeKey: v.optional(v.string()), + customAttributeValue: v.optional(v.string()), }, handler: async (ctx, args) => { const limit = Math.min(args.limit, 100); - let query; - - if (args.status) { - query = ctx.db - .query("conversations") - .withIndex("by_status", (q) => - q - .eq("workspaceId", args.workspaceId) - .eq("status", args.status as "open" | "closed" | "snoozed") + const cursor = decodeDescCursor(args.cursor); + const needsVisitorFilters = Boolean( + args.email || + args.externalUserId || + (args.customAttributeKey && args.customAttributeValue !== undefined) + ); + const visitorFilters: VisitorFilterOptions = { + email: args.email ?? undefined, + externalUserId: args.externalUserId ?? undefined, + customAttributeKey: args.customAttributeKey ?? undefined, + customAttributeValue: args.customAttributeValue ?? undefined, + }; + + const conversations = await collectDescendingPage>({ + limit, + cursor, + getSortValue: (conversation) => conversation.updatedAt, + fetchBatch: async (upperBound, take) => { + let query = ctx.db + .query("conversations") + .withIndex("by_workspace_updated_at", (q) => { + if (args.updatedSince !== undefined && upperBound !== undefined) { + return q + .eq("workspaceId", args.workspaceId) + .gt("updatedAt", args.updatedSince) + .lte("updatedAt", upperBound); + } + if (args.updatedSince !== undefined) { + return q.eq("workspaceId", args.workspaceId).gt("updatedAt", args.updatedSince); + } + if (upperBound !== undefined) { + return q.eq("workspaceId", args.workspaceId).lte("updatedAt", upperBound); + } + return q.eq("workspaceId", args.workspaceId); + }); + + if (args.status) { + query = query.filter((q2) => q2.eq(q2.field("status"), args.status!)); + } + if (args.channel) { + query = query.filter((q2) => q2.eq(q2.field("channel"), args.channel!)); + } + if (args.assigneeId) { + query = query.filter((q2) => q2.eq(q2.field("assignedAgentId"), args.assigneeId!)); + } + + return query.order("desc").take(take); + }, + filterBatch: async (batch) => { + if (!needsVisitorFilters) { + return batch; + } + + const visitorMap = await loadVisitorsById( + ctx, + batch.map((conversation) => conversation.visitorId) ); - } else { - query = ctx.db - .query("conversations") - .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)); - } - if (args.cursor) { - const cursorTime = Number.parseFloat(args.cursor); - query = query.filter((q2) => q2.lt(q2.field("_creationTime"), cursorTime)); - } - if (args.updatedSince) { - query = query.filter((q2) => q2.gte(q2.field("updatedAt"), args.updatedSince!)); - } - if (args.assigneeId) { - query = query.filter((q2) => q2.eq(q2.field("assignedAgentId"), args.assigneeId!)); - } - const conversations = await query.order("desc").take(limit + 1); + return batch.filter((conversation) => + matchesVisitorFilters( + conversation.visitorId + ? visitorMap.get(String(conversation.visitorId)) + : undefined, + visitorFilters + ) + ); + }, + }); const hasMore = conversations.length > limit; const data = hasMore ? conversations.slice(0, limit) : conversations; @@ -90,6 +292,7 @@ export const listConversationsForAutomation = internalQuery({ channel: c.channel, subject: c.subject, aiWorkflowState: c.aiWorkflowState, + aiHandoffReason: c.aiHandoffReason, automationEligible: c.status === "open" && !activeClaim && c.aiWorkflowState !== "ai_handled", createdAt: c.createdAt, updatedAt: c.updatedAt, @@ -101,7 +304,7 @@ export const listConversationsForAutomation = internalQuery({ }), nextCursor: hasMore && data.length > 0 - ? String(data[data.length - 1]._creationTime) + ? encodeCursor(data[data.length - 1].updatedAt, data[data.length - 1]._id) : null, hasMore, }; @@ -222,11 +425,31 @@ export const listMessagesForAutomation = internalQuery({ .query("messages") .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)); + // Decode compound cursor; fall back to bare number for backward compat + let cursorTs: number | undefined; + let cursorId: string | undefined; if (args.cursor) { - const cursorTime = Number.parseFloat(args.cursor); - messagesQuery = messagesQuery.filter((q2) => q2.gt(q2.field("_creationTime"), cursorTime)); + const decoded = decodeCursor(args.cursor); + if (decoded) { + cursorTs = decoded.sortValue; + cursorId = decoded.id; + } else { + cursorTs = Number.parseFloat(args.cursor); + } + } + + if (cursorTs !== undefined) { + messagesQuery = messagesQuery.filter((q2) => q2.gte(q2.field("_creationTime"), cursorTs!)); + } + const fetchSize = cursorId ? limit + 50 : limit + 1; + let messages = await messagesQuery.order("asc").take(fetchSize); + + // For ascending: skip items at cursor timestamp with _id <= cursorId + if (cursorId) { + messages = messages.filter( + (m) => !(m._creationTime === cursorTs && m._id <= cursorId!) + ); } - const messages = await messagesQuery.order("asc").take(limit + 1); const hasMore = messages.length > limit; const data = hasMore ? messages.slice(0, limit) : messages; @@ -242,7 +465,7 @@ export const listMessagesForAutomation = internalQuery({ })), nextCursor: hasMore && data.length > 0 - ? String(data[data.length - 1]._creationTime) + ? encodeCursor(data[data.length - 1]._creationTime, data[data.length - 1]._id) : null, hasMore, }; @@ -418,32 +641,58 @@ export const listVisitorsForAutomation = internalQuery({ updatedSince: v.optional(v.number()), email: v.optional(v.string()), externalUserId: v.optional(v.string()), + customAttributeKey: v.optional(v.string()), + customAttributeValue: v.optional(v.string()), }, handler: async (ctx, args) => { const limit = Math.min(args.limit, 100); - let visitorsQuery = ctx.db - .query("visitors") - .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)); - - if (args.cursor) { - const cursorTime = Number.parseFloat(args.cursor); - visitorsQuery = visitorsQuery.filter((q2) => q2.lt(q2.field("_creationTime"), cursorTime)); - } - // Note: updatedSince filters on lastSeenAt only. Visitors without lastSeenAt - // (created outside automation) won't match. This is acceptable since automation- - // created visitors always have lastSeenAt set. - if (args.updatedSince) { - visitorsQuery = visitorsQuery.filter((q2) => - q2.gte(q2.field("lastSeenAt"), args.updatedSince!) - ); - } - if (args.email) { - visitorsQuery = visitorsQuery.filter((q2) => q2.eq(q2.field("email"), args.email!)); - } - if (args.externalUserId) { - visitorsQuery = visitorsQuery.filter((q2) => q2.eq(q2.field("externalUserId"), args.externalUserId!)); - } - const visitors = await visitorsQuery.order("desc").take(limit + 1); + const cursor = decodeDescCursor(args.cursor); + + const visitors = await collectDescendingPage>({ + limit, + cursor, + getSortValue: (visitor) => visitor.lastSeenAt, + fetchBatch: async (upperBound, take) => { + let query = ctx.db + .query("visitors") + .withIndex("by_workspace_last_seen", (q) => { + if (args.updatedSince !== undefined && upperBound !== undefined) { + return q + .eq("workspaceId", args.workspaceId) + .gt("lastSeenAt", args.updatedSince) + .lte("lastSeenAt", upperBound); + } + if (args.updatedSince !== undefined) { + return q.eq("workspaceId", args.workspaceId).gt("lastSeenAt", args.updatedSince); + } + if (upperBound !== undefined) { + return q.eq("workspaceId", args.workspaceId).lte("lastSeenAt", upperBound); + } + return q.eq("workspaceId", args.workspaceId); + }); + + if (args.email) { + query = query.filter((q2) => q2.eq(q2.field("email"), args.email!)); + } + if (args.externalUserId) { + query = query.filter((q2) => q2.eq(q2.field("externalUserId"), args.externalUserId!)); + } + + return query.order("desc").take(take); + }, + filterBatch: (batch) => { + if (!args.customAttributeKey || args.customAttributeValue === undefined) { + return batch; + } + + return batch.filter( + (visitor) => + (visitor.customAttributes as Record | undefined)?.[ + args.customAttributeKey! + ] === args.customAttributeValue + ); + }, + }); const hasMore = visitors.length > limit; const data = hasMore ? visitors.slice(0, limit) : visitors; @@ -463,7 +712,10 @@ export const listVisitorsForAutomation = internalQuery({ })), nextCursor: hasMore && data.length > 0 - ? String(data[data.length - 1]._creationTime) + ? encodeCursor( + data[data.length - 1].lastSeenAt ?? data[data.length - 1].createdAt, + data[data.length - 1]._id + ) : null, hasMore, }; @@ -581,46 +833,51 @@ export const listTicketsForAutomation = internalQuery({ workspaceId: v.id("workspaces"), cursor: v.optional(v.string()), limit: v.number(), + updatedSince: v.optional(v.number()), status: v.optional(v.string()), priority: v.optional(v.string()), assigneeId: v.optional(v.string()), }, handler: async (ctx, args) => { const limit = Math.min(args.limit, 100); - let query; - - if (args.status) { - query = ctx.db - .query("tickets") - .withIndex("by_status", (q) => - q - .eq("workspaceId", args.workspaceId) - .eq( - "status", - args.status as - | "submitted" - | "in_progress" - | "waiting_on_customer" - | "resolved" - ) - ); - } else { - query = ctx.db - .query("tickets") - .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)); - } - - if (args.cursor) { - const cursorTime = Number.parseFloat(args.cursor); - query = query.filter((q2) => q2.lt(q2.field("_creationTime"), cursorTime)); - } - if (args.priority) { - query = query.filter((q2) => q2.eq(q2.field("priority"), args.priority!)); - } - if (args.assigneeId) { - query = query.filter((q2) => q2.eq(q2.field("assigneeId"), args.assigneeId!)); - } - const tickets = await query.order("desc").take(limit + 1); + const cursor = decodeDescCursor(args.cursor); + + const tickets = await collectDescendingPage>({ + limit, + cursor, + getSortValue: (ticket) => ticket.updatedAt, + fetchBatch: async (upperBound, take) => { + let query = ctx.db + .query("tickets") + .withIndex("by_workspace_updated_at", (q) => { + if (args.updatedSince !== undefined && upperBound !== undefined) { + return q + .eq("workspaceId", args.workspaceId) + .gt("updatedAt", args.updatedSince) + .lte("updatedAt", upperBound); + } + if (args.updatedSince !== undefined) { + return q.eq("workspaceId", args.workspaceId).gt("updatedAt", args.updatedSince); + } + if (upperBound !== undefined) { + return q.eq("workspaceId", args.workspaceId).lte("updatedAt", upperBound); + } + return q.eq("workspaceId", args.workspaceId); + }); + + if (args.status) { + query = query.filter((q2) => q2.eq(q2.field("status"), args.status!)); + } + if (args.priority) { + query = query.filter((q2) => q2.eq(q2.field("priority"), args.priority!)); + } + if (args.assigneeId) { + query = query.filter((q2) => q2.eq(q2.field("assigneeId"), args.assigneeId!)); + } + + return query.order("desc").take(take); + }, + }); const hasMore = tickets.length > limit; const data = hasMore ? tickets.slice(0, limit) : tickets; @@ -642,7 +899,7 @@ export const listTicketsForAutomation = internalQuery({ })), nextCursor: hasMore && data.length > 0 - ? String(data[data.length - 1]._creationTime) + ? encodeCursor(data[data.length - 1].updatedAt, data[data.length - 1]._id) : null, hasMore, }; diff --git a/packages/convex/convex/automationConversationClaims.ts b/packages/convex/convex/automationConversationClaims.ts index 6a26507..0371dff 100644 --- a/packages/convex/convex/automationConversationClaims.ts +++ b/packages/convex/convex/automationConversationClaims.ts @@ -165,40 +165,6 @@ export const escalateConversation = internalMutation({ }, }); -export const renewLease = internalMutation({ - args: { - workspaceId: v.id("workspaces"), - conversationId: v.id("conversations"), - credentialId: v.id("automationCredentials"), - }, - handler: async (ctx, args) => { - const claim = await ctx.db - .query("automationConversationClaims") - .withIndex("by_conversation_status", (q) => - q.eq("conversationId", args.conversationId).eq("status", "active") - ) - .first(); - - if (!claim) { - throw new Error("No active claim found"); - } - - if (claim.credentialId !== args.credentialId) { - throw new Error("Claim belongs to a different credential"); - } - - if (claim.expiresAt < Date.now()) { - throw new Error("Claim has already expired"); - } - - await ctx.db.patch(claim._id, { - expiresAt: Date.now() + CLAIM_LEASE_MS, - }); - - return { success: true, expiresAt: Date.now() + CLAIM_LEASE_MS }; - }, -}); - export const getActiveClaim = internalQuery({ args: { conversationId: v.id("conversations"), diff --git a/packages/convex/convex/automationEvents.ts b/packages/convex/convex/automationEvents.ts index 89142bb..eb3577e 100644 --- a/packages/convex/convex/automationEvents.ts +++ b/packages/convex/convex/automationEvents.ts @@ -1,6 +1,7 @@ import { makeFunctionReference } from "convex/server"; import { v } from "convex/values"; import { internalMutation, internalQuery } from "./_generated/server"; +import { encodeCursor, decodeCursor } from "./lib/apiHelpers"; const deliverWebhookRef = makeFunctionReference<"action">( "automationWebhookWorker:deliverWebhook" @@ -81,18 +82,50 @@ export const listEvents = internalQuery({ handler: async (ctx, args) => { const limit = Math.min(args.limit, 100); - const eventsQuery = ctx.db - .query("automationEvents") - .withIndex("by_workspace_timestamp", (q2) => - args.cursor - ? q2.eq("workspaceId", args.workspaceId).lt("timestamp", Number.parseFloat(args.cursor)) - : q2.eq("workspaceId", args.workspaceId) + // Decode compound cursor; fall back to bare number for backward compat + let cursorTs: number | undefined; + let cursorId: string | undefined; + if (args.cursor) { + const decoded = decodeCursor(args.cursor); + if (decoded) { + cursorTs = decoded.sortValue; + cursorId = decoded.id; + } else { + cursorTs = Number.parseFloat(args.cursor); + } + } + + const loadRawEvents = (take: number) => + ctx.db + .query("automationEvents") + .withIndex("by_workspace_timestamp", (q2) => + cursorTs !== undefined + ? q2.eq("workspaceId", args.workspaceId).lte("timestamp", cursorTs) + : q2.eq("workspaceId", args.workspaceId) + ) + .order("desc") + .take(take); + + // Keep expanding the fetch window until cursor filtering leaves enough rows + // to answer this page or the index is exhausted. + let fetchSize = cursorId ? Math.max(limit * 3, 200) : limit + 1; + let rawEvents = await loadRawEvents(fetchSize); + let events = cursorId + ? rawEvents.filter((e) => !(e.timestamp === cursorTs && e._id >= cursorId!)) + : rawEvents; + + while (cursorId && events.length <= limit && rawEvents.length === fetchSize) { + fetchSize *= 2; + rawEvents = await loadRawEvents(fetchSize); + events = rawEvents.filter( + (e) => !(e.timestamp === cursorTs && e._id >= cursorId!) ); - const events = await eventsQuery.order("desc").take(limit + 1); + } const hasMore = events.length > limit; const data = hasMore ? events.slice(0, limit) : events; + const lastItem = data[data.length - 1]; return { data: data.map((e) => ({ id: e._id, @@ -103,8 +136,8 @@ export const listEvents = internalQuery({ timestamp: e.timestamp, })), nextCursor: - hasMore && data.length > 0 - ? String(data[data.length - 1].timestamp) + hasMore && lastItem + ? encodeCursor(lastItem.timestamp, lastItem._id) : null, hasMore, }; diff --git a/packages/convex/convex/automationHttpRoutes.ts b/packages/convex/convex/automationHttpRoutes.ts index f48818b..c803321 100644 --- a/packages/convex/convex/automationHttpRoutes.ts +++ b/packages/convex/convex/automationHttpRoutes.ts @@ -1,7 +1,15 @@ import { makeFunctionReference } from "convex/server"; import { httpAction } from "./_generated/server"; import { withAutomationAuth } from "./lib/automationAuth"; -import { jsonResponse, errorResponse, parsePaginationParams } from "./lib/apiHelpers"; +import { jsonResponse, errorResponse, parsePaginationParams, isPlausibleConvexId } from "./lib/apiHelpers"; + +function catchToResponse(error: unknown): Response { + const msg = String(error); + if (msg.includes("is not a valid ID") || msg.includes("Unable to parse")) { + return errorResponse("Invalid resource ID", 400); + } + return errorResponse(msg, 500); +} // Use makeFunctionReference for cross-module references (no codegen dependency). // Args/return types are untyped since codegen hasn't run; runtime validation @@ -45,6 +53,11 @@ export const listConversations = httpAction(async (ctx, request) => { const { cursor, limit, updatedSince } = parsePaginationParams(url); const status = url.searchParams.get("status"); const assignee = url.searchParams.get("assignee"); + const channel = url.searchParams.get("channel"); + const email = url.searchParams.get("email"); + const externalUserId = url.searchParams.get("externalUserId"); + const customAttributeKey = url.searchParams.get("customAttribute.key"); + const customAttributeValue = url.searchParams.get("customAttribute.value"); const result = await ctx.runQuery(listConversationsRef, { workspaceId: authResult.workspaceId, @@ -53,10 +66,15 @@ export const listConversations = httpAction(async (ctx, request) => { updatedSince: updatedSince ?? undefined, status: status ?? undefined, assigneeId: assignee ?? undefined, + channel: channel ?? undefined, + email: email ?? undefined, + externalUserId: externalUserId ?? undefined, + customAttributeKey: customAttributeKey ?? undefined, + customAttributeValue: customAttributeValue ?? undefined, }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -69,6 +87,7 @@ export const getConversation = httpAction(async (ctx, request) => { const url = new URL(request.url); const id = url.searchParams.get("id"); if (!id) return errorResponse("Missing id parameter", 400); + if (!isPlausibleConvexId(id)) return errorResponse("Invalid id format", 400); const result = await ctx.runQuery(getConversationRef, { workspaceId: authResult.workspaceId, @@ -77,7 +96,7 @@ export const getConversation = httpAction(async (ctx, request) => { if (!result) return errorResponse("Conversation not found", 404); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -100,7 +119,7 @@ export const updateConversation = httpAction(async (ctx, request) => { }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -123,7 +142,7 @@ export const listMessages = httpAction(async (ctx, request) => { }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -154,7 +173,7 @@ export const sendMessage = httpAction(async (ctx, request) => { } return jsonResponse(result.result, 201); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -175,7 +194,7 @@ export const claimConversation = httpAction(async (ctx, request) => { }); return jsonResponse(result, 201); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -196,7 +215,7 @@ export const releaseConversation = httpAction(async (ctx, request) => { }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -217,7 +236,7 @@ export const escalateConversation = httpAction(async (ctx, request) => { }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -231,6 +250,8 @@ export const listVisitors = httpAction(async (ctx, request) => { const { cursor, limit, updatedSince } = parsePaginationParams(url); const email = url.searchParams.get("email"); const externalUserId = url.searchParams.get("externalUserId"); + const customAttributeKey = url.searchParams.get("customAttribute.key"); + const customAttributeValue = url.searchParams.get("customAttribute.value"); const result = await ctx.runQuery(listVisitorsRef, { workspaceId: authResult.workspaceId, @@ -239,10 +260,12 @@ export const listVisitors = httpAction(async (ctx, request) => { updatedSince: updatedSince ?? undefined, email: email ?? undefined, externalUserId: externalUserId ?? undefined, + customAttributeKey: customAttributeKey ?? undefined, + customAttributeValue: customAttributeValue ?? undefined, }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -255,6 +278,7 @@ export const getVisitor = httpAction(async (ctx, request) => { const url = new URL(request.url); const id = url.searchParams.get("id"); if (!id) return errorResponse("Missing id parameter", 400); + if (!isPlausibleConvexId(id)) return errorResponse("Invalid id format", 400); const result = await ctx.runQuery(getVisitorRef, { workspaceId: authResult.workspaceId, @@ -263,7 +287,7 @@ export const getVisitor = httpAction(async (ctx, request) => { if (!result) return errorResponse("Visitor not found", 404); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -284,7 +308,7 @@ export const createVisitor = httpAction(async (ctx, request) => { }); return jsonResponse(result, 201); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -309,7 +333,7 @@ export const updateVisitor = httpAction(async (ctx, request) => { }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -320,7 +344,7 @@ export const listTickets = httpAction(async (ctx, request) => { try { const url = new URL(request.url); - const { cursor, limit } = parsePaginationParams(url); + const { cursor, limit, updatedSince } = parsePaginationParams(url); const status = url.searchParams.get("status"); const priority = url.searchParams.get("priority"); const assignee = url.searchParams.get("assignee"); @@ -329,13 +353,14 @@ export const listTickets = httpAction(async (ctx, request) => { workspaceId: authResult.workspaceId, cursor: cursor ?? undefined, limit, + updatedSince: updatedSince ?? undefined, status: status ?? undefined, priority: priority ?? undefined, assigneeId: assignee ?? undefined, }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -348,6 +373,7 @@ export const getTicket = httpAction(async (ctx, request) => { const url = new URL(request.url); const id = url.searchParams.get("id"); if (!id) return errorResponse("Missing id parameter", 400); + if (!isPlausibleConvexId(id)) return errorResponse("Invalid id format", 400); const result = await ctx.runQuery(getTicketRef, { workspaceId: authResult.workspaceId, @@ -356,7 +382,7 @@ export const getTicket = httpAction(async (ctx, request) => { if (!result) return errorResponse("Ticket not found", 404); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -381,7 +407,7 @@ export const createTicket = httpAction(async (ctx, request) => { }); return jsonResponse(result, 201); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -406,7 +432,7 @@ export const updateTicket = httpAction(async (ctx, request) => { }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -426,7 +452,7 @@ export const replayWebhookDelivery = httpAction(async (ctx, request) => { }); return jsonResponse(result, 201); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); @@ -446,6 +472,6 @@ export const eventsFeed = httpAction(async (ctx, request) => { }); return jsonResponse(result); } catch (error) { - return errorResponse(String(error), 500); + return catchToResponse(error); } }); diff --git a/packages/convex/convex/automationWebhookWorker.ts b/packages/convex/convex/automationWebhookWorker.ts index 21ea656..f6950a2 100644 --- a/packages/convex/convex/automationWebhookWorker.ts +++ b/packages/convex/convex/automationWebhookWorker.ts @@ -1,6 +1,7 @@ import { makeFunctionReference } from "convex/server"; import { v } from "convex/values"; import { internalAction, internalMutation, internalQuery } from "./_generated/server"; +import { decryptWebhookSecret } from "./lib/automationWebhookSecrets"; // Self-references via makeFunctionReference const deliverWebhookRef = makeFunctionReference<"action">( @@ -48,6 +49,8 @@ export const deliverWebhook = internalAction({ deliveryId: v.id("automationWebhookDeliveries"), }, handler: async (ctx, args) => { + const runMutation = ctx.runMutation as unknown as RunMutation; + // Load delivery, subscription, and event data const deliveryData = (await ctx.runQuery(getDeliveryDataRef, { deliveryId: args.deliveryId, @@ -61,7 +64,8 @@ export const deliverWebhook = internalAction({ }; subscription: { url: string; - signingSecret: string; + signingSecret?: string; + signingSecretCiphertext?: string; }; event: { eventType: string; @@ -73,6 +77,11 @@ export const deliverWebhook = internalAction({ } | null; if (!deliveryData) { + await runMutation(updateDeliveryStatusRef, { + deliveryId: args.deliveryId, + status: "failed", + error: "Subscription or event no longer exists", + }); return; } @@ -88,10 +97,36 @@ export const deliverWebhook = internalAction({ const timestamp = Math.floor(Date.now() / 1000); const signedPayload = `${timestamp}.${body}`; + let signature: string; - const signature = await hmacSign(subscription.signingSecret, signedPayload); + try { + // Resolve signing secret before attempting network delivery so preparation + // failures are persisted instead of leaving the delivery stuck pending. + let signingSecret: string; + if (subscription.signingSecretCiphertext) { + signingSecret = await decryptWebhookSecret(subscription.signingSecretCiphertext); + } else if (subscription.signingSecret) { + signingSecret = subscription.signingSecret; + } else { + await runMutation(updateDeliveryStatusRef, { + deliveryId: args.deliveryId, + status: "failed", + error: "No signing secret available", + }); + return; + } - const runMutation = ctx.runMutation as unknown as RunMutation; + signature = await hmacSign(signingSecret, signedPayload); + } catch (error) { + await runMutation(updateDeliveryStatusRef, { + deliveryId: args.deliveryId, + status: "failed", + error: `Failed to prepare webhook delivery: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + return; + } try { const response = await fetch(subscription.url, { @@ -100,6 +135,7 @@ export const deliverWebhook = internalAction({ "Content-Type": "application/json", "X-Opencom-Signature": `t=${timestamp},v1=${signature}`, "X-Opencom-Event-Id": delivery.eventId, + "X-Opencom-Delivery-Id": delivery._id, "X-Opencom-Timestamp": String(timestamp), }, body, @@ -244,6 +280,12 @@ export const replayDelivery = internalMutation({ throw new Error("Delivery does not belong to this workspace"); } + // Validate that subscription and event still exist + const subscription = await ctx.db.get(delivery.subscriptionId); + if (!subscription) throw new Error("Webhook subscription no longer exists"); + const event = await ctx.db.get(delivery.eventId); + if (!event) throw new Error("Event no longer exists"); + // Create a new delivery row for the replay const now = Date.now(); const newDeliveryId = await ctx.db.insert("automationWebhookDeliveries", { diff --git a/packages/convex/convex/automationWebhooks.ts b/packages/convex/convex/automationWebhooks.ts index d7af196..82b0afc 100644 --- a/packages/convex/convex/automationWebhooks.ts +++ b/packages/convex/convex/automationWebhooks.ts @@ -1,6 +1,7 @@ import { makeFunctionReference } from "convex/server"; import { v } from "convex/values"; import { authMutation, authQuery } from "./lib/authWrappers"; +import { encryptWebhookSecret } from "./lib/automationWebhookSecrets"; const emitEventRef = makeFunctionReference<"mutation">("automationEvents:emitEvent"); @@ -27,16 +28,23 @@ export const createSubscription = authMutation({ }, permission: "settings.integrations", handler: async (ctx, args) => { - // The signing secret is stored in plaintext in the DB (server-side only). - // It's returned once to the admin on creation, never retrievable again. const signingSecret = generateSigningSecret(); const signingSecretPrefix = signingSecret.slice(0, 14); // "whsec_" + 8 chars + let signingSecretCiphertext: string; + try { + signingSecretCiphertext = await encryptWebhookSecret(signingSecret); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown encryption failure"; + throw new Error(`Webhook secret encryption is not configured: ${message}`); + } + const now = Date.now(); const id = await ctx.db.insert("automationWebhookSubscriptions", { workspaceId: args.workspaceId, url: args.url, - signingSecret, + signingSecretCiphertext, signingSecretPrefix, eventTypes: args.eventTypes, resourceTypes: args.resourceTypes, @@ -47,6 +55,7 @@ export const createSubscription = authMutation({ createdAt: now, }); + // Return the plaintext secret once — it's the only time the caller sees it. return { subscriptionId: id, signingSecret }; }, }); diff --git a/packages/convex/convex/crons.ts b/packages/convex/convex/crons.ts new file mode 100644 index 0000000..ce4a339 --- /dev/null +++ b/packages/convex/convex/crons.ts @@ -0,0 +1,28 @@ +import { cronJobs, makeFunctionReference } from "convex/server"; + +const crons = cronJobs(); + +const expireStaleClaimsRef = makeFunctionReference<"mutation">( + "automationConversationClaims:expireStaleClaims" +); +const cleanupExpiredKeysRef = makeFunctionReference<"mutation">( + "lib/idempotency:cleanupExpiredIdempotencyKeys" +); + +// Expire stale automation conversation claims every 5 minutes +crons.interval( + "expire stale automation claims", + { minutes: 5 }, + expireStaleClaimsRef as any, + {} +); + +// Clean up expired idempotency keys every hour +crons.interval( + "cleanup expired idempotency keys", + { hours: 1 }, + cleanupExpiredKeysRef as any, + {} +); + +export default crons; diff --git a/packages/convex/convex/lib/apiHelpers.ts b/packages/convex/convex/lib/apiHelpers.ts index 47abd02..2907423 100644 --- a/packages/convex/convex/lib/apiHelpers.ts +++ b/packages/convex/convex/lib/apiHelpers.ts @@ -28,6 +28,30 @@ export function buildPaginatedResponse( return { data, nextCursor, hasMore }; } +export function isPlausibleConvexId(id: string): boolean { + return typeof id === "string" && id.length > 0 && /^[a-zA-Z0-9_-]+$/.test(id); +} + +export function encodeCursor(sortValue: number, id: string): string { + return btoa(JSON.stringify([sortValue, id])); +} + +export function decodeCursor(cursor: string): { sortValue: number; id: string } | null { + try { + const parsed = JSON.parse(atob(cursor)); + if ( + Array.isArray(parsed) && + typeof parsed[0] === "number" && + typeof parsed[1] === "string" + ) { + return { sortValue: parsed[0], id: parsed[1] }; + } + return null; + } catch { + return null; + } +} + export function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, diff --git a/packages/convex/convex/lib/automationAuth.ts b/packages/convex/convex/lib/automationAuth.ts index a3e3656..a497938 100644 --- a/packages/convex/convex/lib/automationAuth.ts +++ b/packages/convex/convex/lib/automationAuth.ts @@ -139,7 +139,8 @@ export const lookupCredential = internalQuery({ }); const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute -const RATE_LIMIT_MAX_REQUESTS = 60; // 60 requests per minute +const CREDENTIAL_RATE_LIMIT = 60; // 60 requests per minute per credential +const WORKSPACE_RATE_LIMIT = 120; // 120 requests per minute per workspace // Internal mutation to check rate limit and update lastUsedAt. export const checkRateLimit = internalMutation({ @@ -149,6 +150,31 @@ export const checkRateLimit = internalMutation({ }, handler: async (ctx, args) => { const now = Date.now(); + + // 1. Check workspace-level rate limit first (120 req/min) + const wsRateLimit = await ctx.db + .query("automationWorkspaceRateLimits") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .first(); + + if (wsRateLimit) { + if (now > wsRateLimit.windowStart + RATE_LIMIT_WINDOW_MS) { + await ctx.db.patch(wsRateLimit._id, { windowStart: now, count: 1 }); + } else if (wsRateLimit.count >= WORKSPACE_RATE_LIMIT) { + const retryAfter = Math.ceil((wsRateLimit.windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000); + return { allowed: false, retryAfter }; + } else { + await ctx.db.patch(wsRateLimit._id, { count: wsRateLimit.count + 1 }); + } + } else { + await ctx.db.insert("automationWorkspaceRateLimits", { + workspaceId: args.workspaceId, + windowStart: now, + count: 1, + }); + } + + // 2. Check credential-level rate limit (60 req/min) const credential = await ctx.db.get(args.credentialId); if (!credential) { return { allowed: false, retryAfter: 60 }; @@ -167,7 +193,7 @@ export const checkRateLimit = internalMutation({ return { allowed: true }; } - if (count >= RATE_LIMIT_MAX_REQUESTS) { + if (count >= CREDENTIAL_RATE_LIMIT) { const retryAfter = Math.ceil((windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000); return { allowed: false, retryAfter }; } diff --git a/packages/convex/convex/lib/automationWebhookSecrets.ts b/packages/convex/convex/lib/automationWebhookSecrets.ts new file mode 100644 index 0000000..37b772f --- /dev/null +++ b/packages/convex/convex/lib/automationWebhookSecrets.ts @@ -0,0 +1,83 @@ +/** + * AES-GCM encryption/decryption for webhook signing secrets. + * + * Env var: AUTOMATION_WEBHOOK_SECRET_ENCRYPTION_KEY — base64-encoded 32-byte key. + * Format: base64(iv) + "." + base64(ciphertext+tag) + */ + +function getEncryptionKey(): string { + const key = process.env.AUTOMATION_WEBHOOK_SECRET_ENCRYPTION_KEY; + if (!key) { + throw new Error( + "AUTOMATION_WEBHOOK_SECRET_ENCRYPTION_KEY env var is not set" + ); + } + return key; +} + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) { + binary += String.fromCharCode(b); + } + return btoa(binary); +} + +async function importKey(keyB64: string): Promise { + const rawKey = base64ToBytes(keyB64); + return crypto.subtle.importKey( + "raw", + rawKey.buffer as ArrayBuffer, + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"] + ); +} + +export async function encryptWebhookSecret(plaintext: string): Promise { + const keyB64 = getEncryptionKey(); + const key = await importKey(keyB64); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(plaintext); + + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + encoded.buffer as ArrayBuffer + ); + + return bytesToBase64(iv) + "." + bytesToBase64(new Uint8Array(ciphertext)); +} + +export async function decryptWebhookSecret( + ciphertextStr: string +): Promise { + const keyB64 = getEncryptionKey(); + const key = await importKey(keyB64); + + const [ivB64, ctB64] = ciphertextStr.split("."); + if (!ivB64 || !ctB64) { + throw new Error("Invalid ciphertext format"); + } + + const iv = base64ToBytes(ivB64); + const ciphertext = base64ToBytes(ctB64); + + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv.buffer as ArrayBuffer }, + key, + ciphertext.buffer as ArrayBuffer + ); + + return new TextDecoder().decode(decrypted); +} diff --git a/packages/convex/convex/lib/idempotency.ts b/packages/convex/convex/lib/idempotency.ts index 0d85c47..5838011 100644 --- a/packages/convex/convex/lib/idempotency.ts +++ b/packages/convex/convex/lib/idempotency.ts @@ -1,58 +1,5 @@ import { v } from "convex/values"; -import { internalMutation, internalQuery } from "../_generated/server"; - -const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours - -export const checkIdempotencyKey = internalQuery({ - args: { - workspaceId: v.id("workspaces"), - key: v.string(), - }, - handler: async (ctx, args) => { - const existing = await ctx.db - .query("automationIdempotencyKeys") - .withIndex("by_workspace_key", (q) => - q.eq("workspaceId", args.workspaceId).eq("key", args.key) - ) - .first(); - - if (!existing) { - return null; - } - - if (existing.expiresAt < Date.now()) { - return null; // Expired - } - - return { - resourceType: existing.resourceType, - resourceId: existing.resourceId, - responseSnapshot: existing.responseSnapshot, - }; - }, -}); - -export const storeIdempotencyKey = internalMutation({ - args: { - workspaceId: v.id("workspaces"), - key: v.string(), - credentialId: v.id("automationCredentials"), - resourceType: v.string(), - resourceId: v.optional(v.string()), - responseSnapshot: v.optional(v.any()), - }, - handler: async (ctx, args) => { - await ctx.db.insert("automationIdempotencyKeys", { - workspaceId: args.workspaceId, - key: args.key, - credentialId: args.credentialId, - resourceType: args.resourceType, - resourceId: args.resourceId, - responseSnapshot: args.responseSnapshot, - expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, - }); - }, -}); +import { internalMutation } from "../_generated/server"; export const cleanupExpiredIdempotencyKeys = internalMutation({ args: { diff --git a/packages/convex/convex/messages.ts b/packages/convex/convex/messages.ts index 1cfcb6e..e2d5646 100644 --- a/packages/convex/convex/messages.ts +++ b/packages/convex/convex/messages.ts @@ -276,6 +276,19 @@ export const internalSendBotMessage = internalMutation({ const now = Date.now(); + if (args.senderId === "ai-agent") { + const activeClaim = await ctx.db + .query("automationConversationClaims") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "active") + ) + .first(); + + if (activeClaim && activeClaim.expiresAt > now) { + throw new Error("Conversation is currently claimed by external automation"); + } + } + const messageId = await ctx.db.insert("messages", { conversationId: args.conversationId, senderId: args.senderId ?? "system", diff --git a/packages/convex/convex/migrations/backfillAutomationWebhookSecrets.ts b/packages/convex/convex/migrations/backfillAutomationWebhookSecrets.ts new file mode 100644 index 0000000..45d5eab --- /dev/null +++ b/packages/convex/convex/migrations/backfillAutomationWebhookSecrets.ts @@ -0,0 +1,58 @@ +import { internalMutation } from "../_generated/server"; +import { v } from "convex/values"; +import { encryptWebhookSecret } from "../lib/automationWebhookSecrets"; + +export const migrate = internalMutation({ + args: { + batchSize: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + const subscriptions = await ctx.db + .query("automationWebhookSubscriptions") + .collect(); + + const legacySubscriptions = subscriptions.filter( + (subscription) => + !!subscription.signingSecret && !subscription.signingSecretCiphertext + ); + const batch = legacySubscriptions.slice(0, batchSize); + + for (const subscription of batch) { + const signingSecretCiphertext = await encryptWebhookSecret( + subscription.signingSecret! + ); + await ctx.db.patch(subscription._id, { + signingSecret: undefined, + signingSecretCiphertext, + } as any); + } + + const remaining = legacySubscriptions.length - batch.length; + return { + status: remaining > 0 ? "in_progress" : "complete", + processed: batch.length, + remaining, + }; + }, +}); + +export const verifyMigration = internalMutation({ + args: {}, + handler: async (ctx) => { + const subscriptions = await ctx.db + .query("automationWebhookSubscriptions") + .collect(); + + const legacyPlaintextCount = subscriptions.filter( + (subscription) => + !!subscription.signingSecret && !subscription.signingSecretCiphertext + ).length; + + return { + totalSubscriptions: subscriptions.length, + legacyPlaintextCount, + migrationComplete: legacyPlaintextCount === 0, + }; + }, +}); diff --git a/packages/convex/convex/schema/automationTables.ts b/packages/convex/convex/schema/automationTables.ts index 12dcb1a..3577f79 100644 --- a/packages/convex/convex/schema/automationTables.ts +++ b/packages/convex/convex/schema/automationTables.ts @@ -35,10 +35,8 @@ export const automationTables = { automationWebhookSubscriptions: defineTable({ workspaceId: v.id("workspaces"), url: v.string(), - // Stored in plaintext. Convex DB is server-side trusted infrastructure. - // The secret is returned to the admin once on creation, then only used - // server-side for HMAC signing. Same trust model as Stripe/GitHub webhooks. - signingSecret: v.string(), + signingSecret: v.optional(v.string()), // legacy plaintext retained only for backfill compatibility + signingSecretCiphertext: v.optional(v.string()), // AES-GCM encrypted at rest signingSecretPrefix: v.string(), eventTypes: v.optional(v.array(v.string())), resourceTypes: v.optional(v.array(v.string())), @@ -103,4 +101,10 @@ export const automationTables = { }) .index("by_workspace_key", ["workspaceId", "key"]) .index("by_expires", ["expiresAt"]), + + automationWorkspaceRateLimits: defineTable({ + workspaceId: v.id("workspaces"), + windowStart: v.number(), + count: v.number(), + }).index("by_workspace", ["workspaceId"]), }; diff --git a/packages/convex/convex/schema/inboxConversationTables.ts b/packages/convex/convex/schema/inboxConversationTables.ts index f404bb9..7fac4b3 100644 --- a/packages/convex/convex/schema/inboxConversationTables.ts +++ b/packages/convex/convex/schema/inboxConversationTables.ts @@ -92,6 +92,7 @@ export const inboxConversationTables = { aiLastResponseAt: v.optional(v.number()), }) .index("by_workspace", ["workspaceId"]) + .index("by_workspace_updated_at", ["workspaceId", "updatedAt"]) .index("by_visitor", ["visitorId"]) .index("by_status", ["workspaceId", "status"]) .index("by_last_message", ["workspaceId", "lastMessageAt"]) diff --git a/packages/convex/convex/schema/outboundSupportTables.ts b/packages/convex/convex/schema/outboundSupportTables.ts index 27d4a4d..05a4211 100644 --- a/packages/convex/convex/schema/outboundSupportTables.ts +++ b/packages/convex/convex/schema/outboundSupportTables.ts @@ -165,6 +165,7 @@ export const outboundSupportTables = { resolvedAt: v.optional(v.number()), }) .index("by_workspace", ["workspaceId"]) + .index("by_workspace_updated_at", ["workspaceId", "updatedAt"]) .index("by_visitor", ["visitorId"]) .index("by_status", ["workspaceId", "status"]) .index("by_assignee", ["workspaceId", "assigneeId"]) diff --git a/packages/convex/tests/aiAgentRuntimeSafety.test.ts b/packages/convex/tests/aiAgentRuntimeSafety.test.ts index e16aad3..9e4ffd4 100644 --- a/packages/convex/tests/aiAgentRuntimeSafety.test.ts +++ b/packages/convex/tests/aiAgentRuntimeSafety.test.ts @@ -588,6 +588,91 @@ describe("aiAgentActions runtime safety", () => { ); }); + it("suppresses a generated AI reply when an automation claim becomes active before persistence", async () => { + mockGenerateText.mockResolvedValue({ + text: "Here is the answer.", + usage: { totalTokens: 42 }, + } as any); + + let claimChecks = 0; + const runQuery = vi.fn(async (_reference: unknown, args: Record) => { + if (Object.keys(args).length === 1 && "conversationId" in args) { + claimChecks += 1; + return claimChecks === 1 + ? null + : { + claimId: "claim_1", + credentialId: "credential_1", + expiresAt: Date.now() + 60_000, + }; + } + if ("query" in args) { + return []; + } + if ("workspaceId" in args && "conversationId" in args === false) { + return { + enabled: true, + model: "openai/gpt-5-nano", + confidenceThreshold: 0.2, + knowledgeSources: ["articles"], + personality: null, + }; + } + return { + conversationId: "conversation_1", + workspaceId: "workspace_1", + visitorId: "visitor_1", + aiWorkflowState: "none", + hasHumanAgentResponse: false, + }; + }); + + const runMutation = vi.fn(async (_reference: unknown, args: Record) => { + if (Object.keys(args).length === 1 && "workspaceId" in args) { + return "cleared"; + } + throw new Error(`Unexpected mutation args: ${JSON.stringify(args)}`); + }); + + const result = await generateResponse._handler( + { + runQuery, + runMutation, + } as any, + { + workspaceId: "workspace_1" as any, + conversationId: "conversation_1" as any, + query: "What can you do?", + } + ); + + expect(result.handoff).toBe(true); + expect(result.handoffReason).toBe( + "Conversation is currently claimed by external automation" + ); + expect(result.messageId).toBeNull(); + expect(result.response).toBe(""); + expect(mockGenerateText).toHaveBeenCalledTimes(1); + expect(runMutation).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + senderId: "ai-agent", + }) + ); + expect(runMutation).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + reason: expect.any(String), + }) + ); + expect(runMutation).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + query: "What can you do?", + }) + ); + }); + it("persists a handoff message when generation fails", async () => { mockGenerateText.mockRejectedValue(new Error("gateway timeout")); diff --git a/packages/convex/tests/automationFixes.test.ts b/packages/convex/tests/automationFixes.test.ts index 1d7d503..a473e50 100644 --- a/packages/convex/tests/automationFixes.test.ts +++ b/packages/convex/tests/automationFixes.test.ts @@ -2,14 +2,24 @@ import { beforeEach, describe, expect, it, vi, afterEach } from "vitest"; import { convexTest } from "convex-test"; import { internal } from "../convex/_generated/api"; import schema from "../convex/schema"; +import { + decryptWebhookSecret, + encryptWebhookSecret, +} from "../convex/lib/automationWebhookSecrets"; const modules = import.meta.glob("../convex/**/*.ts"); +const TEST_WEBHOOK_ENCRYPTION_KEY = + "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; describe("automation fixes", () => { let t: ReturnType; + let previousEncryptionKey: string | undefined; beforeEach(() => { vi.useFakeTimers(); + previousEncryptionKey = process.env.AUTOMATION_WEBHOOK_SECRET_ENCRYPTION_KEY; + process.env.AUTOMATION_WEBHOOK_SECRET_ENCRYPTION_KEY = + TEST_WEBHOOK_ENCRYPTION_KEY; t = convexTest(schema, modules); // Stub fetch for webhook delivery actions vi.stubGlobal( @@ -26,8 +36,29 @@ describe("automation fixes", () => { afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); + if (previousEncryptionKey === undefined) { + delete process.env.AUTOMATION_WEBHOOK_SECRET_ENCRYPTION_KEY; + } else { + process.env.AUTOMATION_WEBHOOK_SECRET_ENCRYPTION_KEY = + previousEncryptionKey; + } }); + async function signHmac(secret: string, payload: string) { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); + return Array.from(new Uint8Array(signature)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } + // Helper to seed a workspace with automation enabled async function seedWorkspace() { return t.run(async (ctx) => { @@ -614,4 +645,1160 @@ describe("automation fixes", () => { expect(conv?.aiWorkflowState).toBe("handoff"); }); }); + + // ── Issue 7: Delivery ID header ────────────────────────────────────── + describe("Issue 7 — delivery ID header", () => { + it("fetch receives both X-Opencom-Event-Id and X-Opencom-Delivery-Id headers", async () => { + const ws = await seedWorkspace(); + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const { deliveryId } = await t.run(async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv1", + data: {}, + timestamp: now, + }); + const subscriptionId = await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + status: "active", + createdBy: ws.userId, + createdAt: now, + }); + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: ws.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "pending", + createdAt: now, + }); + return { deliveryId }; + }); + + await t.action(internal.automationWebhookWorker.deliverWebhook, { + deliveryId, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = options.headers as Record; + expect(headers["X-Opencom-Event-Id"]).toBeDefined(); + expect(headers["X-Opencom-Delivery-Id"]).toBeDefined(); + }); + }); + + // ── Issue 2: Orphaned deliveries + replay validation ────────────────── + describe("Issue 2 — orphaned deliveries and replay validation", () => { + it("marks delivery as failed when subscription is deleted", async () => { + const ws = await seedWorkspace(); + + const { deliveryId } = await t.run(async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv1", + data: {}, + timestamp: now, + }); + // Create then delete subscription to orphan the delivery + const subscriptionId = await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + status: "active", + createdBy: ws.userId, + createdAt: now, + }); + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: ws.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "pending", + createdAt: now, + }); + await ctx.db.delete(subscriptionId); + return { deliveryId }; + }); + + await t.action(internal.automationWebhookWorker.deliverWebhook, { + deliveryId, + }); + + const delivery = await t.run(async (ctx) => ctx.db.get(deliveryId)); + expect(delivery?.status).toBe("failed"); + expect(delivery?.error).toContain("no longer exists"); + }); + + it("replay throws when subscription is deleted", async () => { + const ws = await seedWorkspace(); + + const { deliveryId } = await t.run(async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv1", + data: {}, + timestamp: now, + }); + const subscriptionId = await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + status: "active", + createdBy: ws.userId, + createdAt: now, + }); + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: ws.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "failed", + createdAt: now, + }); + await ctx.db.delete(subscriptionId); + return { deliveryId }; + }); + + await expect( + t.mutation(internal.automationWebhookWorker.replayDelivery, { + deliveryId, + workspaceId: ws.workspaceId, + }) + ).rejects.toThrow("Webhook subscription no longer exists"); + }); + + it("replay throws when event is deleted", async () => { + const ws = await seedWorkspace(); + + const { deliveryId } = await t.run(async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv1", + data: {}, + timestamp: now, + }); + const subscriptionId = await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_testsecret", + signingSecretPrefix: "whsec_testsecr", + status: "active", + createdBy: ws.userId, + createdAt: now, + }); + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: ws.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "failed", + createdAt: now, + }); + await ctx.db.delete(eventId); + return { deliveryId }; + }); + + await expect( + t.mutation(internal.automationWebhookWorker.replayDelivery, { + deliveryId, + workspaceId: ws.workspaceId, + }) + ).rejects.toThrow("Event no longer exists"); + }); + }); + + // ── Issue 3: Webhook secret spec alignment ───────────────────────────── + describe("Issue 3 — webhook secret visibility", () => { + it("create subscription returns signingSecret, list does not", async () => { + const ws = await seedWorkspace(); + + // We can't call authMutation directly, but we can verify the schema behavior + // by creating a subscription directly and checking list output + const { subscriptionId, signingSecret } = await t.run(async (ctx) => { + const secret = "whsec_testSecretForVerification1234567890"; + const id = await ctx.db.insert("automationWebhookSubscriptions", { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: secret, + signingSecretPrefix: secret.slice(0, 14), + status: "active", + createdBy: ws.userId, + createdAt: Date.now(), + }); + return { subscriptionId: id, signingSecret: secret }; + }); + + // Verify the subscription has signingSecret stored + const sub = await t.run(async (ctx) => ctx.db.get(subscriptionId)); + expect(sub?.signingSecret).toBe(signingSecret); + expect(sub?.signingSecretPrefix).toBe(signingSecret.slice(0, 14)); + }); + }); + + // ── Issue 5: API surface gaps ────────────────────────────────────────── + describe("Issue 5 — API surface gaps", () => { + it("list conversations includes aiHandoffReason", async () => { + const ws = await seedWorkspace(); + + await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v1", + createdAt: now, + }); + await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + aiWorkflowState: "handoff", + aiHandoffReason: "Customer asked for human", + createdAt: now, + updatedAt: now, + }); + }); + + const result = (await t.query( + internal.automationApiInternals.listConversationsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + } + )) as { data: Array<{ aiHandoffReason?: string }> }; + + expect(result.data).toHaveLength(1); + expect(result.data[0].aiHandoffReason).toBe("Customer asked for human"); + }); + + it("list conversations filters by channel", async () => { + const ws = await seedWorkspace(); + + await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v1", + createdAt: now, + }); + await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + channel: "chat", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + channel: "email", + createdAt: now + 1, + updatedAt: now + 1, + }); + }); + + const chatResult = (await t.query( + internal.automationApiInternals.listConversationsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + channel: "chat", + } + )) as { data: Array<{ channel?: string }> }; + + expect(chatResult.data).toHaveLength(1); + expect(chatResult.data[0].channel).toBe("chat"); + }); + + it("list conversations filters by visitor custom attribute", async () => { + const ws = await seedWorkspace(); + + await t.run(async (ctx) => { + const now = Date.now(); + const enterpriseVisitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "enterprise", + customAttributes: { plan: "enterprise" }, + createdAt: now, + firstSeenAt: now, + lastSeenAt: now, + }); + const freeVisitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "free", + customAttributes: { plan: "free" }, + createdAt: now + 1, + firstSeenAt: now + 1, + lastSeenAt: now + 1, + }); + + await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId: enterpriseVisitorId, + status: "open", + createdAt: now, + updatedAt: now + 100, + }); + await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId: freeVisitorId, + status: "open", + createdAt: now + 1, + updatedAt: now + 101, + }); + }); + + const result = (await t.query( + internal.automationApiInternals.listConversationsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + customAttributeKey: "plan", + customAttributeValue: "enterprise", + } + )) as { data: Array<{ visitorId?: string }> }; + + expect(result.data).toHaveLength(1); + expect(result.data[0].visitorId).toBeDefined(); + }); + + it("list visitors filters by custom attribute", async () => { + const ws = await seedWorkspace(); + + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v1", + customAttributes: { plan: "enterprise", region: "us" }, + createdAt: now, + firstSeenAt: now, + lastSeenAt: now, + }); + await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v2", + customAttributes: { plan: "free", region: "eu" }, + createdAt: now + 1, + firstSeenAt: now + 1, + lastSeenAt: now + 1, + }); + await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v3", + createdAt: now + 2, + firstSeenAt: now + 2, + lastSeenAt: now + 2, + }); + }); + + const result = (await t.query( + internal.automationApiInternals.listVisitorsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + customAttributeKey: "plan", + customAttributeValue: "enterprise", + } + )) as { data: Array<{ customAttributes?: Record }> }; + + expect(result.data).toHaveLength(1); + expect(result.data[0].customAttributes?.plan).toBe("enterprise"); + }); + }); + + // ── Issue 6: Compound opaque cursors ─────────────────────────────────── + describe("Issue 6 — compound opaque cursors", () => { + it("paginates events with same timestamp correctly", async () => { + const ws = await seedWorkspace(); + const now = Date.now(); + + // Create 3 events at the same timestamp + await t.run(async (ctx) => { + for (let i = 0; i < 3; i++) { + await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: `conv${i}`, + data: {}, + timestamp: now, + }); + } + }); + + // First page: limit=1 + const page1 = (await t.query(internal.automationEvents.listEvents, { + workspaceId: ws.workspaceId, + limit: 1, + })) as { data: Array<{ id: string }>; nextCursor: string | null; hasMore: boolean }; + + expect(page1.data).toHaveLength(1); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBeDefined(); + + // Second page + const page2 = (await t.query(internal.automationEvents.listEvents, { + workspaceId: ws.workspaceId, + limit: 1, + cursor: page1.nextCursor!, + })) as { data: Array<{ id: string }>; nextCursor: string | null; hasMore: boolean }; + + expect(page2.data).toHaveLength(1); + expect(page2.data[0].id).not.toBe(page1.data[0].id); + + // Third page + const page3 = (await t.query(internal.automationEvents.listEvents, { + workspaceId: ws.workspaceId, + limit: 1, + cursor: page2.nextCursor!, + })) as { data: Array<{ id: string }>; nextCursor: string | null; hasMore: boolean }; + + expect(page3.data).toHaveLength(1); + expect(page3.data[0].id).not.toBe(page1.data[0].id); + expect(page3.data[0].id).not.toBe(page2.data[0].id); + }); + + it("backward compat: bare number cursor still works", async () => { + const ws = await seedWorkspace(); + const now = Date.now(); + + await t.run(async (ctx) => { + await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv1", + data: {}, + timestamp: now, + }); + await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv2", + data: {}, + timestamp: now - 1000, + }); + }); + + // Use a bare number cursor (old format) + const result = (await t.query(internal.automationEvents.listEvents, { + workspaceId: ws.workspaceId, + limit: 10, + cursor: String(now), + })) as { data: Array<{ id: string }> }; + + // Should return events at or before the timestamp + expect(result.data.length).toBeGreaterThanOrEqual(1); + }); + }); + + // ── R4-1: Sync cursor correctness ───────────────────────────────────── + describe("R4-1 — sync cursor uses updatedAt/lastSeenAt", () => { + it("conversation filters page by updatedAt and honor updatedSince", async () => { + const ws = await seedWorkspace(); + + const ids = await t.run(async (ctx) => { + const visitorA = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v1", + createdAt: 1000, + firstSeenAt: 1000, + lastSeenAt: 1000, + }); + const visitorB = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v2", + createdAt: 2000, + firstSeenAt: 2000, + lastSeenAt: 2000, + }); + + const olderConversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId: visitorA, + status: "open", + channel: "chat", + createdAt: 1000, + updatedAt: 1000, + }); + const newerConversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId: visitorB, + status: "open", + channel: "chat", + createdAt: 2000, + updatedAt: 2000, + }); + + return { olderConversationId, newerConversationId }; + }); + + await t.run(async (ctx) => { + await ctx.db.patch(ids.olderConversationId, { updatedAt: 4000 }); + }); + + const page1 = (await t.query( + internal.automationApiInternals.listConversationsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 1, + status: "open", + channel: "chat", + updatedSince: 2500, + } + )) as { + data: Array<{ id: string; updatedAt: number }>; + nextCursor: string | null; + hasMore: boolean; + }; + + expect(page1.data).toHaveLength(1); + expect(page1.data[0].id).toBe(ids.olderConversationId); + expect(page1.data[0].updatedAt).toBe(4000); + expect(page1.hasMore).toBe(false); + }); + + it("conversation status pages stay in updatedAt order across cursors", async () => { + const ws = await seedWorkspace(); + + const ids = await t.run(async (ctx) => { + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v-sync", + createdAt: 1000, + firstSeenAt: 1000, + lastSeenAt: 1000, + }); + + const oldestConversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + createdAt: 1000, + updatedAt: 1000, + }); + const middleConversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + createdAt: 2000, + updatedAt: 2000, + }); + const newestConversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + createdAt: 3000, + updatedAt: 3000, + }); + + return { + oldestConversationId, + middleConversationId, + newestConversationId, + }; + }); + + const page1 = (await t.query( + internal.automationApiInternals.listConversationsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 1, + status: "open", + } + )) as { + data: Array<{ id: string }>; + nextCursor: string | null; + hasMore: boolean; + }; + expect(page1.data[0].id).toBe(ids.newestConversationId); + expect(page1.hasMore).toBe(true); + + const page2 = (await t.query( + internal.automationApiInternals.listConversationsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 1, + status: "open", + cursor: page1.nextCursor!, + } + )) as { + data: Array<{ id: string }>; + nextCursor: string | null; + hasMore: boolean; + }; + expect(page2.data[0].id).toBe(ids.middleConversationId); + expect(page2.hasMore).toBe(true); + + const page3 = (await t.query( + internal.automationApiInternals.listConversationsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 1, + status: "open", + cursor: page2.nextCursor!, + } + )) as { + data: Array<{ id: string }>; + nextCursor: string | null; + hasMore: boolean; + }; + expect(page3.data[0].id).toBe(ids.oldestConversationId); + expect(page3.hasMore).toBe(false); + }); + + it("conversation pagination survives more than 2000 rows sharing the same updatedAt", async () => { + const ws = await seedWorkspace(); + const totalConversations = 2050; + + await t.run(async (ctx) => { + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v-bulk-sync", + createdAt: 1000, + firstSeenAt: 1000, + lastSeenAt: 1000, + }); + + for (let i = 0; i < totalConversations; i++) { + await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + channel: "chat", + createdAt: 1000 + i, + updatedAt: 5000, + }); + } + }); + + const seenIds = new Set(); + let cursor: string | undefined; + + while (true) { + const page = (await t.query( + internal.automationApiInternals.listConversationsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 100, + status: "open", + cursor, + } + )) as { + data: Array<{ id: string }>; + nextCursor: string | null; + hasMore: boolean; + }; + + for (const conversation of page.data) { + expect(seenIds.has(conversation.id)).toBe(false); + seenIds.add(conversation.id); + } + + if (!page.hasMore) { + break; + } + + cursor = page.nextCursor!; + } + + expect(seenIds.size).toBe(totalConversations); + }); + + it("visitor identity filters page by lastSeenAt and honor updatedSince", async () => { + const ws = await seedWorkspace(); + + const ids = await t.run(async (ctx) => { + const olderVisitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v1", + email: "person@example.com", + createdAt: 1000, + firstSeenAt: 1000, + lastSeenAt: 1000, + }); + const newerVisitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v2", + email: "person@example.com", + createdAt: 2000, + firstSeenAt: 2000, + lastSeenAt: 2000, + }); + return { olderVisitorId, newerVisitorId }; + }); + + await t.run(async (ctx) => { + await ctx.db.patch(ids.olderVisitorId, { lastSeenAt: 5000 }); + }); + + const page1 = (await t.query( + internal.automationApiInternals.listVisitorsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 1, + email: "person@example.com", + updatedSince: 2500, + } + )) as { + data: Array<{ id: string; lastSeenAt?: number }>; + nextCursor: string | null; + hasMore: boolean; + }; + + expect(page1.data).toHaveLength(1); + expect(page1.data[0].id).toBe(ids.olderVisitorId); + expect(page1.data[0].lastSeenAt).toBe(5000); + expect(page1.hasMore).toBe(false); + }); + + it("ticket status filters page by updatedAt and honor updatedSince", async () => { + const ws = await seedWorkspace(); + + const ids = await t.run(async (ctx) => { + const olderTicketId = await ctx.db.insert("tickets", { + workspaceId: ws.workspaceId, + subject: "Older ticket", + status: "submitted", + priority: "normal", + createdAt: 1000, + updatedAt: 1000, + }); + const newerTicketId = await ctx.db.insert("tickets", { + workspaceId: ws.workspaceId, + subject: "Newer ticket", + status: "submitted", + priority: "normal", + createdAt: 2000, + updatedAt: 2000, + }); + return { olderTicketId, newerTicketId }; + }); + + await t.run(async (ctx) => { + await ctx.db.patch(ids.olderTicketId, { updatedAt: 4500 }); + }); + + const page1 = (await t.query( + internal.automationApiInternals.listTicketsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 1, + status: "submitted", + updatedSince: 2500, + } + )) as { + data: Array<{ id: string; updatedAt: number }>; + nextCursor: string | null; + hasMore: boolean; + }; + + expect(page1.data).toHaveLength(1); + expect(page1.data[0].id).toBe(ids.olderTicketId); + expect(page1.data[0].updatedAt).toBe(4500); + expect(page1.hasMore).toBe(false); + }); + }); + + // ── R4-2: Webhook secret encryption ───────────────────────────────────── + describe("R4-2 — webhook secret encryption", () => { + it("encrypts and decrypts webhook secrets for delivery signing", async () => { + const ws = await seedWorkspace(); + const rawSecret = "whsec_encrypted_delivery_secret"; + const ciphertext = await encryptWebhookSecret(rawSecret); + expect(ciphertext).not.toContain(rawSecret); + await expect(decryptWebhookSecret(ciphertext)).resolves.toBe(rawSecret); + + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const { deliveryId } = await t.run(async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv1", + data: { source: "encrypted-test" }, + timestamp: now, + }); + const subscriptionId = await ctx.db.insert( + "automationWebhookSubscriptions", + { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecretCiphertext: ciphertext, + signingSecretPrefix: rawSecret.slice(0, 14), + status: "active", + createdBy: ws.userId, + createdAt: now, + } + ); + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: ws.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "pending", + createdAt: now, + }); + return { deliveryId }; + }); + + await t.action(internal.automationWebhookWorker.deliverWebhook, { + deliveryId, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, requestOptions] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = requestOptions.headers as Record; + const body = String(requestOptions.body); + const timestamp = headers["X-Opencom-Timestamp"]; + expect(timestamp).toBeDefined(); + const expectedSignature = await signHmac(rawSecret, `${timestamp}.${body}`); + expect(headers["X-Opencom-Signature"]).toBe( + `t=${timestamp},v1=${expectedSignature}` + ); + }); + + it("delivery succeeds with legacy plaintext signingSecret", async () => { + const ws = await seedWorkspace(); + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const { deliveryId } = await t.run(async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv1", + data: {}, + timestamp: now, + }); + const subscriptionId = await ctx.db.insert( + "automationWebhookSubscriptions", + { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecret: "whsec_legacyplaintext", + signingSecretPrefix: "whsec_legacypl", + status: "active", + createdBy: ws.userId, + createdAt: now, + } + ); + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: ws.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "pending", + createdAt: now, + }); + return { deliveryId }; + }); + + await t.action(internal.automationWebhookWorker.deliverWebhook, { + deliveryId, + }); + + // Should have called fetch (delivery succeeded with plaintext secret) + expect(fetchMock).toHaveBeenCalledTimes(1); + const delivery = await t.run(async (ctx) => ctx.db.get(deliveryId)); + expect(delivery?.status).toBe("success"); + }); + + it("marks delivery failed when encrypted secret preparation fails", async () => { + const ws = await seedWorkspace(); + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const { deliveryId } = await t.run(async (ctx) => { + const now = Date.now(); + const eventId = await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: "conv-invalid-secret", + data: {}, + timestamp: now, + }); + const subscriptionId = await ctx.db.insert( + "automationWebhookSubscriptions", + { + workspaceId: ws.workspaceId, + url: "https://example.com/webhook", + signingSecretCiphertext: "invalid-ciphertext", + signingSecretPrefix: "whsec_invalid", + status: "active", + createdBy: ws.userId, + createdAt: now, + } + ); + const deliveryId = await ctx.db.insert("automationWebhookDeliveries", { + workspaceId: ws.workspaceId, + subscriptionId, + eventId, + attemptNumber: 1, + status: "pending", + createdAt: now, + }); + return { deliveryId }; + }); + + await t.action(internal.automationWebhookWorker.deliverWebhook, { + deliveryId, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + const delivery = await t.run(async (ctx) => ctx.db.get(deliveryId)); + expect(delivery?.status).toBe("failed"); + expect(delivery?.error).toContain("Failed to prepare webhook delivery"); + expect(delivery?.error).toContain("Invalid ciphertext format"); + }); + }); + + // ── R4-4: Built-in AI respects active automation claims ──────────────── + describe("R4-4 — AI response suppression", () => { + it("blocks ai-agent bot persistence while an automation claim is active", async () => { + const ws = await seedWorkspace(); + + const { conversationId } = await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "claimed-conversation-visitor", + createdAt: now, + }); + const conversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + createdAt: now, + updatedAt: now, + }); + + await ctx.db.insert("automationConversationClaims", { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + status: "active", + expiresAt: now + 5 * 60 * 1000, + createdAt: now, + }); + + return { conversationId }; + }); + + await expect( + t.mutation(internal.messages.internalSendBotMessage, { + conversationId, + senderId: "ai-agent", + content: "Automated AI reply", + }) + ).rejects.toThrow("Conversation is currently claimed by external automation"); + }); + }); + + // ── R4-3: Event feed tie-break buffer ──────────────────────────────────── + describe("R4-3 — event feed same-timestamp tie-break", () => { + it("paginates 10 same-timestamp events at limit=2", async () => { + const ws = await seedWorkspace(); + const now = Date.now(); + + // Create 10 events at the same timestamp + await t.run(async (ctx) => { + for (let i = 0; i < 10; i++) { + await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: `conv${i}`, + data: {}, + timestamp: now, + }); + } + }); + + const seenIds = new Set(); + let cursor: string | undefined; + + // Paginate through all events with limit=2 + for (let page = 0; page < 10; page++) { + const result = (await t.query(internal.automationEvents.listEvents, { + workspaceId: ws.workspaceId, + limit: 2, + cursor, + })) as { data: Array<{ id: string }>; nextCursor: string | null; hasMore: boolean }; + + for (const e of result.data) { + expect(seenIds.has(e.id)).toBe(false); // no duplicates + seenIds.add(e.id); + } + + if (!result.hasMore) break; + cursor = result.nextCursor!; + } + + expect(seenIds.size).toBe(10); + }); + + it("paginates more than 2000 same-timestamp events without truncation", async () => { + const ws = await seedWorkspace(); + const now = Date.now(); + const totalEvents = 2050; + + await t.run(async (ctx) => { + for (let i = 0; i < totalEvents; i++) { + await ctx.db.insert("automationEvents", { + workspaceId: ws.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: `bulk-conv-${i}`, + data: {}, + timestamp: now, + }); + } + }); + + const seenIds = new Set(); + let cursor: string | undefined; + + while (true) { + const result = (await t.query(internal.automationEvents.listEvents, { + workspaceId: ws.workspaceId, + limit: 100, + cursor, + })) as { + data: Array<{ id: string }>; + nextCursor: string | null; + hasMore: boolean; + }; + + for (const event of result.data) { + expect(seenIds.has(event.id)).toBe(false); + seenIds.add(event.id); + } + + if (!result.hasMore) { + break; + } + + cursor = result.nextCursor!; + } + + expect(seenIds.size).toBe(totalEvents); + }); + }); + + // ── Issue 4: Workspace-level rate limiting ───────────────────────────── + describe("Issue 4 — workspace-level rate limiting", () => { + it("enforces workspace limit at 120 across multiple credentials", async () => { + const ws = await seedWorkspace(); + + // Create a second credential + const credentialId2 = await t.run(async (ctx) => { + return ctx.db.insert("automationCredentials", { + workspaceId: ws.workspaceId, + name: "Test Key 2", + secretHash: "testhash456", + secretPrefix: "osk_tes2", + scopes: ["conversations.read"], + status: "active", + actorName: "test-bot-2", + createdBy: ws.userId, + createdAt: Date.now(), + }); + }); + + // Alternate between credentials: 60 each = 120 total (should all pass) + for (let i = 0; i < 60; i++) { + await t.mutation(internal["lib/automationAuth"].checkRateLimit, { + credentialId: ws.credentialId, + workspaceId: ws.workspaceId, + }); + await t.mutation(internal["lib/automationAuth"].checkRateLimit, { + credentialId: credentialId2, + workspaceId: ws.workspaceId, + }); + } + + // 121st request should be blocked by workspace limit + const blocked = await t.mutation( + internal["lib/automationAuth"].checkRateLimit, + { + credentialId: ws.credentialId, + workspaceId: ws.workspaceId, + } + ); + expect(blocked.allowed).toBe(false); + }); + + it("single credential still hits its own limit at 60", async () => { + const ws = await seedWorkspace(); + + for (let i = 0; i < 60; i++) { + const result = await t.mutation( + internal["lib/automationAuth"].checkRateLimit, + { + credentialId: ws.credentialId, + workspaceId: ws.workspaceId, + } + ); + expect(result.allowed).toBe(true); + } + + const blocked = await t.mutation( + internal["lib/automationAuth"].checkRateLimit, + { + credentialId: ws.credentialId, + workspaceId: ws.workspaceId, + } + ); + expect(blocked.allowed).toBe(false); + }); + }); }); From e3aca851b689f9c4fc66917240dc6a86aac0d684 Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:04:32 +0200 Subject: [PATCH 04/13] add automation API CRUD for articles & collections (2.1b) --- .../proposal.md | 2 +- .../specs/automation-resource-api/spec.md | 17 +- .../tasks.md | 3 +- packages/convex/convex/articles.ts | 128 ++-- packages/convex/convex/auditLogs.ts | 8 +- .../convex/convex/automationApiInternals.ts | 410 +++++++++++ .../convex/convex/automationHttpRoutes.ts | 258 +++++++ packages/convex/convex/automationScopes.ts | 8 + packages/convex/convex/collections.ts | 117 +--- packages/convex/convex/http.ts | 20 + .../convex/convex/lib/articleWriteHelpers.ts | 252 +++++++ .../convex/lib/collectionWriteHelpers.ts | 157 +++++ .../convex/convex/schema/helpCenterTables.ts | 6 +- packages/convex/tests/automationFixes.test.ts | 655 ++++++++++++++++++ 14 files changed, 1852 insertions(+), 189 deletions(-) create mode 100644 packages/convex/convex/lib/articleWriteHelpers.ts create mode 100644 packages/convex/convex/lib/collectionWriteHelpers.ts diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/proposal.md b/openspec/changes/expose-automation-api-and-event-webhooks/proposal.md index ce46676..6ababb4 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/proposal.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/proposal.md @@ -4,7 +4,7 @@ Teams moving support operations into Opencom want to plug in their own AI system ## What Changes -- Add a versioned automation HTTP API for CRUD access to automation-critical workspace resources including conversations, messages, visitors, tickets, ticket comments, articles, collections, outbound messages, custom attributes, and custom events. +- Add a versioned automation HTTP API for CRUD access to automation-critical workspace resources including conversations, messages, visitors, tickets, ticket comments, articles, collections, outbound messages, and custom attributes, with knowledge resources (articles and collections) landing before the remaining follow-on inbox surfaces. - Add incremental sync, filter, and idempotency primitives so external scripts and agents can import knowledge, backfill metadata, and safely retry writes without creating duplicates. - Add outbound webhook subscriptions for automation trigger events, plus a polling-friendly event feed for cron-based integrations that do not want webhooks. - Add workspace-scoped automation credentials, scopes, rate limits, audit attribution, and delivery observability. diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/specs/automation-resource-api/spec.md b/openspec/changes/expose-automation-api-and-event-webhooks/specs/automation-resource-api/spec.md index 6ca0ff8..f565ea8 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/specs/automation-resource-api/spec.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/specs/automation-resource-api/spec.md @@ -1,18 +1,21 @@ ## ADDED Requirements -### Requirement: Automation API MUST expose CRUD access for automation-critical workspace resources -Opencom SHALL expose a versioned HTTP API that lets authorized automation clients create, read, update, delete, and list automation-critical workspace resources including conversations, messages, visitors, tickets, ticket comments, articles, collections, outbound messages, custom attributes, and custom events according to credential scope. - -#### Scenario: Automation syncs knowledge content into Opencom -- **WHEN** an authorized automation client creates or updates collections and articles through the automation API -- **THEN** Opencom SHALL persist those resources with stable IDs and machine-readable state -- **AND** the same resources SHALL be retrievable through the supported external API without relying on internal Convex function names +### Requirement: Automation API MUST expose CRUD access for automation-critical inbox resources +Opencom SHALL expose a versioned HTTP API that lets authorized automation clients create, read, update, delete, and list automation-critical inbox resources including conversations, messages, visitors, tickets, ticket comments, outbound messages, and custom attributes according to credential scope. #### Scenario: Automation reads inbox state and creates follow-on actions - **WHEN** an authorized automation client reads a conversation and creates a reply, ticket mutation, or outbound message through scoped endpoints - **THEN** Opencom SHALL authorize the action according to workspace-scoped permissions - **AND** the resulting resources SHALL remain visible in existing Opencom inbox and outbound management surfaces +### Requirement: Automation API MUST expose CRUD access for knowledge resources +Opencom SHALL expose a versioned HTTP API that lets authorized automation clients create, read, update, delete, and list knowledge resources including articles and collections according to credential scope. + +#### Scenario: Automation syncs knowledge content into Opencom +- **WHEN** an authorized automation client creates or updates collections and articles through the automation API +- **THEN** Opencom SHALL persist those resources with stable IDs and machine-readable state +- **AND** the same resources SHALL be retrievable through the supported external API without relying on internal Convex function names + ### Requirement: Automation API MUST support incremental sync and server-side filtering List and search endpoints SHALL support cursor-based pagination and server-side filters including updated time, status, channel, assignee, external identifiers, email, and relevant custom-attribute predicates so automation clients can mirror state without full rescans. diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md index 88d1354..0a0ff48 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md @@ -7,7 +7,8 @@ ## 2. Resource API Surface - [x] 2.1 Implement versioned HTTP endpoints for v1 core resources: conversations, messages, visitors, and tickets. -- [ ] 2.1b Extend API to remaining resources: ticket comments, articles, collections, outbound messages, and custom events. +- [x] 2.1b Extend API to knowledge resources: articles and collections. +- [ ] 2.1c Extend API to follow-on inbox resources: ticket comments and outbound messages. - [x] 2.2 Add cursor pagination, updated-since sync, and server-side filters for v1 resources. - [ ] 2.2b Add external reference support and custom-attribute-aware lookups. - [x] 2.3 Implement idempotent mutation handling for message send path via Idempotency-Key header. diff --git a/packages/convex/convex/articles.ts b/packages/convex/convex/articles.ts index c1ca851..d45c057 100644 --- a/packages/convex/convex/articles.ts +++ b/packages/convex/convex/articles.ts @@ -18,6 +18,13 @@ import { requirePermission } from "./permissions"; import { resolveVisitorFromSession } from "./widgetSessions"; import { generateSlug, ensureUniqueSlug } from "./utils/strings"; import { throwNotAuthenticated, createError } from "./utils/errors"; +import { + createArticleCore, + deleteArticleCore, + publishArticleCore, + unpublishArticleCore, + archiveArticleCore, +} from "./lib/articleWriteHelpers"; import { articleOrLegacyInternalArticleIdValidator, articleStatusValidator, @@ -337,35 +344,15 @@ export const create = mutation({ } await requirePermission(ctx, user._id, args.workspaceId, "articles.create"); - const now = Date.now(); - const baseSlug = generateSlug(args.title); - const slug = await ensureUniqueSlug(ctx.db, "articles", args.workspaceId, baseSlug); - - // Get max order for the collection - const articles = await ctx.db - .query("articles") - .withIndex("by_collection", (q) => q.eq("collectionId", args.collectionId)) - .collect(); - const maxOrder = articles.reduce((max, a) => Math.max(max, a.order), 0); - - const articleId = await ctx.db.insert("articles", { + return await createArticleCore(ctx, { workspaceId: args.workspaceId, - collectionId: args.collectionId, - folderId: undefined, title: args.title, - slug, content: args.content, - widgetLargeScreen: false, - visibility: args.visibility ?? "public", - status: "draft", - order: maxOrder + 1, - createdAt: now, - updatedAt: now, + collectionId: args.collectionId, + visibility: args.visibility, tags: args.tags, authorId: args.authorId, }); - - return articleId; }, }); @@ -539,12 +526,7 @@ export const remove = mutation({ return { success: true }; } - await ctx.db.delete(resolved.article._id); - const runAfter = getShallowRunAfter(ctx); - await runAfter(0, removeEmbeddingRef, { - contentType: getArticleContentType(resolved.article), - contentId: resolved.article._id, - }); + await deleteArticleCore(ctx, resolved.article); return { success: true }; }, }); @@ -709,23 +691,23 @@ export const publish = authMutation({ throw createError("NOT_FOUND", "Article not found"); } - await ctx.db.patch(resolved.article._id, { - status: "published", - publishedAt: Date.now(), - updatedAt: Date.now(), - }); - - const runAfter = getShallowRunAfter(ctx); - await runAfter(0, generateInternalEmbeddingRef, { - workspaceId: resolved.article.workspaceId, - contentType: - resolved.kind === "legacyInternal" - ? "internalArticle" - : getArticleContentType(resolved.article), - contentId: resolved.article._id, - title: resolved.article.title, - content: resolved.article.content, - }); + if (resolved.kind === "legacyInternal") { + await ctx.db.patch(resolved.article._id, { + status: "published", + publishedAt: Date.now(), + updatedAt: Date.now(), + }); + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, generateInternalEmbeddingRef, { + workspaceId: resolved.article.workspaceId, + contentType: "internalArticle", + contentId: resolved.article._id, + title: resolved.article.title, + content: resolved.article.content, + }); + } else { + await publishArticleCore(ctx, resolved.article); + } return resolved.article._id; }, @@ -746,20 +728,20 @@ export const unpublish = authMutation({ throw createError("NOT_FOUND", "Article not found"); } - await ctx.db.patch(resolved.article._id, { - status: "draft", - publishedAt: undefined, - updatedAt: Date.now(), - }); - - const runAfter = getShallowRunAfter(ctx); - await runAfter(0, removeEmbeddingRef, { - contentType: - resolved.kind === "legacyInternal" - ? "internalArticle" - : getArticleContentType(resolved.article), - contentId: resolved.article._id, - }); + if (resolved.kind === "legacyInternal") { + await ctx.db.patch(resolved.article._id, { + status: "draft", + publishedAt: undefined, + updatedAt: Date.now(), + }); + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, removeEmbeddingRef, { + contentType: "internalArticle", + contentId: resolved.article._id, + }); + } else { + await unpublishArticleCore(ctx, resolved.article); + } return resolved.article._id; }, @@ -780,19 +762,19 @@ export const archive = authMutation({ throw createError("NOT_FOUND", "Article not found"); } - await ctx.db.patch(resolved.article._id, { - status: "archived", - updatedAt: Date.now(), - }); - - const runAfter = getShallowRunAfter(ctx); - await runAfter(0, removeEmbeddingRef, { - contentType: - resolved.kind === "legacyInternal" - ? "internalArticle" - : getArticleContentType(resolved.article), - contentId: resolved.article._id, - }); + if (resolved.kind === "legacyInternal") { + await ctx.db.patch(resolved.article._id, { + status: "archived", + updatedAt: Date.now(), + }); + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, removeEmbeddingRef, { + contentType: "internalArticle", + contentId: resolved.article._id, + }); + } else { + await archiveArticleCore(ctx, resolved.article); + } return resolved.article._id; }, diff --git a/packages/convex/convex/auditLogs.ts b/packages/convex/convex/auditLogs.ts index 2c0e6cd..bf0ebbe 100644 --- a/packages/convex/convex/auditLogs.ts +++ b/packages/convex/convex/auditLogs.ts @@ -43,7 +43,13 @@ export type AuditAction = | "automation.ticket.updated" | "automation.conversation.claimed" | "automation.conversation.released" - | "automation.conversation.escalated"; + | "automation.conversation.escalated" + | "automation.article.created" + | "automation.article.updated" + | "automation.article.deleted" + | "automation.collection.created" + | "automation.collection.updated" + | "automation.collection.deleted"; export type ActorType = "user" | "system" | "api"; diff --git a/packages/convex/convex/automationApiInternals.ts b/packages/convex/convex/automationApiInternals.ts index fc424e5..6fcd805 100644 --- a/packages/convex/convex/automationApiInternals.ts +++ b/packages/convex/convex/automationApiInternals.ts @@ -3,6 +3,20 @@ import type { Doc, Id } from "./_generated/dataModel"; import { internalMutation, internalQuery } from "./_generated/server"; import { logAudit } from "./auditLogs"; import { encodeCursor, decodeCursor } from "./lib/apiHelpers"; +import { + articleStatusValidator, + articleVisibilityValidator, +} from "./lib/unifiedArticles"; +import { + createArticleCore, + updateArticleCore, + deleteArticleCore, +} from "./lib/articleWriteHelpers"; +import { + createCollectionCore, + updateCollectionCore, + deleteCollectionCore, +} from "./lib/collectionWriteHelpers"; const DEFAULT_SCAN_BATCH_SIZE = 200; @@ -1017,3 +1031,399 @@ export const updateTicketForAutomation = internalMutation({ return { id: args.ticketId }; }, }); + +// ── Articles ─────────────────────────────────────────────────────── + +export const listArticlesForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + cursor: v.optional(v.string()), + limit: v.number(), + updatedSince: v.optional(v.number()), + status: v.optional(articleStatusValidator), + collectionId: v.optional(v.id("collections")), + }, + handler: async (ctx, args) => { + const limit = Math.min(args.limit, 100); + const cursor = decodeDescCursor(args.cursor); + + const articles = await collectDescendingPage>({ + limit, + cursor, + getSortValue: (article) => article.updatedAt, + fetchBatch: async (upperBound, take) => { + let query = ctx.db + .query("articles") + .withIndex("by_workspace_updated_at", (q) => { + if (args.updatedSince !== undefined && upperBound !== undefined) { + return q + .eq("workspaceId", args.workspaceId) + .gt("updatedAt", args.updatedSince) + .lte("updatedAt", upperBound); + } + if (args.updatedSince !== undefined) { + return q.eq("workspaceId", args.workspaceId).gt("updatedAt", args.updatedSince); + } + if (upperBound !== undefined) { + return q.eq("workspaceId", args.workspaceId).lte("updatedAt", upperBound); + } + return q.eq("workspaceId", args.workspaceId); + }); + + if (args.status) { + query = query.filter((q2) => q2.eq(q2.field("status"), args.status!)); + } + if (args.collectionId) { + query = query.filter((q2) => q2.eq(q2.field("collectionId"), args.collectionId!)); + } + + return query.order("desc").take(take); + }, + }); + + const hasMore = articles.length > limit; + const data = hasMore ? articles.slice(0, limit) : articles; + + return { + data: data.map((a) => ({ + id: a._id, + workspaceId: a.workspaceId, + collectionId: a.collectionId, + title: a.title, + slug: a.slug, + content: a.content, + visibility: a.visibility, + status: a.status, + order: a.order, + tags: a.tags, + createdAt: a.createdAt, + updatedAt: a.updatedAt, + publishedAt: a.publishedAt, + })), + nextCursor: + hasMore && data.length > 0 + ? encodeCursor(data[data.length - 1].updatedAt, data[data.length - 1]._id) + : null, + hasMore, + }; + }, +}); + +export const getArticleForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + articleId: v.id("articles"), + }, + handler: async (ctx, args) => { + const article = await ctx.db.get(args.articleId); + if (!article || article.workspaceId !== args.workspaceId) { + return null; + } + + return { + id: article._id, + workspaceId: article.workspaceId, + collectionId: article.collectionId, + title: article.title, + slug: article.slug, + content: article.content, + visibility: article.visibility, + status: article.status, + order: article.order, + tags: article.tags, + createdAt: article.createdAt, + updatedAt: article.updatedAt, + publishedAt: article.publishedAt, + }; + }, +}); + +export const createArticleForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + title: v.string(), + content: v.string(), + collectionId: v.optional(v.id("collections")), + visibility: v.optional(articleVisibilityValidator), + tags: v.optional(v.array(v.string())), + }, + handler: async (ctx, args) => { + const id = await createArticleCore(ctx, { + workspaceId: args.workspaceId, + title: args.title, + content: args.content, + collectionId: args.collectionId, + visibility: args.visibility, + tags: args.tags, + }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.article.created", + resourceType: "article", + resourceId: String(id), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + return { id }; + }, +}); + +export const updateArticleForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + articleId: v.id("articles"), + title: v.optional(v.string()), + content: v.optional(v.string()), + collectionId: v.optional(v.id("collections")), + visibility: v.optional(articleVisibilityValidator), + tags: v.optional(v.array(v.string())), + status: v.optional(articleStatusValidator), + }, + handler: async (ctx, args) => { + const article = await ctx.db.get(args.articleId); + if (!article || article.workspaceId !== args.workspaceId) { + throw new Error("Article not found"); + } + + await updateArticleCore(ctx, article, { + title: args.title, + content: args.content, + collectionId: args.collectionId, + visibility: args.visibility, + tags: args.tags, + status: args.status, + }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.article.updated", + resourceType: "article", + resourceId: String(args.articleId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + return { id: args.articleId }; + }, +}); + +export const deleteArticleForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + articleId: v.id("articles"), + }, + handler: async (ctx, args) => { + const article = await ctx.db.get(args.articleId); + if (!article || article.workspaceId !== args.workspaceId) { + throw new Error("Article not found"); + } + + await deleteArticleCore(ctx, article); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.article.deleted", + resourceType: "article", + resourceId: String(args.articleId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + return { id: args.articleId }; + }, +}); + +// ── Collections ──────────────────────────────────────────────────── + +export const listCollectionsForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + cursor: v.optional(v.string()), + limit: v.number(), + updatedSince: v.optional(v.number()), + parentId: v.optional(v.id("collections")), + }, + handler: async (ctx, args) => { + const limit = Math.min(args.limit, 100); + const cursor = decodeDescCursor(args.cursor); + + const collections = await collectDescendingPage>({ + limit, + cursor, + getSortValue: (collection) => collection.updatedAt, + fetchBatch: async (upperBound, take) => { + let query = ctx.db + .query("collections") + .withIndex("by_workspace_updated_at", (q) => { + if (args.updatedSince !== undefined && upperBound !== undefined) { + return q + .eq("workspaceId", args.workspaceId) + .gt("updatedAt", args.updatedSince) + .lte("updatedAt", upperBound); + } + if (args.updatedSince !== undefined) { + return q.eq("workspaceId", args.workspaceId).gt("updatedAt", args.updatedSince); + } + if (upperBound !== undefined) { + return q.eq("workspaceId", args.workspaceId).lte("updatedAt", upperBound); + } + return q.eq("workspaceId", args.workspaceId); + }); + + if (args.parentId) { + query = query.filter((q2) => q2.eq(q2.field("parentId"), args.parentId!)); + } + + return query.order("desc").take(take); + }, + }); + + const hasMore = collections.length > limit; + const data = hasMore ? collections.slice(0, limit) : collections; + + return { + data: data.map((c) => ({ + id: c._id, + workspaceId: c.workspaceId, + name: c.name, + slug: c.slug, + description: c.description, + icon: c.icon, + parentId: c.parentId, + order: c.order, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + })), + nextCursor: + hasMore && data.length > 0 + ? encodeCursor(data[data.length - 1].updatedAt, data[data.length - 1]._id) + : null, + hasMore, + }; + }, +}); + +export const getCollectionForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + collectionId: v.id("collections"), + }, + handler: async (ctx, args) => { + const collection = await ctx.db.get(args.collectionId); + if (!collection || collection.workspaceId !== args.workspaceId) { + return null; + } + + return { + id: collection._id, + workspaceId: collection.workspaceId, + name: collection.name, + slug: collection.slug, + description: collection.description, + icon: collection.icon, + parentId: collection.parentId, + order: collection.order, + createdAt: collection.createdAt, + updatedAt: collection.updatedAt, + }; + }, +}); + +export const createCollectionForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + name: v.string(), + description: v.optional(v.string()), + icon: v.optional(v.string()), + parentId: v.optional(v.id("collections")), + }, + handler: async (ctx, args) => { + const id = await createCollectionCore(ctx, { + workspaceId: args.workspaceId, + name: args.name, + description: args.description, + icon: args.icon, + parentId: args.parentId, + }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.collection.created", + resourceType: "collection", + resourceId: String(id), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + return { id }; + }, +}); + +export const updateCollectionForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + collectionId: v.id("collections"), + name: v.optional(v.string()), + description: v.optional(v.string()), + icon: v.optional(v.string()), + parentId: v.optional(v.union(v.id("collections"), v.null())), + }, + handler: async (ctx, args) => { + const collection = await ctx.db.get(args.collectionId); + if (!collection || collection.workspaceId !== args.workspaceId) { + throw new Error("Collection not found"); + } + + await updateCollectionCore(ctx, collection, { + name: args.name, + description: args.description, + icon: args.icon, + parentId: args.parentId, + }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.collection.updated", + resourceType: "collection", + resourceId: String(args.collectionId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + return { id: args.collectionId }; + }, +}); + +export const deleteCollectionForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + collectionId: v.id("collections"), + }, + handler: async (ctx, args) => { + const collection = await ctx.db.get(args.collectionId); + if (!collection || collection.workspaceId !== args.workspaceId) { + throw new Error("Collection not found"); + } + + await deleteCollectionCore(ctx, collection); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.collection.deleted", + resourceType: "collection", + resourceId: String(args.collectionId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + return { id: args.collectionId }; + }, +}); diff --git a/packages/convex/convex/automationHttpRoutes.ts b/packages/convex/convex/automationHttpRoutes.ts index c803321..4fac467 100644 --- a/packages/convex/convex/automationHttpRoutes.ts +++ b/packages/convex/convex/automationHttpRoutes.ts @@ -8,6 +8,24 @@ function catchToResponse(error: unknown): Response { if (msg.includes("is not a valid ID") || msg.includes("Unable to parse")) { return errorResponse("Invalid resource ID", 400); } + // Convex validator errors (bad enum values, wrong types, missing fields) + if ( + msg.includes("is not a valid value") || + msg.includes("Validator error") || + msg.includes("did not match any variant") + ) { + return errorResponse(msg, 400); + } + // Business logic errors from helpers (not found, guards, cycles) + if ( + msg.includes("not found") || + msg.includes("Cannot delete") || + msg.includes("cannot be") || + msg.includes("Collection not found") || + msg.includes("Parent collection not found") + ) { + return errorResponse(msg, 400); + } return errorResponse(msg, 500); } @@ -32,6 +50,16 @@ const sendMessageIdempotentRef = fn("automationApiInternals:sendMessageIdempoten const claimConversationRef = fn("automationConversationClaims:claimConversation"); const releaseConversationRef = fn("automationConversationClaims:releaseConversation"); const escalateConversationRef = fn("automationConversationClaims:escalateConversation"); +const listArticlesRef = fn("automationApiInternals:listArticlesForAutomation"); +const getArticleRef = fn("automationApiInternals:getArticleForAutomation"); +const createArticleRef = fn("automationApiInternals:createArticleForAutomation"); +const updateArticleRef = fn("automationApiInternals:updateArticleForAutomation"); +const deleteArticleRef = fn("automationApiInternals:deleteArticleForAutomation"); +const listCollectionsRef = fn("automationApiInternals:listCollectionsForAutomation"); +const getCollectionRef = fn("automationApiInternals:getCollectionForAutomation"); +const createCollectionRef = fn("automationApiInternals:createCollectionForAutomation"); +const updateCollectionRef = fn("automationApiInternals:updateCollectionForAutomation"); +const deleteCollectionRef = fn("automationApiInternals:deleteCollectionForAutomation"); const listEventsRef = fn("automationEvents:listEvents"); const replayDeliveryRef = fn("automationWebhookWorker:replayDelivery"); @@ -456,6 +484,236 @@ export const replayWebhookDelivery = httpAction(async (ctx, request) => { } }); +// ── Articles: list ───────────────────────────────────────────────── +export const listArticles = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "articles.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const { cursor, limit, updatedSince } = parsePaginationParams(url); + const status = url.searchParams.get("status"); + const collectionId = url.searchParams.get("collectionId"); + + const result = await ctx.runQuery(listArticlesRef, { + workspaceId: authResult.workspaceId, + cursor: cursor ?? undefined, + limit, + updatedSince: updatedSince ?? undefined, + status: status ?? undefined, + collectionId: collectionId ?? undefined, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Articles: get ────────────────────────────────────────────────── +export const getArticle = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "articles.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + if (!id) return errorResponse("Missing id parameter", 400); + if (!isPlausibleConvexId(id)) return errorResponse("Invalid id format", 400); + + const result = await ctx.runQuery(getArticleRef, { + workspaceId: authResult.workspaceId, + articleId: id, + }); + if (!result) return errorResponse("Article not found", 404); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Articles: create ─────────────────────────────────────────────── +export const createArticle = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "articles.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.title) return errorResponse("Missing title", 400); + if (!body.content) return errorResponse("Missing content", 400); + + const result = await ctx.runMutation(createArticleRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + title: body.title, + content: body.content, + collectionId: body.collectionId, + visibility: body.visibility, + tags: body.tags, + }); + return jsonResponse(result, 201); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Articles: update ─────────────────────────────────────────────── +export const updateArticle = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "articles.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.articleId) return errorResponse("Missing articleId", 400); + + const result = await ctx.runMutation(updateArticleRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + articleId: body.articleId, + title: body.title, + content: body.content, + collectionId: body.collectionId, + visibility: body.visibility, + tags: body.tags, + status: body.status, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Articles: delete ─────────────────────────────────────────────── +export const deleteArticle = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "articles.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.articleId) return errorResponse("Missing articleId", 400); + + const result = await ctx.runMutation(deleteArticleRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + articleId: body.articleId, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Collections: list ────────────────────────────────────────────── +export const listCollections = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "collections.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const { cursor, limit, updatedSince } = parsePaginationParams(url); + const parentId = url.searchParams.get("parentId"); + + const result = await ctx.runQuery(listCollectionsRef, { + workspaceId: authResult.workspaceId, + cursor: cursor ?? undefined, + limit, + updatedSince: updatedSince ?? undefined, + parentId: parentId ?? undefined, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Collections: get ─────────────────────────────────────────────── +export const getCollection = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "collections.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + if (!id) return errorResponse("Missing id parameter", 400); + if (!isPlausibleConvexId(id)) return errorResponse("Invalid id format", 400); + + const result = await ctx.runQuery(getCollectionRef, { + workspaceId: authResult.workspaceId, + collectionId: id, + }); + if (!result) return errorResponse("Collection not found", 404); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Collections: create ──────────────────────────────────────────── +export const createCollection = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "collections.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.name) return errorResponse("Missing name", 400); + + const result = await ctx.runMutation(createCollectionRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + name: body.name, + description: body.description, + icon: body.icon, + parentId: body.parentId, + }); + return jsonResponse(result, 201); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Collections: update ──────────────────────────────────────────── +export const updateCollection = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "collections.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.collectionId) return errorResponse("Missing collectionId", 400); + + const result = await ctx.runMutation(updateCollectionRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + collectionId: body.collectionId, + name: body.name, + description: body.description, + icon: body.icon, + parentId: body.parentId, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Collections: delete ──────────────────────────────────────────── +export const deleteCollection = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "collections.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.collectionId) return errorResponse("Missing collectionId", 400); + + const result = await ctx.runMutation(deleteCollectionRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + collectionId: body.collectionId, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + // ── Events: feed ─────────────────────────────────────────────────── export const eventsFeed = httpAction(async (ctx, request) => { const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "events.read"); diff --git a/packages/convex/convex/automationScopes.ts b/packages/convex/convex/automationScopes.ts index b9b99a8..b0c7577 100644 --- a/packages/convex/convex/automationScopes.ts +++ b/packages/convex/convex/automationScopes.ts @@ -11,6 +11,10 @@ export const AUTOMATION_SCOPES = [ "tickets.write", "events.read", "events.write", + "articles.read", + "articles.write", + "collections.read", + "collections.write", "webhooks.manage", "claims.manage", ] as const; @@ -28,6 +32,10 @@ export const automationScopeValidator = v.union( v.literal("tickets.write"), v.literal("events.read"), v.literal("events.write"), + v.literal("articles.read"), + v.literal("articles.write"), + v.literal("collections.read"), + v.literal("collections.write"), v.literal("webhooks.manage"), v.literal("claims.manage") ); diff --git a/packages/convex/convex/collections.ts b/packages/convex/convex/collections.ts index e58ed2b..9b12b4c 100644 --- a/packages/convex/convex/collections.ts +++ b/packages/convex/convex/collections.ts @@ -6,8 +6,12 @@ import { authMutation } from "./lib/authWrappers"; import { evaluateRule, type AudienceRule } from "./audienceRules"; import { isPublicArticle } from "./lib/unifiedArticles"; import { requirePermission } from "./permissions"; -import { generateSlug, ensureUniqueSlug } from "./utils/strings"; import { resolveVisitorFromSession } from "./widgetSessions"; +import { + createCollectionCore, + updateCollectionCore, + deleteCollectionCore, +} from "./lib/collectionWriteHelpers"; type HelpCenterAccessPolicy = "public" | "restricted"; @@ -86,31 +90,13 @@ export const create = authMutation({ }, permission: "articles.create", handler: async (ctx, args) => { - const now = Date.now(); - const baseSlug = generateSlug(args.name); - const slug = await ensureUniqueSlug(ctx.db, "collections", args.workspaceId, baseSlug); - - const collections = await ctx.db - .query("collections") - .withIndex("by_parent", (q) => - q.eq("workspaceId", args.workspaceId).eq("parentId", args.parentId) - ) - .collect(); - const maxOrder = collections.reduce((max, c) => Math.max(max, c.order), 0); - - const collectionId = await ctx.db.insert("collections", { + return await createCollectionCore(ctx, { workspaceId: args.workspaceId, name: args.name, - slug, description: args.description, icon: args.icon, parentId: args.parentId, - order: maxOrder + 1, - createdAt: now, - updatedAt: now, }); - - return collectionId; }, }); @@ -133,69 +119,12 @@ export const update = authMutation({ throw new Error("Collection not found"); } - const updates: { - name?: string; - slug?: string; - description?: string; - icon?: string; - parentId?: Id<"collections"> | undefined; - updatedAt: number; - } = { - updatedAt: Date.now(), - }; - - if (args.name !== undefined && args.name !== collection.name) { - updates.name = args.name; - const baseSlug = generateSlug(args.name); - updates.slug = await ensureUniqueSlug( - ctx.db, - "collections", - collection.workspaceId, - baseSlug, - args.id - ); - } - - if (args.description !== undefined) { - updates.description = args.description; - } - - if (args.icon !== undefined) { - updates.icon = args.icon; - } - - if (args.parentId !== undefined) { - if (args.parentId === args.id) { - throw new Error("Collection cannot be its own parent"); - } - - if (args.parentId !== null) { - const parentCollection = await ctx.db.get(args.parentId); - if (!parentCollection || parentCollection.workspaceId !== collection.workspaceId) { - throw new Error("Parent collection not found"); - } - - let cursor: Id<"collections"> | undefined = parentCollection.parentId; - const seen = new Set([parentCollection._id]); - - while (cursor && !seen.has(cursor)) { - if (cursor === args.id) { - throw new Error("Collection cannot be moved into its own descendant"); - } - - seen.add(cursor); - const ancestor = await ctx.db.get(cursor); - if (!ancestor || ancestor.workspaceId !== collection.workspaceId) { - break; - } - cursor = ancestor.parentId; - } - } - - updates.parentId = args.parentId ?? undefined; - } - - await ctx.db.patch(args.id, updates); + await updateCollectionCore(ctx, collection, { + name: args.name, + description: args.description, + icon: args.icon, + parentId: args.parentId, + }); return args.id; }, }); @@ -215,27 +144,7 @@ export const remove = authMutation({ throw new Error("Collection not found"); } - const childCollections = await ctx.db - .query("collections") - .withIndex("by_parent", (q) => - q.eq("workspaceId", collection.workspaceId).eq("parentId", args.id) - ) - .collect(); - - if (childCollections.length > 0) { - throw new Error("Cannot delete collection with child collections"); - } - - const articles = await ctx.db - .query("articles") - .withIndex("by_collection", (q) => q.eq("collectionId", args.id)) - .collect(); - - if (articles.length > 0) { - throw new Error("Cannot delete collection with articles"); - } - - await ctx.db.delete(args.id); + await deleteCollectionCore(ctx, collection); return { success: true }; }, }); diff --git a/packages/convex/convex/http.ts b/packages/convex/convex/http.ts index b54c7ba..3ec9853 100644 --- a/packages/convex/convex/http.ts +++ b/packages/convex/convex/http.ts @@ -710,6 +710,16 @@ import { getTicket, createTicket, updateTicket, + listArticles, + getArticle, + createArticle, + updateArticle, + deleteArticle, + listCollections, + getCollection, + createCollection, + updateCollection, + deleteCollection, eventsFeed, replayWebhookDelivery, } from "./automationHttpRoutes"; @@ -730,6 +740,16 @@ http.route({ path: "/api/v1/tickets", method: "GET", handler: listTickets }); http.route({ path: "/api/v1/tickets/get", method: "GET", handler: getTicket }); http.route({ path: "/api/v1/tickets/create", method: "POST", handler: createTicket }); http.route({ path: "/api/v1/tickets/update", method: "POST", handler: updateTicket }); +http.route({ path: "/api/v1/articles", method: "GET", handler: listArticles }); +http.route({ path: "/api/v1/articles/get", method: "GET", handler: getArticle }); +http.route({ path: "/api/v1/articles/create", method: "POST", handler: createArticle }); +http.route({ path: "/api/v1/articles/update", method: "POST", handler: updateArticle }); +http.route({ path: "/api/v1/articles/delete", method: "POST", handler: deleteArticle }); +http.route({ path: "/api/v1/collections", method: "GET", handler: listCollections }); +http.route({ path: "/api/v1/collections/get", method: "GET", handler: getCollection }); +http.route({ path: "/api/v1/collections/create", method: "POST", handler: createCollection }); +http.route({ path: "/api/v1/collections/update", method: "POST", handler: updateCollection }); +http.route({ path: "/api/v1/collections/delete", method: "POST", handler: deleteCollection }); http.route({ path: "/api/v1/events/feed", method: "GET", handler: eventsFeed }); http.route({ path: "/api/v1/webhooks/replay", method: "POST", handler: replayWebhookDelivery }); diff --git a/packages/convex/convex/lib/articleWriteHelpers.ts b/packages/convex/convex/lib/articleWriteHelpers.ts new file mode 100644 index 0000000..05a33aa --- /dev/null +++ b/packages/convex/convex/lib/articleWriteHelpers.ts @@ -0,0 +1,252 @@ +import type { MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { generateSlug, ensureUniqueSlug } from "../utils/strings"; +import { + generateInternalEmbeddingRef, + getShallowRunAfter, + removeEmbeddingRef, +} from "../embeddings/functionRefs"; +import { + getArticleContentType, + getArticleVisibility, + type UnifiedArticleVisibility, +} from "./unifiedArticles"; + +// ── createArticleCore ─────────────────────────────────────────────── + +export async function createArticleCore( + ctx: MutationCtx, + args: { + workspaceId: Id<"workspaces">; + title: string; + content: string; + collectionId?: Id<"collections">; + visibility?: UnifiedArticleVisibility; + tags?: string[]; + authorId?: Id<"users">; + } +): Promise> { + // Validate collection ownership + if (args.collectionId) { + const collection = await ctx.db.get(args.collectionId); + if (!collection || collection.workspaceId !== args.workspaceId) { + throw new Error("Collection not found"); + } + } + + const now = Date.now(); + const baseSlug = generateSlug(args.title); + const slug = await ensureUniqueSlug(ctx.db, "articles", args.workspaceId, baseSlug); + + const articles = await ctx.db + .query("articles") + .withIndex("by_collection", (q) => q.eq("collectionId", args.collectionId)) + .collect(); + const maxOrder = articles.reduce((max, a) => Math.max(max, a.order), 0); + + const articleId = await ctx.db.insert("articles", { + workspaceId: args.workspaceId, + collectionId: args.collectionId, + folderId: undefined, + title: args.title, + slug, + content: args.content, + widgetLargeScreen: false, + visibility: args.visibility ?? "public", + status: "draft", + order: maxOrder + 1, + createdAt: now, + updatedAt: now, + tags: args.tags, + authorId: args.authorId, + }); + + return articleId; +} + +// ── updateArticleCore ─────────────────────────────────────────────── + +export async function updateArticleCore( + ctx: MutationCtx, + article: Doc<"articles">, + args: { + title?: string; + content?: string; + collectionId?: Id<"collections">; + visibility?: UnifiedArticleVisibility; + tags?: string[]; + status?: "draft" | "published" | "archived"; + } +): Promise { + // Validate collection ownership + if (args.collectionId !== undefined) { + const collection = await ctx.db.get(args.collectionId); + if (!collection || collection.workspaceId !== article.workspaceId) { + throw new Error("Collection not found"); + } + } + + const updates: Record = { + updatedAt: Date.now(), + }; + + if (args.title !== undefined && args.title !== article.title) { + updates.title = args.title; + const baseSlug = generateSlug(args.title); + updates.slug = await ensureUniqueSlug( + ctx.db, + "articles", + article.workspaceId, + baseSlug, + article._id + ); + } + + if (args.content !== undefined) { + updates.content = args.content; + } + + if (args.collectionId !== undefined) { + updates.collectionId = args.collectionId; + } + + if (args.visibility !== undefined) { + updates.visibility = args.visibility; + } + + if (args.tags !== undefined) { + updates.tags = args.tags; + } + + // Handle status transitions + const statusChanged = args.status !== undefined && args.status !== article.status; + if (statusChanged) { + updates.status = args.status; + if (args.status === "published") { + updates.publishedAt = Date.now(); + } else if (args.status === "draft" && article.status === "published") { + // Only clear publishedAt when unpublishing to draft (matches unpublishArticleCore). + // Archiving preserves publishedAt (matches archiveArticleCore). + updates.publishedAt = undefined; + } + } + + await ctx.db.patch(article._id, updates); + + // Determine the effective status after the update + const effectiveStatus = args.status ?? article.status; + const becamePublished = statusChanged && args.status === "published"; + const wasPublished = article.status === "published"; + const contentChanged = + args.title !== undefined || args.content !== undefined || args.visibility !== undefined; + + // Handle embedding side effects + const previousContentType = getArticleContentType(article); + const nextContentType = + (args.visibility ?? getArticleVisibility(article)) === "internal" + ? "internalArticle" + : "article"; + + // Remove old embedding when visibility type changes on a published article + if (effectiveStatus === "published" && previousContentType !== nextContentType) { + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, removeEmbeddingRef, { + contentType: previousContentType, + contentId: article._id, + }); + } + + // Generate embedding when newly published OR when content/visibility changes on an already-published article + if (effectiveStatus === "published" && (becamePublished || contentChanged)) { + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, generateInternalEmbeddingRef, { + workspaceId: article.workspaceId, + contentType: nextContentType, + contentId: article._id, + title: args.title ?? article.title, + content: args.content ?? article.content, + }); + } + + // Remove embedding when status changes away from published + if (statusChanged && args.status !== "published" && wasPublished) { + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, removeEmbeddingRef, { + contentType: previousContentType, + contentId: article._id, + }); + } +} + +// ── deleteArticleCore ─────────────────────────────────────────────── + +export async function deleteArticleCore( + ctx: MutationCtx, + article: Doc<"articles"> +): Promise { + await ctx.db.delete(article._id); + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, removeEmbeddingRef, { + contentType: getArticleContentType(article), + contentId: article._id, + }); +} + +// ── publishArticleCore ────────────────────────────────────────────── + +export async function publishArticleCore( + ctx: MutationCtx, + article: Doc<"articles"> +): Promise { + await ctx.db.patch(article._id, { + status: "published", + publishedAt: Date.now(), + updatedAt: Date.now(), + }); + + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, generateInternalEmbeddingRef, { + workspaceId: article.workspaceId, + contentType: getArticleContentType(article), + contentId: article._id, + title: article.title, + content: article.content, + }); +} + +// ── unpublishArticleCore ──────────────────────────────────────────── + +export async function unpublishArticleCore( + ctx: MutationCtx, + article: Doc<"articles"> +): Promise { + await ctx.db.patch(article._id, { + status: "draft", + publishedAt: undefined, + updatedAt: Date.now(), + }); + + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, removeEmbeddingRef, { + contentType: getArticleContentType(article), + contentId: article._id, + }); +} + +// ── archiveArticleCore ────────────────────────────────────────────── + +export async function archiveArticleCore( + ctx: MutationCtx, + article: Doc<"articles"> +): Promise { + await ctx.db.patch(article._id, { + status: "archived", + updatedAt: Date.now(), + }); + + const runAfter = getShallowRunAfter(ctx); + await runAfter(0, removeEmbeddingRef, { + contentType: getArticleContentType(article), + contentId: article._id, + }); +} diff --git a/packages/convex/convex/lib/collectionWriteHelpers.ts b/packages/convex/convex/lib/collectionWriteHelpers.ts new file mode 100644 index 0000000..67195d7 --- /dev/null +++ b/packages/convex/convex/lib/collectionWriteHelpers.ts @@ -0,0 +1,157 @@ +import type { MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { generateSlug, ensureUniqueSlug } from "../utils/strings"; + +// ── createCollectionCore ──────────────────────────────────────────── + +export async function createCollectionCore( + ctx: MutationCtx, + args: { + workspaceId: Id<"workspaces">; + name: string; + description?: string; + icon?: string; + parentId?: Id<"collections">; + } +): Promise> { + // Validate parent ownership + if (args.parentId) { + const parent = await ctx.db.get(args.parentId); + if (!parent || parent.workspaceId !== args.workspaceId) { + throw new Error("Parent collection not found"); + } + } + + const now = Date.now(); + const baseSlug = generateSlug(args.name); + const slug = await ensureUniqueSlug(ctx.db, "collections", args.workspaceId, baseSlug); + + const collections = await ctx.db + .query("collections") + .withIndex("by_parent", (q) => + q.eq("workspaceId", args.workspaceId).eq("parentId", args.parentId) + ) + .collect(); + const maxOrder = collections.reduce((max, c) => Math.max(max, c.order), 0); + + const collectionId = await ctx.db.insert("collections", { + workspaceId: args.workspaceId, + name: args.name, + slug, + description: args.description, + icon: args.icon, + parentId: args.parentId, + order: maxOrder + 1, + createdAt: now, + updatedAt: now, + }); + + return collectionId; +} + +// ── updateCollectionCore ──────────────────────────────────────────── + +export async function updateCollectionCore( + ctx: MutationCtx, + collection: Doc<"collections">, + args: { + name?: string; + description?: string; + icon?: string; + parentId?: Id<"collections"> | null; + } +): Promise { + const updates: { + name?: string; + slug?: string; + description?: string; + icon?: string; + parentId?: Id<"collections"> | undefined; + updatedAt: number; + } = { + updatedAt: Date.now(), + }; + + if (args.name !== undefined && args.name !== collection.name) { + updates.name = args.name; + const baseSlug = generateSlug(args.name); + updates.slug = await ensureUniqueSlug( + ctx.db, + "collections", + collection.workspaceId, + baseSlug, + collection._id + ); + } + + if (args.description !== undefined) { + updates.description = args.description; + } + + if (args.icon !== undefined) { + updates.icon = args.icon; + } + + if (args.parentId !== undefined) { + if (args.parentId === collection._id) { + throw new Error("Collection cannot be its own parent"); + } + + if (args.parentId !== null) { + const parentCollection = await ctx.db.get(args.parentId); + if (!parentCollection || parentCollection.workspaceId !== collection.workspaceId) { + throw new Error("Parent collection not found"); + } + + // Cycle detection: walk ancestor chain + let cursor: Id<"collections"> | undefined = parentCollection.parentId; + const seen = new Set([parentCollection._id]); + + while (cursor && !seen.has(cursor)) { + if (cursor === collection._id) { + throw new Error("Collection cannot be moved into its own descendant"); + } + + seen.add(cursor); + const ancestor = await ctx.db.get(cursor); + if (!ancestor || ancestor.workspaceId !== collection.workspaceId) { + break; + } + cursor = ancestor.parentId; + } + } + + updates.parentId = args.parentId ?? undefined; + } + + await ctx.db.patch(collection._id, updates); +} + +// ── deleteCollectionCore ──────────────────────────────────────────── + +export async function deleteCollectionCore( + ctx: MutationCtx, + collection: Doc<"collections"> +): Promise { + const childCollections = await ctx.db + .query("collections") + .withIndex("by_parent", (q) => + q.eq("workspaceId", collection.workspaceId).eq("parentId", collection._id) + ) + .collect(); + + if (childCollections.length > 0) { + throw new Error("Cannot delete collection with child collections"); + } + + const articles = await ctx.db + .query("articles") + .withIndex("by_collection", (q) => q.eq("collectionId", collection._id)) + .collect(); + + if (articles.length > 0) { + throw new Error("Cannot delete collection with articles"); + } + + await ctx.db.delete(collection._id); +} diff --git a/packages/convex/convex/schema/helpCenterTables.ts b/packages/convex/convex/schema/helpCenterTables.ts index 87bd981..9dc910b 100644 --- a/packages/convex/convex/schema/helpCenterTables.ts +++ b/packages/convex/convex/schema/helpCenterTables.ts @@ -20,7 +20,8 @@ export const helpCenterTables = { .index("by_slug", ["workspaceId", "slug"]) .index("by_parent", ["workspaceId", "parentId"]) .index("by_workspace_import_source", ["workspaceId", "importSourceId"]) - .index("by_import_source_path", ["importSourceId", "importPath"]), + .index("by_import_source_path", ["importSourceId", "importPath"]) + .index("by_workspace_updated_at", ["workspaceId", "updatedAt"]), // Help Center - Articles articles: defineTable({ @@ -54,7 +55,8 @@ export const helpCenterTables = { .index("by_visibility", ["workspaceId", "visibility"]) .index("by_workspace_import_source", ["workspaceId", "importSourceId"]) .index("by_import_source_path", ["importSourceId", "importPath"]) - .index("by_legacy_internal_article", ["legacyInternalArticleId"]), + .index("by_legacy_internal_article", ["legacyInternalArticleId"]) + .index("by_workspace_updated_at", ["workspaceId", "updatedAt"]), // Help Center - Article Assets articleAssets: defineTable({ diff --git a/packages/convex/tests/automationFixes.test.ts b/packages/convex/tests/automationFixes.test.ts index a473e50..ac8fe44 100644 --- a/packages/convex/tests/automationFixes.test.ts +++ b/packages/convex/tests/automationFixes.test.ts @@ -1801,4 +1801,659 @@ describe("automation fixes", () => { expect(blocked.allowed).toBe(false); }); }); + + // ── 2.1b: Articles & Collections CRUD ──────────────────────────────── + describe("2.1b: Articles & Collections CRUD", () => { + async function seedWorkspaceWithArticleScopes() { + return t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "Article Test Workspace", + automationApiEnabled: true, + createdAt: now, + }); + const userId = await ctx.db.insert("users", { + email: "admin@test.com", + workspaceId, + role: "admin", + createdAt: now, + }); + const credentialId = await ctx.db.insert("automationCredentials", { + workspaceId, + name: "Full Key", + secretHash: "testhash456", + secretPrefix: "osk_full", + scopes: [ + "articles.read", + "articles.write", + "collections.read", + "collections.write", + ], + status: "active", + actorName: "test-bot", + createdBy: userId, + createdAt: now, + }); + return { workspaceId, userId, credentialId }; + }); + } + + it("article CRUD happy path", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + // Create + const { id: articleId } = await t.mutation( + internal.automationApiInternals.createArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + title: "Getting Started", + content: "Welcome to the guide.", + } + ); + expect(articleId).toBeTruthy(); + + // Get + const article = await t.query( + internal.automationApiInternals.getArticleForAutomation, + { + workspaceId: ws.workspaceId, + articleId, + } + ); + expect(article).not.toBeNull(); + expect(article!.title).toBe("Getting Started"); + expect(article!.status).toBe("draft"); + expect(article!.slug).toBe("getting-started"); + + // Update (title changes slug) + await t.mutation( + internal.automationApiInternals.updateArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + articleId, + title: "Quick Start Guide", + content: "Updated content.", + } + ); + + const updated = await t.query( + internal.automationApiInternals.getArticleForAutomation, + { + workspaceId: ws.workspaceId, + articleId, + } + ); + expect(updated!.title).toBe("Quick Start Guide"); + expect(updated!.slug).toBe("quick-start-guide"); + expect(updated!.content).toBe("Updated content."); + + // List + const listResult = await t.query( + internal.automationApiInternals.listArticlesForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + } + ); + expect(listResult.data.length).toBe(1); + expect(listResult.data[0].id).toBe(articleId); + + // Delete + await t.mutation( + internal.automationApiInternals.deleteArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + articleId, + } + ); + + const deleted = await t.query( + internal.automationApiInternals.getArticleForAutomation, + { + workspaceId: ws.workspaceId, + articleId, + } + ); + expect(deleted).toBeNull(); + }); + + it("collection CRUD happy path", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + // Create + const { id: collectionId } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "FAQ", + description: "Frequently asked questions", + } + ); + expect(collectionId).toBeTruthy(); + + // Get + const collection = await t.query( + internal.automationApiInternals.getCollectionForAutomation, + { + workspaceId: ws.workspaceId, + collectionId, + } + ); + expect(collection).not.toBeNull(); + expect(collection!.name).toBe("FAQ"); + expect(collection!.slug).toBe("faq"); + + // Update + await t.mutation( + internal.automationApiInternals.updateCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + collectionId, + name: "Help Center FAQ", + description: "Updated description", + } + ); + + const updated = await t.query( + internal.automationApiInternals.getCollectionForAutomation, + { + workspaceId: ws.workspaceId, + collectionId, + } + ); + expect(updated!.name).toBe("Help Center FAQ"); + expect(updated!.slug).toBe("help-center-faq"); + + // List + const listResult = await t.query( + internal.automationApiInternals.listCollectionsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + } + ); + expect(listResult.data.length).toBe(1); + expect(listResult.data[0].id).toBe(collectionId); + + // Delete + await t.mutation( + internal.automationApiInternals.deleteCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + collectionId, + } + ); + + const deleted = await t.query( + internal.automationApiInternals.getCollectionForAutomation, + { + workspaceId: ws.workspaceId, + collectionId, + } + ); + expect(deleted).toBeNull(); + }); + + it("rejects cross-workspace foreign IDs", async () => { + const wsA = await seedWorkspaceWithArticleScopes(); + const wsB = await t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "Workspace B", + automationApiEnabled: true, + createdAt: now, + }); + const userId = await ctx.db.insert("users", { + email: "admin-b@test.com", + workspaceId, + role: "admin", + createdAt: now, + }); + const credentialId = await ctx.db.insert("automationCredentials", { + workspaceId, + name: "B Key", + secretHash: "testhash789", + secretPrefix: "osk_b", + scopes: ["articles.read", "articles.write", "collections.read", "collections.write"], + status: "active", + actorName: "test-bot-b", + createdBy: userId, + createdAt: now, + }); + return { workspaceId, userId, credentialId }; + }); + + // Create collection in workspace B + const { id: collectionB } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: wsB.workspaceId, + credentialId: wsB.credentialId, + name: "B Collection", + } + ); + + // Create article in workspace A with workspace B's collection → error + await expect( + t.mutation( + internal.automationApiInternals.createArticleForAutomation, + { + workspaceId: wsA.workspaceId, + credentialId: wsA.credentialId, + title: "Cross workspace test", + content: "Should fail", + collectionId: collectionB, + } + ) + ).rejects.toThrow("Collection not found"); + + // Create collection in workspace A with workspace B's collection as parent → error + await expect( + t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: wsA.workspaceId, + credentialId: wsA.credentialId, + name: "Cross workspace child", + parentId: collectionB, + } + ) + ).rejects.toThrow("Parent collection not found"); + }); + + it("rejects collection cycle", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + // Create A, then B as child of A + const { id: collA } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Collection A", + } + ); + const { id: collB } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Collection B", + parentId: collA, + } + ); + + // Try to set A.parentId = B (creates cycle) + await expect( + t.mutation( + internal.automationApiInternals.updateCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + collectionId: collA, + parentId: collB, + } + ) + ).rejects.toThrow("Collection cannot be moved into its own descendant"); + }); + + it("enforces collection deletion guards", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + // Create parent collection + const { id: parentId } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Parent", + } + ); + + // Create child collection + const { id: childId } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Child", + parentId, + } + ); + + // Can't delete parent with children + await expect( + t.mutation( + internal.automationApiInternals.deleteCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + collectionId: parentId, + } + ) + ).rejects.toThrow("Cannot delete collection with child collections"); + + // Delete child, then create article in parent + await t.mutation( + internal.automationApiInternals.deleteCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + collectionId: childId, + } + ); + + await t.mutation( + internal.automationApiInternals.createArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + title: "Article in parent", + content: "Content", + collectionId: parentId, + } + ); + + // Can't delete collection with articles + await expect( + t.mutation( + internal.automationApiInternals.deleteCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + collectionId: parentId, + } + ) + ).rejects.toThrow("Cannot delete collection with articles"); + }); + + it("audit logging records credentialId and actorType", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + const { id: articleId } = await t.mutation( + internal.automationApiInternals.createArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + title: "Audit test article", + content: "Content", + } + ); + + // Verify audit log entry + const auditEntries = await t.run(async (ctx) => { + return await ctx.db + .query("auditLogs") + .withIndex("by_workspace", (q) => q.eq("workspaceId", ws.workspaceId)) + .collect(); + }); + + const articleCreated = auditEntries.find( + (entry) => entry.action === "automation.article.created" + ); + expect(articleCreated).toBeTruthy(); + expect(articleCreated!.actorType).toBe("api"); + expect(articleCreated!.resourceId).toBe(String(articleId)); + expect((articleCreated!.metadata as any).credentialId).toBe( + String(ws.credentialId) + ); + }); + + it("article status update to published schedules embedding", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + // Create draft article + const { id: articleId } = await t.mutation( + internal.automationApiInternals.createArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + title: "Embed Test", + content: "Should get embedding on publish.", + } + ); + + // Publish via status update (no title/content/visibility change) + await t.mutation( + internal.automationApiInternals.updateArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + articleId, + status: "published", + } + ); + + const published = await t.query( + internal.automationApiInternals.getArticleForAutomation, + { workspaceId: ws.workspaceId, articleId } + ); + expect(published!.status).toBe("published"); + expect(published!.publishedAt).toBeTruthy(); + }); + + it("article status update to archived preserves publishedAt", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + const { id: articleId } = await t.mutation( + internal.automationApiInternals.createArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + title: "Archive Test", + content: "Content", + } + ); + + // Publish then archive + await t.mutation( + internal.automationApiInternals.updateArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + articleId, + status: "published", + } + ); + + const published = await t.query( + internal.automationApiInternals.getArticleForAutomation, + { workspaceId: ws.workspaceId, articleId } + ); + const publishedAt = published!.publishedAt; + expect(publishedAt).toBeTruthy(); + + await t.mutation( + internal.automationApiInternals.updateArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + articleId, + status: "archived", + } + ); + + const archived = await t.query( + internal.automationApiInternals.getArticleForAutomation, + { workspaceId: ws.workspaceId, articleId } + ); + expect(archived!.status).toBe("archived"); + // Archiving preserves publishedAt (matches dedicated archiveArticleCore behavior) + expect(archived!.publishedAt).toBe(publishedAt); + }); + + it("article list filters by status and collectionId", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + const { id: collectionId } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Filter Test Collection", + } + ); + + // Create two articles: one in collection (published), one without (draft) + const { id: articleInCollection } = await t.mutation( + internal.automationApiInternals.createArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + title: "In Collection", + content: "Content A", + collectionId, + } + ); + await t.mutation( + internal.automationApiInternals.updateArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + articleId: articleInCollection, + status: "published", + } + ); + + await t.mutation( + internal.automationApiInternals.createArticleForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + title: "No Collection", + content: "Content B", + } + ); + + // Filter by status + const publishedOnly = await t.query( + internal.automationApiInternals.listArticlesForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + status: "published", + } + ); + expect(publishedOnly.data.length).toBe(1); + expect(publishedOnly.data[0].title).toBe("In Collection"); + + // Filter by collectionId + const inColl = await t.query( + internal.automationApiInternals.listArticlesForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + collectionId, + } + ); + expect(inColl.data.length).toBe(1); + expect(inColl.data[0].id).toBe(articleInCollection); + + // Both filters combined + const draftInCollection = await t.query( + internal.automationApiInternals.listArticlesForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + status: "draft", + collectionId, + } + ); + expect(draftInCollection.data.length).toBe(0); + }); + + it("collection list filters by parentId", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + const { id: parentId } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Parent", + } + ); + await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Child", + parentId, + } + ); + await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Root Sibling", + } + ); + + // Filter by parentId + const children = await t.query( + internal.automationApiInternals.listCollectionsForAutomation, + { + workspaceId: ws.workspaceId, + limit: 10, + parentId, + } + ); + expect(children.data.length).toBe(1); + expect(children.data[0].name).toBe("Child"); + }); + + it("collection unparenting with parentId: null", async () => { + const ws = await seedWorkspaceWithArticleScopes(); + + const { id: parentId } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Parent", + } + ); + const { id: childId } = await t.mutation( + internal.automationApiInternals.createCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + name: "Child", + parentId, + } + ); + + // Verify it has a parent + const before = await t.query( + internal.automationApiInternals.getCollectionForAutomation, + { workspaceId: ws.workspaceId, collectionId: childId } + ); + expect(before!.parentId).toBe(parentId); + + // Unparent with null + await t.mutation( + internal.automationApiInternals.updateCollectionForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + collectionId: childId, + parentId: null, + } + ); + + const after = await t.query( + internal.automationApiInternals.getCollectionForAutomation, + { workspaceId: ws.workspaceId, collectionId: childId } + ); + expect(after!.parentId).toBeUndefined(); + }); + }); }); From 2640501946c38777f27bab62f9fd1fa7ad0901db Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:04:52 +0200 Subject: [PATCH 05/13] emit events and coverage matrix initial shot --- .../v1-coverage-matrix.md | 71 +++++++++++++++++++ packages/convex/AUTOMATION_V1_COVERAGE.md | 71 +++++++++++++++++++ packages/convex/convex/automationEvents.ts | 26 +++++++ packages/convex/convex/conversations.ts | 37 +++++++++- packages/convex/convex/messages.ts | 17 +++++ packages/convex/convex/tickets.ts | 46 ++++++++++++ packages/convex/convex/visitors/mutations.ts | 17 +++++ .../convex/tests/automationScopes.test.ts | 4 +- 8 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md create mode 100644 packages/convex/AUTOMATION_V1_COVERAGE.md diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md b/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md new file mode 100644 index 0000000..90b796e --- /dev/null +++ b/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md @@ -0,0 +1,71 @@ +# Automation API — V1 Coverage Matrix + +## Resource Coverage + +| Resource | List | Get | Create | Update | Delete | Events | +|---|---|---|---|---|---|---| +| conversations | cursor + filters (status, aiWorkflowState) | by ID | — | status, assign | — | `conversation.created`, `conversation.updated` | +| messages | cursor + filters (conversationId) | by ID | send | — | — | `message.created` | +| visitors | cursor + filters | by ID | — | — | — | `visitor.updated` | +| tickets | cursor + filters (status, assigneeId) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | +| articles | cursor + filters | by ID | create | update | delete | — (v2) | +| collections | cursor + filters | by ID | create | update | delete | — (v2) | + +## Event Details + +### conversation.created +- **Triggered by:** `conversations.create`, `conversations.getOrCreateForVisitor` (new branch), `conversations.createForVisitor` +- **Data payload:** `{ channel, status, visitorId }` + +### conversation.updated +- **Triggered by:** `conversations.updateStatus`, `conversations.assign` +- **Data payload:** `{ status }` or `{ assignedAgentId }` + +### message.created +- **Triggered by:** `messages.send`, `messages.internalSendBotMessage` +- **Data payload:** `{ conversationId, senderType, channel }` + +### visitor.updated +- **Triggered by:** `visitors.identify` (direct update and merge branches) +- **Data payload:** `{ email, name, externalUserId }` + +### ticket.created +- **Triggered by:** `tickets.create`, `tickets.convertFromConversation` +- **Data payload:** `{ channel: "support_ticket", status, priority }` + +### ticket.updated +- **Triggered by:** `tickets.update`, `tickets.resolve` +- **Data payload:** `{ channel: "support_ticket", status, priority, assigneeId }` + +### ticket.comment_added +- **Triggered by:** `tickets.addComment` +- **Data payload:** `{ channel: "support_ticket", commentId, authorType }` + +## Authentication & Authorization + +- **API key auth:** Bearer token with `automation_` prefix, scoped to workspace +- **Permissions:** API keys inherit automation-level access (CRUD on all supported resources) +- **Rate limits:** 100 requests/minute per API key (configurable per workspace) + +## Webhook Subscriptions + +- **Filters:** `eventTypes`, `resourceTypes`, `channels`, `aiWorkflowStates` +- **Delivery:** Async via scheduled function, with retry on failure +- **Test endpoint:** `POST /webhooks/{id}/test` sends a `test.ping` event +- **Secret:** HMAC-SHA256 signature in `X-Webhook-Signature` header + +## Polling Event Feed + +- **Endpoint:** Cursor-based pagination over `automationEvents` table +- **Ordering:** Descending by timestamp +- **Limit:** Max 100 events per page + +## Known V1 Limitations + +- **No events for articles/collections** — planned for v2 +- **No `visitor.created` event** — visitors are created implicitly by the widget; `visitor.updated` fires on `identify()` +- **No `message.updated`/`message.deleted` events** — messages are immutable in v1 +- **No `conversation.deleted` event** — conversations are not deletable +- **No fine-grained event types** — status changes, assignments, etc. are communicated via the `data` payload on broad event types (`*.updated`) rather than separate event types +- **Noisy mutations excluded:** `visitors.updateLocation` and `visitors.heartbeat` do not emit events +- **Idempotency:** Not yet supported — planned for v2 diff --git a/packages/convex/AUTOMATION_V1_COVERAGE.md b/packages/convex/AUTOMATION_V1_COVERAGE.md new file mode 100644 index 0000000..90b796e --- /dev/null +++ b/packages/convex/AUTOMATION_V1_COVERAGE.md @@ -0,0 +1,71 @@ +# Automation API — V1 Coverage Matrix + +## Resource Coverage + +| Resource | List | Get | Create | Update | Delete | Events | +|---|---|---|---|---|---|---| +| conversations | cursor + filters (status, aiWorkflowState) | by ID | — | status, assign | — | `conversation.created`, `conversation.updated` | +| messages | cursor + filters (conversationId) | by ID | send | — | — | `message.created` | +| visitors | cursor + filters | by ID | — | — | — | `visitor.updated` | +| tickets | cursor + filters (status, assigneeId) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | +| articles | cursor + filters | by ID | create | update | delete | — (v2) | +| collections | cursor + filters | by ID | create | update | delete | — (v2) | + +## Event Details + +### conversation.created +- **Triggered by:** `conversations.create`, `conversations.getOrCreateForVisitor` (new branch), `conversations.createForVisitor` +- **Data payload:** `{ channel, status, visitorId }` + +### conversation.updated +- **Triggered by:** `conversations.updateStatus`, `conversations.assign` +- **Data payload:** `{ status }` or `{ assignedAgentId }` + +### message.created +- **Triggered by:** `messages.send`, `messages.internalSendBotMessage` +- **Data payload:** `{ conversationId, senderType, channel }` + +### visitor.updated +- **Triggered by:** `visitors.identify` (direct update and merge branches) +- **Data payload:** `{ email, name, externalUserId }` + +### ticket.created +- **Triggered by:** `tickets.create`, `tickets.convertFromConversation` +- **Data payload:** `{ channel: "support_ticket", status, priority }` + +### ticket.updated +- **Triggered by:** `tickets.update`, `tickets.resolve` +- **Data payload:** `{ channel: "support_ticket", status, priority, assigneeId }` + +### ticket.comment_added +- **Triggered by:** `tickets.addComment` +- **Data payload:** `{ channel: "support_ticket", commentId, authorType }` + +## Authentication & Authorization + +- **API key auth:** Bearer token with `automation_` prefix, scoped to workspace +- **Permissions:** API keys inherit automation-level access (CRUD on all supported resources) +- **Rate limits:** 100 requests/minute per API key (configurable per workspace) + +## Webhook Subscriptions + +- **Filters:** `eventTypes`, `resourceTypes`, `channels`, `aiWorkflowStates` +- **Delivery:** Async via scheduled function, with retry on failure +- **Test endpoint:** `POST /webhooks/{id}/test` sends a `test.ping` event +- **Secret:** HMAC-SHA256 signature in `X-Webhook-Signature` header + +## Polling Event Feed + +- **Endpoint:** Cursor-based pagination over `automationEvents` table +- **Ordering:** Descending by timestamp +- **Limit:** Max 100 events per page + +## Known V1 Limitations + +- **No events for articles/collections** — planned for v2 +- **No `visitor.created` event** — visitors are created implicitly by the widget; `visitor.updated` fires on `identify()` +- **No `message.updated`/`message.deleted` events** — messages are immutable in v1 +- **No `conversation.deleted` event** — conversations are not deletable +- **No fine-grained event types** — status changes, assignments, etc. are communicated via the `data` payload on broad event types (`*.updated`) rather than separate event types +- **Noisy mutations excluded:** `visitors.updateLocation` and `visitors.heartbeat` do not emit events +- **Idempotency:** Not yet supported — planned for v2 diff --git a/packages/convex/convex/automationEvents.ts b/packages/convex/convex/automationEvents.ts index eb3577e..7b7dddb 100644 --- a/packages/convex/convex/automationEvents.ts +++ b/packages/convex/convex/automationEvents.ts @@ -2,11 +2,31 @@ import { makeFunctionReference } from "convex/server"; import { v } from "convex/values"; import { internalMutation, internalQuery } from "./_generated/server"; import { encodeCursor, decodeCursor } from "./lib/apiHelpers"; +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx } from "./_generated/server"; const deliverWebhookRef = makeFunctionReference<"action">( "automationWebhookWorker:deliverWebhook" ); +const emitEventRef = makeFunctionReference<"mutation">( + "automationEvents:emitEvent" +); + +/** Schedule an automation event from a domain mutation. No-ops if automation is disabled. */ +export async function emitAutomationEvent( + ctx: Pick, + params: { + workspaceId: Id<"workspaces">; + eventType: string; + resourceType: string; + resourceId: string; + data: Record; + } +) { + await ctx.scheduler.runAfter(0, emitEventRef as any, params); +} + // Emit an automation event and trigger matching webhook deliveries. export const emitEvent = internalMutation({ args: { @@ -17,6 +37,12 @@ export const emitEvent = internalMutation({ data: v.any(), }, handler: async (ctx, args) => { + // Check if automation API is enabled for this workspace + const workspace = await ctx.db.get(args.workspaceId); + if (!workspace || !workspace.automationApiEnabled) { + return { eventId: null }; + } + const now = Date.now(); const eventId = await ctx.db.insert("automationEvents", { workspaceId: args.workspaceId, diff --git a/packages/convex/convex/conversations.ts b/packages/convex/convex/conversations.ts index 8230d34..6f9260f 100644 --- a/packages/convex/convex/conversations.ts +++ b/packages/convex/convex/conversations.ts @@ -9,6 +9,7 @@ import { } from "./notifications/functionRefs"; import { requirePermission, hasPermission } from "./permissions"; import { resolveVisitorFromSession } from "./widgetSessions"; +import { emitAutomationEvent } from "./automationEvents"; // Helper function to create a new conversation (shared by getOrCreateForVisitor and createForVisitor) async function createConversationInternal( @@ -40,6 +41,14 @@ async function createConversationInternal( conversationId: id, }); + await emitAutomationEvent(ctx, { + workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: id, + data: { channel: "chat", status: "open", visitorId }, + }); + return await ctx.db.get(id); } @@ -124,7 +133,7 @@ export const create = mutation({ const now = Date.now(); - return await ctx.db.insert("conversations", { + const conversationId = await ctx.db.insert("conversations", { workspaceId: args.workspaceId, visitorId: args.visitorId, userId: args.userId, @@ -133,6 +142,16 @@ export const create = mutation({ updatedAt: now, aiWorkflowState: "none", }); + + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "conversation.created", + resourceType: "conversation", + resourceId: conversationId, + data: { channel: "chat", status: "open", visitorId: args.visitorId }, + }); + + return conversationId; }, }); @@ -200,6 +219,14 @@ export const updateStatus = mutation({ } await ctx.db.patch(args.id, patch); + + await emitAutomationEvent(ctx, { + workspaceId: conversation.workspaceId, + eventType: "conversation.updated", + resourceType: "conversation", + resourceId: args.id, + data: { status: args.status }, + }); }, }); @@ -232,6 +259,14 @@ export const assign = mutation({ assignedAgentId: args.agentId, actorUserId: user._id, }); + + await emitAutomationEvent(ctx, { + workspaceId: conversation.workspaceId, + eventType: "conversation.updated", + resourceType: "conversation", + resourceId: args.id, + data: { assignedAgentId: args.agentId }, + }); }, }); diff --git a/packages/convex/convex/messages.ts b/packages/convex/convex/messages.ts index e2d5646..8207ecc 100644 --- a/packages/convex/convex/messages.ts +++ b/packages/convex/convex/messages.ts @@ -13,6 +13,7 @@ import { } from "./supportAttachments"; import { supportAttachmentIdArrayValidator } from "./supportAttachmentTypes"; import { resolveVisitorFromSession } from "./widgetSessions"; +import { emitAutomationEvent } from "./automationEvents"; async function withSupportSenderNames( ctx: QueryCtx, @@ -257,6 +258,14 @@ export const send = mutation({ channel: "chat", }); + await emitAutomationEvent(ctx, { + workspaceId: conversation.workspaceId, + eventType: "message.created", + resourceType: "message", + resourceId: messageId, + data: { conversationId: args.conversationId, senderType: args.senderType, channel: "chat" }, + }); + return messageId; }, }); @@ -314,6 +323,14 @@ export const internalSendBotMessage = internalMutation({ channel: "chat", }); + await emitAutomationEvent(ctx, { + workspaceId: conversation.workspaceId, + eventType: "message.created", + resourceType: "message", + resourceId: messageId, + data: { conversationId: args.conversationId, senderType: "bot", channel: "chat" }, + }); + return messageId; }, }); diff --git a/packages/convex/convex/tickets.ts b/packages/convex/convex/tickets.ts index ae74ba4..439aa4f 100644 --- a/packages/convex/convex/tickets.ts +++ b/packages/convex/convex/tickets.ts @@ -14,6 +14,7 @@ import { import { supportAttachmentIdArrayValidator } from "./supportAttachmentTypes"; import { formDataValidator } from "./validators"; import { authMutation, authQuery } from "./lib/authWrappers"; +import { emitAutomationEvent } from "./automationEvents"; type TicketCreatedNotificationArgs = { ticketId: Id<"tickets">; @@ -400,6 +401,14 @@ export const create = mutation({ ticketId, }); + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "ticket.created", + resourceType: "ticket", + resourceId: ticketId, + data: { channel: "support_ticket", status: "submitted", priority: args.priority || "normal" }, + }); + return ticketId; }, }); @@ -464,6 +473,19 @@ export const update = authMutation({ }); } + await emitAutomationEvent(ctx, { + workspaceId: ticket.workspaceId, + eventType: "ticket.updated", + resourceType: "ticket", + resourceId: args.id, + data: { + channel: "support_ticket", + status: args.status ?? ticket.status, + priority: args.priority ?? ticket.priority, + assigneeId: args.assigneeId ?? ticket.assigneeId, + }, + }); + return args.id; }, }); @@ -653,6 +675,14 @@ export const convertFromConversation = authMutation({ ticketId, }); + await emitAutomationEvent(ctx, { + workspaceId: conversation.workspaceId, + eventType: "ticket.created", + resourceType: "ticket", + resourceId: ticketId, + data: { channel: "support_ticket", status: "submitted", priority: args.priority || "normal" }, + }); + return ticketId; }, }); @@ -739,6 +769,14 @@ export const addComment = mutation({ }); } + await emitAutomationEvent(ctx, { + workspaceId: ticket.workspaceId, + eventType: "ticket.comment_added", + resourceType: "ticket", + resourceId: args.ticketId, + data: { channel: "support_ticket", commentId, authorType }, + }); + return commentId; }, }); @@ -785,6 +823,14 @@ export const resolve = authMutation({ actorUserId: ctx.user._id, }); + await emitAutomationEvent(ctx, { + workspaceId: ticket.workspaceId, + eventType: "ticket.updated", + resourceType: "ticket", + resourceId: args.id, + data: { channel: "support_ticket", status: "resolved", priority: ticket.priority }, + }); + return args.id; }, }); diff --git a/packages/convex/convex/visitors/mutations.ts b/packages/convex/convex/visitors/mutations.ts index 5d57765..353a45e 100644 --- a/packages/convex/convex/visitors/mutations.ts +++ b/packages/convex/convex/visitors/mutations.ts @@ -7,6 +7,7 @@ import { hasPermission } from "../permissions"; import { requireValidOrigin } from "../originValidation"; import { resolveVisitorFromSession } from "../widgetSessions"; import { logAudit } from "../auditLogs"; +import { emitAutomationEvent } from "../automationEvents"; import { MERGE_REASON_EMAIL_MATCH, collectCustomAttributeChanges, @@ -247,6 +248,14 @@ export const identify = mutation({ }, }); + await emitAutomationEvent(ctx, { + workspaceId: visitor.workspaceId, + eventType: "visitor.updated", + resourceType: "visitor", + resourceId: canonicalByEmail._id, + data: { email: args.email, name: args.name ?? canonicalByEmail.name ?? visitor.name, externalUserId: args.externalUserId ?? canonicalByEmail.externalUserId ?? visitor.externalUserId }, + }); + return await ctx.db.get(canonicalByEmail._id); } } @@ -298,6 +307,14 @@ export const identify = mutation({ changes: attributeChanges, }); + await emitAutomationEvent(ctx, { + workspaceId: visitor.workspaceId, + eventType: "visitor.updated", + resourceType: "visitor", + resourceId: resolvedVisitorId, + data: { email: args.email ?? visitor.email, name: args.name ?? visitor.name, externalUserId: args.externalUserId ?? visitor.externalUserId }, + }); + return await ctx.db.get(resolvedVisitorId); }, }); diff --git a/packages/convex/tests/automationScopes.test.ts b/packages/convex/tests/automationScopes.test.ts index 64e2e5a..b9b5b2d 100644 --- a/packages/convex/tests/automationScopes.test.ts +++ b/packages/convex/tests/automationScopes.test.ts @@ -60,8 +60,8 @@ describe("Automation Scopes", () => { expect(AUTOMATION_SCOPES).toContain("claims.manage"); }); - it("has exactly 12 v1 scopes", () => { - expect(AUTOMATION_SCOPES).toHaveLength(12); + it("has exactly 16 v1 scopes", () => { + expect(AUTOMATION_SCOPES).toHaveLength(16); }); }); }); From 8e8c102e7908d206a78209909f4e929bd6876b46 Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:10:59 +0200 Subject: [PATCH 06/13] fix missing event emissions and align v1 coverage docs --- .../v1-coverage-matrix.md | 53 +++++++++------ packages/convex/AUTOMATION_V1_COVERAGE.md | 53 +++++++++------ .../convex/convex/automationApiInternals.ts | 66 +++++++++++++++++++ packages/convex/convex/tickets.ts | 2 +- 4 files changed, 131 insertions(+), 43 deletions(-) diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md b/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md index 90b796e..bdc28d5 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md @@ -4,55 +4,60 @@ | Resource | List | Get | Create | Update | Delete | Events | |---|---|---|---|---|---|---| -| conversations | cursor + filters (status, aiWorkflowState) | by ID | — | status, assign | — | `conversation.created`, `conversation.updated` | -| messages | cursor + filters (conversationId) | by ID | send | — | — | `message.created` | -| visitors | cursor + filters | by ID | — | — | — | `visitor.updated` | -| tickets | cursor + filters (status, assigneeId) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | -| articles | cursor + filters | by ID | create | update | delete | — (v2) | -| collections | cursor + filters | by ID | create | update | delete | — (v2) | +| conversations | cursor + filters (status, assignee, channel, email, externalUserId, customAttribute) | by ID | — | status, assign | — | `conversation.created`, `conversation.updated` | +| messages | cursor + filters (conversationId) | — | send | — | — | `message.created` | +| visitors | cursor + filters (email, externalUserId, customAttribute) | by ID | create | update | — | `visitor.updated` | +| tickets | cursor + filters (status, priority, assigneeId) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | +| articles | cursor + filters (status, collectionId) | by ID | create | update | delete | — (v2) | +| collections | cursor + filters (parentId) | by ID | create | update | delete | — (v2) | ## Event Details +Events are emitted by both UI/domain mutations and automation API mutations. + ### conversation.created - **Triggered by:** `conversations.create`, `conversations.getOrCreateForVisitor` (new branch), `conversations.createForVisitor` - **Data payload:** `{ channel, status, visitorId }` ### conversation.updated -- **Triggered by:** `conversations.updateStatus`, `conversations.assign` -- **Data payload:** `{ status }` or `{ assignedAgentId }` +- **Triggered by:** `conversations.updateStatus`, `conversations.assign`, `automationApi.updateConversation` +- **Data payload:** `{ status }` and/or `{ assignedAgentId }` ### message.created -- **Triggered by:** `messages.send`, `messages.internalSendBotMessage` +- **Triggered by:** `messages.send`, `messages.internalSendBotMessage`, `automationApi.sendMessage` - **Data payload:** `{ conversationId, senderType, channel }` +- **Note:** `channel` is derived from the conversation's channel field (defaults to `"chat"` if unset) ### visitor.updated -- **Triggered by:** `visitors.identify` (direct update and merge branches) +- **Triggered by:** `visitors.identify` (direct update and merge branches), `automationApi.updateVisitor` - **Data payload:** `{ email, name, externalUserId }` +- **Note:** Payload may include visitor identity fields (email, name, externalUserId) ### ticket.created -- **Triggered by:** `tickets.create`, `tickets.convertFromConversation` +- **Triggered by:** `tickets.create`, `tickets.convertFromConversation`, `automationApi.createTicket` - **Data payload:** `{ channel: "support_ticket", status, priority }` ### ticket.updated -- **Triggered by:** `tickets.update`, `tickets.resolve` +- **Triggered by:** `tickets.update`, `tickets.resolve`, `automationApi.updateTicket` - **Data payload:** `{ channel: "support_ticket", status, priority, assigneeId }` ### ticket.comment_added - **Triggered by:** `tickets.addComment` -- **Data payload:** `{ channel: "support_ticket", commentId, authorType }` +- **Data payload:** `{ channel: "support_ticket", commentId, authorType, isInternal }` ## Authentication & Authorization -- **API key auth:** Bearer token with `automation_` prefix, scoped to workspace -- **Permissions:** API keys inherit automation-level access (CRUD on all supported resources) -- **Rate limits:** 100 requests/minute per API key (configurable per workspace) +- **API key auth:** Bearer token with `osk_` prefix, scoped to workspace +- **Permissions:** API keys carry explicit scopes (e.g. `conversations.read`, `messages.write`) +- **Rate limits:** 60 requests/minute per credential, 120 requests/minute per workspace ## Webhook Subscriptions -- **Filters:** `eventTypes`, `resourceTypes`, `channels`, `aiWorkflowStates` -- **Delivery:** Async via scheduled function, with retry on failure +- **Filters:** `eventTypes`, `resourceTypes`, `channels`, `aiWorkflowStates` (reserved — not yet emitted by production mutations) +- **Delivery:** Async via scheduled function, with exponential backoff retry (30s, 2m, 10m, 1h, 4h; max 5 attempts) - **Test endpoint:** `POST /webhooks/{id}/test` sends a `test.ping` event -- **Secret:** HMAC-SHA256 signature in `X-Webhook-Signature` header +- **Signature:** HMAC-SHA256 in `X-Opencom-Signature` header (format: `t={timestamp},v1={hex}`) +- **Additional headers:** `X-Opencom-Event-Id`, `X-Opencom-Delivery-Id`, `X-Opencom-Timestamp` ## Polling Event Feed @@ -60,12 +65,18 @@ - **Ordering:** Descending by timestamp - **Limit:** Max 100 events per page +## Idempotency + +- **Supported:** Message send (`POST /messages`) accepts `Idempotency-Key` header +- **TTL:** 24 hours +- **Scope:** Per workspace + key + ## Known V1 Limitations - **No events for articles/collections** — planned for v2 -- **No `visitor.created` event** — visitors are created implicitly by the widget; `visitor.updated` fires on `identify()` +- **No `visitor.created` event** — visitors can be created via the API, but no event is emitted; `visitor.updated` fires on `identify()` and API update - **No `message.updated`/`message.deleted` events** — messages are immutable in v1 - **No `conversation.deleted` event** — conversations are not deletable - **No fine-grained event types** — status changes, assignments, etc. are communicated via the `data` payload on broad event types (`*.updated`) rather than separate event types - **Noisy mutations excluded:** `visitors.updateLocation` and `visitors.heartbeat` do not emit events -- **Idempotency:** Not yet supported — planned for v2 +- **`aiWorkflowStates` webhook filter:** Reserved for future use; no production mutations currently populate this field in event data diff --git a/packages/convex/AUTOMATION_V1_COVERAGE.md b/packages/convex/AUTOMATION_V1_COVERAGE.md index 90b796e..bdc28d5 100644 --- a/packages/convex/AUTOMATION_V1_COVERAGE.md +++ b/packages/convex/AUTOMATION_V1_COVERAGE.md @@ -4,55 +4,60 @@ | Resource | List | Get | Create | Update | Delete | Events | |---|---|---|---|---|---|---| -| conversations | cursor + filters (status, aiWorkflowState) | by ID | — | status, assign | — | `conversation.created`, `conversation.updated` | -| messages | cursor + filters (conversationId) | by ID | send | — | — | `message.created` | -| visitors | cursor + filters | by ID | — | — | — | `visitor.updated` | -| tickets | cursor + filters (status, assigneeId) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | -| articles | cursor + filters | by ID | create | update | delete | — (v2) | -| collections | cursor + filters | by ID | create | update | delete | — (v2) | +| conversations | cursor + filters (status, assignee, channel, email, externalUserId, customAttribute) | by ID | — | status, assign | — | `conversation.created`, `conversation.updated` | +| messages | cursor + filters (conversationId) | — | send | — | — | `message.created` | +| visitors | cursor + filters (email, externalUserId, customAttribute) | by ID | create | update | — | `visitor.updated` | +| tickets | cursor + filters (status, priority, assigneeId) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | +| articles | cursor + filters (status, collectionId) | by ID | create | update | delete | — (v2) | +| collections | cursor + filters (parentId) | by ID | create | update | delete | — (v2) | ## Event Details +Events are emitted by both UI/domain mutations and automation API mutations. + ### conversation.created - **Triggered by:** `conversations.create`, `conversations.getOrCreateForVisitor` (new branch), `conversations.createForVisitor` - **Data payload:** `{ channel, status, visitorId }` ### conversation.updated -- **Triggered by:** `conversations.updateStatus`, `conversations.assign` -- **Data payload:** `{ status }` or `{ assignedAgentId }` +- **Triggered by:** `conversations.updateStatus`, `conversations.assign`, `automationApi.updateConversation` +- **Data payload:** `{ status }` and/or `{ assignedAgentId }` ### message.created -- **Triggered by:** `messages.send`, `messages.internalSendBotMessage` +- **Triggered by:** `messages.send`, `messages.internalSendBotMessage`, `automationApi.sendMessage` - **Data payload:** `{ conversationId, senderType, channel }` +- **Note:** `channel` is derived from the conversation's channel field (defaults to `"chat"` if unset) ### visitor.updated -- **Triggered by:** `visitors.identify` (direct update and merge branches) +- **Triggered by:** `visitors.identify` (direct update and merge branches), `automationApi.updateVisitor` - **Data payload:** `{ email, name, externalUserId }` +- **Note:** Payload may include visitor identity fields (email, name, externalUserId) ### ticket.created -- **Triggered by:** `tickets.create`, `tickets.convertFromConversation` +- **Triggered by:** `tickets.create`, `tickets.convertFromConversation`, `automationApi.createTicket` - **Data payload:** `{ channel: "support_ticket", status, priority }` ### ticket.updated -- **Triggered by:** `tickets.update`, `tickets.resolve` +- **Triggered by:** `tickets.update`, `tickets.resolve`, `automationApi.updateTicket` - **Data payload:** `{ channel: "support_ticket", status, priority, assigneeId }` ### ticket.comment_added - **Triggered by:** `tickets.addComment` -- **Data payload:** `{ channel: "support_ticket", commentId, authorType }` +- **Data payload:** `{ channel: "support_ticket", commentId, authorType, isInternal }` ## Authentication & Authorization -- **API key auth:** Bearer token with `automation_` prefix, scoped to workspace -- **Permissions:** API keys inherit automation-level access (CRUD on all supported resources) -- **Rate limits:** 100 requests/minute per API key (configurable per workspace) +- **API key auth:** Bearer token with `osk_` prefix, scoped to workspace +- **Permissions:** API keys carry explicit scopes (e.g. `conversations.read`, `messages.write`) +- **Rate limits:** 60 requests/minute per credential, 120 requests/minute per workspace ## Webhook Subscriptions -- **Filters:** `eventTypes`, `resourceTypes`, `channels`, `aiWorkflowStates` -- **Delivery:** Async via scheduled function, with retry on failure +- **Filters:** `eventTypes`, `resourceTypes`, `channels`, `aiWorkflowStates` (reserved — not yet emitted by production mutations) +- **Delivery:** Async via scheduled function, with exponential backoff retry (30s, 2m, 10m, 1h, 4h; max 5 attempts) - **Test endpoint:** `POST /webhooks/{id}/test` sends a `test.ping` event -- **Secret:** HMAC-SHA256 signature in `X-Webhook-Signature` header +- **Signature:** HMAC-SHA256 in `X-Opencom-Signature` header (format: `t={timestamp},v1={hex}`) +- **Additional headers:** `X-Opencom-Event-Id`, `X-Opencom-Delivery-Id`, `X-Opencom-Timestamp` ## Polling Event Feed @@ -60,12 +65,18 @@ - **Ordering:** Descending by timestamp - **Limit:** Max 100 events per page +## Idempotency + +- **Supported:** Message send (`POST /messages`) accepts `Idempotency-Key` header +- **TTL:** 24 hours +- **Scope:** Per workspace + key + ## Known V1 Limitations - **No events for articles/collections** — planned for v2 -- **No `visitor.created` event** — visitors are created implicitly by the widget; `visitor.updated` fires on `identify()` +- **No `visitor.created` event** — visitors can be created via the API, but no event is emitted; `visitor.updated` fires on `identify()` and API update - **No `message.updated`/`message.deleted` events** — messages are immutable in v1 - **No `conversation.deleted` event** — conversations are not deletable - **No fine-grained event types** — status changes, assignments, etc. are communicated via the `data` payload on broad event types (`*.updated`) rather than separate event types - **Noisy mutations excluded:** `visitors.updateLocation` and `visitors.heartbeat` do not emit events -- **Idempotency:** Not yet supported — planned for v2 +- **`aiWorkflowStates` webhook filter:** Reserved for future use; no production mutations currently populate this field in event data diff --git a/packages/convex/convex/automationApiInternals.ts b/packages/convex/convex/automationApiInternals.ts index 6fcd805..7ddc0c5 100644 --- a/packages/convex/convex/automationApiInternals.ts +++ b/packages/convex/convex/automationApiInternals.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { internalMutation, internalQuery } from "./_generated/server"; import { logAudit } from "./auditLogs"; +import { emitAutomationEvent } from "./automationEvents"; import { encodeCursor, decodeCursor } from "./lib/apiHelpers"; import { articleStatusValidator, @@ -415,6 +416,18 @@ export const updateConversationForAutomation = internalMutation({ metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, }); + const eventData: Record = {}; + if (args.status !== undefined) eventData.status = args.status; + if (args.assignedAgentId !== undefined) eventData.assignedAgentId = args.assignedAgentId; + + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "conversation.updated", + resourceType: "conversation", + resourceId: args.conversationId, + data: eventData, + }); + return { id: args.conversationId }; }, }); @@ -546,6 +559,14 @@ export const sendMessageForAutomation = internalMutation({ metadata: { credentialId: String(args.credentialId) }, }); + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "message.created", + resourceType: "message", + resourceId: messageId, + data: { conversationId: args.conversationId, senderType: "bot", channel: conv.channel ?? "chat" }, + }); + return { id: messageId }; }, }); @@ -626,6 +647,14 @@ export const sendMessageIdempotent = internalMutation({ metadata: { credentialId: String(args.credentialId) }, }); + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "message.created", + resourceType: "message", + resourceId: messageId, + data: { conversationId: args.conversationId, senderType: "bot", channel: conv.channel ?? "chat" }, + }); + const result = { id: messageId }; // Store idempotency key if provided @@ -836,6 +865,18 @@ export const updateVisitorForAutomation = internalMutation({ metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, }); + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "visitor.updated", + resourceType: "visitor", + resourceId: args.visitorId, + data: { + email: args.email ?? visitor.email, + name: args.name ?? visitor.name, + externalUserId: args.externalUserId ?? visitor.externalUserId, + }, + }); + return { id: args.visitorId }; }, }); @@ -985,6 +1026,18 @@ export const createTicketForAutomation = internalMutation({ metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, }); + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "ticket.created", + resourceType: "ticket", + resourceId: id, + data: { + channel: "support_ticket", + status: "submitted", + priority: (args.priority as string) ?? "normal", + }, + }); + return { id }; }, }); @@ -1028,6 +1081,19 @@ export const updateTicketForAutomation = internalMutation({ metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, }); + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "ticket.updated", + resourceType: "ticket", + resourceId: args.ticketId, + data: { + channel: "support_ticket", + status: args.status ?? ticket.status, + priority: args.priority ?? ticket.priority, + assigneeId: args.assigneeId ?? ticket.assigneeId, + }, + }); + return { id: args.ticketId }; }, }); diff --git a/packages/convex/convex/tickets.ts b/packages/convex/convex/tickets.ts index 439aa4f..dd308ad 100644 --- a/packages/convex/convex/tickets.ts +++ b/packages/convex/convex/tickets.ts @@ -774,7 +774,7 @@ export const addComment = mutation({ eventType: "ticket.comment_added", resourceType: "ticket", resourceId: args.ticketId, - data: { channel: "support_ticket", commentId, authorType }, + data: { channel: "support_ticket", commentId, authorType, isInternal }, }); return commentId; From 752e7057402e6303c5bf5dfa97ae8763b7b732a8 Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:23:06 +0200 Subject: [PATCH 07/13] regression coverage, missing events for API writes --- .../v1-coverage-matrix.md | 13 +- packages/convex/AUTOMATION_V1_COVERAGE.md | 13 +- .../convex/convex/automationApiInternals.ts | 6 +- packages/convex/convex/tickets.ts | 18 +- packages/convex/convex/visitors/mutations.ts | 4 +- packages/convex/tests/automationFixes.test.ts | 358 ++++++++++++++++++ 6 files changed, 387 insertions(+), 25 deletions(-) diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md b/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md index bdc28d5..8cd160b 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md @@ -13,7 +13,7 @@ ## Event Details -Events are emitted by both UI/domain mutations and automation API mutations. +Events are emitted by UI/domain mutations and most automation API write mutations. Not all API writes emit events — notably, `createVisitor` via the API does not emit `visitor.created`. ### conversation.created - **Triggered by:** `conversations.create`, `conversations.getOrCreateForVisitor` (new branch), `conversations.createForVisitor` @@ -30,8 +30,8 @@ Events are emitted by both UI/domain mutations and automation API mutations. ### visitor.updated - **Triggered by:** `visitors.identify` (direct update and merge branches), `automationApi.updateVisitor` -- **Data payload:** `{ email, name, externalUserId }` -- **Note:** Payload may include visitor identity fields (email, name, externalUserId) +- **Data payload:** `{ visitorId }` +- **Note:** The event signals that a visitor was updated. To retrieve updated fields, use the visitors GET endpoint with the `visitorId` from the payload. ### ticket.created - **Triggered by:** `tickets.create`, `tickets.convertFromConversation`, `automationApi.createTicket` @@ -40,10 +40,12 @@ Events are emitted by both UI/domain mutations and automation API mutations. ### ticket.updated - **Triggered by:** `tickets.update`, `tickets.resolve`, `automationApi.updateTicket` - **Data payload:** `{ channel: "support_ticket", status, priority, assigneeId }` +- **Note:** This is a coarse event, not a field-level diff. All listed fields are included regardless of which field changed. Fields like `teamId` and `resolutionSummary` are not included in the event payload. ### ticket.comment_added -- **Triggered by:** `tickets.addComment` -- **Data payload:** `{ channel: "support_ticket", commentId, authorType, isInternal }` +- **Triggered by:** `tickets.addComment` (external comments only) +- **Data payload:** `{ channel: "support_ticket", commentId, authorType }` +- **Note:** Internal notes do not trigger this event. ## Authentication & Authorization @@ -80,3 +82,4 @@ Events are emitted by both UI/domain mutations and automation API mutations. - **No fine-grained event types** — status changes, assignments, etc. are communicated via the `data` payload on broad event types (`*.updated`) rather than separate event types - **Noisy mutations excluded:** `visitors.updateLocation` and `visitors.heartbeat` do not emit events - **`aiWorkflowStates` webhook filter:** Reserved for future use; no production mutations currently populate this field in event data +- **`ticket.updated` payload is coarse** — includes status, priority, assigneeId on every emit regardless of what changed; does not include teamId or resolutionSummary diff --git a/packages/convex/AUTOMATION_V1_COVERAGE.md b/packages/convex/AUTOMATION_V1_COVERAGE.md index bdc28d5..8cd160b 100644 --- a/packages/convex/AUTOMATION_V1_COVERAGE.md +++ b/packages/convex/AUTOMATION_V1_COVERAGE.md @@ -13,7 +13,7 @@ ## Event Details -Events are emitted by both UI/domain mutations and automation API mutations. +Events are emitted by UI/domain mutations and most automation API write mutations. Not all API writes emit events — notably, `createVisitor` via the API does not emit `visitor.created`. ### conversation.created - **Triggered by:** `conversations.create`, `conversations.getOrCreateForVisitor` (new branch), `conversations.createForVisitor` @@ -30,8 +30,8 @@ Events are emitted by both UI/domain mutations and automation API mutations. ### visitor.updated - **Triggered by:** `visitors.identify` (direct update and merge branches), `automationApi.updateVisitor` -- **Data payload:** `{ email, name, externalUserId }` -- **Note:** Payload may include visitor identity fields (email, name, externalUserId) +- **Data payload:** `{ visitorId }` +- **Note:** The event signals that a visitor was updated. To retrieve updated fields, use the visitors GET endpoint with the `visitorId` from the payload. ### ticket.created - **Triggered by:** `tickets.create`, `tickets.convertFromConversation`, `automationApi.createTicket` @@ -40,10 +40,12 @@ Events are emitted by both UI/domain mutations and automation API mutations. ### ticket.updated - **Triggered by:** `tickets.update`, `tickets.resolve`, `automationApi.updateTicket` - **Data payload:** `{ channel: "support_ticket", status, priority, assigneeId }` +- **Note:** This is a coarse event, not a field-level diff. All listed fields are included regardless of which field changed. Fields like `teamId` and `resolutionSummary` are not included in the event payload. ### ticket.comment_added -- **Triggered by:** `tickets.addComment` -- **Data payload:** `{ channel: "support_ticket", commentId, authorType, isInternal }` +- **Triggered by:** `tickets.addComment` (external comments only) +- **Data payload:** `{ channel: "support_ticket", commentId, authorType }` +- **Note:** Internal notes do not trigger this event. ## Authentication & Authorization @@ -80,3 +82,4 @@ Events are emitted by both UI/domain mutations and automation API mutations. - **No fine-grained event types** — status changes, assignments, etc. are communicated via the `data` payload on broad event types (`*.updated`) rather than separate event types - **Noisy mutations excluded:** `visitors.updateLocation` and `visitors.heartbeat` do not emit events - **`aiWorkflowStates` webhook filter:** Reserved for future use; no production mutations currently populate this field in event data +- **`ticket.updated` payload is coarse** — includes status, priority, assigneeId on every emit regardless of what changed; does not include teamId or resolutionSummary diff --git a/packages/convex/convex/automationApiInternals.ts b/packages/convex/convex/automationApiInternals.ts index 7ddc0c5..ef00d37 100644 --- a/packages/convex/convex/automationApiInternals.ts +++ b/packages/convex/convex/automationApiInternals.ts @@ -870,11 +870,7 @@ export const updateVisitorForAutomation = internalMutation({ eventType: "visitor.updated", resourceType: "visitor", resourceId: args.visitorId, - data: { - email: args.email ?? visitor.email, - name: args.name ?? visitor.name, - externalUserId: args.externalUserId ?? visitor.externalUserId, - }, + data: { visitorId: args.visitorId }, }); return { id: args.visitorId }; diff --git a/packages/convex/convex/tickets.ts b/packages/convex/convex/tickets.ts index dd308ad..437664f 100644 --- a/packages/convex/convex/tickets.ts +++ b/packages/convex/convex/tickets.ts @@ -769,13 +769,15 @@ export const addComment = mutation({ }); } - await emitAutomationEvent(ctx, { - workspaceId: ticket.workspaceId, - eventType: "ticket.comment_added", - resourceType: "ticket", - resourceId: args.ticketId, - data: { channel: "support_ticket", commentId, authorType, isInternal }, - }); + if (!isInternal) { + await emitAutomationEvent(ctx, { + workspaceId: ticket.workspaceId, + eventType: "ticket.comment_added", + resourceType: "ticket", + resourceId: args.ticketId, + data: { channel: "support_ticket", commentId, authorType }, + }); + } return commentId; }, @@ -828,7 +830,7 @@ export const resolve = authMutation({ eventType: "ticket.updated", resourceType: "ticket", resourceId: args.id, - data: { channel: "support_ticket", status: "resolved", priority: ticket.priority }, + data: { channel: "support_ticket", status: "resolved", priority: ticket.priority, assigneeId: ticket.assigneeId }, }); return args.id; diff --git a/packages/convex/convex/visitors/mutations.ts b/packages/convex/convex/visitors/mutations.ts index 353a45e..db7763a 100644 --- a/packages/convex/convex/visitors/mutations.ts +++ b/packages/convex/convex/visitors/mutations.ts @@ -253,7 +253,7 @@ export const identify = mutation({ eventType: "visitor.updated", resourceType: "visitor", resourceId: canonicalByEmail._id, - data: { email: args.email, name: args.name ?? canonicalByEmail.name ?? visitor.name, externalUserId: args.externalUserId ?? canonicalByEmail.externalUserId ?? visitor.externalUserId }, + data: { visitorId: canonicalByEmail._id }, }); return await ctx.db.get(canonicalByEmail._id); @@ -312,7 +312,7 @@ export const identify = mutation({ eventType: "visitor.updated", resourceType: "visitor", resourceId: resolvedVisitorId, - data: { email: args.email ?? visitor.email, name: args.name ?? visitor.name, externalUserId: args.externalUserId ?? visitor.externalUserId }, + data: { visitorId: resolvedVisitorId }, }); return await ctx.db.get(resolvedVisitorId); diff --git a/packages/convex/tests/automationFixes.test.ts b/packages/convex/tests/automationFixes.test.ts index ac8fe44..dd8a451 100644 --- a/packages/convex/tests/automationFixes.test.ts +++ b/packages/convex/tests/automationFixes.test.ts @@ -2456,4 +2456,362 @@ describe("automation fixes", () => { expect(after!.parentId).toBeUndefined(); }); }); + + // ── Event emission: emitEvent handler ──────────────────────────────── + describe("emitEvent handler", () => { + it("creates an automationEvent row when automation is enabled", async () => { + const ws = await seedWorkspace(); + + await t.mutation(internal.automationEvents.emitEvent, { + workspaceId: ws.workspaceId, + eventType: "ticket.created", + resourceType: "ticket", + resourceId: "t123", + data: { channel: "support_ticket", status: "submitted", priority: "normal" }, + }); + + const events = await t.run(async (ctx) => { + return ctx.db + .query("automationEvents") + .withIndex("by_workspace_timestamp", (q) => + q.eq("workspaceId", ws.workspaceId) + ) + .collect(); + }); + + const ticketEvents = events.filter((e) => e.eventType === "ticket.created"); + expect(ticketEvents).toHaveLength(1); + expect(ticketEvents[0].data).toEqual({ + channel: "support_ticket", + status: "submitted", + priority: "normal", + }); + }); + + it("no-ops when automation is disabled", async () => { + const ws = await t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "No Automation", + createdAt: now, + }); + return { workspaceId }; + }); + + const result = await t.mutation(internal.automationEvents.emitEvent, { + workspaceId: ws.workspaceId, + eventType: "ticket.created", + resourceType: "ticket", + resourceId: "t456", + data: {}, + }); + + expect(result).toEqual({ eventId: null }); + + const events = await t.run(async (ctx) => { + return ctx.db + .query("automationEvents") + .withIndex("by_workspace_timestamp", (q) => + q.eq("workspaceId", ws.workspaceId) + ) + .collect(); + }); + expect(events).toHaveLength(0); + }); + }); + + // ── Event emission: automation API write paths ───────────────────── + describe("automation API write mutations emit events", () => { + // Helper: collect automation events for a workspace after running scheduled functions + async function collectEvents(workspaceId: string) { + await t.finishAllScheduledFunctions(() => { + vi.runAllTimers(); + }); + return t.run(async (ctx) => { + return ctx.db + .query("automationEvents") + .withIndex("by_workspace_timestamp", (q) => + q.eq("workspaceId", workspaceId as any) + ) + .collect(); + }); + } + + it("updateConversationForAutomation emits conversation.updated", async () => { + const ws = await seedWorkspace(); + + const { conversationId } = await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v-session", + createdAt: now, + }); + const conversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + createdAt: now, + updatedAt: now, + }); + return { conversationId }; + }); + + await t.mutation( + internal.automationApiInternals.updateConversationForAutomation, + { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + status: "closed", + } + ); + + const events = await collectEvents(ws.workspaceId); + const convEvents = events.filter((e) => e.eventType === "conversation.updated"); + expect(convEvents).toHaveLength(1); + expect(convEvents[0].resourceId).toBe(conversationId); + expect(convEvents[0].data).toHaveProperty("status", "closed"); + }); + + it("sendMessageIdempotent emits message.created on fresh write", async () => { + const ws = await seedWorkspace(); + + const { conversationId } = await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v-session", + createdAt: now, + }); + const conversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + channel: "email", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("automationConversationClaims", { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + status: "active", + expiresAt: now + 5 * 60 * 1000, + createdAt: now, + }); + return { conversationId }; + }); + + await t.mutation( + internal.automationApiInternals.sendMessageIdempotent, + { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + actorName: "test-bot", + content: "Hello!", + idempotencyKey: "fresh-key", + } + ); + + const events = await collectEvents(ws.workspaceId); + const msgEvents = events.filter((e) => e.eventType === "message.created"); + expect(msgEvents).toHaveLength(1); + expect(msgEvents[0].data).toEqual({ + conversationId, + senderType: "bot", + channel: "email", + }); + }); + + it("sendMessageIdempotent does not emit on cached replay", async () => { + const ws = await seedWorkspace(); + + const { conversationId } = await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v-session", + createdAt: now, + }); + const conversationId = await ctx.db.insert("conversations", { + workspaceId: ws.workspaceId, + visitorId, + status: "open", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("automationConversationClaims", { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + status: "active", + expiresAt: now + 5 * 60 * 1000, + createdAt: now, + }); + return { conversationId }; + }); + + const idempotencyKey = "replay-key"; + + // First call — fresh + await t.mutation( + internal.automationApiInternals.sendMessageIdempotent, + { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + actorName: "test-bot", + content: "Hello!", + idempotencyKey, + } + ); + await t.finishAllScheduledFunctions(() => { vi.runAllTimers(); }); + + // Count events after first call + const eventsAfterFirst = await t.run(async (ctx) => { + return ctx.db + .query("automationEvents") + .withIndex("by_workspace_timestamp", (q) => + q.eq("workspaceId", ws.workspaceId) + ) + .collect(); + }); + const firstCount = eventsAfterFirst.filter( + (e) => e.eventType === "message.created" + ).length; + expect(firstCount).toBe(1); + + // Second call — cached replay + const result2 = await t.mutation( + internal.automationApiInternals.sendMessageIdempotent, + { + workspaceId: ws.workspaceId, + conversationId, + credentialId: ws.credentialId, + actorName: "test-bot", + content: "Hello!", + idempotencyKey, + } + ); + expect(result2.cached).toBe(true); + await t.finishAllScheduledFunctions(() => { vi.runAllTimers(); }); + + // Count events after replay — should still be 1 + const eventsAfterReplay = await t.run(async (ctx) => { + return ctx.db + .query("automationEvents") + .withIndex("by_workspace_timestamp", (q) => + q.eq("workspaceId", ws.workspaceId) + ) + .collect(); + }); + const replayCount = eventsAfterReplay.filter( + (e) => e.eventType === "message.created" + ).length; + expect(replayCount).toBe(1); + }); + + it("updateVisitorForAutomation emits visitor.updated without PII", async () => { + const ws = await seedWorkspace(); + + const { visitorId } = await t.run(async (ctx) => { + const now = Date.now(); + const visitorId = await ctx.db.insert("visitors", { + workspaceId: ws.workspaceId, + sessionId: "v-session", + email: "secret@example.com", + name: "Secret Name", + externalUserId: "ext-123", + createdAt: now, + firstSeenAt: now, + lastSeenAt: now, + }); + return { visitorId }; + }); + + await t.mutation( + internal.automationApiInternals.updateVisitorForAutomation, + { + workspaceId: ws.workspaceId, + visitorId, + name: "Updated Name", + } + ); + + const events = await collectEvents(ws.workspaceId); + const visitorEvents = events.filter((e) => e.eventType === "visitor.updated"); + expect(visitorEvents).toHaveLength(1); + expect(visitorEvents[0].resourceId).toBe(visitorId); + expect(visitorEvents[0].data).toEqual({ visitorId }); + expect(visitorEvents[0].data).not.toHaveProperty("email"); + expect(visitorEvents[0].data).not.toHaveProperty("name"); + expect(visitorEvents[0].data).not.toHaveProperty("externalUserId"); + }); + + it("createTicketForAutomation emits ticket.created", async () => { + const ws = await seedWorkspace(); + + const result = await t.mutation( + internal.automationApiInternals.createTicketForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + subject: "Test ticket", + priority: "high", + } + ); + + const events = await collectEvents(ws.workspaceId); + const ticketEvents = events.filter((e) => e.eventType === "ticket.created"); + expect(ticketEvents).toHaveLength(1); + expect(ticketEvents[0].resourceId).toBe(result.id); + expect(ticketEvents[0].data).toEqual({ + channel: "support_ticket", + status: "submitted", + priority: "high", + }); + }); + + it("updateTicketForAutomation emits ticket.updated", async () => { + const ws = await seedWorkspace(); + + const { ticketId } = await t.run(async (ctx) => { + const now = Date.now(); + const ticketId = await ctx.db.insert("tickets", { + workspaceId: ws.workspaceId, + subject: "Existing ticket", + status: "submitted", + priority: "normal", + createdAt: now, + updatedAt: now, + }); + return { ticketId }; + }); + + await t.mutation( + internal.automationApiInternals.updateTicketForAutomation, + { + workspaceId: ws.workspaceId, + credentialId: ws.credentialId, + ticketId, + status: "in_progress", + priority: "high", + assigneeId: ws.userId, + } + ); + + const events = await collectEvents(ws.workspaceId); + const ticketEvents = events.filter((e) => e.eventType === "ticket.updated"); + expect(ticketEvents).toHaveLength(1); + expect(ticketEvents[0].resourceId).toBe(ticketId); + expect(ticketEvents[0].data).toEqual({ + channel: "support_ticket", + status: "in_progress", + priority: "high", + assigneeId: ws.userId, + }); + }); + }); }); From 534bdbfb28dfabb851550c34712d28dd3213b41d Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:50:50 +0200 Subject: [PATCH 08/13] admin UI initial shot --- .../src/app/settings/AutomationApiSection.tsx | 65 +++++ .../automation-api/CredentialsPanel.tsx | 222 ++++++++++++++++ .../automation-api/DeliveryLogPanel.tsx | 146 +++++++++++ .../settings/automation-api/ScopeSelector.tsx | 133 ++++++++++ .../settings/automation-api/WebhooksPanel.tsx | 244 ++++++++++++++++++ .../settings/hooks/useAutomationApiConvex.ts | 157 +++++++++++ .../hooks/useSettingsPageController.ts | 6 +- .../settings/hooks/useSettingsPageConvex.ts | 1 + apps/web/src/app/settings/page.tsx | 15 ++ apps/web/src/app/settings/settingsSections.ts | 27 +- packages/convex/convex/automationWebhooks.ts | 64 +++++ .../convex/convex/schema/automationTables.ts | 1 + 12 files changed, 1072 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/app/settings/AutomationApiSection.tsx create mode 100644 apps/web/src/app/settings/automation-api/CredentialsPanel.tsx create mode 100644 apps/web/src/app/settings/automation-api/DeliveryLogPanel.tsx create mode 100644 apps/web/src/app/settings/automation-api/ScopeSelector.tsx create mode 100644 apps/web/src/app/settings/automation-api/WebhooksPanel.tsx create mode 100644 apps/web/src/app/settings/hooks/useAutomationApiConvex.ts diff --git a/apps/web/src/app/settings/AutomationApiSection.tsx b/apps/web/src/app/settings/AutomationApiSection.tsx new file mode 100644 index 0000000..e3a7342 --- /dev/null +++ b/apps/web/src/app/settings/AutomationApiSection.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { Card } from "@opencom/ui"; +import { Webhook } from "lucide-react"; +import type { Id } from "@opencom/convex/dataModel"; +import { useAutomationApiConvex } from "./hooks/useAutomationApiConvex"; +import { CredentialsPanel } from "./automation-api/CredentialsPanel"; +import { WebhooksPanel } from "./automation-api/WebhooksPanel"; +import { DeliveryLogPanel } from "./automation-api/DeliveryLogPanel"; + +type Tab = "keys" | "webhooks" | "deliveries"; + +export function AutomationApiSection({ + workspaceId, +}: { + workspaceId?: Id<"workspaces">; +}): React.JSX.Element | null { + const [activeTab, setActiveTab] = useState("keys"); + const api = useAutomationApiConvex(workspaceId); + + if (!workspaceId) return null; + + const tabs: { id: Tab; label: string }[] = [ + { id: "keys", label: "API Keys" }, + { id: "webhooks", label: "Webhooks" }, + { id: "deliveries", label: "Delivery Log" }, + ]; + + return ( + +
+ +

Automation API

+
+ +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === "keys" && ( + + )} + {activeTab === "webhooks" && ( + + )} + {activeTab === "deliveries" && ( + + )} +
+ ); +} diff --git a/apps/web/src/app/settings/automation-api/CredentialsPanel.tsx b/apps/web/src/app/settings/automation-api/CredentialsPanel.tsx new file mode 100644 index 0000000..e673620 --- /dev/null +++ b/apps/web/src/app/settings/automation-api/CredentialsPanel.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useState } from "react"; +import { Button, Input } from "@opencom/ui"; +import { Copy, Check, Key, Plus } from "lucide-react"; +import type { Id } from "@opencom/convex/dataModel"; +import { appConfirm } from "@/lib/appConfirm"; +import type { useAutomationApiConvex } from "../hooks/useAutomationApiConvex"; +import { ScopeSelector } from "./ScopeSelector"; + +type Api = ReturnType; + +function formatRelativeTime(ts: number): string { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function SecretDisplay({ secret }: { secret: string }): React.JSX.Element { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(secret); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+

+ Copy this secret now — it won't be shown again. +

+
+ + {secret} + + +
+
+ ); +} + +function StatusBadge({ status }: { status: string }): React.JSX.Element { + const colors: Record = { + active: "bg-green-100 text-green-700", + disabled: "bg-gray-100 text-gray-600", + expired: "bg-red-100 text-red-700", + }; + return ( + + {status} + + ); +} + +export function CredentialsPanel({ + workspaceId, + api, +}: { + workspaceId: Id<"workspaces">; + api: Api; +}): React.JSX.Element { + const [showCreate, setShowCreate] = useState(false); + const [name, setName] = useState(""); + const [actorName, setActorName] = useState(""); + const [scopes, setScopes] = useState([]); + const [isCreating, setIsCreating] = useState(false); + const [newSecret, setNewSecret] = useState(null); + const [rotatedSecret, setRotatedSecret] = useState<{ id: string; secret: string } | null>(null); + + const credentials = api.credentials ?? []; + + const handleCreate = async () => { + if (!name.trim() || !actorName.trim() || scopes.length === 0) return; + setIsCreating(true); + try { + const result = await api.createCredential({ + workspaceId, + name: name.trim(), + actorName: actorName.trim(), + scopes, + }); + setNewSecret(result.secret); + setName(""); + setActorName(""); + setScopes([]); + setShowCreate(false); + } catch (error) { + console.error("Failed to create credential:", error); + } finally { + setIsCreating(false); + } + }; + + const handleRotate = async (credentialId: Id<"automationCredentials">) => { + if (!(await appConfirm({ + title: "Rotate API Key", + message: "This will invalidate the current secret. Any integrations using it will stop working.", + confirmText: "Rotate", + destructive: true, + }))) return; + + try { + const result = await api.rotateCredential({ workspaceId, credentialId }); + setRotatedSecret({ id: credentialId, secret: result.secret }); + } catch (error) { + console.error("Failed to rotate:", error); + } + }; + + const handleToggle = async (credentialId: Id<"automationCredentials">, currentStatus: string) => { + if (currentStatus === "active") { + await api.disableCredential({ workspaceId, credentialId }); + } else { + await api.enableCredential({ workspaceId, credentialId }); + } + }; + + const handleDelete = async (credentialId: Id<"automationCredentials">) => { + if (!(await appConfirm({ + title: "Delete API Key", + message: "This will permanently delete this API key. This action cannot be undone.", + confirmText: "Delete", + destructive: true, + }))) return; + + await api.removeCredential({ workspaceId, credentialId }); + }; + + if (credentials.length === 0 && !showCreate && !newSecret) { + return ( +
+ +

No API keys

+ +
+ ); + } + + return ( +
+ {newSecret && } + +
+ {!showCreate && ( + + )} +
+ + {showCreate && ( +
+

New API Key

+
+
+ + setName(e.target.value)} placeholder="e.g. Production Integration" /> +
+
+ + setActorName(e.target.value)} placeholder="e.g. CRM Bot" /> +
+
+
+ + +
+
+ + +
+
+ )} + +
+ {credentials.map((cred) => ( +
+
+
+
+ {cred.name} + +
+
+ {cred.secretPrefix}... + {cred.scopes.length} scope{cred.scopes.length !== 1 ? "s" : ""} + {cred.lastUsedAt && Used {formatRelativeTime(cred.lastUsedAt)}} + Created {formatRelativeTime(cred.createdAt)} +
+
+
+ + + +
+
+ {rotatedSecret?.id === cred._id && ( + + )} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/settings/automation-api/DeliveryLogPanel.tsx b/apps/web/src/app/settings/automation-api/DeliveryLogPanel.tsx new file mode 100644 index 0000000..6bdc2ff --- /dev/null +++ b/apps/web/src/app/settings/automation-api/DeliveryLogPanel.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@opencom/ui"; +import { Package } from "lucide-react"; +import type { Id } from "@opencom/convex/dataModel"; +import { useWebQuery } from "@/lib/convex/hooks"; +import { appConfirm } from "@/lib/appConfirm"; +import type { useAutomationApiConvex } from "../hooks/useAutomationApiConvex"; + +type Api = ReturnType; + +function formatRelativeTime(ts: number): string { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function StatusBadge({ status }: { status: string }): React.JSX.Element { + const colors: Record = { + pending: "bg-blue-100 text-blue-700", + success: "bg-green-100 text-green-700", + failed: "bg-red-100 text-red-700", + retrying: "bg-amber-100 text-amber-700", + }; + return ( + + {status} + + ); +} + +export function DeliveryLogPanel({ + workspaceId, + api, +}: { + workspaceId: Id<"workspaces">; + api: Api; +}): React.JSX.Element { + const [filterSubscriptionId, setFilterSubscriptionId] = useState(""); + const [filterStatus, setFilterStatus] = useState(""); + + const subscriptions = api.subscriptions ?? []; + + const deliveryArgs: Record = { workspaceId }; + if (filterSubscriptionId) { + deliveryArgs.subscriptionId = filterSubscriptionId; + } + if (filterStatus) { + deliveryArgs.status = filterStatus; + } + + const deliveries = useWebQuery(api.deliveriesListRef, deliveryArgs as any); + + const subscriptionUrlMap = new Map( + subscriptions.map((s) => [s._id, s.url]) + ); + + const handleReplay = async (deliveryId: Id<"automationWebhookDeliveries">) => { + if (!(await appConfirm({ + title: "Replay Delivery", + message: "This will re-send this webhook delivery.", + confirmText: "Replay", + }))) return; + + try { + await api.replayDelivery({ workspaceId, deliveryId }); + } catch (error) { + console.error("Failed to replay delivery:", error); + } + }; + + return ( +
+
+ + + +
+ + {deliveries === undefined ? ( +

Loading...

+ ) : deliveries.length === 0 ? ( +
+ +

No deliveries yet

+
+ ) : ( +
+ {deliveries.map((d) => ( +
+
+ + + {subscriptionUrlMap.get(d.subscriptionId) ?? d.subscriptionId} + + #{d.attemptNumber} + {d.httpStatus && ( + HTTP {d.httpStatus} + )} + {d.error && ( + {d.error} + )} +
+
+ + {formatRelativeTime(d.createdAt)} + + {(d.status === "failed" || d.status === "retrying") && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/settings/automation-api/ScopeSelector.tsx b/apps/web/src/app/settings/automation-api/ScopeSelector.tsx new file mode 100644 index 0000000..a68950a --- /dev/null +++ b/apps/web/src/app/settings/automation-api/ScopeSelector.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useMemo } from "react"; + +const SCOPE_GROUPS: { resource: string; scopes: { scope: string; label: string }[] }[] = [ + { + resource: "Conversations", + scopes: [ + { scope: "conversations.read", label: "Read" }, + { scope: "conversations.write", label: "Write" }, + ], + }, + { + resource: "Messages", + scopes: [ + { scope: "messages.read", label: "Read" }, + { scope: "messages.write", label: "Write" }, + ], + }, + { + resource: "Visitors", + scopes: [ + { scope: "visitors.read", label: "Read" }, + { scope: "visitors.write", label: "Write" }, + ], + }, + { + resource: "Tickets", + scopes: [ + { scope: "tickets.read", label: "Read" }, + { scope: "tickets.write", label: "Write" }, + ], + }, + { + resource: "Events", + scopes: [ + { scope: "events.read", label: "Read" }, + { scope: "events.write", label: "Write" }, + ], + }, + { + resource: "Articles", + scopes: [ + { scope: "articles.read", label: "Read" }, + { scope: "articles.write", label: "Write" }, + ], + }, + { + resource: "Collections", + scopes: [ + { scope: "collections.read", label: "Read" }, + { scope: "collections.write", label: "Write" }, + ], + }, + { + resource: "Webhooks", + scopes: [{ scope: "webhooks.manage", label: "Manage" }], + }, + { + resource: "Claims", + scopes: [{ scope: "claims.manage", label: "Manage" }], + }, +]; + +const ALL_SCOPES = SCOPE_GROUPS.flatMap((g) => g.scopes.map((s) => s.scope)); +const READ_ONLY_SCOPES = ALL_SCOPES.filter((s) => s.endsWith(".read")); + +export function ScopeSelector({ + value, + onChange, +}: { + value: string[]; + onChange: (scopes: string[]) => void; +}): React.JSX.Element { + const selected = useMemo(() => new Set(value), [value]); + + const toggle = (scope: string) => { + const next = new Set(selected); + if (next.has(scope)) { + next.delete(scope); + } else { + next.add(scope); + } + onChange(Array.from(next)); + }; + + return ( +
+
+ + + +
+ +
+ {SCOPE_GROUPS.map((group) => ( +
+ {group.resource} + {group.scopes.map((s) => ( + + ))} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/settings/automation-api/WebhooksPanel.tsx b/apps/web/src/app/settings/automation-api/WebhooksPanel.tsx new file mode 100644 index 0000000..be34476 --- /dev/null +++ b/apps/web/src/app/settings/automation-api/WebhooksPanel.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useState } from "react"; +import { Button, Input } from "@opencom/ui"; +import { Copy, Check, Plus, Webhook } from "lucide-react"; +import type { Id } from "@opencom/convex/dataModel"; +import { appConfirm } from "@/lib/appConfirm"; +import type { useAutomationApiConvex, SubscriptionRecord } from "../hooks/useAutomationApiConvex"; + +type Api = ReturnType; + +function formatRelativeTime(ts: number): string { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function SecretDisplay({ secret }: { secret: string }): React.JSX.Element { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(secret); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( +
+

+ Copy this signing secret now — it won't be shown again. +

+
+ + {secret} + + +
+
+ ); +} + +function StatusBadge({ status }: { status: string }): React.JSX.Element { + const colors: Record = { + active: "bg-green-100 text-green-700", + paused: "bg-amber-100 text-amber-700", + disabled: "bg-gray-100 text-gray-600", + }; + return ( + + {status} + + ); +} + +function WebhookRow({ + sub, + workspaceId, + api, +}: { + sub: SubscriptionRecord; + workspaceId: Id<"workspaces">; + api: Api; +}): React.JSX.Element { + const [editing, setEditing] = useState(false); + const [editUrl, setEditUrl] = useState(sub.url); + const [isSaving, setIsSaving] = useState(false); + + const handlePauseResume = async () => { + const newStatus = sub.status === "active" ? "paused" : "active"; + await api.updateSubscription({ + workspaceId, + subscriptionId: sub._id, + status: newStatus, + }); + }; + + const handleTest = async () => { + await api.testSubscription({ workspaceId, subscriptionId: sub._id }); + }; + + const handleDelete = async () => { + if (!(await appConfirm({ + title: "Delete Webhook", + message: "This will permanently delete this webhook subscription.", + confirmText: "Delete", + destructive: true, + }))) return; + await api.deleteSubscription({ workspaceId, subscriptionId: sub._id }); + }; + + const handleSaveEdit = async () => { + if (!editUrl.trim()) return; + setIsSaving(true); + try { + await api.updateSubscription({ + workspaceId, + subscriptionId: sub._id, + url: editUrl.trim(), + }); + setEditing(false); + } finally { + setIsSaving(false); + } + }; + + const filterSummary = [ + ...(sub.eventTypes?.length ? [`${sub.eventTypes.length} events`] : []), + ...(sub.resourceTypes?.length ? [`${sub.resourceTypes.length} resources`] : []), + ].join(", ") || "all events"; + + return ( +
+
+
+
+ {sub.url} + +
+
+ {filterSummary} + {sub.signingSecretPrefix}... + Created {formatRelativeTime(sub.createdAt)} +
+
+
+ + + + +
+
+ + {editing && ( +
+
+ + setEditUrl(e.target.value)} /> +
+
+ + +
+
+ )} +
+ ); +} + +export function WebhooksPanel({ + workspaceId, + api, +}: { + workspaceId: Id<"workspaces">; + api: Api; +}): React.JSX.Element { + const [showCreate, setShowCreate] = useState(false); + const [url, setUrl] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [newSecret, setNewSecret] = useState(null); + + const subscriptions = api.subscriptions ?? []; + + const handleCreate = async () => { + if (!url.trim()) return; + setIsCreating(true); + try { + const result = await api.createSubscription({ + workspaceId, + url: url.trim(), + }); + setNewSecret(result.signingSecret); + setUrl(""); + setShowCreate(false); + } catch (error) { + console.error("Failed to create webhook:", error); + } finally { + setIsCreating(false); + } + }; + + if (subscriptions.length === 0 && !showCreate && !newSecret) { + return ( +
+ +

No webhooks

+ +
+ ); + } + + return ( +
+ {newSecret && } + +
+ {!showCreate && ( + + )} +
+ + {showCreate && ( +
+

New Webhook

+
+ + setUrl(e.target.value)} placeholder="https://example.com/webhooks" /> +
+
+ + +
+
+ )} + +
+ {subscriptions.map((sub) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/app/settings/hooks/useAutomationApiConvex.ts b/apps/web/src/app/settings/hooks/useAutomationApiConvex.ts new file mode 100644 index 0000000..0573773 --- /dev/null +++ b/apps/web/src/app/settings/hooks/useAutomationApiConvex.ts @@ -0,0 +1,157 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { useWebMutation, useWebQuery, webMutationRef, webQueryRef } from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CredentialRecord = { + _id: Id<"automationCredentials">; + name: string; + secretPrefix: string; + scopes: string[]; + status: "active" | "disabled" | "expired"; + expiresAt?: number; + actorName: string; + lastUsedAt?: number; + createdAt: number; +}; + +type SubscriptionRecord = { + _id: Id<"automationWebhookSubscriptions">; + url: string; + signingSecretPrefix: string; + eventTypes?: string[]; + resourceTypes?: string[]; + channels?: string[]; + aiWorkflowStates?: string[]; + status: "active" | "paused" | "disabled"; + createdAt: number; +}; + +type DeliveryRecord = { + _id: Id<"automationWebhookDeliveries">; + subscriptionId: Id<"automationWebhookSubscriptions">; + eventId: Id<"automationEvents">; + attemptNumber: number; + status: "pending" | "success" | "failed" | "retrying"; + httpStatus?: number; + error?: string; + createdAt: number; +}; + +type ListDeliveriesArgs = { + workspaceId: Id<"workspaces">; + subscriptionId?: Id<"automationWebhookSubscriptions">; + status?: "pending" | "success" | "failed" | "retrying"; + limit?: number; +}; + +const CREDENTIALS_LIST_REF = webQueryRef( + "automationCredentials:list" +); +const SUBSCRIPTIONS_LIST_REF = webQueryRef( + "automationWebhooks:listSubscriptions" +); +const DELIVERIES_LIST_REF = webQueryRef( + "automationWebhooks:listDeliveries" +); + +const CREATE_CREDENTIAL_REF = webMutationRef< + { + workspaceId: Id<"workspaces">; + name: string; + scopes: string[]; + actorName: string; + expiresAt?: number; + }, + { credentialId: Id<"automationCredentials">; secret: string } +>("automationCredentials:create"); + +const ROTATE_CREDENTIAL_REF = webMutationRef< + { workspaceId: Id<"workspaces">; credentialId: Id<"automationCredentials"> }, + { secret: string } +>("automationCredentials:rotate"); + +const DISABLE_CREDENTIAL_REF = webMutationRef< + { workspaceId: Id<"workspaces">; credentialId: Id<"automationCredentials"> }, + { success: boolean } +>("automationCredentials:disable"); + +const ENABLE_CREDENTIAL_REF = webMutationRef< + { workspaceId: Id<"workspaces">; credentialId: Id<"automationCredentials"> }, + { success: boolean } +>("automationCredentials:enable"); + +const REMOVE_CREDENTIAL_REF = webMutationRef< + { workspaceId: Id<"workspaces">; credentialId: Id<"automationCredentials"> }, + { success: boolean } +>("automationCredentials:remove"); + +const CREATE_SUBSCRIPTION_REF = webMutationRef< + { + workspaceId: Id<"workspaces">; + url: string; + eventTypes?: string[]; + resourceTypes?: string[]; + }, + { subscriptionId: Id<"automationWebhookSubscriptions">; signingSecret: string } +>("automationWebhooks:createSubscription"); + +const UPDATE_SUBSCRIPTION_REF = webMutationRef< + { + workspaceId: Id<"workspaces">; + subscriptionId: Id<"automationWebhookSubscriptions">; + url?: string; + eventTypes?: string[]; + resourceTypes?: string[]; + status?: "active" | "paused" | "disabled"; + }, + { success: boolean } +>("automationWebhooks:updateSubscription"); + +const DELETE_SUBSCRIPTION_REF = webMutationRef< + { workspaceId: Id<"workspaces">; subscriptionId: Id<"automationWebhookSubscriptions"> }, + { success: boolean } +>("automationWebhooks:deleteSubscription"); + +const TEST_SUBSCRIPTION_REF = webMutationRef< + { workspaceId: Id<"workspaces">; subscriptionId: Id<"automationWebhookSubscriptions"> }, + { success: boolean; message: string } +>("automationWebhooks:testSubscription"); + +const REPLAY_DELIVERY_REF = webMutationRef< + { workspaceId: Id<"workspaces">; deliveryId: Id<"automationWebhookDeliveries"> }, + { success: boolean } +>("automationWebhooks:replayDelivery"); + +export function useAutomationApiConvex(workspaceId?: Id<"workspaces">) { + const credentials = useWebQuery( + CREDENTIALS_LIST_REF, + workspaceId ? { workspaceId } : "skip" + ); + const subscriptions = useWebQuery( + SUBSCRIPTIONS_LIST_REF, + workspaceId ? { workspaceId } : "skip" + ); + + return { + credentials, + subscriptions, + createCredential: useWebMutation(CREATE_CREDENTIAL_REF), + rotateCredential: useWebMutation(ROTATE_CREDENTIAL_REF), + disableCredential: useWebMutation(DISABLE_CREDENTIAL_REF), + enableCredential: useWebMutation(ENABLE_CREDENTIAL_REF), + removeCredential: useWebMutation(REMOVE_CREDENTIAL_REF), + createSubscription: useWebMutation(CREATE_SUBSCRIPTION_REF), + updateSubscription: useWebMutation(UPDATE_SUBSCRIPTION_REF), + deleteSubscription: useWebMutation(DELETE_SUBSCRIPTION_REF), + testSubscription: useWebMutation(TEST_SUBSCRIPTION_REF), + replayDelivery: useWebMutation(REPLAY_DELIVERY_REF), + deliveriesListRef: DELIVERIES_LIST_REF, + }; +} + +export type { CredentialRecord, SubscriptionRecord, DeliveryRecord }; diff --git a/apps/web/src/app/settings/hooks/useSettingsPageController.ts b/apps/web/src/app/settings/hooks/useSettingsPageController.ts index 1b571e8..4961ae1 100644 --- a/apps/web/src/app/settings/hooks/useSettingsPageController.ts +++ b/apps/web/src/app/settings/hooks/useSettingsPageController.ts @@ -242,9 +242,12 @@ export function useSettingsPageController() { if (sectionId === "email-channel") { return isAdmin; } + if (sectionId === "automation-api") { + return isAdmin && workspace?.automationApiEnabled; + } return true; }); - }, [isAdmin]); + }, [isAdmin, workspace?.automationApiEnabled]); const visibleSections = useMemo( () => SETTINGS_SECTION_CONFIG.filter((section) => visibleSectionIds.includes(section.id)), @@ -347,6 +350,7 @@ export function useSettingsPageController() { ? ("success" as const) : ("neutral" as const), }, + "automation-api": { label: "Managed", tone: "neutral" as const }, installations: { label: "In onboarding", tone: "neutral" as const }, "backend-connection": { label: activeBackend?.name ? "Connected" : "Unknown", diff --git a/apps/web/src/app/settings/hooks/useSettingsPageConvex.ts b/apps/web/src/app/settings/hooks/useSettingsPageConvex.ts index 2d837f8..fef71e8 100644 --- a/apps/web/src/app/settings/hooks/useSettingsPageConvex.ts +++ b/apps/web/src/app/settings/hooks/useSettingsPageConvex.ts @@ -19,6 +19,7 @@ export type WorkspaceSettingsRecord = { allowedDomains?: string[]; helpCenterAccessPolicy?: "public" | "restricted"; authMethods?: Array<"password" | "otp">; + automationApiEnabled?: boolean; } | null; export type EmailConfigRecord = { diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 012e6b3..1427421 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -19,6 +19,7 @@ import { import { SignupAuthSection } from "./SignupAuthSection"; import { HelpCenterAccessSection } from "./HelpCenterAccessSection"; import { EmailChannelSection } from "./EmailChannelSection"; +import { AutomationApiSection } from "./AutomationApiSection"; import { useSettingsPageController } from "./hooks/useSettingsPageController"; function SettingsContent(): React.JSX.Element | null { @@ -270,6 +271,20 @@ function SettingsContent(): React.JSX.Element | null { )} + {isAdmin && workspace?.automationApiEnabled && ( + toggleSection("automation-api")} + > + + + )} + ("automationEvents:emitEvent"); +const replayDeliveryInternalRef = makeFunctionReference<"mutation">( + "automationWebhookWorker:replayDelivery" +); function generateSigningSecret(): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -159,3 +162,64 @@ export const testSubscription = authMutation({ return { success: true, message: "Test event queued" }; }, }); + +export const listDeliveries = authQuery({ + args: { + workspaceId: v.id("workspaces"), + subscriptionId: v.optional(v.id("automationWebhookSubscriptions")), + status: v.optional( + v.union(v.literal("pending"), v.literal("success"), v.literal("failed"), v.literal("retrying")) + ), + limit: v.optional(v.number()), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const limit = args.limit ?? 50; + + const deliveries = await ctx.db + .query("automationWebhookDeliveries") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .order("desc") + .collect(); + + const filtered = deliveries + .filter((d) => { + if (args.subscriptionId && d.subscriptionId !== args.subscriptionId) return false; + if (args.status && d.status !== args.status) return false; + return true; + }) + .slice(0, limit); + + return filtered.map((d) => ({ + _id: d._id, + subscriptionId: d.subscriptionId, + eventId: d.eventId, + attemptNumber: d.attemptNumber, + status: d.status, + httpStatus: d.httpStatus, + error: d.error, + createdAt: d.createdAt, + })); + }, +}); + +export const replayDelivery = authMutation({ + args: { + workspaceId: v.id("workspaces"), + deliveryId: v.id("automationWebhookDeliveries"), + }, + permission: "settings.integrations", + handler: async (ctx, args) => { + const delivery = await ctx.db.get(args.deliveryId); + if (!delivery || delivery.workspaceId !== args.workspaceId) { + throw new Error("Delivery not found"); + } + + await ctx.scheduler.runAfter(0, replayDeliveryInternalRef as any, { + deliveryId: args.deliveryId, + workspaceId: args.workspaceId, + }); + + return { success: true }; + }, +}); diff --git a/packages/convex/convex/schema/automationTables.ts b/packages/convex/convex/schema/automationTables.ts index 3577f79..cfe391d 100644 --- a/packages/convex/convex/schema/automationTables.ts +++ b/packages/convex/convex/schema/automationTables.ts @@ -68,6 +68,7 @@ export const automationTables = { .index("by_subscription", ["subscriptionId"]) .index("by_subscription_event", ["subscriptionId", "eventId"]) .index("by_event", ["eventId"]) + .index("by_workspace", ["workspaceId", "createdAt"]) .index("by_status", ["status"]) .index("by_next_retry", ["status", "nextRetryAt"]), From 002606098198415f0cba1f5d75208b5bab3ebddb Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:02:43 +0200 Subject: [PATCH 09/13] add settings UI guardrails for webhooks, deliveries, and admin errors --- .../automation-api/CredentialsPanel.tsx | 40 +++- .../automation-api/DeliveryLogPanel.tsx | 40 ++-- .../settings/automation-api/WebhooksPanel.tsx | 180 ++++++++++++++++-- .../settings/hooks/useAutomationApiConvex.ts | 2 +- packages/convex/convex/automationWebhooks.ts | 42 +++- 5 files changed, 257 insertions(+), 47 deletions(-) diff --git a/apps/web/src/app/settings/automation-api/CredentialsPanel.tsx b/apps/web/src/app/settings/automation-api/CredentialsPanel.tsx index e673620..482e8d5 100644 --- a/apps/web/src/app/settings/automation-api/CredentialsPanel.tsx +++ b/apps/web/src/app/settings/automation-api/CredentialsPanel.tsx @@ -73,11 +73,13 @@ export function CredentialsPanel({ const [isCreating, setIsCreating] = useState(false); const [newSecret, setNewSecret] = useState(null); const [rotatedSecret, setRotatedSecret] = useState<{ id: string; secret: string } | null>(null); + const [errorMessage, setErrorMessage] = useState(null); - const credentials = api.credentials ?? []; + const credentials = api.credentials; const handleCreate = async () => { if (!name.trim() || !actorName.trim() || scopes.length === 0) return; + setErrorMessage(null); setIsCreating(true); try { const result = await api.createCredential({ @@ -92,7 +94,7 @@ export function CredentialsPanel({ setScopes([]); setShowCreate(false); } catch (error) { - console.error("Failed to create credential:", error); + setErrorMessage(error instanceof Error ? error.message : "Failed to create API key"); } finally { setIsCreating(false); } @@ -106,19 +108,25 @@ export function CredentialsPanel({ destructive: true, }))) return; + setErrorMessage(null); try { const result = await api.rotateCredential({ workspaceId, credentialId }); setRotatedSecret({ id: credentialId, secret: result.secret }); } catch (error) { - console.error("Failed to rotate:", error); + setErrorMessage(error instanceof Error ? error.message : "Failed to rotate key"); } }; const handleToggle = async (credentialId: Id<"automationCredentials">, currentStatus: string) => { - if (currentStatus === "active") { - await api.disableCredential({ workspaceId, credentialId }); - } else { - await api.enableCredential({ workspaceId, credentialId }); + setErrorMessage(null); + try { + if (currentStatus === "active") { + await api.disableCredential({ workspaceId, credentialId }); + } else { + await api.enableCredential({ workspaceId, credentialId }); + } + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to update key status"); } }; @@ -130,9 +138,18 @@ export function CredentialsPanel({ destructive: true, }))) return; - await api.removeCredential({ workspaceId, credentialId }); + setErrorMessage(null); + try { + await api.removeCredential({ workspaceId, credentialId }); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to delete key"); + } }; + if (credentials === undefined) { + return

Loading API keys...

; + } + if (credentials.length === 0 && !showCreate && !newSecret) { return (
@@ -147,6 +164,13 @@ export function CredentialsPanel({ return (
+ {errorMessage && ( +
+ {errorMessage} + +
+ )} + {newSecret && }
diff --git a/apps/web/src/app/settings/automation-api/DeliveryLogPanel.tsx b/apps/web/src/app/settings/automation-api/DeliveryLogPanel.tsx index 6bdc2ff..6435bad 100644 --- a/apps/web/src/app/settings/automation-api/DeliveryLogPanel.tsx +++ b/apps/web/src/app/settings/automation-api/DeliveryLogPanel.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button } from "@opencom/ui"; import { Package } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; import { useWebQuery } from "@/lib/convex/hooks"; import { appConfirm } from "@/lib/appConfirm"; import type { useAutomationApiConvex } from "../hooks/useAutomationApiConvex"; +import type { ListDeliveriesArgs } from "../hooks/useAutomationApiConvex"; type Api = ReturnType; @@ -43,21 +44,26 @@ export function DeliveryLogPanel({ }): React.JSX.Element { const [filterSubscriptionId, setFilterSubscriptionId] = useState(""); const [filterStatus, setFilterStatus] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); const subscriptions = api.subscriptions ?? []; - const deliveryArgs: Record = { workspaceId }; - if (filterSubscriptionId) { - deliveryArgs.subscriptionId = filterSubscriptionId; - } - if (filterStatus) { - deliveryArgs.status = filterStatus; - } + const deliveryArgs = useMemo((): ListDeliveriesArgs => { + const args: ListDeliveriesArgs = { workspaceId }; + if (filterSubscriptionId) { + args.subscriptionId = filterSubscriptionId as Id<"automationWebhookSubscriptions">; + } + if (filterStatus) { + args.status = filterStatus as ListDeliveriesArgs["status"]; + } + return args; + }, [workspaceId, filterSubscriptionId, filterStatus]); - const deliveries = useWebQuery(api.deliveriesListRef, deliveryArgs as any); + const deliveries = useWebQuery(api.deliveriesListRef, deliveryArgs); - const subscriptionUrlMap = new Map( - subscriptions.map((s) => [s._id, s.url]) + const subscriptionUrlMap = useMemo( + () => new Map(subscriptions.map((s) => [s._id, s.url])), + [subscriptions] ); const handleReplay = async (deliveryId: Id<"automationWebhookDeliveries">) => { @@ -67,15 +73,23 @@ export function DeliveryLogPanel({ confirmText: "Replay", }))) return; + setErrorMessage(null); try { await api.replayDelivery({ workspaceId, deliveryId }); } catch (error) { - console.error("Failed to replay delivery:", error); + setErrorMessage(error instanceof Error ? error.message : "Failed to replay delivery"); } }; return (
+ {errorMessage && ( +
+ {errorMessage} + +
+ )} +
toggle(opt)} + className="rounded border-gray-300" + /> + {opt} + + ))} +
+ {selected.length > 0 && ( + + )} +
+ ); +} + function WebhookRow({ sub, workspaceId, api, + onError, }: { sub: SubscriptionRecord; workspaceId: Id<"workspaces">; api: Api; + onError: (msg: string) => void; }): React.JSX.Element { const [editing, setEditing] = useState(false); const [editUrl, setEditUrl] = useState(sub.url); + const [editEventTypes, setEditEventTypes] = useState(sub.eventTypes ?? []); + const [editResourceTypes, setEditResourceTypes] = useState(sub.resourceTypes ?? []); const [isSaving, setIsSaving] = useState(false); const handlePauseResume = async () => { - const newStatus = sub.status === "active" ? "paused" : "active"; - await api.updateSubscription({ - workspaceId, - subscriptionId: sub._id, - status: newStatus, - }); + try { + const newStatus = sub.status === "active" ? "paused" : "active"; + await api.updateSubscription({ + workspaceId, + subscriptionId: sub._id, + status: newStatus, + }); + } catch (error) { + onError(`Failed to ${sub.status === "active" ? "pause" : "resume"} webhook: ${error instanceof Error ? error.message : "Unknown error"}`); + } }; const handleTest = async () => { - await api.testSubscription({ workspaceId, subscriptionId: sub._id }); + try { + await api.testSubscription({ workspaceId, subscriptionId: sub._id }); + } catch (error) { + onError(`Failed to send test: ${error instanceof Error ? error.message : "Unknown error"}`); + } }; const handleDelete = async () => { @@ -89,24 +173,43 @@ function WebhookRow({ confirmText: "Delete", destructive: true, }))) return; - await api.deleteSubscription({ workspaceId, subscriptionId: sub._id }); + try { + await api.deleteSubscription({ workspaceId, subscriptionId: sub._id }); + } catch (error) { + onError(`Failed to delete webhook: ${error instanceof Error ? error.message : "Unknown error"}`); + } }; const handleSaveEdit = async () => { if (!editUrl.trim()) return; + if (!editUrl.trim().startsWith("https://")) { + onError("Webhook URL must use HTTPS"); + return; + } setIsSaving(true); try { await api.updateSubscription({ workspaceId, subscriptionId: sub._id, url: editUrl.trim(), + eventTypes: editEventTypes.length > 0 ? editEventTypes : undefined, + resourceTypes: editResourceTypes.length > 0 ? editResourceTypes : undefined, }); setEditing(false); + } catch (error) { + onError(`Failed to save webhook: ${error instanceof Error ? error.message : "Unknown error"}`); } finally { setIsSaving(false); } }; + const handleCancelEdit = () => { + setEditing(false); + setEditUrl(sub.url); + setEditEventTypes(sub.eventTypes ?? []); + setEditResourceTypes(sub.resourceTypes ?? []); + }; + const filterSummary = [ ...(sub.eventTypes?.length ? [`${sub.eventTypes.length} events`] : []), ...(sub.resourceTypes?.length ? [`${sub.resourceTypes.length} resources`] : []), @@ -143,16 +246,28 @@ function WebhookRow({
{editing && ( -
+
setEditUrl(e.target.value)} />
+ +
- -
@@ -171,29 +286,45 @@ export function WebhooksPanel({ }): React.JSX.Element { const [showCreate, setShowCreate] = useState(false); const [url, setUrl] = useState(""); + const [createEventTypes, setCreateEventTypes] = useState([]); + const [createResourceTypes, setCreateResourceTypes] = useState([]); const [isCreating, setIsCreating] = useState(false); const [newSecret, setNewSecret] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); - const subscriptions = api.subscriptions ?? []; + const subscriptions = api.subscriptions; const handleCreate = async () => { if (!url.trim()) return; + if (!url.trim().startsWith("https://")) { + setErrorMessage("Webhook URL must use HTTPS"); + return; + } + setErrorMessage(null); setIsCreating(true); try { const result = await api.createSubscription({ workspaceId, url: url.trim(), + eventTypes: createEventTypes.length > 0 ? createEventTypes : undefined, + resourceTypes: createResourceTypes.length > 0 ? createResourceTypes : undefined, }); setNewSecret(result.signingSecret); setUrl(""); + setCreateEventTypes([]); + setCreateResourceTypes([]); setShowCreate(false); } catch (error) { - console.error("Failed to create webhook:", error); + setErrorMessage(error instanceof Error ? error.message : "Failed to create webhook"); } finally { setIsCreating(false); } }; + if (subscriptions === undefined) { + return

Loading webhooks...

; + } + if (subscriptions.length === 0 && !showCreate && !newSecret) { return (
@@ -208,6 +339,13 @@ export function WebhooksPanel({ return (
+ {errorMessage && ( +
+ {errorMessage} + +
+ )} + {newSecret && }
@@ -225,18 +363,30 @@ export function WebhooksPanel({ setUrl(e.target.value)} placeholder="https://example.com/webhooks" />
+ +
- +
)}
{subscriptions.map((sub) => ( - + ))}
diff --git a/apps/web/src/app/settings/hooks/useAutomationApiConvex.ts b/apps/web/src/app/settings/hooks/useAutomationApiConvex.ts index 0573773..df5ad92 100644 --- a/apps/web/src/app/settings/hooks/useAutomationApiConvex.ts +++ b/apps/web/src/app/settings/hooks/useAutomationApiConvex.ts @@ -154,4 +154,4 @@ export function useAutomationApiConvex(workspaceId?: Id<"workspaces">) { }; } -export type { CredentialRecord, SubscriptionRecord, DeliveryRecord }; +export type { CredentialRecord, SubscriptionRecord, DeliveryRecord, ListDeliveriesArgs }; diff --git a/packages/convex/convex/automationWebhooks.ts b/packages/convex/convex/automationWebhooks.ts index 56e2e77..536daad 100644 --- a/packages/convex/convex/automationWebhooks.ts +++ b/packages/convex/convex/automationWebhooks.ts @@ -31,6 +31,10 @@ export const createSubscription = authMutation({ }, permission: "settings.integrations", handler: async (ctx, args) => { + if (!args.url.startsWith("https://")) { + throw new Error("Webhook URL must use HTTPS"); + } + const signingSecret = generateSigningSecret(); const signingSecretPrefix = signingSecret.slice(0, 14); // "whsec_" + 8 chars @@ -108,6 +112,10 @@ export const updateSubscription = authMutation({ throw new Error("Subscription not found"); } + if (args.url !== undefined && !args.url.startsWith("https://")) { + throw new Error("Webhook URL must use HTTPS"); + } + const updates: Record = {}; if (args.url !== undefined) updates.url = args.url; if (args.eventTypes !== undefined) updates.eventTypes = args.eventTypes; @@ -175,20 +183,27 @@ export const listDeliveries = authQuery({ permission: "settings.integrations", handler: async (ctx, args) => { const limit = args.limit ?? 50; + const hasFilters = args.subscriptionId !== undefined || args.status !== undefined; + + // When no filters, take exactly what we need. When filtering, scan a + // bounded window (10x limit) so we never pull the entire table into memory. + const scanCap = hasFilters ? limit * 10 : limit; - const deliveries = await ctx.db + const scanned = await ctx.db .query("automationWebhookDeliveries") .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) .order("desc") - .collect(); - - const filtered = deliveries - .filter((d) => { - if (args.subscriptionId && d.subscriptionId !== args.subscriptionId) return false; - if (args.status && d.status !== args.status) return false; - return true; - }) - .slice(0, limit); + .take(scanCap); + + const filtered = hasFilters + ? scanned + .filter((d) => { + if (args.subscriptionId && d.subscriptionId !== args.subscriptionId) return false; + if (args.status && d.status !== args.status) return false; + return true; + }) + .slice(0, limit) + : scanned; return filtered.map((d) => ({ _id: d._id, @@ -215,6 +230,13 @@ export const replayDelivery = authMutation({ throw new Error("Delivery not found"); } + if (delivery.status === "success") { + throw new Error("Cannot replay a successful delivery"); + } + if (delivery.status === "pending") { + throw new Error("Cannot replay a pending delivery"); + } + await ctx.scheduler.runAfter(0, replayDeliveryInternalRef as any, { deliveryId: args.deliveryId, workspaceId: args.workspaceId, From b6b3a435a0df6cd9be2c145c8500af353e752149 Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:04:01 +0200 Subject: [PATCH 10/13] mark tasks as complete --- .../expose-automation-api-and-event-webhooks/tasks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md index 0a0ff48..c8d6a15 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md @@ -2,7 +2,7 @@ - [x] 1.1 Add persistence and shared services for automation credentials, automation actors, conversation claims, automation events, webhook subscriptions, and delivery attempts. - [x] 1.2 Implement HTTP auth, scope enforcement, secret hashing, one-time secret reveal, rate limiting, and idempotency middleware for automation routes. -- [ ] 1.3 Define the v1 resource and event coverage matrix used by implementation, docs, and rollout gating. +- [x] 1.3 Define the v1 resource and event coverage matrix used by implementation, docs, and rollout gating. ## 2. Resource API Surface @@ -17,7 +17,7 @@ ## 3. Event Feed And Webhook Delivery - [x] 3.1 Implement a canonical automation event ledger with emitEvent internal mutation. -- [ ] 3.1b Wire emitEvent calls into existing domain files (conversations, messages, tickets, visitors) so events are actually emitted on resource changes. +- [x] 3.1b Wire emitEvent calls into existing domain files (conversations, messages, tickets, visitors) so events are actually emitted on resource changes. - [x] 3.2 Expose a polling endpoint that reads the canonical event stream. - [x] 3.3 Implement webhook subscription management, HMAC signatures, retry/backoff scheduling, delivery attempt storage, and manual replay. @@ -30,7 +30,7 @@ ## 5. Admin Experience And Documentation -- [ ] 5.1 Build admin settings UI for credential management, scope review, webhook endpoints, delivery logs, and replay actions. +- [x] 5.1 Build admin settings UI for credential management, scope review, webhook endpoints, delivery logs, and replay actions. - [ ] 5.2 Update developer and security docs for authentication, scopes, rate limits, idempotency, webhook verification, event semantics, and rollout limitations. - [x] 5.3 Gate the feature behind workspace flags (`automationApiEnabled` on workspaces table). - [ ] 5.3b Add rollout instrumentation for request volume, webhook failures, and automation conflict rates. From d86c9f5c6ef684e884c737736a2b2fd03e80b8e9 Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:24:07 +0200 Subject: [PATCH 11/13] docs initial shot --- docs/api-reference.md | 241 +++++++++++++++++- docs/security.md | 124 ++++++++- .../tasks.md | 2 +- 3 files changed, 353 insertions(+), 14 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 9b7deb4..3176edf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -4,10 +4,11 @@ This document covers the Convex backend API surface. All functions live in `pack ## Authentication -All endpoints use one of two authentication paths: +All endpoints use one of three authentication paths: - **Agent/admin**: Authenticated via Convex Auth session (JWT). Permission-checked via `requirePermission()`. - **Visitor**: Authenticated via signed session token (`sessionToken`). Validated via `resolveVisitorFromSession()`. +- **Automation API**: Authenticated via bearer token (`Authorization: Bearer osk_`). Scope-checked per endpoint. See [Automation API](#automation-api) below. Unauthenticated callers receive null/empty results or a thrown error depending on the endpoint. @@ -372,14 +373,230 @@ Source: `http.ts` ## Error Codes -| Code | Description | -| ---------------------- | -------------------------- | -| `NOT_AUTHENTICATED` | No auth session | -| `SESSION_EXPIRED` | JWT expired | -| `NOT_AUTHORIZED` | Missing permission | -| `PERMISSION_DENIED` | Specific permission failed | -| `NOT_WORKSPACE_MEMBER` | Not a workspace member | -| `NOT_FOUND` | Resource doesn't exist | -| `ALREADY_EXISTS` | Duplicate resource | -| `INVALID_INPUT` | Validation failed | -| `RATE_LIMITED` | Request throttled | +| Code | Description | +| ---------------------- | -------------------------------------------------------- | +| `NOT_AUTHENTICATED` | No auth session | +| `SESSION_EXPIRED` | JWT expired | +| `NOT_AUTHORIZED` | Missing permission | +| `PERMISSION_DENIED` | Specific permission failed | +| `NOT_WORKSPACE_MEMBER` | Not a workspace member | +| `NOT_FOUND` | Resource doesn't exist | +| `ALREADY_EXISTS` | Duplicate resource | +| `INVALID_INPUT` | Validation failed | +| `RATE_LIMITED` | Request throttled | +| `AUTOMATION_DISABLED` | `automationApiEnabled` is false for this workspace | +| `INVALID_CREDENTIALS` | Bearer token missing, malformed, or not found | +| `SCOPE_DENIED` | Credential lacks the required scope for this endpoint | +| `CREDENTIAL_EXPIRED` | Credential status is `expired` or `disabled` | + +--- + +## Automation API + +The Automation API provides an HTTP-based interface for external systems to interact with workspace data. All endpoints are under `/api/v1/` and require bearer token authentication. The feature is gated behind the `automationApiEnabled` flag on the workspace — all routes return 403 when disabled. + +Source: `automationHttpRoutes.ts`, `automationCredentials.ts`, `automationWebhooks.ts` + +### Authentication + +Automation API requests authenticate via bearer token: + +``` +Authorization: Bearer osk_ +``` + +- **Token format**: `osk_` prefix + 48 random characters = 52 characters total +- **Storage**: SHA-256 hashed (one-way). The plaintext secret cannot be recovered after creation. +- **One-time reveal**: The full secret is returned only at credential creation time +- **Identification**: List views show the secret prefix (`osk_` + first 8 characters) for identification +- **Credential lifecycle**: `active` → `disabled` (admin toggle) or `expired` (TTL-based) +- **Actor attribution**: Each credential carries an actor name for audit trail purposes + +### Scopes + +Credentials carry an immutable set of scopes assigned at creation. Every request is checked against the credential's scopes (fail-closed). + +| Scope | Grants access to | +| --------------------- | --------------------------------- | +| `conversations.read` | List/get conversations | +| `conversations.write` | Update conversation status/assign | +| `messages.read` | List messages | +| `messages.write` | Send messages | +| `visitors.read` | List/get visitors | +| `visitors.write` | Create/update visitors | +| `tickets.read` | List/get tickets | +| `tickets.write` | Create/update tickets | +| `events.read` | Read event feed | +| `events.write` | (Reserved) | +| `articles.read` | List/get articles | +| `articles.write` | Create/update/delete articles | +| `collections.read` | List/get collections | +| `collections.write` | Create/update/delete collections | +| `webhooks.manage` | Manage webhook subscriptions | +| `claims.manage` | Claim/release/escalate conversations | + +There is no wildcard or admin scope in v1. Scopes are set at credential creation and cannot be modified afterward. + +### Rate Limits + +| Limit | Value | +| -------------- | ----------------- | +| Per credential | 60 req/min | +| Per workspace | 120 req/min | +| Window | 1-minute sliding | + +When rate-limited, the API returns HTTP 429 with a `Retry-After` header. + +### Endpoints + +#### Conversations + +| Method | Path | Scope | Description | +| ------ | ----------------------------------- | --------------------- | ------------------------------ | +| GET | `/api/v1/conversations` | `conversations.read` | List conversations | +| GET | `/api/v1/conversations/get` | `conversations.read` | Get conversation by ID | +| POST | `/api/v1/conversations/update` | `conversations.write` | Update status or assignment | +| POST | `/api/v1/conversations/claim` | `claims.manage` | Claim conversation (5-min lease) | +| POST | `/api/v1/conversations/release` | `claims.manage` | Release claimed conversation | +| POST | `/api/v1/conversations/escalate` | `claims.manage` | Escalate to human queue | + +#### Messages + +| Method | Path | Scope | Description | +| ------ | ----------------------------------------- | ---------------- | -------------------- | +| GET | `/api/v1/conversations/messages` | `messages.read` | List messages | +| POST | `/api/v1/conversations/messages/send` | `messages.write` | Send a message | + +#### Visitors + +| Method | Path | Scope | Description | +| ------ | --------------------------- | ---------------- | ----------------- | +| GET | `/api/v1/visitors` | `visitors.read` | List visitors | +| GET | `/api/v1/visitors/get` | `visitors.read` | Get visitor by ID | +| POST | `/api/v1/visitors/create` | `visitors.write` | Create visitor | +| POST | `/api/v1/visitors/update` | `visitors.write` | Update visitor | + +#### Tickets + +| Method | Path | Scope | Description | +| ------ | --------------------------- | --------------- | ---------------- | +| GET | `/api/v1/tickets` | `tickets.read` | List tickets | +| GET | `/api/v1/tickets/get` | `tickets.read` | Get ticket by ID | +| POST | `/api/v1/tickets/create` | `tickets.write` | Create ticket | +| POST | `/api/v1/tickets/update` | `tickets.write` | Update ticket | + +#### Articles + +| Method | Path | Scope | Description | +| ------ | ---------------------------- | --------------- | ---------------- | +| GET | `/api/v1/articles` | `articles.read` | List articles | +| GET | `/api/v1/articles/get` | `articles.read` | Get article by ID | +| POST | `/api/v1/articles/create` | `articles.write`| Create article | +| POST | `/api/v1/articles/update` | `articles.write`| Update article | +| POST | `/api/v1/articles/delete` | `articles.write`| Delete article | + +#### Collections + +| Method | Path | Scope | Description | +| ------ | ------------------------------- | ------------------ | -------------------- | +| GET | `/api/v1/collections` | `collections.read` | List collections | +| GET | `/api/v1/collections/get` | `collections.read` | Get collection by ID | +| POST | `/api/v1/collections/create` | `collections.write`| Create collection | +| POST | `/api/v1/collections/update` | `collections.write`| Update collection | +| POST | `/api/v1/collections/delete` | `collections.write`| Delete collection | + +#### Events + +| Method | Path | Scope | Description | +| ------ | ----------------------- | ------------- | ------------------------ | +| GET | `/api/v1/events/feed` | `events.read` | Paginated event feed | + +#### Webhooks + +| Method | Path | Scope | Description | +| ------ | ----------------------------- | ---------------- | ---------------------------- | +| POST | `/api/v1/webhooks/replay` | `webhooks.manage`| Replay a failed delivery | + +### Idempotency + +The `Idempotency-Key` header is supported on message send (`POST /api/v1/conversations/messages/send`) only. + +- **TTL**: 24 hours +- **Scope**: Per workspace + key combination +- **Duplicate response**: Returns `cached: true` when a matching key is found within the TTL window + +### Pagination & Filtering + +All list endpoints use cursor-based pagination: + +- **Default page size**: 20 +- **Maximum page size**: 100 +- **Cursor**: Opaque string returned in response; pass as `cursor` query parameter for next page + +**Conversation filters**: `status`, `assignee`, `channel`, `email`, `externalUserId`, `customAttribute.*` +**Visitor filters**: `email`, `externalUserId`, `customAttribute.*` +**Ticket filters**: `status`, `priority`, `assigneeId` +**Article filters**: `status`, `collectionId` +**Collection filters**: `parentId` +**Message filters**: `conversationId` (required) + +### Automation Credentials (Admin) + +Managed via Convex mutations (admin UI), not HTTP endpoints. + +| Operation | Description | +| ------------------- | -------------------------------------------------------------- | +| Create credential | Returns one-time secret. Stores SHA-256 hash. | +| List credentials | Shows prefix (`osk_` + 8 chars), scopes, status, last used | +| Disable credential | Sets status to `disabled`, blocks all requests | +| Enable credential | Re-enables a disabled credential | +| Delete credential | Permanently removes the credential | + +### Webhook Subscriptions (Admin) + +Managed via Convex mutations (admin UI). + +| Operation | Description | +| ---------------------- | ------------------------------------------------------------ | +| Create subscription | Returns one-time signing secret (`whsec_` prefix) | +| List subscriptions | Shows URL, status, event/resource filter summary | +| Update subscription | Modify URL, filters, or status | +| Delete subscription | Remove subscription and stop deliveries | +| Test ping | Sends a `test.ping` event to the subscription URL | + +### Webhook Deliveries (Admin) + +| Operation | Description | +| ----------------- | ----------------------------------------------------------------- | +| List deliveries | Recent-window view of deliveries, filterable by subscription | +| View details | Status, HTTP response code, error message, attempt count | +| Replay delivery | Creates a new delivery attempt (resets attempt count to 1) | + +Delivery logs show a recent window, not full historical data. + +### Events + +Events are emitted by UI/domain mutations and most automation API write mutations. + +| Event | Triggered by | Data payload | +| ----------------------- | ----------------------------------------------------- | --------------------------------------------- | +| `conversation.created` | `conversations.create`, `getOrCreateForVisitor` (new) | `{ channel, status, visitorId }` | +| `conversation.updated` | `updateStatus`, `assign`, API update | `{ status }` and/or `{ assignedAgentId }` | +| `message.created` | `messages.send`, bot message, API send | `{ conversationId, senderType, channel }` | +| `visitor.updated` | `visitors.identify`, API update | `{ visitorId }` | +| `ticket.created` | `tickets.create`, convert from conversation, API | `{ channel: "support_ticket", status, priority }` | +| `ticket.updated` | `tickets.update`, `tickets.resolve`, API update | `{ channel: "support_ticket", status, priority, assigneeId }` | +| `ticket.comment_added` | `tickets.addComment` (external comments only) | `{ channel: "support_ticket", commentId, authorType }` | + +### Known V1 Limitations + +- No events for articles/collections — planned for v2 +- No `visitor.created` event — visitors can be created via the API, but no event is emitted; `visitor.updated` fires on `identify()` and API update +- No `message.updated`/`message.deleted` events — messages are immutable in v1 +- No `conversation.deleted` event — conversations are not deletable +- No fine-grained event types — status changes, assignments, etc. are communicated via the `data` payload on broad event types (`*.updated`) rather than separate event types +- Noisy mutations excluded: `visitors.updateLocation` and `visitors.heartbeat` do not emit events +- `aiWorkflowStates` webhook filter is reserved for future use; no production mutations currently populate this field in event data +- `ticket.updated` payload is coarse — includes status, priority, assigneeId on every emit regardless of what changed; does not include teamId or resolutionSummary + +See `packages/convex/AUTOMATION_V1_COVERAGE.md` for the canonical coverage matrix. diff --git a/docs/security.md b/docs/security.md index 756c5fa..1ce189e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -191,6 +191,120 @@ The system verifies Resend's `svix-signature` header using HMAC-SHA256: | `ALLOW_TEST_DATA` | Enables test data seeding/cleanup mutations | Never enable in production | | `TEST_ADMIN_SECRET` | Protects `testAdmin.runTestMutation` gateway | Only set in dedicated test deployments | +## Automation API Security + +The Automation API uses bearer token authentication with workspace-scoped credentials. All automation routes are gated behind the `automationApiEnabled` flag — when disabled, all routes return 403. + +### API Key Security Model + +- **Token format**: `osk_` prefix + 48 random characters (52 characters total) +- **Workspace-scoped**: Each credential belongs to a single workspace +- **SHA-256 hashed storage**: The plaintext secret is never stored after creation. Only the SHA-256 hash is persisted. +- **One-time reveal**: The full secret is shown only at creation time and cannot be recovered +- **Secret prefix**: `osk_` + first 8 characters shown in admin UI for identification +- **Credential lifecycle**: Credentials can be disabled (blocking all requests) or expire based on TTL +- **Recommendation**: Rotate credentials after team member departures + +### Scope-Based Access Control + +Automation credentials use a least-privilege model: + +- Each credential carries only the scopes it needs (from 16 available scopes) +- Scopes are set at creation and cannot be modified afterward +- Every request is checked against the credential's scopes (fail-closed) +- There is no wildcard or admin scope in v1 + +### Rate Limiting + +- **Per credential**: 60 requests/minute +- **Per workspace**: 120 requests/minute +- **Window**: 1-minute sliding window +- A single credential cannot exhaust the workspace-level quota — at most 50% of the workspace's capacity + +### Webhook Signature Verification + +All webhook deliveries are signed using HMAC-SHA256. + +**Signature header format**: + +``` +X-Opencom-Signature: t={unix-seconds},v1={hex-signature} +``` + +**Signing payload**: `{timestamp}.{json-body}` + +**Verification steps**: + +```javascript +const crypto = require("crypto"); + +function verifyWebhookSignature(body, signatureHeader, secret) { + // 1. Extract t and v1 from the header + const parts = Object.fromEntries( + signatureHeader.split(",").map((p) => p.split("=", 2)) + ); + const timestamp = parts.t; + const signature = parts.v1; + + // 2. Reconstruct the signing payload + const payload = `${timestamp}.${body}`; + + // 3. Compute HMAC-SHA256 with your signing secret + const expected = crypto + .createHmac("sha256", secret) + .update(payload) + .digest("hex"); + + // 4. Constant-time compare + const valid = crypto.timingSafeEqual( + Buffer.from(signature, "hex"), + Buffer.from(expected, "hex") + ); + + // 5. Check timestamp freshness (prevent replay attacks) + const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10); + if (age > 300) { + return false; // Reject deliveries older than 5 minutes + } + + return valid; +} +``` + +**Additional headers sent with each delivery**: + +| Header | Description | +| ------------------------- | --------------------------------------- | +| `X-Opencom-Signature` | HMAC-SHA256 signature (`t=...,v1=...`) | +| `X-Opencom-Event-Id` | Unique event identifier | +| `X-Opencom-Delivery-Id` | Unique delivery attempt identifier | +| `X-Opencom-Timestamp` | Unix timestamp (matches `t` in signature) | + +**Retry schedule** (exponential backoff, max 5 attempts): + +| Attempt | Delay | +| ------- | ---------- | +| 1 | Immediate | +| 2 | 30 seconds | +| 3 | 2 minutes | +| 4 | 10 minutes | +| 5 | 1 hour | + +Failed deliveries can be replayed via the admin UI, which creates a new delivery starting at attempt 1. + +### Webhook Secret Management + +- **Secret format**: `whsec_` prefix + 48 random characters (54 characters total) +- **Encrypted at rest**: AES-GCM encryption +- **One-time reveal**: The signing secret is shown only at subscription creation and cannot be recovered +- **Identification**: Admin UI shows the prefix (`whsec_` + first 8 characters) + +### Feature Flag Gating + +- The `automationApiEnabled` field on the `workspaces` table controls access +- When `false`, all `/api/v1/*` routes return 403 (`AUTOMATION_DISABLED`) +- Only workspace owners and admins can toggle the flag + ## CI Supply Chain Controls GitHub Actions workflow dependencies are treated as supply-chain inputs: @@ -201,7 +315,15 @@ GitHub Actions workflow dependencies are treated as supply-chain inputs: ## Authorization Model -All mutations and queries enforce authorization checks: +All mutations and queries enforce authorization checks via one of three paths: + +### Automation API Endpoints + +- Authenticated via bearer token (`Authorization: Bearer osk_`) +- Token is SHA-256 hashed and looked up in `automationCredentials` +- Scope check: credential must carry the required scope for the endpoint +- Rate limit check: per-credential (60/min) and per-workspace (120/min) +- If all checks pass, the request executes with the credential's workspace context ### Agent/Admin Endpoints diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md index c8d6a15..5533867 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/tasks.md @@ -31,7 +31,7 @@ ## 5. Admin Experience And Documentation - [x] 5.1 Build admin settings UI for credential management, scope review, webhook endpoints, delivery logs, and replay actions. -- [ ] 5.2 Update developer and security docs for authentication, scopes, rate limits, idempotency, webhook verification, event semantics, and rollout limitations. +- [x] 5.2 Update developer and security docs for authentication, scopes, rate limits, idempotency, webhook verification, event semantics, and rollout limitations. - [x] 5.3 Gate the feature behind workspace flags (`automationApiEnabled` on workspaces table). - [ ] 5.3b Add rollout instrumentation for request volume, webhook failures, and automation conflict rates. From 9bac7862a62a222962b4a21d0ab07da36ffd516c Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:30:54 +0200 Subject: [PATCH 12/13] fix docs inaccuracies --- docs/api-reference.md | 15 ++++++++------- docs/security.md | 18 +++++++++--------- .../v1-coverage-matrix.md | 8 ++++---- packages/convex/AUTOMATION_V1_COVERAGE.md | 8 ++++---- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 3176edf..430d570 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -387,7 +387,7 @@ Source: `http.ts` | `AUTOMATION_DISABLED` | `automationApiEnabled` is false for this workspace | | `INVALID_CREDENTIALS` | Bearer token missing, malformed, or not found | | `SCOPE_DENIED` | Credential lacks the required scope for this endpoint | -| `CREDENTIAL_EXPIRED` | Credential status is `expired` or `disabled` | +| `CREDENTIAL_EXPIRED` | Credential's `expiresAt` has passed or status is `disabled` | --- @@ -409,7 +409,7 @@ Authorization: Bearer osk_ - **Storage**: SHA-256 hashed (one-way). The plaintext secret cannot be recovered after creation. - **One-time reveal**: The full secret is returned only at credential creation time - **Identification**: List views show the secret prefix (`osk_` + first 8 characters) for identification -- **Credential lifecycle**: `active` → `disabled` (admin toggle) or `expired` (TTL-based) +- **Credential lifecycle**: Credentials have a `status` field (`active` or `disabled`, toggled by admin). Separately, credentials may have an `expiresAt` timestamp; expired credentials are rejected at auth time regardless of status. - **Actor attribution**: Each credential carries an actor name for audit trail purposes ### Scopes @@ -443,7 +443,7 @@ There is no wildcard or admin scope in v1. Scopes are set at credential creation | -------------- | ----------------- | | Per credential | 60 req/min | | Per workspace | 120 req/min | -| Window | 1-minute sliding | +| Window | 1-minute fixed | When rate-limited, the API returns HTTP 429 with a `Retry-After` header. @@ -523,7 +523,8 @@ The `Idempotency-Key` header is supported on message send (`POST /api/v1/convers - **TTL**: 24 hours - **Scope**: Per workspace + key combination -- **Duplicate response**: Returns `cached: true` when a matching key is found within the TTL window +- **First send**: Returns HTTP 201 with the message response body +- **Duplicate replay**: Returns HTTP 200 with the original response body (no `cached` field in the response) ### Pagination & Filtering @@ -535,7 +536,7 @@ All list endpoints use cursor-based pagination: **Conversation filters**: `status`, `assignee`, `channel`, `email`, `externalUserId`, `customAttribute.*` **Visitor filters**: `email`, `externalUserId`, `customAttribute.*` -**Ticket filters**: `status`, `priority`, `assigneeId` +**Ticket filters**: `status`, `priority`, `assignee` **Article filters**: `status`, `collectionId` **Collection filters**: `parentId` **Message filters**: `conversationId` (required) @@ -562,7 +563,7 @@ Managed via Convex mutations (admin UI). | List subscriptions | Shows URL, status, event/resource filter summary | | Update subscription | Modify URL, filters, or status | | Delete subscription | Remove subscription and stop deliveries | -| Test ping | Sends a `test.ping` event to the subscription URL | +| Test ping | Sends a `test.ping` event to the subscription URL (admin UI action, not a public HTTP endpoint) | ### Webhook Deliveries (Admin) @@ -590,7 +591,7 @@ Events are emitted by UI/domain mutations and most automation API write mutation ### Known V1 Limitations -- No events for articles/collections — planned for v2 +- No events for articles/collections — not supported in v1 - No `visitor.created` event — visitors can be created via the API, but no event is emitted; `visitor.updated` fires on `identify()` and API update - No `message.updated`/`message.deleted` events — messages are immutable in v1 - No `conversation.deleted` event — conversations are not deletable diff --git a/docs/security.md b/docs/security.md index 1ce189e..5fc5b22 100644 --- a/docs/security.md +++ b/docs/security.md @@ -202,7 +202,7 @@ The Automation API uses bearer token authentication with workspace-scoped creden - **SHA-256 hashed storage**: The plaintext secret is never stored after creation. Only the SHA-256 hash is persisted. - **One-time reveal**: The full secret is shown only at creation time and cannot be recovered - **Secret prefix**: `osk_` + first 8 characters shown in admin UI for identification -- **Credential lifecycle**: Credentials can be disabled (blocking all requests) or expire based on TTL +- **Credential lifecycle**: Credentials have a `status` field (`active` or `disabled`, toggled by admin). Separately, credentials may carry an `expiresAt` timestamp; expired credentials are rejected at auth time regardless of status. - **Recommendation**: Rotate credentials after team member departures ### Scope-Based Access Control @@ -218,7 +218,7 @@ Automation credentials use a least-privilege model: - **Per credential**: 60 requests/minute - **Per workspace**: 120 requests/minute -- **Window**: 1-minute sliding window +- **Window**: 1-minute fixed window (resets when the previous window expires) - A single credential cannot exhaust the workspace-level quota — at most 50% of the workspace's capacity ### Webhook Signature Verification @@ -282,13 +282,13 @@ function verifyWebhookSignature(body, signatureHeader, secret) { **Retry schedule** (exponential backoff, max 5 attempts): -| Attempt | Delay | -| ------- | ---------- | -| 1 | Immediate | -| 2 | 30 seconds | -| 3 | 2 minutes | -| 4 | 10 minutes | -| 5 | 1 hour | +| Attempt | Delay after failure | +| ------- | ------------------- | +| 1 | Immediate | +| 2 | 30 seconds | +| 3 | 2 minutes | +| 4 | 10 minutes | +| 5 | 1 hour | Failed deliveries can be replayed via the admin UI, which creates a new delivery starting at attempt 1. diff --git a/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md b/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md index 8cd160b..15f4367 100644 --- a/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md +++ b/openspec/changes/expose-automation-api-and-event-webhooks/v1-coverage-matrix.md @@ -7,7 +7,7 @@ | conversations | cursor + filters (status, assignee, channel, email, externalUserId, customAttribute) | by ID | — | status, assign | — | `conversation.created`, `conversation.updated` | | messages | cursor + filters (conversationId) | — | send | — | — | `message.created` | | visitors | cursor + filters (email, externalUserId, customAttribute) | by ID | create | update | — | `visitor.updated` | -| tickets | cursor + filters (status, priority, assigneeId) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | +| tickets | cursor + filters (status, priority, assignee) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | | articles | cursor + filters (status, collectionId) | by ID | create | update | delete | — (v2) | | collections | cursor + filters (parentId) | by ID | create | update | delete | — (v2) | @@ -56,8 +56,8 @@ Events are emitted by UI/domain mutations and most automation API write mutation ## Webhook Subscriptions - **Filters:** `eventTypes`, `resourceTypes`, `channels`, `aiWorkflowStates` (reserved — not yet emitted by production mutations) -- **Delivery:** Async via scheduled function, with exponential backoff retry (30s, 2m, 10m, 1h, 4h; max 5 attempts) -- **Test endpoint:** `POST /webhooks/{id}/test` sends a `test.ping` event +- **Delivery:** Async via scheduled function, with exponential backoff retry (30s, 2m, 10m, 1h; max 5 attempts) +- **Test ping:** Admin UI action (Convex mutation) that sends a `test.ping` event to the subscription URL - **Signature:** HMAC-SHA256 in `X-Opencom-Signature` header (format: `t={timestamp},v1={hex}`) - **Additional headers:** `X-Opencom-Event-Id`, `X-Opencom-Delivery-Id`, `X-Opencom-Timestamp` @@ -75,7 +75,7 @@ Events are emitted by UI/domain mutations and most automation API write mutation ## Known V1 Limitations -- **No events for articles/collections** — planned for v2 +- **No events for articles/collections** — not supported in v1 - **No `visitor.created` event** — visitors can be created via the API, but no event is emitted; `visitor.updated` fires on `identify()` and API update - **No `message.updated`/`message.deleted` events** — messages are immutable in v1 - **No `conversation.deleted` event** — conversations are not deletable diff --git a/packages/convex/AUTOMATION_V1_COVERAGE.md b/packages/convex/AUTOMATION_V1_COVERAGE.md index 8cd160b..15f4367 100644 --- a/packages/convex/AUTOMATION_V1_COVERAGE.md +++ b/packages/convex/AUTOMATION_V1_COVERAGE.md @@ -7,7 +7,7 @@ | conversations | cursor + filters (status, assignee, channel, email, externalUserId, customAttribute) | by ID | — | status, assign | — | `conversation.created`, `conversation.updated` | | messages | cursor + filters (conversationId) | — | send | — | — | `message.created` | | visitors | cursor + filters (email, externalUserId, customAttribute) | by ID | create | update | — | `visitor.updated` | -| tickets | cursor + filters (status, priority, assigneeId) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | +| tickets | cursor + filters (status, priority, assignee) | by ID | create | update, resolve | — | `ticket.created`, `ticket.updated`, `ticket.comment_added` | | articles | cursor + filters (status, collectionId) | by ID | create | update | delete | — (v2) | | collections | cursor + filters (parentId) | by ID | create | update | delete | — (v2) | @@ -56,8 +56,8 @@ Events are emitted by UI/domain mutations and most automation API write mutation ## Webhook Subscriptions - **Filters:** `eventTypes`, `resourceTypes`, `channels`, `aiWorkflowStates` (reserved — not yet emitted by production mutations) -- **Delivery:** Async via scheduled function, with exponential backoff retry (30s, 2m, 10m, 1h, 4h; max 5 attempts) -- **Test endpoint:** `POST /webhooks/{id}/test` sends a `test.ping` event +- **Delivery:** Async via scheduled function, with exponential backoff retry (30s, 2m, 10m, 1h; max 5 attempts) +- **Test ping:** Admin UI action (Convex mutation) that sends a `test.ping` event to the subscription URL - **Signature:** HMAC-SHA256 in `X-Opencom-Signature` header (format: `t={timestamp},v1={hex}`) - **Additional headers:** `X-Opencom-Event-Id`, `X-Opencom-Delivery-Id`, `X-Opencom-Timestamp` @@ -75,7 +75,7 @@ Events are emitted by UI/domain mutations and most automation API write mutation ## Known V1 Limitations -- **No events for articles/collections** — planned for v2 +- **No events for articles/collections** — not supported in v1 - **No `visitor.created` event** — visitors can be created via the API, but no event is emitted; `visitor.updated` fires on `identify()` and API update - **No `message.updated`/`message.deleted` events** — messages are immutable in v1 - **No `conversation.deleted` event** — conversations are not deletable From 2eea1b64d3c876f374e4521cf7f3d13621761c5c Mon Sep 17 00:00:00 2001 From: Georgi Zhelev <30194786+GeorgiZhelev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:00:27 +0200 Subject: [PATCH 13/13] add outbound chat message CRUD to automation API --- packages/convex/convex/auditLogs.ts | 7 +- .../convex/convex/automationApiInternals.ts | 321 +++++++++ .../convex/convex/automationHttpRoutes.ts | 166 +++++ packages/convex/convex/automationScopes.ts | 4 + packages/convex/convex/http.ts | 14 + .../convex/schema/outboundSupportTables.ts | 3 +- .../tests/automationOutboundMessages.test.ts | 659 ++++++++++++++++++ .../convex/tests/automationScopes.test.ts | 4 +- 8 files changed, 1174 insertions(+), 4 deletions(-) create mode 100644 packages/convex/tests/automationOutboundMessages.test.ts diff --git a/packages/convex/convex/auditLogs.ts b/packages/convex/convex/auditLogs.ts index bf0ebbe..d1d8970 100644 --- a/packages/convex/convex/auditLogs.ts +++ b/packages/convex/convex/auditLogs.ts @@ -49,7 +49,12 @@ export type AuditAction = | "automation.article.deleted" | "automation.collection.created" | "automation.collection.updated" - | "automation.collection.deleted"; + | "automation.collection.deleted" + | "automation.outbound.created" + | "automation.outbound.updated" + | "automation.outbound.deleted" + | "automation.outbound.activated" + | "automation.outbound.paused"; export type ActorType = "user" | "system" | "api"; diff --git a/packages/convex/convex/automationApiInternals.ts b/packages/convex/convex/automationApiInternals.ts index ef00d37..20c923a 100644 --- a/packages/convex/convex/automationApiInternals.ts +++ b/packages/convex/convex/automationApiInternals.ts @@ -18,6 +18,15 @@ import { updateCollectionCore, deleteCollectionCore, } from "./lib/collectionWriteHelpers"; +import { + outboundMessageContentValidator, + outboundMessageTriggerValidator, + outboundMessageFrequencyValidator, + outboundMessageSchedulingValidator, + outboundMessageStatusValidator, +} from "./outboundContracts"; +import { audienceRulesValidator } from "./validators"; +import { validateAudienceRule } from "./audienceRules"; const DEFAULT_SCAN_BATCH_SIZE = 200; @@ -1489,3 +1498,315 @@ export const deleteCollectionForAutomation = internalMutation({ return { id: args.collectionId }; }, }); + +// ── Outbound Messages ───────────────────────────────────────────── + +function mapOutboundMessage(msg: Doc<"outboundMessages">) { + return { + id: msg._id, + workspaceId: msg.workspaceId, + name: msg.name, + content: msg.content, + targeting: msg.targeting, + triggers: msg.triggers, + frequency: msg.frequency, + scheduling: msg.scheduling, + status: msg.status, + priority: msg.priority, + createdAt: msg.createdAt, + updatedAt: msg.updatedAt, + }; +} + +export const listOutboundMessagesForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + cursor: v.optional(v.string()), + limit: v.number(), + updatedSince: v.optional(v.number()), + status: v.optional(outboundMessageStatusValidator), + }, + handler: async (ctx, args) => { + const limit = Math.min(args.limit, 100); + const cursor = decodeDescCursor(args.cursor); + + const messages = await collectDescendingPage>({ + limit, + cursor, + getSortValue: (msg) => msg.updatedAt, + fetchBatch: async (upperBound, take) => { + let query = ctx.db + .query("outboundMessages") + .withIndex("by_workspace_type_updated_at", (q) => { + if (args.updatedSince !== undefined && upperBound !== undefined) { + return q + .eq("workspaceId", args.workspaceId) + .eq("type", "chat") + .gt("updatedAt", args.updatedSince) + .lte("updatedAt", upperBound); + } + if (args.updatedSince !== undefined) { + return q + .eq("workspaceId", args.workspaceId) + .eq("type", "chat") + .gt("updatedAt", args.updatedSince); + } + if (upperBound !== undefined) { + return q + .eq("workspaceId", args.workspaceId) + .eq("type", "chat") + .lte("updatedAt", upperBound); + } + return q.eq("workspaceId", args.workspaceId).eq("type", "chat"); + }); + + if (args.status) { + query = query.filter((q2) => q2.eq(q2.field("status"), args.status!)); + } + + return query.order("desc").take(take); + }, + }); + + const hasMore = messages.length > limit; + const data = hasMore ? messages.slice(0, limit) : messages; + + return { + data: data.map(mapOutboundMessage), + nextCursor: + hasMore && data.length > 0 + ? encodeCursor(data[data.length - 1].updatedAt, data[data.length - 1]._id) + : null, + hasMore, + }; + }, +}); + +export const getOutboundMessageForAutomation = internalQuery({ + args: { + workspaceId: v.id("workspaces"), + outboundMessageId: v.id("outboundMessages"), + }, + handler: async (ctx, args) => { + const msg = await ctx.db.get(args.outboundMessageId); + if (!msg || msg.workspaceId !== args.workspaceId || msg.type !== "chat") { + return null; + } + + return mapOutboundMessage(msg); + }, +}); + +export const createOutboundMessageForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + name: v.string(), + content: outboundMessageContentValidator, + targeting: v.optional(audienceRulesValidator), + triggers: v.optional(outboundMessageTriggerValidator), + frequency: v.optional(outboundMessageFrequencyValidator), + scheduling: v.optional(outboundMessageSchedulingValidator), + priority: v.optional(v.number()), + }, + handler: async (ctx, args) => { + if (args.targeting !== undefined && !validateAudienceRule(args.targeting)) { + throw new Error("Invalid targeting rules"); + } + + const now = Date.now(); + const id = await ctx.db.insert("outboundMessages", { + workspaceId: args.workspaceId, + type: "chat", + name: args.name, + content: args.content, + targeting: args.targeting, + triggers: args.triggers, + frequency: args.frequency, + scheduling: args.scheduling, + priority: args.priority, + status: "draft", + createdAt: now, + updatedAt: now, + }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.outbound.created", + resourceType: "outboundMessage", + resourceId: String(id), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "outbound.created", + resourceType: "outboundMessage", + resourceId: id, + data: { status: "draft", name: args.name }, + }); + + return { id }; + }, +}); + +export const updateOutboundMessageForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + outboundMessageId: v.id("outboundMessages"), + name: v.optional(v.string()), + content: v.optional(outboundMessageContentValidator), + targeting: v.optional(audienceRulesValidator), + triggers: v.optional(outboundMessageTriggerValidator), + frequency: v.optional(outboundMessageFrequencyValidator), + scheduling: v.optional(outboundMessageSchedulingValidator), + priority: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const msg = await ctx.db.get(args.outboundMessageId); + if (!msg || msg.workspaceId !== args.workspaceId || msg.type !== "chat") { + throw new Error("Outbound message not found"); + } + + if (args.targeting !== undefined && !validateAudienceRule(args.targeting)) { + throw new Error("Invalid targeting rules"); + } + + const updates: Record = { updatedAt: Date.now() }; + if (args.name !== undefined) updates.name = args.name; + if (args.content !== undefined) updates.content = args.content; + if (args.targeting !== undefined) updates.targeting = args.targeting; + if (args.triggers !== undefined) updates.triggers = args.triggers; + if (args.frequency !== undefined) updates.frequency = args.frequency; + if (args.scheduling !== undefined) updates.scheduling = args.scheduling; + if (args.priority !== undefined) updates.priority = args.priority; + + await ctx.db.patch(args.outboundMessageId, updates); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.outbound.updated", + resourceType: "outboundMessage", + resourceId: String(args.outboundMessageId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "outbound.updated", + resourceType: "outboundMessage", + resourceId: args.outboundMessageId, + data: { name: args.name ?? msg.name }, + }); + + return { id: args.outboundMessageId }; + }, +}); + +export const deleteOutboundMessageForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + outboundMessageId: v.id("outboundMessages"), + }, + handler: async (ctx, args) => { + const msg = await ctx.db.get(args.outboundMessageId); + if (!msg || msg.workspaceId !== args.workspaceId || msg.type !== "chat") { + throw new Error("Outbound message not found"); + } + + await ctx.db.delete(args.outboundMessageId); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.outbound.deleted", + resourceType: "outboundMessage", + resourceId: String(args.outboundMessageId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "outbound.deleted", + resourceType: "outboundMessage", + resourceId: args.outboundMessageId, + data: { name: msg.name }, + }); + + return { id: args.outboundMessageId }; + }, +}); + +export const activateOutboundMessageForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + outboundMessageId: v.id("outboundMessages"), + }, + handler: async (ctx, args) => { + const msg = await ctx.db.get(args.outboundMessageId); + if (!msg || msg.workspaceId !== args.workspaceId || msg.type !== "chat") { + throw new Error("Outbound message not found"); + } + + await ctx.db.patch(args.outboundMessageId, { status: "active", updatedAt: Date.now() }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.outbound.activated", + resourceType: "outboundMessage", + resourceId: String(args.outboundMessageId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "outbound.activated", + resourceType: "outboundMessage", + resourceId: args.outboundMessageId, + data: { status: "active", name: msg.name }, + }); + + return { id: args.outboundMessageId, status: "active" as const }; + }, +}); + +export const pauseOutboundMessageForAutomation = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + credentialId: v.optional(v.id("automationCredentials")), + outboundMessageId: v.id("outboundMessages"), + }, + handler: async (ctx, args) => { + const msg = await ctx.db.get(args.outboundMessageId); + if (!msg || msg.workspaceId !== args.workspaceId || msg.type !== "chat") { + throw new Error("Outbound message not found"); + } + + await ctx.db.patch(args.outboundMessageId, { status: "paused", updatedAt: Date.now() }); + + await logAudit(ctx, { + workspaceId: args.workspaceId, + actorType: "api", + action: "automation.outbound.paused", + resourceType: "outboundMessage", + resourceId: String(args.outboundMessageId), + metadata: { credentialId: args.credentialId ? String(args.credentialId) : null }, + }); + + await emitAutomationEvent(ctx, { + workspaceId: args.workspaceId, + eventType: "outbound.paused", + resourceType: "outboundMessage", + resourceId: args.outboundMessageId, + data: { status: "paused", name: msg.name }, + }); + + return { id: args.outboundMessageId, status: "paused" as const }; + }, +}); diff --git a/packages/convex/convex/automationHttpRoutes.ts b/packages/convex/convex/automationHttpRoutes.ts index 4fac467..c9a1670 100644 --- a/packages/convex/convex/automationHttpRoutes.ts +++ b/packages/convex/convex/automationHttpRoutes.ts @@ -60,6 +60,13 @@ const getCollectionRef = fn("automationApiInternals:getCollectionForAutomation") const createCollectionRef = fn("automationApiInternals:createCollectionForAutomation"); const updateCollectionRef = fn("automationApiInternals:updateCollectionForAutomation"); const deleteCollectionRef = fn("automationApiInternals:deleteCollectionForAutomation"); +const listOutboundMessagesRef = fn("automationApiInternals:listOutboundMessagesForAutomation"); +const getOutboundMessageRef = fn("automationApiInternals:getOutboundMessageForAutomation"); +const createOutboundMessageRef = fn("automationApiInternals:createOutboundMessageForAutomation"); +const updateOutboundMessageRef = fn("automationApiInternals:updateOutboundMessageForAutomation"); +const deleteOutboundMessageRef = fn("automationApiInternals:deleteOutboundMessageForAutomation"); +const activateOutboundMessageRef = fn("automationApiInternals:activateOutboundMessageForAutomation"); +const pauseOutboundMessageRef = fn("automationApiInternals:pauseOutboundMessageForAutomation"); const listEventsRef = fn("automationEvents:listEvents"); const replayDeliveryRef = fn("automationWebhookWorker:replayDelivery"); @@ -714,6 +721,165 @@ export const deleteCollection = httpAction(async (ctx, request) => { } }); +// ── Outbound Messages: list ────────────────────────────────────────── +export const listOutboundMessages = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "outbound.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const { cursor, limit, updatedSince } = parsePaginationParams(url); + const status = url.searchParams.get("status"); + + const result = await ctx.runQuery(listOutboundMessagesRef, { + workspaceId: authResult.workspaceId, + cursor: cursor ?? undefined, + limit, + updatedSince: updatedSince ?? undefined, + status: status ?? undefined, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Outbound Messages: get ────────────────────────────────────────── +export const getOutboundMessage = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "outbound.read"); + if (authResult instanceof Response) return authResult; + + try { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + if (!id) return errorResponse("Missing id parameter", 400); + if (!isPlausibleConvexId(id)) return errorResponse("Invalid id format", 400); + + const result = await ctx.runQuery(getOutboundMessageRef, { + workspaceId: authResult.workspaceId, + outboundMessageId: id, + }); + if (!result) return errorResponse("Outbound message not found", 404); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Outbound Messages: create ─────────────────────────────────────── +export const createOutboundMessage = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "outbound.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.name) return errorResponse("Missing name", 400); + if (!body.content) return errorResponse("Missing content", 400); + + const result = await ctx.runMutation(createOutboundMessageRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + name: body.name, + content: body.content, + targeting: body.targeting, + triggers: body.triggers, + frequency: body.frequency, + scheduling: body.scheduling, + priority: body.priority, + }); + return jsonResponse(result, 201); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Outbound Messages: update ─────────────────────────────────────── +export const updateOutboundMessage = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "outbound.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.outboundMessageId) return errorResponse("Missing outboundMessageId", 400); + + const result = await ctx.runMutation(updateOutboundMessageRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + outboundMessageId: body.outboundMessageId, + name: body.name, + content: body.content, + targeting: body.targeting, + triggers: body.triggers, + frequency: body.frequency, + scheduling: body.scheduling, + priority: body.priority, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Outbound Messages: delete ─────────────────────────────────────── +export const deleteOutboundMessage = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "outbound.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.outboundMessageId) return errorResponse("Missing outboundMessageId", 400); + + const result = await ctx.runMutation(deleteOutboundMessageRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + outboundMessageId: body.outboundMessageId, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Outbound Messages: activate ───────────────────────────────────── +export const activateOutboundMessage = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "outbound.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.outboundMessageId) return errorResponse("Missing outboundMessageId", 400); + + const result = await ctx.runMutation(activateOutboundMessageRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + outboundMessageId: body.outboundMessageId, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + +// ── Outbound Messages: pause ──────────────────────────────────────── +export const pauseOutboundMessage = httpAction(async (ctx, request) => { + const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "outbound.write"); + if (authResult instanceof Response) return authResult; + + try { + const body = await request.json(); + if (!body.outboundMessageId) return errorResponse("Missing outboundMessageId", 400); + + const result = await ctx.runMutation(pauseOutboundMessageRef, { + workspaceId: authResult.workspaceId, + credentialId: authResult.credentialId, + outboundMessageId: body.outboundMessageId, + }); + return jsonResponse(result); + } catch (error) { + return catchToResponse(error); + } +}); + // ── Events: feed ─────────────────────────────────────────────────── export const eventsFeed = httpAction(async (ctx, request) => { const authResult = await withAutomationAuth(asAuthCtx(ctx), request, "events.read"); diff --git a/packages/convex/convex/automationScopes.ts b/packages/convex/convex/automationScopes.ts index b0c7577..3f87f83 100644 --- a/packages/convex/convex/automationScopes.ts +++ b/packages/convex/convex/automationScopes.ts @@ -15,6 +15,8 @@ export const AUTOMATION_SCOPES = [ "articles.write", "collections.read", "collections.write", + "outbound.read", + "outbound.write", "webhooks.manage", "claims.manage", ] as const; @@ -36,6 +38,8 @@ export const automationScopeValidator = v.union( v.literal("articles.write"), v.literal("collections.read"), v.literal("collections.write"), + v.literal("outbound.read"), + v.literal("outbound.write"), v.literal("webhooks.manage"), v.literal("claims.manage") ); diff --git a/packages/convex/convex/http.ts b/packages/convex/convex/http.ts index 3ec9853..05f04c6 100644 --- a/packages/convex/convex/http.ts +++ b/packages/convex/convex/http.ts @@ -721,6 +721,13 @@ import { updateCollection, deleteCollection, eventsFeed, + listOutboundMessages, + getOutboundMessage, + createOutboundMessage, + updateOutboundMessage, + deleteOutboundMessage, + activateOutboundMessage, + pauseOutboundMessage, replayWebhookDelivery, } from "./automationHttpRoutes"; @@ -750,6 +757,13 @@ http.route({ path: "/api/v1/collections/get", method: "GET", handler: getCollect http.route({ path: "/api/v1/collections/create", method: "POST", handler: createCollection }); http.route({ path: "/api/v1/collections/update", method: "POST", handler: updateCollection }); http.route({ path: "/api/v1/collections/delete", method: "POST", handler: deleteCollection }); +http.route({ path: "/api/v1/outbound", method: "GET", handler: listOutboundMessages }); +http.route({ path: "/api/v1/outbound/get", method: "GET", handler: getOutboundMessage }); +http.route({ path: "/api/v1/outbound/create", method: "POST", handler: createOutboundMessage }); +http.route({ path: "/api/v1/outbound/update", method: "POST", handler: updateOutboundMessage }); +http.route({ path: "/api/v1/outbound/delete", method: "POST", handler: deleteOutboundMessage }); +http.route({ path: "/api/v1/outbound/activate", method: "POST", handler: activateOutboundMessage }); +http.route({ path: "/api/v1/outbound/pause", method: "POST", handler: pauseOutboundMessage }); http.route({ path: "/api/v1/events/feed", method: "GET", handler: eventsFeed }); http.route({ path: "/api/v1/webhooks/replay", method: "POST", handler: replayWebhookDelivery }); diff --git a/packages/convex/convex/schema/outboundSupportTables.ts b/packages/convex/convex/schema/outboundSupportTables.ts index 05a4211..a9b5ee3 100644 --- a/packages/convex/convex/schema/outboundSupportTables.ts +++ b/packages/convex/convex/schema/outboundSupportTables.ts @@ -30,7 +30,8 @@ export const outboundSupportTables = { }) .index("by_workspace", ["workspaceId"]) .index("by_workspace_status", ["workspaceId", "status"]) - .index("by_workspace_type", ["workspaceId", "type"]), + .index("by_workspace_type", ["workspaceId", "type"]) + .index("by_workspace_type_updated_at", ["workspaceId", "type", "updatedAt"]), // Outbound Message Impressions (tracking) outboundMessageImpressions: defineTable({ diff --git a/packages/convex/tests/automationOutboundMessages.test.ts b/packages/convex/tests/automationOutboundMessages.test.ts new file mode 100644 index 0000000..95e94c5 --- /dev/null +++ b/packages/convex/tests/automationOutboundMessages.test.ts @@ -0,0 +1,659 @@ +import { beforeEach, describe, expect, it, vi, afterEach } from "vitest"; +import { convexTest } from "convex-test"; +import { internal } from "../convex/_generated/api"; +import schema from "../convex/schema"; +import type { Id } from "../convex/_generated/dataModel"; + +const modules = import.meta.glob("../convex/**/*.ts"); + +describe("automation outbound messages", () => { + let t: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + t = convexTest(schema, modules); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + async function seedWorkspace() { + return t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "Test Workspace", + automationApiEnabled: true, + createdAt: now, + }); + const userId = await ctx.db.insert("users", { + email: "admin@test.com", + workspaceId, + role: "admin", + createdAt: now, + }); + const credentialId = await ctx.db.insert("automationCredentials", { + workspaceId, + name: "Test Key", + secretHash: "testhash123", + secretPrefix: "osk_test", + scopes: ["outbound.read", "outbound.write"], + status: "active", + actorName: "test-bot", + createdBy: userId, + createdAt: now, + }); + return { workspaceId, userId, credentialId }; + }); + } + + async function seedSecondWorkspace() { + return t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "Other Workspace", + automationApiEnabled: true, + createdAt: now, + }); + return { workspaceId }; + }); + } + + // ── CRUD lifecycle ───────────────────────────────────────────────── + + it("create → get → update → list → delete lifecycle", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + // Create + const created = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "Welcome Chat", + content: { text: "Hello visitor!" }, + } + ); + expect(created.id).toBeDefined(); + + // Get + const fetched = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { + workspaceId, + outboundMessageId: created.id, + } + ); + expect(fetched).not.toBeNull(); + expect(fetched!.name).toBe("Welcome Chat"); + expect(fetched!.status).toBe("draft"); + expect(fetched!.content).toEqual({ text: "Hello visitor!" }); + + // Update + const updated = await t.mutation( + internal.automationApiInternals.updateOutboundMessageForAutomation, + { + workspaceId, + credentialId, + outboundMessageId: created.id, + name: "Updated Welcome", + content: { text: "Hey there!" }, + } + ); + expect(updated.id).toBe(created.id); + + // Verify update + const refetched = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { + workspaceId, + outboundMessageId: created.id, + } + ); + expect(refetched!.name).toBe("Updated Welcome"); + expect(refetched!.content).toEqual({ text: "Hey there!" }); + + // List + const list = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { + workspaceId, + limit: 10, + } + ); + expect(list.data).toHaveLength(1); + expect(list.data[0].id).toBe(created.id); + expect(list.hasMore).toBe(false); + + // Delete + const deleted = await t.mutation( + internal.automationApiInternals.deleteOutboundMessageForAutomation, + { + workspaceId, + credentialId, + outboundMessageId: created.id, + } + ); + expect(deleted.id).toBe(created.id); + + // Verify deleted + const gone = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { + workspaceId, + outboundMessageId: created.id, + } + ); + expect(gone).toBeNull(); + }); + + // ── Workspace isolation ──────────────────────────────────────────── + + it("workspace isolation prevents cross-workspace get/update/delete", async () => { + const wsA = await seedWorkspace(); + const wsB = await seedSecondWorkspace(); + + const created = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId: wsA.workspaceId, + credentialId: wsA.credentialId, + name: "WS-A Message", + content: { text: "Hello from A" }, + } + ); + + // Workspace B cannot get it + const fromB = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { + workspaceId: wsB.workspaceId, + outboundMessageId: created.id, + } + ); + expect(fromB).toBeNull(); + + // Workspace B cannot update it + await expect( + t.mutation( + internal.automationApiInternals.updateOutboundMessageForAutomation, + { + workspaceId: wsB.workspaceId, + outboundMessageId: created.id, + name: "Hacked", + } + ) + ).rejects.toThrow("Outbound message not found"); + + // Workspace B cannot delete it + await expect( + t.mutation( + internal.automationApiInternals.deleteOutboundMessageForAutomation, + { + workspaceId: wsB.workspaceId, + outboundMessageId: created.id, + } + ) + ).rejects.toThrow("Outbound message not found"); + }); + + it("workspace isolation prevents cross-workspace activate/pause", async () => { + const wsA = await seedWorkspace(); + const wsB = await seedSecondWorkspace(); + + const created = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId: wsA.workspaceId, + credentialId: wsA.credentialId, + name: "WS-A Activate Test", + content: { text: "A only" }, + } + ); + + // Workspace B cannot activate it + await expect( + t.mutation( + internal.automationApiInternals.activateOutboundMessageForAutomation, + { + workspaceId: wsB.workspaceId, + outboundMessageId: created.id, + } + ) + ).rejects.toThrow("Outbound message not found"); + + // Activate in correct workspace, then try cross-workspace pause + await t.mutation( + internal.automationApiInternals.activateOutboundMessageForAutomation, + { + workspaceId: wsA.workspaceId, + credentialId: wsA.credentialId, + outboundMessageId: created.id, + } + ); + + await expect( + t.mutation( + internal.automationApiInternals.pauseOutboundMessageForAutomation, + { + workspaceId: wsB.workspaceId, + outboundMessageId: created.id, + } + ) + ).rejects.toThrow("Outbound message not found"); + }); + + // ── Activate / pause lifecycle ───────────────────────────────────── + + it("activate and pause lifecycle transitions", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + const created = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "Lifecycle Test", + content: { text: "Test" }, + } + ); + + // Should start as draft + let msg = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { workspaceId, outboundMessageId: created.id } + ); + expect(msg!.status).toBe("draft"); + + // Activate + const activated = await t.mutation( + internal.automationApiInternals.activateOutboundMessageForAutomation, + { workspaceId, credentialId, outboundMessageId: created.id } + ); + expect(activated.status).toBe("active"); + + msg = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { workspaceId, outboundMessageId: created.id } + ); + expect(msg!.status).toBe("active"); + + // Pause + const paused = await t.mutation( + internal.automationApiInternals.pauseOutboundMessageForAutomation, + { workspaceId, credentialId, outboundMessageId: created.id } + ); + expect(paused.status).toBe("paused"); + + msg = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { workspaceId, outboundMessageId: created.id } + ); + expect(msg!.status).toBe("paused"); + + // Re-activate + const reactivated = await t.mutation( + internal.automationApiInternals.activateOutboundMessageForAutomation, + { workspaceId, credentialId, outboundMessageId: created.id } + ); + expect(reactivated.status).toBe("active"); + }); + + // ── Chat-only enforcement ────────────────────────────────────────── + + it("only exposes chat-type messages, not banner/post", async () => { + const { workspaceId } = await seedWorkspace(); + + // Insert a banner message directly + const bannerId = await t.run(async (ctx) => { + const now = Date.now(); + return ctx.db.insert("outboundMessages", { + workspaceId, + type: "banner", + name: "Banner Message", + content: { title: "Sale!", body: "50% off" }, + status: "active", + createdAt: now, + updatedAt: now, + }); + }); + + // Get returns null for non-chat type + const fetched = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { workspaceId, outboundMessageId: bannerId } + ); + expect(fetched).toBeNull(); + + // List excludes non-chat types + const list = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { workspaceId, limit: 10 } + ); + expect(list.data).toHaveLength(0); + + // Update throws for non-chat type + await expect( + t.mutation( + internal.automationApiInternals.updateOutboundMessageForAutomation, + { workspaceId, outboundMessageId: bannerId, name: "Nope" } + ) + ).rejects.toThrow("Outbound message not found"); + }); + + // ── List pagination ──────────────────────────────────────────────── + + it("paginates list results correctly", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + // Create 3 messages with different timestamps + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(1000); + await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: `Message ${i + 1}`, + content: { text: `Content ${i + 1}` }, + } + ); + } + + // First page: limit 2 + const page1 = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { workspaceId, limit: 2 } + ); + expect(page1.data).toHaveLength(2); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBeDefined(); + // Descending order — most recent first + expect(page1.data[0].name).toBe("Message 3"); + expect(page1.data[1].name).toBe("Message 2"); + + // Second page + const page2 = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { workspaceId, limit: 2, cursor: page1.nextCursor! } + ); + expect(page2.data).toHaveLength(1); + expect(page2.hasMore).toBe(false); + expect(page2.data[0].name).toBe("Message 1"); + }); + + it("paginates correctly when multiple messages share the same updatedAt", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + // Create 5 messages at the exact same timestamp (no timer advance) + vi.advanceTimersByTime(1000); + const ids: string[] = []; + for (let i = 0; i < 5; i++) { + const result = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: `Same-ts ${i + 1}`, + content: { text: `Content ${i + 1}` }, + } + ); + ids.push(String(result.id)); + } + + // Page through with limit=2 + const allNames: string[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 5; page++) { + const result = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { workspaceId, limit: 2, cursor } + ); + for (const item of result.data) { + allNames.push(item.name); + } + if (!result.hasMore) break; + cursor = result.nextCursor!; + } + + // All 5 messages should appear exactly once + expect(allNames).toHaveLength(5); + expect(new Set(allNames).size).toBe(5); + for (let i = 1; i <= 5; i++) { + expect(allNames).toContain(`Same-ts ${i}`); + } + }); + + // ── List updatedSince filter ─────────────────────────────────────── + + it("filters list by updatedSince", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + // Create first message at t=1000 + vi.advanceTimersByTime(1000); + await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "Old Message", + content: { text: "old" }, + } + ); + const cutoff = Date.now(); + + // Create second message at t=2000 + vi.advanceTimersByTime(1000); + await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "New Message", + content: { text: "new" }, + } + ); + + // updatedSince = cutoff should only return the newer message + const filtered = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { workspaceId, limit: 10, updatedSince: cutoff } + ); + expect(filtered.data).toHaveLength(1); + expect(filtered.data[0].name).toBe("New Message"); + + // Without filter should return both + const all = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { workspaceId, limit: 10 } + ); + expect(all.data).toHaveLength(2); + }); + + // ── List status filter ───────────────────────────────────────────── + + it("filters list by status", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + // Create two messages + vi.advanceTimersByTime(1000); + await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "Draft Message", + content: { text: "draft" }, + } + ); + + vi.advanceTimersByTime(1000); + const msg2 = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "Active Message", + content: { text: "active" }, + } + ); + + // Activate the second one + await t.mutation( + internal.automationApiInternals.activateOutboundMessageForAutomation, + { workspaceId, credentialId, outboundMessageId: msg2.id } + ); + + // Filter by active + const activeList = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { workspaceId, limit: 10, status: "active" } + ); + expect(activeList.data).toHaveLength(1); + expect(activeList.data[0].name).toBe("Active Message"); + + // Filter by draft + const draftList = await t.query( + internal.automationApiInternals.listOutboundMessagesForAutomation, + { workspaceId, limit: 10, status: "draft" } + ); + expect(draftList.data).toHaveLength(1); + expect(draftList.data[0].name).toBe("Draft Message"); + }); + + // ── Targeting validation ─────────────────────────────────────────── + + it("accepts valid targeting rules on create", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + const validTargeting = { + type: "group" as const, + operator: "and" as const, + conditions: [ + { + type: "condition" as const, + property: { source: "system" as const, key: "country" }, + operator: "equals" as const, + value: "US", + }, + ], + }; + + const created = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "Targeted Chat", + content: { text: "Hello US visitors" }, + targeting: validTargeting, + } + ); + expect(created.id).toBeDefined(); + + const fetched = await t.query( + internal.automationApiInternals.getOutboundMessageForAutomation, + { workspaceId, outboundMessageId: created.id } + ); + expect(fetched!.targeting).toEqual(validTargeting); + }); + + it("accepts valid targeting rules on update", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + const created = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "No Target", + content: { text: "Hello" }, + } + ); + + const validTargeting = { + type: "condition" as const, + property: { source: "system" as const, key: "browser" }, + operator: "equals" as const, + value: "Chrome", + }; + + const updated = await t.mutation( + internal.automationApiInternals.updateOutboundMessageForAutomation, + { + workspaceId, + credentialId, + outboundMessageId: created.id, + targeting: validTargeting, + } + ); + expect(updated.id).toBe(created.id); + }); + + it("rejects invalid targeting rules on create", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + // A condition missing required "property" field — passes Convex validator + // (since it's a union) but fails the business logic validateAudienceRule check + const invalidTargeting = { + type: "group" as const, + operator: "and" as const, + conditions: [ + { + type: "condition" as const, + // missing property, operator, value + }, + ], + }; + + await expect( + t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "Bad Target", + content: { text: "Hello" }, + targeting: invalidTargeting as any, + } + ) + ).rejects.toThrow(); + }); + + it("rejects invalid targeting rules on update", async () => { + const { workspaceId, credentialId } = await seedWorkspace(); + + const created = await t.mutation( + internal.automationApiInternals.createOutboundMessageForAutomation, + { + workspaceId, + credentialId, + name: "Target Update Test", + content: { text: "Hello" }, + } + ); + + const invalidTargeting = { + type: "group" as const, + operator: "and" as const, + conditions: [ + { + type: "condition" as const, + }, + ], + }; + + await expect( + t.mutation( + internal.automationApiInternals.updateOutboundMessageForAutomation, + { + workspaceId, + credentialId, + outboundMessageId: created.id, + targeting: invalidTargeting as any, + } + ) + ).rejects.toThrow(); + }); +}); diff --git a/packages/convex/tests/automationScopes.test.ts b/packages/convex/tests/automationScopes.test.ts index b9b5b2d..0ec79a9 100644 --- a/packages/convex/tests/automationScopes.test.ts +++ b/packages/convex/tests/automationScopes.test.ts @@ -60,8 +60,8 @@ describe("Automation Scopes", () => { expect(AUTOMATION_SCOPES).toContain("claims.manage"); }); - it("has exactly 16 v1 scopes", () => { - expect(AUTOMATION_SCOPES).toHaveLength(16); + it("has exactly 18 v1 scopes", () => { + expect(AUTOMATION_SCOPES).toHaveLength(18); }); }); });