> ## Documentation Index
> Fetch the complete documentation index at: https://docs-terra.withunify.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Submissions

> How form submissions are processed, tracked, and enriched with metadata

# Submissions

> Submissions are saved immediately with metadata, then processed asynchronously.

<Note>
  For bulk management of test data and archived submissions, see [Admin Bulk Management](#admin-bulk-management) below.
</Note>

***

## Submission Flow

```mermaid theme={null}
sequenceDiagram
    participant A as Applicant
    participant S as Server
    participant DB as Database
    participant Q as Queue

    A->>S: Submit form
    S->>S: Validate answers
    S->>S: Encrypt PII fields
    S->>S: Capture geolocation
    S->>DB: Save submission
    S->>DB: Generate reference ID
    S->>Q: Enqueue integrations
    S->>A: Success + reference ID
```

***

## Reference IDs

Human-readable IDs for status lookup. Uses safe characters (no confusing 0/O, 1/I/l):

```typescript theme={null}
// 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

```typescript theme={null}
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:

```typescript theme={null}
// 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:

```typescript theme={null}
// 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

| Header                | Provider   | Data Available |
| --------------------- | ---------- | -------------- |
| `x-vercel-ip-country` | Vercel     | Country code   |
| `x-vercel-ip-city`    | Vercel     | City name      |
| `cf-ipcountry`        | Cloudflare | Country code   |
| `cf-connecting-ip`    | Cloudflare | Client IP      |

***

## Outcome Field

The `outcome` field stores applicant-visible results. Unlike `status` (internal workflow), `outcome` is what the applicant sees on their status page.

```typescript theme={null}
// 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:

```typescript theme={null}
{
  amount: 3000,
  disbursement_date: "2025-02-01",
  program_id: "era-2025",
  notes: "Approved for 3 months of assistance"
}
```

### Setting Outcomes

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

***

## Submission Status

| Status       | Meaning              | Applicant Sees    |
| ------------ | -------------------- | ----------------- |
| `submitted`  | Initial state        | "Received"        |
| `pending`    | Under review         | "In Review"       |
| `approved`   | Application approved | Outcome text      |
| `rejected`   | Application denied   | Outcome text      |
| `waitlisted` | On waiting list      | "Waitlisted"      |
| `withdrawn`  | Applicant withdrew   | "Withdrawn"       |
| `incomplete` | Missing information  | "Action Required" |

### Status Changes

```typescript theme={null}
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;
    }
  );
}
```

***

## Featured Questions

Forms can mark specific questions as "featured" to display prominently in submission lists:

```typescript theme={null}
// 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.

```typescript theme={null}
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

```typescript theme={null}
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:

```typescript theme={null}
// 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:

```typescript theme={null}
// Safety check enforced
const result = await bulkDeleteSubmissions(submissionIds);
// Returns error if any submission is not test/archived
```

### Admin UI Features

The Submissions page provides:

| Feature              | Description                                  |
| -------------------- | -------------------------------------------- |
| Form filter          | Filter by form (with form status sub-filter) |
| Status filter        | Filter by submission status                  |
| Test/Archive filters | Show test-only, real-only, archived, active  |
| Email search         | Search by applicant email                    |
| Bulk select          | Checkbox selection for bulk actions          |
| Mark as Test         | Flag selected submissions as test data       |
| Archive/Unarchive    | Archive or restore selected submissions      |
| Delete               | Permanently delete test/archived submissions |

***

## Soft Delete

Submissions use soft delete for data preservation:

```typescript theme={null}
// 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](/core-systems/data/encryption-pii) for details.

```typescript theme={null}
// Before saving
const encryptedData = encryptSubmissionPII(data, piiFieldIds);

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

***

<CardGroup cols={2}>
  <Card title="Queue Architecture" icon="clock" href="/core-systems/async/queue-architecture">
    Background processing
  </Card>

  <Card title="Notifications" icon="bell" href="/integrations/notifications">
    Email and SMS
  </Card>

  <Card title="Encryption" icon="lock" href="/core-systems/data/encryption-pii">
    PII protection
  </Card>

  <Card title="Audit Logging" icon="clipboard-list" href="/core-systems/data/audit-logging">
    Change tracking
  </Card>
</CardGroup>
