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
Submissions are saved immediately with metadata, then processed asynchronously.
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
Header Provider Data Available x-vercel-ip-countryVercel Country code x-vercel-ip-cityVercel City name cf-ipcountryCloudflare Country code cf-connecting-ipCloudflare 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.
// 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
Status Meaning Applicant Sees submittedInitial state ”Received” pendingUnder review ”In Review” approvedApplication approved Outcome text rejectedApplication denied Outcome 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 ;
}
);
}
Featured Questions
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:
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:
// 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 );
Queue Architecture Background processing
Notifications Email and SMS
Audit Logging Change tracking