Skip to main content

Standalone Support App

This is a separate product from the AIQLick platform's support module documented on the Support System overview page. It lives in its own repos, has its own backend, its own database, its own deploy pipeline, and shares no Prisma schema with aiqlick-backend.

Don't confuse the two
  • AIQLick support module — NestJS, GraphQL, Prisma, lives inside aiqlick-backend. Powers the in-product help desk for AIQLick customers. Documented on the Support System overview and Frontend Implementation Guide.
  • Standalone Support App (this page) — Expo + Rust + own Postgres. Single-org ticketing for an independent customer install. Two separate repos: support-app (frontend) and support-backend-rust (backend).

Architecture

Live URLs

SurfaceDevProd
Frontend (Amplify)https://dev-support.aiqlick.comhttps://support.aiqlick.com
Backend APIhttps://api-dev-support.aiqlick.comhttps://api-support.aiqlick.com
/health probehttps://api-dev-support.aiqlick.com/healthhttps://api-support.aiqlick.com/health
Amplify app idd3nd2rf3fst1x9d3nd2rf3fst1x9
ECS clustersupport-backend-rustsupport-backend-rust-prod
ECS servicesupport-backend-rustsupport-backend-rust-prod
ALBsupport-backend-alb (shared, host-routes by Host:)same
ECR reposupport-backend-rustsupport-backend-rust
S3 attachments bucketaiqlick-support-attachments-devaiqlick-support-attachments-prod
Postgresdedicated RDS instance (NEVER the AIQLick DB)dedicated RDS instance

api-dev-support.aiqlick.com and api-support.aiqlick.com resolve to the same support-backend-alb. The ALB has a single HTTPS:443 listener with both ACM certs attached (SNI) and host-header rules forwarding to support-backend-tg (dev) / support-backend-prod-tg (prod).

Tech stack

LayerChoice
FrontendExpo SDK 54, React Native 0.81, React 19 (new arch + react-compiler), expo-router v6 file-based, NativeWind 4 + Tailwind 3, TanStack Query v5, react-hook-form + zod
BackendRust 1.x, Axum 0.8, SQLx 0.8 (Postgres, runtime-tokio-rustls), tokio, argon2/sha2 for auth crypto, tracing-subscriber JSON logs
AWS SDKaws-config = "1" + aws-sdk-s3 = "1" with the behavior-version-latest feature
AuthBearer-token sessions (24h sessions rows). No JWT — opaque tokens, hashed in DB. Sign-in rate-limited to 5/15m per IP+email
Tests#[sqlx::test]-driven integration suite, ~57 cases, covers fan-out, mark-read flows, conversation dedup, message read-receipts, presence transitions, attachment allow-list
E2Escripts/e2e.sh (29-step bash) runs against the live deployed dev backend in CI; Playwright suite (support-app/e2e/) runs against the live deployed dev frontend

Data model

conversation_pairs is a denorm table whose unique index on the canonically-ordered pair (user_a, user_b) (with a user_a < user_b CHECK) makes start_conversation idempotent: a second call with the same pair returns the existing conversation instead of minting a duplicate.

The audit allow-list (audit_entries.action CHECK constraint) was extended in migration #3 with message.sent and notification.read_all so the new modules can log their own state changes inside the same transaction as the mutation.

API surface

24 routes on the live API. New phase-2 endpoints in bold:

MethodPathNotes
GET/health, /ready, /Anonymous
POST/api/auth/sign-in/emailRate-limited 5/15m per IP+email
POST/api/auth/sign-outRevokes session
GET/api/auth/get-session
POST/api/auth/set-passwordSingle-use setup token, FOR UPDATE locked
POST/api/auth/change-passwordRevokes other sessions
PATCH/meEmail change requires currentPassword re-auth gate
GET/me/dashboardRich MyDashboard shape (charts, SLA buckets, assignee workload)
GET/me/dashboard-summaryCards-and-recent-tickets summary
POST/me/presenceHeartbeat — updates users.last_seen_at = now()
GET/me/streamServer-Sent Events. ?token=<bearer> query param (EventSource can't send custom headers). Pushes notification / message / comment / ticket_updated events filtered to the caller. 15s keep-alive ping
GET/me/notificationsPaginated, sorted at DESC
GET/me/notifications/unread-countHits the partial index idx_notifications_user_unread; polled every 30s by the bell
POST/me/notifications/{id}/readOwner-gated; 404 if not the caller's
POST/me/notifications/read-all
GET / POST/me/conversationsIdempotent start — second call with same participantId returns existing convo
GET/conversations/{id}404 if caller not a participant
GET / POST/conversations/{id}/messagesSend fans out a message notification to the other participant in the same tx
POST/conversations/{id}/readBulk INSERT into message_reads ON CONFLICT DO NOTHING (idempotent)
GET / POST/ticketsList supports status / priority / category / createdById / assigned / assignedToId / q / sort
POST/tickets/bulk-updateUp to 200 ids, per-id outcomes
GET / PATCH/tickets/{id}Diff-aware UPDATE; updated_at only bumps on real changes. PATCH accepts testerId (nullable). Auto-snaps resolved_at (sticky) and closed_at (resets on each entry)
GET / POST/tickets/{id}/commentsOff-bucket attachment URLs rejected with 422. Server derives sender_role (user for requester, support for staff). First staff non-internal reply auto-snaps tickets.first_response_at
POST/tickets/{id}/attachmentsS3 presigned PUT (5 min); response includes the canonical key
GET/tickets/{id}/timeline6 event kinds: created / status_changed / priority_changed / assignment_changed / tester_changed / imported
GET / POST/admin/usersList / invite (returns one-time setup token)
PATCH/admin/users/{id}Role / active changes revoke live sessions
POST/admin/users/{id}/issue-setup-tokenRe-issue setup token if the original expired
GET/admin/audit-logPaginated, joins users for actor display

The OpenAPI source of truth lives in support-app/backend-spec/openapi.yaml. Frontend zod schemas in support-app/lib/api/types.ts and support-app/lib/auth/types.ts mirror the same shapes.

Notification fan-out

Notifications are written inside the same transaction as the originating mutation, via the fan_out helper. The helper is executor-generic so callers pass &mut *tx and get rollback safety for free.

Recipient rules per §12 of backend-spec/CHANGES.md:

EventRecipientsType
Ticket assigned to user XX (only if actor ≠ X)assignment
Status changedcreated_by (only if actor ≠ created_by)status_change
closed / resolvedreopenedassigned_to (in addition to status_change)reopened
Comment postedcreated_byassigned_to ∪ prior commenters, minus actor; internal notes filter requesterscomment
Direct message sentother participant(s)message
@user mention (future)mentioned usermention

The internal-note recipient filter loads candidate users inside the tx and drops anyone whose role is requester — matches the lib/messages/mock.ts semantics so flipping EXPO_PUBLIC_API_MODE=mock↔live doesn't change observable behavior.

Real-time delivery (SSE)

Notifications, comments, messages, and ticket updates push to connected clients in real time over a single GET /me/stream Server-Sent Events pipe — no WebSocket server, no message broker, no per-user channel sharding. The transport landed alongside the legacy import (commits 8ebd24c on the backend, fb0f655 on the frontend) and lives in src/realtime.rs on the backend, lib/realtime/event-source.ts + hooks/use-realtime.ts on the frontend.

Event bus internals

AspectChoice
Transporttokio::sync::broadcast::channel(1024) — in-process, single channel for all events
ShardingNone. Recipient filtering happens on the SSE handler side by recipient_user_id. Cardinality is low (≤ low hundreds of concurrent agents on one ECS task), so a flat broadcast keeps the API trivial: handlers call bus.publish(event) and the SSE handler does the filtering
Auth?token=<bearer> query param — EventSource can't send custom headers. Token is sha256-hashed and looked up against sessions exactly like the regular bearer-header flow (same validate_token semantics as SessionInfo)
Channel capacity1024 events ($\approx$ 30s headroom at 30 events/s). Subscribers that lag further drop messages (RecvError::Lagged) and the FE's polling fallback recovers — we never treat SSE as a source of truth
Keep-alive15s ping comment so ALB / NAT idle reapers don't kill the long-lived connection
ReconnectionBrowser EventSource built-in retry handles transient drops. On permanent close (e.g. token revoked → 401), the FE client reads a fresh token from AsyncStorage and re-opens after 5s
Source of truthEvery event has a DB row underneath (notifications, messages, comments, ticket fields). Lost SSE = recoverable by the next list refetch — never data-loss

Event kinds

The backend emits one named SSE event per kind. Each event carries the recipient, the actor, a server timestamp, and any contextual ids the client needs to do scoped invalidations.

kindPublished fromRecipient(s)Carries
notification(currently fan_out doesn't double-push; reserved for future direct-notification flows like mentions)recipient_user_idnotificationId, preview
messagemessages::send_messageevery other participant of the conversationconversationId, messageId, preview (= message body excerpt)
commentcomments::add_comment (after fan_out writes notifications)every fan-out recipient (creator + assignee + prior commenters, minus actor; internal notes filtered to staff only)ticketId, commentId, preview (= comment body excerpt)
ticket_updatedtickets::try_update_ticket_in_tx — status / priority / assignment / tester / reopencreated_by, plus assigned_to on reopen, the new assignee on assignmentticketId, preview (= summary like "Status: open → in_progress")

Publish points compose with fan_out (transactional) on the same call site — the DB row is durable before the broadcast. If the broadcast fails (no subscribers), it's a no-op; if it succeeds, every connected SSE client for that recipient gets the event without a second DB round-trip.

FE consumer + scoped invalidation

hooks/use-realtime.ts subscribes once at the root layout (next to the heartbeat hook) and dispatches every event to TanStack Query as a targeted invalidation. The mapping:

Event kindInvalidated keys
notification['notifications']
message['messages', conversationId], ['conversation', conversationId], ['conversations'], ['notifications']
comment['comments', ticketId], ['timeline', ticketId], ['ticket', ticketId], ['tickets'], ['notifications']
ticket_updated['ticket', ticketId], ['timeline', ticketId], ['tickets'], ['my-dashboard'], ['notifications']

The FE treats SSE as a cache invalidation hint — it never tries to reconcile state in-memory from the event payload alone. After invalidation, TanStack Query refetches the relevant list / detail and the screen rerenders with server-authoritative data. This means a new agent loading the page from cold storage and an agent receiving a live SSE event end up with byte-identical UI state.

A single RealtimeClient instance (lib/realtime/event-source.ts) holds one EventSource regardless of how many hooks / components subscribe — the bus is shared via a process-local listener registry. The connection auto-opens on first subscribe and auto-closes when the last listener unsubscribes (e.g. on sign-out).

In-app toast + browser web push

components/realtime-toast.tsx is mounted next to useRealtime() at the root layout and renders a slim banner in the top-right (max 3 visible, 5s TTL each) for every event whose actorId is not the viewer. Tap-to-navigate: comment → ticket detail, message → conversation, ticket_updated → ticket detail.

When the browser tab is backgrounded (document.visibilityState === 'hidden'), the toast also fires a native Notification. Permission is requested contextually on the first off-tab event — not on sign-in — so users opt in at the moment they'd benefit from it. Clicking the OS notification focuses the tab. There's no service worker yet, so the prompt only works while the tab is alive (i.e. backgrounded but not closed); a true background-push follow-up with a service worker + VAPID is on the deferred list.

Polling fallback

Native (iOS / Android) and any web client where the SSE pipe drops permanently fall back to TanStack Query refetchInterval:

SurfaceIntervalHook
Open conversation messages5shooks/use-messages.ts::useMessages
Conversations list15suseConversations
Bell unread count + list15shooks/use-notifications.ts
Ticket detail / liston-demand + SSEn/a
Dashboard summaryon-demand + SSEn/a

The intervals are safe to leave on even when SSE is connected — the cache is keyed by query so an SSE-driven invalidation and a poll-driven refetch resolve to the same network request and TanStack dedupes.

Presence

Real-time presence intentionally stays heartbeat-only — SSE handles message / comment / ticket pushes, but presence updates are too noisy (1 row write per 30s per online user) to justify pushing through the broadcast bus. Phase 2 ships:

  1. Frontend hooks/use-presence.ts runs a 30s interval POST /me/presence while the app is foregrounded, pauses on AppState !== 'active', and fires one immediate heartbeat when the app returns to foreground.
  2. The handler is a single statement: UPDATE users SET presence = 'online', last_seen_at = now() WHERE id = $1.
  3. The read side computes presence from last_seen_at via users::effective_presence (src/users.rs):
    • last_seen_at IS NULLoffline
    • < 90s ago → online
    • < 5 min ago → away
    • else → offline

There is no background sweeper — staleness is computed at read time so users are never "stuck online" after a client crash.

Tester, SLA timestamps & comment sender roles

Migration 202605100003_extend_legacy_fields.sql closes the field-parity gap with AIQLick's support module. These are first-class columns on every ticket / comment now, native creates use them too — not just imported rows.

Status workflow with test

test is a distinct workflow step (not a synonym for in_progress) where an agent has shipped a candidate fix and a tester needs to verify it. Carried over from AIQLick's "Ready for Test" / READY_FOR_RETEST column — in the new system it appears as a magenta chip in the status pickers and ticket badge.

Tester (tester_id)

  • Optional tester_id FK on ticketsseparate from assigned_to_id so the agent who works the fix is not the person who signs it off.
  • Set / changed / cleared via PATCH /tickets/{id} with testerId (null clears it). Audit row written with action = ticket.tester_changed; timeline gets a tester_changed event.
  • Frontend renders a "Tester" row in the ticket detail metadata card whenever set.

SLA timestamps

The backend snaps three timestamps as side effects of normal mutations — no separate API call needed:

ColumnSet whenSticky?
first_response_atFirst time a staff user posts a non-internal comment on a ticket. Computed inside the same add_comment transaction.Yes — only set once per ticket
resolved_atFirst transition status -> 'resolved'. Computed inside try_update_ticket_in_tx.Yes — surviving subsequent reopens / re-resolves
closed_atEvery transition status -> 'closed'.No — resets so the latest closure is queryable

archived_at, deleted_at, deleted_by_id are populated by the legacy importer for migrated rows and are reserved for a future archive/soft-delete API on the new product.

The frontend renders the timestamps as a "First response / Resolved / Closed" block in the ticket detail header whenever any of them are non-null.

Comment sender_role

Every comment carries one of three sender roles:

RolePosted byWhere it shows
userRequester (non-staff).Plain "User" badge
supportStaff (super_admin / admin / team_lead / agent). Includes internal notes."Support" badge with role accent
systemBackend-emitted. Currently used only by the legacy importer to back-fill events the AIQLick support system surfaced as system-authored messages."System" badge with distinct tinted background

POST /tickets/{id}/comments derives the role from the actor; clients never set it. NULL is tolerated (treated as "unknown" by the FE) so legacy rows that pre-date this column degrade gracefully.

Edit / delete tombstones

comments.is_edited, comments.is_deleted, comments.deleted_at replace the AIQLick-era convention of mutating the body string with [EDITED] / [DELETED] markers. The body itself is preserved as authored, the chips render as pill-shaped overlays, and deleted comments are dimmed in the FE list view. Audit log entries (comment.edited, comment.deleted) reference the comment id and the actor — the body change is not recorded for privacy reasons.

The mutation API for editing / deleting an existing comment is not yet wired — these columns are populated only by the legacy importer at the moment, but the schema is shipped so the FE can render the chips and a future PATCH/DELETE endpoint can land without another migration.

S3 attachments

Bytes never traverse the API server. The backend hands out presigned S3 URLs for both upload and read.

ConstantValue
Bucket (dev / prod)aiqlick-support-attachments-dev / aiqlick-support-attachments-prod
Regioneu-north-1
Public accessBlock all (no public ACLs, no public policy)
CORS allowed originsdev-support.aiqlick.com, support.aiqlick.com, localhost:4000, localhost:8081
Allowed methodsPUT, GET, HEAD
MIME allow-listimage/*, video/mp4, application/pdf
Max size25 MB
PUT TTL5 min
GET TTL1 hour (re-issued on every render)
Object key formatuploads/{ticketId}/{attachmentId}-{sanitized-filename}
Off-bucket URL on POST /tickets/{id}/comments422 validation_failed (caller would need to phish their way past the allow-list)

The ECS task role support-backend-rust-task-role has an inline policy granting s3:PutObject / s3:GetObject / s3:DeleteObject scoped to those two bucket ARNs only. The github-actions-role was updated with iam:PassRole on the new task role so CI can register task definitions that reference it.

Repository layout

support-app/                              support-backend-rust/
+-- app/ +-- migrations/
| +-- _layout.tsx (heartbeat wiring) | +-- 202605050001_create_support_schema.sql
| +-- (tabs)/index|tickets|messages... | +-- 202605060001_review_fixes.sql
| +-- ticket/[id].tsx | +-- 202605100001_phase2_notifications_dms_presence.sql
| +-- messages/[id].tsx +-- src/
+-- components/ | +-- app.rs (Router)
| +-- comment-composer.tsx | +-- attachments.rs (S3 presign)
| +-- notifications-sheet.tsx | +-- audit.rs
+-- hooks/ | +-- auth.rs (sessions, rate-limit)
| +-- use-presence.ts (NEW) | +-- comments.rs
| +-- use-session.ts | +-- dashboards.rs
| +-- use-tickets.ts | +-- messages.rs (DMs)
+-- lib/ | +-- notifications.rs (fan-out)
| +-- api/ (tickets http + zod) | +-- presence.rs (heartbeat)
| +-- auth/ (auth http + zod) | +-- tickets.rs
| +-- messages/ (DM http + zod) | +-- timeline.rs
| +-- notifications/ (bell http + zod) | +-- users.rs (effective_presence)
| +-- attachments/upload.ts | +-- validation.rs
| +-- http/request.ts (shared helper) +-- tests/api.rs (~28 integration cases)
+-- e2e/ (Playwright suite) +-- scripts/e2e.sh (29-step bash smoke)
+-- backend-spec/ +-- deploy/, Dockerfile (cargo-chef + trixie-slim)
| +-- openapi.yaml +-- .github/workflows/
| +-- CHANGES.md +-- deploy-dev.yml, deploy-main.yml
+-- playwright.config.ts
+-- amplify.yml
+-- .github/workflows/
+-- ci.yml (lint+typecheck)
+-- e2e-live.yml (Playwright vs deployed dev FE)

Deploy pipeline

StageWhat happens
cargo test (CI)Spins up postgres:16 service, runs #[sqlx::test] suite
Image buildcargo-chef recipe cache → release binary → debian:trixie-slim runtime (GLIBC >= 2.38 required by aws-lc-rs)
Task def registrationjq splices in taskRoleArn, S3_ATTACHMENTS_BUCKET, AWS_REGION, image URI, secrets
Service rollout--force-new-deployment swaps to the new revision
MigrationsRun on container startup — never cargo run -- migrate ahead of time
Bash e2eRuns against the live ALB URL post-deploy; failures fail the workflow
Playwright e2eRuns after Amplify build settles, against the deployed FE in live mode

main mirrors dev with the support-backend-rust-prod cluster + aiqlick-support-attachments-prod bucket. Per workspace policy, never push directly to main — promote dev &rarr; main via PR.

Mock vs live mode

Frontend respects EXPO_PUBLIC_API_MODE:

ModeAPI client picksWhen to use
mock (default)lib/{api,auth,messages,notifications}/mock.ts — in-memory storeLocal dev without a backend, demos, screenshots
livelib/{api,auth,messages,notifications}/http.ts — real fetchesDeployed Amplify (set on the dev branch as of 2026-05-10)

Amplify env vars (Console → Hosting → Environment variables) — both branches are now live post-cutover:

BranchEXPO_PUBLIC_API_MODEEXPO_PUBLIC_API_BASE_URL
devlivehttps://api-dev-support.aiqlick.com
mainlivehttps://api-support.aiqlick.com

The four HTTP clients all delegate to a single lib/http/request.ts helper that handles AsyncStorage token loading, error-shape unwrapping, and bearer-auth header injection. Adding a new endpoint is a 5-line zod schema + handler — no fetch boilerplate to copy.

Web-only: hydration gating

Amplify static export pre-renders each route to HTML at build time. Because the whole app depends on browser-only state (useColorScheme, useWindowDimensions, useUser reading AsyncStorage), the static shell can never match what the client renders on first paint — React would log error #418 on every page load and fall back to a full client re-render.

hooks/use-has-mounted.ts plus a guard in app/_layout.tsx gate AuthGate + Stack behind mounted === true. Server emits an empty <body>, client hydrates the empty shell, then renders the real tree on useEffect. Trade-off accepted because the entire app is auth-gated — SEO is moot when every route requires a session.

Legacy HTML decoding

The AIQLick importer brought migrated tickets and comments across with their original TipTap HTML embedded in description (182/195 tickets) and body (45/851 comments). lib/format/html.ts is a dependency-free helper that converts them to readable plain text:

InputOutput
</p>, </div>, </hN>, </pre>, </blockquote>\n\n (paragraph break)
<br>, <br/>, <br />\n
<li> (bullet)
</li>, </ul>, </ol>\n
Other tagsstripped
&amp;, &lt;, &gt;, &quot;, &apos;, &#39;, &nbsp;, &hellip;, &mdash;, &ndash;, &copy;, &reg;, &trade;decoded
Numeric &#NN; / &#xNN;decoded via String.fromCodePoint
Multiple blank linescollapsed to 2

Wired into ticket detail description, comment list, and the ticket-card list snippet. Plain-text writes (anything without < or &) hit a fast path and pass through unchanged, so native (non-imported) content has zero overhead.

End-to-end test coverage

The plan called for 100% e2e on live. Two independent suites exercise the deployed dev environment on every push to dev:

Backend bash suite (29 steps, runs in CI after deploy)

scripts/e2e.sh — default BASE_URL=https://api-dev-support.aiqlick.com. Steps 1–18 are the original MVP smoke; steps 19–29 cover phase 2:

StepAsserts
19Requester sees a status_change notification from earlier ticket activity
20Unread count >= 1
21POST /me/notifications/{id}/read → 200, count drops
22POST /me/notifications/read-all → count == 0
23POST /me/conversations returns same id on second call (dedup)
24POST /conversations/{id}/messages → 201 + creates a message notification
25POST /conversations/{id}/read adds caller to readBy[], idempotent
26POST /me/presence → 204; /admin/users shows presence: "online"
27Presigned PUT URL targets aiqlick-support-attachments-*
28Comment with off-bucket URL → 422; comment with key-shaped URL → 201
29Sign-out invalidates the token

Frontend Playwright suite

support-app/.github/workflows/e2e-live.yml waits for the Amplify build to settle, then runs yarn e2e against https://dev-support.aiqlick.com with the dev seed credentials. Specs:

FileCoverage
e2e/auth.spec.tsSign-in screen renders; super_admin and agent both land on the dashboard
e2e/tickets.spec.tsAgent sees seeded tickets; requester sees only their own
e2e/comments.spec.tsTicket detail loads with composer and Attach button
e2e/notifications.spec.tsBell sheet opens; agent's bell has populated entries from the bash run
e2e/messages.spec.tsMessages tab + conversation detail open without crashes
e2e/presence.spec.tsNetwork listener catches the POST /me/presence heartbeat within 5s of sign-in

Together they prove the FE wires through to the live Rust backend and that the backend honors the contract under real ALB → ECS → RDS conditions.

Key files

ConcernFile
Phase-2 migrationsupport-backend-rust/migrations/202605100001_phase2_notifications_dms_presence.sql
Legacy import migration #1 (provenance)support-backend-rust/migrations/202605100002_legacy_import_support.sql
Legacy import migration #2 (field parity)support-backend-rust/migrations/202605100003_extend_legacy_fields.sql
Legacy importer binarysupport-backend-rust/src/bin/support-import.rs + src/import/
Import runbooksupport-backend-rust/scripts/run-import-prod.sh
Notifications + fan-outsupport-backend-rust/src/notifications.rs
Conversations + DMssupport-backend-rust/src/messages.rs
Presence heartbeatsupport-backend-rust/src/presence.rs
SSE event bus + handlersupport-backend-rust/src/realtime.rs
SSE consumer (web)support-app/lib/realtime/event-source.ts
Realtime → TanStack Query gluesupport-app/hooks/use-realtime.ts
In-app toast + browser web pushsupport-app/components/realtime-toast.tsx
S3 presignsupport-backend-rust/src/attachments.rs
User DTO + effective_presencesupport-backend-rust/src/users.rs
Tickets (SLA + tester auto-snap)support-backend-rust/src/tickets.rs
Comments (sender_role derive + first_response_at)support-backend-rust/src/comments.rs
Frontend HTTP layersupport-app/lib/http/request.ts
Heartbeat hooksupport-app/hooks/use-presence.ts
Hydration mount gatesupport-app/hooks/use-has-mounted.ts + app/_layout.tsx
Legacy HTML → plain textsupport-app/lib/format/html.ts
Attachment upload helpersupport-app/lib/attachments/upload.ts
OpenAPI source of truthsupport-app/backend-spec/openapi.yaml
Bash live e2esupport-backend-rust/scripts/e2e.sh
Playwright suitesupport-app/e2e/

Legacy AIQLick import (one-shot, 2026-05-10)

When this product replaced the AIQLick platform's support module as the primary intake surface, a one-shot importer migrated AIQLick's SupportTicket / TicketMessage / TicketAttachment / TicketAuditLog data into the prod RDS. The importer lives in support-backend-rust/src/bin/support-import.rs + src/import/ and is re-runnable.

What was migratedSource countTarget outcome
Support participants (creators, assignees, testers, commenters, agents, super-admins)87 inserted; 1 merged into bootstrap usr_super by email collision
SupportTicket rows (incl. soft-deleted/archived)195195
TicketMessage rows (incl. internal notes, edited/deleted)851 (across 171 tickets)851 (across 171 tickets — 24 source tickets had no replies). 84 attachment-only messages get a [Attachment only] body placeholder; edited / soft-deleted state lives on dedicated is_edited/is_deleted columns (no more [EDITED] / [DELETED] body prefixes).
TicketAttachment objects copied to S3142142
TicketAuditLog rows2,6922,692 + 1,304 import-provenance rows = 3,996 *.imported audit entries
Setup tokens issuedn/a7 (one per imported user; usr_super already had a password)

Schema additions

The import lands across two migrations:

migrations/202605100002_legacy_import_support.sql — provenance + relaxed length caps:

  • tickets.ticket_number TEXT UNIQUE NULL — preserves AIQLick's customer-facing SUP-00001 identifiers (referenced in old emails). Native creates leave it NULL; only imported rows have it set.
  • tickets.legacy_archived BOOLEAN NOT NULL DEFAULT FALSE and tickets.legacy_deleted BOOLEAN NOT NULL DEFAULT FALSE — both fold into status='closed' but the booleans stay queryable so reports can distinguish "archived in AIQLick" from "deleted in AIQLick".
  • audit_entries.action allow-list extended with ticket.imported, comment.imported, user.imported, attachment.imported, message.imported, audit.imported.
  • timeline_events.kind allow-list extended with imported (one row per imported ticket, used as a provenance marker).
  • Length CHECKs relaxed to fit legacy content: tickets.title 1..500, tickets.description ≤50000, comments.body ≤50000. The application's validation.rs still caps new API writes at the original limits.

migrations/202605100003_extend_legacy_fields.sql — field-level parity (covered in detail under Tester, SLA timestamps & comment sender roles):

  • Adds tickets.tester_id, first_response_at, resolved_at, closed_at, archived_at, deleted_at, deleted_by_id.
  • Adds comments.sender_role (user/support/system), is_edited, is_deleted, deleted_at.
  • Extends tickets.status allow-list with 'test' (so TEST / READY_FOR_RETEST map to a first-class status, not in_progress).
  • Extends timeline_events.kind with tester_changed.
  • Extends audit_entries.action with ticket.tester_changed, ticket.deleted, ticket.archived, comment.edited, comment.deleted.

ID translation

Every legacy AIQLick UUID is mapped deterministically:

fn legacy_id_to_target(prefix: &str, legacy_uuid: &str) -> String {
format!("{}_{}", prefix, legacy_uuid.replace('-', "").to_lowercase())
}

So 19060a45-c16d-435a-b24f-75e97c60f30c becomes usr_19060a45c16d435ab24f75e97c60f30c, and the same input always produces the same output. This makes the importer idempotent — every INSERT uses ON CONFLICT (id) DO UPDATE, so re-running picks up new legacy rows under a later --cutover-at without producing duplicates. Email collisions (e.g. AIQLick super-admin merging into the pre-existing bootstrap usr_super) are detected at runtime and routed to the existing target id, with the legacy id captured in audit details.

Enum mapping

AIQLickRust
SupportTicket.status OPENopen · IN_PROGRESSin_progress · WAITING_ON_CUSTOMERwaiting · TEST / READY_FOR_RETESTin_progress · RESOLVEDresolved · CLOSEDclosed · isDeleted / isArchivedclosed (with legacy_* boolean)tickets.status
TicketPriority LOW/MEDIUM/HIGH/URGENTlow/normal/high/urgenttickets.priority
TicketCategory BILLINGbilling · TECHNICAL/BUG_REPORTbug · FEATURE_REQUESTfeature · GENERAL/ACCOUNTothertickets.category
User.isSuperAdmin = TRUEsuper_admin · support role on UserRoleagent · otherwise → requesterusers.role
TicketMessage.isInternalNotecomments.is_internal (1:1)comments.is_internal

Out of scope for the import

  • AIQLick Notification rows — transient bell entries; the Rust app re-emits its own going forward.
  • AIQLick platform DMs (Conversation / Message) — not tied to support tickets, lives in a separate domain.
  • AIQLick FaqEntry — the Rust app has no FAQ table.
  • AIQLick companyId — this app is single-org. The legacy company affiliation isn't preserved.

Re-running the import

# From a laptop with AWS SSO logged in to Administrator-842697652860:
bash scripts/run-import-prod.sh dry-run all # validate sources/wiring
bash scripts/run-import-prod.sh apply users # phase by phase or...
bash scripts/run-import-prod.sh apply tickets # ... in any order
bash scripts/run-import-prod.sh apply attachments # S3 CopyObject loop
bash scripts/run-import-prod.sh apply setup-tokens > tokens.tsv # one TSV row per user
bash scripts/run-import-prod.sh apply validate # post-import row counts + FK

The script resolves both DATABASE_URLs from Secrets Manager (aiqlick-backend/production and support-backend-rust/production), exports short-lived AWS env credentials via aws configure export-credentials (the Rust SDK doesn't parse our SSO profile config), and cargo build --release && exec the binary. All RDSs share vpc-0aa980c32ac6870d2 and sg-076465841f814c6ea which has 0.0.0.0/0:5432 on inbound, so no SSM tunnel is needed.

Imported users sign in flow

Every imported user has password_hash = NULL until they consume their setup token:

  1. Operator runs apply setup-tokens → binary prints email\tsetup_url\texpires_at per user (default TTL 7 days).
  2. Operator emails the URL out (no SES wiring inside the binary; pipe TSV to a separate sender).
  3. User clicks the link, lands on /set-password?token=..., sets a password.
  4. From then on they sign in normally with email + new password.

POST /admin/users/{id}/issue-setup-token re-issues a token if any user loses theirs; the previous token is invalidated.

Out of scope (deferred)

FeatureStatus
expo-notifications push delivery + POST /me/devices (APNS / FCM)Post-MVP. Settings has a "Push for urgent tickets" toggle today (local only). When wired, web SSE + browser Notification covers desktop while expo-notifications + Expo Push Service covers iOS / Android
Native (iOS / Android) SSE via react-native-sseWeb is on SSE, native still polls. Pulling in react-native-sse is the natural follow-up; until then useMessages / useNotifications poll at 5s / 15s on mobile
True background web push (service worker + VAPID + Push API)Today's Notification API only fires when the tab is alive (backgrounded OK, fully closed not). A service worker subscribed to a VAPID push manager is the path to deliver while the tab is closed — deferred until a measurable user need lands
WebSocket presence transportHeartbeat-only by design (read-side effective_presence is computed from last_seen_at). Pushing presence over SSE was rejected because of write amplification (1 broadcast per 30s per user) for low UI value
SSO sign-in (/api/auth/sso/{provider})Specced in OpenAPI, not built
/api-keys/* admin UISpecced, not built
/admin/dashboard / /admin/tickets[/{id}] super-admin override routesRole-aware /me/dashboard + /tickets covers the use case today; dedicated override path with mandatory reason audit deferred
CloudFront in front of the S3 bucketDefer until usage justifies edge caching; presigned-GET works fine for current load
SES for invite/password-reset emailBackend currently no-ops the invite email per spec; SES wire-up is the natural managed-AWS path when we land it
  • Support System Overview — the AIQLick platform's support module (different system, different DB, different deploy)
  • AWS — ECS — the same Fargate cluster pattern used by support-backend-rust
  • AWS — Amplify — how the Expo web build deploys