798 lines
32 KiB
Markdown
798 lines
32 KiB
Markdown
# 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`)
|