Skip to main content

Submissions

Submissions are saved immediately with metadata, then processed asynchronously.
For bulk management of test data and archived submissions, see Admin Bulk Management below.

Submission Flow


Reference IDs

Human-readable IDs for status lookup. Uses safe characters (no confusing 0/O, 1/I/l):
// Format: {PREFIX}-{SHORTID}
// Example: GRANT-A3B7C4D9

const SAFE_CHARS = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";

function generateShortId(length = 8): string {
  let id = "";
  for (let i = 0; i < length; i++) {
    id += SAFE_CHARS[Math.floor(Math.random() * SAFE_CHARS.length)];
  }
  return id;
}
The prefix is configurable per form via submission_prefix. Once set, the prefix becomes immutable to prevent broken references.

Submission Data Model

interface Submission {
  id: string;
  form_id: string;
  reference_id: string;

  // Answer data (encrypted JSONB)
  data: Record<string, unknown>;

  // Status tracking
  status: SubmissionStatus;
  payment_status: PaymentStatus | null;

  // Applicant-visible outcome
  outcome: string | null;
  outcome_details: Record<string, unknown> | null;

  // Identity linking
  applicant_id: string | null;
  user_id: string | null;

  // Geolocation (captured at submission)
  ip_address: string | null;  // Anonymized
  ip_country: string | null;
  ip_country_code: string | null;
  ip_city: string | null;
  ip_region: string | null;
  user_agent: string | null;

  // Admin management flags
  is_test: boolean;           // Marked as test data
  is_archived: boolean;       // Hidden from normal views
  archived_at: string | null; // When archived
  archived_by: string | null; // Who archived it

  // Timestamps
  created_at: string;
  updated_at: string;
  deleted_at: string | null;
  deleted_by: string | null;
}

Soft Deletion

Submissions are soft-deleted to preserve auditability and prevent accidental data loss. Instead of deleting rows, we set:
  • deleted_at — when the record was removed
  • deleted_by — who removed it
All standard reads filter out soft-deleted records.

Geolocation Tracking

Every submission captures location data for compliance and fraud detection:
// src/lib/geolocation.ts

export async function getSubmissionGeoData(): Promise<GeoData> {
  const [ip, userAgent] = await Promise.all([
    getClientIP(),
    getUserAgent()
  ]);

  const geoData = await getGeoFromIP(ip);

  return {
    ip_address: ip ? anonymizeIP(ip) : null,  // Privacy: zero last octet
    ip_country: geoData.ip_country || null,
    ip_country_code: geoData.ip_country_code || null,
    ip_city: geoData.ip_city || null,
    ip_region: geoData.ip_region || null,
    user_agent: userAgent,
  };
}

IP Anonymization

For privacy compliance, IP addresses are anonymized before storage:
// 192.168.1.100 → 192.168.1.0
function anonymizeIP(ip: string): string {
  const parts = ip.split(".");
  return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
}

Data Sources

HeaderProviderData Available
x-vercel-ip-countryVercelCountry code
x-vercel-ip-cityVercelCity name
cf-ipcountryCloudflareCountry code
cf-connecting-ipCloudflareClient IP

Outcome Field

The outcome field stores applicant-visible results. Unlike status (internal workflow), outcome is what the applicant sees on their status page.
// Example outcomes
"Approved: $3,000 Emergency Rental Assistance"
"Approved: Housing Voucher #HCV-2025-1234"
"Denied: Income exceeds program limits"
"Waitlisted: Position #47"

Outcome Details

The outcome_details JSONB field stores structured data:
{
  amount: 3000,
  disbursement_date: "2025-02-01",
  program_id: "era-2025",
  notes: "Approved for 3 months of assistance"
}

Setting Outcomes

await submissionsRepository.updateOutcome(
  submissionId,
  "Approved: $3,000 Emergency Rental Assistance",
  {
    amount: 3000,
    disbursement_date: "2025-02-01",
  }
);

Submission Status

StatusMeaningApplicant Sees
submittedInitial state”Received”
pendingUnder review”In Review”
approvedApplication approvedOutcome text
rejectedApplication deniedOutcome text
waitlistedOn waiting list”Waitlisted”
withdrawnApplicant withdrew”Withdrawn”
incompleteMissing information”Action Required”

Status Changes

export async function updateSubmissionStatus(
  submissionId: string,
  newStatus: SubmissionStatus,
  formId?: string
): Promise<DALResult<Submission>> {
  // Auth check
  const auth = await checkFormAccess(formId);
  if (!auth.success) {
    return { success: false, error: auth.error };
  }

  // Update with audit
  return await withAudit(
    {
      entityType: "submission",
      entityId: submissionId,
      actionType: "update",
      changes: { status: { before: oldStatus, after: newStatus } },
    },
    async () => {
      const result = await this.update(submissionId, { status: newStatus });
      return result.data;
    }
  );
}

Forms can mark specific questions as “featured” to display prominently in submission lists:
// Form settings
{
  featured_questions: ["full_name", "email", "program_type"]
}
These fields appear as columns in the submissions table, making it easy to scan applications without opening each one.
await formsRepository.setFeaturedQuestions(formId, [
  "full_name",
  "email",
  "household_size"
]);

Admin Bulk Management

Admins can manage submissions in bulk via the Submissions page (/submissions). This is useful for cleaning up test data, archiving old submissions, and managing large datasets.

Test Flag

Mark submissions as test data for filtering. Test submissions:
  • Appear with a “Test” badge in the admin UI
  • Can be filtered out of reports and exports
  • Can be safely deleted
interface SubmissionFlags {
  is_test: boolean;           // True if marked as test data
  is_archived: boolean;       // True if archived
  archived_at: string | null; // When archived
  archived_by: string | null; // Who archived it
}

Archive Flag

Archive submissions to hide them from normal views while preserving data:
// Archive submissions
await bulkArchiveSubmissions(submissionIds, true);

// Unarchive submissions
await bulkArchiveSubmissions(submissionIds, false);
Archived submissions:
  • Hidden from normal submission lists
  • Preserved for audit/compliance
  • Can be filtered and viewed by admins
  • Can be unarchived at any time

Bulk Delete

Only submissions marked as test or archived can be deleted. This prevents accidental deletion of real data:
// Safety check enforced
const result = await bulkDeleteSubmissions(submissionIds);
// Returns error if any submission is not test/archived

Admin UI Features

The Submissions page provides:
FeatureDescription
Form filterFilter by form (with form status sub-filter)
Status filterFilter by submission status
Test/Archive filtersShow test-only, real-only, archived, active
Email searchSearch by applicant email
Bulk selectCheckbox selection for bulk actions
Mark as TestFlag selected submissions as test data
Archive/UnarchiveArchive or restore selected submissions
DeletePermanently delete test/archived submissions

Soft Delete

Submissions use soft delete for data preservation:
// Soft delete
await supabaseAdmin
  .from("submissions")
  .update({
    deleted_at: new Date().toISOString(),
    deleted_by: userId,
  })
  .eq("id", submissionId);

// Permanent cleanup runs after 30 days
// via the background worker
All queries automatically exclude soft-deleted records unless explicitly requested.

Encryption

PII fields are encrypted at rest. See Encryption & PII for details.
// Before saving
const encryptedData = encryptSubmissionPII(data, piiFieldIds);

// When reading
const decryptedData = decryptSubmissionPII(submission.data, piiFieldIds);