CODEX // oaoisme wiki

§ unswayed-backend

Engagement extras (Phase 11)

updated 2026-06-11

Engagement extras (Phase 11)

Phase 11 is the engagement layer — three small surfaces that bolt onto the platform once the follow graph (Phase 7) and the queue + mailer (Phase 1) exist: public support tickets (§10), app feedback (§27), and the talent network (§16). They live in src/support/, src/feedback/, and src/talent-network/, and they are deliberately leaf-shaped: only two new tables (support_tickets, the singular legacy feedback), and the talent network adds zero — it is a pure read over user_followers.

Support tickets — persist, answer, then mail (§10)

POST /api/support/ticket is one of the few fully public routes (no bearer token; the global per-IP throttle still applies). It validates name/email (≤255), an optional subject (≤255), and message, writes a SupportTicket row with status='open', and answers 201 {data:{ticket_id}} the instant the insert commits.

Why the ordering matters. The legacy controller sent the notification email inline, inside the same try/catch as the insert. A transient SMTP hiccup therefore returned a 500 for a ticket that was already saved — the caller retried, support got duplicates, and nobody was sure what happened. Here the email is a queued job (send-support-ticket-email, attempts:3 + backoff + a dead-letter queue) enqueued after the row exists, and even the enqueue is wrapped: a queue outage is logged and swallowed, never altering the HTTP response. The worker owns delivery; the request owns persistence.

The email itself is frozen behaviour: TO contact@rallitechnologies.online, CC kemdi.ifeanyi@rallitechnologies.online (the mailer's MailMessage grew an optional cc for this — the first mailable that needed one), subject 🎫 New Support Ticket #{id} — {subject|'General Inquiry'}, from-name "Unswayed". A failed insert is the only 500, with the exact legacy string. There is no status-transition endpoint — the contract has none, so status stays the 'open' the create writes.

One subtle validation choice: validator.js normally rejects emails longer than 254 chars outright, which would have made the legacy "255 is valid" boundary unreachable — so the DTO uses @IsEmail({ ignore_max_length: true }) and lets @MaxLength(255) be the single binding length rule, exactly like Laravel's email|max:255 pair behaved.

Feedback — the envelope that breaks the rules on purpose (§27)

Everything in this API answers {status, message, data} — except two endpoints, and GET /api/feedback is one of them. Legacy returned FeedbackResource::collection($query->paginate(20)) directly, which serializes as the bare Laravel resource-collection body: {data: [...], links: {first,last,prev,next}, meta: {...}} — no status, no message. That shape is frozen.

Reproducing it means escaping the global response interceptor. The handler takes @Res() and writes the body itself — the same precedent the §45 Stripe webhook set (the only other bypass). The body is built by laravelPaginate(...), the byte-faithful Laravel paginator Phase 10 wrote for subscription history, now promoted to src/common/pagination/laravel-paginator.ts since two domains share the dialect (its sibling requestPath moved to src/common/helpers/). Twenty per page, newest first, ?page=N links, the full meta.links link-collection with « Previous/Next » labels.

POST /api/feedback (normal envelope, 201 Feedback submitted successfully.) demonstrates a pattern worth knowing: snapshot-on-create, live-on-read. The create snapshots the author's user_id, name (trimmed first + ' ' + last — employer "names" derive from the company-name split, the Phase-3 convention), email, and request ip onto the row, denormalized exactly like legacy. But the resource ignores the snapshot and reads the live user relation — so the feed shows current names, while the row preserves who-said-what at submission time. When is_anonymous is true or the author has since deleted their account (the FK is SET NULL), the user block masks to exactly {name: 'Anonymous User'} — one key, no id, no photo. created_at serializes as UTC Y-m-d H:i:s (Carbon's toDateTimeString()), not ISO-8601.

is_anonymous accepts Laravel's boolean set — true/false/1/0/'1'/'0' — because mobile clients send the string forms; it defaults to false.

Talent network — four windows onto one graph (§16)

GET /search, /recommendation, /networks, /friends are read-only, authenticated projections over the Phase-7 UserFollower table (soft-deleted rows = un-follows, dead everywhere). All four filter to completed + active accounts and exclude the caller. What makes them interesting is that the response envelopes are deliberately non-uniform — each endpoint froze a different legacy shape:

Endpoint data shape followers_count on rows?
/search {users: [...]} no (legacy ran no withCount)
/recommendation {users: [...]} (≤12, random, unfollowed) yes
/networks {followers: [...], followings: [...]} yes
/friends the bare arraydata: [...], no wrapper key no

Two more frozen quirks: /networks runs one shared limit/page cursor across both lists (page 2 advances followers and followings together — the legacy controller fed one paginator cursor to two queries), and neither paginated endpoint emits a pagination meta block (legacy embedded the paginator inside a JSON array, which serialized items only).

The performance rewrite under the frozen surface. Legacy's SearchNetworkResource called isFollowed() — a database query — per serialized row, and /search was an unbounded ->get(). The rebuild resolves the caller's followed-id set once per request (every row's isFollowed is a Set lookup), batch-loads all followers_count values in one groupBy, replaces /friends' two-plucks-plus-PHP-array_intersect with a single mutual-follow query (following.some + followers.some), and caps /search at 100 rows internally (SEARCH_CAP — the data.users key is unchanged). Recommendation's randomness flows through RandomService.int (a Fisher–Yates over candidate ids), so tests can pin a deterministic permutation instead of praying at ORDER BY RANDOM().

Multi-term search (space-split, every term must match, each term ORs across first/middle/last name, username, email) maps the legacy users-table name columns onto this schema's reality: applicant names live on the Applicant profile, employer "names" are the companyName — the same mapping popular companies (Phase 6) established.

Gotchas

  • The §27 GET body has no status key. A contract test pins Object.keys(body) to exactly ['data','links','meta'] — if the interceptor ever swallows that route again, the suite fails loudly. Any future bare-body endpoint should copy the @Res() + key-pin pattern.
  • followers_count is absent, not null/0, on /search and /friends rows. The resource builder omits the key entirely unless the endpoint loaded counts.
  • SupportTicket has no FK to User — in the Postman harness its rows survive the seed's user-graph truncate across runs, so ticket ids must be asserted relatively.
  • Caller exclusion is an adjudication. Legacy /networks//friends could surface you in your own lists via a §14 self-follow; the roadmap's phase DoD says all four endpoints exclude the caller, and ADR-0035 records it.

Tests: 130 unit tests across the three modules (100% on all four metrics each), plus test/support-feedback.e2e-spec.ts (43) and test/talent-network.e2e-spec.ts (34) — both built through an author → adversarial-review → patch pipeline — and the Postman folder 20-engagement-extras (28 requests / 138 assertions; full collection 498/1959 green). Decisions: ADR-0035; memory entry 0021.