32 KiB
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 userX-User-Role: <owner|admin|member>— role in the community contextX-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
-- 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
banstable 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
mutestable withexpires_attimestamp - 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
tsvectorfull-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:
{"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:
- Validate file type and size
- Upload to MinIO with storage key
attachments/{uuid}/{filename} - 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:
{"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
{"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
{: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):
{"status": "ok", "checks": {"db": "ok", "nats": "ok", "minio": "ok"}}
Response (503 — degraded):
{"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:
userstable + unique index onusername+ unique index onemail - 006-create-channels:
channelstable,community_idis nullable (DMs have NULL) - 008-create-messages:
messagestable + 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 onmessagesfor full-text search - 019-create-bans:
banstable with composite PK(community_id, user_id) - 020-create-mutes:
mutestable with composite PK(community_id, user_id), index onexpires_at - 021-create-oauth-providers:
oauth_providerstable for runtime-configurable OAuth providers - 022-create-system-settings:
system_settingskey-value table for deployment-wide settings (e.g.,setup_completed)