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.
- 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) andsupport-backend-rust(backend).
Architecture
Live URLs
| Surface | Dev | Prod |
|---|---|---|
| Frontend (Amplify) | https://dev-support.aiqlick.com | https://support.aiqlick.com |
| Backend API | https://api-dev-support.aiqlick.com | https://api-support.aiqlick.com |
/health probe | https://api-dev-support.aiqlick.com/health | https://api-support.aiqlick.com/health |
| Amplify app id | d3nd2rf3fst1x9 | d3nd2rf3fst1x9 |
| ECS cluster | support-backend-rust | support-backend-rust-prod |
| ECS service | support-backend-rust | support-backend-rust-prod |
| ALB | support-backend-alb (shared, host-routes by Host:) | same |
| ECR repo | support-backend-rust | support-backend-rust |
| S3 attachments bucket | aiqlick-support-attachments-dev | aiqlick-support-attachments-prod |
| Postgres | dedicated 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
| Layer | Choice |
|---|---|
| Frontend | Expo 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 |
| Backend | Rust 1.x, Axum 0.8, SQLx 0.8 (Postgres, runtime-tokio-rustls), tokio, argon2/sha2 for auth crypto, tracing-subscriber JSON logs |
| AWS SDK | aws-config = "1" + aws-sdk-s3 = "1" with the behavior-version-latest feature |
| Auth | Bearer-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 |
| E2E | scripts/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:
| Method | Path | Notes |
|---|---|---|
| GET | /health, /ready, / | Anonymous |
| POST | /api/auth/sign-in/email | Rate-limited 5/15m per IP+email |
| POST | /api/auth/sign-out | Revokes session |
| GET | /api/auth/get-session | |
| POST | /api/auth/set-password | Single-use setup token, FOR UPDATE locked |
| POST | /api/auth/change-password | Revokes other sessions |
| PATCH | /me | Email change requires currentPassword re-auth gate |
| GET | /me/dashboard | Rich MyDashboard shape (charts, SLA buckets, assignee workload) |
| GET | /me/dashboard-summary | Cards-and-recent-tickets summary |
| POST | /me/presence | Heartbeat — updates users.last_seen_at = now() |
| GET | /me/stream | Server-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/notifications | Paginated, sorted at DESC |
| GET | /me/notifications/unread-count | Hits the partial index idx_notifications_user_unread; polled every 30s by the bell |
| POST | /me/notifications/{id}/read | Owner-gated; 404 if not the caller's |
| POST | /me/notifications/read-all | |
| GET / POST | /me/conversations | Idempotent start — second call with same participantId returns existing convo |
| GET | /conversations/{id} | 404 if caller not a participant |
| GET / POST | /conversations/{id}/messages | Send fans out a message notification to the other participant in the same tx |
| POST | /conversations/{id}/read | Bulk INSERT into message_reads ON CONFLICT DO NOTHING (idempotent) |
| GET / POST | /tickets | List supports status / priority / category / createdById / assigned / assignedToId / q / sort |
| POST | /tickets/bulk-update | Up 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}/comments | Off-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}/attachments | S3 presigned PUT (5 min); response includes the canonical key |
| GET | /tickets/{id}/timeline | 6 event kinds: created / status_changed / priority_changed / assignment_changed / tester_changed / imported |
| GET / POST | /admin/users | List / invite (returns one-time setup token) |
| PATCH | /admin/users/{id} | Role / active changes revoke live sessions |
| POST | /admin/users/{id}/issue-setup-token | Re-issue setup token if the original expired |
| GET | /admin/audit-log | Paginated, 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:
| Event | Recipients | Type |
|---|---|---|
| Ticket assigned to user X | X (only if actor ≠ X) | assignment |
| Status changed | created_by (only if actor ≠ created_by) | status_change |
closed / resolved → reopened | assigned_to (in addition to status_change) | reopened |
| Comment posted | created_by ∪ assigned_to ∪ prior commenters, minus actor; internal notes filter requesters | comment |
| Direct message sent | other participant(s) | message |
@user mention (future) | mentioned user | mention |
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
| Aspect | Choice |
|---|---|
| Transport | tokio::sync::broadcast::channel(1024) — in-process, single channel for all events |
| Sharding | None. 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 capacity | 1024 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-alive | 15s ping comment so ALB / NAT idle reapers don't kill the long-lived connection |
| Reconnection | Browser 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 truth | Every 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.
kind | Published from | Recipient(s) | Carries |
|---|---|---|---|
notification | (currently fan_out doesn't double-push; reserved for future direct-notification flows like mentions) | recipient_user_id | notificationId, preview |
message | messages::send_message | every other participant of the conversation | conversationId, messageId, preview (= message body excerpt) |
comment | comments::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_updated | tickets::try_update_ticket_in_tx — status / priority / assignment / tester / reopen | created_by, plus assigned_to on reopen, the new assignee on assignment | ticketId, 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 kind | Invalidated 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:
| Surface | Interval | Hook |
|---|---|---|
| Open conversation messages | 5s | hooks/use-messages.ts::useMessages |
| Conversations list | 15s | useConversations |
| Bell unread count + list | 15s | hooks/use-notifications.ts |
| Ticket detail / list | on-demand + SSE | n/a |
| Dashboard summary | on-demand + SSE | n/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:
- Frontend
hooks/use-presence.tsruns a 30s intervalPOST /me/presencewhile the app is foregrounded, pauses onAppState !== 'active', and fires one immediate heartbeat when the app returns to foreground. - The handler is a single statement:
UPDATE users SET presence = 'online', last_seen_at = now() WHERE id = $1. - The read side computes
presencefromlast_seen_atviausers::effective_presence(src/users.rs):last_seen_at IS NULL→offline- < 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_idFK ontickets— separate fromassigned_to_idso the agent who works the fix is not the person who signs it off. - Set / changed / cleared via
PATCH /tickets/{id}withtesterId(nullclears it). Audit row written withaction = ticket.tester_changed; timeline gets atester_changedevent. - 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:
| Column | Set when | Sticky? |
|---|---|---|
first_response_at | First 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_at | First transition status -> 'resolved'. Computed inside try_update_ticket_in_tx. | Yes — surviving subsequent reopens / re-resolves |
closed_at | Every 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:
| Role | Posted by | Where it shows |
|---|---|---|
user | Requester (non-staff). | Plain "User" badge |
support | Staff (super_admin / admin / team_lead / agent). Includes internal notes. | "Support" badge with role accent |
system | Backend-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.
| Constant | Value |
|---|---|
| Bucket (dev / prod) | aiqlick-support-attachments-dev / aiqlick-support-attachments-prod |
| Region | eu-north-1 |
| Public access | Block all (no public ACLs, no public policy) |
| CORS allowed origins | dev-support.aiqlick.com, support.aiqlick.com, localhost:4000, localhost:8081 |
| Allowed methods | PUT, GET, HEAD |
| MIME allow-list | image/*, video/mp4, application/pdf |
| Max size | 25 MB |
| PUT TTL | 5 min |
| GET TTL | 1 hour (re-issued on every render) |
| Object key format | uploads/{ticketId}/{attachmentId}-{sanitized-filename} |
Off-bucket URL on POST /tickets/{id}/comments | 422 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
| Stage | What happens |
|---|---|
cargo test (CI) | Spins up postgres:16 service, runs #[sqlx::test] suite |
| Image build | cargo-chef recipe cache → release binary → debian:trixie-slim runtime (GLIBC >= 2.38 required by aws-lc-rs) |
| Task def registration | jq splices in taskRoleArn, S3_ATTACHMENTS_BUCKET, AWS_REGION, image URI, secrets |
| Service rollout | --force-new-deployment swaps to the new revision |
| Migrations | Run on container startup — never cargo run -- migrate ahead of time |
| Bash e2e | Runs against the live ALB URL post-deploy; failures fail the workflow |
| Playwright e2e | Runs 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 → main via PR.
Mock vs live mode
Frontend respects EXPO_PUBLIC_API_MODE:
| Mode | API client picks | When to use |
|---|---|---|
mock (default) | lib/{api,auth,messages,notifications}/mock.ts — in-memory store | Local dev without a backend, demos, screenshots |
live | lib/{api,auth,messages,notifications}/http.ts — real fetches | Deployed 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:
| Branch | EXPO_PUBLIC_API_MODE | EXPO_PUBLIC_API_BASE_URL |
|---|---|---|
dev | live | https://api-dev-support.aiqlick.com |
main | live | https://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:
| Input | Output |
|---|---|
</p>, </div>, </hN>, </pre>, </blockquote> | \n\n (paragraph break) |
<br>, <br/>, <br /> | \n |
<li> | • (bullet) |
</li>, </ul>, </ol> | \n |
| Other tags | stripped |
&, <, >, ", ', ', , …, —, –, ©, ®, ™ | decoded |
Numeric &#NN; / &#xNN; | decoded via String.fromCodePoint |
| Multiple blank lines | collapsed 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.
Frontend: ticket board, filters & detail
The Tickets surface ships a full Kanban workflow alongside the classic list. It lives on the Tickets tab (app/(tabs)/tickets.tsx) as a Kanban / Normal view toggle — there is no separate route; the board and the list share one FilterState so they never drift.
The board and its cards are the shipped "test design" / QA workflow (the test lane is the Ready-for-Test column from the status workflow). It is a view mode inside the Tickets tab, not a distinct tab or screen. Components: components/kanban-board.tsx, kanban-card.tsx, kanban-status.tsx, kanban-context.ts, and the ticket-detail ticket-details-panel.tsx.
Kanban board
| Capability | Behavior |
|---|---|
| Status lanes | One lane per visible TicketStatus. Lanes are fixed 320px on tablet/desktop; on phones a lane fills ~84% so the next lane peeks, signalling sideways scroll. Colored lane header shows the count + quick-add |
| Drag-to-move | Staff-only (isStaff). A PanResponder claims the gesture only after ~6px of movement, so a plain tap still opens the ticket and small drags don't hijack column scroll. A rotated floating clone tracks the pointer; dropping on a lane fires updateTicket({ status }). Edge hot-zones auto-scroll the board so off-screen lanes are reachable on narrow screens |
| Per-lane quick-add "+" | Staff-only. Opens a New-ticket sheet (title / description / priority / category), creates the ticket, then moves it into that lane's status |
| Jira-style status dropdown | Tapping a card's colored StatusPill opens a "Move to…" sheet (StatusPicker) covering every board status — change lane without dragging |
| Auto-hide empty lanes | A lane renders only if it holds tickets or is open. A Lanes bottom-sheet lets you force any lane on/off or reset to Auto |
| Board / List toggle | The Kanban / Normal pill switches views. Normal view adds a 1–3 column card/list ViewSwitcher and bulk-select (Resolve / Close / Assign me) |
Filters & sort
Both views write the same shared FilterState (lib/tickets/filters.ts), surfaced through three chrome variants:
| Surface | Where | Fields |
|---|---|---|
Desktop sidebar (FilterSidebar) | Normal view, ≥1024px | Status · Priority · Category · Assignee · Tester · Time · Sort · Tags |
| Mobile filter panel | Normal view, phones | Category · Priority · Assignee · Tester · Tags · Sort |
Kanban bottom-sheet (KanbanFiltersSheet) | Kanban view | Sort · Priority · Category · Assignee · Tester · Sections (tags) |
Dismissible active-filter pills render on-page in both views (activeFilterChips / chipDismissPatch) with a Clear all shortcut, so an applied-but-collapsed filter is never invisible. A count badge on the Filters toggle mirrors the number of narrowing filters.
Tester filter (PR #70)
A Tester filter was added everywhere the Assignee filter appears (desktop sidebar, mobile panel, kanban sheet), backed by testerScope. Tester is a first-class ticket field (tester_id, see above) rendered on the kanban, list, and compact cards — KanbanCardBody always shows a Tester row (with an "Unassigned" placeholder so every card is the same height).
The backend can only AND assignedToId and testerId, so the frontend strips both from the server query and filters client-side to get OR semantics — show tickets where the assignee or the tester matches. Within each dimension it's also OR (any selected person). When a people filter is active, the list auto-loads all pages so the client-side OR sees every candidate row (otherwise a match on page 2 would be hidden).
Ticket-detail redesign
The ticket detail (app/ticket/[id].tsx, via components/ticket-details-panel.tsx) is a two-column layout on desktop — metadata/timeline beside the conversation — with chat-style comment bubbles. It reuses the tester / SLA-timestamp / sender_role fields documented under Tester, SLA timestamps & comment sender roles.
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:
| Step | Asserts |
|---|---|
| 19 | Requester sees a status_change notification from earlier ticket activity |
| 20 | Unread count >= 1 |
| 21 | POST /me/notifications/{id}/read → 200, count drops |
| 22 | POST /me/notifications/read-all → count == 0 |
| 23 | POST /me/conversations returns same id on second call (dedup) |
| 24 | POST /conversations/{id}/messages → 201 + creates a message notification |
| 25 | POST /conversations/{id}/read adds caller to readBy[], idempotent |
| 26 | POST /me/presence → 204; /admin/users shows presence: "online" |
| 27 | Presigned PUT URL targets aiqlick-support-attachments-* |
| 28 | Comment with off-bucket URL → 422; comment with key-shaped URL → 201 |
| 29 | Sign-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:
| File | Coverage |
|---|---|
e2e/auth.spec.ts | Sign-in screen renders; super_admin and agent both land on the dashboard |
e2e/tickets.spec.ts | Agent sees seeded tickets; requester sees only their own |
e2e/comments.spec.ts | Ticket detail loads with composer and Attach button |
e2e/notifications.spec.ts | Bell sheet opens; agent's bell has populated entries from the bash run |
e2e/messages.spec.ts | Messages tab + conversation detail open without crashes |
e2e/presence.spec.ts | Network 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
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 migrated | Source count | Target outcome |
|---|---|---|
| Support participants (creators, assignees, testers, commenters, agents, super-admins) | 8 | 7 inserted; 1 merged into bootstrap usr_super by email collision |
SupportTicket rows (incl. soft-deleted/archived) | 195 | 195 |
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 S3 | 142 | 142 |
TicketAuditLog rows | 2,692 | 2,692 + 1,304 import-provenance rows = 3,996 *.imported audit entries |
| Setup tokens issued | n/a | 7 (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-facingSUP-00001identifiers (referenced in old emails). Native creates leave it NULL; only imported rows have it set.tickets.legacy_archived BOOLEAN NOT NULL DEFAULT FALSEandtickets.legacy_deleted BOOLEAN NOT NULL DEFAULT FALSE— both fold intostatus='closed'but the booleans stay queryable so reports can distinguish "archived in AIQLick" from "deleted in AIQLick".audit_entries.actionallow-list extended withticket.imported,comment.imported,user.imported,attachment.imported,message.imported,audit.imported.timeline_events.kindallow-list extended withimported(one row per imported ticket, used as a provenance marker).- Length CHECKs relaxed to fit legacy content:
tickets.title1..500,tickets.description≤50000,comments.body≤50000. The application'svalidation.rsstill 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.statusallow-list with'test'(soTEST/READY_FOR_RETESTmap to a first-class status, notin_progress). - Extends
timeline_events.kindwithtester_changed. - Extends
audit_entries.actionwithticket.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
| AIQLick | Rust |
|---|---|
SupportTicket.status OPEN → open · IN_PROGRESS → in_progress · WAITING_ON_CUSTOMER → waiting · TEST / READY_FOR_RETEST → in_progress · RESOLVED → resolved · CLOSED → closed · isDeleted / isArchived → closed (with legacy_* boolean) | tickets.status |
TicketPriority LOW/MEDIUM/HIGH/URGENT → low/normal/high/urgent | tickets.priority |
TicketCategory BILLING → billing · TECHNICAL/BUG_REPORT → bug · FEATURE_REQUEST → feature · GENERAL/ACCOUNT → other | tickets.category |
User.isSuperAdmin = TRUE → super_admin · support role on UserRole → agent · otherwise → requester | users.role |
TicketMessage.isInternalNote → comments.is_internal (1:1) | comments.is_internal |
Out of scope for the import
- AIQLick
Notificationrows — 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:
- Operator runs
apply setup-tokens→ binary printsemail\tsetup_url\texpires_atper user (default TTL 7 days). - Operator emails the URL out (no SES wiring inside the binary; pipe TSV to a separate sender).
- User clicks the link, lands on
/set-password?token=..., sets a password. - 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)
| Feature | Status |
|---|---|
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-sse | Web 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 transport | Heartbeat-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 UI | Specced, not built |
/admin/dashboard / /admin/tickets[/{id}] super-admin override routes | Role-aware /me/dashboard + /tickets covers the use case today; dedicated override path with mandatory reason audit deferred |
| CloudFront in front of the S3 bucket | Defer until usage justifies edge caching; presigned-GET works fine for current load |
| SES for invite/password-reset email | Backend currently no-ops the invite email per spec; SES wire-up is the natural managed-AWS path when we land it |
Related
- 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