Architecture
1. System Context (C4 L1)
2. Container View (C4 L2)
No Kafka consumer, no BullMQ worker. The only background fan-out is the Redis-backed WebSocket emitter.
3. Component View (C4 L3) — Internal Layering
| Layer | Responsibility |
|---|---|
| Routes | HTTP surface; custom routes declared in RestPaths + controller definitions.ts |
| Controllers | Auth + permission gate, DTO mapping, fire WS notify on submit |
| Services | SubscriberService subscribe/unsubscribe/stats logic |
| Repositories | Drizzle queries, soft-delete, getStatistics() rollup |
| Components | ApplicationWebSocketComponent — Redis emitter + socket event service |
4. State Machines Index
| Entity | States | Diagram |
|---|---|---|
Inquiry | NEW, PROCESSING, COMPLETED, CLOSED, CANCELLED | → jump |
Subscriber | ACTIVATED, DEACTIVATED, ARCHIVED | → jump |
Both use IGNIS
Statuses. Transitions are not machine-enforced — admins moveInquiryfreely via CRUD update;Subscribertransitions are driven bySubscriberService(subscribe ↔ unsubscribe). The diagrams below show the intended lifecycle.
Inquiry
| From | Event | To | Guards |
|---|---|---|---|
[*] | POST /inquiries/submit | NEW | default status |
NEW | admin update | PROCESSING | none (manual) |
PROCESSING | admin update | COMPLETED / CLOSED | none (manual) |
* | admin update | CANCELLED | none (manual) |
Subscriber
| From | Event | To | Guards |
|---|---|---|---|
[*] | subscribe (no existing row) | ACTIVATED | unique email |
ACTIVATED | subscribe (existing active) | ACTIVATED | idempotent — returns existing |
DEACTIVATED | subscribe | ACTIVATED | reactivate, clear unsubscribedAt |
ACTIVATED | unsubscribe(token) | DEACTIVATED | token must resolve to a row |
5. Runtime Scenarios
5.1 Inquiry submission + real-time notify
| Step | Detail |
|---|---|
| 1-3 | Inquiry persisted with default status=NEW, type=000_CONSULT unless overridden |
| 4-6 | WS notify is fire-and-forget (not awaited); skipped if emitter not ready |
| 6 | Broadcast to outreach/inquiries and outreach/inquiries/{id} via Promise.allSettled |
| 7 | 201 returned regardless of WS outcome |
5.2 Subscribe (idempotent)
5.3 Unsubscribe (token)
6. Crosscutting Concerns
| Concern | How this service handles it |
|---|---|
| AuthN | JWT (Issuer = identity), JWKS verified per request; public endpoints skip auth |
| AuthZ | Casbin permissions seeded via migrations; CRUD + stats gated, public submit/subscribe/unsubscribe open |
| i18n | Subscriber.locale (vi/en) chooses newsletter language; permission labels carry { en, vi } |
| Logging | Structured key-value (key: %s); WS notify logs inquiry id + rooms |
| Tracing | No-op (no tracer wired) |
| Idempotency | subscribe idempotent on email; unsubscribe idempotent (re-applying DEACTIVATED is safe) |
| Soft-delete | SoftDeletableRepository (deletedAt); default filter deletedAt IS NULL |
| IDs | Snowflake via IdGenerator, worker 10; unsubscribeToken is also a Snowflake |