# PRD: API Service

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

---

## 1. Overview

The API service is the stateless REST backend — the only service (besides Auth GW) with direct PostgreSQL access. It handles all data reads/writes and publishes events to NATS after mutations. All other services interact with data through the API.

## 2. Architecture

```
Auth Gateway → API Service → PostgreSQL
                           → NATS (publish events)
                           → MinIO (file storage)
```

- **No direct client access** — all requests arrive via Auth GW with validated session context
- **Request headers from Auth GW:**
  - `X-User-Id: <uuid>` — authenticated user
  - `X-User-Role: <owner|admin|member>` — role in the community context
  - `X-Community-Id: <uuid>` — current community (for community-scoped endpoints)
  - `X-Trace-Id: <uuid>` — request tracing

## 3. Database Schema

### 3.1 Migrations (Migratus, sequential)

```
001-create-users.up.sql
002-create-oauth-accounts.up.sql
003-create-communities.up.sql
004-create-community-members.up.sql
005-create-channel-categories.up.sql
006-create-channels.up.sql
007-create-channel-members.up.sql
008-create-messages.up.sql
009-create-attachments.up.sql
010-create-reactions.up.sql
011-create-mentions.up.sql
012-create-notifications.up.sql
013-create-sessions.up.sql
014-create-api-users.up.sql
015-create-api-tokens.up.sql
016-create-webhooks.up.sql
017-create-invites.up.sql
018-add-search-indexes.up.sql
019-create-bans.up.sql
020-create-mutes.up.sql
021-create-oauth-providers.up.sql
022-create-system-settings.up.sql
```

### 3.2 Tables

```sql
-- Core entities
users (id uuid PK, username text UNIQUE, display_name text, email text, avatar_url text, status_text text, created_at timestamptz)
oauth_accounts (id uuid PK, user_id uuid FK→users, provider text, provider_user_id text, created_at timestamptz, UNIQUE(provider, provider_user_id))
communities (id uuid PK, name text, slug text UNIQUE, created_at timestamptz)
community_members (community_id uuid FK, user_id uuid FK, role text CHECK(owner/admin/member), nickname text, avatar_url text, joined_at timestamptz, PK(community_id, user_id))
channel_categories (id uuid PK, community_id uuid FK, name text, position int)
channels (id uuid PK, community_id uuid FK NULL, category_id uuid FK NULL, name text, type text CHECK(text/dm/group_dm), visibility text CHECK(public/private), topic text, created_at timestamptz)
channel_members (channel_id uuid FK, user_id uuid FK, joined_at timestamptz, last_read_message_id uuid, PK(channel_id, user_id))
messages (id uuid PK, channel_id uuid FK, user_id uuid FK, parent_id uuid FK→messages NULL, body_md text, created_at timestamptz, edited_at timestamptz NULL)
attachments (id uuid PK, message_id uuid FK, filename text, content_type text, size_bytes bigint, storage_key text)
reactions (message_id uuid FK, user_id uuid FK, emoji text, created_at timestamptz, PK(message_id, user_id, emoji))
mentions (id uuid PK, message_id uuid FK, target_type text CHECK(user/channel/here), target_id uuid NULL)
notifications (id uuid PK, user_id uuid FK, type text CHECK(mention/dm/thread_reply/invite/system), source_id uuid, read boolean DEFAULT false, created_at timestamptz)
webhooks (id uuid PK, community_id uuid FK, channel_id uuid FK, name text, avatar_url text, token_hash text, created_by uuid FK, created_at timestamptz)
invites (id uuid PK, community_id uuid FK, created_by uuid FK, code text UNIQUE, max_uses int NULL, uses int DEFAULT 0, expires_at timestamptz NULL, created_at timestamptz)

-- Indexes
idx_messages_channel_created ON messages(channel_id, created_at)
idx_messages_parent ON messages(parent_id) WHERE parent_id IS NOT NULL
idx_messages_search ON messages USING GIN(to_tsvector('english', body_md))
idx_notifications_user_unread ON notifications(user_id, created_at) WHERE read = false
idx_channel_members_user ON channel_members(user_id)
idx_community_members_user ON community_members(user_id)

-- Ban/mute enforcement
bans (community_id uuid FK, user_id uuid FK, reason text, banned_by uuid FK, created_at timestamptz, PK(community_id, user_id))
mutes (community_id uuid FK, user_id uuid FK, expires_at timestamptz, muted_by uuid FK, created_at timestamptz, PK(community_id, user_id))
idx_mutes_expires ON mutes(expires_at) WHERE expires_at IS NOT NULL

-- OAuth providers (runtime-configurable)
oauth_providers (id uuid PK, provider_type text CHECK(github/gitea/oidc), name text, client_id text, client_secret_encrypted text, base_url text NULL, issuer_url text NULL, enabled boolean DEFAULT true, created_at timestamptz, updated_at timestamptz)
idx_oauth_providers_type ON oauth_providers(provider_type)

-- System settings (key-value for deployment-wide config)
system_settings (key text PK, value jsonb, updated_at timestamptz)
```

### 3.3 Ban & Mute Enforcement

**Bans:**
- Ban record in `bans` table prevents user from:
  - Sending messages in any channel of the community
  - Joining channels
  - Accepting invites to the community
- Banned user is removed from all channels and community membership on ban
- Ban check runs in middleware for all community-scoped API endpoints
- Bans are permanent until explicitly lifted by Admin+

**Mutes:**
- Mute record in `mutes` table with `expires_at` timestamp
- Muted user cannot:
  - Send messages (POST to message endpoints returns 403)
  - Add reactions
  - Send typing indicators
- Muted user CAN still read messages and channels
- Expired mutes are ignored (no cleanup needed — checked on read)
- Duration specified as interval: `10m`, `1h`, `24h`, `7d`

## 4. API Endpoints

### 4.1 Communities

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/communities` | Create community (first-user bootstrap or new) | User |
| GET | `/api/communities` | List user's communities | User |
| GET | `/api/communities/:id` | Get community details | Member |
| PUT | `/api/communities/:id` | Update community name/slug | Owner |
| DELETE | `/api/communities/:id` | Delete community | Owner |

**POST /api/communities**
```
Request:  {"name": "My Team", "slug": "my-team"}
Response: {"id": "uuid", "name": "My Team", "slug": "my-team", "created_at": "..."}
Side effects:
  - Creates community
  - Adds requesting user as owner
  - Creates #general channel (text, public)
  - Publishes :community/created to chat.events.{id}
```

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| COM-T1 | Create community | POST creates community, user is owner, #general exists |
| COM-T2 | Slug uniqueness | POST with existing slug returns 409 |
| COM-T3 | Slug format validation | Slug with spaces/uppercase returns 422 |
| COM-T4 | List communities | GET returns only communities user is a member of |
| COM-T5 | Get community (member) | GET returns community details |
| COM-T6 | Get community (non-member) | GET returns 403 |
| COM-T7 | Update community (owner) | PUT updates name/slug |
| COM-T8 | Update community (admin) | PUT returns 403 (owner-only) |
| COM-T9 | Delete community (owner) | DELETE removes community and all associated data |
| COM-T10 | Delete community (non-owner) | DELETE returns 403 |

---

### 4.2 Channels

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/communities/:cid/channels` | List channels in community | Member |
| POST | `/api/communities/:cid/channels` | Create channel | Admin+ |
| GET | `/api/channels/:id` | Get channel details | Channel member (or community member if public) |
| PUT | `/api/channels/:id` | Update channel | Admin+ |
| DELETE | `/api/channels/:id` | Delete channel | Admin+ |
| POST | `/api/channels/:id/join` | Join channel (public only) | Member |
| POST | `/api/channels/:id/leave` | Leave channel | Member |
| GET | `/api/channels/:id/members` | List channel members | Channel member |

**POST /api/communities/:cid/channels**
```
Request:  {"name": "backend", "type": "text", "visibility": "public", "category_id": "uuid or null"}
Response: {"id": "uuid", "name": "backend", ...}
Side effects:
  - Creates channel
  - Adds creator as channel member
  - Publishes :channel/created to chat.events.{community-id}
```

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| CH-T1 | Create public channel | POST creates channel, creator is member, event published |
| CH-T2 | Create private channel | POST with visibility=private works |
| CH-T3 | Create channel (member role) | POST returns 403 for non-admin |
| CH-T4 | Duplicate channel name | POST with existing name in same community returns 409 |
| CH-T5 | List channels | GET returns public channels + private channels user is in |
| CH-T6 | Join public channel | POST /join adds user to channel, publishes :member/joined |
| CH-T7 | Join private channel | POST /join returns 403 (must be invited) |
| CH-T8 | Leave channel | POST /leave removes membership, publishes :member/left |
| CH-T9 | Leave last channel | Leaving #general is allowed |
| CH-T10 | Delete channel | DELETE removes channel, all messages, publishes :channel/deleted |
| CH-T11 | Update channel topic | PUT with new topic, publishes :channel/updated |
| CH-T12 | Assign category | PUT with category_id moves channel to category |

---

### 4.3 Channel Categories

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/communities/:cid/categories` | List categories (ordered) | Member |
| POST | `/api/communities/:cid/categories` | Create category | Admin+ |
| PUT | `/api/categories/:id` | Update category (name, position) | Admin+ |
| DELETE | `/api/categories/:id` | Delete category (channels become uncategorized) | Admin+ |

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| CAT-T1 | Create category | POST creates with position, returns category |
| CAT-T2 | Reorder categories | PUT with new position reorders correctly |
| CAT-T3 | Delete category | Channels in deleted category become uncategorized (category_id = null) |
| CAT-T4 | List categories with channels | GET returns categories with nested channel list |

---

### 4.4 Messages

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/channels/:id/messages` | List messages (paginated) | Channel member |
| POST | `/api/channels/:id/messages` | Send message | Channel member |
| GET | `/api/messages/:id` | Get single message | Channel member |
| PUT | `/api/messages/:id` | Edit message (1-hour window) | Author |
| DELETE | `/api/messages/:id` | Delete message | Author or Admin+ |
| GET | `/api/messages/:id/thread` | Get thread replies | Channel member |

**Pagination:** Cursor-based using message UUID.
```
GET /api/channels/:id/messages?before=<uuid>&limit=50
GET /api/channels/:id/messages?after=<uuid>&limit=50
Default limit: 50, max: 100
```

**POST /api/channels/:id/messages**
```
Request:  {"body_md": "hello @<user:uuid> check #<channel:uuid>", "parent_id": null}
Response: {"id": "uuid", "channel_id": "...", "user_id": "...", "body_md": "...", "created_at": "..."}
Side effects:
  - Creates message row
  - Parses mentions → creates mention rows
  - Creates notification rows for mentioned users
  - Publishes :message/created to appropriate NATS subject
  - If thread reply: notifies thread participants
```

**PUT /api/messages/:id (edit)**
```
Request:  {"body_md": "updated text"}
Response: {"id": "uuid", ..., "edited_at": "..."}
Validation:
  - User must be the author
  - created_at must be within 1 hour of now
  - Re-parses mentions, updates mention rows
  - Publishes :message/edited
```

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| MSG-T1 | Send message | POST creates message, returns with ID and timestamps |
| MSG-T2 | Send message to channel user is not in | POST returns 403 |
| MSG-T3 | Send empty message | POST with empty body returns 422 |
| MSG-T4 | Send message with mentions | Mention rows created, notifications created for mentioned users |
| MSG-T5 | Send message with @here | Notifications created for all online channel members |
| MSG-T6 | Send thread reply | POST with parent_id creates threaded message, notifies thread participants |
| MSG-T7 | Thread does not nest | POST with parent_id pointing to another reply is rejected (or parent_id resolves to root) |
| MSG-T8 | Get messages (default pagination) | GET returns last 50 messages, newest last |
| MSG-T9 | Get messages (before cursor) | GET with `?before=uuid` returns older messages |
| MSG-T10 | Get messages (after cursor) | GET with `?after=uuid` returns newer messages |
| MSG-T11 | Edit message within window | PUT succeeds within 1 hour, sets edited_at |
| MSG-T12 | Edit message after window | PUT returns 403 after 1 hour |
| MSG-T13 | Edit message (non-author) | PUT returns 403 |
| MSG-T14 | Edit updates mentions | Editing to add @user creates new mention + notification |
| MSG-T15 | Delete own message | DELETE removes message, publishes :message/deleted |
| MSG-T16 | Delete other's message (admin) | DELETE succeeds for admin/owner |
| MSG-T17 | Delete other's message (member) | DELETE returns 403 |
| MSG-T18 | Get thread | GET /thread returns all replies to a message, ordered by created_at |
| MSG-T19 | NATS event published | After send, :message/created event appears on correct subject |
| MSG-T20 | DM message event | Message in DM channel publishes to `chat.dm.{channel-id}` |
| MSG-T21 | Community message event | Message in community channel publishes to `chat.events.{community-id}` |

---

### 4.5 Reactions

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| PUT | `/api/messages/:id/reactions/:emoji` | Add reaction (idempotent) | Channel member |
| DELETE | `/api/messages/:id/reactions/:emoji` | Remove reaction | Reactor |
| GET | `/api/messages/:id/reactions` | List reactions on message | Channel member |

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| RXN-T1 | Add reaction | PUT creates reaction, publishes :reaction/added |
| RXN-T2 | Add duplicate reaction | PUT is idempotent, no error |
| RXN-T3 | Remove reaction | DELETE removes, publishes :reaction/removed |
| RXN-T4 | Remove non-existent reaction | DELETE returns 404 |
| RXN-T5 | List reactions | GET returns reactions grouped by emoji with user lists |
| RXN-T6 | Multiple users same emoji | Both reactions stored, count shows 2 |

---

### 4.6 DMs (Global)

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/dms` | List user's DM channels | User |
| POST | `/api/dms` | Create or get existing DM | User |
| POST | `/api/dms/group` | Create group DM | User |

**POST /api/dms**
```
Request:  {"user_id": "uuid"}
Response: {"id": "channel-uuid", "type": "dm", "members": [...]}
Behavior: Returns existing DM if one exists between the two users, otherwise creates new.
```

**POST /api/dms/group**
```
Request:  {"user_ids": ["uuid1", "uuid2", ...]}
Response: {"id": "channel-uuid", "type": "group_dm", "members": [...]}
```

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| DM-T1 | Create DM | POST creates DM channel with both users as members |
| DM-T2 | Get existing DM | POST for existing pair returns same channel |
| DM-T3 | Create group DM | POST /group creates group_dm with all specified users |
| DM-T4 | List DMs | GET returns all DM/group_dm channels for user, ordered by last message |
| DM-T5 | DM with self | POST with own user_id returns 422 |
| DM-T6 | Group DM minimum | POST /group with < 2 other users returns 422 |

---

### 4.7 Users & Profiles

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/me` | Get current user | User |
| PUT | `/api/me` | Update profile (display_name, status_text) | User |
| GET | `/api/users/:id` | Get user profile | User |
| GET | `/api/communities/:cid/members` | List community members | Member |
| PUT | `/api/communities/:cid/members/:uid` | Update member (nickname, role) | Admin+ (role: Owner only) |
| DELETE | `/api/communities/:cid/members/:uid` | Remove/kick member | Admin+ |

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| USR-T1 | Get current user | GET /me returns authenticated user's profile |
| USR-T2 | Update display name | PUT /me with display_name updates it |
| USR-T3 | Update status text | PUT /me with status_text updates it |
| USR-T4 | Get user profile | GET /users/:id returns public profile |
| USR-T5 | List community members | GET returns all members with roles and nicknames |
| USR-T6 | Set community nickname | PUT /members/:uid with nickname (admin) |
| USR-T7 | Change member role (owner) | Owner can promote member to admin |
| USR-T8 | Change member role (admin) | Admin cannot change roles (403) |
| USR-T9 | Kick member (admin) | DELETE removes member, publishes :member/kicked |
| USR-T10 | Kick owner | Cannot kick owner (403) |

---

### 4.8 Notifications

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/notifications` | List notifications (paginated) | User |
| POST | `/api/notifications/read` | Mark notifications as read | User |
| GET | `/api/notifications/unread-count` | Get unread count | User |

**Pagination:** `?after=<uuid>&limit=50&unread=true`

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| NTF-T1 | Get notifications | GET returns notifications newest-first |
| NTF-T2 | Filter unread | GET with ?unread=true returns only unread |
| NTF-T3 | Mark read | POST with notification IDs marks them read |
| NTF-T4 | Unread count | GET /unread-count returns integer count |
| NTF-T5 | Notification on mention | @mention in message creates notification |
| NTF-T6 | Notification on DM | New DM message creates notification |
| NTF-T7 | Notification on thread reply | Reply to your thread root creates notification |

---

### 4.9 Presence & Typing

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/heartbeat` | Report online status | User |
| GET | `/api/communities/:cid/presence` | Get online members | Member |

**POST /api/heartbeat**
```
Response: 200 OK (immediate)
Side effects:
  - Async: update last_seen_at in DB
  - Async: publish :presence/online to chat.presence.{community-id} for each community
```

**Presence logic:**
- Online: last heartbeat < 2 minutes ago
- Offline: last heartbeat >= 2 minutes ago
- Clients send heartbeat every 60 seconds

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| PRS-T1 | Heartbeat returns 200 immediately | POST /heartbeat responds fast (< 50ms) |
| PRS-T2 | Heartbeat updates last_seen | DB shows updated last_seen_at after heartbeat |
| PRS-T3 | Online presence | User with recent heartbeat appears in presence list |
| PRS-T4 | Offline after timeout | User without heartbeat for 2+ minutes not in online list |
| PRS-T5 | Presence event published | Heartbeat publishes to NATS presence subject |

---

### 4.10 Search

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/search` | Global search | User |

**Parameters:**
```
?q=search+term          — required, search query
&type=messages|channels|users  — optional filter (default: all)
&community_id=uuid      — optional, scope to community
&channel_id=uuid        — optional, scope to channel
&from=uuid              — optional, filter by author
&after=datetime         — optional, date range start
&before=datetime        — optional, date range end
&cursor=uuid&limit=20   — pagination
```

**Behavior:**
- Messages: PostgreSQL `tsvector` full-text search, only in channels user has access to
- Channels: name substring match within user's communities
- Users: username/display_name substring match

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| SRC-T1 | Search messages by keyword | Returns matching messages user can access |
| SRC-T2 | Search respects access | Messages in channels user isn't in are excluded |
| SRC-T3 | Search in specific channel | `channel_id` filter narrows results |
| SRC-T4 | Search by author | `from` filter returns only that user's messages |
| SRC-T5 | Search channels | `type=channels` returns matching channel names |
| SRC-T6 | Search users | `type=users` returns matching usernames/display names |
| SRC-T7 | Empty query | Returns 422 |
| SRC-T8 | Pagination | Cursor-based pagination returns next page |
| SRC-T9 | Date range filter | `after`/`before` narrows message results |

---

### 4.11 Invites

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/communities/:cid/invites` | Create invite link | Admin+ |
| GET | `/api/communities/:cid/invites` | List active invites | Admin+ |
| DELETE | `/api/invites/:id` | Revoke invite | Admin+ |
| POST | `/api/invites/:code/accept` | Accept invite (join community) | User |
| POST | `/api/communities/:cid/invites/direct` | Direct invite (by user ID) | Admin+ |

**POST /api/communities/:cid/invites**
```
Request:  {"max_uses": 10, "expires_in_hours": 48}   — both optional
Response: {"id": "uuid", "code": "abc123", "url": "https://chat.example.com/invite/abc123", ...}
```

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| INV-T1 | Create invite link | POST creates invite with unique code |
| INV-T2 | Accept invite | POST /accept adds user to community as member |
| INV-T3 | Accept expired invite | POST /accept returns 410 (gone) |
| INV-T4 | Accept max-uses exhausted | POST /accept returns 410 after max uses reached |
| INV-T5 | Accept invite (already member) | POST /accept returns 200 (idempotent) |
| INV-T6 | List invites | GET returns active (non-expired, non-exhausted) invites |
| INV-T7 | Revoke invite | DELETE invalidates the invite |
| INV-T8 | Direct invite | POST /direct creates invite + notification for target user |
| INV-T9 | Create invite (member role) | POST returns 403 |

---

### 4.12 Webhooks (Incoming)

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/communities/:cid/webhooks` | Create webhook | Admin+ |
| GET | `/api/communities/:cid/webhooks` | List webhooks | Admin+ |
| DELETE | `/api/webhooks/:id` | Delete webhook | Admin+ |
| POST | `/api/webhooks/:id/incoming` | Post via webhook | Webhook token |

**POST /api/webhooks/:id/incoming**
```
Headers: Authorization: Bearer <webhook-token>
Request: {"content": "Build #42 passed!", "username": "CI Bot", "avatar_url": "..."}
Response: 204 No Content
Side effects: Creates message in webhook's channel, publishes event
```

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| WH-T1 | Create webhook | POST creates webhook with token, returns token once |
| WH-T2 | Post via webhook | POST /incoming creates message in channel |
| WH-T3 | Post with invalid token | Returns 401 |
| WH-T4 | Post with custom username | Message shows webhook's custom username |
| WH-T5 | Delete webhook | DELETE removes webhook, token becomes invalid |
| WH-T6 | List webhooks | GET returns webhooks (without tokens) |

---

### 4.13 Slash Commands

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/commands` | Execute slash command | User |

**Request:**
```json
{"command": "/kick @<user:uuid>", "channel_id": "uuid", "community_id": "uuid"}
```

**Commands v1:**

| Command | Args | Permission | Description |
|---------|------|------------|-------------|
| `/help` | `[command]` | All | Show help or specific command help |
| `/topic` | `<text>` | Admin+ | Set channel topic |
| `/nick` | `<nickname>` | All | Set community nickname |
| `/invite` | `[max_uses] [expires_hours]` | Admin+ | Generate invite link |
| `/kick` | `@<user>` | Admin+ | Kick user from community |
| `/ban` | `@<user>` | Admin+ | Ban user from community |
| `/mute` | `@<user> <duration>` | Admin+ | Mute user for duration |
| `/token create` | `<name> [scopes]` | Owner | Create API token |
| `/token revoke` | `<name>` | Owner | Revoke API token |
| `/token list` | | Owner | List API tokens |
| `/webhook create` | `<name> [channel]` | Admin+ | Create incoming webhook |
| `/webhook delete` | `<name>` | Admin+ | Delete webhook |
| `/webhook list` | | Admin+ | List webhooks |
| `/status` | `<text>` | All | Set status text |

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| CMD-T1 | /help returns command list | All commands listed with descriptions |
| CMD-T2 | /topic sets channel topic | Channel topic updated, event published |
| CMD-T3 | /topic (non-admin) | Returns 403 |
| CMD-T4 | /nick sets nickname | Community member nickname updated |
| CMD-T5 | /kick removes user | User removed from community, event published |
| CMD-T6 | /kick (non-admin) | Returns 403 |
| CMD-T7 | /ban bans user | User banned (cannot rejoin) |
| CMD-T8 | /mute mutes user | User cannot send messages for duration |
| CMD-T9 | /token create | Creates API token, returns token string once |
| CMD-T10 | /webhook create | Creates webhook, returns webhook URL + token |
| CMD-T11 | Unknown command | Returns error message suggesting /help |
| CMD-T12 | /status sets status text | User's status_text updated |

---

### 4.14 File Upload

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/channels/:id/upload` | Upload image | Channel member |

**Request:** Multipart form data with image file.
**Constraints:** Images only (JPEG, PNG, GIF, WebP). Max size: 10MB.

```
Response: {"id": "uuid", "filename": "screenshot.png", "content_type": "image/png", "size_bytes": 12345, "url": "/files/uuid/screenshot.png"}
```

**Flow:**
1. Validate file type and size
2. Upload to MinIO with storage key `attachments/{uuid}/{filename}`
3. Return attachment metadata (to be referenced in message)

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| UPL-T1 | Upload JPEG | File stored in MinIO, attachment record created |
| UPL-T2 | Upload PNG | Same as above |
| UPL-T3 | Upload non-image | Returns 422 (only images allowed) |
| UPL-T4 | Upload too large | Returns 413 (> 10MB) |
| UPL-T5 | Upload to channel user isn't in | Returns 403 |

---

### 4.15 Read Tracking

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/channels/:id/read` | Mark channel as read | Channel member |

**Request:**
```json
{"last_read_message_id": "uuid"}
```

Updates `channel_members.last_read_message_id`. Used by SMs to calculate unread counts.

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| RD-T1 | Mark read | POST updates last_read_message_id |
| RD-T2 | Unread count derivation | Messages after last_read_message_id are "unread" |
| RD-T3 | Mark read (non-member) | Returns 403 |

---

### 4.16 Admin: OAuth Provider Management

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/admin/oauth-providers` | List all OAuth providers | Owner |
| POST | `/api/admin/oauth-providers` | Create OAuth provider | Owner |
| PUT | `/api/admin/oauth-providers/:id` | Update OAuth provider | Owner |
| DELETE | `/api/admin/oauth-providers/:id` | Delete OAuth provider | Owner |

**POST /api/admin/oauth-providers**
```
Request:  {"provider_type": "github", "name": "GitHub", "client_id": "...", "client_secret": "...", "enabled": true}
Response: {"id": "uuid", "provider_type": "github", "name": "GitHub", "client_id": "...", "enabled": true, "created_at": "..."}
Note: client_secret is encrypted at rest and never returned in responses.
```

**PUT /api/admin/oauth-providers/:id**
```
Request:  {"name": "GitHub Org", "client_id": "...", "client_secret": "...", "enabled": false}
Response: {"id": "uuid", "provider_type": "github", "name": "GitHub Org", ...}
Note: Omitting client_secret from the request leaves it unchanged.
```

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| AOP-T1 | Create OAuth provider | POST creates provider, returns without client_secret |
| AOP-T2 | List providers | GET returns all providers without secrets |
| AOP-T3 | Update provider | PUT updates name/enabled, secret unchanged if omitted |
| AOP-T4 | Delete provider | DELETE removes provider |
| AOP-T5 | Non-owner access | Non-owner user returns 403 |
| AOP-T6 | Invalid provider_type | POST with unknown type returns 422 |
| AOP-T7 | Duplicate provider_type | Allowed (multiple GitHub providers for different orgs) |

---

## 5. Cross-Cutting Concerns

### 5.1 Error Response Format
```json
{"error": {"code": "NOT_FOUND", "message": "Channel not found", "details": {}}}
```

### 5.2 Rate Limiting
- Applied at Auth GW level, not in API service itself
- API trusts that Auth GW has already rate-limited

### 5.3 NATS Publishing
- Every mutation endpoint publishes an event to the appropriate NATS subject
- Events are fire-and-forget (API does not wait for subscriber acknowledgment)
- Event publishing failure should be logged but not fail the HTTP response

### 5.4 Audit Trail (P2)
- Log all admin actions (kick, ban, role change, channel delete) with actor + target + timestamp
- Queryable by Owner via future admin API

---

## 6. Service Configuration

### 6.1 Config Shape

```clojure
{:server {:host "0.0.0.0" :port 3001}
 :db     {:host "localhost" :port 5432 :dbname "ajet_chat"
          :user "ajet" :password "..." :pool-size 10
          :migrations {:enabled true :location "migrations"}}
 :nats   {:url "nats://localhost:4222"
          :stream-name "ajet-events"
          :publish-timeout-ms 5000}
 :minio  {:endpoint "http://localhost:9000"
          :access-key "minioadmin" :secret-key "minioadmin"
          :bucket "ajet-chat"}
 :limits {:max-message-length 4000        ;; characters
          :max-upload-size    10485760     ;; 10MB in bytes
          :edit-window-minutes 60
          :default-page-size  50
          :max-page-size      100}}
```

### 6.2 Middleware Pipeline

Requests flow through middleware in this order:

```
1. Ring defaults (params, cookies, multipart)
2. Request logging (method, path, start time)
3. Exception handler (catch-all → 500 JSON error)
4. Trace ID extraction (X-Trace-Id → MDC)
5. User context extraction (X-User-Id, X-User-Role, X-Community-Id → request map)
6. Ban check (community-scoped: reject if user is banned)
7. Mute check (write endpoints: reject if user is muted)
8. Reitit routing → handler
9. Response logging (status, duration)
```

### 6.3 Startup / Shutdown Sequence

**Startup:**
```
1. Load config (EDN + env vars)
2. Create DB connection pool (HikariCP)
3. Run Migratus migrations (if enabled)
4. Connect to NATS
5. Connect to MinIO, ensure bucket exists
6. Start http-kit server
7. Log "API service started on port {port}"
```

**Shutdown (graceful):**
```
1. Stop accepting new HTTP connections
2. Wait for in-flight requests (max 30s)
3. Close NATS connection
4. Close DB connection pool
5. Log "API service stopped"
```

### 6.4 Health Check

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/health` | None | Service health status |

**Response (200):**
```json
{"status": "ok", "checks": {"db": "ok", "nats": "ok", "minio": "ok"}}
```

**Response (503 — degraded):**
```json
{"status": "degraded", "checks": {"db": "ok", "nats": "error", "minio": "ok"}}
```

**Test Cases:**

| ID | Test | Description |
|----|------|-------------|
| HLT-T1 | Health check all up | Returns 200 with all checks "ok" |
| HLT-T2 | Health check DB down | Returns 503 with db check "error" |
| HLT-T3 | Health check NATS down | Returns 503 with nats check "error" |
| HLT-T4 | Health check MinIO down | Returns 503 with minio check "error" |

---

## 7. Migration SQL (Outlines)

### 7.1 Migration Naming Convention

```
{NNN}-{description}.up.sql     — forward migration
{NNN}-{description}.down.sql   — rollback migration
```

Migrations are sequential and must be applied in order. Each migration is idempotent — re-running a completed migration is a no-op (handled by Migratus tracking table).

### 7.2 Key Migration Notes

- **001-create-users:** `users` table + unique index on `username` + unique index on `email`
- **006-create-channels:** `channels` table, `community_id` is nullable (DMs have NULL)
- **008-create-messages:** `messages` table + composite index `(channel_id, created_at)` for pagination
- **010-create-reactions:** Composite PK `(message_id, user_id, emoji)` — one reaction per user per emoji per message
- **018-add-search-indexes:** `to_tsvector('english', body_md)` GIN index on `messages` for full-text search
- **019-create-bans:** `bans` table with composite PK `(community_id, user_id)`
- **020-create-mutes:** `mutes` table with composite PK `(community_id, user_id)`, index on `expires_at`
- **021-create-oauth-providers:** `oauth_providers` table for runtime-configurable OAuth providers
- **022-create-system-settings:** `system_settings` key-value table for deployment-wide settings (e.g., `setup_completed`)
