Skip to main content

Publishing

Forms have draft and published states. Edits don’t affect live forms until published.

Versioning Model


Draft vs Published

SchemaWho Sees ItPurpose
draft_schemaAdmins (preview)Work in progress
published_schemaApplicantsLive form
When you edit a published form, changes go to draft_schema. Applicants continue seeing published_schema until you re-publish.

Publishing a Form

export async function publishForm(formId: string) {
  const form = await getForm(formId);

  await supabaseAdmin
    .from("forms")
    .update({
      published_schema: form.draft_schema,
      last_published_at: new Date().toISOString(),
      published_version: form.published_version + 1,
      status: "published",
    })
    .eq("id", formId);

  // Create version snapshot
  await recordFormVersionSnapshot(formId, form.draft_schema);
}

Scheduled Publishing

Forms can be scheduled to go live at a future date:
// Set scheduled publish time
await formsRepository.schedulePublish(formId, new Date("2025-02-01T00:00:00Z"));
The background worker checks every minute for forms where published_at has passed:
async function processScheduledPublishes(): Promise<number> {
  const result = await formsRepository.getFormsDueForPublish();

  for (const form of result.data) {
    await supabaseAdmin
      .from("forms")
      .update({
        status: "published",
        published_schema: form.schema,
      })
      .eq("id", form.id);

    logger.info("Auto-published form", { formId: form.id });
  }

  return result.data.length;
}

Scheduled Closing

Forms can also be scheduled to stop accepting submissions:
// Set scheduled close time (deadline)
await formsRepository.scheduleClose(formId, new Date("2025-03-31T23:59:59Z"));
The worker closes forms where closes_at has passed:
async function processScheduledCloses(): Promise<number> {
  const result = await formsRepository.getFormsDueForClose();

  for (const form of result.data) {
    await supabaseAdmin
      .from("forms")
      .update({ status: "closed" })
      .eq("id", form.id);

    logger.info("Auto-closed form", { formId: form.id });
  }

  return result.data.length;
}

Form Freezing

After a form receives its first submission, it becomes frozen. Frozen forms:
  • Cannot have their schema modified
  • Can still be closed/reopened
  • Can have settings changed (branding, etc.)
This protects data integrity—changing questions after receiving responses would break historical data.

Freeze Fields

interface Form {
  frozen: boolean;
  frozen_at: string | null;
  frozen_reason: string | null;
}

Auto-Freeze Behavior

The background worker automatically freezes forms:
async function processAutoFreeze(): Promise<number> {
  // Find published, non-frozen forms with submissions
  const { data: forms } = await supabaseAdmin
    .from("forms")
    .select("id, title")
    .eq("status", "published")
    .eq("frozen", false);

  for (const form of forms) {
    const { count } = await supabaseAdmin
      .from("submissions")
      .select("*", { count: "exact", head: true })
      .eq("form_id", form.id);

    if (count > 0) {
      await supabaseAdmin
        .from("forms")
        .update({
          frozen: true,
          frozen_at: new Date().toISOString(),
          frozen_reason: "First submission received",
        })
        .eq("id", form.id);
    }
  }
}

Manual Freeze/Unfreeze

Super admins can manually freeze or unfreeze forms:
// Freeze a form
await formsRepository.freeze(formId, "Locked for audit");

// Unfreeze (be careful!)
await formsRepository.unfreeze(formId);
Unfreezing a form with existing submissions can cause data integrity issues. Only do this if you’re certain the schema changes won’t break existing responses.

Preview Mode

Admins can preview draft changes before publishing:
/f/rental-assistance?preview=draft
This shows draft_schema instead of published_schema. Only authenticated admins see the preview.

Effective Status

The effective form status considers scheduling:
function getEffectiveFormStatus(
  status: "draft" | "published" | "closed",
  publishedAt: string | null,
  closesAt: string | null
): "draft" | "published" | "closed" {
  const now = new Date();

  // If closed, stay closed
  if (status === "closed") return "closed";

  // If draft and published_at hasn't passed, still draft
  if (status === "draft") {
    if (publishedAt && new Date(publishedAt) <= now) {
      return "published";  // Scheduled publish time has passed
    }
    return "draft";
  }

  // If published, check if closes_at has passed
  if (closesAt && new Date(closesAt) <= now) {
    return "closed";
  }

  return "published";
}

Form Status Summary

StatusAccepting SubmissionsSchema EditableTrigger
draftNoYesCreate / Edit
publishedYesYes (to draft)Publish
published + frozenYesNoFirst submission
closedNoNoManual / Scheduled

Lifecycle Diagram