Last Updated: 2026-06-28 Status: Active Source of truth:
apps/forms-service/src/db/schema.ts· migrations inapps/forms-service/drizzle/Companion docs: architecture.md · api-spec.md · billing.md
Single Neon PostgreSQL database (FORMS_DATABASE_URL). Every table is tenant-scoped via a tenant_id text column. Credit metering reads from CORE_DATABASE_URL (a separate database — see billing.md).
1. forms
The form definition. The id (form_<uuid>) is the public submit identifier embedded in the endpoint URL.
| Column | Type | Notes |
|---|---|---|
id |
text PK |
form_<uuid> — public, safe in client HTML |
tenant_id |
text NOT NULL |
Owning workspace |
name |
text NOT NULL |
Internal label (1–200 chars) |
slug |
text NOT NULL |
URL-friendly; unique per tenant among live rows |
schema |
jsonb NOT NULL |
{ fields: FormField[] }; default {"fields":[]} — empty = accept any fields (Web3Forms parity) |
settings |
jsonb NOT NULL |
{ successMessage?, redirectUrl?, theme?: { accent, buttonLabel }, turnstileSiteKey? }; default {} |
notify |
jsonb NOT NULL |
{ email?: { enabled, to? }, newsletterListId? }; default {} |
webhook_url |
text |
Webhook fan-out target; generates webhook_secret when set |
webhook_secret |
text |
Auto-generated HMAC secret on first webhook_url save; used for X-Vlozi-Signature. See webhooks.md. |
allowed_origins |
jsonb NOT NULL |
string[] CORS allowlist; default [] (empty = any origin) |
honeypot_field |
text NOT NULL |
Hidden field name a bot fills; default botcheck |
captcha_required |
boolean NOT NULL |
Require a valid Turnstile token; default false |
status |
text NOT NULL |
active | paused | archived; default active |
created_by |
text |
User id from the gateway (nullable) |
created_at |
timestamp |
now() |
updated_at |
timestamp |
now(), bumped on every update |
deleted_at |
timestamp |
Soft-delete tombstone; NULL = live |
Indexes
forms_tenant_slug_unique— partial unique on(tenant_id, slug) WHERE deleted_at IS NULL(deleted forms free their slug).isSlugUniqueViolation()matches on this index name.forms_tenant_idxon(tenant_id)forms_tenant_status_idxon(tenant_id, status)
2. form_submissions
One row per submission attempt, including spam (so the dashboard can surface / hide it).
| Column | Type | Notes |
|---|---|---|
id |
text PK |
sub_<uuid> |
form_id |
text NOT NULL FK |
→ forms.id, ON DELETE CASCADE |
tenant_id |
text NOT NULL |
Denormalized for tenant-scoped queries |
data |
jsonb NOT NULL |
User field values (control fields stripped). File fields stored as { __file: true, mediaId, name, size, type, url }. |
meta |
jsonb NOT NULL |
{ referer?, origin?, utm_source?, utm_medium?, utm_campaign?, utm_term?, utm_content? }; default {} |
status |
text NOT NULL |
new | seen | handled | spam; default new |
spam_reason |
text |
honeypot | captcha; null when not spam |
note |
text |
Admin triage comment (set via PATCH /admin/submissions/:id) |
source_ip |
text |
cf-connecting-ip from request headers |
user_agent |
text |
Request User-Agent |
submitted_at |
timestamp |
now() |
handled_at |
timestamp |
Set automatically when status transitions to handled |
Indexes
form_submissions_tenant_form_submitted_idxon(tenant_id, form_id, submitted_at)— hot path: list a form's submissions newest-firstform_submissions_tenant_status_idxon(tenant_id, status)— status filter (hide spam / show new)form_submissions_form_idxon(form_id)— cascade / join support
data JSONB — file field shape
When a submission includes file uploads, each file field is replaced with a structured reference after upload to Media Service:
{
"resume": {
"__file": true,
"mediaId": "med_01j…",
"name": "resume.pdf",
"size": 184320,
"type": "application/pdf",
"url": "https://cdn.vlozi.app/…/resume.pdf"
}
}NOTE
If the Media Service upload fails transiently, the field stores only the original filename as a plain string (fail-soft). CSV export renders file fields as the url when present, or the raw string otherwise.
3. form_webhook_deliveries
One row per webhook delivery attempt. Created for every submission to a form with a webhook_url.
| Column | Type | Notes |
|---|---|---|
id |
text PK |
wd_<uuid> |
submission_id |
text NOT NULL FK |
→ form_submissions.id, ON DELETE CASCADE |
tenant_id |
text NOT NULL |
|
url |
text NOT NULL |
Delivery target (snapshot of form.webhook_url at delivery time) |
attempt |
integer NOT NULL |
Attempt number (1, 2, or 3) |
status_code |
integer |
HTTP response status from target |
response_body |
text |
First 1000 chars of response body |
status |
text NOT NULL |
pending | success | failed; default pending |
delivered_at |
timestamp |
Set on success |
created_at |
timestamp |
now() |
Indexes: form_webhook_deliveries_submission_idx on (submission_id); form_webhook_deliveries_tenant_status_idx on (tenant_id, status).
NOTE
Manual retry (via MCP retry-webhook-delivery) inserts a new delivery row rather than updating the existing failed row. The failed row remains as a historical record. See webhooks.md for delivery flow details.
4. form_views_daily
Daily view counter per form, updated on every GET /f/:form_id/schema or GET /f/:form_id/page request.
| Column | Type | Notes |
|---|---|---|
form_id |
text NOT NULL FK |
→ forms.id |
tenant_id |
text NOT NULL |
Denormalized for tenant-wide analytics |
day |
date NOT NULL |
UTC calendar date |
views |
integer NOT NULL |
Cumulative count for that day |
Primary key: (form_id, day) — upsert increments views atomically.
Used by the analytics endpoints to compute views, conversion rate (submissions ÷ views), and daily view series.
5. tenant_forms_usage
Fractional credit accumulator for submission billing. See billing.md for the full metering model.
| Column | Type | Notes |
|---|---|---|
tenant_id |
text PK |
One row per tenant |
accumulator_thousandths |
integer NOT NULL |
Unbilled thousandths (0–999). Each submission adds 250. When ≥ 1000, charge 1 credit and reset. |
settlement_seq |
integer NOT NULL |
Optimistic lock — incremented on each settlement to prevent double-charge |
updated_at |
timestamp |
Last metering event |
6. JSONB shapes (TypeScript)
type FormField = {
name: string;
type: "text" | "email" | "tel" | "url" | "number" | "textarea"
| "select" | "checkbox" | "date" | "file";
label?: string;
placeholder?: string;
help?: string;
required?: boolean;
min?: number;
max?: number;
pattern?: string; // regex string for text/email/tel
options?: string[]; // for select fields (max 100 options)
};
type FormSchema = { fields: FormField[] }; // max 100 fields
type FormSettings = {
successMessage?: string; // max 500 chars
redirectUrl?: string; // max 2000 chars, valid URL
theme?: {
accent?: string; // hex color, max 32 chars
buttonLabel?: string; // max 60 chars
};
turnstileSiteKey?: string; // max 100 chars (widget-side key)
};
type FormNotify = {
email?: { enabled: boolean; to?: string };
newsletterListId?: string | null; // Phase 4 — stored, not yet triggered
};
type SubmissionFileMeta = {
__file: true;
mediaId: string;
name: string;
size: number;
type: string;
url: string;
};7. Migrations
Migrations live in apps/forms-service/drizzle/ and are generated by drizzle-kit:
npm run generate # drizzle-kit generate → new SQL file
npm run migrate # apply pending migrations against FORMS_DATABASE_URL
npm run db:push # push schema directly (dev only)The forms database can share a Neon project with other services or be isolated — it has no cross-service foreign keys. All external relationships (credits, tenant plans) are resolved at runtime via CORE_DATABASE_URL queries, not FK constraints.