# 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: ` — authenticated user - `X-User-Role: ` — role in the community context - `X-Community-Id: ` — current community (for community-scoped endpoints) - `X-Trace-Id: ` — 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=&limit=50 GET /api/channels/:id/messages?after=&limit=50 Default limit: 50, max: 100 ``` **POST /api/channels/:id/messages** ``` Request: {"body_md": "hello @ check #", "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=&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 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 @", "channel_id": "uuid", "community_id": "uuid"} ``` **Commands v1:** | Command | Args | Permission | Description | |---------|------|------------|-------------| | `/help` | `[command]` | All | Show help or specific command help | | `/topic` | `` | Admin+ | Set channel topic | | `/nick` | `` | All | Set community nickname | | `/invite` | `[max_uses] [expires_hours]` | Admin+ | Generate invite link | | `/kick` | `@` | Admin+ | Kick user from community | | `/ban` | `@` | Admin+ | Ban user from community | | `/mute` | `@ ` | Admin+ | Mute user for duration | | `/token create` | ` [scopes]` | Owner | Create API token | | `/token revoke` | `` | Owner | Revoke API token | | `/token list` | | Owner | List API tokens | | `/webhook create` | ` [channel]` | Admin+ | Create incoming webhook | | `/webhook delete` | `` | Admin+ | Delete webhook | | `/webhook list` | | Admin+ | List webhooks | | `/status` | `` | 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`)