# PRD: Shared Module

**Module:** `shared/` | **Namespace:** `ajet.chat.shared.*`
**Status:** v1 | **Last updated:** 2026-02-17

---

## 1. Overview

The shared module provides foundational libraries used by all other services: database access, event bus (NATS), API client SDK, data schemas, and common protocols. It has no main entry point — it's a library dependency.

## 2. Components

### 2.1 Database Layer (`ajet.chat.shared.db`)

**Purpose:** PostgreSQL connection management, query helpers, migration runner.

**Requirements:**

| ID | Requirement | Priority |
|----|-------------|----------|
| DB-1 | Create HikariCP connection pool from config map | P0 |
| DB-2 | Provide `with-transaction` macro wrapping `next.jdbc/with-transaction` | P0 |
| DB-3 | Provide `execute!`, `execute-one!`, `plan` wrappers that accept HoneySQL maps | P0 |
| DB-4 | Run Migratus migrations on startup (configurable: auto or manual) | P0 |
| DB-5 | Support read-only datasource config for future read replicas | P2 |
| DB-6 | Log slow queries (> configurable threshold, default 500ms) | P1 |

**Config shape:**
```clojure
{:db {:host "localhost"
      :port 5432
      :dbname "ajet_chat"
      :user "ajet"
      :password "..."
      :pool-size 10
      :migrations {:enabled true :location "migrations"}}}
```

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| DB-T1 | Pool creation with valid config | Unit | `make-datasource` returns a pooled datasource |
| DB-T2 | Pool creation with invalid config | Unit | Throws clear error on bad host/port/creds |
| DB-T3 | HoneySQL map execution | Integration | `execute!` with `{:select [:*] :from [:users]}` returns rows |
| DB-T4 | Transaction commit | Integration | Writes inside `with-transaction` are visible after commit |
| DB-T5 | Transaction rollback | Integration | Exception inside `with-transaction` rolls back all writes |
| DB-T6 | Migration forward | Integration | `migrate!` applies pending migrations, creates tables |
| DB-T7 | Migration rollback | Integration | `rollback!` reverses last migration |
| DB-T8 | Migration idempotency | Integration | Running `migrate!` twice is a no-op when up to date |
| DB-T9 | Slow query logging | Integration | Query taking > threshold triggers log warning |
| DB-T10 | Connection pool exhaustion | Integration | Requests beyond pool-size block then timeout with clear error |

---

### 2.2 EventBus (`ajet.chat.shared.eventbus`)

**Purpose:** NATS pub/sub abstraction with JetStream support for durable subscriptions.

**Requirements:**

| ID | Requirement | Priority |
|----|-------------|----------|
| EB-1 | `connect!` — create NATS connection from config | P0 |
| EB-2 | `publish!` — publish event to a subject (JSON-encoded) | P0 |
| EB-3 | `subscribe!` — subscribe to subject pattern, return subscription handle | P0 |
| EB-4 | `unsubscribe!` — close subscription by handle | P0 |
| EB-5 | `close!` — close NATS connection | P0 |
| EB-6 | Events are EDN maps serialized as JSON on the wire | P0 |
| EB-7 | Support wildcard subjects (`chat.events.*`) | P0 |
| EB-8 | JetStream durable consumer for SM replay on reconnect | P1 |
| EB-9 | Auto-reconnect on NATS connection loss with backoff | P1 |
| EB-10 | Connection health check (ping/pong) | P1 |

**Subject hierarchy:**
```
chat.events.{community-id}                — channel message CRUD, channel events, reactions
chat.dm.{channel-id}                      — DM message events
chat.typing.{community-id}.{channel-id}   — channel typing indicators
chat.typing.dm.{channel-id}               — DM typing indicators
chat.presence.{community-id}              — heartbeat/presence events
chat.notifications.{user-id}              — per-user notification delivery
```

**Event envelope:**
```clojure
{:type    :message/created      ;; keyword
 :id      "uuid"                ;; event ID for dedup
 :ts      "2026-02-17T..."      ;; ISO 8601
 :payload {:message-id "uuid"
           :channel-id "uuid"
           :user-id    "uuid"
           :body-md    "hello"}}
```

**Event types:**
```
:message/created, :message/edited, :message/deleted
:reaction/added, :reaction/removed
:channel/created, :channel/updated, :channel/deleted
:member/joined, :member/left, :member/kicked, :member/banned
:typing/start, :typing/stop
:presence/online, :presence/offline
:notification/new
```

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| EB-T1 | Connect to NATS | Integration | `connect!` returns active connection |
| EB-T2 | Connect with bad URL | Unit | Throws clear error |
| EB-T3 | Publish and subscribe | Integration | Published event is received by subscriber |
| EB-T4 | Wildcard subscribe | Integration | `chat.events.*` receives events for any community |
| EB-T5 | Unsubscribe stops delivery | Integration | After `unsubscribe!`, no more events received |
| EB-T6 | Multiple subscribers | Integration | Two subscribers on same subject both receive event |
| EB-T7 | JSON roundtrip | Unit | EDN map → JSON → EDN map preserves all fields |
| EB-T8 | Event envelope validation | Unit | Missing `:type` or `:payload` throws |
| EB-T9 | JetStream replay | Integration | Durable consumer replays missed events on reconnect |
| EB-T10 | Auto-reconnect | Integration | Subscriber resumes after NATS restart |
| EB-T11 | Close connection | Integration | `close!` disconnects cleanly, subsequent publish throws |

---

### 2.3 API Client SDK (`ajet.chat.shared.api-client`)

**Purpose:** HTTP client for internal (SM → API) and external (CLI → Auth GW) communication.

**Status:** Substantially implemented (219 lines). Needs expansion.

**Requirements:**

| ID | Requirement | Priority |
|----|-------------|----------|
| AC-1 | Context-based design: `{:base-url :auth-token :trace-id}` | P0 |
| AC-2 | All CRUD for: channels, messages, reactions, DMs, users, notifications | P0 |
| AC-3 | Heartbeat endpoint | P0 |
| AC-4 | Search endpoint (messages, channels, users) | P0 |
| AC-5 | Invite management (create link, direct invite, list, revoke) | P0 |
| AC-6 | Slash command dispatch | P0 |
| AC-7 | Community CRUD (create, update, list, get) | P0 |
| AC-8 | Channel category CRUD (create, reorder, assign channels) | P1 |
| AC-9 | Webhook management (create, delete, list) | P1 |
| AC-10 | Bidirectional cursor-based pagination (`?before=<uuid>&after=<uuid>&limit=N`) | P0 |
| AC-11 | Retry with exponential backoff on 5xx/timeout | P1 |
| AC-12 | Request timeout (configurable, default 10s) | P0 |
| AC-13 | Structured error responses (`ex-info` with status + body) | P0 |
| AC-14 | File upload (multipart) for image attachments | P0 |

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| AC-T1 | Build headers with token | Unit | `build-headers` includes Authorization + trace ID |
| AC-T2 | Build headers without token | Unit | No Authorization header when token nil |
| AC-T3 | JSON encode/decode roundtrip | Unit | Clojure map survives JSON serialization |
| AC-T4 | 4xx response throws ex-info | Unit | `check-response!` throws with status + body |
| AC-T5 | 5xx response throws ex-info | Unit | Includes status code and response body |
| AC-T6 | get-channels returns list | Integration | Returns vector of channel maps |
| AC-T7 | send-message creates message | Integration | POST returns created message with ID |
| AC-T8 | edit-message within window | Integration | PUT succeeds within 1-hour window |
| AC-T9 | edit-message after window | Integration | PUT returns 403 after 1-hour window |
| AC-T10 | delete-message own | Integration | DELETE succeeds for message author |
| AC-T11 | delete-message other (non-admin) | Integration | DELETE returns 403 |
| AC-T12 | cursor pagination | Integration | `?after=uuid&limit=20` returns next page |
| AC-T13 | search messages | Integration | Returns matching messages across accessible channels |
| AC-T14 | retry on 503 | Unit | Retries up to 3 times with backoff |
| AC-T15 | timeout handling | Unit | Throws after configured timeout |
| AC-T16 | image upload multipart | Integration | File uploaded, attachment record created |

---

### 2.4 Schemas & Validation (`ajet.chat.shared.schema`)

**Purpose:** Data shape definitions and validation for all entities.

**Requirements:**

| ID | Requirement | Priority |
|----|-------------|----------|
| SC-1 | Define specs/schemas for all DB entities | P0 |
| SC-2 | Request validation for API endpoints | P0 |
| SC-3 | Event payload validation | P1 |
| SC-4 | Use `clojure.spec.alpha` or Malli (TBD) | P0 |

**Entities to define:**

```
community:       {:id uuid, :name string, :slug string, :created-at inst}
user:            {:id uuid, :username string, :display-name string, :email string, :avatar-url string?, :status-text string?, :created-at inst}
oauth-account:   {:id uuid, :user-id uuid, :provider string, :provider-user-id string}
session:         {:id uuid, :user-id uuid, :token-hash string, :expires-at inst}
api-user:        {:id uuid, :name string, :community-id uuid, :scopes [string]}
api-token:       {:id uuid, :api-user-id uuid, :token-hash string, :scopes [string], :expires-at inst}
channel:         {:id uuid, :community-id uuid?, :name string, :type enum, :visibility enum, :topic string?, :category-id uuid?}
channel-category:{:id uuid, :community-id uuid, :name string, :position int}
community-member:{:community-id uuid, :user-id uuid, :role enum, :nickname string?, :avatar-url string?}
channel-member:  {:channel-id uuid, :user-id uuid, :joined-at inst}
message:         {:id uuid, :channel-id uuid, :user-id uuid, :parent-id uuid?, :body-md string, :created-at inst, :edited-at inst?}
attachment:      {:id uuid, :message-id uuid, :filename string, :content-type string, :size-bytes int, :storage-key string}
reaction:        {:message-id uuid, :user-id uuid, :emoji string}
webhook:         {:id uuid, :community-id uuid, :channel-id uuid, :name string, :avatar-url string?, :token-hash string}
mention:         {:id uuid, :message-id uuid, :target-type enum, :target-id uuid?}
notification:    {:id uuid, :user-id uuid, :type enum, :source-id uuid, :read boolean}
invite:          {:id uuid, :community-id uuid, :created-by uuid, :code string, :max-uses int?, :uses int, :expires-at inst?}
oauth-provider:  {:id uuid, :provider-type enum, :name string, :client-id string, :client-secret-encrypted string, :base-url string?, :issuer-url string?, :enabled boolean, :created-at inst, :updated-at inst}
system-setting:  {:key string, :value any, :updated-at inst}
```

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| SC-T1 | Valid community passes validation | Unit | All required fields present and correct types |
| SC-T2 | Missing required field fails | Unit | Omitting `:name` from community fails validation |
| SC-T3 | Invalid UUID format fails | Unit | String "not-a-uuid" in UUID field fails |
| SC-T4 | Enum validation | Unit | Channel `:type` must be one of `#{:text :voice :dm :group-dm}` |
| SC-T5 | Optional fields accepted when nil | Unit | `:topic nil` passes for channel |
| SC-T6 | Message body not empty | Unit | Empty string body fails validation |
| SC-T7 | Slug format validation | Unit | Community slug must match `[a-z0-9-]+` |
| SC-T8 | Mention storage format | Unit | `@<user:uuid>` parses correctly to `{:target-type :user :target-id uuid}` |
| SC-T9 | Event envelope validation | Unit | Event with valid type + payload passes |

---

### 2.5 Mention Parser (`ajet.chat.shared.mentions`)

**Purpose:** Parse and render mention/channel-link syntax in message bodies.

**Storage format:** Raw markdown with embedded references:
- `@<user:550e8400-e29b-41d4-a716-446655440000>` — user mention
- `@<here>` — @here mention (notify all online in channel)
- `#<channel:550e8400-e29b-41d4-a716-446655440000>` — channel link

**Rendered output:** (resolved at display time by SMs)
- `@someUser` — rendered with user's display name
- `@here` — rendered as-is
- `#general` — rendered with channel name

**Requirements:**

| ID | Requirement | Priority |
|----|-------------|----------|
| MP-1 | Parse `@<user:uuid>` from message body, extract UUIDs | P0 |
| MP-2 | Parse `@<here>` from message body | P0 |
| MP-3 | Parse `#<channel:uuid>` from message body | P0 |
| MP-4 | Return list of `{:type :target-id}` for mention record creation | P0 |
| MP-5 | Render mentions by replacing references with display names | P0 |

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| MP-T1 | Parse single user mention | Unit | `"hello @<user:abc-123>"` → `[{:type :user :id "abc-123"}]` |
| MP-T2 | Parse multiple mentions | Unit | Two user mentions in one message → both extracted |
| MP-T3 | Parse @here | Unit | `"@<here> look at this"` → `[{:type :here}]` |
| MP-T4 | Parse channel link | Unit | `"see #<channel:def-456>"` → `[{:type :channel :id "def-456"}]` |
| MP-T5 | Parse mixed mentions | Unit | User + here + channel in one message → all three extracted |
| MP-T6 | No mentions returns empty | Unit | `"just a normal message"` → `[]` |
| MP-T7 | Mention inside code block ignored | Unit | `` `@<user:abc>` `` → `[]` (code blocks are not parsed) |
| MP-T8 | Render user mention | Unit | Replace `@<user:abc>` with `@alice` given lookup map |
| MP-T9 | Render channel link | Unit | Replace `#<channel:def>` with `#general` given lookup map |
| MP-T10 | Render unknown user | Unit | `@<user:unknown>` renders as `@unknown-user` |

---

### 2.6 Markdown Processor (`ajet.chat.shared.markdown`)

**Purpose:** Parse and render Discord-flavor markdown to HTML (for web) and ANSI (for TUI).

**Supported syntax:**
- **Bold:** `**text**`
- *Italic:* `*text*` or `_text_`
- ~~Strikethrough:~~ `~~text~~`
- __Underline:__ `__text__` (Discord extension)
- `Inline code`: `` `code` ``
- Code blocks: ` ```lang\ncode\n``` ` with syntax highlighting
- Spoilers: `||hidden text||`
- Block quotes: `> text`
- Links: `[text](url)` — auto-link bare URLs
- Emoji shortcodes: `:smile:` → rendered emoji

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| MD-T1 | Bold rendering | Unit | `**bold**` → `<strong>bold</strong>` |
| MD-T2 | Italic rendering | Unit | `*italic*` → `<em>italic</em>` |
| MD-T3 | Strikethrough | Unit | `~~strike~~` → `<del>strike</del>` |
| MD-T4 | Underline | Unit | `__underline__` → `<u>underline</u>` |
| MD-T5 | Inline code | Unit | `` `code` `` → `<code>code</code>` |
| MD-T6 | Fenced code block | Unit | Triple backtick block → `<pre><code>` with language class |
| MD-T7 | Spoiler | Unit | `\|\|spoiler\|\|` → `<span class="spoiler">spoiler</span>` |
| MD-T8 | Block quote | Unit | `> quote` → `<blockquote>quote</blockquote>` |
| MD-T9 | Auto-link URL | Unit | Bare `https://example.com` → clickable link |
| MD-T10 | Nested formatting | Unit | `**bold and *italic***` renders correctly |
| MD-T11 | XSS prevention | Unit | `<script>alert(1)</script>` is escaped, not executed |
| MD-T12 | Emoji shortcode | Unit | `:smile:` → Unicode emoji character |
| MD-T13 | ANSI rendering for TUI | Unit | `**bold**` → ANSI bold escape code |
| MD-T14 | Code block in ANSI | Unit | Code block rendered with box-drawing characters |
| MD-T15 | Mentions not processed | Unit | Markdown processor does not handle `@<user:>` (separate concern) |

---

### 2.7 Config Loader (`ajet.chat.shared.config`)

**Purpose:** Load, validate, and merge EDN configuration from files and environment variables. All modules use this for startup configuration.

**Requirements:**

| ID | Requirement | Priority |
|----|-------------|----------|
| CFG-1 | Load config from EDN file path (default: `config.edn` in classpath) | P0 |
| CFG-2 | Deep-merge module-specific config over shared defaults | P0 |
| CFG-3 | Override any config key via environment variables (`AJET_DB_HOST` → `{:db {:host ...}}`) | P0 |
| CFG-4 | Validate required keys on load, throw clear error on missing/invalid config | P0 |
| CFG-5 | Support profiles (`:dev`, `:test`, `:prod`) — merge profile-specific overrides | P1 |
| CFG-6 | Secrets from env vars only — never log or serialize secret values | P0 |

**Env var mapping convention:**
```
AJET_DB_HOST        → {:db {:host "..."}}
AJET_DB_PORT        → {:db {:port 5432}}
AJET_DB_PASSWORD    → {:db {:password "..."}}
AJET_NATS_URL       → {:nats {:url "..."}}
AJET_OAUTH_GITHUB_CLIENT_ID → {:oauth {:github {:client-id "..."}}}
```
Underscores map to nested keys. Numeric strings auto-coerce to integers. `"true"`/`"false"` coerce to booleans.

**Shared defaults:**
```clojure
{:db   {:host "localhost" :port 5432 :dbname "ajet_chat" :user "ajet" :pool-size 10}
 :nats {:url "nats://localhost:4222"}
 :minio {:endpoint "http://localhost:9000" :access-key "minioadmin" :secret-key "minioadmin" :bucket "ajet-chat"}}
```

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| CFG-T1 | Load valid EDN config | Unit | `load-config` parses EDN file and returns map |
| CFG-T2 | Missing config file | Unit | Throws with clear error message |
| CFG-T3 | Env var override | Unit | `AJET_DB_HOST=remote` overrides `{:db {:host "localhost"}}` |
| CFG-T4 | Env var numeric coercion | Unit | `AJET_DB_PORT=5433` becomes integer 5433 |
| CFG-T5 | Deep merge module config | Unit | Module config merges over defaults without clobbering sibling keys |
| CFG-T6 | Missing required key | Unit | Missing `:db :host` throws validation error |
| CFG-T7 | Profile merge | Unit | `:test` profile overrides `{:db {:dbname "ajet_chat_test"}}` |
| CFG-T8 | Secrets not logged | Unit | Config with `:password` redacts value in string representation |

---

### 2.8 Logging (`ajet.chat.shared.logging`)

**Purpose:** Structured logging with trace ID propagation across all services.

**Requirements:**

| ID | Requirement | Priority |
|----|-------------|----------|
| LOG-1 | Use `clojure.tools.logging` with Logback backend | P0 |
| LOG-2 | Structured JSON log format in production, human-readable in dev | P0 |
| LOG-3 | Bind `trace-id` to MDC (Mapped Diagnostic Context) per request | P0 |
| LOG-4 | Log request/response summary for every HTTP request (method, path, status, duration) | P0 |
| LOG-5 | Log level configurable per namespace via config | P1 |
| LOG-6 | Redact sensitive fields (passwords, tokens) in log output | P0 |

**MDC fields per request:**
```
trace-id:   UUID from X-Trace-Id header
user-id:    UUID from X-User-Id header (if authenticated)
method:     HTTP method
path:       Request path
```

**Log format (production):**
```json
{"timestamp":"2026-02-17T10:30:00Z","level":"INFO","logger":"ajet.chat.api.routes","trace-id":"uuid","user-id":"uuid","msg":"POST /api/channels/uuid/messages 201 (45ms)"}
```

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| LOG-T1 | Request logging middleware | Unit | Request/response logged with method, path, status, duration |
| LOG-T2 | Trace ID in MDC | Unit | Log entries include trace-id from request header |
| LOG-T3 | Sensitive field redaction | Unit | Password and token values replaced with `[REDACTED]` |
| LOG-T4 | JSON format in prod | Unit | Log output parses as valid JSON in `:prod` profile |
| LOG-T5 | Human-readable in dev | Unit | Log output is plain text with colors in `:dev` profile |

---

### 2.9 File Storage Client (`ajet.chat.shared.storage`)

**Purpose:** S3-compatible client for MinIO/AWS S3 file operations.

**Requirements:**

| ID | Requirement | Priority |
|----|-------------|----------|
| FS-1 | `upload!` — upload bytes/stream to storage with key | P0 |
| FS-2 | `download` — get file bytes/stream by key | P0 |
| FS-3 | `delete!` — remove file by key | P0 |
| FS-4 | `presigned-url` — generate time-limited download URL | P1 |
| FS-5 | Create bucket on startup if it doesn't exist | P0 |
| FS-6 | Validate content-type (images only: JPEG, PNG, GIF, WebP) | P0 |
| FS-7 | Validate file size (max 10MB) | P0 |
| FS-8 | Storage key format: `attachments/{uuid}/{filename}` | P0 |

**Config shape:**
```clojure
{:minio {:endpoint   "http://localhost:9000"
         :access-key "minioadmin"
         :secret-key "minioadmin"
         :bucket     "ajet-chat"}}
```

**Test Cases:**

| ID | Test | Type | Description |
|----|------|------|-------------|
| FS-T1 | Upload file | Integration | `upload!` stores file, retrievable by key |
| FS-T2 | Download file | Integration | `download` returns same bytes as uploaded |
| FS-T3 | Delete file | Integration | `delete!` removes file, subsequent download returns nil |
| FS-T4 | Presigned URL | Integration | Generated URL is accessible and expires |
| FS-T5 | Invalid content-type | Unit | Non-image content-type throws validation error |
| FS-T6 | Oversized file | Unit | File > 10MB throws validation error |
| FS-T7 | Bucket auto-creation | Integration | On startup, creates bucket if missing |
| FS-T8 | Upload with storage key format | Unit | Key matches `attachments/{uuid}/{filename}` pattern |
