mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Delete Workflows app (thank you Vercel)
This commit is contained in:
parent
243ba48cb0
commit
c8e730eed8
@ -0,0 +1,6 @@
|
||||
-- Drop workflow-related tables now that the workflows feature has been removed.
|
||||
DROP TABLE IF EXISTS "WorkflowTrigger" CASCADE;
|
||||
DROP TABLE IF EXISTS "WorkflowExecution" CASCADE;
|
||||
DROP TABLE IF EXISTS "WorkflowTriggerToken" CASCADE;
|
||||
DROP TABLE IF EXISTS "CurrentlyCompilingWorkflow" CASCADE;
|
||||
DROP TABLE IF EXISTS "CompiledWorkflow" CASCADE;
|
||||
@ -828,98 +828,3 @@ model DataVaultEntry {
|
||||
@@unique([tenancyId, storeId, hashedKey])
|
||||
@@index([tenancyId, storeId])
|
||||
}
|
||||
|
||||
model WorkflowTriggerToken {
|
||||
tenancyId String @db.Uuid
|
||||
id String @default(uuid()) @db.Uuid
|
||||
|
||||
tokenHash String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
expiresAt DateTime
|
||||
|
||||
@@id([tenancyId, id])
|
||||
@@unique([tenancyId, tokenHash])
|
||||
}
|
||||
|
||||
model WorkflowTrigger {
|
||||
tenancyId String @db.Uuid
|
||||
id String @default(uuid()) @db.Uuid
|
||||
executionId String @db.Uuid
|
||||
|
||||
triggerData Json
|
||||
|
||||
// the following fields determine the state of the trigger:
|
||||
// - scheduledAt && !compiledWorkflowId && !output && !error: the trigger is scheduled to be executed
|
||||
// - !scheduledAt && !compiledWorkflowId: the trigger was scheduled, but its workflow subsequently deleted. The trigger never ran
|
||||
// - !scheduledAt && compiledWorkflowId && !output && !error: the trigger is currently executing
|
||||
// - !scheduledAt && compiledWorkflowId && output && !error: the trigger has successfully completed execution
|
||||
// - !scheduledAt && compiledWorkflowId && !output && error: the trigger has failed execution
|
||||
// All other combinations are invalid.
|
||||
scheduledAt DateTime?
|
||||
output Json?
|
||||
error Json?
|
||||
compiledWorkflowId String? @db.Uuid
|
||||
compiledWorkflow CompiledWorkflow? @relation(fields: [tenancyId, compiledWorkflowId], references: [tenancyId, id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
execution WorkflowExecution @relation(fields: [tenancyId, executionId], references: [tenancyId, id])
|
||||
|
||||
@@id([tenancyId, id])
|
||||
}
|
||||
|
||||
model WorkflowExecution {
|
||||
tenancyId String @db.Uuid
|
||||
id String @default(uuid()) @db.Uuid
|
||||
|
||||
workflowId String
|
||||
|
||||
triggerIds String[]
|
||||
triggers WorkflowTrigger[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([tenancyId, id])
|
||||
}
|
||||
|
||||
model CurrentlyCompilingWorkflow {
|
||||
tenancyId String @db.Uuid
|
||||
workflowId String
|
||||
compilationVersion Int
|
||||
sourceHash String
|
||||
|
||||
startedCompilingAt DateTime @default(now())
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([tenancyId, workflowId, compilationVersion, sourceHash])
|
||||
}
|
||||
|
||||
model CompiledWorkflow {
|
||||
tenancyId String @db.Uuid
|
||||
id String @default(uuid()) @db.Uuid
|
||||
workflowId String // note: The workflow with this ID may have been edited or deleted in the meantime, so there may be multiple CompiledWorkflows with the same workflowId
|
||||
compilationVersion Int
|
||||
sourceHash String
|
||||
|
||||
// exactly one of [compiledCode, compileError] must be set
|
||||
compiledCode String?
|
||||
compileError String?
|
||||
|
||||
compiledAt DateTime @default(now())
|
||||
registeredTriggers String[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
workflowTriggers WorkflowTrigger[]
|
||||
|
||||
@@id([tenancyId, id])
|
||||
@@unique([tenancyId, workflowId, compilationVersion, sourceHash])
|
||||
}
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import { getTenancy } from "@/lib/tenancies";
|
||||
import { ensureUpstashSignature } from "@/lib/upstash";
|
||||
import { triggerScheduledWorkflows } from "@/lib/workflows";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
hidden: true,
|
||||
},
|
||||
request: yupObject({
|
||||
headers: yupObject({
|
||||
"upstash-signature": yupTuple([yupString().defined()]).defined(),
|
||||
}).defined(),
|
||||
body: yupObject({
|
||||
tenancyId: yupString().defined(),
|
||||
}).defined(),
|
||||
method: yupString().oneOf(["POST"]).defined(),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["success"]).defined(),
|
||||
}),
|
||||
handler: async (req, fullReq) => {
|
||||
await ensureUpstashSignature(fullReq);
|
||||
|
||||
const tenancy = await getTenancy(req.body.tenancyId);
|
||||
if (!tenancy) {
|
||||
throw new StackAssertionError(`Tenancy not found for scheduled trigger`, { tenancyId: req.body.tenancyId });
|
||||
}
|
||||
|
||||
await triggerScheduledWorkflows(tenancy);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "success",
|
||||
} as const;
|
||||
},
|
||||
});
|
||||
|
||||
@ -5,7 +5,6 @@ import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-chec
|
||||
import { Tenancy, getSoleTenancyFromProjectBranch, getTenancy } from "@/lib/tenancies";
|
||||
import { PrismaTransaction } from "@/lib/types";
|
||||
import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks";
|
||||
import { triggerWorkflows } from "@/lib/workflows";
|
||||
import { RawQuery, getPrismaClientForSourceOfTruth, getPrismaClientForTenancy, getPrismaSchemaForSourceOfTruth, getPrismaSchemaForTenancy, globalPrismaClient, rawQuery, retryTransaction, sqlQuoteIdent } from "@/prisma-client";
|
||||
import { createCrudHandlers } from "@/route-handlers/crud-handler";
|
||||
import { uploadAndGetUrl } from "@/s3";
|
||||
@ -649,14 +648,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
|
||||
await createPersonalTeamIfEnabled(prisma, auth.tenancy, result);
|
||||
|
||||
// if the user is not an anonymous user, trigger onSignUp workflows
|
||||
if (!result.is_anonymous) {
|
||||
await triggerWorkflows(auth.tenancy, {
|
||||
type: "sign-up",
|
||||
userId: result.id,
|
||||
});
|
||||
}
|
||||
|
||||
runAsynchronouslyAndWaitUntil(sendUserCreatedWebhook({
|
||||
projectId: auth.project.id,
|
||||
data: result,
|
||||
@ -959,12 +950,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
|
||||
// if we went from anonymous to non-anonymous:
|
||||
if (oldUser.isAnonymous && data.is_anonymous === false) {
|
||||
// trigger onSignUp workflows
|
||||
await triggerWorkflows(auth.tenancy, {
|
||||
type: "sign-up",
|
||||
userId: params.user_id,
|
||||
});
|
||||
|
||||
// rename the personal team
|
||||
await tx.team.updateMany({
|
||||
where: {
|
||||
|
||||
@ -1,582 +0,0 @@
|
||||
import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client";
|
||||
import { traceSpan } from "@/utils/telemetry";
|
||||
import { allPromisesAndWaitUntilEach, runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
|
||||
import { CompiledWorkflow, Prisma } from "@prisma/client";
|
||||
import { isStringArray } from "@stackframe/stack-shared/dist/utils/arrays";
|
||||
import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes";
|
||||
import { generateSecureRandomString, hash } from "@stackframe/stack-shared/dist/utils/crypto";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StackAssertionError, captureError, errorToNiceString, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { bundleJavaScript, initializeEsbuild } from "@stackframe/stack-shared/dist/utils/esbuild";
|
||||
import { runAsynchronously, timeout, wait } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { Result } from "@stackframe/stack-shared/dist/utils/results";
|
||||
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
|
||||
import { Freestyle } from "./freestyle";
|
||||
import { Tenancy } from "./tenancies";
|
||||
import { upstash } from "./upstash";
|
||||
|
||||
const externalPackages: Record<string, string> = {};
|
||||
|
||||
type WorkflowRegisteredTriggerType = "sign-up";
|
||||
|
||||
type WorkflowTrigger =
|
||||
| {
|
||||
type: "sign-up",
|
||||
userId: string,
|
||||
}
|
||||
| {
|
||||
type: "compile",
|
||||
}
|
||||
| {
|
||||
type: "callback",
|
||||
callbackId: string,
|
||||
scheduledAtMillis: number,
|
||||
data: unknown,
|
||||
callerTriggerId: string,
|
||||
executionId: string,
|
||||
};
|
||||
|
||||
async function hashWorkflowSource(source: string) {
|
||||
return encodeBase64(await hash({
|
||||
purpose: "stack-auth-workflow-source",
|
||||
value: JSON.stringify(source),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function hashWorkflowTriggerToken(token: string) {
|
||||
return encodeBase64(await hash({
|
||||
purpose: "stack-auth-workflow-trigger-token",
|
||||
value: token,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function compileWorkflowSource(source: string): Promise<Result<string, string>> {
|
||||
const bundleResult = await bundleJavaScript({
|
||||
"/source.tsx": source,
|
||||
"/entry.js": `
|
||||
import { StackServerApp } from 'https://esm.sh/@stackframe/js@2.8.36?target=es2021&standalone';
|
||||
|
||||
globalThis.navigator.onLine = true;
|
||||
|
||||
export default async () => {
|
||||
globalThis.stackApp = new StackServerApp({
|
||||
tokenStore: null,
|
||||
extraRequestHeaders: {
|
||||
"x-stack-workflow-token": process.env.STACK_WORKFLOW_TOKEN_SECRET,
|
||||
}
|
||||
});
|
||||
|
||||
const registeredTriggers = new Map();
|
||||
globalThis._registerTrigger = (triggerType, func) => {
|
||||
registeredTriggers.set(triggerType, func);
|
||||
};
|
||||
_registerTrigger("compile", () => ({
|
||||
registeredTriggers: [...registeredTriggers.keys()],
|
||||
}));
|
||||
|
||||
const registeredCallbacks = new Map();
|
||||
globalThis.registerCallback = (callbackId, func) => {
|
||||
registeredCallbacks.set(callbackId, func);
|
||||
};
|
||||
_registerTrigger("callback", ({ callbackId, data }) => {
|
||||
const callbackFunc = registeredCallbacks.get(callbackId);
|
||||
if (!callbackFunc) {
|
||||
throw new Error(\`Callback \${callbackId} not found. Was it maybe deleted from the workflow?\`);
|
||||
}
|
||||
return callbackFunc(data);
|
||||
});
|
||||
let scheduledCallback = undefined;
|
||||
globalThis.scheduleCallback = ({ callbackId, data, scheduleAt }) => {
|
||||
if (scheduledCallback) {
|
||||
throw new Error("Only one callback can be scheduled at a time!");
|
||||
}
|
||||
scheduledCallback = { callbackId, data, scheduleAtMillis: scheduleAt.getTime() };
|
||||
return scheduledCallback;
|
||||
};
|
||||
|
||||
function makeTriggerRegisterer(str, typeCb, argsCb) {
|
||||
globalThis[str] = (...args) => _registerTrigger(typeCb(...args.slice(0, -1)), async (data) => args[args.length - 1](...await argsCb(data)));
|
||||
}
|
||||
|
||||
makeTriggerRegisterer("onSignUp", () => "sign-up", async (data) => [await stackApp.getUser(data.userId, { or: "throw" })]);
|
||||
|
||||
await import("./source.tsx");
|
||||
|
||||
const triggerData = JSON.parse(process.env.STACK_WORKFLOW_TRIGGER_DATA);
|
||||
const trigger = registeredTriggers.get(triggerData.type);
|
||||
if (!trigger) {
|
||||
throw new Error(\`Workflow trigger \${triggerData.type} invoked but not found. Please report this to the developers.\`);
|
||||
}
|
||||
const triggerOutput = await trigger(triggerData);
|
||||
if (scheduledCallback !== undefined) {
|
||||
if (triggerOutput !== scheduledCallback) {
|
||||
throw new Error("When calling scheduleCallback, you must return its return value in the event handler!");
|
||||
}
|
||||
return {
|
||||
scheduledCallback: triggerOutput,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
triggerOutput,
|
||||
};
|
||||
}
|
||||
}
|
||||
`,
|
||||
}, {
|
||||
format: 'esm',
|
||||
keepAsImports: Object.keys(externalPackages),
|
||||
allowHttpImports: true,
|
||||
});
|
||||
if (bundleResult.status === "error") {
|
||||
return Result.error(bundleResult.error);
|
||||
}
|
||||
return Result.ok(bundleResult.data);
|
||||
}
|
||||
|
||||
async function compileWorkflow(tenancy: Tenancy, workflowId: string): Promise<Result<{ compiledCode: string, registeredTriggers: string[] }, { compileError?: string }>> {
|
||||
return await traceSpan(`compileWorkflow ${workflowId}`, async () => {
|
||||
if (!(workflowId in tenancy.config.workflows.availableWorkflows)) {
|
||||
throw new StackAssertionError(`Workflow ${workflowId} not found`);
|
||||
}
|
||||
const workflow = tenancy.config.workflows.availableWorkflows[workflowId];
|
||||
const res = await timeout(async () => {
|
||||
console.log(`Compiling workflow ${workflowId}...`);
|
||||
const compiledCodeResult = await compileWorkflowSource(workflow.tsSource);
|
||||
if (compiledCodeResult.status === "error") {
|
||||
return Result.error({ compileError: `Failed to compile workflow: ${compiledCodeResult.error}` });
|
||||
}
|
||||
|
||||
console.log(`Compiled workflow source for ${workflowId}, running compilation trigger...`, { compiledCodeLength: compiledCodeResult.data.length });
|
||||
|
||||
const compileTriggerResult = await triggerWorkflowRaw(tenancy, compiledCodeResult.data, {
|
||||
type: "compile",
|
||||
});
|
||||
if (compileTriggerResult.status === "error") {
|
||||
return Result.error({ compileError: `Failed to initialize workflow: ${compileTriggerResult.error}` });
|
||||
}
|
||||
|
||||
console.log(`Compilation trigger completed!`);
|
||||
|
||||
const compileTriggerOutputResult = compileTriggerResult.data;
|
||||
if (typeof compileTriggerOutputResult !== "object" || !compileTriggerOutputResult || !("triggerOutput" in compileTriggerOutputResult)) {
|
||||
captureError("workflows-compile-trigger-output", new StackAssertionError(`Failed to parse compile trigger output`, { compileTriggerOutputResult }));
|
||||
return Result.error({ compileError: `Failed to parse compile trigger output` });
|
||||
}
|
||||
const registeredTriggers = (compileTriggerOutputResult.triggerOutput as any)?.registeredTriggers;
|
||||
if (!isStringArray(registeredTriggers)) {
|
||||
captureError("workflows-compile-trigger-output", new StackAssertionError(`Failed to parse compile trigger output, should be array of strings`, { compileTriggerOutputResult }));
|
||||
return Result.error({ compileError: `Failed to parse compile trigger output, should be array of strings` });
|
||||
}
|
||||
|
||||
console.log(`Workflow ${workflowId} compiled successfully, returning result...`, { registeredTriggers });
|
||||
|
||||
return Result.ok({
|
||||
compiledCode: compiledCodeResult.data,
|
||||
registeredTriggers: registeredTriggers,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
if (res.status === "error") {
|
||||
console.warn(`Timed out compiling workflow ${workflowId} after ${res.error.ms}ms`, { res });
|
||||
return Result.error({ compileError: `Timed out compiling workflow ${workflowId} after ${res.error.ms}ms` });
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
import.meta.vitest?.test("compileWorkflow", async ({ expect }) => {
|
||||
const compileAndGetResult = async (tsSource: string) => {
|
||||
const tenancy = {
|
||||
id: "01234567-89ab-cdef-0123-456789abcdef",
|
||||
project: {
|
||||
id: "test-project",
|
||||
},
|
||||
config: {
|
||||
workflows: {
|
||||
availableWorkflows: {
|
||||
"test-workflow": {
|
||||
enabled: true,
|
||||
tsSource,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return await compileWorkflow(tenancy as any, "test-workflow");
|
||||
};
|
||||
const compileAndGetRegisteredTriggers = async (tsSource: string) => {
|
||||
const res = await compileAndGetResult(tsSource);
|
||||
if (res.status === "error") throw new StackAssertionError(`Failed to compile workflow: ${errorToNiceString(res.error)}`, { cause: res.error });
|
||||
return res.data.registeredTriggers;
|
||||
};
|
||||
|
||||
expect(await compileAndGetRegisteredTriggers("console.log('hello, world!');")).toEqual([
|
||||
"compile",
|
||||
"callback",
|
||||
]);
|
||||
expect(await compileAndGetRegisteredTriggers("onSignUp(() => {}); registerCallback('test', () => {});")).toEqual([
|
||||
"compile",
|
||||
"callback",
|
||||
"sign-up",
|
||||
]);
|
||||
expect(await compileAndGetResult("return return return return;")).toMatchInlineSnapshot(`
|
||||
{
|
||||
"error": {
|
||||
"compileError": "Failed to compile workflow: Build failed with 1 error:
|
||||
virtual:/source.tsx:1:7: ERROR: Unexpected "return"",
|
||||
},
|
||||
"status": "error",
|
||||
}
|
||||
`);
|
||||
expect(await compileAndGetResult("console.log('hello, world!'); throw new Error('test');")).toMatchInlineSnapshot(`
|
||||
{
|
||||
"error": {
|
||||
"compileError": "Failed to initialize workflow: test",
|
||||
},
|
||||
"status": "error",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
async function compileAndGetEnabledWorkflows(tenancy: Tenancy): Promise<Map<string, CompiledWorkflow>> {
|
||||
// initialize ESBuild early so it doesn't count towards the 10s compilation timeout later
|
||||
await initializeEsbuild();
|
||||
|
||||
const compilationVersion = 1;
|
||||
const enabledWorkflows = new Map(await Promise.all(Object.entries(tenancy.config.workflows.availableWorkflows)
|
||||
.filter(([_, workflow]) => workflow.enabled)
|
||||
.map(async ([workflowId, workflow]) => [workflowId, {
|
||||
id: workflowId,
|
||||
workflow,
|
||||
sourceHash: await hashWorkflowSource(workflow.tsSource),
|
||||
}] as const)));
|
||||
|
||||
const getWorkflowsToCompile = async (tx: Prisma.TransactionClient) => {
|
||||
const compiledWorkflows = await tx.compiledWorkflow.findMany({
|
||||
where: {
|
||||
tenancyId: tenancy.id,
|
||||
workflowId: { in: [...enabledWorkflows.keys()] },
|
||||
compilationVersion,
|
||||
sourceHash: { in: [...enabledWorkflows.values()].map(({ sourceHash }) => sourceHash) },
|
||||
},
|
||||
});
|
||||
|
||||
const found = new Map<string, CompiledWorkflow>();
|
||||
const missing = new Set(enabledWorkflows.keys());
|
||||
for (const compiledWorkflow of compiledWorkflows) {
|
||||
const enabledWorkflow = enabledWorkflows.get(compiledWorkflow.workflowId) ?? throwErr(`Compiled workflow ${compiledWorkflow.workflowId} not found in enabled workflows — this should not happen due to our Prisma filter!`);
|
||||
if (enabledWorkflow.sourceHash === compiledWorkflow.sourceHash) {
|
||||
found.set(compiledWorkflow.workflowId, compiledWorkflow);
|
||||
missing.delete(compiledWorkflow.workflowId);
|
||||
}
|
||||
}
|
||||
|
||||
const toCompile: string[] = [];
|
||||
const waiting: string[] = [];
|
||||
for (const workflowId of missing) {
|
||||
const enabledWorkflow = enabledWorkflows.get(workflowId) ?? throwErr(`Enabled workflow ${workflowId} not found in enabled workflows — this should not happen due to our Prisma filter!`);
|
||||
const currentlyCompiling = await tx.currentlyCompilingWorkflow.findUnique({
|
||||
where: {
|
||||
tenancyId_workflowId_compilationVersion_sourceHash: {
|
||||
tenancyId: tenancy.id,
|
||||
workflowId,
|
||||
compilationVersion,
|
||||
sourceHash: enabledWorkflow.sourceHash,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (currentlyCompiling) {
|
||||
waiting.push(workflowId);
|
||||
} else {
|
||||
toCompile.push(workflowId);
|
||||
}
|
||||
}
|
||||
|
||||
if (toCompile.length > 0) {
|
||||
await tx.currentlyCompilingWorkflow.createMany({
|
||||
data: toCompile.map((workflowId) => ({
|
||||
tenancyId: tenancy.id,
|
||||
compilationVersion,
|
||||
workflowId,
|
||||
sourceHash: enabledWorkflows.get(workflowId)?.sourceHash ?? throwErr(`Enabled workflow ${workflowId} not found in enabled workflows — this should not happen due to our Prisma filter!`),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
toCompile,
|
||||
waiting,
|
||||
workflows: found,
|
||||
};
|
||||
};
|
||||
|
||||
let retryInfo = [];
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
for (let retries = 0; retries < 10; retries++) {
|
||||
const todo = await retryTransaction(prisma, async (tx) => {
|
||||
return await getWorkflowsToCompile(tx);
|
||||
}, { level: "serializable" });
|
||||
|
||||
retryInfo.push({
|
||||
toCompile: todo.toCompile,
|
||||
waiting: todo.waiting,
|
||||
done: [...todo.workflows.entries()].map(([workflowId, workflow]) => workflowId),
|
||||
});
|
||||
|
||||
if (todo.toCompile.length === 0 && todo.waiting.length === 0) {
|
||||
return todo.workflows;
|
||||
}
|
||||
|
||||
await allPromisesAndWaitUntilEach(todo.toCompile.map(async (workflowId) => {
|
||||
const enabledWorkflow = enabledWorkflows.get(workflowId) ?? throwErr(`Enabled workflow ${workflowId} not found in enabled workflows — this should not happen due to our Prisma filter!`);
|
||||
try {
|
||||
const compiledWorkflow = await compileWorkflow(tenancy, workflowId);
|
||||
await prisma.compiledWorkflow.create({
|
||||
data: {
|
||||
tenancyId: tenancy.id,
|
||||
compilationVersion,
|
||||
workflowId,
|
||||
sourceHash: enabledWorkflow.sourceHash,
|
||||
...compiledWorkflow.status === "ok" ? {
|
||||
compiledCode: compiledWorkflow.data.compiledCode,
|
||||
registeredTriggers: compiledWorkflow.data.registeredTriggers,
|
||||
} : {
|
||||
compileError: compiledWorkflow.error.compileError,
|
||||
registeredTriggers: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.currentlyCompilingWorkflow.delete({
|
||||
where: {
|
||||
tenancyId_workflowId_compilationVersion_sourceHash: {
|
||||
tenancyId: tenancy.id,
|
||||
compilationVersion,
|
||||
workflowId,
|
||||
sourceHash: enabledWorkflow.sourceHash,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
const { count } = await prisma.currentlyCompilingWorkflow.deleteMany({
|
||||
where: {
|
||||
tenancyId: tenancy.id,
|
||||
startedCompilingAt: { lt: new Date(Date.now() - 40_000) },
|
||||
},
|
||||
});
|
||||
if (count > 0) {
|
||||
captureError("workflows-compile-timeout", new StackAssertionError(`Deleted ${count} currently compiling workflows that were compiling for more than 40 seconds; this probably indicates a bug in the workflow compilation code (as they should time out after 30 seconds)`));
|
||||
}
|
||||
|
||||
await wait(1000);
|
||||
}
|
||||
|
||||
throw new StackAssertionError(`Timed out compiling workflows after retries`, { retryInfo });
|
||||
}
|
||||
|
||||
async function triggerWorkflowRaw(tenancy: Tenancy, compiledWorkflowCode: string, trigger: WorkflowTrigger): Promise<Result<unknown, string>> {
|
||||
return await traceSpan({ description: `triggerWorkflowRaw ${trigger.type}` }, async () => {
|
||||
const workflowToken = generateSecureRandomString();
|
||||
const workflowTriggerToken = await globalPrismaClient.workflowTriggerToken.create({
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 35),
|
||||
tenancyId: tenancy.id,
|
||||
tokenHash: await hashWorkflowTriggerToken(workflowToken),
|
||||
},
|
||||
});
|
||||
|
||||
const tokenRefreshInterval = setInterval(() => {
|
||||
runAsynchronously(async () => {
|
||||
await globalPrismaClient.workflowTriggerToken.update({
|
||||
where: {
|
||||
tenancyId_id: {
|
||||
tenancyId: tenancy.id,
|
||||
id: workflowTriggerToken.id,
|
||||
},
|
||||
},
|
||||
data: { expiresAt: new Date(Date.now() + 1000 * 35) },
|
||||
});
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
try {
|
||||
const freestyle = new Freestyle();
|
||||
const apiUrl = new URL("/", getEnvVariable("NEXT_PUBLIC_STACK_API_URL").replace("http://localhost", "http://host.docker.internal"));
|
||||
const freestyleRes = await freestyle.executeScript(compiledWorkflowCode, {
|
||||
envVars: {
|
||||
STACK_WORKFLOW_TRIGGER_DATA: JSON.stringify(trigger),
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID: tenancy.project.id,
|
||||
NEXT_PUBLIC_STACK_API_URL: apiUrl.toString(),
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "<placeholder publishable client key; the actual auth happens with the workflow token>",
|
||||
STACK_SECRET_SERVER_KEY: "<placeholder secret server key; the actual auth happens with the workflow token>",
|
||||
STACK_WORKFLOW_TOKEN_SECRET: workflowToken,
|
||||
},
|
||||
nodeModules: Object.fromEntries(Object.entries(externalPackages).map(([packageName, version]) => [packageName, version])),
|
||||
networkPermissions: [
|
||||
{
|
||||
action: "allow",
|
||||
behavior: "exact",
|
||||
query: apiUrl.host,
|
||||
},
|
||||
],
|
||||
});
|
||||
return Result.map(freestyleRes, (data) => data.result);
|
||||
} finally {
|
||||
clearInterval(tokenRefreshInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createScheduledTrigger(tenancy: Tenancy, workflowId: string, trigger: WorkflowTrigger, scheduledAt: Date) {
|
||||
const executionId = trigger.type === "callback" ? trigger.executionId : generateUuid();
|
||||
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const dbTrigger = await prisma.workflowTrigger.create({
|
||||
data: {
|
||||
triggerData: trigger as any,
|
||||
scheduledAt,
|
||||
execution: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
tenancyId_id: {
|
||||
tenancyId: tenancy.id,
|
||||
id: executionId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenancyId: tenancy.id,
|
||||
workflowId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await upstash.publishJSON({
|
||||
url: new URL(`/api/v1/internal/trigger/run-scheduled`, getEnvVariable("NEXT_PUBLIC_STACK_API_URL").replace("http://localhost", "http://host.docker.internal")).toString(),
|
||||
body: {
|
||||
tenancyId: tenancy.id,
|
||||
},
|
||||
notBefore: Math.floor(scheduledAt.getTime() / 1000),
|
||||
});
|
||||
|
||||
return dbTrigger;
|
||||
}
|
||||
|
||||
export async function triggerScheduledWorkflows(tenancy: Tenancy) {
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const compiledWorkflows = await compileAndGetEnabledWorkflows(tenancy);
|
||||
|
||||
const toTrigger = await retryTransaction(prisma, async (tx) => {
|
||||
const triggers = await tx.workflowTrigger.findMany({
|
||||
where: {
|
||||
tenancyId: tenancy.id,
|
||||
scheduledAt: { lt: new Date(Date.now() + 5_000) },
|
||||
},
|
||||
include: {
|
||||
execution: true,
|
||||
},
|
||||
orderBy: {
|
||||
scheduledAt: "asc",
|
||||
},
|
||||
// let's take multiple triggers so we can catch up on the backlog, in case some triggers never went through (eg. if the queue was down)
|
||||
// however, to prevent deadlocks as we are doing multiple writes in this transaction, we randomize it (so there's
|
||||
// a chance that we only take one trigger, which would never deadlock)
|
||||
take: Math.floor(1 + Math.random() * 3),
|
||||
});
|
||||
const toTrigger = [];
|
||||
for (const trigger of triggers) {
|
||||
const compiledWorkflow = compiledWorkflows.get(trigger.execution.workflowId);
|
||||
const updatedTrigger = await tx.workflowTrigger.update({
|
||||
where: {
|
||||
tenancyId_id: {
|
||||
tenancyId: tenancy.id,
|
||||
id: trigger.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
scheduledAt: null,
|
||||
compiledWorkflowId: compiledWorkflow?.id ?? null,
|
||||
output: Prisma.DbNull,
|
||||
error: Prisma.DbNull,
|
||||
},
|
||||
include: {
|
||||
execution: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (compiledWorkflow) {
|
||||
toTrigger.push(updatedTrigger);
|
||||
} else {
|
||||
// the workflow was deleted; we don't run the trigger, but we still mark it in the DB
|
||||
}
|
||||
}
|
||||
return toTrigger;
|
||||
}, { level: "serializable" });
|
||||
|
||||
await allPromisesAndWaitUntilEach(toTrigger.map(async (trigger) => {
|
||||
const compiledWorkflow = compiledWorkflows.get(trigger.execution.workflowId) ?? throwErr(`Compiled workflow ${trigger.execution.workflowId} not found in trigger execution; this should not happen because we should've already checked for this in the transaction!`);
|
||||
if (compiledWorkflow.compiledCode === null) {
|
||||
return Result.error(`Workflow ${compiledWorkflow.id} failed to compile: ${compiledWorkflow.compileError}`);
|
||||
}
|
||||
|
||||
const res = await triggerWorkflowRaw(tenancy, compiledWorkflow.compiledCode, trigger.triggerData as WorkflowTrigger);
|
||||
if (res.status === "error") {
|
||||
// This is probably fine and just a user error, but let's log it regardless
|
||||
console.log(`Compiled workflow failed to process trigger: ${res.error}`, { trigger, compiledWorkflowId: compiledWorkflow.id, res });
|
||||
} else {
|
||||
if (res.data && typeof res.data === "object" && "scheduledCallback" in res.data && res.data.scheduledCallback && typeof res.data.scheduledCallback === "object") {
|
||||
const scheduledCallback: any = res.data.scheduledCallback;
|
||||
const callbackId = `${scheduledCallback.callbackId}`;
|
||||
const scheduleAt = new Date(scheduledCallback.scheduleAtMillis);
|
||||
const callbackData = scheduledCallback.data;
|
||||
await createScheduledTrigger(
|
||||
tenancy,
|
||||
compiledWorkflow.id,
|
||||
{
|
||||
type: "callback",
|
||||
callbackId,
|
||||
data: callbackData,
|
||||
scheduledAtMillis: scheduleAt.getTime(),
|
||||
callerTriggerId: trigger.id,
|
||||
executionId: trigger.executionId,
|
||||
},
|
||||
scheduleAt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
await prisma.workflowTrigger.update({
|
||||
where: {
|
||||
tenancyId_id: {
|
||||
tenancyId: tenancy.id,
|
||||
id: trigger.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...res.status === "ok" ? {
|
||||
output: res.data as any,
|
||||
} : {
|
||||
error: res.error,
|
||||
},
|
||||
},
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
}));
|
||||
}
|
||||
|
||||
export async function triggerWorkflows(tenancy: Tenancy, trigger: WorkflowTrigger & { type: WorkflowRegisteredTriggerType }) {
|
||||
runAsynchronouslyAndWaitUntil(async () => {
|
||||
const compiledWorkflows = await compileAndGetEnabledWorkflows(tenancy);
|
||||
const promises = [...compiledWorkflows]
|
||||
.filter(([_, compiledWorkflow]) => compiledWorkflow.registeredTriggers.includes(trigger.type))
|
||||
.map(async ([workflowId, compiledWorkflow]) => {
|
||||
await createScheduledTrigger(tenancy, workflowId, trigger, new Date());
|
||||
});
|
||||
await allPromisesAndWaitUntilEach(promises);
|
||||
});
|
||||
}
|
||||
@ -30,7 +30,6 @@ const corsAllowedRequestHeaders = [
|
||||
'x-stack-secret-server-key',
|
||||
'x-stack-super-secret-admin-key',
|
||||
'x-stack-admin-access-token',
|
||||
'x-stack-workflow-token',
|
||||
|
||||
// User auth
|
||||
'x-stack-refresh-token',
|
||||
|
||||
@ -6,7 +6,6 @@ import { checkApiKeySet, checkApiKeySetQuery } from "@/lib/internal-api-keys";
|
||||
import { getProjectQuery, listManagedProjectIds } from "@/lib/projects";
|
||||
import { DEFAULT_BRANCH_ID, Tenancy, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
|
||||
import { decodeAccessToken } from "@/lib/tokens";
|
||||
import { hashWorkflowTriggerToken } from "@/lib/workflows";
|
||||
import { globalPrismaClient, rawQueryAll } from "@/prisma-client";
|
||||
import { KnownErrors } from "@stackframe/stack-shared";
|
||||
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
|
||||
@ -168,7 +167,6 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque
|
||||
const secretServerKey = req.headers.get("x-stack-secret-server-key");
|
||||
const superSecretAdminKey = req.headers.get("x-stack-super-secret-admin-key");
|
||||
const adminAccessToken = req.headers.get("x-stack-admin-access-token");
|
||||
const workflowToken = req.headers.get("x-stack-workflow-token");
|
||||
const accessToken = req.headers.get("x-stack-access-token");
|
||||
const developmentKeyOverride = req.headers.get("x-stack-development-override-key"); // in development, the internal project's API key can optionally be used to access any project
|
||||
const allowAnonymousUser = req.headers.get("x-stack-allow-anonymous-user") === "true";
|
||||
@ -277,25 +275,6 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque
|
||||
} else if (adminAccessToken) {
|
||||
// TODO put this into the bundled queries above (not so important because this path is quite rare)
|
||||
await extractUserFromAdminAccessToken({ token: adminAccessToken, projectId }); // assert that the admin token is valid
|
||||
} else if (workflowToken) {
|
||||
// TODO put this into the bundled queries above (not so important because this path is quite rare)
|
||||
if (requestType === "admin") {
|
||||
throw new KnownErrors.AdminAuthenticationRequired();
|
||||
}
|
||||
if (!["client", "server"].includes(requestType)) {
|
||||
throw new StackAssertionError(`Unexpected request type in workflow token auth: ${requestType}. This should never happen because we should've filtered this earlier`);
|
||||
}
|
||||
const workflowTokenHash = await hashWorkflowTriggerToken(workflowToken);
|
||||
const workflowTriggerToken = tenancy ? await globalPrismaClient.workflowTriggerToken.findUnique({
|
||||
where: {
|
||||
tenancyId_tokenHash: {
|
||||
tenancyId: tenancy.id,
|
||||
tokenHash: workflowTokenHash,
|
||||
},
|
||||
},
|
||||
}) : undefined;
|
||||
if (!workflowTriggerToken) throw new KnownErrors.WorkflowTokenDoesNotExist();
|
||||
if (workflowTriggerToken.expiresAt < new Date()) throw new KnownErrors.WorkflowTokenExpired();
|
||||
} else {
|
||||
switch (requestType) {
|
||||
case "client": {
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "@/components/router";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, toast } from "@stackframe/stack-ui";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { useAdminApp } from "../../use-admin-app";
|
||||
|
||||
export default function WorkflowDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workflowId = params.workflowId as string;
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const stackAdminApp = useAdminApp();
|
||||
const project = stackAdminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
|
||||
const availableWorkflows = config.workflows.availableWorkflows;
|
||||
const workflow = workflowId in availableWorkflows ? availableWorkflows[workflowId] : undefined;
|
||||
const [workflowContent, setWorkflowContent] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (workflow && workflow.tsSource) {
|
||||
setWorkflowContent(workflow.tsSource);
|
||||
}
|
||||
}, [workflow]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await project.updateConfig({
|
||||
[`workflows.availableWorkflows.${workflowId}.tsSource`]: workflowContent
|
||||
});
|
||||
toast({ title: "Workflow saved successfully" });
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to save workflow", variant: "destructive" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push(`/projects/${projectId}/workflows`);
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
if (!workflow) return;
|
||||
setIsToggling(true);
|
||||
try {
|
||||
await project.updateConfig({
|
||||
[`workflows.availableWorkflows.${workflowId}.enabled`]: !workflow.enabled,
|
||||
});
|
||||
toast({ title: workflow.enabled ? "Workflow disabled" : "Workflow enabled" });
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to toggle workflow", variant: "destructive" });
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (workflow === undefined) {
|
||||
return (
|
||||
<AppEnabledGuard appId="workflows">
|
||||
<PageLayout title="Workflow Not Found">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-muted-foreground mb-4">The workflow {JSON.stringify(workflowId)} was not found.</p>
|
||||
<Button onClick={handleBack} variant="outline">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Workflows
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="workflows">
|
||||
<PageLayout
|
||||
title={workflow.displayName || workflowId}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleBack} variant="outline" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleToggleEnabled} size="sm" variant={workflow.enabled ? "outline" : "default"} disabled={isToggling}>
|
||||
{workflow.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isLoading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-6 flex-1" style={{ flexBasis: "0px", overflow: "scroll" }}>
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Workflow Definition</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{workflow.enabled ? "This workflow is enabled" : "This workflow is disabled"}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={workflowContent}
|
||||
onChange={(e) => setWorkflowContent(e.target.value)}
|
||||
className="w-full min-h-[600px] p-4 font-mono text-sm bg-muted/50 border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
spellCheck={false}
|
||||
placeholder="Enter your workflow code here..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
@ -1,365 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
|
||||
import { Button, Card, CardContent, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Textarea, toast } from "@stackframe/stack-ui";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { IllustratedInfo } from "../../../../../../components/illustrated-info";
|
||||
import { AppEnabledGuard } from "../app-enabled-guard";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
import { WorkflowList } from "./workflow-list";
|
||||
|
||||
function EmptyState({ onCreateWorkflow }: { onCreateWorkflow: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 py-12 max-w-3xl mx-auto">
|
||||
<IllustratedInfo
|
||||
illustration={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-background rounded p-3 shadow-sm border">
|
||||
<div className="h-2 bg-muted rounded mb-2"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-6 w-6 bg-primary/20 rounded"></div>
|
||||
<div className="h-6 flex-1 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-6">
|
||||
<div className="h-6 w-6 bg-primary/20 rounded"></div>
|
||||
<div className="h-6 flex-1 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-12">
|
||||
<div className="h-6 w-6 bg-primary/20 rounded"></div>
|
||||
<div className="h-6 flex-1 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-3 shadow-sm border">
|
||||
<div className="h-2 bg-muted rounded mb-2"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-6 w-6 bg-primary/20 rounded"></div>
|
||||
<div className="h-6 flex-1 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-6">
|
||||
<div className="h-6 w-6 bg-primary/20 rounded"></div>
|
||||
<div className="h-6 flex-1 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title="Welcome to Workflows!"
|
||||
description={[
|
||||
<>Workflows help you automate complex business processes.</>,
|
||||
<>Create your first workflow to get started with automation.</>,
|
||||
]}
|
||||
/>
|
||||
<Button onClick={onCreateWorkflow}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Your First Workflow
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateWorkflowDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave
|
||||
}: {
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
onSave: (id: string, displayName: string, tsSource: string, enabled: boolean) => Promise<void>,
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!displayName) {
|
||||
alert("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSave(generateUuid(), displayName, deindent`
|
||||
onSignUp(async (user) => {
|
||||
await stackApp.sendEmail({ userIds: [user.id], subject: "Welcome to the app!", html: "<p>Example email</p>" });
|
||||
return scheduleCallback({
|
||||
scheduleAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
data: { "userId": user.id },
|
||||
callbackId: "in-7-days",
|
||||
});
|
||||
});
|
||||
|
||||
registerCallback("in-7-days", async (data) => {
|
||||
await stackApp.sendEmail({ userIds: [data.userId], subject: "Welcome to the app!", html: "<p>Example email</p>" });
|
||||
});
|
||||
`, false);
|
||||
onOpenChange(false);
|
||||
setDisplayName("");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Workflow</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mb-4 mt-4">
|
||||
<div>
|
||||
<Label htmlFor="workflow-name">Display Name</Label>
|
||||
<Input
|
||||
id="workflow-name"
|
||||
placeholder="e.g., User Onboarding"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
width="200px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create Workflow"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const project = stackAdminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<any>(null);
|
||||
|
||||
const workflows = Object.entries(config.workflows.availableWorkflows).map(([id, workflow]) => ({
|
||||
id,
|
||||
...workflow
|
||||
}));
|
||||
|
||||
const handleCreateWorkflow = () => {
|
||||
setShowCreateDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveWorkflow = async (id: string, displayName: string, tsSource: string, enabled: boolean) => {
|
||||
await project.updateConfig({
|
||||
[`workflows.availableWorkflows.${id}`]: {
|
||||
displayName,
|
||||
tsSource,
|
||||
enabled,
|
||||
}
|
||||
});
|
||||
toast({ title: "Workflow created successfully" });
|
||||
};
|
||||
|
||||
const handleEditWorkflow = (workflow: any) => {
|
||||
setEditingWorkflow(workflow);
|
||||
setShowEditDialog(true);
|
||||
};
|
||||
|
||||
const handleUpdateWorkflow = async (id: string, displayName: string, tsSource: string) => {
|
||||
await project.updateConfig({
|
||||
[`workflows.availableWorkflows.${id}`]: {
|
||||
displayName,
|
||||
tsSource,
|
||||
enabled: editingWorkflow?.enabled ?? true
|
||||
}
|
||||
});
|
||||
toast({ title: "Workflow updated successfully" });
|
||||
setShowEditDialog(false);
|
||||
setEditingWorkflow(null);
|
||||
};
|
||||
|
||||
const handleDeleteWorkflow = async (workflowId: string) => {
|
||||
if (confirm(`Are you sure you want to delete the workflow "${workflowId}"?`)) {
|
||||
await project.updateConfig({
|
||||
[`workflows.availableWorkflows.${workflowId}`]: null
|
||||
});
|
||||
toast({ title: "Workflow deleted" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateWorkflow = async (workflow: any) => {
|
||||
const newId = `${workflow.id}_copy`;
|
||||
let finalId = newId;
|
||||
let counter = 1;
|
||||
|
||||
// Find unique ID
|
||||
const availableWorkflows = config.workflows.availableWorkflows;
|
||||
while (finalId in availableWorkflows) {
|
||||
finalId = `${newId}_${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
await project.updateConfig({
|
||||
[`workflows.availableWorkflows.${finalId}`]: {
|
||||
displayName: `${workflow.displayName} (Copy)`,
|
||||
tsSource: workflow.tsSource,
|
||||
enabled: false
|
||||
}
|
||||
});
|
||||
toast({ title: "Workflow duplicated successfully" });
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (workflow: any) => {
|
||||
await project.updateConfig({
|
||||
[`workflows.availableWorkflows.${workflow.id}.enabled`]: !workflow.enabled
|
||||
});
|
||||
toast({
|
||||
title: workflow.enabled ? "Workflow disabled" : "Workflow enabled"
|
||||
});
|
||||
};
|
||||
|
||||
const content = workflows.length === 0 ? (
|
||||
<>
|
||||
<EmptyState onCreateWorkflow={handleCreateWorkflow} />
|
||||
<CreateWorkflowDialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
onSave={handleSaveWorkflow}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PageLayout title="Workflows">
|
||||
<div className="flex gap-6 flex-1" style={{ flexBasis: "0px", overflow: "scroll" }}>
|
||||
<Card className="flex w-full">
|
||||
<CardContent className="flex w-full p-0">
|
||||
<div className="flex-1">
|
||||
<WorkflowList
|
||||
workflows={workflows}
|
||||
projectId={project.id}
|
||||
onAddClick={handleCreateWorkflow}
|
||||
onEdit={handleEditWorkflow}
|
||||
onDelete={(id) => {
|
||||
handleDeleteWorkflow(id).catch(() => {
|
||||
toast({ title: "Failed to delete workflow", variant: "destructive" });
|
||||
});
|
||||
}}
|
||||
onDuplicate={(workflow) => {
|
||||
handleDuplicateWorkflow(workflow).catch(() => {
|
||||
toast({ title: "Failed to duplicate workflow", variant: "destructive" });
|
||||
});
|
||||
}}
|
||||
onToggleEnabled={(workflow) => {
|
||||
handleToggleEnabled(workflow).catch(() => {
|
||||
toast({ title: "Failed to toggle workflow", variant: "destructive" });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
<CreateWorkflowDialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
onSave={handleSaveWorkflow}
|
||||
/>
|
||||
{editingWorkflow && (
|
||||
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Workflow</DialogTitle>
|
||||
</DialogHeader>
|
||||
<EditWorkflowForm
|
||||
workflow={editingWorkflow}
|
||||
onSave={handleUpdateWorkflow}
|
||||
onCancel={() => {
|
||||
setShowEditDialog(false);
|
||||
setEditingWorkflow(null);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="workflows">
|
||||
{content}
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function EditWorkflowForm({
|
||||
workflow,
|
||||
onSave,
|
||||
onCancel
|
||||
}: {
|
||||
workflow: any,
|
||||
onSave: (id: string, displayName: string, tsSource: string) => Promise<void>,
|
||||
onCancel: () => void,
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState(workflow.displayName);
|
||||
const [tsSource, setTsSource] = useState(workflow.tsSource);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!displayName) {
|
||||
toast({ title: "Please fill in all required fields", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSave(workflow.id, displayName, tsSource);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-workflow-id">Workflow ID</Label>
|
||||
<Input
|
||||
id="edit-workflow-id"
|
||||
value={workflow.id}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-workflow-name">Display Name</Label>
|
||||
<Input
|
||||
id="edit-workflow-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-workflow-source">TypeScript Source</Label>
|
||||
<Textarea
|
||||
id="edit-workflow-source"
|
||||
className="font-mono text-sm min-h-[200px]"
|
||||
value={tsSource}
|
||||
onChange={(e) => setTsSource(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,159 +0,0 @@
|
||||
import { useRouter } from "@/components/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@stackframe/stack-ui";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ListSection } from "../payments/products/list-section";
|
||||
|
||||
type Workflow = {
|
||||
id: string,
|
||||
displayName: string,
|
||||
tsSource: string,
|
||||
enabled: boolean,
|
||||
};
|
||||
|
||||
type WorkflowListItemProps = {
|
||||
workflow: Workflow,
|
||||
projectId: string,
|
||||
onEdit?: () => void,
|
||||
onDelete?: () => void,
|
||||
onDuplicate?: () => void,
|
||||
onToggleEnabled?: () => void,
|
||||
};
|
||||
|
||||
function WorkflowListItem({
|
||||
workflow,
|
||||
projectId,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onToggleEnabled,
|
||||
}: WorkflowListItemProps) {
|
||||
const router = useRouter();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/projects/${projectId}/workflows/${workflow.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-3 cursor-pointer relative duration-200 hover:duration-0 hover:bg-primary/10 transition-colors flex items-center justify-between group"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">{workflow.displayName}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-xs rounded-full border",
|
||||
workflow.enabled
|
||||
? "bg-green-500/10 text-green-600 border-green-500/20"
|
||||
: "bg-gray-500/10 text-gray-600 border-gray-500/20"
|
||||
)}
|
||||
>
|
||||
{workflow.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono mb-1">
|
||||
{workflow.id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{workflow.tsSource ? `${workflow.tsSource.split('\n').length} lines of code` : "No source code"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseEnter={() => setIsMenuOpen(true)}
|
||||
onMouseLeave={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 relative",
|
||||
"hover:bg-secondary/80",
|
||||
isMenuOpen && "bg-secondary/80"
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuItem onClick={onToggleEnabled}>
|
||||
{workflow.enabled ? "Disable" : "Enable"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDuplicate}>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function WorkflowList({
|
||||
workflows,
|
||||
projectId,
|
||||
onAddClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onToggleEnabled
|
||||
}: {
|
||||
workflows: Workflow[],
|
||||
projectId: string,
|
||||
onAddClick?: () => void,
|
||||
onEdit?: (workflow: Workflow) => void,
|
||||
onDelete?: (workflowId: string) => void,
|
||||
onDuplicate?: (workflow: Workflow) => void,
|
||||
onToggleEnabled?: (workflow: Workflow) => void,
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const filteredWorkflows = workflows.filter((workflow) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
workflow.id.toLowerCase().includes(query) ||
|
||||
workflow.displayName.toLowerCase().includes(query) ||
|
||||
(workflow.tsSource && workflow.tsSource.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<ListSection
|
||||
title="Workflows"
|
||||
titleTooltip="Workflows automate complex business processes and integrations"
|
||||
onAddClick={onAddClick}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="Search workflows..."
|
||||
>
|
||||
<div>
|
||||
{filteredWorkflows.map((workflow) => (
|
||||
<WorkflowListItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
projectId={projectId}
|
||||
onEdit={() => onEdit?.(workflow)}
|
||||
onDelete={() => onDelete?.(workflow.id)}
|
||||
onDuplicate={() => onDuplicate?.(workflow)}
|
||||
onToggleEnabled={() => onToggleEnabled?.(workflow)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ListSection>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { Link } from "@/components/link";
|
||||
import { StackAdminApp } from "@stackframe/stack";
|
||||
import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { getRelativePart, isChildUrl } from "@stackframe/stack-shared/dist/utils/urls";
|
||||
import { CreditCard, KeyRound, Mail, Mails, Rocket, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook, Workflow } from "lucide-react";
|
||||
import { CreditCard, KeyRound, Mail, Mails, Rocket, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import ConvexLogo from "../../public/convex-logo.png";
|
||||
import LogoBright from "../../public/logo-bright.svg";
|
||||
@ -147,15 +147,6 @@ export const ALL_APPS_FRONTEND = {
|
||||
screenshots: [],
|
||||
storeDescription: <></>,
|
||||
},
|
||||
workflows: {
|
||||
icon: Workflow,
|
||||
href: "workflows",
|
||||
navigationItems: [
|
||||
{ displayName: "Workflows", href: "." },
|
||||
],
|
||||
screenshots: [],
|
||||
storeDescription: <></>,
|
||||
},
|
||||
webhooks: {
|
||||
icon: Webhook,
|
||||
href: "webhooks",
|
||||
|
||||
@ -1,374 +0,0 @@
|
||||
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { Mailbox, test } from "../helpers";
|
||||
import { withPortPrefix } from "../helpers/ports";
|
||||
import { Auth, InternalApiKey, Project, bumpEmailAddress, createMailbox, niceBackendFetch } from "./backend-helpers";
|
||||
|
||||
async function configureEmailAndWorkflow(workflowId: string, tsSource: string, enabled = true) {
|
||||
await Project.updateConfig({
|
||||
emails: {
|
||||
server: {
|
||||
isShared: false,
|
||||
host: "localhost",
|
||||
port: Number(withPortPrefix("29")),
|
||||
username: "test",
|
||||
password: "test",
|
||||
senderEmail: "test@example.com",
|
||||
senderName: "Test Project",
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
availableWorkflows: {
|
||||
[workflowId]: {
|
||||
displayName: workflowId,
|
||||
tsSource,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const waitRetries = 25;
|
||||
|
||||
async function waitForMailboxSubject(mailbox: Mailbox, subject: string) {
|
||||
for (let i = 0; i < waitRetries; i++) {
|
||||
const messages = await mailbox.fetchMessages();
|
||||
const message = messages.find((m) => m.subject === subject);
|
||||
if (message) return;
|
||||
await wait(1_000);
|
||||
}
|
||||
throw new Error(`Message with subject ${subject} not found after ${waitRetries} tries`);
|
||||
}
|
||||
|
||||
async function waitForServerMetadataNotNull(userId: string, key: string) {
|
||||
for (let i = 0; i < waitRetries; i++) {
|
||||
const user = await niceBackendFetch(`/api/v1/users/${userId}`, { accessType: "server" });
|
||||
if (user.body.server_metadata?.[key]) return;
|
||||
await wait(1_000);
|
||||
}
|
||||
throw new Error(`Server metadata for user ${userId} with key ${key} not found after ${waitRetries} tries`);
|
||||
}
|
||||
|
||||
test("onSignUp workflow sends email for client sign-up", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await InternalApiKey.createAndSetProjectKeys();
|
||||
const mailbox = await bumpEmailAddress({ unindexed: true });
|
||||
const subject = `WF client signup ${crypto.randomUUID()}`;
|
||||
|
||||
await configureEmailAndWorkflow("wf-email", `
|
||||
onSignUp(async (user) => {
|
||||
await stackApp.sendEmail({ userIds: [user.id], subject: ${JSON.stringify(subject)}, html: "<p>hi</p>" });
|
||||
|
||||
// schedule a callback as an example (we don't actually test whether it executed successfully)
|
||||
return scheduleCallback({
|
||||
scheduleAt: new Date(Date.now() + 7_000),
|
||||
data: { "example": "data" },
|
||||
callbackId: "my-callback",
|
||||
});
|
||||
});
|
||||
|
||||
registerCallback("my-callback", async (data) => {
|
||||
console.log("my-callback", data);
|
||||
});
|
||||
`);
|
||||
|
||||
await Auth.Password.signUpWithEmail({ password: "password" });
|
||||
|
||||
await waitForMailboxSubject(mailbox, subject);
|
||||
|
||||
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
|
||||
[
|
||||
MailboxMessage {
|
||||
"attachments": [],
|
||||
"body": {
|
||||
"html": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
|
||||
"text": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
|
||||
},
|
||||
"from": "Test Project <test@example.com>",
|
||||
"subject": "Verify your email at New Project",
|
||||
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
MailboxMessage {
|
||||
"attachments": [],
|
||||
"body": {
|
||||
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:1rem;line-height:1.5rem\\"><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div><p>hi</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
|
||||
"text": "hi",
|
||||
},
|
||||
"from": "Test Project <test@example.com>",
|
||||
"subject": "WF client signup <stripped UUID>",
|
||||
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
]
|
||||
`);
|
||||
}, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
test("onSignUp workflow can schedule callbacks", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await InternalApiKey.createAndSetProjectKeys();
|
||||
const mailbox = await bumpEmailAddress({ unindexed: true });
|
||||
const subject = `WF client signup ${crypto.randomUUID()}`;
|
||||
|
||||
await configureEmailAndWorkflow("wf-email", `
|
||||
onSignUp(async (user) => {
|
||||
return scheduleCallback({
|
||||
scheduleAt: new Date(Date.now() + 7_000),
|
||||
data: { "userId": user.id },
|
||||
callbackId: "my-callback",
|
||||
});
|
||||
});
|
||||
|
||||
registerCallback("my-callback", async (data) => {
|
||||
await stackApp.sendEmail({ userIds: [data.userId], subject: ${JSON.stringify(subject)}, html: "<p>hi</p>" });
|
||||
});
|
||||
`);
|
||||
|
||||
await Auth.Password.signUpWithEmail({ password: "password" });
|
||||
|
||||
// since we wait for the callback, add some extra time
|
||||
await wait(10_000);
|
||||
await waitForMailboxSubject(mailbox, subject);
|
||||
|
||||
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
|
||||
[
|
||||
MailboxMessage {
|
||||
"attachments": [],
|
||||
"body": {
|
||||
"html": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
|
||||
"text": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
|
||||
},
|
||||
"from": "Test Project <test@example.com>",
|
||||
"subject": "Verify your email at New Project",
|
||||
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
MailboxMessage {
|
||||
"attachments": [],
|
||||
"body": {
|
||||
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:1rem;line-height:1.5rem\\"><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div><p>hi</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
|
||||
"text": "hi",
|
||||
},
|
||||
"from": "Test Project <test@example.com>",
|
||||
"subject": "WF client signup <stripped UUID>",
|
||||
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
]
|
||||
`);
|
||||
}, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
test("onSignUp workflow sends email for server-created user", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await InternalApiKey.createAndSetProjectKeys();
|
||||
|
||||
const mailbox = createMailbox(`wf-server-${crypto.randomUUID()}@stack-generated.example.com`);
|
||||
const subject = `WF server create ${crypto.randomUUID()}`;
|
||||
|
||||
await configureEmailAndWorkflow("wf-email-server", `
|
||||
onSignUp(async (user) => {
|
||||
await stackApp.sendEmail({ userIds: [user.id], subject: ${JSON.stringify(subject)}, html: "<p>server</p>" });
|
||||
});
|
||||
`);
|
||||
|
||||
const createUserRes = await niceBackendFetch("/api/v1/users", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
primary_email: mailbox.emailAddress,
|
||||
primary_email_verified: true,
|
||||
},
|
||||
});
|
||||
expect(createUserRes.status).toBe(201);
|
||||
|
||||
await waitForMailboxSubject(mailbox, subject);
|
||||
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
|
||||
[
|
||||
MailboxMessage {
|
||||
"attachments": [],
|
||||
"body": {
|
||||
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:1rem;line-height:1.5rem\\"><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div><p>server</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
|
||||
"text": "server",
|
||||
},
|
||||
"from": "Test Project <test@example.com>",
|
||||
"subject": "WF server create <stripped UUID>",
|
||||
"to": ["<wf-server-<stripped UUID>@stack-generated.example.com>"],
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
]
|
||||
`);
|
||||
}, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
test("disabled workflows do not trigger", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await InternalApiKey.createAndSetProjectKeys();
|
||||
const mailbox = await bumpEmailAddress({ unindexed: true });
|
||||
const subject = `WF disabled ${crypto.randomUUID()}`;
|
||||
|
||||
await configureEmailAndWorkflow("wf-disabled", `
|
||||
onSignUp(async (user) => {
|
||||
await stackApp.sendEmail({ userIds: [user.id], subject: ${JSON.stringify(subject)}, html: "<p>nope</p>" });
|
||||
});
|
||||
`, /* enabled */ false);
|
||||
|
||||
await Auth.Password.signUpWithEmail({ password: "password" });
|
||||
|
||||
await wait(waitRetries * 1_000 * 1.3);
|
||||
await Auth.refreshAccessToken();
|
||||
|
||||
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
|
||||
[
|
||||
MailboxMessage {
|
||||
"attachments": [],
|
||||
"body": {
|
||||
"html": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
|
||||
"text": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
|
||||
},
|
||||
"from": "Test Project <test@example.com>",
|
||||
"subject": "Verify your email at New Project",
|
||||
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
]
|
||||
`);
|
||||
}, {
|
||||
timeout: 90_000,
|
||||
});
|
||||
|
||||
test("compile/runtime errors in one workflow don't block others", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await InternalApiKey.createAndSetProjectKeys();
|
||||
const mailbox = await bumpEmailAddress({ unindexed: true });
|
||||
const subject = `WF ok ${crypto.randomUUID()}`;
|
||||
|
||||
// bad compile
|
||||
await configureEmailAndWorkflow("wf-bad-compile", `return return`);
|
||||
// bad runtime
|
||||
await configureEmailAndWorkflow("wf-bad-runtime", `onSignUp(() => { throw new Error('boom') });`);
|
||||
// good one
|
||||
await configureEmailAndWorkflow("wf-good", `
|
||||
onSignUp(async (user) => {
|
||||
await stackApp.sendEmail({ userIds: [user.id], subject: ${JSON.stringify(subject)}, html: "<p>ok</p>" });
|
||||
});
|
||||
`);
|
||||
|
||||
await Auth.Password.signUpWithEmail({ password: "password" });
|
||||
|
||||
await waitForMailboxSubject(mailbox, subject);
|
||||
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
|
||||
[
|
||||
MailboxMessage {
|
||||
"attachments": [],
|
||||
"body": {
|
||||
"html": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
|
||||
"text": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
|
||||
},
|
||||
"from": "Test Project <test@example.com>",
|
||||
"subject": "Verify your email at New Project",
|
||||
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
MailboxMessage {
|
||||
"attachments": [],
|
||||
"body": {
|
||||
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:1rem;line-height:1.5rem\\"><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div><p>ok</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
|
||||
"text": "ok",
|
||||
},
|
||||
"from": "Test Project <test@example.com>",
|
||||
"subject": "WF ok <stripped UUID>",
|
||||
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
]
|
||||
`);
|
||||
}, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
test("anonymous sign-up does not trigger; upgrade triggers workflow", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await InternalApiKey.createAndSetProjectKeys();
|
||||
const markerKey = `wfMarker-${crypto.randomUUID()}`;
|
||||
|
||||
await Project.updateConfig({
|
||||
workflows: {
|
||||
availableWorkflows: {
|
||||
"wf-anon-upgrade": {
|
||||
displayName: "wf-anon-upgrade",
|
||||
enabled: true,
|
||||
tsSource: `onSignUp(async (user) => { await user.update({ serverMetadata: { ${JSON.stringify(markerKey)}: user.primaryEmail } }); });`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// create anonymous session/user
|
||||
const { userId: anonUserId } = await Auth.Anonymous.signUp();
|
||||
|
||||
// ensure marker not present yet
|
||||
await wait(waitRetries * 1_000 * 1.3);
|
||||
await Auth.refreshAccessToken();
|
||||
const me1 = await niceBackendFetch("/api/v1/users/me", { accessType: "client" });
|
||||
expect(me1.body.server_metadata?.[markerKey]).toBeUndefined();
|
||||
|
||||
// upgrade via password sign-up
|
||||
const { userId } = await Auth.Password.signUpWithEmail({ password: "password" });
|
||||
expect(userId).toEqual(anonUserId);
|
||||
|
||||
await waitForServerMetadataNotNull(anonUserId, markerKey);
|
||||
const me2 = await niceBackendFetch("/api/v1/users/me", { accessType: "server" });
|
||||
expect(me2.body.is_anonymous).toBe(false);
|
||||
expect(me2.body.server_metadata?.[markerKey]).toBe(me2.body.primary_email);
|
||||
}, {
|
||||
timeout: 90_000,
|
||||
});
|
||||
|
||||
test("workflow source changes take effect for subsequent sign-ups", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await InternalApiKey.createAndSetProjectKeys();
|
||||
const markerKey = `versionMarker-${crypto.randomUUID()}`;
|
||||
|
||||
// v1
|
||||
await Project.updateConfig({
|
||||
workflows: {
|
||||
availableWorkflows: {
|
||||
"wf-versioned": {
|
||||
displayName: "wf-versioned",
|
||||
enabled: true,
|
||||
tsSource: `onSignUp(async (user) => { await user.update({ serverMetadata: { ${JSON.stringify(markerKey)}: "v1" } }); });`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await bumpEmailAddress({ unindexed: true });
|
||||
await Auth.Password.signUpWithEmail({ password: "password" });
|
||||
await waitForServerMetadataNotNull("me", markerKey);
|
||||
const me1 = await niceBackendFetch("/api/v1/users/me", { accessType: "server" });
|
||||
expect(me1.body.server_metadata?.[markerKey]).toBe("v1");
|
||||
|
||||
// v2
|
||||
await Project.updateConfig({
|
||||
workflows: {
|
||||
availableWorkflows: {
|
||||
"wf-versioned": {
|
||||
displayName: "wf-versioned",
|
||||
enabled: true,
|
||||
tsSource: `onSignUp(async (user) => { await user.update({ serverMetadata: { ${JSON.stringify(markerKey)}: "v2" } }); });`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await bumpEmailAddress({ unindexed: true });
|
||||
await Auth.Password.signUpWithEmail({ password: "password" });
|
||||
await waitForServerMetadataNotNull("me", markerKey);
|
||||
const me2 = await niceBackendFetch("/api/v1/users/me", { accessType: "server" });
|
||||
expect(me2.body.server_metadata?.[markerKey]).toBe("v2");
|
||||
}, {
|
||||
timeout: 90_000,
|
||||
});
|
||||
@ -5,3 +5,6 @@ A: Host ports use `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}` plus the two-digit suff
|
||||
|
||||
Q: How can I show helper text beneath metadata text areas in the dashboard?
|
||||
A: Use the shared `TextAreaField` component's `helperText` prop in `apps/dashboard/src/components/form-fields.tsx`; it now renders the helper content in a secondary Typography line under the textarea.
|
||||
|
||||
Q: Why did `pnpm typecheck` fail after deleting a Next.js route?
|
||||
A: The generated `.next/types/validator.ts` can keep stale imports for removed routes. Deleting that file (or regenerating Next build output) clears the outdated references so `pnpm typecheck` succeeds again.
|
||||
|
||||
@ -96,12 +96,6 @@ export const ALL_APPS = {
|
||||
tags: ["security", "storage"],
|
||||
stage: "beta",
|
||||
},
|
||||
"workflows": {
|
||||
displayName: "Workflows",
|
||||
subtitle: "Automated business process orchestration",
|
||||
tags: ["automation"],
|
||||
stage: "beta",
|
||||
},
|
||||
"webhooks": {
|
||||
displayName: "Webhooks",
|
||||
subtitle: "Real-time event notifications and integrations",
|
||||
|
||||
@ -124,15 +124,6 @@ const branchSchemaFuzzerConfig = [{
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
workflows: [{
|
||||
availableWorkflows: [{
|
||||
"some-workflow-id": [{
|
||||
displayName: ["Some Workflow", "Some Other Workflow"],
|
||||
tsSource: ["", "some typescript source code"],
|
||||
enabled: [true, false],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
teams: [{
|
||||
createPersonalTeamOnSignUp: [true, false],
|
||||
allowClientTeamCreation: [true, false],
|
||||
@ -315,4 +306,3 @@ import.meta.vitest?.test("fuzz schemas", async ({ expect }) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -167,16 +167,6 @@ const branchDomain = yupObject({
|
||||
allowLocalhost: yupBoolean(),
|
||||
});
|
||||
|
||||
const branchWorkflowsSchema = yupObject({
|
||||
availableWorkflows: yupRecord(
|
||||
userSpecifiedIdSchema("workflowId"),
|
||||
yupObject({
|
||||
displayName: yupString(),
|
||||
tsSource: yupString(),
|
||||
enabled: yupBoolean(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, ["sourceOfTruth"]).concat(yupObject({
|
||||
rbac: branchRbacSchema,
|
||||
@ -214,8 +204,6 @@ export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, [
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
workflows: branchWorkflowsSchema,
|
||||
}));
|
||||
|
||||
|
||||
@ -572,14 +560,6 @@ const organizationConfigDefaults = {
|
||||
displayName: "Unnamed Vault",
|
||||
}),
|
||||
},
|
||||
|
||||
workflows: {
|
||||
availableWorkflows: (key: string) => ({
|
||||
displayName: "Unnamed Workflow",
|
||||
tsSource: "Error: Workflow config is missing TypeScript source code.",
|
||||
enabled: false,
|
||||
}),
|
||||
},
|
||||
} as const satisfies DefaultsType<OrganizationRenderedConfigBeforeDefaults, [typeof environmentConfigDefaults, typeof branchConfigDefaults, typeof projectConfigDefaults]>;
|
||||
|
||||
type _DeepOmitDefaultsImpl<T, U> = T extends object ? (
|
||||
|
||||
@ -1566,25 +1566,6 @@ const StripeAccountInfoNotFound = createKnownErrorConstructor(
|
||||
() => [] as const,
|
||||
);
|
||||
|
||||
const WorkflowTokenDoesNotExist = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"WORKFLOW_TOKEN_DOES_NOT_EXIST",
|
||||
() => [
|
||||
400,
|
||||
"The workflow token you specified does not exist. Make sure the value in x-stack-workflow-token is correct.",
|
||||
] as const,
|
||||
() => [] as const,
|
||||
);
|
||||
|
||||
const WorkflowTokenExpired = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"WORKFLOW_TOKEN_EXPIRED",
|
||||
() => [
|
||||
400,
|
||||
"The workflow token you specified has expired. Make sure the value in x-stack-workflow-token is correct.",
|
||||
] as const,
|
||||
() => [] as const,
|
||||
);
|
||||
|
||||
export type KnownErrors = {
|
||||
[K in keyof typeof KnownErrors]: InstanceType<typeof KnownErrors[K]>;
|
||||
@ -1711,8 +1692,6 @@ export const KnownErrors = {
|
||||
StripeAccountInfoNotFound,
|
||||
DataVaultStoreDoesNotExist,
|
||||
DataVaultStoreHashedKeyDoesNotExist,
|
||||
WorkflowTokenDoesNotExist,
|
||||
WorkflowTokenExpired,
|
||||
} satisfies Record<string, KnownErrorConstructor<any, any>>;
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user