# Architecture — `core` + `admin-console`

Local reference doc (gitignored). Covers the two apps that make up the admin panel:
`apps/core` (Hono API, 277 TS files) and `apps/admin-console` (React Router 7 SSR app, 388 TS/TSX files).

---

## 1. Monorepo shape

pnpm workspace + Turborepo. Workspace globs: `apps/*`, `packages/*`, `configs/*`.

```
karma-subito-and-marketing-admin-panel/
├── apps/
│   ├── core/                    @karma/core          — Hono API  (port 9005)
│   ├── admin-console/           @karma/admin-console — RR7 SSR   (port 3002)
│   ├── marketing-cms/                                            (port 3003)
│   ├── marketing-landing-page/                                   (port 3004)
│   └── karma-chatbot-frontend-nextJS/                            (port 3001)
├── packages/    ui, cva, icon, oauth, wallet, rci-sdk,
│               viewpoint-sdk, strapi-sdk, kcgm-db, kcgm-shared
├── configs/     tsconfig, eslint-config, prettier-config, tailwind-config (+ web-* variants)
├── turbo.json         build → dependsOn ^build; dev is persistent + uncached
└── ecosystem.config.js  PM2 process list for prod (all five apps)
```

Root scripts are thin Turbo/pnpm filters: `pnpm dev`, `pnpm build:core`,
`pnpm build:admin-console`, `pnpm typecheck`, `pnpm lint`.

**Out-of-tree neighbour:** the KCGM Dashboard lives in a *separate* repo
(`internal/KCGM-Dashboard`), runs its own API on `:4000` and web on `:3010`, and is
stitched into the console at runtime (§5).

---

## 2. `apps/core` — the API

Runtime: Node 20 + [Hono](https://hono.dev) on `@hono/node-server`. Bundled for prod with
esbuild into a single ESM file (`dist/index.js`); dev is `tsx watch src`.
Path alias `@/*` → `src/*`.

### 2.1 Boot sequence — [src/cmd/main.ts](apps/core/src/cmd/main.ts)

```
src/index.ts
  └─ BigInt.prototype.toJSON patch → ./instrument (Sentry) → main()
       1. build cfg from env  (env.GetString/GetInt — fatal-logs on missing key)
       2. Hono app + CORS (WEB_URL, IDP_WEB_URL, ADMIN_CONSOLE_URL, localhost:3000/3002/3010)
       3. timeout middleware — 3 min → HTTP 408
       4. createDBPool(datastore) + createDBPool(memberDatastore)
       5. migratePSQL(datastore)                    ← runs Kysely migrations on every boot
       6. createRedisDB(redisOptions)
       7. context middleware: ctx.set("datastore" | "memberDatastore" | "redisDB")
       8. UpdotAuthManager.getOrLogin()             ← fire-and-forget upstream login
       9. app.onError → always JSON {success,message,data}
      10. app.route("", AppRoutes)
      11. serve() → initializeBackgroundServices(pool, memberPool, httpServer)
```

**Two databases, both Postgres, injected per-request via Hono context:**

| Context key       | Env prefix   | What it is                                                              |
| ----------------- | ------------ | ----------------------------------------------------------------------- |
| `datastore`       | `DATABASE_`  | `karma_core` — **owned** by this app. Typed `Kysely<DB>` from kysely-codegen. Migrated here. |
| `memberDatastore` | `MEMBER_DB_` | `coredb` — **external/read-mostly** member system (users, members, member_profiles, member_preferences, sessions, notifications). Typed `Kysely<any>`. Never migrated here. |

This split is the single most important thing to internalise: anything about *console* state
(campaigns, roles, activity logs) is `datastore`; anything about *actual members* is
`memberDatastore` and is a foreign schema we only query.

### 2.2 Directory map

```
src/
├── index.ts, instrument.js, types.ts, regex.ts
├── cmd/                    composition root
│   ├── main.ts             boot (above)
│   ├── services.ts         background workers / schedulers
│   └── routes/
│       ├── index.ts        AppRoutes — the URL table (§2.3)
│       ├── updot-proxy.ts  /v1/admin/* → Updot Core upstream, cookie-injected
│       └── v1/
│           ├── routes.admin.ts   mounted at /v2 — console user/role/RBAC surface
│           └── controllers/      *.controller.ts + *.schema.ts (zod) + index.ts (DI wiring)
├── routes/<domain>/<domain>.route.ts    Hono sub-routers, one per domain
├── controllers/admin/<domain>/          HTTP handlers for those routers
├── v1/services/admin/<domain>/          business logic (class-based services)
├── internal/                            domain modules + infra
│   ├── datastore/       index.ts (pool+migrate), db.d.ts, repository.ts (BaseRepository), redis.ts,
│   │                    migrations/  (61 timestamped Kysely migrations)
│   ├── repository/      campaigns/, members/, admin_console/, promo-code-campaigns/
│   ├── console-users/ console-roles/ console-user-roles/ console-features/
│   │   console-role-privileges/ console-user-access-list/ console-user-sessions/
│   │                    → each: *.repository.ts + *.services.ts (+ *.utils.ts)
│   ├── members/ memberships/ membership-clubs/ member-auth/ member-sessions/ …
│   ├── queue/           campaign.worker.ts, notification-target.{queue,worker}.ts,
│   │                    scheduled-offers.worker.ts, viewpoint.ts
│   ├── socket/          socket.io server           sse/ SSEManager
│   ├── redis-subscribers/, middlewares/sanitzePayload.ts, models/
├── middlewares/         withConsoleUserSession, console-role, activity-log,
│                        redis-cache.middleware, admin-auth, apiKeyAuth, auth, member
├── lib/                 env, logger (pino), response, error, session, cookie, cipher,
│                        password, token, mailer (nodemailer), rabbitmq, redis.utils,
│                        safeFetch, upstream-monitor, updot-auth, updot-core-auth,
│                        storage/ (GCS), templates/ (react-email), helpers/, booking/, rci/, viewpoint/
└── karma/ viewpoint/ strapi/ leadsquared/     external-system clients
```

### 2.3 URL surface — [src/cmd/routes/index.ts](apps/core/src/cmd/routes/index.ts)

| Prefix                             | Router                                       |
| ---------------------------------- | -------------------------------------------- |
| `GET /health`                      | version + git branch/commit                   |
| `/v1/admin-console/notifications`  | `NotificationRoutes`                          |
| `/v1/admin-console/{chatbot,bookings,promo-codes,promo-code-campaigns,hot-deals,edm,resorts,points-transfer,member-offers,travel-rules,workflows}` | one router each |
| `/v1/admin/memberships`            | `MembershipRoutes`                            |
| `/v1/availability`                 | `AvailabilityRoutes`                          |
| `/v1/admin-console/kcgm/*`         | guarded by `withConsoleUserSession`; SSO token |
| `/v1/public/hot-deals*`, `/v1/public/travel-rules` | unauthenticated public reads |
| `/v1/admin/*`                      | `UpdotProxyRoutes` → Updot Core upstream      |
| `/v1/admin-console/kcgm-proxy/*`   | raw passthrough to `KCGM_API_URL` (`:4000`)   |
| `/v2/admin/*`                      | `AdminRoutes` — console users, roles, features, access list, activity logs |

`activityLogMiddleware` is applied to `*`, so every request is a candidate audit-log entry
(handlers opt in by `c.set("entityId", …)`).

### 2.4 The two coexisting layering styles

The codebase has **two generations** of structure. Both are live; match whichever the file
you're editing already uses.

**(a) Newer / v2 — functional DI, used by `/v2` console-user & RBAC endpoints**

```
routes.admin.ts  →  V1Controllers(ctx).XController.Method()
                       └─ controllers/index.ts injects:
                            Repository(ctx)  ← factory closing over BaseRepository(ctx)
                            Services({ Repository, …otherServices })
```

- `BaseRepository` ([internal/datastore/repository.ts](apps/core/src/internal/datastore/repository.ts))
  pulls `datastore`/`redisDB` off the Hono context.
- Repositories are `const XRepository = (ctx) => ({ CreateEntry, FindEntryBy…, … })`,
  each method wrapping errors in `DatabaseError`.
- Services take a typed deps object (`TXServiceDeps`) — pure composition, no classes.
- Validation is zod schemas in `*.schema.ts`, applied as
  `sanitizePayload(Schema)` / `sanitizeQueryParams(Schema)` route middleware.
- RBAC: `withConsoleUserSession()` then `withConsoleUserRole("<feature-slug>", "create"|"read"|"update"|"delete")`.

**(b) Older / v1 — class services constructed inside the handler, used by feature domains
(notifications, bookings, promo codes, hot deals, EDM, …)**

```
routes/<d>/<d>.route.ts  →  controllers/admin/<d>/<d>.controller.ts
                              const service = new XService(new ARepo(db), new BRepo(memberDb), …)
                              return success(c, data, "msg")  /  error(c, msg, 500)
```

- Repos are classes taking a `Kysely` instance in the constructor
  (e.g. [ExternalMemberRepository](apps/core/src/internal/repository/members/external_members.ts)).
- Services live under `v1/services/admin/<domain>/*.service.ts` and hold the real logic
  (batching, upstream calls, retry, progress emission).
- Handlers wire dependencies **per request** — there is no container.

Response envelope is uniform across both: `{ success, message, data }` via
[lib/response.ts](apps/core/src/lib/response.ts).

### 2.5 Background work — [src/cmd/services.ts](apps/core/src/cmd/services.ts)

Started once, after `serve()`:

| Thing                        | Mechanism                                                    |
| ---------------------------- | ------------------------------------------------------------ |
| `CampaignWorker`             | RabbitMQ consumer — sends notification campaigns in batches   |
| `recoverStuckCampaigns()`    | one-shot on boot                                              |
| `NotificationTargetWorker`   | BullMQ/Redis — precomputes campaign audiences                 |
| Campaign scheduler           | naive `setTimeout` loop, 5 s, enqueues due scheduled campaigns |
| Scheduled-offers worker      | naive `setTimeout` loop, 60 s                                 |
| `initSocket(httpServer)`     | socket.io — live campaign progress/status to the console      |
| `UpdotCoreAuthManager`       | session rotation worker for the upstream member API           |

Two loop styles coexist (queue-driven vs. `setTimeout` polling) — the pollers are
self-rescheduling in a `finally`, so a throw never kills the loop.

Realtime out to the browser is **both** socket.io (`emitCampaignProgress`,
`emitCampaignStatus`) and SSE (`SSEManager`), depending on the feature.

### 2.6 Migrations

`migratePSQL()` runs on every boot (twice, actually — before and after `serve()`).
61 files in `internal/datastore/migrations/`, named `YYYYMMDDHHMMSS_description.ts`,
Kysely up/down. They cover only `karma_core`: console users/roles/features/privileges,
campaigns + recipients, activity logs, verification tokens, chatbot tables, feature seeds.

`db.d.ts` is generated by `kysely-codegen`; `memberDatastore` is deliberately untyped.

### 2.7 External systems core talks to

Updot Core (member API, cookie-auth via `UpdotAuthManager` / `UpdotCoreAuthManager`),
the notification send API, Strapi CMS, RCI (`@karma/rci-sdk`), Viewpoint
(`@karma/viewpoint-sdk`), LeadSquared, Razorpay, Google Cloud Storage, SMTP via nodemailer,
Redis (ioredis + Upstash), RabbitMQ (amqplib), Sentry.

`lib/upstream-monitor.ts` gates background work when upstream is known-down.

---

## 3. `apps/admin-console` — the UI

React 19 + **React Router 7 in framework mode with SSR** (`react-router.config.ts`:
`ssr: true`, `appDirectory: "src"`). Vite 7 for bundling. **Mantine 8** is the component
system (core, form, dates, charts, modals, notifications, spotlight, tiptap) plus
`mantine-react-table` for grids. Path alias `@/*` → `src/*`.

Not a SPA and not plain Vite: routes are modules with `loader`/`action` exports that run
on the server.

### 3.1 Serving — [server.js](apps/admin-console/server.js) + [server/app.ts](apps/admin-console/server/app.ts)

Custom Express 5 host rather than `react-router-serve`:

- dev → Vite middleware mode, `ssrLoadModule("./server/app.ts")`
- prod → `express.static("build/client")` (hashed assets `immutable, 1y`; rest `1h`) then the
  built RR handler from `build/server/index.js`
- `GET /kcgm-app` → server-side fetch of `${KCGM_WEB_URL}/app.html`, re-served from the
  console's own origin so the iframe never points at `localhost:3010`

### 3.2 Directory map

```
src/
├── root.tsx, root.css, app.tsx, entry.client.tsx, catchall.tsx
├── routes.ts               ← explicit route table (NOT file-system routing)
├── routes/<domain>/        page.tsx (loader/action/handle) + _client.tsx + (widgets)/
├── layouts/                root.layout, console.layout, + per-domain layouts, console/, shared/
├── providers/UserProvider.tsx
├── components/             cross-domain widgets (NotificationsBell, Richtext, blocks/, …)
├── hooks/, utils/
└── lib/
    ├── features/<domain>/action.ts   ← all API calls, one module per domain
    ├── features/coreClient.ts        ← fetch wrapper + session-rotate retry
    ├── features/protectedFetcher.ts  ← createProtectedLoader / createRoleProtectedLoader
    ├── features/useCoreFetcher.ts    ← client-side mutation hook over useFetcher
    ├── features/types.ts             ← CoreResponse<T>, CoreAPIError, DecodedRole
    ├── role-helpers.ts               ← FEATURE_SLUGS + checkAccess (JWT cookie)
    ├── theme.ts, colors/, components/, export/, OutletContexts/, cookies.ts, safeFetch.ts, path.ts
```

### 3.3 Routing

[src/routes.ts](apps/admin-console/src/routes.ts) is a hand-written `RouteConfig` using
`layout()` / `prefix()` / `route()` / `index()`. Two nesting levels matter:

```
root.layout.tsx                       ← providers, theme, notifications
├── /auth/*  /set-password  /forgot-password  /reset-password     (unauthenticated)
└── console.layout.tsx                ← shell: nav, breadcrumbs, user menu
    ├── /admin/dashboard
    ├── /admin/members/*        /admin/memberships/*      /admin/bookings/*
    ├── promo-codes.layout.tsx → /admin/promo-codes/*, /admin/promo-code-campaigns,
    │                            /admin/member-referrals, /admin/reports/*,
    │                            /admin/access-list/*, /admin/kcgm
    ├── /admin/edm/*  /admin/resorts/*  /admin/member-offers/*  /admin/notifications/*
    ├── chatbot.layout.tsx → /admin/chatbot/{agents,prompts,history}
    ├── settings.layout.tsx → /admin/settings/{users,roles}/*
    └── /admin/activity-logs, /admin/points-transfer[/deactivated]
```

Conventions inside a route folder:

- `page.tsx` — exports `loader` (server), sometimes `action`, `handle.breadcrumb`, and a thin
  default component that hands `loaderData` to…
- `_client.tsx` — the interactive client component
- `(widgets)/` — parenthesised = not a route, just co-located components
- `$param` in filenames mirrors the `:param` in the route table

### 3.4 Data flow

```
Browser ──▶ RR loader/action (server, in-process)
              └─ lib/features/<domain>/action.ts
                   └─ coreClient(path, init)
                        ├─ server-side : baseUrl = CORE_API_URL, x-api-key injected
                        └─ browser     : baseUrl = "/api/proxy"
                                            └─ routes/api/proxy.$.ts  (server)
                                                 └─ strips host/origin/referer, injects
                                                    x-api-key, forwards cookies + Set-Cookie
                                                 └─ CORE_API_URL/<path>
```

`/api/proxy` exists so the API key never reaches the browser and cookies stay first-party.

**Auth/session:** cookie-based. `coreClient` detects a rejected session and retries once via
`PATCH /v2/admin/console-user/session-rotate`, de-duplicated through a module-level
`refreshPromise`. On hard failure the loader throws `UNAUTHORIZED`, which
`createProtectedLoader` converts into `adminSignout` + `redirect("/auth/signin")`.

**RBAC in the UI:** the `admin_scopes` cookie is a JWT of
`{ scopes: [{feature_slug, create, read, update, delete}], super_admin }`.
`checkAccess(request, "read", FEATURE_SLUGS.notifications)` decodes it *without verifying*
(it's a UI hint only — core re-checks with `withConsoleUserRole`). Pages wrap their loader in
`createRoleProtectedLoader(FEATURE_SLUGS.x, "read", fn)`.

**Mutations:** either a RR `action` + `useCoreFetcher(intent, method)` (dispatches on an
`intent` field, toasts errors via sonner), or a direct `action.ts` call from the client.
TanStack Query is available and used in the heavier screens; it is not the default.

**Realtime:** `socket.io-client` for campaign progress; `NotificationsBell` polls/streams
console notifications.

---

## 4. Request lifecycle, end to end

Example — the notification audience count on Step 2 of Create Notification:

```
targetForm.tsx  (client, Mantine form)
   → lib/features/notifications/action.ts :: getAudienceCount(criteria)
   → coreClient POST /api/proxy/v1/admin-console/notifications/audience/count
   → routes/api/proxy.$.ts   (console server: +x-api-key, +cookies)
   → core  POST /v1/admin-console/notifications/audience/count
        activityLogMiddleware → withConsoleUserSession()
        → controllers/admin/notifications/campaign.controller.ts :: getAudienceCountHandler
        → new CampaignService(new CampaignRepository(datastore),
                              new ExternalMemberRepository(memberDatastore), …)
        → service.getAudienceCount(criteria)
        → repo.countByCriteria → findEmailsByCriteria  (queries memberDatastore)
        → success(c, count)
```

Note the crossing at the last step: the *console* DB holds the campaign, the *member* DB
answers who it targets.

---

## 5. KCGM integration (cross-repo)

The KCGM Dashboard is a separate repo but appears inside the console:

1. Console page `/admin/kcgm` renders an iframe pointing at the console's own `/kcgm-app`.
2. Express proxies that to `KCGM_WEB_URL` (`:3010`) `app.html`.
3. The iframe's XHRs go to `/v1/admin-console/kcgm-proxy/*` on core, which forwards to
   `KCGM_API_URL` (`:4000`) and rewrites CORS headers.
4. Auth hand-off: `GET /v1/admin-console/kcgm/sso-token` mints a short-lived token for a
   console user who holds the `kcgm` feature role.

Shared code lives in `packages/kcgm-shared` and `packages/kcgm-db`.

---

## 6. Conventions worth following

- **Aliases:** `@/` in both apps. Core adds `@repo/kcgm-shared`.
- **Errors:** core never throws raw to the client — `app.onError` plus `error(c, msg, status)`
  keep the `{success,message,data}` envelope. Repos throw `DatabaseError`.
- **Logging:** pino via `lib/logger` (`logInfo`/`logError`/`logFatal`). `logFatal` on a missing
  env var is how config problems surface.
- **Caching:** `redisCacheMiddleware({ ttl })` on read routes; handlers call
  `invalidateCacheByPrefix(redis, "GET:/v1/…")` after writes. The prefix is the literal
  route string — keep them in sync when renaming routes.
- **Naming:** core files are `kebab-case.role.ts` (`campaign.service.ts`,
  `console-users.repository.ts`); console routes are `page.tsx` / `_client.tsx` / `(widgets)/`.
- **Which DB:** if the query touches `users`, `members`, `member_profiles`,
  `member_preferences`, `sessions`, or `notifications`, it is `memberDatastore` — a schema this
  repo does not own and cannot migrate.
- **New domain in core:** `routes/<d>/<d>.route.ts` → `controllers/admin/<d>/` →
  `v1/services/admin/<d>/` → `internal/repository/<d>/`, then mount in
  `cmd/routes/index.ts`. New RBAC-managed area also needs a feature-seed migration and a
  `FEATURE_SLUGS` entry on the console side.
- **New page in the console:** add to `src/routes.ts`, create `routes/<d>/page.tsx`
  (loader wrapped in `createRoleProtectedLoader`) + `_client.tsx`, and put every fetch in
  `lib/features/<d>/action.ts` — never call the API inline from a component.

---

## 7. Rough edges to know about

- **Duplicated boot work:** `migratePSQL` runs twice in `main()`.
- **Per-request `new Service(new Repo(...))`** in v1 controllers — cheap, but the wiring is
  copy-pasted across every handler in a file.
- **Polling schedulers** (`setTimeout` 5 s / 60 s) run in the same process as the HTTP server;
  they are not clustered, so running two core instances doubles the work.
- **Two structural generations** (§2.4) — expect to see both idioms side by side.
- **`memberDatastore` is `Kysely<any>`** — no type safety on the highest-traffic queries.
  Concurrency there is fragile: fetching multiple large member aggregates in parallel has
  produced 504s where sequential calls succeed.
- **Version gating uses string comparison** (`application_version >= '0.0.14'`), so `'0.0.9'`
  sorts above `'0.0.14'` and passes.
- **`routes/notifications/page.tsx`** ships a loader that returns a hard-coded empty campaign
  list; the real fetch happens client-side in `_client.tsx`.
- **Some route folders are dead** (`routes/notifications/blockList`, commented blocks at the
  bottom of `src/routes.ts`, `routes/destinations`).
