Skip to content

Commit c44ec9a

Browse files
bchapuisclaude
andcommitted
Add create-feedback-form node with public review page
Introduces a `create-feedback-form` logic node that emits a signed, unlisted URL (`/feedback/:token`) displaying the execution's visible node outputs alongside the workflow's evaluation criteria for anonymous reviewers. Object reference outputs are exposed via 7-day presigned R2 URLs and the node completes immediately without pausing the workflow. Form tokens now carry an HMAC-signed expiration and optional organization ID (shared constant `UNLISTED_LINK_TTL_SECONDS` = 7 days). The `feedback.user_id` column becomes nullable to support anonymous submissions (migration 0052). Renames `/f/:token` to `/form/:token` for readability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c9a7620 commit c44ec9a

13 files changed

Lines changed: 1055 additions & 19 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- Make user_id nullable in feedback to allow anonymous submissions
2+
-- (e.g. via create-feedback-form node public page)
3+
CREATE TABLE `feedback_new` (
4+
`id` text PRIMARY KEY NOT NULL,
5+
`execution_id` text NOT NULL,
6+
`criterion_id` text NOT NULL,
7+
`workflow_id` text,
8+
`organization_id` text NOT NULL,
9+
`user_id` text,
10+
`sentiment` text NOT NULL,
11+
`comment` text,
12+
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
13+
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
14+
FOREIGN KEY (`criterion_id`) REFERENCES `feedback_criteria`(`id`) ON UPDATE no action ON DELETE cascade,
15+
FOREIGN KEY (`workflow_id`) REFERENCES `workflows`(`id`) ON UPDATE no action ON DELETE cascade,
16+
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
17+
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
18+
);
19+
--> statement-breakpoint
20+
INSERT INTO `feedback_new` (`id`, `execution_id`, `criterion_id`, `workflow_id`, `organization_id`, `user_id`, `sentiment`, `comment`, `created_at`, `updated_at`)
21+
SELECT `id`, `execution_id`, `criterion_id`, `workflow_id`, `organization_id`, `user_id`, `sentiment`, `comment`, `created_at`, `updated_at` FROM `feedback`;
22+
--> statement-breakpoint
23+
DROP TABLE `feedback`;
24+
--> statement-breakpoint
25+
ALTER TABLE `feedback_new` RENAME TO `feedback`;
26+
--> statement-breakpoint
27+
CREATE INDEX `feedback_execution_id_idx` ON `feedback` (`execution_id`);--> statement-breakpoint
28+
CREATE INDEX `feedback_criterion_id_idx` ON `feedback` (`criterion_id`);--> statement-breakpoint
29+
CREATE INDEX `feedback_workflow_id_idx` ON `feedback` (`workflow_id`);--> statement-breakpoint
30+
CREATE INDEX `feedback_organization_id_idx` ON `feedback` (`organization_id`);--> statement-breakpoint
31+
CREATE INDEX `feedback_user_id_idx` ON `feedback` (`user_id`);--> statement-breakpoint
32+
CREATE INDEX `feedback_sentiment_idx` ON `feedback` (`sentiment`);--> statement-breakpoint
33+
CREATE INDEX `feedback_created_at_idx` ON `feedback` (`created_at`);--> statement-breakpoint
34+
CREATE UNIQUE INDEX `feedback_execution_id_criterion_id_unique` ON `feedback` (`execution_id`, `criterion_id`);

apps/api/src/db/schema/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,9 @@ export const feedback = sqliteTable(
380380
organizationId: text("organization_id")
381381
.notNull()
382382
.references(() => organizations.id, { onDelete: "cascade" }),
383-
userId: text("user_id")
384-
.notNull()
385-
.references(() => users.id, { onDelete: "cascade" }),
383+
userId: text("user_id").references(() => users.id, {
384+
onDelete: "cascade",
385+
}),
386386
sentiment: text("sentiment").$type<FeedbackSentimentType>().notNull(),
387387
comment: text("comment"),
388388
createdAt: createCreatedAt(),

apps/api/src/durable-objects/workflow-agent.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export class WorkflowAgent extends Agent<Bindings, WorkflowAgentState> {
9292
private static readonly STORAGE_KEY_DIRTY = "dirty:persist";
9393
private static readonly STORAGE_PREFIX_EXEC_BUFFER = "execbuf:";
9494
private static readonly STORAGE_PREFIX_FORM = "form:";
95+
private static readonly STORAGE_PREFIX_FEEDBACK_FORM = "fform:";
9596

9697
initialState: WorkflowAgentState = {};
9798

@@ -532,27 +533,35 @@ export class WorkflowAgent extends Agent<Bindings, WorkflowAgentState> {
532533
* Scan node outputs for form schema data (`schema` + `token`) and
533534
* store them in DO transactional storage. This is how form nodes
534535
* register their field definitions without touching the main DB.
536+
*
537+
* Also picks up `feedbackFormConfig` from create-feedback-form nodes
538+
* so the public feedback page can read title/description by token.
535539
*/
536540
private async extractFormSchemas(
537541
execution: WorkflowExecution
538542
): Promise<void> {
539543
for (const nodeExec of execution.nodeExecutions) {
540-
if (
541-
nodeExec.status === "completed" &&
542-
nodeExec.outputs &&
543-
typeof nodeExec.outputs.schema === "string" &&
544-
typeof nodeExec.outputs.token === "string"
545-
) {
546-
const key =
547-
WorkflowAgent.STORAGE_PREFIX_FORM +
548-
nodeExec.outputs.token +
549-
":schema";
550-
// Only store if not already present (idempotent)
544+
if (nodeExec.status !== "completed" || !nodeExec.outputs) continue;
545+
546+
const token = nodeExec.outputs.token;
547+
if (typeof token !== "string") continue;
548+
549+
if (typeof nodeExec.outputs.schema === "string") {
550+
const key = WorkflowAgent.STORAGE_PREFIX_FORM + token + ":schema";
551551
const existing = await this.storage.get(key);
552552
if (!existing) {
553553
await this.storage.put(key, nodeExec.outputs.schema);
554554
}
555555
}
556+
557+
if (typeof nodeExec.outputs.feedbackFormConfig === "string") {
558+
const key =
559+
WorkflowAgent.STORAGE_PREFIX_FEEDBACK_FORM + token + ":config";
560+
const existing = await this.storage.get(key);
561+
if (!existing) {
562+
await this.storage.put(key, nodeExec.outputs.feedbackFormConfig);
563+
}
564+
}
556565
}
557566
}
558567

@@ -659,6 +668,40 @@ export class WorkflowAgent extends Agent<Bindings, WorkflowAgentState> {
659668
}
660669
}
661670

671+
// ── Feedback form state ───────────────────────────────────────────────
672+
673+
async getFeedbackFormStatus(
674+
token: string
675+
): Promise<{ submitted: boolean; config?: string }> {
676+
const key = WorkflowAgent.STORAGE_PREFIX_FEEDBACK_FORM + token;
677+
const [record, config] = await Promise.all([
678+
this.storage.get<{ submitted: boolean }>(key),
679+
this.storage.get<string>(key + ":config"),
680+
]);
681+
return {
682+
submitted: record?.submitted ?? false,
683+
...(config ? { config } : {}),
684+
};
685+
}
686+
687+
/**
688+
* Unlike `checkAndSubmitForm`, this does not send any workflow event —
689+
* feedback submission is decoupled from workflow execution.
690+
*/
691+
async markFeedbackSubmitted(
692+
token: string
693+
): Promise<{ success: boolean; error?: string }> {
694+
const key = WorkflowAgent.STORAGE_PREFIX_FEEDBACK_FORM + token;
695+
const existing = await this.storage.get<{ submitted: boolean }>(key);
696+
697+
if (existing?.submitted) {
698+
return { success: false, error: "Feedback has already been submitted" };
699+
}
700+
701+
await this.storage.put(key, { submitted: true, submittedAt: Date.now() });
702+
return { success: true };
703+
}
704+
662705
// ── Persistence ───────────────────────────────────────────────────────
663706

664707
/**

apps/api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import endpointExecuteRoutes from "./routes/endpoint-execute";
2020
import endpointRoutes from "./routes/endpoints";
2121
import executionRoutes from "./routes/executions";
2222
import feedbackRoutes from "./routes/feedback";
23+
import feedbackFormRoutes from "./routes/feedback-forms";
2324
import formRoutes from "./routes/forms";
2425
import health from "./routes/health";
2526
import integrationRoutes from "./routes/integrations";
@@ -108,6 +109,7 @@ app.route("/replicate", replicateRoutes);
108109

109110
// Public routes
110111
app.route("/forms", formRoutes);
112+
app.route("/feedback-forms", feedbackFormRoutes);
111113
app.route("/templates", templateRoutes);
112114
app.route("/types", typeRoutes);
113115

apps/api/src/mocks/node-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { NumberInputNode } from "@dafthunk/runtime/nodes/input/number-input-node";
77
import { ConditionalForkNode } from "@dafthunk/runtime/nodes/logic/conditional-fork-node";
88
import { ConditionalJoinNode } from "@dafthunk/runtime/nodes/logic/conditional-join-node";
9+
import { CreateFeedbackFormNode } from "@dafthunk/runtime/nodes/logic/create-feedback-form-node";
910
import { CreateFormNode } from "@dafthunk/runtime/nodes/logic/create-form-node";
1011
import { WaitForFormNode } from "@dafthunk/runtime/nodes/logic/wait-for-form-node";
1112
import { AdditionNode } from "@dafthunk/runtime/nodes/math/addition-node";
@@ -49,6 +50,7 @@ export class MockNodeRegistry extends BaseNodeRegistry<Bindings> {
4950
this.registerImplementation(ConditionalForkNode);
5051
this.registerImplementation(ConditionalJoinNode);
5152
this.registerImplementation(CreateFormNode);
53+
this.registerImplementation(CreateFeedbackFormNode);
5254
this.registerImplementation(WaitForFormNode);
5355
this.registerImplementation(MultiStepAdditionNode);
5456
this.registerImplementation(FailingMultiStepNode);

0 commit comments

Comments
 (0)