Prepare Portainer deployment

This commit is contained in:
2026-06-05 23:46:10 -06:00
commit e6f1c5c13f
84 changed files with 12383 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
.env
.env.*
!.env.example
.git
.next
node_modules
npm-debug.log*
coverage
test-results
playwright-report
scripts/screenshots
scripts
tests
*.local
*.log
+60
View File
@@ -0,0 +1,60 @@
# --- Portainer / Compose ---
APP_PORT="3000"
POSTGRES_DB="formbuilder"
POSTGRES_USER="formbuilder"
POSTGRES_PASSWORD="change-this"
# --- Auth.js ---
# Generate with: openssl rand -base64 32
AUTH_SECRET="change-me"
# Public origin of the app (no trailing slash).
AUTH_URL="https://forms.example.com"
# --- Authentik OIDC ---
# In Authentik: Applications → Providers → Create → OAuth2/OpenID
# Redirect URI: ${AUTH_URL}/api/auth/callback/oidc
OIDC_ISSUER="https://authentik.example.com/application/o/formbuilder/"
OIDC_CLIENT_ID="your-client-id"
OIDC_CLIENT_SECRET="your-client-secret"
OIDC_PROVIDER_NAME="Authentik"
# Comma-separated emails auto-promoted to admin on first sign-in.
# (The very first user is also auto-promoted to admin.)
AUTH_BOOTSTRAP_ADMINS=""
# --- Rate limiting ---
# memory (default, single process) or redis (multi-instance; requires REDIS_URL)
RATE_LIMIT_DRIVER="memory"
# REDIS_URL="redis://localhost:6379"
# --- Notifications ---
# Defaults to AUTH_URL in docker-compose.yml.
PUBLIC_BASE_URL="https://forms.example.com"
# Email driver: resend | smtp | none
EMAIL_DRIVER="none"
EMAIL_FROM="Forms <forms@example.com>"
# RESEND_API_KEY="re_xxx"
# SMTP_URL="smtp://user:pass@smtp.example.com:587"
# --- File storage ---
# local (default; files under ./uploads/) | s3 (install @aws-sdk/client-s3 first)
STORAGE_DRIVER="local"
UPLOAD_DIR="uploads"
# S3_BUCKET=""
# S3_REGION=""
# S3_ENDPOINT="" # set for R2 / Minio / custom
# S3_ACCESS_KEY_ID=""
# S3_SECRET_ACCESS_KEY=""
# --- hCaptcha (optional) ---
# Site key + secret from https://dashboard.hcaptcha.com/
# HCAPTCHA_SITE_KEY=""
# HCAPTCHA_SECRET=""
# --- Webhook worker (optional) ---
# Shared secret protecting POST /api/webhooks/process — call it from a cron job
# every minute to drain pending webhook retries.
# Example call:
# curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
# $PUBLIC_BASE_URL/api/webhooks/process
# CRON_SECRET=""
+14
View File
@@ -0,0 +1,14 @@
node_modules
.next
.env
.env.local
!.env.example
dist
out
*.log
.DS_Store
data/
uploads/
coverage/
test-results/
playwright-report/
+45
View File
@@ -0,0 +1,45 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ENV AUTH_SECRET=build-time-placeholder
ENV AUTH_URL=http://localhost:3000
ENV OIDC_ISSUER=https://authentik.example.com/application/o/formbuilder/
ENV OIDC_CLIENT_ID=build-time-placeholder
ENV OIDC_CLIENT_SECRET=build-time-placeholder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
RUN apk add --no-cache dumb-init
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci --omit=dev
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh
VOLUME ["/app/uploads"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/signin').then(r => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
ENTRYPOINT ["dumb-init", "--", "/app/docker/entrypoint.sh"]
CMD ["node", "server.js"]
+85
View File
@@ -0,0 +1,85 @@
# FormBuilder
Self-hosted form builder for one workspace with Authentik OIDC sign-in, Prisma/Postgres storage, per-form response ACLs, public form links, webhooks, uploads, and an MCP endpoint.
## Portainer Deployment
This repo is ready to deploy as a Portainer stack with `docker-compose.yml`.
1. Create a new git repo and push this directory.
2. In Portainer, create a stack from the git repository.
3. Copy `.env.example` to `.env` in the stack environment and fill the values below.
4. Deploy the stack.
The app container runs `prisma migrate deploy` before starting the Next.js standalone server.
## Required `.env`
```bash
APP_PORT=3000
POSTGRES_DB=formbuilder
POSTGRES_USER=formbuilder
POSTGRES_PASSWORD=replace-with-a-strong-password
AUTH_SECRET=replace-with-openssl-rand-base64-32
AUTH_URL=https://forms.example.com
OIDC_ISSUER=https://authentik.example.com/application/o/formbuilder/
OIDC_CLIENT_ID=replace-with-authentik-client-id
OIDC_CLIENT_SECRET=replace-with-authentik-client-secret
OIDC_PROVIDER_NAME=Authentik
AUTH_BOOTSTRAP_ADMINS=you@example.com
```
Optional values are documented in `.env.example` for Redis rate limiting, email, file storage, hCaptcha, and webhook worker auth.
## Authentik Setup
Create an OAuth2/OpenID provider in Authentik:
- Provider type: OAuth2/OpenID
- Client type: Confidential
- Redirect URI: `${AUTH_URL}/api/auth/callback/oidc`
- Scopes: `openid`, `profile`, `email`
- Issuer mode: use the provider's OpenID Configuration Issuer URL
Then create an Authentik application and bind it to that provider. Put the issuer, client ID, and client secret in `.env`.
The first successful signer becomes an admin. Any emails listed in `AUTH_BOOTSTRAP_ADMINS` are also promoted on first sign-in.
## Persistent Data
The compose stack creates two named volumes:
- `postgres_data`: Postgres database
- `uploads`: local uploaded files mounted at `/app/uploads`
For multi-instance deployments, set `RATE_LIMIT_DRIVER=redis` and provide `REDIS_URL`. For durable object storage outside the app container, configure the S3 values in `.env.example`.
## Useful Commands
```bash
npm ci
npm test
npm run build
docker compose up --build
```
## MCP Endpoint
The MCP endpoint is available at:
```text
POST /api/mcp
```
Create a token in `/app/account`, then send requests with:
```text
Authorization: Bearer fb_xxxxxxxxxxxxxxxx
```
## Stack
Next.js 15 App Router, React 19, Auth.js v5, Authentik OIDC, Prisma, Postgres, Tailwind, and Docker Compose.
+60
View File
@@ -0,0 +1,60 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://${POSTGRES_USER:-formbuilder}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-formbuilder}
AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET}
AUTH_URL: ${AUTH_URL:?set AUTH_URL}
PUBLIC_BASE_URL: ${AUTH_URL}
OIDC_ISSUER: ${OIDC_ISSUER:?set OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?set OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
OIDC_PROVIDER_NAME: ${OIDC_PROVIDER_NAME:-Authentik}
AUTH_BOOTSTRAP_ADMINS: ${AUTH_BOOTSTRAP_ADMINS:-}
RATE_LIMIT_DRIVER: ${RATE_LIMIT_DRIVER:-memory}
REDIS_URL: ${REDIS_URL:-}
EMAIL_DRIVER: ${EMAIL_DRIVER:-none}
EMAIL_FROM: ${EMAIL_FROM:-Forms <forms@example.com>}
RESEND_API_KEY: ${RESEND_API_KEY:-}
SMTP_URL: ${SMTP_URL:-}
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
UPLOAD_DIR: ${UPLOAD_DIR:-uploads}
S3_BUCKET: ${S3_BUCKET:-}
S3_REGION: ${S3_REGION:-}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
HCAPTCHA_SITE_KEY: ${HCAPTCHA_SITE_KEY:-}
HCAPTCHA_SECRET: ${HCAPTCHA_SECRET:-}
CRON_SECRET: ${CRON_SECRET:-}
ports:
- "${APP_PORT:-3000}:3000"
volumes:
- uploads:/app/uploads
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-formbuilder}
POSTGRES_USER: ${POSTGRES_USER:-formbuilder}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-formbuilder} -d ${POSTGRES_DB:-formbuilder}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
uploads:
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -eu
npx prisma migrate deploy
exec "$@"
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: { serverActions: { bodySizeLimit: "2mb" } },
output: "standalone",
};
export default config;
+4803
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "formbuilder",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"db:deploy": "prisma migrate deploy",
"test": "vitest run",
"test:watch": "vitest",
"postinstall": "prisma generate"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.4",
"@dnd-kit/core": "^6.2.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^5.22.0",
"clsx": "^2.1.1",
"ioredis": "^5.4.1",
"lucide-react": "^0.460.0",
"nanoid": "^5.0.8",
"next": "^15.1.0",
"next-auth": "5.0.0-beta.25",
"prisma": "^5.22.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.4",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^2.1.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vitest": "^2.1.5"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};
@@ -0,0 +1,319 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT NOT NULL,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"role" TEXT NOT NULL DEFAULT 'member',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Form" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"ownerId" TEXT NOT NULL,
"fields" TEXT NOT NULL DEFAULT '[]',
"settings" TEXT NOT NULL DEFAULT '{}',
"published" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Form_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FormVersion" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"fields" TEXT NOT NULL,
"settings" TEXT NOT NULL,
"authorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FormVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FormViewer" (
"formId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "FormViewer_pkey" PRIMARY KEY ("formId","userId")
);
-- CreateTable
CREATE TABLE "Response" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"submitterId" TEXT,
"data" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Response_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FormEvent" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"sessionKey" TEXT NOT NULL,
"durationMs" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FormEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartialResponse" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"submitterId" TEXT,
"anonKey" TEXT,
"data" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartialResponse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UploadedFile" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"responseId" TEXT,
"uploaderId" TEXT,
"storageKey" TEXT NOT NULL,
"driver" TEXT NOT NULL DEFAULT 'local',
"name" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"contentType" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UploadedFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookDelivery" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"responseId" TEXT NOT NULL,
"url" TEXT NOT NULL DEFAULT '',
"status" INTEGER NOT NULL DEFAULT 0,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"nextAttemptAt" TIMESTAMP(3),
"lastAttemptAt" TIMESTAMP(3),
"payload" TEXT NOT NULL DEFAULT '',
"secret" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccessToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FormTag" (
"formId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "FormTag_pkey" PRIMARY KEY ("formId","tagId")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"actorId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
"metadata" TEXT NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Form_slug_key" ON "Form"("slug");
-- CreateIndex
CREATE INDEX "FormVersion_formId_createdAt_idx" ON "FormVersion"("formId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "FormVersion_formId_version_key" ON "FormVersion"("formId", "version");
-- CreateIndex
CREATE INDEX "FormEvent_formId_createdAt_idx" ON "FormEvent"("formId", "createdAt");
-- CreateIndex
CREATE INDEX "FormEvent_formId_kind_idx" ON "FormEvent"("formId", "kind");
-- CreateIndex
CREATE UNIQUE INDEX "PartialResponse_formId_submitterId_key" ON "PartialResponse"("formId", "submitterId");
-- CreateIndex
CREATE UNIQUE INDEX "PartialResponse_formId_anonKey_key" ON "PartialResponse"("formId", "anonKey");
-- CreateIndex
CREATE INDEX "UploadedFile_formId_createdAt_idx" ON "UploadedFile"("formId", "createdAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_formId_createdAt_idx" ON "WebhookDelivery"("formId", "createdAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_nextAttemptAt_idx" ON "WebhookDelivery"("nextAttemptAt");
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_tokenHash_key" ON "AccessToken"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_slug_key" ON "Tag"("slug");
-- CreateIndex
CREATE INDEX "FormTag_tagId_idx" ON "FormTag"("tagId");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
-- CreateIndex
CREATE INDEX "AuditLog_actorId_createdAt_idx" ON "AuditLog"("actorId", "createdAt");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Form" ADD CONSTRAINT "Form_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormVersion" ADD CONSTRAINT "FormVersion_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormVersion" ADD CONSTRAINT "FormVersion_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormViewer" ADD CONSTRAINT "FormViewer_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormViewer" ADD CONSTRAINT "FormViewer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Response" ADD CONSTRAINT "Response_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Response" ADD CONSTRAINT "Response_submitterId_fkey" FOREIGN KEY ("submitterId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormEvent" ADD CONSTRAINT "FormEvent_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartialResponse" ADD CONSTRAINT "PartialResponse_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartialResponse" ADD CONSTRAINT "PartialResponse_submitterId_fkey" FOREIGN KEY ("submitterId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UploadedFile" ADD CONSTRAINT "UploadedFile_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UploadedFile" ADD CONSTRAINT "UploadedFile_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormTag" ADD CONSTRAINT "FormTag_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormTag" ADD CONSTRAINT "FormTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+267
View File
@@ -0,0 +1,267 @@
// One workspace, one team. No multi-tenancy.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------- Auth.js (Prisma adapter) ----------
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
role String @default("member") // "admin" | "member"
createdAt DateTime @default(now())
accounts Account[]
sessions Session[]
forms Form[] @relation("FormOwner")
responses Response[]
viewers FormViewer[]
tokens AccessToken[]
uploads UploadedFile[]
partials PartialResponse[]
auditLogs AuditLog[]
formVersions FormVersion[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ---------- Forms ----------
model Form {
id String @id @default(cuid())
slug String @unique
title String
description String?
ownerId String
owner User @relation("FormOwner", fields: [ownerId], references: [id], onDelete: Cascade)
// Field schema stored as JSON: Field[] (see src/lib/types.ts)
fields String @default("[]")
// Form-level settings JSON: { visibility, thanksMessage, theme }
settings String @default("{}")
published Boolean @default(false)
responses Response[]
viewers FormViewer[]
uploads UploadedFile[]
partials PartialResponse[]
events FormEvent[]
versions FormVersion[]
tags FormTag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Immutable snapshot of a form's schema for revert/diff. One row per save.
model FormVersion {
id String @id @default(cuid())
formId String
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
// 1-indexed, monotonically increasing per form.
version Int
title String
description String?
fields String
settings String
// The user who saved this version (may be null if author was deleted).
authorId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@unique([formId, version])
@@index([formId, createdAt])
}
// Who, besides owner & admins, can view responses.
model FormViewer {
formId String
userId String
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([formId, userId])
}
model Response {
id String @id @default(cuid())
formId String
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
submitterId String?
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
// JSON: Record<fieldId, value>
data String
createdAt DateTime @default(now())
}
model FormEvent {
id String @id @default(cuid())
formId String
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
// "view" | "start" | "submit" | "abandon"
kind String
sessionKey String
durationMs Int?
createdAt DateTime @default(now())
@@index([formId, createdAt])
@@index([formId, kind])
}
model PartialResponse {
id String @id @default(cuid())
formId String
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
submitterId String?
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
anonKey String?
data String
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
// Nulls are distinct in Postgres, so multiple rows with one side null
// can coexist — that's intended (one row per (form, identity)).
@@unique([formId, submitterId])
@@unique([formId, anonKey])
}
model UploadedFile {
id String @id @default(cuid())
formId String
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
responseId String?
uploaderId String?
uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
// Storage key — opaque path/key in the configured driver.
storageKey String
// Storage driver that wrote this object (so we can read it back later
// even if the default is later switched).
driver String @default("local")
name String
size Int
contentType String
createdAt DateTime @default(now())
@@index([formId, createdAt])
}
// Outbound webhook delivery record. One row per attempt-chain; deliveries with
// remaining retries have status=0 and nextAttemptAt in the future.
model WebhookDelivery {
id String @id @default(cuid())
formId String
responseId String
url String @default("")
status Int @default(0)
attempts Int @default(0)
lastError String?
// Scheduled time of the next retry. When null, no more retries are pending.
nextAttemptAt DateTime?
lastAttemptAt DateTime?
// The signed JSON body, kept so a worker can retry without re-loading state.
payload String @default("")
// Secret used for HMAC, kept with the row for retry independence.
secret String?
createdAt DateTime @default(now())
@@index([formId, createdAt])
@@index([nextAttemptAt])
}
// Personal access tokens — used for the MCP endpoint.
model AccessToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
// sha256 of the secret; secret is shown once.
tokenHash String @unique
prefix String // first 8 chars of the secret, shown in lists
lastUsedAt DateTime?
createdAt DateTime @default(now())
}
// ---------- Tags ----------
model Tag {
id String @id @default(cuid())
// Lowercase, unique. Display name preserves the casing of the first use.
slug String @unique
name String
// Optional accent color (hex) for the chip; falls back to theme default.
color String?
createdAt DateTime @default(now())
forms FormTag[]
}
model FormTag {
formId String
tagId String
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([formId, tagId])
@@index([tagId])
}
// ---------- Audit log ----------
// Append-only feed of mutations. Visible to admins.
model AuditLog {
id String @id @default(cuid())
// Who did the thing. Null only for system actions (cron, webhook worker, etc).
actorId String?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
// Verb in past tense: "form.created", "form.deleted", "member.role_changed", etc.
action String
// What the action operated on.
entityType String
entityId String?
// Free-form JSON for action-specific details (old/new values, etc.).
metadata String @default("{}")
createdAt DateTime @default(now())
@@index([createdAt])
@@index([entityType, entityId])
@@index([actorId, createdAt])
}
View File
+1
View File
@@ -0,0 +1 @@
export { GET, POST } from "@/lib/auth-handlers";
+47
View File
@@ -0,0 +1,47 @@
// Download for an UploadedFile.
//
// Authorization rules:
// - Branding images (referenced as form.settings.coverImage / .logo) are public —
// the form filler needs to see them.
// - Files attached to a response: viewer/owner/admin only.
// - Pre-submit orphan uploads: only the uploader sees them.
import { type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canViewResults, parseSettings } from "@/lib/forms";
import { driver, type StorageDriver } from "@/lib/storage";
export const runtime = "nodejs";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const file = await prisma.uploadedFile.findUnique({
where: { id },
include: { form: { select: { settings: true, ownerId: true } } },
});
if (!file) return new Response("Not found", { status: 404 });
// Branding images are public.
const s = parseSettings(file.form.settings);
const isBranding = s.coverImage === file.id || s.logo === file.id;
if (!isBranding) {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
const isUploader = file.uploaderId === session.user.id;
const canView = isUploader || (await canViewResults(file.formId, session.user.id, session.user.role));
if (!canView) return new Response("Forbidden", { status: 403 });
}
const stream = await driver(file.driver as StorageDriver).read(file.storageKey);
const download = req.nextUrl.searchParams.get("download") === "1";
return new Response(stream as unknown as ReadableStream, {
headers: {
"content-type": file.contentType || "application/octet-stream",
"content-length": String(file.size),
"content-disposition": `${download ? "attachment" : "inline"}; filename="${file.name.replace(/"/g, "")}"`,
"cache-control": "private, max-age=0, must-revalidate",
},
});
}
+83
View File
@@ -0,0 +1,83 @@
// Multipart upload endpoint. Used by the file field in FormRuntime.
// Accepts:
// form-data field "file" — the file blob
// form-data field "formId" — the form this file is being uploaded for
//
// Returns: { id, name, size, contentType } — the client stores this as the
// field value. The file is associated with a response when the response is
// submitted (UploadedFile.responseId is set then).
import { NextResponse, type NextRequest } from "next/server";
import { nanoid } from "nanoid";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseFields, parseSettings } from "@/lib/forms";
import { driver, currentDriver } from "@/lib/storage";
import { consume, clientIp } from "@/lib/ratelimit";
const MAX_DEFAULT_MB = 25;
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
// Rate-limit uploads per IP.
const ip = clientIp(req.headers);
const rl = await consume(`upload:${ip}`, 20, 60_000);
if (!rl.ok) {
return NextResponse.json(
{ error: "Too many uploads. Slow down." },
{ status: 429, headers: { "retry-after": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } },
);
}
const fd = await req.formData().catch(() => null);
if (!fd) return NextResponse.json({ error: "Multipart body required" }, { status: 400 });
const file = fd.get("file");
const formId = String(fd.get("formId") ?? "");
if (!(file instanceof File) || !formId) {
return NextResponse.json({ error: "Missing file or formId" }, { status: 400 });
}
const form = await prisma.form.findUnique({ where: { id: formId } });
if (!form) return NextResponse.json({ error: "Not found" }, { status: 404 });
const settings = parseSettings(form.settings);
const fields = parseFields(form.fields);
const session = await auth();
const isOwnerOrAdmin = !!session?.user && (session.user.id === form.ownerId || session.user.role === "admin");
// Public form fillers can only upload to published forms; owners/admins can
// upload to draft forms too (branding images uploaded during editing).
if (!form.published && !isOwnerOrAdmin) return NextResponse.json({ error: "Not found" }, { status: 404 });
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Enforce per-form max size (cap by the most permissive file field, or default).
const maxMB = Math.max(MAX_DEFAULT_MB, ...fields.filter((f) => f.type === "file").map((f) => f.maxSizeMB ?? MAX_DEFAULT_MB));
if (file.size > maxMB * 1024 * 1024) {
return NextResponse.json({ error: `File too large (max ${maxMB} MB)` }, { status: 413 });
}
const buf = Buffer.from(await file.arrayBuffer());
const id = nanoid(16);
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "upload";
const key = `${form.id}/${id}-${safeName}`;
const drv = currentDriver();
await driver(drv).put(key, buf, { size: file.size, contentType: file.type || "application/octet-stream" });
const row = await prisma.uploadedFile.create({
data: {
formId: form.id,
uploaderId: session?.user?.id ?? null,
storageKey: key,
driver: drv,
name: file.name || safeName,
size: file.size,
contentType: file.type || "application/octet-stream",
},
select: { id: true, name: true, size: true, contentType: true },
});
return NextResponse.json(row);
}
+30
View File
@@ -0,0 +1,30 @@
// Analytics beacon — extremely lightweight POST. Accepts: { kind, sessionKey, durationMs? }.
// Called via navigator.sendBeacon() from the runtime. Always returns 204.
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { consume, clientIp } from "@/lib/ratelimit";
const VALID = new Set(["view", "start", "submit", "abandon"]);
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const rl = await consume(`event:${id}:${clientIp(req.headers)}`, 60, 60_000);
if (!rl.ok) return new Response(null, { status: 204 });
// sendBeacon sends a Blob, not always JSON — accept either.
let body: { kind?: string; sessionKey?: string; durationMs?: number } = {};
try { body = await req.json(); } catch { /* ignore */ }
if (!body.kind || !VALID.has(body.kind) || !body.sessionKey) return new Response(null, { status: 204 });
await prisma.formEvent.create({
data: {
formId: id,
kind: body.kind,
sessionKey: String(body.sessionKey).slice(0, 64),
durationMs: typeof body.durationMs === "number" ? Math.max(0, Math.min(86_400_000, body.durationMs)) : null,
},
}).catch(() => {});
return new Response(null, { status: 204 });
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canViewResults, parseFields } from "@/lib/forms";
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) return new NextResponse("Unauthorized", { status: 401 });
if (!(await canViewResults(id, session.user.id, session.user.role))) {
return new NextResponse("Forbidden", { status: 403 });
}
const form = await prisma.form.findUnique({ where: { id } });
if (!form) return new NextResponse("Not found", { status: 404 });
const fields = parseFields(form.fields);
const responses = await prisma.response.findMany({
where: { formId: id },
orderBy: { createdAt: "asc" },
include: { submitter: { select: { email: true } } },
});
const header = ["created_at", "submitter", ...fields.map((f) => f.label)];
const rows = responses.map((r) => {
let data: Record<string, unknown> = {};
try { data = JSON.parse(r.data); } catch {}
return [
r.createdAt.toISOString(),
r.submitter?.email ?? "",
...fields.map((f) => stringify(data[f.id])),
];
});
const csv = [header, ...rows].map((r) => r.map(csvCell).join(",")).join("\n");
return new NextResponse(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="${form.slug}.csv"`,
},
});
}
function stringify(v: unknown): string {
if (v === undefined || v === null) return "";
if (Array.isArray(v)) return v.join("; ");
if (typeof v === "boolean") return v ? "true" : "false";
return String(v);
}
function csvCell(s: string) {
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
+82
View File
@@ -0,0 +1,82 @@
// Partial submission persistence. Lets responders close the tab and come back.
//
// - Signed-in users: keyed by (formId, submitterId).
// - Anonymous: an httpOnly cookie "fb_partial=<nanoid>" identifies the row.
// We set it on first PUT/GET if missing.
//
// GET → { data: Record<string, unknown> } | null
// PUT → { ok: true } body: { data: Record<string, unknown> }
//
// Submissions delete the partial row in the main submit handler.
import { NextResponse, type NextRequest } from "next/server";
import { nanoid } from "nanoid";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseSettings } from "@/lib/forms";
const COOKIE = "fb_partial";
async function getKey(req: NextRequest): Promise<{ submitterId: string | null; anonKey: string | null; setCookie?: string }> {
const session = await auth();
if (session?.user) return { submitterId: session.user.id, anonKey: null };
const existing = req.cookies.get(COOKIE)?.value;
if (existing) return { submitterId: null, anonKey: existing };
const fresh = nanoid(24);
return { submitterId: null, anonKey: fresh, setCookie: fresh };
}
function cookieHeader(value: string): string {
return `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`;
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const form = await prisma.form.findUnique({ where: { id } });
if (!form) return NextResponse.json({ error: "Not found" }, { status: 404 });
const k = await getKey(req);
const where = k.submitterId
? { formId_submitterId: { formId: id, submitterId: k.submitterId } }
: { formId_anonKey: { formId: id, anonKey: k.anonKey! } };
const row = await prisma.partialResponse.findUnique({ where });
const res = NextResponse.json(row ? { data: safeParse(row.data) } : null);
if (k.setCookie) res.headers.append("set-cookie", cookieHeader(k.setCookie));
return res;
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const form = await prisma.form.findUnique({ where: { id } });
if (!form || !form.published) return NextResponse.json({ error: "Not found" }, { status: 404 });
const settings = parseSettings(form.settings);
if ((settings.visibility ?? "workspace") === "workspace") {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
const data = body.data;
if (!data || typeof data !== "object") return NextResponse.json({ error: "Bad request" }, { status: 400 });
const k = await getKey(req);
const json = JSON.stringify(data);
await prisma.partialResponse.upsert({
where: k.submitterId
? { formId_submitterId: { formId: id, submitterId: k.submitterId } }
: { formId_anonKey: { formId: id, anonKey: k.anonKey! } },
update: { data: json },
create: { formId: id, submitterId: k.submitterId, anonKey: k.anonKey, data: json },
});
const res = NextResponse.json({ ok: true });
if (k.setCookie) res.headers.append("set-cookie", cookieHeader(k.setCookie));
return res;
}
function safeParse(s: string): unknown {
try { return JSON.parse(s); } catch { return {}; }
}
+154
View File
@@ -0,0 +1,154 @@
import { NextResponse, type NextRequest } from "next/server";
import { after } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseFields, parseSettings } from "@/lib/forms";
import { visibleFields } from "@/lib/logic";
import { applyCalculations } from "@/lib/calc";
import { consume, clientIp } from "@/lib/ratelimit";
import { sendEmail } from "@/lib/notify";
import { enqueueWebhook } from "@/lib/webhook";
import { escapeHtml } from "@/lib/sanitize";
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const ip = clientIp(req.headers);
const rl = await consume(`submit:${id}:${ip}`, 10, 60_000);
if (!rl.ok) {
const retry = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: "Too many submissions. Slow down." },
{ status: 429, headers: { "retry-after": String(retry) } },
);
}
const form = await prisma.form.findUnique({ where: { id } });
if (!form || !form.published) return NextResponse.json({ error: "Not found" }, { status: 404 });
const settings = parseSettings(form.settings);
const session = await auth();
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
// Honeypot — bots fill the hidden _h field; humans don't.
if (typeof body._h === "string" && body._h.length > 0) {
// Pretend success so bots don't learn we noticed.
return NextResponse.json({ ok: true });
}
// hCaptcha verification (only when enabled on the form and a secret is configured).
if (settings.hcaptchaEnabled && process.env.HCAPTCHA_SECRET) {
const token = typeof body._captcha === "string" ? body._captcha : "";
if (!token) return NextResponse.json({ error: "Captcha required" }, { status: 400 });
const verify = await fetch("https://api.hcaptcha.com/siteverify", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret: process.env.HCAPTCHA_SECRET, response: token }),
}).then((r) => r.json() as Promise<{ success: boolean }>).catch(() => ({ success: false }));
if (!verify.success) return NextResponse.json({ error: "Captcha failed" }, { status: 400 });
}
const data = (body.data ?? {}) as Record<string, unknown>;
const fields = parseFields(form.fields);
const shown = visibleFields(fields, data);
// Server-side validation (mirrors client).
const cleaned: Record<string, unknown> = {};
for (const f of shown) {
const v = data[f.id];
const empty = v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
if (f.required && empty) return NextResponse.json({ error: `Missing: ${f.label}` }, { status: 400 });
if (!empty) {
if (f.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v))) {
return NextResponse.json({ error: `Invalid email: ${f.label}` }, { status: 400 });
}
if (f.type === "number") {
const n = Number(v);
if (Number.isNaN(n)) return NextResponse.json({ error: `Not a number: ${f.label}` }, { status: 400 });
if (f.min !== undefined && n < f.min) return NextResponse.json({ error: `${f.label} < ${f.min}` }, { status: 400 });
if (f.max !== undefined && n > f.max) return NextResponse.json({ error: `${f.label} > ${f.max}` }, { status: 400 });
cleaned[f.id] = n;
continue;
}
cleaned[f.id] = v;
}
}
// Compute calculated fields server-side (don't trust client values).
const withCalcs = applyCalculations(fields, cleaned);
const response = await prisma.response.create({
data: {
formId: form.id,
submitterId: session?.user?.id ?? null,
data: JSON.stringify(withCalcs),
},
});
// Bind any uploaded files to this response so permission checks work later
// and orphan-cleanup can prune un-attached uploads.
const fileIds: string[] = [];
for (const f of fields) {
if (f.type !== "file") continue;
const v = cleaned[f.id] as { id?: string } | undefined;
if (v && typeof v.id === "string") fileIds.push(v.id);
}
if (fileIds.length > 0) {
await prisma.uploadedFile.updateMany({
where: { id: { in: fileIds }, formId: form.id },
data: { responseId: response.id },
}).catch(() => {});
}
// Clear the partial row (if any) — submission complete.
const partialKey = session?.user
? { formId_submitterId: { formId: form.id, submitterId: session.user.id } }
: (req.cookies.get("fb_partial")?.value
? { formId_anonKey: { formId: form.id, anonKey: req.cookies.get("fb_partial")!.value } }
: null);
if (partialKey) {
await prisma.partialResponse.delete({ where: partialKey }).catch(() => {});
}
// Notifications fire after the response is returned — never block the user.
after(async () => {
const baseUrl = process.env.PUBLIC_BASE_URL ?? "";
if (settings.notifyEmails && settings.notifyEmails.length > 0) {
const lines = fields
.filter((f) => f.type !== "page_break" && withCalcs[f.id] !== undefined)
.map((f) => `<p><strong>${escapeHtml(f.label)}</strong><br>${escapeHtml(String(formatVal(withCalcs[f.id])))}</p>`)
.join("");
await sendEmail({
to: settings.notifyEmails,
subject: `New response: ${form.title}`,
html: `<h2>${escapeHtml(form.title)}</h2>${lines}<p><a href="${baseUrl}/app/forms/${form.id}/responses">View all responses</a></p>`,
});
}
if (settings.webhookUrl) {
await enqueueWebhook({
url: settings.webhookUrl,
secret: settings.webhookSecret,
payload: {
event: "response.created",
formId: form.id,
responseId: response.id,
data: withCalcs,
submittedAt: response.createdAt.toISOString(),
},
});
}
});
return NextResponse.json({ ok: true });
}
function formatVal(v: unknown): string {
if (Array.isArray(v)) return v.join(", ");
if (typeof v === "boolean") return v ? "Yes" : "No";
return String(v ?? "");
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse, type NextRequest } from "next/server";
import { authenticate, handleRpc } from "@/lib/mcp";
import { consume } from "@/lib/ratelimit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const ctx = await authenticate(req.headers.get("authorization"));
if (!ctx) {
return new NextResponse(JSON.stringify({
jsonrpc: "2.0", id: null,
error: { code: -32001, message: "Unauthorized — send Authorization: Bearer <token>" },
}), { status: 401, headers: { "content-type": "application/json", "WWW-Authenticate": "Bearer" } });
}
const rl = await consume(`mcp:${ctx.userId}`, 60, 60_000);
if (!rl.ok) {
const retry = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return new NextResponse(JSON.stringify({
jsonrpc: "2.0", id: null,
error: { code: -32002, message: "Rate limit exceeded" },
}), { status: 429, headers: { "content-type": "application/json", "retry-after": String(retry) } });
}
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, { status: 400 });
// Support both single and batch requests
if (Array.isArray(body)) {
const out = (await Promise.all(body.map((m) => handleRpc(m, ctx)))).filter(Boolean);
return NextResponse.json(out);
}
const res = await handleRpc(body, ctx);
if (!res) return new NextResponse(null, { status: 204 });
return NextResponse.json(res);
}
export async function GET() {
return NextResponse.json({
name: "formbuilder MCP",
transport: "http",
instructions: "POST JSON-RPC 2.0 messages here with Authorization: Bearer <token>.",
});
}
+33
View File
@@ -0,0 +1,33 @@
// Admin/owner retry-now endpoint. POSTs to /api/webhooks/<deliveryId>/retry to
// force an immediate attempt, regardless of nextAttemptAt.
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canEditForm } from "@/lib/forms";
import { attemptDelivery } from "@/lib/webhook";
import { recordAudit } from "@/lib/audit";
export const runtime = "nodejs";
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const row = await prisma.webhookDelivery.findUnique({ where: { id }, select: { formId: true } });
if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!(await canEditForm(row.formId, session.user.id, session.user.role))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ok = await attemptDelivery(id);
await recordAudit({
actorId: session.user.id,
action: "webhook.retried",
entityType: "webhook",
entityId: id,
metadata: { ok },
});
return NextResponse.json({ ok });
}
+25
View File
@@ -0,0 +1,25 @@
// Cron-driven webhook drainer. Call this every minute from an external scheduler
// (cron-job.org, a Kubernetes CronJob, or `node -e "fetch(...)"` on a timer).
//
// Auth: set CRON_SECRET in env, then call with header `Authorization: Bearer <secret>`.
import { NextResponse, type NextRequest } from "next/server";
import { processDueWebhooks } from "@/lib/webhook";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) return NextResponse.json({ error: "CRON_SECRET not configured" }, { status: 503 });
const auth = req.headers.get("authorization") ?? "";
if (auth !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const res = await processDueWebhooks();
return NextResponse.json(res);
}
export async function GET(req: NextRequest) {
return POST(req);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { createToken, revokeToken } from "./tokenActions";
import { Trash2 } from "lucide-react";
type Token = { id: string; name: string; prefix: string; createdAt: string; lastUsedAt: string | null };
export default function TokenManager({ initialTokens }: { initialTokens: Token[] }) {
const [tokens, setTokens] = useState<Token[]>(initialTokens);
const [name, setName] = useState("");
const [secret, setSecret] = useState<string | null>(null);
const [pending, start] = useTransition();
return (
<div className="space-y-3">
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!name.trim()) return;
start(async () => {
const r = await createToken(name.trim());
setSecret(r.secret);
setTokens([{ id: r.id, name: r.name, prefix: r.prefix, createdAt: new Date().toISOString(), lastUsedAt: null }, ...tokens]);
setName("");
});
}}
>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Token name (e.g. Claude Desktop)" />
<Button type="submit" variant="primary" disabled={pending}>Create</Button>
</form>
{secret && (
<div className="border border-fg/30 rounded-lg p-3 bg-line/30 space-y-1.5">
<div className="text-xs text-muted">Copy now you won't see it again.</div>
<div className="flex gap-2">
<code className="font-mono text-sm flex-1 break-all">{secret}</code>
<Button size="sm" variant="outline" onClick={() => navigator.clipboard.writeText(secret)}>Copy</Button>
<Button size="sm" variant="ghost" onClick={() => setSecret(null)}>Dismiss</Button>
</div>
</div>
)}
<ul className="border border-line rounded-xl divide-y divide-line">
{tokens.length === 0 && (
<li className="px-4 py-6 text-sm text-muted text-center">No tokens.</li>
)}
{tokens.map((t) => (
<li key={t.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="min-w-0 flex-1">
<div>{t.name}</div>
<div className="text-xs text-muted font-mono">{t.prefix} · created {new Date(t.createdAt).toLocaleDateString()}</div>
</div>
<button
onClick={() => start(async () => { await revokeToken(t.id); setTokens(tokens.filter((x) => x.id !== t.id)); })}
className="btn btn-ghost btn-sm text-muted hover:text-danger"
aria-label="Revoke"
>
<Trash2 size={14}/>
</button>
</li>
))}
</ul>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import TokenManager from "./TokenManager";
export default async function AccountPage() {
const session = await auth();
if (!session?.user) redirect("/signin");
const tokens = await prisma.accessToken.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
select: { id: true, name: true, prefix: true, createdAt: true, lastUsedAt: true },
});
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-xl font-semibold mb-1">Account</h1>
<p className="text-sm text-muted">{session.user.email} · {session.user.role}</p>
</div>
<section className="space-y-3">
<div>
<h2 className="font-medium">MCP tokens</h2>
<p className="text-xs text-muted">
Use these to connect an LLM client to <code className="font-mono">/api/mcp</code>.
Send <code className="font-mono">Authorization: Bearer &lt;token&gt;</code>.
</p>
</div>
<TokenManager initialTokens={tokens.map((t) => ({ ...t, createdAt: t.createdAt.toISOString(), lastUsedAt: t.lastUsedAt?.toISOString() ?? null }))} />
</section>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { createHash, randomBytes } from "crypto";
export async function createToken(name: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
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: session.user.id, name, tokenHash, prefix },
select: { id: true, name: true, prefix: true },
});
revalidatePath("/app/account");
return { ...t, secret };
}
export async function revokeToken(id: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
await prisma.accessToken.deleteMany({ where: { id, userId: session.user.id } });
revalidatePath("/app/account");
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseAuditMeta } from "@/lib/audit";
import { ScrollText } from "lucide-react";
function fmtRelative(d: Date): string {
const ms = Date.now() - d.getTime();
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
const VERBS: Record<string, (m: Record<string, unknown>) => string> = {
"form.created": (m) => `created form “${m.title ?? "Untitled"}`,
"form.updated": () => "edited a form",
"form.deleted": (m) => `deleted form “${m.title ?? "Untitled"}`,
"form.published": (m) => `published “${m.title ?? "Untitled"}`,
"form.unpublished": (m) => `unpublished “${m.title ?? "Untitled"}`,
"form.reverted": (m) => `reverted to v${m.revertedTo}`,
"form.viewers_set": (m) => `updated viewers (${m.count})`,
"form.tagged": (m) => `set tags: ${(m.tags as string[] | undefined)?.join(", ") || "none"}`,
"response.deleted": () => "deleted a response",
"member.role_changed": (m) => `${m.email}: ${m.from}${m.to}`,
"member.removed": (m) => `removed ${m.email}`,
"token.created": (m) => `created API token “${m.name}`,
"token.revoked": (m) => `revoked API token`,
"webhook.retried": (m) => `retried webhook (${m.ok ? "ok" : "failed"})`,
};
export default async function AuditPage() {
const session = await auth();
if (!session?.user) redirect("/signin");
if (session.user.role !== "admin") redirect("/app");
const rows = await prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 200,
include: { actor: { select: { name: true, email: true } } },
});
return (
<div className="space-y-4 max-w-3xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">Audit log</h1>
<p className="text-sm text-[rgb(var(--muted))]">Last 200 events. Append-only.</p>
</div>
<ScrollText size={18} className="text-[rgb(var(--muted))]" />
</div>
{rows.length === 0 ? (
<div className="card p-10 text-center text-sm text-[rgb(var(--muted))]">
Nothing logged yet.
</div>
) : (
<ul className="border border-[rgb(var(--line))] rounded-xl divide-y divide-[rgb(var(--line))]">
{rows.map((r) => {
const meta = parseAuditMeta(r.metadata);
const fmt = VERBS[r.action] ?? (() => r.action);
const actor = r.actor?.name || r.actor?.email || "system";
const detail = fmt(meta);
const formLink =
r.entityType === "form" && r.entityId && r.action !== "form.deleted"
? `/app/forms/${r.entityId}`
: null;
return (
<li key={r.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="min-w-0 flex-1">
<span className="font-medium text-[rgb(var(--fg))]">{actor}</span>{" "}
<span className="text-[rgb(var(--muted))]">{detail}</span>
{formLink && (
<Link href={formLink} className="ml-2 text-xs text-[rgb(var(--muted))] underline">
open
</Link>
)}
</div>
<div className="text-xs text-[rgb(var(--muted))] shrink-0 font-mono">
{fmtRelative(r.createdAt)}
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canViewResults } from "@/lib/forms";
import { ArrowLeft } from "lucide-react";
const DAYS = 30;
export default async function Analytics({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect("/signin");
const form = await prisma.form.findUnique({ where: { id } });
if (!form) notFound();
if (!(await canViewResults(form.id, session.user.id, session.user.role))) {
return <p className="text-sm text-muted">You don't have access to this form's analytics.</p>;
}
const since = new Date(Date.now() - DAYS * 86_400_000);
const events = await prisma.formEvent.groupBy({
by: ["kind"],
where: { formId: id, createdAt: { gte: since } },
_count: { _all: true },
});
const totals = Object.fromEntries(events.map((e) => [e.kind, e._count._all])) as Record<string, number>;
const views = totals.view ?? 0;
const starts = totals.start ?? 0;
const submits = totals.submit ?? 0;
const completion = starts > 0 ? Math.round((submits / starts) * 100) : 0;
const submitDurations = await prisma.formEvent.findMany({
where: { formId: id, kind: "submit", durationMs: { not: null }, createdAt: { gte: since } },
select: { durationMs: true },
take: 500,
});
const avgMs = submitDurations.length > 0
? submitDurations.reduce((s, e) => s + (e.durationMs ?? 0), 0) / submitDurations.length
: 0;
// Daily counts by kind for sparkline.
type Row = { day: Date; kind: string; n: bigint };
const daily = await prisma.$queryRaw<Row[]>`
SELECT date_trunc('day', "createdAt") AS day, kind, COUNT(*)::bigint AS n
FROM "FormEvent"
WHERE "formId" = ${id} AND "createdAt" >= ${since}
GROUP BY day, kind
ORDER BY day ASC
`;
const buckets = bucketize(daily, since, DAYS);
return (
<div className="space-y-6">
<div className="flex items-center gap-3 flex-wrap">
<Link href={`/app/forms/${form.id}`} className="btn btn-ghost btn-sm"><ArrowLeft size={14}/>{form.title}</Link>
<h1 className="text-lg font-semibold">Analytics</h1>
<span className="text-xs text-muted">last {DAYS} days</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card label="Views" value={views} />
<Card label="Starts" value={starts} />
<Card label="Submits" value={submits} />
<Card label="Completion" value={`${completion}%`} sub={`avg ${formatDuration(avgMs)}`} />
</div>
<div className="border border-line rounded-xl p-4 space-y-3">
<div className="text-xs uppercase tracking-wide text-muted">Daily</div>
<Sparkline series={buckets.view} color="rgb(var(--muted))" label="Views" />
<Sparkline series={buckets.submit} color="rgb(var(--fg))" label="Submits" />
</div>
</div>
);
}
function Card({ label, value, sub }: { label: string; value: number | string; sub?: string }) {
return (
<div className="border border-line rounded-xl p-4">
<div className="text-xs text-muted">{label}</div>
<div className="text-2xl font-semibold mt-1">{value}</div>
{sub && <div className="text-xs text-muted mt-1">{sub}</div>}
</div>
);
}
function Sparkline({ series, color, label }: { series: number[]; color: string; label: string }) {
const max = Math.max(1, ...series);
const w = 600, h = 40;
const stepX = w / Math.max(1, series.length - 1);
const points = series.map((v, i) => `${(i * stepX).toFixed(1)},${(h - (v / max) * h).toFixed(1)}`).join(" ");
return (
<div>
<div className="text-xs text-muted mb-1">{label}</div>
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-10" preserveAspectRatio="none">
<polyline points={points} fill="none" stroke={color} strokeWidth={1.5} />
</svg>
</div>
);
}
type Row = { day: Date; kind: string; n: bigint };
function bucketize(rows: Row[], since: Date, days: number) {
const start = new Date(since); start.setUTCHours(0, 0, 0, 0);
const view = new Array(days).fill(0) as number[];
const submit = new Array(days).fill(0) as number[];
for (const r of rows) {
const idx = Math.floor((new Date(r.day).getTime() - start.getTime()) / 86_400_000);
if (idx < 0 || idx >= days) continue;
const n = Number(r.n);
if (r.kind === "view") view[idx] += n;
if (r.kind === "submit") submit[idx] += n;
}
return { view, submit };
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)} s`;
return `${Math.round(s / 60)} min`;
}
+54
View File
@@ -0,0 +1,54 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canEditForm, parseFields, parseSettings } from "@/lib/forms";
import { listAllTags } from "@/lib/tags";
import Builder from "@/components/builder/Builder";
import TagBar from "@/components/ui/TagBar";
export default async function FormPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect("/signin");
const form = await prisma.form.findUnique({
where: { id },
include: {
viewers: { select: { userId: true } },
tags: { include: { tag: { select: { slug: true, name: true } } } },
},
});
if (!form) notFound();
if (!(await canEditForm(form.id, session.user.id, session.user.role))) {
redirect(`/app/forms/${form.id}/responses`);
}
const [members, allTags] = await Promise.all([
prisma.user.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, email: true },
}),
listAllTags(),
]);
return (
<div className="space-y-3">
<TagBar
formId={form.id}
initialTags={form.tags.map((ft) => ({ slug: ft.tag.slug, name: ft.tag.name }))}
allTags={allTags.map((t) => ({ slug: t.slug, name: t.name, count: t.count }))}
/>
<Builder
form={{
id: form.id,
slug: form.slug,
title: form.title,
description: form.description ?? "",
published: form.published,
fields: parseFields(form.fields),
settings: parseSettings(form.settings),
viewerIds: form.viewers.map((v) => v.userId),
}}
members={members}
currentUserId={session.user.id}
/>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canViewResults, parseFields } from "@/lib/forms";
import { Download, ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
const PAGE_SIZE = 50;
export default async function Responses({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const { id } = await params;
const sp = await searchParams;
const session = await auth();
if (!session?.user) redirect("/signin");
const form = await prisma.form.findUnique({ where: { id } });
if (!form) notFound();
if (!(await canViewResults(form.id, session.user.id, session.user.role))) {
return <p className="text-sm text-muted">You don't have access to these responses.</p>;
}
const fields = parseFields(form.fields);
const total = await prisma.response.count({ where: { formId: id } });
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const page = Math.min(Math.max(1, Number(sp.page) || 1), pages);
const responses = await prisma.response.findMany({
where: { formId: id },
orderBy: { createdAt: "desc" },
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
include: { submitter: { select: { name: true, email: true } } },
});
return (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<Link href={`/app/forms/${form.id}`} className="btn btn-ghost btn-sm"><ArrowLeft size={14}/>{form.title}</Link>
<h1 className="text-lg font-semibold">Responses</h1>
<span className="text-xs text-muted">{total} total</span>
<Link href={`/api/forms/${form.id}/export`} className="ml-auto btn btn-outline btn-sm">
<Download size={14}/>CSV
</Link>
</div>
{responses.length === 0 ? (
<div className="border border-dashed border-line rounded-xl p-10 text-center text-muted text-sm">No responses yet.</div>
) : (
<div className="border border-line rounded-xl overflow-auto">
<table className="w-full text-sm">
<thead className="bg-line/30 text-xs uppercase tracking-wide text-muted">
<tr>
<th className="text-left px-3 py-2 font-medium">When</th>
<th className="text-left px-3 py-2 font-medium">By</th>
{fields.map((f) => <th key={f.id} className="text-left px-3 py-2 font-medium">{f.label}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-line">
{responses.map((r) => {
let data: Record<string, unknown> = {};
try { data = JSON.parse(r.data); } catch {}
return (
<tr key={r.id}>
<td className="px-3 py-2 text-muted whitespace-nowrap">{r.createdAt.toLocaleString()}</td>
<td className="px-3 py-2 text-muted whitespace-nowrap">{r.submitter?.name || r.submitter?.email || "anonymous"}</td>
{fields.map((f) => (
<td key={f.id} className="px-3 py-2 max-w-xs truncate">{renderCell(f.type, data[f.id])}</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{pages > 1 && (
<div className="flex items-center justify-end gap-1 text-sm">
<Link
href={`/app/forms/${form.id}/responses?page=${Math.max(1, page - 1)}`}
aria-disabled={page === 1}
className={`btn btn-ghost btn-sm ${page === 1 ? "opacity-40 pointer-events-none" : ""}`}
>
<ChevronLeft size={14}/>Prev
</Link>
<span className="text-xs text-muted px-2">Page {page} / {pages}</span>
<Link
href={`/app/forms/${form.id}/responses?page=${Math.min(pages, page + 1)}`}
aria-disabled={page === pages}
className={`btn btn-ghost btn-sm ${page === pages ? "opacity-40 pointer-events-none" : ""}`}
>
Next<ChevronRight size={14}/>
</Link>
</div>
)}
</div>
);
}
function formatCell(v: unknown): string {
if (v === undefined || v === null) return "";
if (Array.isArray(v)) return v.join(", ");
if (typeof v === "boolean") return v ? "✓" : "";
return String(v);
}
function renderCell(type: string, v: unknown) {
if ((type === "file" || type === "signature") && v && typeof v === "object" && "id" in (v as object)) {
const f = v as { id: string; name: string };
if (type === "signature") {
// eslint-disable-next-line @next/next/no-img-element
return <a href={`/api/files/${f.id}`} target="_blank" rel="noreferrer"><img src={`/api/files/${f.id}`} alt={f.name} className="h-10 inline-block" /></a>;
}
return (
<a href={`/api/files/${f.id}`} target="_blank" rel="noreferrer" className="underline text-fg hover:opacity-80">
{f.name}
</a>
);
}
return formatCell(v);
}
+69
View File
@@ -0,0 +1,69 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canEditForm } from "@/lib/forms";
import { listVersions } from "@/lib/versions";
import { revertFormToVersion } from "@/lib/actions";
import { History, RotateCcw, ArrowLeft } from "lucide-react";
function fmtRelative(d: Date): string {
const ms = Date.now() - d.getTime();
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
export default async function VersionsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect("/signin");
const form = await prisma.form.findUnique({ where: { id }, select: { id: true, title: true } });
if (!form) notFound();
if (!(await canEditForm(form.id, session.user.id, session.user.role))) {
redirect(`/app/forms/${form.id}/responses`);
}
const versions = await listVersions(form.id);
return (
<div className="space-y-5 max-w-3xl mx-auto">
<div className="flex items-center justify-between">
<div>
<Link href={`/app/forms/${form.id}`} className="text-xs text-[rgb(var(--muted))] hover:text-[rgb(var(--fg))] inline-flex items-center gap-1 mb-1">
<ArrowLeft size={12} /> Back to builder
</Link>
<h1 className="text-xl font-semibold tracking-tight">{form.title || "Untitled form"} · history</h1>
<p className="text-sm text-[rgb(var(--muted))]">
{versions.length} version{versions.length === 1 ? "" : "s"}. The latest is at the top.
</p>
</div>
<History size={18} className="text-[rgb(var(--muted))]" />
</div>
<ul className="border border-[rgb(var(--line))] rounded-xl divide-y divide-[rgb(var(--line))]">
{versions.map((v, idx) => (
<li key={v.id} className="flex items-center gap-4 px-4 py-3">
<div className="text-xs font-mono text-[rgb(var(--muted))] w-12">v{v.version}</div>
<div className="min-w-0 flex-1">
<div className="text-sm">{v.title || "Untitled"}</div>
<div className="text-xs text-[rgb(var(--muted))]">
{v.author?.name || v.author?.email || "system"} · {fmtRelative(v.createdAt)}
{idx === 0 && <span className="ml-2 text-[rgb(var(--fg))]">· current</span>}
</div>
</div>
{idx !== 0 && (
<form action={async () => { "use server"; await revertFormToVersion(form.id, v.version); }}>
<button className="btn btn-outline btn-sm" type="submit">
<RotateCcw size={12} />
Revert
</button>
</form>
)}
</li>
))}
</ul>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import Link from "next/link";
import { auth, signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { NavLinks } from "@/components/ui/NavLinks";
import { LogOut } from "lucide-react";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) redirect("/signin");
const isAdmin = session.user.role === "admin";
const initials = (session.user.name || session.user.email || "U")
.split(" ").map((p: string) => p[0]).join("").toUpperCase().slice(0, 2);
return (
<div className="min-h-screen bg-[rgb(var(--bg))]">
{/* Top header bar */}
<header className="sticky top-0 z-40 bg-[rgb(var(--surface)/0.95)] border-b border-[rgb(var(--line))]"
style={{ backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)" }}>
<div className="max-w-6xl mx-auto px-5 h-14 flex items-center gap-3">
{/* Logo */}
<Link href="/app" className="shrink-0 text-[rgb(var(--fg))] hover:opacity-60 transition-opacity mr-1">
<AsteriskLogo />
</Link>
{/* Vertical separator */}
<div className="w-px h-4 bg-[rgb(var(--line))] shrink-0" />
{/* Nav links */}
<NavLinks isAdmin={isAdmin} />
{/* Right: user + controls */}
<div className="ml-auto flex items-center gap-1.5">
<div
className="h-7 w-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0
bg-[rgb(var(--line))] text-[rgb(var(--fg-2))] border border-[rgb(var(--line))]"
title={session.user.name || session.user.email || "User"}
>
{initials}
</div>
<ThemeToggle />
<form action={async () => { "use server"; await signOut({ redirectTo: "/" }); }}>
<button type="submit" className="app-icon-btn" title="Sign out" aria-label="Sign out">
<LogOut size={14} strokeWidth={1.8} />
</button>
</form>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-5 py-6">{children}</main>
</div>
);
}
function AsteriskLogo() {
return (
<svg width="24" height="24" viewBox="0 0 28 28" fill="none" aria-label="Forms home">
<line x1="14" y1="3" x2="14" y2="25" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
<line x1="3" y1="14" x2="25" y2="14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
<line x1="5.8" y1="5.8" x2="22.2" y2="22.2" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
<line x1="22.2" y1="5.8" x2="5.8" y2="22.2" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { setMemberRole, removeMember } from "@/lib/actions";
import { Trash2 } from "lucide-react";
export default async function MembersPage() {
const session = await auth();
if (!session?.user) redirect("/signin");
if (session.user.role !== "admin") redirect("/app");
const members = await prisma.user.findMany({ orderBy: { createdAt: "asc" } });
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<h1 className="text-xl font-semibold">Members</h1>
<p className="text-xs text-muted">Members appear here the first time they sign in.</p>
</div>
<ul className="border border-line rounded-xl divide-y divide-line">
{members.map((m) => (
<li key={m.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm">{m.name || "—"}</div>
<div className="text-xs text-muted">{m.email}</div>
</div>
<form action={async () => { "use server"; await setMemberRole(m.id, m.role === "admin" ? "member" : "admin"); }}>
<button className="btn btn-outline btn-sm">
{m.role === "admin" ? "Admin" : "Member"}
</button>
</form>
{m.id !== session.user.id && (
<form action={async () => { "use server"; await removeMember(m.id); }}>
<button className="btn btn-ghost btn-sm text-muted hover:text-danger" aria-label="Remove">
<Trash2 size={14}/>
</button>
</form>
)}
</li>
))}
</ul>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import Link from "next/link";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { createForm, createFromTemplate } from "@/lib/actions";
import { TEMPLATES } from "@/lib/templates";
import { listAllTags } from "@/lib/tags";
import { Plus, FileText, Layers } from "lucide-react";
export default async function FormsList({ searchParams }: { searchParams: Promise<{ tag?: string }> }) {
const { tag: tagFilter } = await searchParams;
const session = await auth();
const userId = session!.user.id;
const isAdmin = session!.user.role === "admin";
const baseWhere = isAdmin ? {} : {
OR: [
{ ownerId: userId },
{ viewers: { some: { userId } } },
{ published: true },
],
};
const where = tagFilter
? { AND: [baseWhere, { tags: { some: { tag: { slug: tagFilter } } } }] }
: baseWhere;
const [forms, allTags] = await Promise.all([
prisma.form.findMany({
where,
orderBy: { updatedAt: "desc" },
include: {
_count: { select: { responses: true } },
owner: { select: { name: true, email: true } },
viewers: { where: { userId }, select: { userId: true } },
tags: { include: { tag: { select: { slug: true, name: true } } } },
},
}),
listAllTags(),
]);
return (
<div className="space-y-8">
{/* Header row */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">Forms</h1>
<p className="text-sm text-[rgb(var(--muted))] mt-0.5">
{forms.length === 0
? (tagFilter ? `No forms tagged “${tagFilter}` : "No forms yet")
: `${forms.length} form${forms.length === 1 ? "" : "s"}${tagFilter ? ` tagged “${tagFilter}` : ""}`}
</p>
</div>
<form action={createForm}>
<button className="btn btn-primary">
<Plus size={15} strokeWidth={2.5} />
New form
</button>
</form>
</div>
{/* Tag filter chips */}
{allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<Link
href="/app"
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
!tagFilter
? "bg-[rgb(var(--fg))] text-[rgb(var(--bg))] border-[rgb(var(--fg))]"
: "border-[rgb(var(--line))] hover:border-[rgb(var(--fg-2))]"
}`}
>
All
</Link>
{allTags.map((t) => (
<Link
key={t.slug}
href={`/app?tag=${encodeURIComponent(t.slug)}`}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
tagFilter === t.slug
? "bg-[rgb(var(--fg))] text-[rgb(var(--bg))] border-[rgb(var(--fg))]"
: "border-[rgb(var(--line))] hover:border-[rgb(var(--fg-2))]"
}`}
>
{t.name}
<span className="ml-1 opacity-60">{t.count}</span>
</Link>
))}
</div>
)}
{/* Templates */}
<div className="card p-4">
<div className="flex items-center gap-2 mb-3">
<Layers size={14} className="text-[rgb(var(--muted))]" />
<span className="panel-label">Start from a template</span>
</div>
<div className="flex flex-wrap gap-2">
{TEMPLATES.map((t) => (
<form key={t.id} action={createFromTemplate.bind(null, t.id)}>
<button
className="btn btn-outline btn-sm"
title={t.description}
>
<span aria-hidden>{t.emoji}</span>
{t.name}
</button>
</form>
))}
</div>
</div>
{/* Forms grid */}
{forms.length === 0 ? (
<div className="card p-14 text-center">
<div className="inline-flex h-12 w-12 rounded-xl items-center justify-center mb-4
bg-[rgb(var(--line)/0.7)]">
<FileText size={22} className="text-[rgb(var(--muted))]" />
</div>
<p className="font-medium text-[rgb(var(--fg))]">
{tagFilter ? `No forms tagged “${tagFilter}` : "No forms yet"}
</p>
<p className="text-sm text-[rgb(var(--muted))] mt-1">
{tagFilter
? <>Try a different tag, or <Link href="/app" className="underline">clear the filter</Link>.</>
: "Create your first form or start from a template above."}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{forms.map((f) => {
const canEdit = isAdmin || f.ownerId === userId;
const canSeeCount = canEdit || f.viewers.length > 0;
const href = canEdit ? `/app/forms/${f.id}` : `/f/${f.slug}`;
const ownerLabel = f.owner.name || f.owner.email;
return (
<Link
key={f.id}
href={href}
className="card card-hover p-5 flex flex-col gap-4 block"
>
{/* Top row: icon + badge */}
<div className="flex items-start justify-between gap-2">
<div className="h-9 w-9 rounded-lg flex items-center justify-center shrink-0
bg-[rgb(var(--stage))]">
<FileText size={16} className="text-[rgb(var(--muted))]" />
</div>
<span className={`badge ${f.published ? "badge-green" : "badge-gray"}`}>
{f.published ? "Published" : "Draft"}
</span>
</div>
{/* Title + owner */}
<div className="flex-1 min-w-0">
<div className="font-semibold text-[rgb(var(--fg))] truncate leading-snug">
{f.title || "Untitled form"}
</div>
<div className="text-xs text-[rgb(var(--muted))] mt-0.5 truncate">
{ownerLabel}
</div>
</div>
{/* Tags */}
{f.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{f.tags.map((ft) => (
<span
key={ft.tag.slug}
className="text-[10px] px-1.5 py-0.5 rounded-full
bg-[rgb(var(--line))] text-[rgb(var(--muted))]"
>
{ft.tag.name}
</span>
))}
</div>
)}
{/* Footer: response count */}
{canSeeCount && (
<div className="pt-3 border-t border-[rgb(var(--line-2))] text-xs text-[rgb(var(--muted))] font-medium">
{f._count.responses === 0
? "No responses yet"
: `${f._count.responses} response${f._count.responses === 1 ? "" : "s"}`}
</div>
)}
</Link>
);
})}
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
// Static embed loader. Usage:
// <script src="https://YOUR_HOST/embed.js"></script>
// <button data-form="SLUG">Open form</button>
// Or programmatically: window.FormBuilder.open("SLUG").
import { NextResponse } from "next/server";
const SCRIPT = `(function(){
var BASE = document.currentScript ? new URL(document.currentScript.src).origin : location.origin;
function open(slug){
var overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(2px);";
overlay.addEventListener("click", function(e){ if (e.target === overlay) close(); });
var box = document.createElement("div");
box.style.cssText = "position:relative;background:#fff;border-radius:14px;width:100%;max-width:640px;height:80vh;max-height:760px;overflow:hidden;box-shadow:0 30px 60px -20px rgba(0,0,0,0.4);";
var close = function(){ overlay.remove(); };
var x = document.createElement("button");
x.textContent = "\\u00D7";
x.setAttribute("aria-label","Close");
x.style.cssText = "position:absolute;top:8px;right:12px;z-index:1;background:transparent;border:0;font-size:22px;line-height:1;cursor:pointer;color:#888;";
x.addEventListener("click", close);
var iframe = document.createElement("iframe");
iframe.src = BASE + "/f/" + encodeURIComponent(slug) + "?embed=1";
iframe.style.cssText = "border:0;width:100%;height:100%;";
iframe.setAttribute("title","Form");
box.appendChild(x); box.appendChild(iframe); overlay.appendChild(box);
document.body.appendChild(overlay);
document.addEventListener("keydown", function esc(e){ if (e.key === "Escape"){ close(); document.removeEventListener("keydown", esc); } });
}
document.addEventListener("click", function(e){
var t = e.target;
while (t && t !== document) {
if (t.dataset && t.dataset.form){ e.preventDefault(); open(t.dataset.form); return; }
t = t.parentNode;
}
});
window.FormBuilder = { open: open };
})();`;
export const dynamic = "force-static";
export function GET() {
return new NextResponse(SCRIPT, {
headers: {
"content-type": "application/javascript; charset=utf-8",
"cache-control": "public, max-age=300, s-maxage=86400",
},
});
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { FormRuntime, type SubmitResult } from "@/components/runtime/FormRuntime";
import type { Field, FormSettings } from "@/lib/types";
import { pipe } from "@/lib/pipe";
export default function FormFiller({ formId, title, description, fields, settings, initialValues, hcaptchaSiteKey }: {
formId: string; title: string; description: string | null; fields: Field[]; settings: FormSettings;
initialValues?: Record<string, unknown>;
hcaptchaSiteKey?: string | null;
}) {
async function submit(values: Record<string, unknown>, meta: { hp: string; captcha?: string }): Promise<SubmitResult> {
const r = await fetch(`/api/forms/${formId}/submit`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: values, _h: meta.hp, _captcha: meta.captcha }),
});
if (r.ok) {
if (settings.redirectUrl) {
window.location.replace(pipe(settings.redirectUrl, values, fields));
return { ok: true };
}
return { ok: true };
}
const j = await r.json().catch(() => ({}));
return { ok: false, error: j.error ?? "Submission failed" };
}
return <FormRuntime title={title} description={description} fields={fields} settings={settings} onSubmit={submit} initialValues={initialValues} formId={formId} hcaptchaSiteKey={hcaptchaSiteKey ?? undefined} />;
}
+66
View File
@@ -0,0 +1,66 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseFields, parseSettings } from "@/lib/forms";
import FormFiller from "./FormFiller";
function slugifyLabel(s: string) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
}
export default async function PublicForm({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const sp = await searchParams;
const form = await prisma.form.findUnique({ where: { slug } });
if (!form) notFound();
const settings = parseSettings(form.settings);
const session = await auth();
const isOwnerOrAdmin = session?.user && (session.user.id === form.ownerId || session.user.role === "admin");
if (!form.published && !isOwnerOrAdmin) notFound();
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
redirect(`/signin?from=/f/${slug}`);
}
const fields = parseFields(form.fields);
// URL prefill: ?label_slug=value or ?field_id=value
const initialValues: Record<string, unknown> = {};
for (const f of fields) {
const labelKey = slugifyLabel(f.label);
const raw = sp[f.id] ?? sp[labelKey];
if (raw === undefined) continue;
const v = Array.isArray(raw) ? raw[0] : raw;
if (f.type === "number") initialValues[f.id] = Number(v);
else if (f.type === "checkbox") initialValues[f.id] = v === "true" || v === "1";
else if (f.type === "multi_select") initialValues[f.id] = String(v).split(",").map((s) => s.trim()).filter(Boolean);
else initialValues[f.id] = v;
}
const embed = sp.embed === "1";
const hcaptchaSiteKey = settings.hcaptchaEnabled ? (process.env.HCAPTCHA_SITE_KEY ?? null) : null;
return (
<main className={embed ? "min-h-screen px-4 py-4" : "min-h-screen px-6 py-12"}>
<div className={embed ? "max-w-xl mx-auto" : "max-w-xl mx-auto"}>
<FormFiller
formId={form.id}
title={form.title}
description={form.description}
fields={fields}
settings={settings}
initialValues={initialValues}
hcaptchaSiteKey={hcaptchaSiteKey}
/>
</div>
</main>
);
}
+257
View File
@@ -0,0 +1,257 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Design tokens ─────────────────────────────────────────── */
:root {
--bg: 248 248 247; /* warm off-white page bg */
--surface: 255 255 255; /* elevated cards/panels */
--stage: 241 241 239; /* canvas stage, code areas */
--fg: 15 15 15;
--fg-2: 60 60 67; /* secondary text */
--muted: 107 107 116;
--line: 228 228 231;
--line-2: 241 241 243; /* very subtle dividers */
--accent: 15 15 15;
--accent-fg: 255 255 255;
--danger: 220 38 38;
--success: 22 163 74;
--warn: 161 98 7;
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.06);
--shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.06);
--shadow-lg: 0 10px 24px -4px rgb(0 0 0 / 0.12), 0 4px 8px -4px rgb(0 0 0 / 0.07);
}
.dark {
--bg: 16 16 17;
--surface: 24 24 27;
--stage: 12 12 14;
--fg: 242 242 243;
--fg-2: 185 185 195;
--muted: 140 140 150;
--line: 39 39 42;
--line-2: 30 30 33;
--accent: 240 240 243;
--accent-fg: 15 15 15;
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.30);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.35), 0 1px 2px -1px rgb(0 0 0 / 0.25);
--shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.40), 0 2px 4px -2px rgb(0 0 0 / 0.25);
--shadow-lg: 0 10px 24px -4px rgb(0 0 0 / 0.50), 0 4px 8px -4px rgb(0 0 0 / 0.30);
}
/* ── Base ───────────────────────────────────────────────────── */
html, body {
background: rgb(var(--bg));
color: rgb(var(--fg));
}
body {
font-family: var(--font-inter, "Inter", system-ui, sans-serif);
-webkit-font-smoothing: antialiased;
font-feature-settings: "cv11", "ss01";
}
button { cursor: pointer; }
input, textarea, select { background: transparent; }
*:focus-visible {
outline: 2px solid rgb(var(--accent));
outline-offset: 2px;
border-radius: 6px;
}
/* ── Surface / card ─────────────────────────────────────────── */
.surface {
background: rgb(var(--surface));
}
.card {
background: rgb(var(--surface));
border-radius: 14px;
box-shadow: var(--shadow-sm);
border: 1px solid rgb(var(--line) / 0.6);
}
.card-hover {
transition: box-shadow 150ms ease, transform 150ms ease;
}
.card-hover:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
/* ── Badges ─────────────────────────────────────────────────── */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 8px; border-radius: 99px;
font-size: 11.5px; font-weight: 500; line-height: 1.6;
white-space: nowrap;
}
.badge::before {
content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor;
}
.badge-green {
background: rgb(220 252 231); color: rgb(20 120 50);
}
.badge-gray {
background: rgb(var(--line-2)); color: rgb(var(--muted));
}
.badge-orange {
background: rgb(255 237 213); color: rgb(154 52 18);
}
.dark .badge-green { background: rgb(20 83 45 / 0.4); color: rgb(74 222 128); }
.dark .badge-gray { background: rgb(var(--line)); color: rgb(var(--muted)); }
.dark .badge-orange { background: rgb(124 45 18 / 0.4); color: rgb(253 186 116); }
/* ── Field type chips (builder) ─────────────────────────────── */
.chip {
display: inline-flex; align-items: center;
padding: 2px 7px; border-radius: 6px;
font-size: 10px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
white-space: nowrap;
}
.chip-text { background: rgb(219 234 254); color: rgb(30 64 175); }
.chip-choice { background: rgb(220 252 231); color: rgb(20 83 45); }
.chip-number { background: rgb(237 233 254); color: rgb(91 33 182); }
.chip-media { background: rgb(255 237 213); color: rgb(154 52 18); }
.chip-special { background: rgb(var(--line)); color: rgb(var(--muted)); }
.dark .chip-text { background: rgb(30 64 175 / 0.3); color: rgb(147 197 253); }
.dark .chip-choice { background: rgb(20 83 45 / 0.3); color: rgb(134 239 172); }
.dark .chip-number { background: rgb(91 33 182 / 0.3); color: rgb(196 181 253); }
.dark .chip-media { background: rgb(154 52 18 / 0.3); color: rgb(253 186 116); }
.dark .chip-special { background: rgb(var(--line)); color: rgb(var(--muted)); }
/* ── Form runtime inputs ─────────────────────────────────────── */
.field-label {
font-size: 15px; font-weight: 500; color: rgb(var(--fg)); line-height: 1.4;
}
.field-help {
font-size: 13px; color: rgb(var(--muted));
}
.field-input {
width: 100%; padding: 10px 14px;
border: 1.5px solid rgb(var(--line));
border-radius: 10px;
background: rgb(var(--surface));
color: rgb(var(--fg));
font-size: 15px;
box-shadow: var(--shadow-xs);
transition: border-color 130ms ease, box-shadow 130ms ease;
}
.field-input:hover { border-color: rgb(var(--muted) / 0.5); }
.field-input:focus {
outline: none;
border-color: rgb(var(--accent));
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.10);
}
/* ── Buttons ─────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 9px;
font-size: 13.5px; font-weight: 500; letter-spacing: -0.01em;
border: 1.5px solid transparent;
transition: all 140ms ease;
white-space: nowrap;
}
.btn-primary {
background: rgb(var(--accent));
color: rgb(var(--accent-fg));
box-shadow: var(--shadow-xs);
}
.btn-primary:hover {
opacity: 0.88;
box-shadow: var(--shadow-sm);
}
.btn-ghost {
color: rgb(var(--fg));
}
.btn-ghost:hover {
background: rgb(var(--line) / 0.7);
}
.btn-outline {
border-color: rgb(var(--line));
background: rgb(var(--surface));
color: rgb(var(--fg));
box-shadow: var(--shadow-xs);
}
.btn-outline:hover {
border-color: rgb(var(--muted) / 0.5);
box-shadow: var(--shadow-sm);
}
.btn-sm { padding: 5px 11px; font-size: 12.5px; border-radius: 8px; }
.btn-icon { padding: 6px; border-radius: 8px; }
/* ── Section label ───────────────────────────────────────────── */
.panel-label {
font-size: 11px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: rgb(var(--muted));
}
/* ── Pill navbar ─────────────────────────────────────────────── */
.nav-pill {
background: rgb(255 255 255 / 0.97);
border: 1px solid rgb(0 0 0 / 0.07);
box-shadow:
0 0 0 1px rgb(0 0 0 / 0.03),
0 8px 32px -4px rgb(0 0 0 / 0.10),
inset 0 1px 0 rgb(255 255 255 / 1); /* top highlight */
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.dark .nav-pill {
background: rgb(30 30 33 / 0.97);
border: 1px solid rgb(255 255 255 / 0.07);
box-shadow:
0 0 0 1px rgb(255 255 255 / 0.03),
0 8px 32px -4px rgb(0 0 0 / 0.50),
inset 0 1px 0 rgb(255 255 255 / 0.09); /* top highlight */
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.nav-pill-link {
font-size: 14px; font-weight: 500; padding: 6px 12px;
border-radius: 8px; color: rgb(var(--muted));
transition: color 140ms ease, background 140ms ease;
}
.nav-pill-link:hover {
color: rgb(var(--fg));
background: rgb(var(--line) / 0.6);
}
.nav-pill-link.active {
color: rgb(var(--fg));
}
.nav-pill-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: 10px;
color: rgb(var(--fg));
background: rgb(var(--stage));
border: 1px solid rgb(var(--line));
transition: all 140ms ease;
}
.nav-pill-btn:hover {
background: rgb(var(--line));
box-shadow: var(--shadow-xs);
}
/* ── App header icon buttons ─────────────────────────────────── */
.app-icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 8px;
color: rgb(var(--muted));
transition: color 140ms ease, background 140ms ease;
}
.app-icon-btn:hover {
color: rgb(var(--fg));
background: rgb(var(--line-2));
}
/* ── Canvas dot-grid background ──────────────────────────────── */
.canvas-dots {
background-color: rgb(var(--stage));
background-image: radial-gradient(circle, rgb(var(--muted) / 0.18) 1px, transparent 1px);
background-size: 24px 24px;
}
+26
View File
@@ -0,0 +1,26 @@
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeScript } from "@/components/ThemeProvider";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
export const metadata: Metadata = {
title: "Forms",
description: "Minimalist forms for your team.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body>{children}</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function Home() {
const session = await auth();
if (session?.user) redirect("/app");
return (
<main className="min-h-screen grid place-items-center px-6">
<div className="max-w-md w-full text-center space-y-8">
<div className="space-y-2">
<div className="inline-flex h-10 w-10 rounded-xl bg-fg text-bg items-center justify-center text-lg font-semibold mb-2">F</div>
<h1 className="text-2xl font-semibold tracking-tight">Forms</h1>
<p className="text-muted text-sm">Minimal forms for your team.</p>
</div>
<Link href="/signin" className="btn btn-primary w-full justify-center">Sign in</Link>
</div>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { signIn, auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function SignIn({ searchParams }: { searchParams: Promise<{ from?: string }> }) {
const session = await auth();
const sp = await searchParams;
if (session?.user) redirect(sp.from || "/app");
const provider = process.env.OIDC_PROVIDER_NAME || "SSO";
return (
<main className="min-h-screen grid place-items-center px-6">
<div className="max-w-sm w-full space-y-6 text-center">
<div className="inline-flex h-10 w-10 rounded-xl bg-fg text-bg items-center justify-center text-lg font-semibold mx-auto">F</div>
<h1 className="text-xl font-semibold">Sign in</h1>
<form
action={async () => {
"use server";
await signIn("oidc", { redirectTo: sp.from || "/app" });
}}
>
<button type="submit" className="btn btn-primary w-full justify-center">
Continue with {provider}
</button>
</form>
</div>
</main>
);
}
+14
View File
@@ -0,0 +1,14 @@
// Inline script injected into <head> — runs before paint to avoid flash.
export function ThemeScript() {
const code = `
(function(){
try {
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch(e){}
})();
`;
return <script dangerouslySetInnerHTML={{ __html: code }} />;
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Field, FileRef, FormSettings } from "@/lib/types";
import { visibleFields } from "@/lib/logic";
import { applyCalculations } from "@/lib/calc";
import { pipe } from "@/lib/pipe";
import { Button } from "@/components/ui/Button";
import { Input, Textarea } from "@/components/ui/Input";
import { Star, Paperclip, X } from "lucide-react";
export type SubmitResult = { ok: true } | { ok: false; error: string };
export function FormRuntime({
title,
description,
fields,
settings,
onSubmit,
submitLabel = "Submit",
initialValues,
formId,
hcaptchaSiteKey,
}: {
title: string;
description?: string | null;
fields: Field[];
settings: FormSettings;
onSubmit: (values: Record<string, unknown>, meta: { hp: string; captcha?: string }) => Promise<SubmitResult> | SubmitResult;
submitLabel?: string;
initialValues?: Record<string, unknown>;
/** Required for file fields — passed when actually filling a form (not in preview). */
formId?: string;
/** When set, render hCaptcha and require a token to submit. */
hcaptchaSiteKey?: string;
}) {
const [values, setValues] = useState<Record<string, unknown>>(initialValues ?? {});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string>("");
const [resumed, setResumed] = useState(false);
const hpRef = useRef<HTMLInputElement>(null);
const captchaRef = useRef<HTMLDivElement>(null);
const valuesRef = useRef(values);
valuesRef.current = values;
// Load partial on mount (only when we have a formId, i.e. real form filling).
useEffect(() => {
if (!formId) return;
let cancelled = false;
(async () => {
const r = await fetch(`/api/forms/${formId}/partial`).catch(() => null);
if (!r || !r.ok) return;
const j = await r.json().catch(() => null);
if (!cancelled && j && j.data && typeof j.data === "object" && Object.keys(j.data).length > 0) {
setValues((cur) => ({ ...j.data, ...cur }));
setResumed(true);
}
})();
return () => { cancelled = true; };
}, [formId]);
// Debounced save of partial on changes.
useEffect(() => {
if (!formId || done) return;
if (Object.keys(values).length === 0) return;
const t = setTimeout(() => {
fetch(`/api/forms/${formId}/partial`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: valuesRef.current }),
}).catch(() => {});
}, 1500);
return () => clearTimeout(t);
}, [values, formId, done]);
// Analytics beacons. Session-scoped key so we can correlate view→start→submit.
const sessionKey = useRef<string>("");
const startedAt = useRef<number>(0);
const startedRef = useRef(false);
const submittedRef = useRef(false);
if (!sessionKey.current) sessionKey.current = `s_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
const sendEvent = useCallbackBeacon();
useEffect(() => {
if (!formId) return;
startedAt.current = Date.now();
sendEvent(formId, { kind: "view", sessionKey: sessionKey.current });
const onPageHide = () => {
if (submittedRef.current) return;
if (!startedRef.current) return;
sendEvent(formId, { kind: "abandon", sessionKey: sessionKey.current, durationMs: Date.now() - startedAt.current });
};
window.addEventListener("pagehide", onPageHide);
return () => window.removeEventListener("pagehide", onPageHide);
}, [formId, sendEvent]);
function markStartedOnce() {
if (!formId || startedRef.current) return;
startedRef.current = true;
sendEvent(formId, { kind: "start", sessionKey: sessionKey.current });
}
// Auto-compute calculated fields into the values map (read-only to the user).
const computed = useMemo(() => applyCalculations(fields, values), [fields, values]);
const shown = useMemo(() => visibleFields(fields, computed), [fields, computed]);
// Split visible fields into pages based on layout setting + page_break markers.
// Drops page_break fields from the rendered output — they're structural only.
const pages = useMemo<Field[][]>(() => {
const layout = settings.layout ?? "one_page";
const renderable = shown.filter((f) => f.type !== "page_break");
if (layout === "one_page") return [renderable];
if (layout === "one_at_a_time") return renderable.map((f) => [f]);
// "paged" — split at page_break markers using their position in `shown`.
const out: Field[][] = [];
let current: Field[] = [];
for (const f of shown) {
if (f.type === "page_break") { if (current.length) out.push(current); current = []; }
else current.push(f);
}
if (current.length) out.push(current);
return out.length ? out : [[]];
}, [shown, settings.layout]);
const [pageIdx, setPageIdx] = useState(0);
useEffect(() => { if (pageIdx >= pages.length) setPageIdx(Math.max(0, pages.length - 1)); }, [pageIdx, pages.length]);
const isLast = pageIdx >= pages.length - 1;
const currentPage = pages[pageIdx] ?? [];
// hCaptcha: load script + render widget when a site key is provided.
useEffect(() => {
if (!hcaptchaSiteKey || !captchaRef.current) return;
let mounted = true;
let widgetId: number | undefined;
const SRC = "https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off";
function render() {
type HC = { render: (el: HTMLElement, opts: Record<string, unknown>) => number; reset: (id?: number) => void };
const hc = (window as unknown as { hcaptcha?: HC }).hcaptcha;
if (!hc || !mounted || !captchaRef.current) return;
widgetId = hc.render(captchaRef.current, {
sitekey: hcaptchaSiteKey,
callback: (t: string) => setCaptchaToken(t),
"expired-callback": () => setCaptchaToken(""),
"error-callback": () => setCaptchaToken(""),
});
}
const existing = document.querySelector(`script[src^="https://js.hcaptcha.com"]`) as HTMLScriptElement | null;
if (existing) {
const w = window as unknown as { hcaptcha?: unknown };
if (w.hcaptcha) render();
else existing.addEventListener("load", render, { once: true });
} else {
const s = document.createElement("script");
s.src = SRC; s.async = true; s.defer = true;
s.onload = render;
document.head.appendChild(s);
}
return () => {
mounted = false;
const w = window as unknown as { hcaptcha?: { reset: (id?: number) => void } };
if (w.hcaptcha && widgetId !== undefined) try { w.hcaptcha.reset(widgetId); } catch { /* widget already gone */ }
};
}, [hcaptchaSiteKey]);
function set(id: string, v: unknown) {
markStartedOnce();
setValues((s) => ({ ...s, [id]: v }));
setErrors((e) => ({ ...e, [id]: "" }));
}
function validate(scope: Field[] = shown): boolean {
const next: Record<string, string> = {};
for (const f of scope) {
if (f.type === "page_break") continue;
const v = values[f.id];
const empty = v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
if (f.required && empty) { next[f.id] = "Required"; continue; }
if (!empty && f.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v))) {
next[f.id] = "Invalid email";
}
if (!empty && f.type === "number") {
const n = Number(v);
if (Number.isNaN(n)) next[f.id] = "Must be a number";
else if (f.min !== undefined && n < f.min) next[f.id] = `Min ${f.min}`;
else if (f.max !== undefined && n > f.max) next[f.id] = `Max ${f.max}`;
}
}
setErrors(next);
return Object.keys(next).length === 0;
}
function goNext() {
if (!validate(currentPage)) return;
setPageIdx((p) => Math.min(pages.length - 1, p + 1));
}
function goBack() { setPageIdx((p) => Math.max(0, p - 1)); }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
if (hcaptchaSiteKey && !captchaToken) {
setErrors({ _form: "Please complete the captcha." });
return;
}
setSubmitting(true);
try {
// Drop values for hidden fields.
const filtered: Record<string, unknown> = {};
for (const f of shown) if (values[f.id] !== undefined) filtered[f.id] = values[f.id];
const r = await onSubmit(filtered, { hp: hpRef.current?.value ?? "", captcha: captchaToken || undefined });
if (r.ok) {
submittedRef.current = true;
if (formId) sendEvent(formId, { kind: "submit", sessionKey: sessionKey.current, durationMs: Date.now() - (startedAt.current || Date.now()) });
setDone(true);
} else {
setErrors({ _form: r.error });
}
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<div className="text-center py-16 space-y-3">
<div className="text-3xl"></div>
<p className="text-muted">{pipe(settings.thanksMessage || "Thanks.", computed, fields)}</p>
</div>
);
}
const fontStack = settings.font ? `"${settings.font}", ui-sans-serif, system-ui, sans-serif` : undefined;
return (
<form
onSubmit={handleSubmit}
className="space-y-6"
style={fontStack ? { fontFamily: fontStack } : undefined}
>
{/* Honeypot — bots fill it; humans never see it. */}
<div aria-hidden="true" style={{ position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" }}>
<label>Leave this empty
<input ref={hpRef} type="text" name="_h" tabIndex={-1} autoComplete="off" defaultValue="" />
</label>
</div>
{settings.coverImage && pageIdx === 0 && (
// eslint-disable-next-line @next/next/no-img-element
<img src={`/api/files/${settings.coverImage}`} alt="" className="w-full h-40 object-cover rounded-xl" />
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
{settings.logo && (
// eslint-disable-next-line @next/next/no-img-element
<img src={`/api/files/${settings.logo}`} alt="" className="h-8 w-auto" />
)}
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
</div>
{description && pageIdx === 0 && <p className="text-muted whitespace-pre-line text-sm">{description}</p>}
{resumed && (
<div className="flex items-center gap-2 text-xs text-muted">
<span>Continuing where you left off.</span>
<button type="button" onClick={() => { setValues({}); setResumed(false); }} className="underline hover:text-fg">Start over</button>
</div>
)}
</div>
{pages.length > 1 && (
<div className="space-y-1">
<div className="h-1 bg-line/60 rounded-full overflow-hidden">
<div
className="h-full bg-fg transition-[width]"
style={{ width: `${((pageIdx + 1) / pages.length) * 100}%` }}
/>
</div>
<div className="text-[11px] text-muted text-right">{pageIdx + 1} / {pages.length}</div>
</div>
)}
<div className="space-y-5">
{currentPage.map((f) => {
const piped: Field = { ...f, label: pipe(f.label, computed, fields), help: f.help ? pipe(f.help, computed, fields) : f.help };
return <FieldInput key={f.id} field={piped} value={computed[f.id]} error={errors[f.id]} onChange={(v) => set(f.id, v)} accent={settings.accent} formId={formId} />;
})}
</div>
{isLast && hcaptchaSiteKey && <div ref={captchaRef} className="my-2" />}
{errors._form && <p className="text-sm text-danger">{errors._form}</p>}
<div className="flex items-center gap-2">
{pages.length > 1 && pageIdx > 0 && (
<Button type="button" variant="outline" onClick={goBack}>Back</Button>
)}
{!isLast ? (
<Button type="button" variant="primary" onClick={goNext}>Next</Button>
) : (
<Button type="submit" variant="primary" disabled={submitting}>
{submitting ? "…" : submitLabel}
</Button>
)}
</div>
</form>
);
}
function FieldInput({ field, value, error, onChange, accent, formId }: {
field: Field; value: unknown; error?: string; onChange: (v: unknown) => void; accent?: string; formId?: string;
}) {
const id = `f-${field.id}`;
return (
<div className="space-y-1.5">
<label htmlFor={id} className="field-label inline-flex items-center gap-1">
{field.label || <span className="text-muted italic">Untitled</span>}
{field.required && <span className="text-danger">*</span>}
</label>
{field.help && <p className="field-help">{field.help}</p>}
{renderControl(field, value, onChange, id, accent, formId)}
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
}
function renderControl(f: Field, value: unknown, onChange: (v: unknown) => void, id: string, accent?: string, formId?: string) {
switch (f.type) {
case "short_text":
case "email":
return <Input id={id} type={f.type === "email" ? "email" : "text"} value={String(value ?? "")}
placeholder={f.placeholder} onChange={(e) => onChange(e.target.value)} />;
case "long_text":
return <Textarea id={id} value={String(value ?? "")} placeholder={f.placeholder} onChange={(e) => onChange(e.target.value)} />;
case "number":
return <Input id={id} type="number" value={value === undefined ? "" : String(value)}
min={f.min} max={f.max} onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))} />;
case "date":
return <Input id={id} type="date" value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />;
case "checkbox":
return (
<label className="inline-flex items-center gap-2 text-sm">
<input id={id} type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
<span>{f.placeholder || "Yes"}</span>
</label>
);
case "select":
return (
<div className="flex flex-wrap gap-2">
{(f.options ?? []).map((o) => (
<button key={o.value} type="button"
onClick={() => onChange(o.value)}
className={`btn btn-sm ${value === o.value ? "btn-primary" : "btn-outline"}`}
style={value === o.value && accent ? { background: accent, borderColor: accent } : undefined}>
{o.label}
</button>
))}
</div>
);
case "multi_select": {
const arr = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="flex flex-wrap gap-2">
{(f.options ?? []).map((o) => {
const on = arr.includes(o.value);
return (
<button key={o.value} type="button"
onClick={() => onChange(on ? arr.filter((v) => v !== o.value) : [...arr, o.value])}
className={`btn btn-sm ${on ? "btn-primary" : "btn-outline"}`}
style={on && accent ? { background: accent, borderColor: accent } : undefined}>
{o.label}
</button>
);
})}
</div>
);
}
case "rating": {
const max = f.max ?? 5;
const cur = Number(value || 0);
return (
<div className="flex gap-1">
{Array.from({ length: max }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => onChange(n)} className="btn-icon btn-ghost"
aria-label={`${n} of ${max}`}>
<Star size={20} fill={n <= cur ? "currentColor" : "none"} />
</button>
))}
</div>
);
}
case "file":
return <FileField id={id} field={f} value={value as FileRef | undefined} onChange={onChange} formId={formId} />;
case "signature":
return <SignatureField id={id} value={value as FileRef | undefined} onChange={onChange} formId={formId} />;
case "page_break":
return null;
case "calculated":
return (
<div className="px-3 py-2 border border-line rounded-lg text-sm text-muted bg-line/20">
{value === null || value === undefined || value === "" ? <span className="italic"></span> : String(value)}
</div>
);
}
}
function FileField({ id, field, value, onChange, formId }: {
id: string; field: Field; value: FileRef | undefined; onChange: (v: unknown) => void; formId?: string;
}) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!formId) { setError("Preview only — not uploadable."); return; }
const maxMB = field.maxSizeMB ?? 25;
if (file.size > maxMB * 1024 * 1024) { setError(`Max ${maxMB} MB`); return; }
setError(null);
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("formId", formId);
const r = await fetch("/api/files/upload", { method: "POST", body: fd });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
setError(j.error || "Upload failed");
return;
}
const ref = (await r.json()) as FileRef;
onChange(ref);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div className="space-y-1.5">
{value ? (
<div className="flex items-center gap-2 px-3 py-2 border border-line rounded-lg text-sm">
<Paperclip size={14} className="text-muted shrink-0" />
<span className="truncate flex-1">{value.name}</span>
<span className="text-xs text-muted shrink-0">{formatBytes(value.size)}</span>
<button type="button" onClick={() => onChange(undefined)} className="text-muted hover:text-danger" aria-label="Remove">
<X size={14} />
</button>
</div>
) : (
<label htmlFor={id} className="btn btn-outline btn-sm cursor-pointer inline-flex">
<Paperclip size={14}/>
{uploading ? "Uploading…" : (field.placeholder || "Choose file")}
<input ref={inputRef} id={id} type="file" accept={field.accept} onChange={onPick} className="sr-only" disabled={uploading} />
</label>
)}
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
}
function useCallbackBeacon() {
return useCallback((formId: string, payload: Record<string, unknown>) => {
try {
const url = `/api/forms/${formId}/event`;
const body = JSON.stringify(payload);
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
} else {
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true }).catch(() => {});
}
} catch { /* don't surface analytics failures */ }
}, []);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function SignatureField({ id, value, onChange, formId }: {
id: string; value: FileRef | undefined; onChange: (v: unknown) => void; formId?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawing = useRef(false);
const dirty = useRef(false);
const [uploading, setUploading] = useState(false);
// Init canvas with white background and resolution.
useEffect(() => {
const c = canvasRef.current;
if (!c) return;
const ratio = window.devicePixelRatio || 1;
c.width = 600 * ratio; c.height = 200 * ratio;
const ctx = c.getContext("2d");
if (!ctx) return;
ctx.scale(ratio, ratio);
ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.strokeStyle = "#111";
ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, c.width, c.height);
}, []);
function pos(e: React.PointerEvent<HTMLCanvasElement>) {
const c = canvasRef.current!;
const r = c.getBoundingClientRect();
return { x: (e.clientX - r.left), y: (e.clientY - r.top) };
}
function down(e: React.PointerEvent<HTMLCanvasElement>) {
drawing.current = true; dirty.current = true;
const ctx = canvasRef.current?.getContext("2d"); if (!ctx) return;
const p = pos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y);
(e.target as Element).setPointerCapture(e.pointerId);
}
function move(e: React.PointerEvent<HTMLCanvasElement>) {
if (!drawing.current) return;
const ctx = canvasRef.current?.getContext("2d"); if (!ctx) return;
const p = pos(e); ctx.lineTo(p.x, p.y); ctx.stroke();
}
async function up() {
drawing.current = false;
if (!dirty.current || !canvasRef.current || !formId) return;
setUploading(true);
try {
const blob: Blob = await new Promise((res) => canvasRef.current!.toBlob((b) => res(b!), "image/png"));
const fd = new FormData();
fd.append("file", new File([blob], `signature-${Date.now()}.png`, { type: "image/png" }));
fd.append("formId", formId);
const r = await fetch("/api/files/upload", { method: "POST", body: fd });
if (r.ok) onChange(await r.json());
} finally { setUploading(false); }
}
function clear() {
const c = canvasRef.current; if (!c) return;
const ctx = c.getContext("2d"); if (!ctx) return;
ctx.save(); ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, c.width, c.height); ctx.restore();
dirty.current = false;
onChange(undefined);
}
return (
<div className="space-y-1.5">
<canvas
ref={canvasRef} id={id}
style={{ width: 600, height: 200, maxWidth: "100%" }}
className="border border-line rounded-lg bg-white touch-none cursor-crosshair"
onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerLeave={up}
/>
<div className="flex items-center gap-2 text-xs">
<button type="button" onClick={clear} className="text-muted hover:text-fg">Clear</button>
{uploading && <span className="text-muted">Saving</span>}
{value && !uploading && <span className="text-muted">Signed </span>}
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { cn } from "@/lib/cn";
import { forwardRef, ButtonHTMLAttributes } from "react";
type Variant = "primary" | "ghost" | "outline" | "danger";
type Size = "md" | "sm" | "icon";
export const Button = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant; size?: Size;
}>(({ variant = "outline", size = "md", className, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"btn",
variant === "primary" && "btn-primary",
variant === "ghost" && "btn-ghost",
variant === "outline" && "btn-outline",
variant === "danger" && "bg-danger text-white",
size === "sm" && "btn-sm",
size === "icon" && "btn-icon",
className,
)}
{...props}
/>
);
});
Button.displayName = "Button";
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { cn } from "@/lib/cn";
import { forwardRef, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input ref={ref} className={cn("field-input", className)} {...props} />
),
);
Input.displayName = "Input";
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => (
<textarea ref={ref} className={cn("field-input min-h-[88px] resize-y", className)} {...props} />
),
);
Textarea.displayName = "Textarea";
+34
View File
@@ -0,0 +1,34 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
const path = usePathname();
return (
<nav className="flex items-center gap-0.5">
<NavItem href="/app" active={path === "/app"}>Forms</NavItem>
{isAdmin && <NavItem href="/app/members" active={path.startsWith("/app/members")}>Members</NavItem>}
{isAdmin && <NavItem href="/app/audit" active={path.startsWith("/app/audit")}>Audit</NavItem>}
<NavItem href="/app/account" active={path.startsWith("/app/account")}>Account</NavItem>
</nav>
);
}
function NavItem({ href, active, children }: {
href: string; active: boolean; children: React.ReactNode;
}) {
return (
<Link
href={href}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
${active
? "text-[rgb(var(--fg))] bg-[rgb(var(--stage))]"
: "text-[rgb(var(--muted))] hover:text-[rgb(var(--fg))] hover:bg-[rgb(var(--line-2))]"
}`}
>
{children}
</Link>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, X, Tag as TagIcon } from "lucide-react";
import { setTagsForForm } from "@/lib/actions";
type Tag = { slug: string; name: string };
export default function TagBar({
formId,
initialTags,
allTags,
}: {
formId: string;
initialTags: Tag[];
allTags: Array<{ slug: string; name: string; count: number }>;
}) {
const [tags, setTags] = useState<Tag[]>(initialTags);
const [adding, setAdding] = useState(false);
const [input, setInput] = useState("");
const [pending, startTransition] = useTransition();
function commit(next: Tag[]) {
setTags(next);
startTransition(async () => {
await setTagsForForm(formId, next.map((t) => t.name));
});
}
function addTag(name: string) {
const trimmed = name.trim();
if (!trimmed) return;
if (tags.some((t) => t.name.toLowerCase() === trimmed.toLowerCase())) return;
commit([...tags, { name: trimmed, slug: trimmed.toLowerCase().replace(/\s+/g, "-") }]);
setInput("");
setAdding(false);
}
function removeTag(slug: string) {
commit(tags.filter((t) => t.slug !== slug));
}
const suggestions = input.length > 0
? allTags.filter((t) =>
t.name.toLowerCase().includes(input.toLowerCase()) &&
!tags.some((existing) => existing.slug === t.slug)
).slice(0, 6)
: allTags.filter((t) => !tags.some((existing) => existing.slug === t.slug)).slice(0, 6);
return (
<div className="flex items-center gap-2 flex-wrap text-sm">
<TagIcon size={14} className="text-[rgb(var(--muted))]" />
{tags.map((t) => (
<span
key={t.slug}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs
bg-[rgb(var(--line))] text-[rgb(var(--fg))]"
>
{t.name}
<button
type="button"
onClick={() => removeTag(t.slug)}
className="opacity-60 hover:opacity-100"
aria-label={`Remove ${t.name}`}
>
<X size={10} strokeWidth={2.5} />
</button>
</span>
))}
{adding ? (
<div className="relative">
<input
autoFocus
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); addTag(input); }
else if (e.key === "Escape") { setAdding(false); setInput(""); }
}}
onBlur={() => setTimeout(() => setAdding(false), 100)}
placeholder="tag name…"
className="text-xs px-2 py-0.5 rounded-full border border-[rgb(var(--line))]
bg-[rgb(var(--bg))] outline-none focus:border-[rgb(var(--fg-2))] w-32"
/>
{suggestions.length > 0 && (
<div className="absolute left-0 top-full mt-1 min-w-[140px] z-50
bg-[rgb(var(--bg))] border border-[rgb(var(--line))] rounded-lg
shadow-md py-1">
{suggestions.map((s) => (
<button
key={s.slug}
type="button"
onMouseDown={(e) => { e.preventDefault(); addTag(s.name); }}
className="block w-full text-left px-3 py-1 text-xs hover:bg-[rgb(var(--line))]"
>
{s.name} <span className="text-[rgb(var(--muted))]">({s.count})</span>
</button>
))}
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
disabled={pending}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs
border border-dashed border-[rgb(var(--line))] text-[rgb(var(--muted))]
hover:text-[rgb(var(--fg))] hover:border-[rgb(var(--fg-2))]"
>
<Plus size={11} strokeWidth={2.5} />
add tag
</button>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
setDark(document.documentElement.classList.contains("dark"));
}, []);
const toggle = () => {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
try { localStorage.setItem("theme", next ? "dark" : "light"); } catch {}
};
return (
<button
onClick={toggle}
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
className="app-icon-btn"
title={dark ? "Light mode" : "Dark mode"}
>
{dark ? (
// Sun icon
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="3"/>
<line x1="8" y1="1" x2="8" y2="2.5"/>
<line x1="8" y1="13.5" x2="8" y2="15"/>
<line x1="1" y1="8" x2="2.5" y2="8"/>
<line x1="13.5" y1="8" x2="15" y2="8"/>
<line x1="3.05" y1="3.05" x2="4.1" y2="4.1"/>
<line x1="11.9" y1="11.9" x2="12.95" y2="12.95"/>
<line x1="12.95" y1="3.05" x2="11.9" y2="4.1"/>
<line x1="4.1" y1="11.9" x2="3.05" y2="12.95"/>
</svg>
) : (
// Moon icon
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 10A6 6 0 0 1 6 2.5a6 6 0 1 0 7.5 7.5z"/>
</svg>
)}
</button>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { cn } from "@/lib/cn";
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<label className="inline-flex items-center gap-2 text-sm cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative w-9 h-5 rounded-full transition-colors",
checked ? "bg-fg" : "bg-line",
)}
>
<span className={cn(
"absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-bg shadow-sm transition-transform",
checked && "translate-x-4",
)}/>
</button>
{label && <span>{label}</span>}
</label>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { nanoid } from "nanoid";
import { auth } from "./auth";
import { prisma } from "./db";
import type { Field, FormSettings } from "./types";
import { canEditForm, parseFields, parseSettings } from "./forms";
import { recordAudit } from "./audit";
import { snapshotForm } from "./versions";
import { setFormTags } from "./tags";
async function requireUser() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return session.user;
}
export async function createForm() {
const user = await requireUser();
const form = await prisma.form.create({
data: {
slug: nanoid(10),
title: "Untitled form",
ownerId: user.id,
fields: "[]",
settings: JSON.stringify({ visibility: "workspace" } satisfies FormSettings),
},
});
// Seed version 1.
await snapshotForm({
formId: form.id,
current: { title: form.title, description: form.description, fields: form.fields, settings: form.settings },
authorId: user.id,
force: true,
});
await recordAudit({
actorId: user.id, action: "form.created", entityType: "form", entityId: form.id,
metadata: { title: form.title },
});
redirect(`/app/forms/${form.id}`);
}
export async function createFromTemplate(templateId: string) {
const user = await requireUser();
const { getTemplate } = await import("./templates");
const tpl = getTemplate(templateId);
if (!tpl) throw new Error("Template not found");
// Rewrite field ids so multiple template clones don't collide, and rewrite
// any showIf rules to point at the new ids.
const idMap = new Map<string, string>();
const newFields = tpl.form.fields.map((f) => {
const newId = nanoid(8);
idMap.set(f.id, newId);
return { ...f, id: newId };
});
for (const f of newFields) {
if (!f.showIf) continue;
const groups = "fieldId" in (f.showIf[0] as object)
? [{ rules: f.showIf as { fieldId: string; op: string; value?: unknown }[] }]
: (f.showIf as { rules: { fieldId: string; op: string; value?: unknown }[] }[]);
f.showIf = groups.map((g) => ({
rules: g.rules.map((r) => ({ ...r, fieldId: idMap.get(r.fieldId) ?? r.fieldId })),
})) as typeof f.showIf;
}
const form = await prisma.form.create({
data: {
slug: nanoid(10),
title: tpl.form.title,
description: tpl.form.description ?? null,
ownerId: user.id,
fields: JSON.stringify(newFields),
settings: JSON.stringify(tpl.form.settings ?? { visibility: "workspace" }),
},
});
await snapshotForm({
formId: form.id,
current: { title: form.title, description: form.description, fields: form.fields, settings: form.settings },
authorId: user.id,
force: true,
});
await recordAudit({
actorId: user.id, action: "form.created", entityType: "form", entityId: form.id,
metadata: { title: form.title, template: templateId },
});
redirect(`/app/forms/${form.id}`);
}
export async function deleteForm(id: string) {
const user = await requireUser();
if (!(await canEditForm(id, user.id, user.role))) throw new Error("Forbidden");
const f = await prisma.form.findUnique({ where: { id }, select: { title: true } });
await prisma.form.delete({ where: { id } });
await recordAudit({
actorId: user.id, action: "form.deleted", entityType: "form", entityId: id,
metadata: { title: f?.title ?? null },
});
revalidatePath("/app");
}
export async function saveForm(id: string, patch: {
title?: string;
description?: string | null;
fields?: Field[];
settings?: FormSettings;
published?: boolean;
}) {
const user = await requireUser();
if (!(await canEditForm(id, user.id, user.role))) throw new Error("Forbidden");
const before = await prisma.form.findUnique({ where: { id } });
if (!before) throw new Error("Form not found");
const data: Record<string, unknown> = {};
if (patch.title !== undefined) data.title = patch.title;
if (patch.description !== undefined) data.description = patch.description;
if (patch.fields !== undefined) data.fields = JSON.stringify(patch.fields);
if (patch.settings !== undefined) data.settings = JSON.stringify(patch.settings);
if (patch.published !== undefined) data.published = patch.published;
const after = await prisma.form.update({ where: { id }, data });
// Snapshot only if the schema or meta actually changed.
await snapshotForm({
formId: id,
current: { title: after.title, description: after.description, fields: after.fields, settings: after.settings },
authorId: user.id,
});
// Specific publish/unpublish audit events for nicer feed entries.
if (patch.published !== undefined && patch.published !== before.published) {
await recordAudit({
actorId: user.id,
action: patch.published ? "form.published" : "form.unpublished",
entityType: "form", entityId: id,
metadata: { title: after.title },
});
} else {
await recordAudit({
actorId: user.id, action: "form.updated", entityType: "form", entityId: id,
metadata: { title: after.title, fields: patch.fields !== undefined, settings: patch.settings !== undefined },
});
}
revalidatePath(`/app/forms/${id}`);
revalidatePath("/app");
}
export async function setViewers(formId: string, userIds: string[]) {
const user = await requireUser();
if (!(await canEditForm(formId, user.id, user.role))) throw new Error("Forbidden");
await prisma.$transaction([
prisma.formViewer.deleteMany({ where: { formId } }),
prisma.formViewer.createMany({ data: userIds.map((userId) => ({ formId, userId })) }),
]);
await recordAudit({
actorId: user.id, action: "form.viewers_set", entityType: "form", entityId: formId,
metadata: { count: userIds.length },
});
revalidatePath(`/app/forms/${formId}`);
}
export async function setMemberRole(userId: string, role: "admin" | "member") {
const user = await requireUser();
if (user.role !== "admin") throw new Error("Forbidden");
const before = await prisma.user.findUnique({ where: { id: userId }, select: { role: true, email: true } });
await prisma.user.update({ where: { id: userId }, data: { role } });
await recordAudit({
actorId: user.id, action: "member.role_changed", entityType: "user", entityId: userId,
metadata: { from: before?.role ?? null, to: role, email: before?.email ?? null },
});
revalidatePath("/app/members");
}
export async function removeMember(userId: string) {
const user = await requireUser();
if (user.role !== "admin") throw new Error("Forbidden");
if (userId === user.id) throw new Error("Cannot remove yourself");
const before = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
await prisma.user.delete({ where: { id: userId } });
await recordAudit({
actorId: user.id, action: "member.removed", entityType: "user", entityId: userId,
metadata: { email: before?.email ?? null },
});
revalidatePath("/app/members");
}
// Replace this form's tags. Empty array clears all tags.
export async function setTagsForForm(formId: string, tagNames: string[]) {
const user = await requireUser();
if (!(await canEditForm(formId, user.id, user.role))) throw new Error("Forbidden");
await setFormTags(formId, tagNames);
await recordAudit({
actorId: user.id, action: "form.tagged", entityType: "form", entityId: formId,
metadata: { tags: tagNames },
});
revalidatePath(`/app/forms/${formId}`);
revalidatePath("/app");
}
// Revert a form to a prior version. Creates a new version atop rather than
// destroying the forward history.
export async function revertFormToVersion(formId: string, version: number) {
const user = await requireUser();
if (!(await canEditForm(formId, user.id, user.role))) throw new Error("Forbidden");
const snap = await prisma.formVersion.findUnique({ where: { formId_version: { formId, version } } });
if (!snap) throw new Error("Version not found");
await prisma.form.update({
where: { id: formId },
data: {
title: snap.title,
description: snap.description,
fields: snap.fields,
settings: snap.settings,
},
});
await snapshotForm({
formId,
current: { title: snap.title, description: snap.description, fields: snap.fields, settings: snap.settings },
authorId: user.id,
force: true,
});
await recordAudit({
actorId: user.id, action: "form.reverted", entityType: "form", entityId: formId,
metadata: { revertedTo: version },
});
revalidatePath(`/app/forms/${formId}`);
}
// Wipe a single response. Owner/admin/viewer-with-permission only.
export async function deleteResponse(responseId: string) {
const user = await requireUser();
const r = await prisma.response.findUnique({ where: { id: responseId }, select: { formId: true } });
if (!r) throw new Error("Not found");
if (!(await canEditForm(r.formId, user.id, user.role))) throw new Error("Forbidden");
await prisma.response.delete({ where: { id: responseId } });
await recordAudit({
actorId: user.id, action: "response.deleted", entityType: "response", entityId: responseId,
metadata: { formId: r.formId },
});
revalidatePath(`/app/forms/${r.formId}/responses`);
}
// Re-export for convenience so callers can stay in one import.
export { parseFields, parseSettings };
+66
View File
@@ -0,0 +1,66 @@
// Append-only audit log. Failures are swallowed so a logging hiccup never
// breaks a real mutation — audit is observability, not a transactional guarantee.
import { prisma } from "./db";
export type AuditAction =
// Forms
| "form.created"
| "form.updated"
| "form.deleted"
| "form.published"
| "form.unpublished"
| "form.reverted"
| "form.viewers_set"
| "form.tagged"
| "form.untagged"
// Responses
| "response.deleted"
// Members
| "member.role_changed"
| "member.removed"
// Tokens
| "token.created"
| "token.revoked"
// Webhooks
| "webhook.retried";
export type AuditEntity =
| "form"
| "response"
| "user"
| "token"
| "webhook"
| "tag";
export async function recordAudit(opts: {
actorId?: string | null;
action: AuditAction;
entityType: AuditEntity;
entityId?: string | null;
metadata?: Record<string, unknown>;
}): Promise<void> {
try {
await prisma.auditLog.create({
data: {
actorId: opts.actorId ?? null,
action: opts.action,
entityType: opts.entityType,
entityId: opts.entityId ?? null,
metadata: JSON.stringify(opts.metadata ?? {}),
},
});
} catch {
// Intentional swallow — see header.
}
}
export function parseAuditMeta(raw: string | null | undefined): Record<string, unknown> {
if (!raw) return {};
try {
const v = JSON.parse(raw);
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
} catch {
return {};
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { getOidcConfig } from "./auth-config";
describe("getOidcConfig", () => {
test("requires the Authentik OIDC environment variables", () => {
expect(() =>
getOidcConfig({
OIDC_ISSUER: "",
OIDC_CLIENT_ID: "client",
OIDC_CLIENT_SECRET: "secret",
}),
).toThrow("OIDC_ISSUER");
});
test("normalizes Authentik issuer and provider name", () => {
const config = getOidcConfig({
OIDC_ISSUER: "https://auth.example.com/application/o/forms",
OIDC_CLIENT_ID: "client",
OIDC_CLIENT_SECRET: "secret",
});
expect(config).toEqual({
issuer: "https://auth.example.com/application/o/forms/",
clientId: "client",
clientSecret: "secret",
providerName: "Authentik",
});
});
});
+28
View File
@@ -0,0 +1,28 @@
type OidcEnv = {
[key: string]: string | undefined;
OIDC_ISSUER?: string;
OIDC_CLIENT_ID?: string;
OIDC_CLIENT_SECRET?: string;
OIDC_PROVIDER_NAME?: string;
};
export function getOidcConfig(env: OidcEnv = process.env) {
const issuer = requiredEnv(env.OIDC_ISSUER, "OIDC_ISSUER");
const clientId = requiredEnv(env.OIDC_CLIENT_ID, "OIDC_CLIENT_ID");
const clientSecret = requiredEnv(env.OIDC_CLIENT_SECRET, "OIDC_CLIENT_SECRET");
return {
issuer: issuer.endsWith("/") ? issuer : `${issuer}/`,
clientId,
clientSecret,
providerName: env.OIDC_PROVIDER_NAME?.trim() || "Authentik",
};
}
function requiredEnv(value: string | undefined, name: string) {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error(`${name} is required for Authentik OIDC sign-in.`);
}
return trimmed;
}
+2
View File
@@ -0,0 +1,2 @@
import { handlers } from "./auth";
export const { GET, POST } = handlers;
+62
View File
@@ -0,0 +1,62 @@
import NextAuth, { type DefaultSession } from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "./db";
import { getOidcConfig } from "./auth-config";
declare module "next-auth" {
interface Session {
user: { id: string; role: string } & DefaultSession["user"];
}
interface User { role?: string }
}
const bootstrapAdmins = (process.env.AUTH_BOOTSTRAP_ADMINS ?? "")
.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
const oidc = getOidcConfig();
const next = NextAuth({
adapter: PrismaAdapter(prisma),
trustHost: true,
providers: [
{
id: "oidc",
name: oidc.providerName,
type: "oidc",
issuer: oidc.issuer,
clientId: oidc.clientId,
clientSecret: oidc.clientSecret,
authorization: { params: { scope: "openid profile email" } },
checks: ["pkce", "state"],
profile(p: Record<string, unknown>) {
return {
id: String(p.sub),
name: (p.name as string) || (p.preferred_username as string) || null,
email: (p.email as string) || null,
image: (p.picture as string) || null,
};
},
},
],
session: { strategy: "database" },
pages: { signIn: "/signin" },
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
session.user.role = (user as { role?: string }).role ?? "member";
return session;
},
},
events: {
async createUser({ user }) {
if (!user.id || !user.email) return;
const count = await prisma.user.count();
const isBootstrap = bootstrapAdmins.includes(user.email.toLowerCase());
if (count === 1 || isBootstrap) {
await prisma.user.update({ where: { id: user.id }, data: { role: "admin" } });
}
},
},
});
export const { handlers, signIn, signOut } = next;
export const { auth } = next;
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import { evalExpr, applyCalculations } from "./calc";
import type { Field } from "./types";
describe("evalExpr — arithmetic", () => {
it("evaluates simple arithmetic", () => {
expect(evalExpr("1 + 2", {})).toBe(3);
expect(evalExpr("10 - 4", {})).toBe(6);
expect(evalExpr("3 * 4", {})).toBe(12);
expect(evalExpr("12 / 4", {})).toBe(3);
expect(evalExpr("10 % 3", {})).toBe(1);
});
it("honors operator precedence", () => {
expect(evalExpr("2 + 3 * 4", {})).toBe(14);
expect(evalExpr("(2 + 3) * 4", {})).toBe(20);
expect(evalExpr("10 - 2 - 3", {})).toBe(5); // left-associative
});
it("handles unary minus", () => {
expect(evalExpr("-5 + 10", {})).toBe(5);
expect(evalExpr("-(2 + 3)", {})).toBe(-5);
});
it("returns 0 on divide by zero (graceful, not NaN)", () => {
expect(evalExpr("5 / 0", {})).toBe(0);
});
it("concatenates strings with +", () => {
expect(evalExpr("'hello ' + 'world'", {})).toBe("hello world");
expect(evalExpr("'count: ' + 5", {})).toBe("count: 5");
});
});
describe("evalExpr — variables", () => {
it("looks up bare identifiers from the values map", () => {
expect(evalExpr("a + b", { a: 3, b: 4 })).toBe(7);
});
it("returns null for missing variables", () => {
expect(evalExpr("nonexistent", {})).toBeNull();
});
it("recognizes true/false/null constants", () => {
expect(evalExpr("true", {})).toBe(true);
expect(evalExpr("false", {})).toBe(false);
expect(evalExpr("null", {})).toBeNull();
});
});
describe("evalExpr — comparisons & logic", () => {
it("compares numbers", () => {
expect(evalExpr("5 > 3", {})).toBe(true);
expect(evalExpr("3 >= 3", {})).toBe(true);
expect(evalExpr("2 < 1", {})).toBe(false);
expect(evalExpr("4 == 4", {})).toBe(true);
expect(evalExpr("4 != 5", {})).toBe(true);
});
it("short-circuits && and ||", () => {
expect(evalExpr("true && false", {})).toBe(false);
expect(evalExpr("true || false", {})).toBe(true);
expect(evalExpr("1 && 2", {})).toBe(true); // truthy && truthy → true
});
it("handles ! unary", () => {
expect(evalExpr("!true", {})).toBe(false);
expect(evalExpr("!false", {})).toBe(true);
expect(evalExpr("!0", {})).toBe(true);
});
});
describe("evalExpr — functions", () => {
it("if(condition, then, else)", () => {
expect(evalExpr("if(true, 1, 2)", {})).toBe(1);
expect(evalExpr("if(false, 1, 2)", {})).toBe(2);
expect(evalExpr("if(0, 'a', 'b')", {})).toBe("b");
});
it("sum, avg, min, max flatten and tolerate non-numbers", () => {
expect(evalExpr("sum(1, 2, 3)", {})).toBe(6);
expect(evalExpr("avg(2, 4, 6)", {})).toBe(4);
expect(evalExpr("min(5, 2, 8)", {})).toBe(2);
expect(evalExpr("max(5, 2, 8)", {})).toBe(8);
});
it("avg on empty input returns 0, not NaN", () => {
expect(evalExpr("avg()", {})).toBe(0);
});
it("count skips empty values", () => {
expect(evalExpr("count(1, '', 2, null, 3)", {})).toBe(3);
});
it("len on strings and arrays", () => {
expect(evalExpr("len('hello')", {})).toBe(5);
expect(evalExpr("len(items)", { items: [1, 2, 3] })).toBe(3);
});
it("num coerces to a number", () => {
expect(evalExpr("num('42')", {})).toBe(42);
expect(evalExpr("num(null)", {})).toBe(0);
});
});
describe("evalExpr — safety", () => {
it("returns null on syntax errors (doesn't throw)", () => {
expect(evalExpr("1 +", {})).toBeNull();
expect(evalExpr("(((", {})).toBeNull();
expect(evalExpr("@@@", {})).toBeNull();
});
it("rejects unknown functions safely", () => {
expect(evalExpr("nope(1, 2)", {})).toBeNull();
});
it("does not let identifiers be eval'd as JS (no prototype, no global)", () => {
expect(evalExpr("constructor", {})).toBeNull();
expect(evalExpr("__proto__", {})).toBeNull();
expect(evalExpr("globalThis", {})).toBeNull();
});
});
describe("applyCalculations", () => {
const fields: Field[] = [
{ id: "a", type: "number", label: "A" },
{ id: "b", type: "number", label: "B" },
{ id: "sum_ab", type: "calculated", label: "Sum", expression: "a + b" },
{ id: "doubled", type: "calculated", label: "Doubled", expression: "sum_ab * 2" },
];
it("computes and injects calculated values", () => {
const out = applyCalculations(fields, { a: 2, b: 3 });
expect(out.sum_ab).toBe(5);
expect(out.doubled).toBe(10);
});
it("computes downstream calculations after upstream ones (in field order)", () => {
const out = applyCalculations(fields, { a: 10, b: 1 });
expect(out.sum_ab).toBe(11);
expect(out.doubled).toBe(22);
});
it("returns null for calculated fields without an expression", () => {
const f: Field[] = [{ id: "x", type: "calculated", label: "x" }];
expect(applyCalculations(f, {}).x).toBeNull();
});
it("leaves non-calculated fields untouched", () => {
const out = applyCalculations(fields, { a: 1, b: 2, extra: "kept" });
expect(out.extra).toBe("kept");
expect(out.a).toBe(1);
});
});
+217
View File
@@ -0,0 +1,217 @@
// Hand-rolled expression evaluator. No eval, no deps.
// Supported grammar:
// expr := ternary
// ternary := or
// or := and ("||" and)*
// and := compare ("&&" compare)*
// compare := sum ((==|!=|<|>|<=|>=) sum)?
// sum := mul (("+"|"-") mul)*
// mul := unary (("*"|"/"|"%") unary)*
// unary := ("-"|"!") unary | atom
// atom := number | string | ident | call | "(" expr ")"
// call := ident "(" (expr ("," expr)*)? ")"
//
// Identifiers resolve in this order:
// 1. functions: if, sum, avg, min, max, count, len, num
// 2. constants: true, false, null
// 3. vars[id] from values map
import type { Field } from "./types";
type Value = number | string | boolean | null | Value[];
const FUNCTIONS: Record<string, (args: Value[]) => Value> = {
if: ([c, a, b]) => (truthy(c) ? (a ?? null) : (b ?? null)),
sum: (args) => flatNums(args).reduce((a, b) => a + b, 0),
avg: (args) => {
const ns = flatNums(args);
return ns.length === 0 ? 0 : ns.reduce((a, b) => a + b, 0) / ns.length;
},
min: (args) => Math.min(...flatNums(args)),
max: (args) => Math.max(...flatNums(args)),
count: (args) => flatten(args).filter((v) => !empty(v)).length,
len: ([v]) => (typeof v === "string" ? v.length : Array.isArray(v) ? v.length : 0),
num: ([v]) => Number(v ?? 0),
};
function flatten(vs: Value[]): Value[] {
const out: Value[] = [];
for (const v of vs) Array.isArray(v) ? out.push(...flatten(v)) : out.push(v);
return out;
}
function flatNums(vs: Value[]): number[] {
return flatten(vs).map((v) => Number(v)).filter((n) => !Number.isNaN(n));
}
function empty(v: Value): boolean {
return v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0);
}
function truthy(v: Value): boolean {
if (typeof v === "number") return v !== 0;
if (typeof v === "string") return v.length > 0;
if (Array.isArray(v)) return v.length > 0;
return !!v;
}
// ----- Tokenizer -----
type TokenKind = "num" | "str" | "ident" | "op" | "lparen" | "rparen" | "comma";
type Token = { kind: TokenKind; value: string };
function tokenize(src: string): Token[] {
const out: Token[] = [];
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === " " || c === "\t" || c === "\n") { i++; continue; }
if (c >= "0" && c <= "9") {
let j = i + 1;
while (j < src.length && /[0-9.]/.test(src[j])) j++;
out.push({ kind: "num", value: src.slice(i, j) }); i = j; continue;
}
if (c === '"' || c === "'") {
let j = i + 1;
while (j < src.length && src[j] !== c) j++;
out.push({ kind: "str", value: src.slice(i + 1, j) }); i = j + 1; continue;
}
if (/[A-Za-z_]/.test(c)) {
let j = i + 1;
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
out.push({ kind: "ident", value: src.slice(i, j) }); i = j; continue;
}
if (c === "(") { out.push({ kind: "lparen", value: c }); i++; continue; }
if (c === ")") { out.push({ kind: "rparen", value: c }); i++; continue; }
if (c === ",") { out.push({ kind: "comma", value: c }); i++; continue; }
// Multi-char operators first.
const two = src.slice(i, i + 2);
if (["==","!=","<=",">=","&&","||"].includes(two)) {
out.push({ kind: "op", value: two }); i += 2; continue;
}
if ("+-*/%<>!".includes(c)) { out.push({ kind: "op", value: c }); i++; continue; }
throw new Error(`Unexpected character: ${c}`);
}
return out;
}
// ----- Parser + evaluator (recursive descent) -----
class Parser {
i = 0;
constructor(public tokens: Token[], public vars: Record<string, Value>) {}
peek(): Token | undefined { return this.tokens[this.i]; }
eat(): Token { return this.tokens[this.i++]; }
expect(kind: TokenKind, value?: string): Token {
const t = this.eat();
if (!t || t.kind !== kind || (value && t.value !== value)) throw new Error(`Expected ${kind}${value ? " " + value : ""}`);
return t;
}
parseExpr(): Value { return this.parseOr(); }
parseOr(): Value {
let l = this.parseAnd();
while (this.peek()?.kind === "op" && this.peek()!.value === "||") {
this.eat(); const r = this.parseAnd();
l = truthy(l) || truthy(r);
}
return l;
}
parseAnd(): Value {
let l = this.parseCompare();
while (this.peek()?.kind === "op" && this.peek()!.value === "&&") {
this.eat(); const r = this.parseCompare();
l = truthy(l) && truthy(r);
}
return l;
}
parseCompare(): Value {
const l = this.parseSum();
const t = this.peek();
if (!t || t.kind !== "op" || !["==","!=","<","<=",">",">="].includes(t.value)) return l;
this.eat();
const r = this.parseSum();
switch (t.value) {
case "==": return l === r;
case "!=": return l !== r;
case "<": return Number(l) < Number(r);
case "<=": return Number(l) <= Number(r);
case ">": return Number(l) > Number(r);
case ">=": return Number(l) >= Number(r);
}
return false;
}
parseSum(): Value {
let l = this.parseMul();
while (this.peek()?.kind === "op" && (this.peek()!.value === "+" || this.peek()!.value === "-")) {
const op = this.eat().value;
const r = this.parseMul();
if (op === "+" && (typeof l === "string" || typeof r === "string")) l = String(l ?? "") + String(r ?? "");
else l = Number(l) + (op === "+" ? 1 : -1) * Number(r);
}
return l;
}
parseMul(): Value {
let l = this.parseUnary();
while (this.peek()?.kind === "op" && ["*","/","%"].includes(this.peek()!.value)) {
const op = this.eat().value;
const r = this.parseUnary();
const a = Number(l); const b = Number(r);
l = op === "*" ? a * b : op === "/" ? (b === 0 ? 0 : a / b) : a % b;
}
return l;
}
parseUnary(): Value {
const t = this.peek();
if (t?.kind === "op" && t.value === "-") { this.eat(); return -Number(this.parseUnary()); }
if (t?.kind === "op" && t.value === "!") { this.eat(); return !truthy(this.parseUnary()); }
return this.parseAtom();
}
parseAtom(): Value {
const t = this.eat();
if (!t) throw new Error("Unexpected end of expression");
if (t.kind === "num") return Number(t.value);
if (t.kind === "str") return t.value;
if (t.kind === "lparen") { const v = this.parseExpr(); this.expect("rparen"); return v; }
if (t.kind === "ident") {
if (this.peek()?.kind === "lparen") {
this.eat();
const args: Value[] = [];
if (this.peek()?.kind !== "rparen") {
args.push(this.parseExpr());
while (this.peek()?.kind === "comma") { this.eat(); args.push(this.parseExpr()); }
}
this.expect("rparen");
const fn = FUNCTIONS[t.value];
if (!fn) throw new Error(`Unknown function: ${t.value}`);
return fn(args);
}
if (t.value === "true") return true;
if (t.value === "false") return false;
if (t.value === "null") return null;
// hasOwnProperty so identifiers like `constructor`, `__proto__`, or
// `toString` don't leak through the prototype chain.
if (!Object.prototype.hasOwnProperty.call(this.vars, t.value)) return null;
const v = this.vars[t.value];
return v === undefined ? null : (v as Value);
}
throw new Error(`Unexpected token: ${t.value}`);
}
}
export function evalExpr(expr: string, values: Record<string, unknown>): Value {
try {
const p = new Parser(tokenize(expr), values as Record<string, Value>);
return p.parseExpr();
} catch {
return null;
}
}
/** Compute and inject calculated field values into the result map. */
export function applyCalculations(fields: Field[], values: Record<string, unknown>): Record<string, unknown> {
const next = { ...values };
for (const f of fields) {
if (f.type !== "calculated") continue;
if (!f.expression) { next[f.id] = null; continue; }
next[f.id] = evalExpr(f.expression, next);
}
return next;
}
+6
View File
@@ -0,0 +1,6 @@
import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+8
View File
@@ -0,0 +1,8 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"] });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+30
View File
@@ -0,0 +1,30 @@
import { prisma } from "./db";
import type { Field, FormSettings } from "./types";
export function parseFields(raw: string | null | undefined): Field[] {
if (!raw) return [];
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? (v as Field[]) : [];
} catch { return []; }
}
export function parseSettings(raw: string | null | undefined): FormSettings {
if (!raw) return {};
try { return JSON.parse(raw) as FormSettings; } catch { return {}; }
}
export async function canViewResults(formId: string, userId: string, userRole: string) {
if (userRole === "admin") return true;
const form = await prisma.form.findUnique({ where: { id: formId }, select: { ownerId: true } });
if (!form) return false;
if (form.ownerId === userId) return true;
const v = await prisma.formViewer.findUnique({ where: { formId_userId: { formId, userId } } });
return !!v;
}
export async function canEditForm(formId: string, userId: string, userRole: string) {
if (userRole === "admin") return true;
const form = await prisma.form.findUnique({ where: { id: formId }, select: { ownerId: true } });
return !!form && form.ownerId === userId;
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { ruleMatches, fieldVisible, visibleFields } from "./logic";
import type { Field, LogicRule } from "./types";
const r = (fieldId: string, op: LogicRule["op"], value?: string | number): LogicRule => ({ fieldId, op, value });
describe("ruleMatches", () => {
it("eq compares as strings (so '5' === 5)", () => {
expect(ruleMatches(r("x", "eq", "5"), { x: 5 })).toBe(true);
expect(ruleMatches(r("x", "eq", "yes"), { x: "yes" })).toBe(true);
expect(ruleMatches(r("x", "eq", "yes"), { x: "no" })).toBe(false);
});
it("neq is the inverse of eq", () => {
expect(ruleMatches(r("x", "neq", "a"), { x: "b" })).toBe(true);
expect(ruleMatches(r("x", "neq", "a"), { x: "a" })).toBe(false);
});
it("contains supports both strings and arrays", () => {
expect(ruleMatches(r("x", "contains", "ello"), { x: "hello" })).toBe(true);
expect(ruleMatches(r("x", "contains", "b"), { x: ["a", "b", "c"] })).toBe(true);
expect(ruleMatches(r("x", "contains", "z"), { x: ["a", "b"] })).toBe(false);
});
it("contains is case-insensitive for strings", () => {
expect(ruleMatches(r("x", "contains", "ELLO"), { x: "hello" })).toBe(true);
});
it("gt and lt compare numerically", () => {
expect(ruleMatches(r("x", "gt", 5), { x: 10 })).toBe(true);
expect(ruleMatches(r("x", "gt", 5), { x: "10" })).toBe(true); // coerces
expect(ruleMatches(r("x", "lt", 5), { x: 3 })).toBe(true);
});
it("empty / not_empty distinguish empty values", () => {
expect(ruleMatches(r("x", "empty"), { x: "" })).toBe(true);
expect(ruleMatches(r("x", "empty"), { x: null })).toBe(true);
expect(ruleMatches(r("x", "empty"), { x: [] })).toBe(true);
expect(ruleMatches(r("x", "empty"), {})).toBe(true);
expect(ruleMatches(r("x", "not_empty"), { x: "ok" })).toBe(true);
expect(ruleMatches(r("x", "not_empty"), { x: 0 })).toBe(true); // 0 is not empty
expect(ruleMatches(r("x", "not_empty"), { x: "" })).toBe(false);
});
});
describe("fieldVisible", () => {
const visibleField = (showIf: Field["showIf"]): Field => ({
id: "target", type: "short_text", label: "Target", showIf,
});
it("returns true when showIf is empty/missing", () => {
expect(fieldVisible({ id: "x", type: "short_text", label: "x" }, {})).toBe(true);
expect(fieldVisible(visibleField([]), {})).toBe(true);
});
it("ANDs rules within a single group", () => {
const f = visibleField([{ rules: [r("a", "eq", "yes"), r("b", "gt", 5)] }]);
expect(fieldVisible(f, { a: "yes", b: 10 })).toBe(true);
expect(fieldVisible(f, { a: "yes", b: 1 })).toBe(false);
expect(fieldVisible(f, { a: "no", b: 10 })).toBe(false);
});
it("ORs across groups", () => {
const f = visibleField([
{ rules: [r("a", "eq", "yes")] },
{ rules: [r("b", "gt", 10)] },
]);
expect(fieldVisible(f, { a: "yes", b: 0 })).toBe(true);
expect(fieldVisible(f, { a: "no", b: 20 })).toBe(true);
expect(fieldVisible(f, { a: "no", b: 0 })).toBe(false);
});
it("auto-upgrades legacy LogicRule[] to a single group", () => {
const f = visibleField([r("a", "eq", "yes")] as unknown as Field["showIf"]);
expect(fieldVisible(f, { a: "yes" })).toBe(true);
expect(fieldVisible(f, { a: "no" })).toBe(false);
});
});
describe("visibleFields", () => {
const fields: Field[] = [
{ id: "color", type: "select", label: "Color", options: [{ value: "red", label: "Red" }, { value: "blue", label: "Blue" }] },
{
id: "redOnly", type: "short_text", label: "Red?",
showIf: [{ rules: [{ fieldId: "color", op: "eq", value: "red" }] }],
},
{
id: "blueOnly", type: "short_text", label: "Blue?",
showIf: [{ rules: [{ fieldId: "color", op: "eq", value: "blue" }] }],
},
];
it("includes only fields whose conditions pass", () => {
const shown = visibleFields(fields, { color: "red" });
expect(shown.map((f) => f.id)).toEqual(["color", "redOnly"]);
});
it("hides both branches when neither matches", () => {
const shown = visibleFields(fields, { color: "green" });
expect(shown.map((f) => f.id)).toEqual(["color"]);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { normalizeShowIf, type Field, type LogicRule } from "./types";
export function ruleMatches(rule: LogicRule, values: Record<string, unknown>): boolean {
const v = values[rule.fieldId];
switch (rule.op) {
case "eq": return String(v ?? "") === String(rule.value ?? "");
case "neq": return String(v ?? "") !== String(rule.value ?? "");
case "contains":
if (Array.isArray(v)) return v.map(String).includes(String(rule.value ?? ""));
return String(v ?? "").toLowerCase().includes(String(rule.value ?? "").toLowerCase());
case "gt": return Number(v) > Number(rule.value);
case "lt": return Number(v) < Number(rule.value);
case "empty": return v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
case "not_empty": return !(v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0));
}
}
export function fieldVisible(field: Field, values: Record<string, unknown>): boolean {
const groups = normalizeShowIf(field.showIf);
if (groups.length === 0) return true;
// Visible if ANY group passes (rules within a group are ANDed).
return groups.some((g) => g.rules.every((r) => ruleMatches(r, values)));
}
export function visibleFields(fields: Field[], values: Record<string, unknown>): Field[] {
return fields.filter((f) => fieldVisible(f, values));
}
+549
View File
@@ -0,0 +1,549 @@
// 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 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: "workspace" };
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: `/f/${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) }] };
}
+48
View File
@@ -0,0 +1,48 @@
// Email notification driver. Three modes:
// EMAIL_DRIVER=resend → uses RESEND_API_KEY (recommended, no node deps)
// EMAIL_DRIVER=smtp → uses SMTP_URL via nodemailer (install nodemailer first)
// EMAIL_DRIVER=none → swallow silently; sendEmail() returns immediately
// EMAIL_FROM is required for non-"none" drivers.
type SendArgs = { to: string | string[]; subject: string; html: string; text?: string };
export async function sendEmail(args: SendArgs): Promise<{ ok: boolean; error?: string }> {
const driver = (process.env.EMAIL_DRIVER || "none").toLowerCase();
if (driver === "none") return { ok: true };
const from = process.env.EMAIL_FROM;
if (!from) return { ok: false, error: "EMAIL_FROM not set" };
const to = Array.isArray(args.to) ? args.to : [args.to];
try {
if (driver === "resend") {
const key = process.env.RESEND_API_KEY;
if (!key) return { ok: false, error: "RESEND_API_KEY not set" };
const r = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({ from, to, subject: args.subject, html: args.html, text: args.text }),
});
if (!r.ok) return { ok: false, error: `Resend ${r.status}: ${await r.text().catch(() => "")}` };
return { ok: true };
}
if (driver === "smtp") {
const url = process.env.SMTP_URL;
if (!url) return { ok: false, error: "SMTP_URL not set" };
// Webpack: don't try to resolve nodemailer at build time — it's only
// required when SMTP is actually configured. Users on SMTP must `npm i nodemailer`.
// The indirection through a variable hides the literal from TypeScript so
// the bare `import "nodemailer"` isn't required to resolve at typecheck time.
const mod = "nodemailer";
const nodemailer = await import(/* webpackIgnore: true */ mod).catch(() => null);
if (!nodemailer) return { ok: false, error: "Install nodemailer to use SMTP driver" };
const transporter = (nodemailer as { default: { createTransport: (u: string) => { sendMail: (o: Record<string, unknown>) => Promise<unknown> } } }).default.createTransport(url);
await transporter.sendMail({ from, to, subject: args.subject, html: args.html, text: args.text });
return { ok: true };
}
return { ok: false, error: `Unknown EMAIL_DRIVER: ${driver}` };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "send failed" };
}
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { pipe } from "./pipe";
import type { Field } from "./types";
const fields: Field[] = [
{ id: "name", type: "short_text", label: "Your name" },
{ id: "email", type: "email", label: "Email Address" },
{ id: "items", type: "multi_select", label: "Items" },
];
describe("pipe", () => {
it("replaces id-keyed tokens", () => {
const out = pipe("Hi {{name}}", { name: "Ada" }, fields);
expect(out).toBe("Hi Ada");
});
it("replaces label-keyed tokens (case-insensitive)", () => {
const out = pipe("Hi {{Your name}}!", { name: "Ada" }, fields);
expect(out).toBe("Hi Ada!");
});
it("collapses missing tokens to empty string (never undefined)", () => {
const out = pipe("Hi {{unknown}}!", {}, fields);
expect(out).toBe("Hi !");
});
it("tolerates whitespace inside the braces", () => {
expect(pipe("{{ name }}", { name: "x" }, fields)).toBe("x");
});
it("joins arrays with commas", () => {
const out = pipe("Picked {{items}}", { items: ["a", "b", "c"] }, fields);
expect(out).toBe("Picked a, b, c");
});
it("returns the template unchanged when there are no tokens", () => {
expect(pipe("plain text", { name: "x" }, fields)).toBe("plain text");
});
it("handles null/undefined values gracefully", () => {
expect(pipe("{{name}}", { name: null }, fields)).toBe("");
expect(pipe("{{email}}", { email: undefined }, fields)).toBe("");
});
it("does not infinitely recurse on token-looking values", () => {
const out = pipe("{{name}}", { name: "{{email}}" }, fields);
expect(out).toBe("{{email}}"); // tokens are replaced once, not recursively
});
});
+27
View File
@@ -0,0 +1,27 @@
// Answer piping. Replaces {{Label}} or {{field_id}} tokens in text with current
// field values. Used in field labels/help, settings.thanksMessage, and the
// redirect URL.
import type { Field } from "./types";
const TOKEN = /\{\{\s*([^}]+?)\s*\}\}/g;
export function pipe(template: string, values: Record<string, unknown>, fields: Field[]): string {
if (!template) return template;
// Build a name-to-id lookup once. Try id-as-key first, then label.
const labelToId = new Map<string, string>();
for (const f of fields) {
labelToId.set(f.label.toLowerCase(), f.id);
}
return template.replace(TOKEN, (_, raw) => {
const key = String(raw).trim();
let v = values[key];
if (v === undefined) {
const id = labelToId.get(key.toLowerCase());
if (id !== undefined) v = values[id];
}
if (v === undefined || v === null) return "";
if (Array.isArray(v)) return v.join(", ");
return String(v);
});
}
+71
View File
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consume, _resetMemoryBuckets, clientIp } from "./ratelimit";
describe("memory rate limiter", () => {
beforeEach(() => {
_resetMemoryBuckets();
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
});
afterEach(() => {
vi.useRealTimers();
});
it("allows requests under the limit", async () => {
const r1 = await consume("k", 3, 1000);
const r2 = await consume("k", 3, 1000);
const r3 = await consume("k", 3, 1000);
expect(r1.ok).toBe(true);
expect(r2.ok).toBe(true);
expect(r3.ok).toBe(true);
expect(r3.remaining).toBe(0);
});
it("rejects the request that exceeds the limit", async () => {
await consume("k", 2, 1000);
await consume("k", 2, 1000);
const r3 = await consume("k", 2, 1000);
expect(r3.ok).toBe(false);
expect(r3.remaining).toBe(0);
});
it("resets the bucket after the window elapses", async () => {
await consume("k", 1, 1000);
const blocked = await consume("k", 1, 1000);
expect(blocked.ok).toBe(false);
vi.advanceTimersByTime(1001);
const allowed = await consume("k", 1, 1000);
expect(allowed.ok).toBe(true);
});
it("isolates buckets by key", async () => {
await consume("a", 1, 1000);
const a2 = await consume("a", 1, 1000);
const b1 = await consume("b", 1, 1000);
expect(a2.ok).toBe(false);
expect(b1.ok).toBe(true);
});
it("returns a sensible resetAt timestamp", async () => {
const r = await consume("k", 5, 1000);
expect(r.resetAt).toBe(1000);
});
});
describe("clientIp", () => {
it("trusts the first hop in x-forwarded-for", () => {
const h = new Headers({ "x-forwarded-for": "1.2.3.4, 5.6.7.8" });
expect(clientIp(h)).toBe("1.2.3.4");
});
it("falls back to x-real-ip", () => {
const h = new Headers({ "x-real-ip": "9.9.9.9" });
expect(clientIp(h)).toBe("9.9.9.9");
});
it("returns 'unknown' when no header is present", () => {
expect(clientIp(new Headers())).toBe("unknown");
});
});
+123
View File
@@ -0,0 +1,123 @@
// Sliding-window rate limiter with pluggable drivers.
//
// RATE_LIMIT_DRIVER=memory (default) — in-process bucket, single-instance only
// RATE_LIMIT_DRIVER=redis — requires REDIS_URL, multi-instance safe
//
// Both drivers expose the same {ok, remaining, resetAt} contract.
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
const MAX_BUCKETS = 10_000;
function sweep(now: number) {
if (buckets.size <= MAX_BUCKETS) return;
for (const [k, b] of buckets) {
if (b.resetAt <= now) buckets.delete(k);
}
}
function memoryConsume(key: string, limit: number, windowMs: number): ConsumeResult {
const now = Date.now();
const b = buckets.get(key);
if (!b || b.resetAt <= now) {
const resetAt = now + windowMs;
buckets.set(key, { count: 1, resetAt });
sweep(now);
return { ok: true, remaining: limit - 1, resetAt };
}
if (b.count >= limit) return { ok: false, remaining: 0, resetAt: b.resetAt };
b.count += 1;
return { ok: true, remaining: limit - b.count, resetAt: b.resetAt };
}
// ---------- Redis driver ----------
// Sorted-set window: ZADD ts ts; ZREMRANGEBYSCORE 0 (now-window); ZCARD; EXPIRE.
// Pipelined in a single round-trip.
type RedisClient = {
multi: () => RedisClient;
zadd: (key: string, score: number, member: string) => RedisClient;
zremrangebyscore: (key: string, min: number, max: number) => RedisClient;
zcard: (key: string) => RedisClient;
pexpire: (key: string, ms: number) => RedisClient;
exec: () => Promise<Array<[Error | null, unknown]> | null>;
};
let redisClient: RedisClient | null = null;
let redisInitPromise: Promise<RedisClient | null> | null = null;
async function getRedis(): Promise<RedisClient | null> {
if (redisClient) return redisClient;
if (!process.env.REDIS_URL) return null;
if (!redisInitPromise) {
redisInitPromise = (async () => {
try {
// Dynamic import so memory-driver users don't pay the cost.
const mod = await import(/* webpackIgnore: true */ "ioredis").catch(() => null);
if (!mod) return null;
// ioredis exports both `default` (CJS) and named `Redis`. Either is a
// constructor that accepts a URL string (the rest of its overloads
// aren't relevant to us, so we cast to a narrow shape).
type Ctor = new (url: string) => RedisClient;
const ModuleShape = mod as unknown as { default?: Ctor; Redis?: Ctor };
const RedisCtor = ModuleShape.default ?? ModuleShape.Redis;
if (!RedisCtor) return null;
redisClient = new RedisCtor(process.env.REDIS_URL!);
return redisClient;
} catch {
return null;
}
})();
}
return redisInitPromise;
}
async function redisConsume(key: string, limit: number, windowMs: number): Promise<ConsumeResult> {
const client = await getRedis();
if (!client) return memoryConsume(key, limit, windowMs); // graceful fallback
const now = Date.now();
const cutoff = now - windowMs;
const member = `${now}:${Math.random().toString(36).slice(2, 8)}`;
try {
const res = await client
.multi()
.zadd(`rl:${key}`, now, member)
.zremrangebyscore(`rl:${key}`, 0, cutoff)
.zcard(`rl:${key}`)
.pexpire(`rl:${key}`, windowMs)
.exec();
const count = res ? Number(res[2]?.[1] ?? 0) : 0;
const ok = count <= limit;
return { ok, remaining: Math.max(0, limit - count), resetAt: now + windowMs };
} catch {
// Redis blip — fail open by falling back to memory for this call.
return memoryConsume(key, limit, windowMs);
}
}
export type ConsumeResult = { ok: boolean; remaining: number; resetAt: number };
export async function consume(
key: string,
limit: number,
windowMs: number,
): Promise<ConsumeResult> {
const driver = (process.env.RATE_LIMIT_DRIVER || "memory").toLowerCase();
if (driver === "redis") return redisConsume(key, limit, windowMs);
return memoryConsume(key, limit, windowMs);
}
// Extract a client IP from a Next.js Request's headers.
// Trusts the first hop in x-forwarded-for (typical for reverse-proxy setups
// like nginx, Cloudflare, or fly.io). Falls back to x-real-ip, then "unknown".
export function clientIp(headers: Headers): string {
const xff = headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
return headers.get("x-real-ip") ?? "unknown";
}
// Test hook — drop the in-memory bucket state between unit-test runs.
export function _resetMemoryBuckets() {
buckets.clear();
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { escapeHtml, stripTags, isSafeUrl } from "./sanitize";
describe("escapeHtml", () => {
it("escapes the canonical XSS characters", () => {
expect(escapeHtml("<script>alert('xss')</script>"))
.toBe("&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
});
it("escapes &, <, >, \", '", () => {
expect(escapeHtml('a & b < c > d "e" \'f\'')).toBe("a &amp; b &lt; c &gt; d &quot;e&quot; &#39;f&#39;");
});
it("handles null/undefined as empty string", () => {
expect(escapeHtml(null)).toBe("");
expect(escapeHtml(undefined)).toBe("");
});
it("leaves plain text untouched", () => {
expect(escapeHtml("hello world")).toBe("hello world");
});
});
describe("stripTags", () => {
it("removes simple tags", () => {
expect(stripTags("<b>bold</b> and <i>italic</i>")).toBe("bold and italic");
});
it("strips attributes-laden tags", () => {
expect(stripTags('<a href="x" onclick="y">link</a>')).toBe("link");
});
it("handles malformed input without throwing", () => {
expect(stripTags("<not a tag")).toBe("<not a tag"); // no closing > means nothing to strip
});
});
describe("isSafeUrl", () => {
it("accepts http and https", () => {
expect(isSafeUrl("http://example.com")).toBe(true);
expect(isSafeUrl("https://example.com/path?q=1")).toBe(true);
});
it("rejects javascript: and data: schemes", () => {
expect(isSafeUrl("javascript:alert(1)")).toBe(false);
expect(isSafeUrl("data:text/html,<script>alert(1)</script>")).toBe(false);
});
it("rejects invalid URLs", () => {
expect(isSafeUrl("not a url")).toBe(false);
expect(isSafeUrl("")).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
// Centralized text-to-HTML escaping. Use everywhere user-provided strings flow
// into an HTML context (emails, raw markup, etc). React's JSX rendering escapes
// by default; this is for the non-JSX paths.
const REPLACE: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
export function escapeHtml(input: unknown): string {
if (input === null || input === undefined) return "";
return String(input).replace(/[&<>"']/g, (c) => REPLACE[c] ?? c);
}
// Strip everything that smells like markup. Use for places where the value
// should be plain text but might be rendered raw (mailer subject lines, etc.).
export function stripTags(input: unknown): string {
return String(input ?? "").replace(/<[^>]*>/g, "");
}
// Quick allowlist URL check — refuses anything that isn't http/https. Used on
// the redirect-after-submit setting and on webhook URLs.
export function isSafeUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
+74
View File
@@ -0,0 +1,74 @@
// Storage driver abstraction. Two drivers today:
// STORAGE_DRIVER=local (default) — writes under ./uploads/<key>
// STORAGE_DRIVER=s3 — uses @aws-sdk/client-s3 (install when switching)
//
// The contract is intentionally small: write a buffer with a key, read it back
// as a Node Readable, and check existence. Each UploadedFile row records which
// driver wrote the object so old files keep reading correctly after a driver swap.
import { promises as fs, createReadStream } from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
export type StorageDriver = "local" | "s3";
export function currentDriver(): StorageDriver {
const d = (process.env.STORAGE_DRIVER || "local").toLowerCase();
return d === "s3" ? "s3" : "local";
}
export interface ObjectMeta { size: number; contentType: string }
export interface StorageDriverImpl {
put(key: string, body: Buffer, meta: ObjectMeta): Promise<void>;
read(key: string): Promise<Readable>;
exists(key: string): Promise<boolean>;
remove(key: string): Promise<void>;
}
// ---------- Local driver ----------
const LOCAL_ROOT = path.resolve(process.cwd(), process.env.UPLOAD_DIR || "uploads");
async function ensureLocalRoot() {
await fs.mkdir(LOCAL_ROOT, { recursive: true });
}
function localPath(key: string) {
// Disallow path traversal — keys must be plain segments only.
const safe = key.replace(/[^a-zA-Z0-9_\-./]/g, "");
return path.join(LOCAL_ROOT, safe);
}
const local: StorageDriverImpl = {
async put(key, body) {
await ensureLocalRoot();
const p = localPath(key);
await fs.mkdir(path.dirname(p), { recursive: true });
await fs.writeFile(p, body);
},
async read(key) {
return createReadStream(localPath(key));
},
async exists(key) {
try { await fs.access(localPath(key)); return true; } catch { return false; }
},
async remove(key) {
await fs.unlink(localPath(key)).catch(() => {});
},
};
// ---------- S3 driver (stub — wire when needed) ----------
const s3: StorageDriverImpl = {
async put() { throw new Error("S3 driver not wired. Install @aws-sdk/client-s3 and implement put()."); },
async read() { throw new Error("S3 driver not wired."); },
async exists() { return false; },
async remove() { /* noop */ },
};
const drivers: Record<StorageDriver, StorageDriverImpl> = { local, s3 };
export function driver(name?: StorageDriver): StorageDriverImpl {
return drivers[name ?? currentDriver()];
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { slugifyTag } from "./tags";
describe("slugifyTag", () => {
it("lowercases and replaces spaces with hyphens", () => {
expect(slugifyTag("Customer Feedback")).toBe("customer-feedback");
});
it("strips non-alphanumeric characters", () => {
expect(slugifyTag("HR / Hiring!")).toBe("hr-hiring");
});
it("trims leading/trailing hyphens", () => {
expect(slugifyTag(" --internal-- ")).toBe("internal");
});
it("collapses to empty when input has nothing slug-worthy", () => {
expect(slugifyTag("@@@@")).toBe("");
expect(slugifyTag(" ")).toBe("");
});
it("clips length at 40 chars", () => {
expect(slugifyTag("a".repeat(80)).length).toBe(40);
});
});
+66
View File
@@ -0,0 +1,66 @@
// Lightweight tag helpers. Tags are global to the workspace — there's only one
// team, so we don't scope them. A tag is created on first use; deleting it is
// an explicit operation in the tag manager.
import { prisma } from "./db";
const SLUG_RE = /[^a-z0-9-]+/g;
export function slugifyTag(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(SLUG_RE, "")
.replace(/-+/g, "-") // collapse adjacent hyphens from stripped punctuation
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
// Find or create tags by name. Returns the canonical Tag rows.
export async function upsertTagsByName(names: string[]): Promise<Array<{ id: string; slug: string; name: string }>> {
const out: Array<{ id: string; slug: string; name: string }> = [];
for (const raw of names) {
const name = raw.trim();
if (!name) continue;
const slug = slugifyTag(name);
if (!slug) continue;
const row = await prisma.tag.upsert({
where: { slug },
create: { slug, name },
update: {}, // Don't overwrite display name on re-use.
select: { id: true, slug: true, name: true },
});
out.push(row);
}
return out;
}
export async function setFormTags(formId: string, tagNames: string[]): Promise<void> {
const tags = await upsertTagsByName(tagNames);
await prisma.$transaction([
prisma.formTag.deleteMany({ where: { formId } }),
prisma.formTag.createMany({
data: tags.map((t) => ({ formId, tagId: t.id })),
skipDuplicates: true,
}),
]);
}
export async function listAllTags() {
// Tags ordered by usage count desc, then name asc.
const rows = await prisma.tag.findMany({
orderBy: { name: "asc" },
include: { _count: { select: { forms: true } } },
});
return rows
.map((t) => ({ id: t.id, slug: t.slug, name: t.name, color: t.color, count: t._count.forms }))
.sort((a, b) => (b.count - a.count) || a.name.localeCompare(b.name));
}
export async function deleteTagIfUnused(tagId: string): Promise<boolean> {
const count = await prisma.formTag.count({ where: { tagId } });
if (count > 0) return false;
await prisma.tag.delete({ where: { id: tagId } }).catch(() => {});
return true;
}
+131
View File
@@ -0,0 +1,131 @@
// Starter templates. Each is a partial Form — title, description, fields,
// settings. Cloned into a new row by createFromTemplate().
import type { Field, FormSettings } from "./types";
export type Template = {
id: string;
name: string;
emoji: string;
description: string;
form: { title: string; description?: string; fields: Field[]; settings: FormSettings };
};
const fid = (s: string) => s; // template-time ids; nanoid'd at clone time
export const TEMPLATES: Template[] = [
{
id: "contact",
name: "Contact",
emoji: "✉️",
description: "Name, email, message — the classic.",
form: {
title: "Get in touch",
description: "We'll reply within a few business days.",
settings: { visibility: "public" },
fields: [
{ id: fid("name"), type: "short_text", label: "Name", required: true },
{ id: fid("email"), type: "email", label: "Email", required: true },
{ id: fid("message"), type: "long_text", label: "Message", required: true, placeholder: "What's on your mind?" },
],
},
},
{
id: "nps",
name: "NPS survey",
emoji: "📊",
description: "Net promoter score with a follow-up.",
form: {
title: "How likely are you to recommend us?",
settings: { visibility: "workspace" },
fields: [
{ id: fid("score"), type: "rating", label: "0 — 10", max: 10, required: true },
{ id: fid("reason"), type: "long_text", label: "What's the main reason for your score?" },
],
},
},
{
id: "rsvp",
name: "Event RSVP",
emoji: "🎟️",
description: "Attending, plus-ones, dietary notes.",
form: {
title: "Will you be there?",
settings: { visibility: "workspace" },
fields: [
{ id: fid("name"), type: "short_text", label: "Your name", required: true },
{
id: fid("attending"), type: "select", label: "Attending?", required: true,
options: [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
{ value: "maybe", label: "Maybe" },
],
},
{
id: fid("guests"), type: "number", label: "Plus ones", min: 0, max: 5,
showIf: [{ rules: [{ fieldId: fid("attending"), op: "eq", value: "yes" }] }],
},
{
id: fid("diet"), type: "long_text", label: "Dietary notes",
showIf: [{ rules: [{ fieldId: fid("attending"), op: "eq", value: "yes" }] }],
},
],
},
},
{
id: "job",
name: "Job application",
emoji: "💼",
description: "Quick intake for a role posting.",
form: {
title: "Apply",
description: "Tell us about yourself and we'll get back to you.",
settings: { visibility: "public" },
fields: [
{ id: fid("name"), type: "short_text", label: "Full name", required: true },
{ id: fid("email"), type: "email", label: "Email", required: true },
{ id: fid("link"), type: "short_text", label: "Portfolio / LinkedIn" },
{
id: fid("role"), type: "select", label: "Role", required: true,
options: [
{ value: "eng", label: "Engineering" },
{ value: "design", label: "Design" },
{ value: "ops", label: "Operations" },
{ value: "other", label: "Other" },
],
},
{ id: fid("why"), type: "long_text", label: "Why this role?", required: true },
],
},
},
{
id: "bug",
name: "Bug report",
emoji: "🐛",
description: "Repro steps, severity, expected vs actual.",
form: {
title: "Report a bug",
settings: { visibility: "workspace" },
fields: [
{ id: fid("title"), type: "short_text", label: "What went wrong?", required: true },
{
id: fid("severity"), type: "select", label: "Severity", required: true,
options: [
{ value: "low", label: "Low" },
{ value: "med", label: "Medium" },
{ value: "high", label: "High" },
{ value: "blocker", label: "Blocker" },
],
},
{ id: fid("steps"), type: "long_text", label: "Steps to reproduce", required: true },
{ id: fid("expected"), type: "long_text", label: "Expected" },
{ id: fid("actual"), type: "long_text", label: "Actual" },
],
},
},
];
export function getTemplate(id: string): Template | undefined {
return TEMPLATES.find((t) => t.id === id);
}
+111
View File
@@ -0,0 +1,111 @@
// Shared types stored as JSON in DB.
export type FieldType =
| "short_text"
| "long_text"
| "number"
| "email"
| "date"
| "select"
| "multi_select"
| "checkbox"
| "rating"
| "file"
| "page_break"
| "calculated"
| "signature";
export type FormLayout = "one_page" | "paged" | "one_at_a_time";
/** A reference to an uploaded file, stored in response.data for file fields. */
export type FileRef = { id: string; name: string; size: number; contentType: string };
export type LogicOp =
| "eq"
| "neq"
| "contains"
| "gt"
| "lt"
| "empty"
| "not_empty";
export type LogicRule = {
fieldId: string;
op: LogicOp;
value?: string | number;
};
// Rules within a group are ANDed; field is visible if ANY group passes.
// Single-group means strict AND (the original behavior). Multiple groups = OR.
export type LogicGroup = { rules: LogicRule[] };
export type FieldOption = { value: string; label: string };
export type Field = {
id: string;
type: FieldType;
label: string;
help?: string;
required?: boolean;
placeholder?: string;
options?: FieldOption[];
min?: number;
max?: number;
/** File-only: comma-separated MIME types or extensions (e.g. "image/*,.pdf"). */
accept?: string;
/** File-only: max file size in MB. */
maxSizeMB?: number;
/** Calculated-only: expression evaluated against other field values. */
expression?: string;
// Visibility rules. The new shape is LogicGroup[] (groups ORed). The legacy
// shape LogicRule[] is auto-upgraded by normalizeShowIf().
showIf?: LogicGroup[] | LogicRule[];
};
export type FormVisibility = "workspace" | "public";
export type FormSettings = {
visibility?: FormVisibility;
thanksMessage?: string;
accent?: string; // hex
redirectUrl?: string;
/** Email recipients to notify on each new response. */
notifyEmails?: string[];
/** Webhook POSTed to on each new response. Signed with webhookSecret. */
webhookUrl?: string;
webhookSecret?: string;
/** Require hCaptcha on submit (only effective when HCAPTCHA_SITE_KEY is set). */
hcaptchaEnabled?: boolean;
/** Page rendering mode. Defaults to "one_page". */
layout?: FormLayout;
/** UploadedFile id for the cover image (banner at top of form). */
coverImage?: string;
/** UploadedFile id for the logo (inline beside title). */
logo?: string;
/** Curated font family name. See FONT_OPTIONS in the runtime. */
font?: string;
};
/** Coerce either legacy LogicRule[] or new LogicGroup[] into LogicGroup[]. */
export function normalizeShowIf(s: Field["showIf"]): LogicGroup[] {
if (!s || s.length === 0) return [];
// Heuristic: rule items have `fieldId`; group items have `rules`.
if ("fieldId" in (s[0] as object)) return [{ rules: s as LogicRule[] }];
return s as LogicGroup[];
}
export const FIELD_TYPE_LABELS: Record<FieldType, string> = {
short_text: "Short text",
long_text: "Long text",
number: "Number",
email: "Email",
date: "Date",
select: "Single choice",
multi_select: "Multiple choice",
checkbox: "Checkbox",
rating: "Rating",
file: "File upload",
page_break: "Page break",
calculated: "Calculated",
signature: "Signature",
};
+67
View File
@@ -0,0 +1,67 @@
// Form versioning. Every save snapshots the form schema into FormVersion.
// Snapshots are skipped when no meaningful field changed (title/description-only
// edits don't bloat the history). Reverting creates a new version with the old
// content rather than deleting forward history.
import { prisma } from "./db";
type FormShape = {
title: string;
description: string | null;
fields: string;
settings: string;
};
// Returns the version number that was just written, or null if skipped.
export async function snapshotForm(opts: {
formId: string;
current: FormShape;
authorId: string | null;
force?: boolean;
}): Promise<number | null> {
const { formId, current, authorId, force } = opts;
const last = await prisma.formVersion.findFirst({
where: { formId },
orderBy: { version: "desc" },
select: { version: true, fields: true, settings: true, title: true, description: true },
});
if (!force && last) {
const sameSchema = last.fields === current.fields && last.settings === current.settings;
const sameMeta = last.title === current.title && (last.description ?? null) === (current.description ?? null);
if (sameSchema && sameMeta) return null;
}
const nextVersion = (last?.version ?? 0) + 1;
await prisma.formVersion.create({
data: {
formId,
version: nextVersion,
title: current.title,
description: current.description,
fields: current.fields,
settings: current.settings,
authorId,
},
});
return nextVersion;
}
export async function listVersions(formId: string, take = 50) {
return prisma.formVersion.findMany({
where: { formId },
orderBy: { version: "desc" },
take,
select: {
id: true, version: true, title: true,
authorId: true, createdAt: true,
author: { select: { name: true, email: true } },
},
});
}
export async function getVersion(formId: string, version: number) {
return prisma.formVersion.findUnique({
where: { formId_version: { formId, version } },
});
}
+129
View File
@@ -0,0 +1,129 @@
// Outbound webhook delivery. POST JSON, sign with HMAC-SHA256 over `${ts}.${body}`.
// Header: X-FormBuilder-Signature: t=<unix>,v1=<hex>
//
// Delivery model:
// 1. enqueueWebhook() persists a WebhookDelivery row and triggers the first attempt.
// 2. attemptDelivery() does one HTTP try. On a retryable failure (network or 5xx)
// it schedules nextAttemptAt with exponential backoff; on 4xx it gives up.
// 3. processDueWebhooks() is the worker entry point — call it from a cron route.
//
// Backoff: 30s, 2m, 10m, 1h, 6h (5 retries → ~7h total). Stops after MAX_ATTEMPTS.
import { createHmac } from "node:crypto";
import { prisma } from "./db";
export type WebhookPayload = {
event: "response.created";
formId: string;
responseId: string;
data: Record<string, unknown>;
submittedAt: string;
};
const MAX_ATTEMPTS = 6;
const BACKOFF_MS = [30_000, 120_000, 600_000, 3_600_000, 21_600_000];
const TIMEOUT_MS = 5_000;
function nextBackoff(attempts: number): number | null {
if (attempts >= MAX_ATTEMPTS) return null;
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)];
}
function sign(secret: string | null | undefined, body: string, ts: number): string | null {
if (!secret) return null;
return createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
}
async function postOnce(url: string, body: string, secret: string | null | undefined): Promise<{
status: number; error: string | null;
}> {
const ts = Math.floor(Date.now() / 1000);
const sig = sign(secret, body, ts);
const headers: Record<string, string> = {
"content-type": "application/json",
"user-agent": "formbuilder-webhook/2",
};
if (sig) headers["x-formbuilder-signature"] = `t=${ts},v1=${sig}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
try {
const r = await fetch(url, { method: "POST", headers, body, signal: ctrl.signal });
return { status: r.status, error: r.ok ? null : `HTTP ${r.status}` };
} catch (e) {
return { status: 0, error: e instanceof Error ? e.message : "fetch error" };
} finally {
clearTimeout(timer);
}
}
// Persist a new delivery and attempt it immediately. Failures schedule a retry
// row; the caller doesn't await retries.
export async function enqueueWebhook(opts: {
url: string;
secret?: string;
payload: WebhookPayload;
}): Promise<void> {
const body = JSON.stringify(opts.payload);
const row = await prisma.webhookDelivery.create({
data: {
formId: opts.payload.formId,
responseId: opts.payload.responseId,
url: opts.url,
payload: body,
secret: opts.secret ?? null,
attempts: 0,
status: 0,
nextAttemptAt: new Date(),
},
}).catch(() => null);
if (!row) return;
await attemptDelivery(row.id);
}
// Make one attempt against a delivery row, updating status/attempts/nextAttemptAt.
// Returns true if delivered successfully, false otherwise.
export async function attemptDelivery(deliveryId: string): Promise<boolean> {
const row = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId } });
if (!row || !row.url) return false;
if (row.status >= 200 && row.status < 300) return true;
const result = await postOnce(row.url, row.payload, row.secret);
const attempts = row.attempts + 1;
const success = result.status >= 200 && result.status < 300;
const isRetryable = !success && (result.status === 0 || result.status >= 500);
const backoff = isRetryable ? nextBackoff(attempts) : null;
const nextAttemptAt = backoff !== null ? new Date(Date.now() + backoff) : null;
await prisma.webhookDelivery.update({
where: { id: deliveryId },
data: {
attempts,
status: result.status,
lastError: success ? null : result.error,
lastAttemptAt: new Date(),
nextAttemptAt,
},
}).catch(() => {});
return success;
}
// Worker entry — drain all deliveries whose nextAttemptAt is due. Cap each
// invocation so a long backlog doesn't tie up the runtime.
export async function processDueWebhooks(maxRows = 50): Promise<{ processed: number; succeeded: number }> {
const due = await prisma.webhookDelivery.findMany({
where: { nextAttemptAt: { not: null, lte: new Date() } },
orderBy: { nextAttemptAt: "asc" },
take: maxRows,
});
let succeeded = 0;
for (const row of due) {
const ok = await attemptDelivery(row.id);
if (ok) succeeded += 1;
}
return { processed: due.length, succeeded };
}
// Back-compat shim — old call sites used deliverWebhook().
export const deliverWebhook = enqueueWebhook;
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
export default auth((req) => {
const isApp = req.nextUrl.pathname.startsWith("/app");
if (isApp && !req.auth) {
const url = new URL("/signin", req.url);
url.searchParams.set("from", req.nextUrl.pathname);
return NextResponse.redirect(url);
}
});
export const config = {
matcher: ["/app/:path*"],
};
+26
View File
@@ -0,0 +1,26 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "rgb(var(--bg) / <alpha-value>)",
fg: "rgb(var(--fg) / <alpha-value>)",
muted: "rgb(var(--muted) / <alpha-value>)",
line: "rgb(var(--line) / <alpha-value>)",
accent: "rgb(var(--accent) / <alpha-value>)",
"accent-fg": "rgb(var(--accent-fg) / <alpha-value>)",
danger: "rgb(var(--danger) / <alpha-value>)",
},
fontFamily: {
sans: ["var(--font-sans)", "ui-sans-serif", "system-ui", "sans-serif"],
},
borderRadius: { DEFAULT: "10px" },
},
},
plugins: [],
};
export default config;
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
exclude: ["node_modules/**", ".next/**"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/lib/**/*.ts"],
exclude: ["src/lib/**/*.test.ts"],
},
},
});