551 lines
22 KiB
TypeScript
551 lines
22 KiB
TypeScript
// Minimal JSON-RPC 2.0 / MCP server. Tool-only — no resources/prompts.
|
|
|
|
import { createHash } from "crypto";
|
|
import { nanoid } from "nanoid";
|
|
import { prisma } from "./db";
|
|
import { canEditForm, canViewResults, parseFields, parseSettings } from "./forms";
|
|
import { visibleFields } from "./logic";
|
|
import { recordAudit } from "./audit";
|
|
import { snapshotForm } from "./versions";
|
|
import { publicFormUrl } from "./urls";
|
|
import type { Field, FormSettings } from "./types";
|
|
|
|
const PROTOCOL_VERSION = "2024-11-05";
|
|
|
|
type Ctx = { userId: string; role: string };
|
|
|
|
type JsonRpcReq = { jsonrpc: "2.0"; id?: number | string | null; method: string; params?: unknown };
|
|
type JsonRpcRes =
|
|
| { jsonrpc: "2.0"; id: number | string | null; result: unknown }
|
|
| { jsonrpc: "2.0"; id: number | string | null; error: { code: number; message: string; data?: unknown } };
|
|
|
|
export async function authenticate(authHeader: string | null): Promise<Ctx | null> {
|
|
if (!authHeader || !authHeader.startsWith("Bearer ")) return null;
|
|
const secret = authHeader.slice(7).trim();
|
|
const tokenHash = createHash("sha256").update(secret).digest("hex");
|
|
const t = await prisma.accessToken.findUnique({
|
|
where: { tokenHash },
|
|
include: { user: { select: { id: true, role: true } } },
|
|
});
|
|
if (!t) return null;
|
|
await prisma.accessToken.update({ where: { id: t.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
|
|
return { userId: t.user.id, role: t.user.role };
|
|
}
|
|
|
|
export async function handleRpc(req: JsonRpcReq, ctx: Ctx): Promise<JsonRpcRes | null> {
|
|
const id = req.id ?? null;
|
|
try {
|
|
switch (req.method) {
|
|
case "initialize":
|
|
return ok(id, {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: { tools: {}, resources: { subscribe: false } },
|
|
serverInfo: { name: "formbuilder", version: "0.2.0" },
|
|
});
|
|
case "notifications/initialized":
|
|
return null;
|
|
case "ping":
|
|
return ok(id, {});
|
|
case "tools/list":
|
|
return ok(id, { tools: TOOL_LIST });
|
|
case "tools/call": {
|
|
const { name, arguments: args } = (req.params ?? {}) as { name: string; arguments?: Record<string, unknown> };
|
|
const tool = TOOLS[name];
|
|
if (!tool) return err(id, -32601, `Unknown tool: ${name}`);
|
|
const text = await tool(ctx, args ?? {});
|
|
return ok(id, { content: [{ type: "text", text }] });
|
|
}
|
|
case "resources/list":
|
|
return ok(id, await listResources(ctx));
|
|
case "resources/read":
|
|
return ok(id, await readResource((req.params as { uri: string }).uri, ctx));
|
|
default:
|
|
return err(id, -32601, `Unknown method: ${req.method}`);
|
|
}
|
|
} catch (e) {
|
|
return err(id, -32603, e instanceof Error ? e.message : "Internal error");
|
|
}
|
|
}
|
|
|
|
function ok(id: JsonRpcRes["id"], result: unknown): JsonRpcRes {
|
|
return { jsonrpc: "2.0", id, result };
|
|
}
|
|
function err(id: JsonRpcRes["id"], code: number, message: string): JsonRpcRes {
|
|
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
}
|
|
|
|
// ---------- Tool definitions ----------
|
|
|
|
const ALL_FIELD_TYPES = ["short_text","long_text","number","email","date","select","multi_select","checkbox","rating","file","signature","page_break","calculated"] as const;
|
|
|
|
const FIELD_INPUT_SCHEMA = {
|
|
type: "object",
|
|
properties: {
|
|
type: { type: "string", enum: ALL_FIELD_TYPES },
|
|
label: { type: "string" },
|
|
help: { type: "string" },
|
|
required: { type: "boolean" },
|
|
placeholder: { type: "string" },
|
|
options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" } }, required: ["value","label"] } },
|
|
min: { type: "number" }, max: { type: "number" },
|
|
accept: { type: "string" }, maxSizeMB: { type: "number" },
|
|
expression: { type: "string" },
|
|
},
|
|
required: ["type","label"],
|
|
};
|
|
|
|
const TOOL_LIST = [
|
|
{
|
|
name: "list_forms",
|
|
description: "List forms visible to the authenticated user. Paginated.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
limit: { type: "number", description: "Page size, max 100 (default 50)." },
|
|
cursor: { type: "string", description: "Opaque cursor returned by a previous call." },
|
|
tag: { type: "string", description: "Filter to forms with this tag slug." },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "get_form",
|
|
description: "Get a form's schema and settings by id or slug.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { id: { type: "string" }, slug: { type: "string" } },
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "create_form",
|
|
description: "Create a new form. Returns the new form id and slug.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string" },
|
|
description: { type: "string" },
|
|
fields: { type: "array", items: FIELD_INPUT_SCHEMA },
|
|
publish: { type: "boolean", description: "Publish immediately (default false)." },
|
|
},
|
|
required: ["title"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "update_form",
|
|
description: "Update form metadata. Pass only the fields you want to change.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "string" },
|
|
title: { type: "string" },
|
|
description: { type: "string" },
|
|
published: { type: "boolean" },
|
|
settings: { type: "object", additionalProperties: true },
|
|
},
|
|
required: ["id"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "delete_form",
|
|
description: "Delete a form and all of its responses. Owner or admin only.",
|
|
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
|
|
},
|
|
{
|
|
name: "add_field",
|
|
description: "Append a field to a form (or insert at index).",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { formId: { type: "string" }, field: FIELD_INPUT_SCHEMA, index: { type: "number" } },
|
|
required: ["formId", "field"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "remove_field",
|
|
description: "Remove a field by id.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { formId: { type: "string" }, fieldId: { type: "string" } },
|
|
required: ["formId", "fieldId"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "update_field",
|
|
description: "Update properties of an existing field.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
formId: { type: "string" }, fieldId: { type: "string" },
|
|
patch: { type: "object", additionalProperties: true },
|
|
},
|
|
required: ["formId", "fieldId", "patch"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "reorder_fields",
|
|
description: "Set the order of fields. Pass an array of field ids.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { formId: { type: "string" }, order: { type: "array", items: { type: "string" } } },
|
|
required: ["formId", "order"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "list_members",
|
|
description: "List workspace members. Admin only.",
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
},
|
|
{
|
|
name: "set_member_role",
|
|
description: "Promote or demote a member. Admin only.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { userId: { type: "string" }, role: { type: "string", enum: ["admin", "member"] } },
|
|
required: ["userId", "role"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "create_access_token",
|
|
description: "Mint an MCP token for the current user. Returns the secret once.",
|
|
inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
|
|
},
|
|
{
|
|
name: "list_responses",
|
|
description: "List responses for a form. Caller must be owner, admin, or listed viewer. Paginated.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
formId: { type: "string" },
|
|
limit: { type: "number", description: "Page size, max 200 (default 50)." },
|
|
cursor: { type: "string", description: "Opaque cursor from a previous call." },
|
|
},
|
|
required: ["formId"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "get_response",
|
|
description: "Get a single response by id.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { id: { type: "string" } },
|
|
required: ["id"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "submit_response",
|
|
description: "Submit a response to a published form. Field keys may be field ids or labels.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
formId: { type: "string" }, slug: { type: "string" },
|
|
data: { type: "object", additionalProperties: true },
|
|
},
|
|
required: ["data"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
] as const;
|
|
|
|
const TOOLS: Record<string, (ctx: Ctx, args: Record<string, unknown>) => Promise<string>> = {
|
|
async list_forms(ctx, args) {
|
|
const isAdmin = ctx.role === "admin";
|
|
const limit = Math.min(Math.max(Number(args.limit ?? 50), 1), 100);
|
|
const cursor = typeof args.cursor === "string" && args.cursor ? args.cursor : undefined;
|
|
const tagSlug = typeof args.tag === "string" && args.tag ? args.tag : undefined;
|
|
const baseWhere = isAdmin
|
|
? {}
|
|
: { OR: [{ ownerId: ctx.userId }, { viewers: { some: { userId: ctx.userId } } }, { published: true }] };
|
|
const where = tagSlug
|
|
? { AND: [baseWhere, { tags: { some: { tag: { slug: tagSlug } } } }] }
|
|
: baseWhere;
|
|
const forms = await prisma.form.findMany({
|
|
where,
|
|
orderBy: { updatedAt: "desc" },
|
|
take: limit + 1,
|
|
cursor: cursor ? { id: cursor } : undefined,
|
|
skip: cursor ? 1 : 0,
|
|
include: { _count: { select: { responses: true } }, tags: { include: { tag: { select: { slug: true, name: true } } } } },
|
|
});
|
|
const hasMore = forms.length > limit;
|
|
const page = hasMore ? forms.slice(0, limit) : forms;
|
|
const nextCursor = hasMore ? page[page.length - 1].id : null;
|
|
return JSON.stringify({
|
|
forms: page.map((f) => ({
|
|
id: f.id, slug: f.slug, title: f.title, published: f.published,
|
|
responses: f._count.responses, updatedAt: f.updatedAt.toISOString(),
|
|
tags: f.tags.map((ft) => ({ slug: ft.tag.slug, name: ft.tag.name })),
|
|
})),
|
|
nextCursor,
|
|
}, null, 2);
|
|
},
|
|
|
|
async get_form(_ctx, args) {
|
|
const form = await findForm(args);
|
|
if (!form) throw new Error("Form not found");
|
|
return JSON.stringify({
|
|
id: form.id, slug: form.slug, title: form.title, description: form.description,
|
|
published: form.published, fields: parseFields(form.fields),
|
|
settings: parseSettings(form.settings),
|
|
}, null, 2);
|
|
},
|
|
|
|
async create_form(ctx, args) {
|
|
const title = String(args.title ?? "Untitled form");
|
|
const description = args.description ? String(args.description) : null;
|
|
const rawFields = Array.isArray(args.fields) ? (args.fields as Partial<Field>[]) : [];
|
|
// Always generate the id ourselves — never trust an id supplied via MCP.
|
|
const fields: Field[] = rawFields.map((f) => ({ ...(f as Field), id: nanoid(8) }));
|
|
const settings: FormSettings = { visibility: "public" };
|
|
const form = await prisma.form.create({
|
|
data: {
|
|
slug: nanoid(10), title, description, ownerId: ctx.userId,
|
|
fields: JSON.stringify(fields), settings: JSON.stringify(settings),
|
|
published: args.publish === true,
|
|
},
|
|
});
|
|
await snapshotForm({
|
|
formId: form.id,
|
|
current: { title: form.title, description: form.description, fields: form.fields, settings: form.settings },
|
|
authorId: ctx.userId, force: true,
|
|
});
|
|
await recordAudit({
|
|
actorId: ctx.userId, action: "form.created", entityType: "form", entityId: form.id,
|
|
metadata: { title, via: "mcp" },
|
|
});
|
|
return JSON.stringify({ id: form.id, slug: form.slug, url: publicFormUrl(form.slug) }, null, 2);
|
|
},
|
|
|
|
async list_responses(ctx, args) {
|
|
const formId = String(args.formId);
|
|
if (!(await canViewResults(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
const limit = Math.min(Math.max(Number(args.limit ?? 50), 1), 200);
|
|
const cursor = typeof args.cursor === "string" && args.cursor ? args.cursor : undefined;
|
|
const rows = await prisma.response.findMany({
|
|
where: { formId },
|
|
orderBy: { createdAt: "desc" },
|
|
take: limit + 1,
|
|
cursor: cursor ? { id: cursor } : undefined,
|
|
skip: cursor ? 1 : 0,
|
|
include: { submitter: { select: { email: true, name: true } } },
|
|
});
|
|
const hasMore = rows.length > limit;
|
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
const nextCursor = hasMore ? page[page.length - 1].id : null;
|
|
return JSON.stringify({
|
|
responses: page.map((r) => ({
|
|
id: r.id, at: r.createdAt.toISOString(),
|
|
by: r.submitter ? r.submitter.email : null,
|
|
data: safeJson(r.data),
|
|
})),
|
|
nextCursor,
|
|
}, null, 2);
|
|
},
|
|
|
|
async get_response(ctx, args) {
|
|
const r = await prisma.response.findUnique({ where: { id: String(args.id) } });
|
|
if (!r) throw new Error("Not found");
|
|
if (!(await canViewResults(r.formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
return JSON.stringify({ id: r.id, formId: r.formId, at: r.createdAt.toISOString(), data: safeJson(r.data) }, null, 2);
|
|
},
|
|
|
|
async update_form(ctx, args) {
|
|
const formId = String(args.id);
|
|
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
const data: Record<string, unknown> = {};
|
|
if (args.title !== undefined) data.title = String(args.title);
|
|
if (args.description !== undefined) data.description = args.description === null ? null : String(args.description);
|
|
if (args.published !== undefined) data.published = !!args.published;
|
|
if (args.settings !== undefined) {
|
|
const cur = await prisma.form.findUnique({ where: { id: formId }, select: { settings: true } });
|
|
const merged = { ...parseSettings(cur?.settings ?? "{}"), ...(args.settings as FormSettings) };
|
|
data.settings = JSON.stringify(merged);
|
|
}
|
|
const out = await prisma.form.update({ where: { id: formId }, data, select: { id: true, slug: true, published: true, title: true, description: true, fields: true, settings: true } });
|
|
await snapshotForm({
|
|
formId, current: { title: out.title, description: out.description, fields: out.fields, settings: out.settings },
|
|
authorId: ctx.userId,
|
|
});
|
|
await recordAudit({
|
|
actorId: ctx.userId, action: "form.updated", entityType: "form", entityId: formId, metadata: { via: "mcp" },
|
|
});
|
|
return JSON.stringify({ id: out.id, slug: out.slug, published: out.published }, null, 2);
|
|
},
|
|
|
|
async delete_form(ctx, args) {
|
|
const formId = String(args.id);
|
|
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
await prisma.form.delete({ where: { id: formId } });
|
|
await recordAudit({
|
|
actorId: ctx.userId, action: "form.deleted", entityType: "form", entityId: formId, metadata: { via: "mcp" },
|
|
});
|
|
return JSON.stringify({ ok: true }, null, 2);
|
|
},
|
|
|
|
async add_field(ctx, args) {
|
|
const formId = String(args.formId);
|
|
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
|
|
const fields = parseFields(form.fields);
|
|
const incoming = args.field as Partial<Field>;
|
|
const newField: Field = { ...(incoming as Field), id: nanoid(8) };
|
|
const idx = typeof args.index === "number" ? Math.max(0, Math.min(fields.length, args.index)) : fields.length;
|
|
fields.splice(idx, 0, newField);
|
|
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
|
|
return JSON.stringify({ id: newField.id }, null, 2);
|
|
},
|
|
|
|
async remove_field(ctx, args) {
|
|
const formId = String(args.formId);
|
|
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
|
|
const fields = parseFields(form.fields).filter((f) => f.id !== args.fieldId);
|
|
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
|
|
return JSON.stringify({ ok: true }, null, 2);
|
|
},
|
|
|
|
async update_field(ctx, args) {
|
|
const formId = String(args.formId);
|
|
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
|
|
const fields = parseFields(form.fields).map((f) => f.id === args.fieldId ? { ...f, ...(args.patch as Partial<Field>) } : f);
|
|
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
|
|
return JSON.stringify({ ok: true }, null, 2);
|
|
},
|
|
|
|
async reorder_fields(ctx, args) {
|
|
const formId = String(args.formId);
|
|
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
|
|
const order = (args.order as string[]) ?? [];
|
|
const fields = parseFields(form.fields);
|
|
const map = new Map(fields.map((f) => [f.id, f]));
|
|
const ordered: Field[] = [];
|
|
for (const id of order) { const f = map.get(id); if (f) { ordered.push(f); map.delete(id); } }
|
|
for (const f of map.values()) ordered.push(f); // append any unspecified
|
|
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(ordered) } });
|
|
return JSON.stringify({ ok: true }, null, 2);
|
|
},
|
|
|
|
async list_members(ctx) {
|
|
if (ctx.role !== "admin") throw new Error("Forbidden — admin only");
|
|
const members = await prisma.user.findMany({
|
|
orderBy: { createdAt: "asc" },
|
|
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
|
});
|
|
return JSON.stringify(members.map((m) => ({ ...m, createdAt: m.createdAt.toISOString() })), null, 2);
|
|
},
|
|
|
|
async set_member_role(ctx, args) {
|
|
if (ctx.role !== "admin") throw new Error("Forbidden — admin only");
|
|
const role = args.role === "admin" ? "admin" : "member";
|
|
await prisma.user.update({ where: { id: String(args.userId) }, data: { role } });
|
|
return JSON.stringify({ ok: true }, null, 2);
|
|
},
|
|
|
|
async create_access_token(ctx, args) {
|
|
const { createHash, randomBytes } = await import("node:crypto");
|
|
const secret = `fb_${randomBytes(24).toString("hex")}`;
|
|
const tokenHash = createHash("sha256").update(secret).digest("hex");
|
|
const prefix = secret.slice(0, 10);
|
|
const t = await prisma.accessToken.create({
|
|
data: { userId: ctx.userId, name: String(args.name ?? "MCP token"), tokenHash, prefix },
|
|
});
|
|
await recordAudit({
|
|
actorId: ctx.userId, action: "token.created", entityType: "token", entityId: t.id,
|
|
metadata: { name: t.name, via: "mcp" },
|
|
});
|
|
return JSON.stringify({ secret, note: "Copy now — it won't be shown again." }, null, 2);
|
|
},
|
|
|
|
async submit_response(ctx, args) {
|
|
const form = await findForm(args);
|
|
if (!form || !form.published) throw new Error("Form not available");
|
|
const fields = parseFields(form.fields);
|
|
|
|
// Allow callers to use either field ids OR labels as keys.
|
|
const incoming = (args.data ?? {}) as Record<string, unknown>;
|
|
const byId: Record<string, unknown> = {};
|
|
for (const f of fields) {
|
|
if (f.id in incoming) byId[f.id] = incoming[f.id];
|
|
else if (f.label in incoming) byId[f.id] = incoming[f.label];
|
|
}
|
|
const shown = visibleFields(fields, byId);
|
|
|
|
for (const f of shown) {
|
|
const v = byId[f.id];
|
|
const empty = v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
if (f.required && empty) throw new Error(`Missing: ${f.label}`);
|
|
}
|
|
|
|
const r = await prisma.response.create({
|
|
data: { formId: form.id, submitterId: ctx.userId, data: JSON.stringify(byId) },
|
|
});
|
|
return JSON.stringify({ id: r.id }, null, 2);
|
|
},
|
|
};
|
|
|
|
async function findForm(args: Record<string, unknown>) {
|
|
if (args.id) return prisma.form.findUnique({ where: { id: String(args.id) } });
|
|
if (args.slug) return prisma.form.findUnique({ where: { slug: String(args.slug) } });
|
|
return null;
|
|
}
|
|
|
|
function safeJson(s: string): unknown {
|
|
try { return JSON.parse(s); } catch { return s; }
|
|
}
|
|
|
|
// ---------- Resources ----------
|
|
//
|
|
// formbuilder://forms/{id} → full form schema
|
|
// formbuilder://forms/{id}/responses → recent responses (capped at 200)
|
|
|
|
async function listResources(ctx: Ctx) {
|
|
const isAdmin = ctx.role === "admin";
|
|
const forms = await prisma.form.findMany({
|
|
where: isAdmin ? {} : { OR: [{ ownerId: ctx.userId }, { viewers: { some: { userId: ctx.userId } } }, { published: true }] },
|
|
orderBy: { updatedAt: "desc" },
|
|
select: { id: true, slug: true, title: true },
|
|
});
|
|
const resources = forms.flatMap((f) => [
|
|
{ uri: `formbuilder://forms/${f.id}`, name: f.title, mimeType: "application/json", description: `Form schema (${f.slug})` },
|
|
{ uri: `formbuilder://forms/${f.id}/responses`, name: `${f.title} — responses`, mimeType: "application/json" },
|
|
]);
|
|
return { resources };
|
|
}
|
|
|
|
async function readResource(uri: string, ctx: Ctx) {
|
|
const m = uri.match(/^formbuilder:\/\/forms\/([^/]+)(?:\/(responses))?$/);
|
|
if (!m) throw new Error(`Unknown resource: ${uri}`);
|
|
const formId = m[1];
|
|
const kind = m[2];
|
|
|
|
if (kind === "responses") {
|
|
if (!(await canViewResults(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
|
const responses = await prisma.response.findMany({
|
|
where: { formId }, orderBy: { createdAt: "desc" }, take: 200,
|
|
include: { submitter: { select: { email: true } } },
|
|
});
|
|
const body = responses.map((r) => ({
|
|
id: r.id, at: r.createdAt.toISOString(), by: r.submitter?.email ?? null, data: safeJson(r.data),
|
|
}));
|
|
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
|
|
}
|
|
|
|
const form = await prisma.form.findUnique({ where: { id: formId } });
|
|
if (!form) throw new Error("Not found");
|
|
const body = {
|
|
id: form.id, slug: form.slug, title: form.title, description: form.description,
|
|
published: form.published, fields: parseFields(form.fields), settings: parseSettings(form.settings),
|
|
};
|
|
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
|
|
}
|