205 lines
5.2 KiB
TypeScript
205 lines
5.2 KiB
TypeScript
import { v } from "convex/values";
|
|
import { mutation, query } from "./_generated/server";
|
|
|
|
/**
|
|
* Convex Approvals - Human-in-the-loop workflow approvals
|
|
*
|
|
* Features:
|
|
* - Real-time subscriptions (no polling needed!)
|
|
* - Automatic reactivity when approval status changes
|
|
* - Perfect for pausing workflows waiting for human input
|
|
*/
|
|
|
|
// Create a new approval request
|
|
export const create = mutation({
|
|
args: {
|
|
approvalId: v.string(),
|
|
workflowId: v.id("workflows"),
|
|
nodeId: v.optional(v.string()),
|
|
executionId: v.optional(v.string()),
|
|
message: v.string(),
|
|
userId: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const now = new Date().toISOString();
|
|
|
|
const approvalDoc = await ctx.db.insert("approvals", {
|
|
approvalId: args.approvalId,
|
|
workflowId: args.workflowId,
|
|
executionId: args.executionId,
|
|
nodeId: args.nodeId,
|
|
message: args.message,
|
|
status: "pending",
|
|
userId: args.userId,
|
|
createdAt: now,
|
|
});
|
|
|
|
return approvalDoc;
|
|
},
|
|
});
|
|
|
|
// Get approval by approvalId (for polling or one-time checks)
|
|
export const getByApprovalId = query({
|
|
args: { approvalId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const approval = await ctx.db
|
|
.query("approvals")
|
|
.withIndex("by_approvalId", (q) => q.eq("approvalId", args.approvalId))
|
|
.first();
|
|
|
|
return approval;
|
|
},
|
|
});
|
|
|
|
// Watch approval status - REAL-TIME SUBSCRIPTION
|
|
// Use this in your frontend to automatically resume when approved
|
|
export const watchStatus = query({
|
|
args: { approvalId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const approval = await ctx.db
|
|
.query("approvals")
|
|
.withIndex("by_approvalId", (q) => q.eq("approvalId", args.approvalId))
|
|
.first();
|
|
|
|
if (!approval) {
|
|
return { status: "not_found", approval: null };
|
|
}
|
|
|
|
return {
|
|
status: approval.status,
|
|
approval,
|
|
};
|
|
},
|
|
});
|
|
|
|
// Approve an approval request
|
|
export const approve = mutation({
|
|
args: {
|
|
approvalId: v.string(),
|
|
userId: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const approval = await ctx.db
|
|
.query("approvals")
|
|
.withIndex("by_approvalId", (q) => q.eq("approvalId", args.approvalId))
|
|
.first();
|
|
|
|
if (!approval) {
|
|
throw new Error(`Approval ${args.approvalId} not found`);
|
|
}
|
|
|
|
if (approval.status !== "pending") {
|
|
throw new Error(`Approval ${args.approvalId} already ${approval.status}`);
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
await ctx.db.patch(approval._id, {
|
|
status: "approved",
|
|
respondedAt: now,
|
|
respondedBy: args.userId,
|
|
});
|
|
|
|
return { success: true, status: "approved" };
|
|
},
|
|
});
|
|
|
|
// Reject an approval request
|
|
export const reject = mutation({
|
|
args: {
|
|
approvalId: v.string(),
|
|
userId: v.optional(v.string()),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const approval = await ctx.db
|
|
.query("approvals")
|
|
.withIndex("by_approvalId", (q) => q.eq("approvalId", args.approvalId))
|
|
.first();
|
|
|
|
if (!approval) {
|
|
throw new Error(`Approval ${args.approvalId} not found`);
|
|
}
|
|
|
|
if (approval.status !== "pending") {
|
|
throw new Error(`Approval ${args.approvalId} already ${approval.status}`);
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
await ctx.db.patch(approval._id, {
|
|
status: "rejected",
|
|
respondedAt: now,
|
|
respondedBy: args.userId,
|
|
});
|
|
|
|
return { success: true, status: "rejected", reason: args.reason };
|
|
},
|
|
});
|
|
|
|
// List pending approvals for a workflow
|
|
export const listPending = query({
|
|
args: { workflowId: v.id("workflows") },
|
|
handler: async (ctx, args) => {
|
|
const approvals = await ctx.db
|
|
.query("approvals")
|
|
.withIndex("by_workflow", (q) => q.eq("workflowId", args.workflowId))
|
|
.filter((q) => q.eq(q.field("status"), "pending"))
|
|
.collect();
|
|
|
|
return approvals;
|
|
},
|
|
});
|
|
|
|
// List all approvals for a workflow
|
|
export const listByWorkflow = query({
|
|
args: { workflowId: v.id("workflows") },
|
|
handler: async (ctx, args) => {
|
|
const approvals = await ctx.db
|
|
.query("approvals")
|
|
.withIndex("by_workflow", (q) => q.eq("workflowId", args.workflowId))
|
|
.order("desc")
|
|
.collect();
|
|
|
|
return approvals;
|
|
},
|
|
});
|
|
|
|
// Get approvals for a specific execution (useful for tracking what's blocking a run)
|
|
export const getByExecution = query({
|
|
args: { executionId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const approvals = await ctx.db
|
|
.query("approvals")
|
|
.withIndex("by_execution", (q) => q.eq("executionId", args.executionId))
|
|
.order("desc")
|
|
.collect();
|
|
|
|
return approvals;
|
|
},
|
|
});
|
|
|
|
// Clean up old approvals (approved/rejected older than 24 hours)
|
|
export const cleanup = mutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
const oldApprovals = await ctx.db
|
|
.query("approvals")
|
|
.filter((q) =>
|
|
q.and(
|
|
q.neq(q.field("status"), "pending"),
|
|
q.lt(q.field("createdAt"), oneDayAgo)
|
|
)
|
|
)
|
|
.collect();
|
|
|
|
for (const approval of oldApprovals) {
|
|
await ctx.db.delete(approval._id);
|
|
}
|
|
|
|
return { deleted: oldApprovals.length };
|
|
},
|
|
});
|