init prds
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# ajet-chat PRDs
|
||||
|
||||
Product Requirements Documents for ajet-chat v1.
|
||||
|
||||
## Documents
|
||||
|
||||
| Module | PRD | Test Cases | Status |
|
||||
|--------|-----|------------|--------|
|
||||
| [shared](shared.md) | DB, EventBus, API Client, Schemas, Mentions, Markdown | 60 | v1 |
|
||||
| [api](api.md) | REST API: 15 endpoint groups, full CRUD | 95 | v1 |
|
||||
| [auth-gw](auth-gw.md) | OAuth, session/token validation, reverse proxy | 40 | v1 |
|
||||
| [web-sm](web-sm.md) | Browser UI: Datastar SSE, Discord layout | 57 | v1 |
|
||||
| [tui-sm](tui-sm.md) | TUI session manager: SSE JSON events | 30 | v1 |
|
||||
| [cli](cli.md) | CLI commands + Rich TUI (clojure-tui) | 50 | v1 |
|
||||
| [mobile](mobile.md) | Deferred — PWA recommended for v1 | 0 | v2+ |
|
||||
|
||||
**Total test cases: ~332**
|
||||
|
||||
## Key Product Decisions
|
||||
|
||||
- **Multi-community** — users can be in multiple communities (Discord model)
|
||||
- **Discord-style layout** — community icon strip, categories, channel sidebar
|
||||
- **DMs are global** — separate from communities, always accessible
|
||||
- **Flat threads** — Slack-style 1-level threads, no nesting
|
||||
- **Discord-flavor markdown** — bold, italic, strikethrough, underline, spoilers, code blocks
|
||||
- **Mention storage** — `@<user:uuid>` / `@<here>` / `#<channel:uuid>` in DB, rendered at display time
|
||||
- **1-hour edit window** — messages can only be edited within 1 hour of creation
|
||||
- **Images + paste** — clipboard paste and upload, no arbitrary file types in v1
|
||||
- **OAuth-only auth** — GitHub + Gitea + generic OIDC (for self-hosters)
|
||||
- **3-tier roles** — Owner / Admin / Member (no custom roles in v1)
|
||||
- **Invite links + direct invites** — admins generate links or invite by user ID
|
||||
- **Incoming webhooks** — external services POST to channel (outgoing deferred)
|
||||
- **Full slash commands** — /help, /kick, /ban, /mute, /topic, /nick, /token, /webhook, /status
|
||||
- **Global search** — messages + channels + users via PostgreSQL tsvector
|
||||
- **Full Datastar hypermedia** — server-rendered HTML, SSE fragment updates, minimal client JS
|
||||
- **Paginated messages** — "Load older" button, cursor-based pagination
|
||||
- **Rich TUI** — split panes, inline images (timg/sixel), markdown rendering, mouse support
|
||||
- **Full CLI** — all operations scriptable, JSON output, stdin piping
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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)
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,287 @@
|
||||
# PRD: Auth Gateway
|
||||
|
||||
**Module:** `auth-gw/` | **Namespace:** `ajet.chat.auth-gw.*`
|
||||
**Status:** v1 | **Last updated:** 2026-02-17
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The Auth Gateway is the single edge entry point for all client traffic. It terminates sessions, validates tokens, and reverse-proxies authenticated requests to internal services (API, Web SM, TUI SM). It also handles OAuth login flows and session management.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
Client → (nginx TLS, prod) → Auth Gateway → API Service
|
||||
→ Web Session Manager
|
||||
→ TUI Session Manager
|
||||
```
|
||||
|
||||
**Auth GW has direct PG access** for session/token table lookups — this avoids a round-trip to the API for every request.
|
||||
|
||||
## 3. Route Table
|
||||
|
||||
| Path Pattern | Target | Auth Required | Description |
|
||||
|--------------|--------|---------------|-------------|
|
||||
| `GET /` | Web SM | Session | Web app root |
|
||||
| `GET /app/*` | Web SM | Session | Web app pages |
|
||||
| `GET /sse/*` | Web SM | Session | SSE streams for web |
|
||||
| `POST /web/*` | Web SM | Session | Web form submissions / Datastar signals |
|
||||
| `GET,POST /api/*` | API | Session or API Token | REST API |
|
||||
| `GET /tui/sse/*` | TUI SM | Session | SSE streams for TUI clients |
|
||||
| `POST /tui/*` | TUI SM | Session | TUI client signals |
|
||||
| `POST /api/webhooks/*/incoming` | API | Webhook Token | Incoming webhooks (bypass session auth) |
|
||||
| `GET /auth/login` | Self | None | Login page |
|
||||
| `GET /auth/callback/:provider` | Self | None | OAuth callback |
|
||||
| `POST /auth/logout` | Self | Session | Logout (destroy session) |
|
||||
| `GET /invite/:code` | Self | None | Invite landing page → redirect to login if needed |
|
||||
| `GET /health` | Self | None | Health check |
|
||||
|
||||
## 4. Authentication Flows
|
||||
|
||||
### 4.1 Session Token Validation
|
||||
|
||||
Every authenticated request follows this flow:
|
||||
|
||||
```
|
||||
1. Extract token from Cookie: ajet_session=<base64url-token>
|
||||
2. bcrypt-verify token against sessions.token_hash
|
||||
3. Check sessions.expires_at > now
|
||||
4. If valid:
|
||||
a. Extend session TTL (rolling expiry) — async, don't block request
|
||||
b. Inject headers: X-User-Id, X-User-Role, X-Community-Id, X-Trace-Id
|
||||
c. Proxy to target service
|
||||
5. If invalid/expired: redirect to /auth/login (web) or 401 (API/TUI)
|
||||
```
|
||||
|
||||
**Rolling expiry:** Each valid request extends `expires_at` by the session TTL (default: 30 days). This is done asynchronously to avoid adding latency.
|
||||
|
||||
**Token format:** 32 random bytes, base64url-encoded (43 characters). Stored as bcrypt hash.
|
||||
|
||||
### 4.2 API Token Validation
|
||||
|
||||
For `Authorization: Bearer <token>` requests to `/api/*`:
|
||||
|
||||
```
|
||||
1. Extract token from Authorization header
|
||||
2. bcrypt-verify against api_tokens.token_hash
|
||||
3. Check api_tokens.expires_at > now (if set)
|
||||
4. Check scopes allow the requested operation
|
||||
5. If valid: inject X-User-Id (api_user's owner), X-Trace-Id
|
||||
6. If invalid: 401
|
||||
```
|
||||
|
||||
### 4.3 OAuth Login Flow
|
||||
|
||||
**Supported providers:** GitHub, Gitea, Generic OIDC
|
||||
|
||||
```
|
||||
1. User visits /auth/login
|
||||
2. Page shows provider buttons (GitHub, Gitea, or configured OIDC)
|
||||
3. User clicks provider → redirect to provider's authorize URL
|
||||
4. Provider redirects to /auth/callback/:provider with code
|
||||
5. Auth GW exchanges code for access token
|
||||
6. Auth GW fetches user profile from provider
|
||||
7. Look up oauth_accounts by (provider, provider_user_id):
|
||||
a. EXISTS: load user, create session
|
||||
b. NOT EXISTS: create user + oauth_account, create session
|
||||
8. Set session cookie, redirect to / (or to pending invite if present)
|
||||
```
|
||||
|
||||
**OAuth config shape:**
|
||||
```clojure
|
||||
{:oauth
|
||||
{:github {:client-id "..." :client-secret "..." :enabled true}
|
||||
:gitea {:client-id "..." :client-secret "..." :base-url "https://gitea.example.com" :enabled true}
|
||||
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}}
|
||||
```
|
||||
|
||||
**Generic OIDC:** Uses `.well-known/openid-configuration` discovery. Requires `openid`, `profile`, `email` scopes.
|
||||
|
||||
### 4.4 First-User Bootstrap
|
||||
|
||||
```
|
||||
1. User hits /auth/login
|
||||
2. Auth GW checks: any users in DB?
|
||||
- No users: show "Create your community" flow after OAuth
|
||||
- Has users: normal login
|
||||
3. After first OAuth login:
|
||||
a. Create user from OAuth profile
|
||||
b. Redirect to /setup (community creation wizard on Web SM)
|
||||
c. Web SM shows: community name input, slug auto-generated
|
||||
d. POST creates community (user becomes owner, #general created)
|
||||
e. Redirect to /app
|
||||
```
|
||||
|
||||
### 4.5 Invite Flow
|
||||
|
||||
```
|
||||
1. User visits /invite/:code
|
||||
2. Auth GW checks invite validity (exists, not expired, not exhausted)
|
||||
- Invalid: show error page
|
||||
- Valid: store invite code in cookie/session, redirect to /auth/login
|
||||
3. After OAuth login, if pending invite code:
|
||||
a. Accept invite (join community)
|
||||
b. Redirect to community
|
||||
```
|
||||
|
||||
## 5. Reverse Proxy Behavior
|
||||
|
||||
**Request forwarding:**
|
||||
- Strip auth headers from original request
|
||||
- Inject: `X-User-Id`, `X-User-Role`, `X-Community-Id`, `X-Trace-Id`, `X-Forwarded-For`
|
||||
- Forward request body, method, path, query string unchanged
|
||||
- SSE: hold connection open, stream response bytes through
|
||||
|
||||
**Response forwarding:**
|
||||
- Pass through status code, headers, body unchanged
|
||||
- For SSE responses: stream chunks as they arrive (no buffering)
|
||||
|
||||
**Service discovery (v1):** Static config — all services on localhost with configured ports.
|
||||
```clojure
|
||||
{:services
|
||||
{:api {:host "localhost" :port 3001}
|
||||
:web-sm {:host "localhost" :port 3002}
|
||||
:tui-sm {:host "localhost" :port 3003}}}
|
||||
```
|
||||
|
||||
## 6. Rate Limiting
|
||||
|
||||
| Endpoint Pattern | Limit | Window |
|
||||
|-----------------|-------|--------|
|
||||
| `POST /auth/callback/*` | 10 | 1 min per IP |
|
||||
| `POST /api/*` | 60 | 1 min per user |
|
||||
| `GET /api/*` | 120 | 1 min per user |
|
||||
| `POST /api/webhooks/*/incoming` | 30 | 1 min per webhook |
|
||||
| `GET /sse/*`, `GET /tui/sse/*` | 5 | 1 min per user (connection attempts) |
|
||||
|
||||
**Implementation:** In-memory token bucket (atom-based). No Redis needed for v1 (single instance).
|
||||
|
||||
## 7. Session Cookie
|
||||
|
||||
```
|
||||
Name: ajet_session
|
||||
Value: <base64url-encoded-token>
|
||||
Attributes:
|
||||
HttpOnly: true
|
||||
Secure: true (prod only)
|
||||
SameSite: Lax
|
||||
Path: /
|
||||
Max-Age: 2592000 (30 days)
|
||||
```
|
||||
|
||||
## 8. Test Cases
|
||||
|
||||
### 8.1 Session Validation
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T1 | Valid session cookie | Request proxied with injected headers |
|
||||
| AUTH-T2 | Expired session | Returns 401 (API) or redirect to login (web) |
|
||||
| AUTH-T3 | Invalid/tampered token | Returns 401 |
|
||||
| AUTH-T4 | Missing cookie | Returns 401 (API) or redirect to login (web) |
|
||||
| AUTH-T5 | Session TTL extension | Valid request extends expires_at |
|
||||
| AUTH-T6 | Concurrent requests | Multiple requests with same session all succeed |
|
||||
|
||||
### 8.2 API Token Validation
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T7 | Valid API token | Request proxied with X-User-Id |
|
||||
| AUTH-T8 | Expired API token | Returns 401 |
|
||||
| AUTH-T9 | Invalid scope | Returns 403 (scope mismatch) |
|
||||
| AUTH-T10 | Bearer header format | Correctly parses `Bearer <token>` |
|
||||
|
||||
### 8.3 OAuth Flow
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T11 | GitHub OAuth success | Code exchanged, user created/found, session set, redirected |
|
||||
| AUTH-T12 | Gitea OAuth success | Same as above for Gitea |
|
||||
| AUTH-T13 | OIDC OAuth success | Uses discovery document, same flow |
|
||||
| AUTH-T14 | OAuth invalid code | Returns error, redirects to login with error message |
|
||||
| AUTH-T15 | OAuth provider down | Returns 502 with friendly error |
|
||||
| AUTH-T16 | Existing user re-login | Finds existing user via oauth_accounts, creates new session |
|
||||
| AUTH-T17 | New user first login | Creates user + oauth_account + session |
|
||||
| AUTH-T18 | OAuth state parameter | CSRF protection via state param validated on callback |
|
||||
|
||||
### 8.4 First-User Bootstrap
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T19 | Empty DB shows setup flow | /auth/login with 0 users shows setup message |
|
||||
| AUTH-T20 | First user becomes owner | After OAuth + community creation, user has owner role |
|
||||
| AUTH-T21 | Subsequent users see normal login | With users in DB, normal login page shown |
|
||||
|
||||
### 8.5 Invite Flow
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T22 | Valid invite → login → join | Full invite acceptance flow works |
|
||||
| AUTH-T23 | Expired invite | Shows error page |
|
||||
| AUTH-T24 | Exhausted invite | Shows error page |
|
||||
| AUTH-T25 | Already-member invite | Accepts gracefully, redirects to community |
|
||||
|
||||
### 8.6 Reverse Proxy
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T26 | API route proxied | /api/channels → forwarded to API service |
|
||||
| AUTH-T27 | Web route proxied | / → forwarded to Web SM |
|
||||
| AUTH-T28 | TUI route proxied | /tui/sse → forwarded to TUI SM |
|
||||
| AUTH-T29 | SSE streaming | SSE response streamed without buffering |
|
||||
| AUTH-T30 | Target service down | Returns 502 |
|
||||
| AUTH-T31 | Headers injected | X-User-Id, X-Trace-Id present on proxied request |
|
||||
| AUTH-T32 | Original auth headers stripped | Client cannot forge X-User-Id |
|
||||
|
||||
### 8.7 Rate Limiting
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T33 | Under limit | Requests succeed normally |
|
||||
| AUTH-T34 | Over limit | Returns 429 with Retry-After header |
|
||||
| AUTH-T35 | Rate limit per-user | Different users have independent limits |
|
||||
| AUTH-T36 | Rate limit per-IP for auth | OAuth callback rate limited by IP |
|
||||
|
||||
### 8.8 Logout
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T37 | Logout destroys session | POST /auth/logout deletes session from DB, clears cookie |
|
||||
| AUTH-T38 | Logout with invalid session | Returns 200 (idempotent), clears cookie |
|
||||
|
||||
### 8.9 Health Check
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T39 | Health check | GET /health returns 200 with service status |
|
||||
| AUTH-T40 | Health check (DB down) | Returns 503 with degraded status |
|
||||
|
||||
---
|
||||
|
||||
## 9. Login Page UI Mock (Hiccup rendered by Auth GW)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ ajet chat │ │
|
||||
│ └──────────────┘ │
|
||||
│ │
|
||||
│ Sign in to continue │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with GitHub │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with Gitea │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with SSO │ │ ← only if OIDC configured
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ ─── or accepting invite ─── │ ← only if invite code present
|
||||
│ Joining: My Team │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
# PRD: CLI & TUI Client
|
||||
|
||||
**Module:** `cli/` | **Namespace:** `ajet.chat.cli.*`
|
||||
**Status:** v1 | **Last updated:** 2026-02-17
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The CLI module is a single executable (babashka via bbin) that provides two modes:
|
||||
- **CLI Mode:** Stateless one-shot commands for scripting and quick interactions
|
||||
- **TUI Mode:** Full interactive terminal application with split panes, inline images, markdown rendering, and mouse support (built on clojure-tui)
|
||||
|
||||
Both modes connect through the Auth Gateway.
|
||||
|
||||
## 2. Installation & Config
|
||||
|
||||
### 2.1 Installation
|
||||
```bash
|
||||
bbin install ajet-chat # installs as `ajet` command
|
||||
```
|
||||
|
||||
### 2.2 Config Location
|
||||
```
|
||||
~/.config/ajet-chat/
|
||||
├── config.edn # server URL, default community, preferences
|
||||
├── session.edn # session token (encrypted at rest)
|
||||
└── state.edn # TUI state: last community, last channel per community
|
||||
```
|
||||
|
||||
### 2.3 Config Shape
|
||||
```clojure
|
||||
;; config.edn
|
||||
{:server-url "https://chat.example.com"
|
||||
:default-community "my-team" ;; slug
|
||||
:tui {:theme :dark ;; :dark | :light
|
||||
:image-viewer :timg ;; :timg | :sixel | :none
|
||||
:mouse true
|
||||
:timestamps :relative ;; :relative | :absolute | :none
|
||||
:notifications :bell}} ;; :bell | :none
|
||||
|
||||
;; session.edn (encrypted)
|
||||
{:token "base64url-session-token"
|
||||
:user-id "uuid"
|
||||
:username "alice"
|
||||
:expires-at "2026-03-19T..."}
|
||||
```
|
||||
|
||||
## 3. CLI Mode
|
||||
|
||||
### 3.1 Command Syntax
|
||||
```
|
||||
ajet <command> [options] [args]
|
||||
```
|
||||
|
||||
### 3.2 Commands
|
||||
|
||||
#### Authentication
|
||||
```bash
|
||||
ajet login # Interactive OAuth login (opens browser)
|
||||
ajet login --token <api-token> # Login with API token (for scripts)
|
||||
ajet logout # Clear session
|
||||
ajet whoami # Show current user info
|
||||
```
|
||||
|
||||
#### Channels & Communities
|
||||
```bash
|
||||
ajet communities # List communities
|
||||
ajet channels # List channels in default community
|
||||
ajet channels --community <slug> # List channels in specific community
|
||||
ajet channels --join <name> # Join a public channel
|
||||
ajet channels --leave <name> # Leave a channel
|
||||
```
|
||||
|
||||
#### Messages
|
||||
```bash
|
||||
ajet read <channel> # Read last 50 messages in channel
|
||||
ajet read <channel> --limit 100 # Custom limit
|
||||
ajet read <channel> --before <id> # Older messages (pagination)
|
||||
ajet read <channel> --thread <id> # Read thread replies
|
||||
ajet send <channel> <message> # Send message
|
||||
ajet send <channel> --stdin # Read message from stdin (piping)
|
||||
ajet send <channel> --image <path> # Send message with image
|
||||
ajet edit <message-id> <new-text> # Edit a message
|
||||
ajet delete <message-id> # Delete a message
|
||||
```
|
||||
|
||||
#### DMs
|
||||
```bash
|
||||
ajet dms # List DM channels
|
||||
ajet dm <username> <message> # Send DM (creates if needed)
|
||||
ajet dm <username> --read # Read DM conversation
|
||||
```
|
||||
|
||||
#### Notifications
|
||||
```bash
|
||||
ajet notifications # List unread notifications
|
||||
ajet notifications --all # List all notifications
|
||||
ajet notifications --mark-read # Mark all as read
|
||||
```
|
||||
|
||||
#### Search
|
||||
```bash
|
||||
ajet search <query> # Global search
|
||||
ajet search <query> --channel <ch> # Search in specific channel
|
||||
ajet search <query> --from <user> # Search by author
|
||||
ajet search <query> --type messages # Filter by type
|
||||
```
|
||||
|
||||
#### Presence & Status
|
||||
```bash
|
||||
ajet status # Show current status
|
||||
ajet status "Working on backend" # Set status
|
||||
ajet who # Show online users in current community
|
||||
```
|
||||
|
||||
#### Invites
|
||||
```bash
|
||||
ajet invite create # Generate invite link
|
||||
ajet invite create --max-uses 10 # With use limit
|
||||
ajet invite list # List active invites
|
||||
ajet invite revoke <id> # Revoke an invite
|
||||
```
|
||||
|
||||
#### Admin
|
||||
```bash
|
||||
ajet config # Show current config
|
||||
ajet config set <key> <value> # Set config value
|
||||
ajet config server <url> # Set server URL
|
||||
```
|
||||
|
||||
### 3.3 Output Formats
|
||||
|
||||
**Default (human-readable):**
|
||||
```
|
||||
$ ajet read general --limit 3
|
||||
#general — My Team
|
||||
|
||||
alice 10:30 AM
|
||||
hello everyone!
|
||||
|
||||
bob 10:31 AM
|
||||
hey! check this out
|
||||
[image: screenshot.png]
|
||||
|
||||
carol 10:45 AM
|
||||
@bob nice work! 👍
|
||||
```
|
||||
|
||||
**JSON output (for scripting):**
|
||||
```bash
|
||||
ajet read general --json | jq '.messages[].body_md'
|
||||
```
|
||||
|
||||
**Pipe-friendly:**
|
||||
```bash
|
||||
echo "Build #42 passed!" | ajet send devops --stdin
|
||||
git log --oneline -5 | ajet send backend --stdin
|
||||
```
|
||||
|
||||
### 3.4 OAuth Login Flow (CLI)
|
||||
|
||||
```
|
||||
1. ajet login
|
||||
2. CLI starts a temporary local HTTP server (localhost:random-port)
|
||||
3. Opens browser to: https://chat.example.com/auth/login?redirect=http://localhost:{port}/callback
|
||||
4. User completes OAuth in browser
|
||||
5. Auth GW redirects to localhost callback with session token
|
||||
6. CLI captures token, saves to ~/.config/ajet-chat/session.edn
|
||||
7. Prints "Logged in as alice"
|
||||
```
|
||||
|
||||
**Fallback (no browser):**
|
||||
```
|
||||
$ ajet login
|
||||
Opening browser...
|
||||
If your browser didn't open, visit:
|
||||
https://chat.example.com/auth/login?cli=true
|
||||
Then paste the token here: _
|
||||
```
|
||||
|
||||
## 4. TUI Mode
|
||||
|
||||
### 4.1 Launch
|
||||
```bash
|
||||
ajet tui # Open TUI (interactive mode)
|
||||
ajet tui --community <slug> # Open to specific community
|
||||
ajet tui --channel <name> # Open to specific channel
|
||||
```
|
||||
|
||||
### 4.2 Layout (Rich TUI with clojure-tui)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ajet chat │ My Team alice ● online │
|
||||
├──────────────────┬──────────────────────────────────────────────────────────┤
|
||||
│ Communities │ #general — Welcome to My Team! │
|
||||
│ ● My Team │──────────────────────────────────────────────────────────│
|
||||
│ Open Source │ │
|
||||
│ │ alice 10:30 │
|
||||
│ ──────────────── │ hello everyone! │
|
||||
│ ▼ GENERAL │ │
|
||||
│ #general 3 │ bob 10:31 │
|
||||
│ #random │ hey! check out this screenshot │
|
||||
│ │ ┌─────────────────────────────┐ │
|
||||
│ ▼ DEV │ │ [inline image via timg] │ │
|
||||
│ #backend ● │ │ │ │
|
||||
│ #frontend │ └──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ──────────────── │ carol 10:45 │
|
||||
│ DMs │ @bob nice work! 👍 │
|
||||
│ alice ● │ 💬 2 replies │
|
||||
│ bob │ │
|
||||
│ group (3) 2 │ ── Load older ── │
|
||||
│ │ │
|
||||
│ ──────────────── │──────────────────────────────────────────────────────────│
|
||||
│ 🔍 /search │ bob is typing... │
|
||||
│ │ > hello world_ │
|
||||
│ │ │
|
||||
├──────────────────┴──────────────────────────────────────────────────────────┤
|
||||
│ [/help] Ctrl+K: search Ctrl+N: next channel Ctrl+P: prev Ctrl+Q: quit │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 TUI Panes
|
||||
|
||||
| Pane | Description |
|
||||
|------|-------------|
|
||||
| Header | App name, community name, user status |
|
||||
| Sidebar | Communities, categories, channels, DMs with unread indicators |
|
||||
| Channel Header | Channel name + topic |
|
||||
| Message List | Scrollable message history with rendered markdown |
|
||||
| Input Area | Message composition with autocomplete |
|
||||
| Status Bar | Keybindings, connection status |
|
||||
| Thread Panel | Overlays right side when viewing thread (like web) |
|
||||
|
||||
### 4.4 Keyboard Navigation
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Send message |
|
||||
| `Shift+Enter` / `Alt+Enter` | Newline in input |
|
||||
| `Ctrl+K` | Open search |
|
||||
| `Ctrl+N` | Next channel (down in sidebar) |
|
||||
| `Ctrl+P` | Previous channel (up in sidebar) |
|
||||
| `Ctrl+Q` | Quit |
|
||||
| `Ctrl+T` | Open thread for selected message |
|
||||
| `Ctrl+R` | React to selected message |
|
||||
| `Ctrl+E` | Edit selected message |
|
||||
| `Ctrl+D` | Delete selected message (with confirmation) |
|
||||
| `Tab` | Switch focus: input ↔ message list ↔ sidebar |
|
||||
| `↑/↓` | Navigate messages (when message list focused) |
|
||||
| `j/k` | Vim-style navigate messages |
|
||||
| `PgUp/PgDn` | Scroll message history (loads older on PgUp at top) |
|
||||
| `Esc` | Close thread panel / cancel edit / blur search |
|
||||
| `/` | Start slash command (when input focused) |
|
||||
| `@` | Start mention autocomplete |
|
||||
| `#` | Start channel autocomplete |
|
||||
| `Mouse click` | Select channel, message, or button |
|
||||
| `Mouse scroll` | Scroll message list |
|
||||
|
||||
### 4.5 Inline Image Rendering
|
||||
|
||||
**Via timg (preferred):**
|
||||
- Detect terminal capabilities (sixel, kitty graphics, iterm2)
|
||||
- Use timg to render images inline in the message list
|
||||
- Fallback: show `[image: filename.png]` placeholder with URL
|
||||
|
||||
**Image display flow:**
|
||||
1. Message with attachment arrives
|
||||
2. TUI SM provides image URL in event
|
||||
3. TUI client downloads image in background
|
||||
4. Renders inline using timg/sixel
|
||||
5. If terminal doesn't support graphics: show filename + dimensions
|
||||
|
||||
### 4.6 Markdown Rendering (ANSI)
|
||||
|
||||
| Markdown | ANSI Rendering |
|
||||
|----------|---------------|
|
||||
| `**bold**` | ANSI bold |
|
||||
| `*italic*` | ANSI italic (or dim on unsupported terminals) |
|
||||
| `~~strike~~` | ANSI strikethrough |
|
||||
| `__underline__` | ANSI underline |
|
||||
| `` `code` `` | Dim background or different color |
|
||||
| Code block | Box-drawing border + syntax highlighting (ANSI colors) |
|
||||
| `> quote` | Vertical bar prefix + dim text |
|
||||
| `\|\|spoiler\|\|` | Hidden text (press Enter on selected to reveal) |
|
||||
| Links | Underlined + hyperlink escape (OSC 8) |
|
||||
| Emoji | Unicode emoji (terminal must support) |
|
||||
| `@mention` | Highlighted (bold + color) |
|
||||
| `#channel` | Highlighted (different color), clickable |
|
||||
|
||||
### 4.7 SSE Connection
|
||||
|
||||
TUI client connects to TUI SM via SSE:
|
||||
```
|
||||
GET https://chat.example.com/tui/sse/events?community_id=<uuid>
|
||||
Cookie: ajet_session=<token>
|
||||
Accept: text/event-stream
|
||||
Last-Event-ID: <id> (for reconnection)
|
||||
```
|
||||
|
||||
- On connect: receive `init` event, populate sidebar + channel list
|
||||
- On events: update message list, sidebar badges, typing indicators
|
||||
- On disconnect: show "Reconnecting..." in status bar, auto-retry with backoff
|
||||
|
||||
### 4.8 Notifications in TUI
|
||||
|
||||
- Terminal bell (`\a`) on new @mention or DM (configurable)
|
||||
- Unread counts in sidebar (same as web)
|
||||
- Notification list accessible via slash command `/notifications`
|
||||
|
||||
## 5. Test Cases
|
||||
|
||||
### 5.1 CLI Authentication
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| CLI-T1 | Login via OAuth | Opens browser, captures callback, saves token |
|
||||
| CLI-T2 | Login via API token | `--token` flag saves token directly |
|
||||
| CLI-T3 | Logout clears session | Token removed from config |
|
||||
| CLI-T4 | Whoami shows user | Prints username, display name, communities |
|
||||
| CLI-T5 | Expired token | Commands return clear "session expired, run ajet login" message |
|
||||
| CLI-T6 | No config exists | First run creates config dir and prompts for server URL |
|
||||
|
||||
### 5.2 CLI Commands
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| CLI-T7 | List communities | Shows user's communities |
|
||||
| CLI-T8 | List channels | Shows channels for default community |
|
||||
| CLI-T9 | Read messages | Displays formatted messages with avatars, timestamps |
|
||||
| CLI-T10 | Read with pagination | `--before` flag returns older messages |
|
||||
| CLI-T11 | Read thread | `--thread` flag shows thread replies |
|
||||
| CLI-T12 | Send message | Message appears in channel |
|
||||
| CLI-T13 | Send via stdin | `echo "msg" \| ajet send general --stdin` works |
|
||||
| CLI-T14 | Send with image | Image uploaded, message sent with attachment |
|
||||
| CLI-T15 | Edit message | Message updated within 1-hour window |
|
||||
| CLI-T16 | Edit after window | Returns error "edit window expired" |
|
||||
| CLI-T17 | Delete message | Message deleted with confirmation prompt |
|
||||
| CLI-T18 | List DMs | Shows DM conversations |
|
||||
| CLI-T19 | Send DM | Creates DM if needed, sends message |
|
||||
| CLI-T20 | List notifications | Shows unread notifications |
|
||||
| CLI-T21 | Mark notifications read | All notifications marked read |
|
||||
| CLI-T22 | Search | Returns matching results with context |
|
||||
| CLI-T23 | Search with filters | --channel, --from, --type filters work |
|
||||
| CLI-T24 | Set status | Status updated |
|
||||
| CLI-T25 | Who online | Shows online users list |
|
||||
| CLI-T26 | Create invite | Returns invite link |
|
||||
| CLI-T27 | JSON output | `--json` flag outputs raw JSON |
|
||||
| CLI-T28 | Unknown command | Prints help with suggestion |
|
||||
| CLI-T29 | No arguments | Prints usage/help |
|
||||
|
||||
### 5.3 TUI Launch & Layout
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TUI-T1 | TUI launches | Full layout renders with all panes |
|
||||
| TUI-T2 | Sidebar populated | Communities, channels, DMs shown from init event |
|
||||
| TUI-T3 | Messages loaded | Active channel messages displayed |
|
||||
| TUI-T4 | Unread badges | Channels with unread messages show count |
|
||||
| TUI-T5 | Online indicators | Online users have green dot in DM list |
|
||||
| TUI-T6 | Status bar | Shows keybindings and connection status |
|
||||
|
||||
### 5.4 TUI Navigation
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TUI-T7 | Ctrl+N next channel | Moves to next channel in sidebar |
|
||||
| TUI-T8 | Ctrl+P prev channel | Moves to previous channel |
|
||||
| TUI-T9 | Tab focus cycle | Focus cycles: input → messages → sidebar |
|
||||
| TUI-T10 | Click channel | Mouse click switches to channel |
|
||||
| TUI-T11 | Arrow keys in messages | Navigate between messages |
|
||||
| TUI-T12 | j/k vim navigation | Vim-style navigation in message list |
|
||||
| TUI-T13 | PgUp loads older | Scrolling up loads older messages |
|
||||
| TUI-T14 | Mouse scroll | Mouse scroll in message list |
|
||||
| TUI-T15 | Esc closes panels | Thread panel or search closes on Esc |
|
||||
| TUI-T16 | Community switch | Click community in sidebar → channels update |
|
||||
|
||||
### 5.5 TUI Messaging
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TUI-T17 | Send message | Enter sends, message appears in list |
|
||||
| TUI-T18 | Multiline input | Alt+Enter adds newline |
|
||||
| TUI-T19 | @mention autocomplete | Typing @ shows dropdown, Tab selects |
|
||||
| TUI-T20 | #channel autocomplete | Typing # shows dropdown |
|
||||
| TUI-T21 | /slash command | Typing / shows command list |
|
||||
| TUI-T22 | Edit message | Ctrl+E on selected → inline edit → Enter to save |
|
||||
| TUI-T23 | Delete message | Ctrl+D on selected → confirmation → deleted |
|
||||
| TUI-T24 | React to message | Ctrl+R → emoji input → reaction added |
|
||||
| TUI-T25 | Open thread | Ctrl+T on message with replies → thread panel |
|
||||
| TUI-T26 | Reply in thread | Type in thread input → reply sent |
|
||||
| TUI-T27 | Image paste | Not supported in TUI (CLI `--image` flag instead) |
|
||||
|
||||
### 5.6 TUI Real-Time
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TUI-T28 | New message arrives | SSE event → message appears at bottom |
|
||||
| TUI-T29 | Typing indicator | "bob is typing..." shows below message list |
|
||||
| TUI-T30 | Presence updates | Online dot changes when user goes offline |
|
||||
| TUI-T31 | Unread updates | New message in other channel updates sidebar badge |
|
||||
| TUI-T32 | SSE reconnect | Connection lost → "Reconnecting..." → auto-reconnects |
|
||||
| TUI-T33 | Bell notification | Terminal bell on @mention or DM |
|
||||
|
||||
### 5.7 TUI Rendering
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TUI-T34 | Markdown bold | `**text**` renders as ANSI bold |
|
||||
| TUI-T35 | Markdown italic | `*text*` renders as ANSI italic |
|
||||
| TUI-T36 | Code block | Fenced code block renders with border + syntax colors |
|
||||
| TUI-T37 | Inline code | Backtick code renders with different color |
|
||||
| TUI-T38 | Block quote | `> text` renders with vertical bar prefix |
|
||||
| TUI-T39 | Spoiler text | `\|\|text\|\|` renders hidden until Enter pressed |
|
||||
| TUI-T40 | Inline image (timg) | Image renders inline via timg when supported |
|
||||
| TUI-T41 | Image fallback | `[image: file.png 800x600]` when graphics not supported |
|
||||
| TUI-T42 | Mention highlight | @alice renders bold + colored |
|
||||
| TUI-T43 | Channel link | #general renders colored and navigable |
|
||||
| TUI-T44 | Hyperlinks | URLs render as OSC 8 hyperlinks when terminal supports |
|
||||
| TUI-T45 | Long message wrapping | Long messages wrap correctly within pane width |
|
||||
| TUI-T46 | Terminal resize | Layout reflows on terminal resize event |
|
||||
|
||||
### 5.8 TUI Error Handling
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TUI-T47 | Send fails | Error shown inline below input |
|
||||
| TUI-T48 | API timeout | Status bar shows warning, retries |
|
||||
| TUI-T49 | Ctrl+Q quit | Clean shutdown: close SSE, save state |
|
||||
| TUI-T50 | SIGINT handling | Ctrl+C during TUI gracefully exits |
|
||||
@@ -0,0 +1,42 @@
|
||||
# PRD: Mobile Client
|
||||
|
||||
**Module:** `mobile/` | **Namespace:** TBD
|
||||
**Status:** Deferred (v2+) | **Last updated:** 2026-02-17
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Mobile client is deferred to a future version. The architecture supports it via the same Auth GW + SSE pattern used by the web and TUI clients.
|
||||
|
||||
## 2. Options to Evaluate
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **PWA (web-sm)** | Zero extra work — web UI works on mobile browsers. Add manifest + service worker for installability. | Limited push notifications, no native feel. |
|
||||
| **ClojureDart** | Stay in Clojure ecosystem. Compiles to Dart/Flutter. | Immature ecosystem, debugging harder. |
|
||||
| **React Native + API** | Mature ecosystem, native feel, large talent pool. | Separate language (JS/TS), not Clojure. |
|
||||
|
||||
## 3. Recommended Path
|
||||
|
||||
Start with **PWA** (progressive web app from web-sm) for mobile access in v1. Evaluate native options for v2 based on user feedback.
|
||||
|
||||
**PWA requirements (P2, not v1):**
|
||||
- Add web app manifest to web-sm
|
||||
- Add service worker for offline caching
|
||||
- Responsive CSS for mobile viewports
|
||||
- Touch-friendly interaction sizes
|
||||
|
||||
## 4. Architecture (Future Native)
|
||||
|
||||
Same pattern as TUI client:
|
||||
```
|
||||
Mobile App ←─ SSE (JSON) ──→ Mobile SM ──→ NATS
|
||||
←─ HTTP POST ───→ ──→ API
|
||||
```
|
||||
|
||||
A dedicated Mobile SM would handle push notification routing and mobile-specific optimizations (battery-aware polling, data compression).
|
||||
|
||||
## 5. No Test Cases
|
||||
|
||||
No test cases defined for the mobile client as it is deferred. Test cases will be created when a specific implementation approach is chosen.
|
||||
@@ -0,0 +1,300 @@
|
||||
# PRD: Shared Module
|
||||
|
||||
**Module:** `shared/` | **Namespace:** `ajet.chat.shared.*`
|
||||
**Status:** v1 | **Last updated:** 2026-02-17
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The shared module provides foundational libraries used by all other services: database access, event bus (NATS), API client SDK, data schemas, and common protocols. It has no main entry point — it's a library dependency.
|
||||
|
||||
## 2. Components
|
||||
|
||||
### 2.1 Database Layer (`ajet.chat.shared.db`)
|
||||
|
||||
**Purpose:** PostgreSQL connection management, query helpers, migration runner.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| DB-1 | Create HikariCP connection pool from config map | P0 |
|
||||
| DB-2 | Provide `with-transaction` macro wrapping `next.jdbc/with-transaction` | P0 |
|
||||
| DB-3 | Provide `execute!`, `execute-one!`, `plan` wrappers that accept HoneySQL maps | P0 |
|
||||
| DB-4 | Run Migratus migrations on startup (configurable: auto or manual) | P0 |
|
||||
| DB-5 | Support read-only datasource config for future read replicas | P2 |
|
||||
| DB-6 | Log slow queries (> configurable threshold, default 500ms) | P1 |
|
||||
|
||||
**Config shape:**
|
||||
```clojure
|
||||
{:db {:host "localhost"
|
||||
:port 5432
|
||||
:dbname "ajet_chat"
|
||||
:user "ajet"
|
||||
:password "..."
|
||||
:pool-size 10
|
||||
:migrations {:enabled true :location "migrations"}}}
|
||||
```
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Type | Description |
|
||||
|----|------|------|-------------|
|
||||
| DB-T1 | Pool creation with valid config | Unit | `make-datasource` returns a pooled datasource |
|
||||
| DB-T2 | Pool creation with invalid config | Unit | Throws clear error on bad host/port/creds |
|
||||
| DB-T3 | HoneySQL map execution | Integration | `execute!` with `{:select [:*] :from [:users]}` returns rows |
|
||||
| DB-T4 | Transaction commit | Integration | Writes inside `with-transaction` are visible after commit |
|
||||
| DB-T5 | Transaction rollback | Integration | Exception inside `with-transaction` rolls back all writes |
|
||||
| DB-T6 | Migration forward | Integration | `migrate!` applies pending migrations, creates tables |
|
||||
| DB-T7 | Migration rollback | Integration | `rollback!` reverses last migration |
|
||||
| DB-T8 | Migration idempotency | Integration | Running `migrate!` twice is a no-op when up to date |
|
||||
| DB-T9 | Slow query logging | Integration | Query taking > threshold triggers log warning |
|
||||
| DB-T10 | Connection pool exhaustion | Integration | Requests beyond pool-size block then timeout with clear error |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 EventBus (`ajet.chat.shared.eventbus`)
|
||||
|
||||
**Purpose:** NATS pub/sub abstraction with JetStream support for durable subscriptions.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| EB-1 | `connect!` — create NATS connection from config | P0 |
|
||||
| EB-2 | `publish!` — publish event to a subject (JSON-encoded) | P0 |
|
||||
| EB-3 | `subscribe!` — subscribe to subject pattern, return subscription handle | P0 |
|
||||
| EB-4 | `unsubscribe!` — close subscription by handle | P0 |
|
||||
| EB-5 | `close!` — close NATS connection | P0 |
|
||||
| EB-6 | Events are EDN maps serialized as JSON on the wire | P0 |
|
||||
| EB-7 | Support wildcard subjects (`chat.events.*`) | P0 |
|
||||
| EB-8 | JetStream durable consumer for SM replay on reconnect | P1 |
|
||||
| EB-9 | Auto-reconnect on NATS connection loss with backoff | P1 |
|
||||
| EB-10 | Connection health check (ping/pong) | P1 |
|
||||
|
||||
**Subject hierarchy:**
|
||||
```
|
||||
chat.events.{community-id} — channel message CRUD, channel events, reactions
|
||||
chat.dm.{channel-id} — DM message events
|
||||
chat.typing.{community-id}.{channel-id} — channel typing indicators
|
||||
chat.typing.dm.{channel-id} — DM typing indicators
|
||||
chat.presence.{community-id} — heartbeat/presence events
|
||||
chat.notifications.{user-id} — per-user notification delivery
|
||||
```
|
||||
|
||||
**Event envelope:**
|
||||
```clojure
|
||||
{:type :message/created ;; keyword
|
||||
:id "uuid" ;; event ID for dedup
|
||||
:ts "2026-02-17T..." ;; ISO 8601
|
||||
:payload {:message-id "uuid"
|
||||
:channel-id "uuid"
|
||||
:user-id "uuid"
|
||||
:body-md "hello"}}
|
||||
```
|
||||
|
||||
**Event types:**
|
||||
```
|
||||
:message/created, :message/edited, :message/deleted
|
||||
:reaction/added, :reaction/removed
|
||||
:channel/created, :channel/updated, :channel/deleted
|
||||
:member/joined, :member/left, :member/kicked, :member/banned
|
||||
:typing/start, :typing/stop
|
||||
:presence/online, :presence/offline
|
||||
:notification/new
|
||||
```
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Type | Description |
|
||||
|----|------|------|-------------|
|
||||
| EB-T1 | Connect to NATS | Integration | `connect!` returns active connection |
|
||||
| EB-T2 | Connect with bad URL | Unit | Throws clear error |
|
||||
| EB-T3 | Publish and subscribe | Integration | Published event is received by subscriber |
|
||||
| EB-T4 | Wildcard subscribe | Integration | `chat.events.*` receives events for any community |
|
||||
| EB-T5 | Unsubscribe stops delivery | Integration | After `unsubscribe!`, no more events received |
|
||||
| EB-T6 | Multiple subscribers | Integration | Two subscribers on same subject both receive event |
|
||||
| EB-T7 | JSON roundtrip | Unit | EDN map → JSON → EDN map preserves all fields |
|
||||
| EB-T8 | Event envelope validation | Unit | Missing `:type` or `:payload` throws |
|
||||
| EB-T9 | JetStream replay | Integration | Durable consumer replays missed events on reconnect |
|
||||
| EB-T10 | Auto-reconnect | Integration | Subscriber resumes after NATS restart |
|
||||
| EB-T11 | Close connection | Integration | `close!` disconnects cleanly, subsequent publish throws |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 API Client SDK (`ajet.chat.shared.api-client`)
|
||||
|
||||
**Purpose:** HTTP client for internal (SM → API) and external (CLI → Auth GW) communication.
|
||||
|
||||
**Status:** Substantially implemented (219 lines). Needs expansion.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| AC-1 | Context-based design: `{:base-url :auth-token :trace-id}` | P0 |
|
||||
| AC-2 | All CRUD for: channels, messages, reactions, DMs, users, notifications | P0 |
|
||||
| AC-3 | Heartbeat endpoint | P0 |
|
||||
| AC-4 | Search endpoint (messages, channels, users) | P0 |
|
||||
| AC-5 | Invite management (create link, direct invite, list, revoke) | P0 |
|
||||
| AC-6 | Slash command dispatch | P0 |
|
||||
| AC-7 | Community CRUD (create, update, list, get) | P0 |
|
||||
| AC-8 | Channel category CRUD (create, reorder, assign channels) | P1 |
|
||||
| AC-9 | Webhook management (create, delete, list) | P1 |
|
||||
| AC-10 | Cursor-based pagination support (`?after=<uuid>&limit=N`) | P0 |
|
||||
| AC-11 | Retry with exponential backoff on 5xx/timeout | P1 |
|
||||
| AC-12 | Request timeout (configurable, default 10s) | P0 |
|
||||
| AC-13 | Structured error responses (`ex-info` with status + body) | P0 |
|
||||
| AC-14 | File upload (multipart) for image attachments | P0 |
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Type | Description |
|
||||
|----|------|------|-------------|
|
||||
| AC-T1 | Build headers with token | Unit | `build-headers` includes Authorization + trace ID |
|
||||
| AC-T2 | Build headers without token | Unit | No Authorization header when token nil |
|
||||
| AC-T3 | JSON encode/decode roundtrip | Unit | Clojure map survives JSON serialization |
|
||||
| AC-T4 | 4xx response throws ex-info | Unit | `check-response!` throws with status + body |
|
||||
| AC-T5 | 5xx response throws ex-info | Unit | Includes status code and response body |
|
||||
| AC-T6 | get-channels returns list | Integration | Returns vector of channel maps |
|
||||
| AC-T7 | send-message creates message | Integration | POST returns created message with ID |
|
||||
| AC-T8 | edit-message within window | Integration | PUT succeeds within 1-hour window |
|
||||
| AC-T9 | edit-message after window | Integration | PUT returns 403 after 1-hour window |
|
||||
| AC-T10 | delete-message own | Integration | DELETE succeeds for message author |
|
||||
| AC-T11 | delete-message other (non-admin) | Integration | DELETE returns 403 |
|
||||
| AC-T12 | cursor pagination | Integration | `?after=uuid&limit=20` returns next page |
|
||||
| AC-T13 | search messages | Integration | Returns matching messages across accessible channels |
|
||||
| AC-T14 | retry on 503 | Unit | Retries up to 3 times with backoff |
|
||||
| AC-T15 | timeout handling | Unit | Throws after configured timeout |
|
||||
| AC-T16 | image upload multipart | Integration | File uploaded, attachment record created |
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Schemas & Validation (`ajet.chat.shared.schema`)
|
||||
|
||||
**Purpose:** Data shape definitions and validation for all entities.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| SC-1 | Define specs/schemas for all DB entities | P0 |
|
||||
| SC-2 | Request validation for API endpoints | P0 |
|
||||
| SC-3 | Event payload validation | P1 |
|
||||
| SC-4 | Use `clojure.spec.alpha` or Malli (TBD) | P0 |
|
||||
|
||||
**Entities to define:**
|
||||
|
||||
```
|
||||
community: {:id uuid, :name string, :slug string, :created-at inst}
|
||||
user: {:id uuid, :username string, :display-name string, :email string, :avatar-url string?, :status-text string?, :created-at inst}
|
||||
oauth-account: {:id uuid, :user-id uuid, :provider string, :provider-user-id string}
|
||||
session: {:id uuid, :user-id uuid, :token-hash string, :expires-at inst}
|
||||
api-user: {:id uuid, :name string, :community-id uuid, :scopes [string]}
|
||||
api-token: {:id uuid, :api-user-id uuid, :token-hash string, :scopes [string], :expires-at inst}
|
||||
channel: {:id uuid, :community-id uuid?, :name string, :type enum, :visibility enum, :topic string?, :category-id uuid?}
|
||||
channel-category:{:id uuid, :community-id uuid, :name string, :position int}
|
||||
community-member:{:community-id uuid, :user-id uuid, :role enum, :nickname string?, :avatar-url string?}
|
||||
channel-member: {:channel-id uuid, :user-id uuid, :joined-at inst}
|
||||
message: {:id uuid, :channel-id uuid, :user-id uuid, :parent-id uuid?, :body-md string, :created-at inst, :edited-at inst?}
|
||||
attachment: {:id uuid, :message-id uuid, :filename string, :content-type string, :size-bytes int, :storage-key string}
|
||||
reaction: {:message-id uuid, :user-id uuid, :emoji string}
|
||||
webhook: {:id uuid, :community-id uuid, :channel-id uuid, :name string, :avatar-url string?, :token-hash string}
|
||||
mention: {:id uuid, :message-id uuid, :target-type enum, :target-id uuid?}
|
||||
notification: {:id uuid, :user-id uuid, :type enum, :source-id uuid, :read boolean}
|
||||
invite: {:id uuid, :community-id uuid, :created-by uuid, :code string, :max-uses int?, :uses int, :expires-at inst?}
|
||||
```
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Type | Description |
|
||||
|----|------|------|-------------|
|
||||
| SC-T1 | Valid community passes validation | Unit | All required fields present and correct types |
|
||||
| SC-T2 | Missing required field fails | Unit | Omitting `:name` from community fails validation |
|
||||
| SC-T3 | Invalid UUID format fails | Unit | String "not-a-uuid" in UUID field fails |
|
||||
| SC-T4 | Enum validation | Unit | Channel `:type` must be one of `#{:text :voice :dm :group-dm}` |
|
||||
| SC-T5 | Optional fields accepted when nil | Unit | `:topic nil` passes for channel |
|
||||
| SC-T6 | Message body not empty | Unit | Empty string body fails validation |
|
||||
| SC-T7 | Slug format validation | Unit | Community slug must match `[a-z0-9-]+` |
|
||||
| SC-T8 | Mention storage format | Unit | `@<user:uuid>` parses correctly to `{:target-type :user :target-id uuid}` |
|
||||
| SC-T9 | Event envelope validation | Unit | Event with valid type + payload passes |
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Mention Parser (`ajet.chat.shared.mentions`)
|
||||
|
||||
**Purpose:** Parse and render mention/channel-link syntax in message bodies.
|
||||
|
||||
**Storage format:** Raw markdown with embedded references:
|
||||
- `@<user:550e8400-e29b-41d4-a716-446655440000>` — user mention
|
||||
- `@<here>` — @here mention (notify all online in channel)
|
||||
- `#<channel:550e8400-e29b-41d4-a716-446655440000>` — channel link
|
||||
|
||||
**Rendered output:** (resolved at display time by SMs)
|
||||
- `@someUser` — rendered with user's display name
|
||||
- `@here` — rendered as-is
|
||||
- `#general` — rendered with channel name
|
||||
|
||||
**Requirements:**
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| MP-1 | Parse `@<user:uuid>` from message body, extract UUIDs | P0 |
|
||||
| MP-2 | Parse `@<here>` from message body | P0 |
|
||||
| MP-3 | Parse `#<channel:uuid>` from message body | P0 |
|
||||
| MP-4 | Return list of `{:type :target-id}` for mention record creation | P0 |
|
||||
| MP-5 | Render mentions by replacing references with display names | P0 |
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Type | Description |
|
||||
|----|------|------|-------------|
|
||||
| MP-T1 | Parse single user mention | Unit | `"hello @<user:abc-123>"` → `[{:type :user :id "abc-123"}]` |
|
||||
| MP-T2 | Parse multiple mentions | Unit | Two user mentions in one message → both extracted |
|
||||
| MP-T3 | Parse @here | Unit | `"@<here> look at this"` → `[{:type :here}]` |
|
||||
| MP-T4 | Parse channel link | Unit | `"see #<channel:def-456>"` → `[{:type :channel :id "def-456"}]` |
|
||||
| MP-T5 | Parse mixed mentions | Unit | User + here + channel in one message → all three extracted |
|
||||
| MP-T6 | No mentions returns empty | Unit | `"just a normal message"` → `[]` |
|
||||
| MP-T7 | Mention inside code block ignored | Unit | `` `@<user:abc>` `` → `[]` (code blocks are not parsed) |
|
||||
| MP-T8 | Render user mention | Unit | Replace `@<user:abc>` with `@alice` given lookup map |
|
||||
| MP-T9 | Render channel link | Unit | Replace `#<channel:def>` with `#general` given lookup map |
|
||||
| MP-T10 | Render unknown user | Unit | `@<user:unknown>` renders as `@unknown-user` |
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Markdown Processor (`ajet.chat.shared.markdown`)
|
||||
|
||||
**Purpose:** Parse and render Discord-flavor markdown to HTML (for web) and ANSI (for TUI).
|
||||
|
||||
**Supported syntax:**
|
||||
- **Bold:** `**text**`
|
||||
- *Italic:* `*text*` or `_text_`
|
||||
- ~~Strikethrough:~~ `~~text~~`
|
||||
- __Underline:__ `__text__` (Discord extension)
|
||||
- `Inline code`: `` `code` ``
|
||||
- Code blocks: ` ```lang\ncode\n``` ` with syntax highlighting
|
||||
- Spoilers: `||hidden text||`
|
||||
- Block quotes: `> text`
|
||||
- Links: `[text](url)` — auto-link bare URLs
|
||||
- Emoji shortcodes: `:smile:` → rendered emoji
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Type | Description |
|
||||
|----|------|------|-------------|
|
||||
| MD-T1 | Bold rendering | Unit | `**bold**` → `<strong>bold</strong>` |
|
||||
| MD-T2 | Italic rendering | Unit | `*italic*` → `<em>italic</em>` |
|
||||
| MD-T3 | Strikethrough | Unit | `~~strike~~` → `<del>strike</del>` |
|
||||
| MD-T4 | Underline | Unit | `__underline__` → `<u>underline</u>` |
|
||||
| MD-T5 | Inline code | Unit | `` `code` `` → `<code>code</code>` |
|
||||
| MD-T6 | Fenced code block | Unit | Triple backtick block → `<pre><code>` with language class |
|
||||
| MD-T7 | Spoiler | Unit | `\|\|spoiler\|\|` → `<span class="spoiler">spoiler</span>` |
|
||||
| MD-T8 | Block quote | Unit | `> quote` → `<blockquote>quote</blockquote>` |
|
||||
| MD-T9 | Auto-link URL | Unit | Bare `https://example.com` → clickable link |
|
||||
| MD-T10 | Nested formatting | Unit | `**bold and *italic***` renders correctly |
|
||||
| MD-T11 | XSS prevention | Unit | `<script>alert(1)</script>` is escaped, not executed |
|
||||
| MD-T12 | Emoji shortcode | Unit | `:smile:` → Unicode emoji character |
|
||||
| MD-T13 | ANSI rendering for TUI | Unit | `**bold**` → ANSI bold escape code |
|
||||
| MD-T14 | Code block in ANSI | Unit | Code block rendered with box-drawing characters |
|
||||
| MD-T15 | Mentions not processed | Unit | Markdown processor does not handle `@<user:>` (separate concern) |
|
||||
@@ -0,0 +1,178 @@
|
||||
# PRD: TUI Session Manager
|
||||
|
||||
**Module:** `tui-sm/` | **Namespace:** `ajet.chat.tui-sm.*`
|
||||
**Status:** v1 | **Last updated:** 2026-02-17
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The TUI Session Manager serves terminal clients via SSE. It holds live connections from TUI clients, subscribes to NATS for real-time events, and calls the API for data. It acts as an intermediary — translating NATS events into structured SSE events that the TUI client renders.
|
||||
|
||||
Unlike Web SM (which sends HTML fragments), TUI SM sends **JSON-structured events** over SSE. The TUI client handles all rendering.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
TUI Client ←─ SSE (JSON events) ──→ TUI SM ──→ NATS (subscribe)
|
||||
←─ HTTP POST (signals) ──→ ──→ API (HTTP, data)
|
||||
```
|
||||
|
||||
## 3. SSE Event Protocol
|
||||
|
||||
### 3.1 Connection
|
||||
|
||||
**Endpoint:** `GET /tui/sse/events`
|
||||
**Headers:**
|
||||
- `Accept: text/event-stream`
|
||||
- `X-User-Id`, `X-Trace-Id` (injected by Auth GW)
|
||||
|
||||
**Query params:**
|
||||
- `community_id=<uuid>` — active community (optional, can start with DMs)
|
||||
- `last_event_id=<id>` — for reconnection replay
|
||||
|
||||
### 3.2 SSE Event Format
|
||||
|
||||
```
|
||||
event: <event-type>
|
||||
id: <sequential-id>
|
||||
data: <json-payload>
|
||||
```
|
||||
|
||||
### 3.3 Event Types
|
||||
|
||||
| Event Type | Payload | Description |
|
||||
|------------|---------|-------------|
|
||||
| `init` | `{communities, channels, dms, user}` | Initial state on connect |
|
||||
| `message.new` | `{message}` | New message in subscribed channel |
|
||||
| `message.edit` | `{message}` | Message edited |
|
||||
| `message.delete` | `{message_id, channel_id}` | Message deleted |
|
||||
| `reaction.add` | `{message_id, emoji, user_id}` | Reaction added |
|
||||
| `reaction.remove` | `{message_id, emoji, user_id}` | Reaction removed |
|
||||
| `typing.start` | `{channel_id, user_id, username}` | User started typing |
|
||||
| `typing.stop` | `{channel_id, user_id}` | User stopped typing |
|
||||
| `presence.update` | `{user_id, status}` | Online/offline change |
|
||||
| `channel.new` | `{channel}` | New channel in community |
|
||||
| `channel.update` | `{channel}` | Channel updated (topic, name) |
|
||||
| `channel.delete` | `{channel_id}` | Channel deleted |
|
||||
| `member.join` | `{channel_id, user}` | User joined channel |
|
||||
| `member.leave` | `{channel_id, user_id}` | User left channel |
|
||||
| `notification` | `{notification}` | New notification for user |
|
||||
| `unread.update` | `{channel_id, count, mentions}` | Unread count changed |
|
||||
| `ping` | `{}` | Keepalive (every 30s) |
|
||||
|
||||
### 3.4 Init Event
|
||||
|
||||
Sent immediately after SSE connection established:
|
||||
|
||||
```json
|
||||
{
|
||||
"communities": [
|
||||
{"id": "uuid", "name": "My Team", "slug": "my-team", "role": "owner"}
|
||||
],
|
||||
"channels": {
|
||||
"community-uuid": [
|
||||
{"id": "uuid", "name": "general", "type": "text", "category": "General",
|
||||
"unread_count": 3, "mention_count": 1}
|
||||
]
|
||||
},
|
||||
"dms": [
|
||||
{"id": "uuid", "type": "dm", "members": [...], "unread_count": 0}
|
||||
],
|
||||
"user": {"id": "uuid", "username": "alice", "display_name": "Alice"}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Client → Server Signals (HTTP POST)
|
||||
|
||||
| Action | Endpoint | Payload |
|
||||
|--------|----------|---------|
|
||||
| Send message | `POST /tui/messages` | `{channel_id, body_md, parent_id?}` |
|
||||
| Edit message | `POST /tui/messages/:id/edit` | `{body_md}` |
|
||||
| Delete message | `POST /tui/messages/:id/delete` | — |
|
||||
| Add reaction | `POST /tui/reactions` | `{message_id, emoji}` |
|
||||
| Remove reaction | `POST /tui/reactions/remove` | `{message_id, emoji}` |
|
||||
| Switch channel | `POST /tui/navigate` | `{channel_id, community_id?}` |
|
||||
| Mark read | `POST /tui/read` | `{channel_id, message_id}` |
|
||||
| Typing | `POST /tui/typing` | `{channel_id}` |
|
||||
| Heartbeat | `POST /tui/heartbeat` | — |
|
||||
| Fetch messages | `GET /tui/messages` | `?channel_id=<uuid>&before=<uuid>&limit=50` |
|
||||
| Search | `GET /tui/search` | `?q=<query>&type=<type>` |
|
||||
| Slash command | `POST /tui/command` | `{command, channel_id, community_id}` |
|
||||
|
||||
All responses are JSON.
|
||||
|
||||
## 5. Connection Tracking
|
||||
|
||||
```clojure
|
||||
{user-id {:sse-channel <http-kit-channel>
|
||||
:active-community uuid-or-nil
|
||||
:active-channel uuid-or-nil
|
||||
:nats-subs [sub-handles...]
|
||||
:last-event-id int
|
||||
:connected-at instant}}
|
||||
```
|
||||
|
||||
- On connect: send `init` event, subscribe to user's NATS subjects
|
||||
- On disconnect: unsubscribe from NATS, clean up state
|
||||
- On community switch: update NATS subscriptions
|
||||
- Keepalive pings every 30 seconds to detect dead connections
|
||||
|
||||
## 6. Presence Batching
|
||||
|
||||
TUI SM buffers presence events to avoid flooding clients:
|
||||
- Collect heartbeat events over 60-second windows
|
||||
- On flush: diff with previous state, send only changes
|
||||
- Typing indicators sent immediately (latency-sensitive)
|
||||
|
||||
## 7. Test Cases
|
||||
|
||||
### 7.1 SSE Connection
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TSM-T1 | SSE connect | GET /tui/sse/events returns text/event-stream |
|
||||
| TSM-T2 | Init event sent | First event is `init` with communities, channels, DMs, user |
|
||||
| TSM-T3 | NATS subscribed on connect | Server subscribes to user's community subjects |
|
||||
| TSM-T4 | Ping keepalive | Ping event sent every 30 seconds |
|
||||
| TSM-T5 | Disconnect cleanup | SSE disconnect unsubscribes from NATS |
|
||||
| TSM-T6 | Reconnect with last_event_id | Missed events replayed via JetStream |
|
||||
|
||||
### 7.2 Real-Time Events
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TSM-T7 | New message event | Message from NATS → SSE `message.new` event to client |
|
||||
| TSM-T8 | Message edit event | Edit from NATS → SSE `message.edit` event |
|
||||
| TSM-T9 | Message delete event | Delete from NATS → SSE `message.delete` event |
|
||||
| TSM-T10 | Reaction events | Add/remove from NATS → SSE reaction events |
|
||||
| TSM-T11 | Typing indicator | Typing from NATS → SSE `typing.start` event |
|
||||
| TSM-T12 | Typing auto-expire | `typing.stop` sent after 15 seconds of silence |
|
||||
| TSM-T13 | Presence update | Batched presence diffs sent every 60 seconds |
|
||||
| TSM-T14 | Channel events | New/update/delete channel events forwarded |
|
||||
| TSM-T15 | Notification event | User-targeted notification forwarded |
|
||||
| TSM-T16 | Unread count update | New message in other channel → unread count SSE event |
|
||||
|
||||
### 7.3 Client Signals
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TSM-T17 | Send message | POST /tui/messages → API call → success response |
|
||||
| TSM-T18 | Edit message | POST /tui/messages/:id/edit → API call → success |
|
||||
| TSM-T19 | Delete message | POST /tui/messages/:id/delete → API call → success |
|
||||
| TSM-T20 | Switch channel | POST /tui/navigate → NATS sub update → returns channel messages |
|
||||
| TSM-T21 | Fetch older messages | GET /tui/messages with before cursor → paginated results |
|
||||
| TSM-T22 | Search | GET /tui/search → API search → JSON results |
|
||||
| TSM-T23 | Slash command | POST /tui/command → API command → result |
|
||||
| TSM-T24 | Heartbeat | POST /tui/heartbeat → proxied to API |
|
||||
|
||||
### 7.4 Edge Cases
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| TSM-T25 | Multiple TUI clients | Same user, two connections — both receive events |
|
||||
| TSM-T26 | API unavailable | Returns 502, SSE stays open |
|
||||
| TSM-T27 | NATS unavailable | SSE stays open, events resume when NATS recovers |
|
||||
| TSM-T28 | Event filtering | Events for non-subscribed channels not forwarded |
|
||||
| TSM-T29 | DM events | DM messages forwarded via `chat.dm.{channel-id}` subscription |
|
||||
| TSM-T30 | Large init payload | Init event with many channels/DMs serializes correctly |
|
||||
@@ -0,0 +1,441 @@
|
||||
# PRD: Web Session Manager
|
||||
|
||||
**Module:** `web-sm/` | **Namespace:** `ajet.chat.web.*`
|
||||
**Status:** v1 | **Last updated:** 2026-02-17
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The Web Session Manager serves the browser-based chat UI. It renders HTML via Hiccup, uses Datastar for SSE-driven reactivity (server pushes HTML fragments to update the page), subscribes to NATS for real-time events, and calls the API for data reads/writes. **No direct PostgreSQL access.**
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
Browser ←─ SSE ──→ Web SM ──→ NATS (subscribe events)
|
||||
←─ HTML ──→ ──→ API (HTTP, data reads/writes)
|
||||
```
|
||||
|
||||
**Key principle:** Full Datastar hypermedia. The server renders all HTML. Client-side JS is minimal — only what Datastar requires plus small helpers for image paste and textarea auto-resize.
|
||||
|
||||
## 3. Page Layout (Discord-Style)
|
||||
|
||||
```
|
||||
┌─────┬───────────────┬─────────────────────────────────────────┬──────────────┐
|
||||
│ │ │ # general ☰ │ │
|
||||
│ C │ CATEGORIES │ ┌── channel topic text ──────────┐ │ THREAD │
|
||||
│ O │ │ │ │ │ PANEL │
|
||||
│ M │ ▼ General │ │ alice 10:30 AM │ │ (when open) │
|
||||
│ M │ # general │ │ hello everyone! │ │ │
|
||||
│ U │ # random │ │ │ │ Thread in │
|
||||
│ N │ │ │ bob 10:31 AM │ │ # general │
|
||||
│ I │ ▼ Dev │ │ hey! check this out │ │ │
|
||||
│ T │ # backend │ │ [image preview] │ │ alice: │
|
||||
│ Y │ # frontend │ │ │ │ hello! │
|
||||
│ │ │ │ ── New Messages ── │ │ │
|
||||
│ I │ ───────── │ │ │ │ bob: │
|
||||
│ C │ DIRECT MSGS │ │ carol 10:45 AM │ │ reply here │
|
||||
│ O │ alice │ │ @bob nice work! 👍 │ │ │
|
||||
│ N │ bob ● │ │ │ │ │
|
||||
│ S │ group (3) │ │ ── Load older messages ── │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ + │ │ ├────────────────────────────────┤ │ │
|
||||
│ │ 🔍 Search │ │ @mention autocomplete ↕ │ │ │
|
||||
│ │ │ │ [message input area ] 📎│ │ │
|
||||
│ │ │ │ bob is typing... │ │ │
|
||||
│ │ │ └────────────────────────────────┘ │ │
|
||||
└─────┴───────────────┴─────────────────────────────────────────┴──────────────┘
|
||||
```
|
||||
|
||||
### 3.1 Layout Regions
|
||||
|
||||
| Region | ID | Description |
|
||||
|--------|----|-------------|
|
||||
| Community Strip | `#community-strip` | Vertical icon strip (far left). Community avatars/initials. Click to switch. `+` button to create/join. |
|
||||
| Sidebar | `#sidebar` | Channel list with collapsible categories. DM section below separator. Search at bottom. |
|
||||
| Channel Header | `#channel-header` | Channel name, topic, settings icon. |
|
||||
| Message List | `#message-list` | Paginated message display. "Load older" button at top. New message divider. |
|
||||
| Message Input | `#message-input` | Auto-expanding textarea, @mention autocomplete, image paste, typing indicator display. |
|
||||
| Thread Panel | `#thread-panel` | Slide-in panel on right when viewing a thread. Shows root message + replies. |
|
||||
| Member List | `#member-list` | Optional right panel showing channel members + presence. Toggle via header icon. |
|
||||
|
||||
### 3.2 Community Strip
|
||||
|
||||
```
|
||||
┌─────┐
|
||||
│ MT │ ← "My Team" (initials, or community avatar)
|
||||
├─────┤
|
||||
│ OS │ ← "Open Source"
|
||||
├─────┤
|
||||
│ │
|
||||
│ ... │
|
||||
├─────┤
|
||||
│ DM │ ← DMs section (always present, not community-scoped)
|
||||
├─────┤
|
||||
│ + │ ← Create/join community
|
||||
└─────┘
|
||||
```
|
||||
|
||||
- Active community: highlighted border/background
|
||||
- Unread indicator: dot badge on community icon
|
||||
- Mention indicator: red badge with count
|
||||
- DM icon always at top (or bottom — separate from communities)
|
||||
- Each community remembers the last-viewed channel
|
||||
|
||||
### 3.3 Sidebar — Channel List
|
||||
|
||||
```
|
||||
▼ GENERAL ← category (collapsible)
|
||||
# general 3 ← unread count badge
|
||||
# random
|
||||
# announcements
|
||||
|
||||
▼ DEVELOPMENT
|
||||
# backend ● ← mention indicator
|
||||
# frontend
|
||||
# devops
|
||||
|
||||
──────────────────────────── ← separator
|
||||
|
||||
DIRECT MESSAGES
|
||||
alice ● ← online indicator (green dot)
|
||||
bob
|
||||
group chat (3) 2 ← unread count
|
||||
```
|
||||
|
||||
- Categories collapsible (click header to toggle)
|
||||
- Channels: bold name = has unread messages
|
||||
- Unread count badge (number) for messages
|
||||
- Red dot for unread @mentions
|
||||
- DM section: shows online status dot, unread count
|
||||
- Right-click channel → context menu (mute, leave, mark read)
|
||||
|
||||
### 3.4 Message Display
|
||||
|
||||
Each message renders as:
|
||||
|
||||
```
|
||||
┌─ avatar ─┬─────────────────────────────────────────────┐
|
||||
│ [img] │ alice 10:30 AM │
|
||||
│ │ hello everyone! check out #backend │
|
||||
│ │ │
|
||||
│ │ [inline image preview ─────────────] │
|
||||
│ │ │
|
||||
│ │ 👍 3 ❤️ 1 │ + Add Reaction │ ⋮ More │
|
||||
│ │ 💬 2 replies (last reply 5 min ago) │
|
||||
└──────────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Message elements:**
|
||||
- Avatar (from user profile or OAuth)
|
||||
- Username (display name, colored if admin/owner)
|
||||
- Timestamp (relative for today, absolute for older)
|
||||
- Body (rendered Discord markdown → HTML)
|
||||
- Mentions: `@alice` highlighted, `@here` highlighted differently
|
||||
- Channel links: `#backend` clickable
|
||||
- Inline image preview (click for full size)
|
||||
- Reaction bar: emoji + count, click to toggle your reaction, `+` to add new
|
||||
- Thread indicator: reply count + last reply time, click to open thread panel
|
||||
- Hover actions: reply (thread), react, more (edit, delete, copy link)
|
||||
- Edited indicator: "(edited)" text after timestamp if `edited_at` is set
|
||||
|
||||
**Message grouping:** Consecutive messages from the same user within 5 minutes collapse (no repeated avatar/name).
|
||||
|
||||
### 3.5 Message Input Area
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ @bob ▼ ← autocomplete │
|
||||
│ @bobby dropdown │
|
||||
│ @bob_builder │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [auto-expanding textarea ] 📎│
|
||||
│ │
|
||||
│ alice is typing... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Auto-expanding textarea (grows with content, max ~10 lines then scroll)
|
||||
- Enter to send, Shift+Enter for newline
|
||||
- `@` triggers user mention autocomplete (search by username/display name)
|
||||
- `#` triggers channel autocomplete
|
||||
- `/` at start triggers slash command autocomplete
|
||||
- `📎` button for image upload (opens file picker, images only)
|
||||
- Ctrl+V / Cmd+V pastes clipboard images directly
|
||||
- Typing indicator: "alice is typing..." / "alice and bob are typing..." / "several people are typing..."
|
||||
|
||||
### 3.6 Thread Panel
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Thread in #general ✕ │
|
||||
│─────────────────────────────────│
|
||||
│ alice 10:30 AM │
|
||||
│ hello everyone! │
|
||||
│─────────── 3 replies ──────────│
|
||||
│ bob 10:31 AM │
|
||||
│ hey there! │
|
||||
│ │
|
||||
│ carol 10:32 AM │
|
||||
│ hi all! │
|
||||
│ │
|
||||
│ [reply input ] │
|
||||
│ ☐ Also send to #general │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Opens as a right panel (does not replace message list)
|
||||
- Shows root message at top
|
||||
- Thread replies below
|
||||
- Reply input at bottom
|
||||
- Optional "Also send to #general" checkbox (broadcasts reply to channel)
|
||||
- Close button returns to normal view
|
||||
|
||||
### 3.7 Search Modal
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 🔍 Search... │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ [search input ] │ │
|
||||
│ │ Filter: ○ All ○ Messages │ │
|
||||
│ │ ○ Channels ○ Users │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Results: │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ #backend · alice · Feb 15 │ │
|
||||
│ │ "check out the new **API** docs" │ │
|
||||
│ ├────────────────────────────────────┤ │
|
||||
│ │ #general · bob · Feb 14 │ │
|
||||
│ │ "the API is looking good" │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ Load more results │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Triggered by Ctrl+K or clicking search in sidebar
|
||||
- Search input with type filter tabs
|
||||
- Results show context (channel, author, date) with highlighted matches
|
||||
- Click result → navigate to message in channel (scroll to it)
|
||||
|
||||
## 4. Datastar SSE Integration
|
||||
|
||||
### 4.1 How It Works
|
||||
|
||||
Datastar uses SSE to push HTML fragment updates from server to client. The server decides what changes and sends targeted DOM updates.
|
||||
|
||||
**Connection flow:**
|
||||
1. Browser loads initial full page (server-rendered Hiccup)
|
||||
2. Datastar opens SSE connection to `/sse/events`
|
||||
3. Server subscribes to relevant NATS subjects for the user
|
||||
4. When events arrive (new message, reaction, typing, etc.), server:
|
||||
a. Renders the HTML fragment for the change
|
||||
b. Sends it over SSE with a Datastar merge directive
|
||||
c. Browser updates the DOM in-place
|
||||
|
||||
**SSE endpoint:** `GET /sse/events`
|
||||
- Params: `?community_id=<uuid>` (which community is active)
|
||||
- Server holds connection open, pushes fragments
|
||||
|
||||
### 4.2 Datastar Fragment Types
|
||||
|
||||
| Event | Fragment Target | Behavior |
|
||||
|-------|----------------|----------|
|
||||
| New message | `#message-list` | Append message HTML at bottom |
|
||||
| Message edited | `#msg-{id}` | Replace message content |
|
||||
| Message deleted | `#msg-{id}` | Remove element |
|
||||
| Reaction added/removed | `#reactions-{msg-id}` | Replace reaction bar |
|
||||
| Typing start | `#typing-indicator` | Update typing text |
|
||||
| Typing stop | `#typing-indicator` | Update typing text |
|
||||
| User online | `#member-{user-id}` | Update presence dot |
|
||||
| User offline | `#member-{user-id}` | Update presence dot |
|
||||
| Channel created | `#channel-list` | Append channel to sidebar |
|
||||
| Unread count update | `#unread-{channel-id}` | Update badge number |
|
||||
| Notification | `#notification-badge` | Update notification count |
|
||||
| Thread reply | `#thread-{parent-id}` | Update reply count / append in thread panel |
|
||||
|
||||
### 4.3 SSE Reconnection
|
||||
|
||||
If SSE connection drops:
|
||||
1. Datastar auto-reconnects (built-in)
|
||||
2. Server uses NATS JetStream to replay missed events since last event ID
|
||||
3. `Last-Event-ID` header used to resume from correct position
|
||||
|
||||
## 5. Client → Server Signals
|
||||
|
||||
All user actions are HTTP POSTs (Datastar form submissions):
|
||||
|
||||
| Action | Endpoint | Payload |
|
||||
|--------|----------|---------|
|
||||
| Send message | `POST /web/messages` | `{channel_id, body_md, parent_id?}` |
|
||||
| Edit message | `POST /web/messages/:id/edit` | `{body_md}` |
|
||||
| Delete message | `POST /web/messages/:id/delete` | — |
|
||||
| Add reaction | `POST /web/reactions` | `{message_id, emoji}` |
|
||||
| Remove reaction | `POST /web/reactions/remove` | `{message_id, emoji}` |
|
||||
| Switch channel | `POST /web/navigate` | `{channel_id}` |
|
||||
| Switch community | `POST /web/navigate` | `{community_id}` |
|
||||
| Mark channel read | `POST /web/read` | `{channel_id, message_id}` |
|
||||
| Upload image | `POST /web/upload` | Multipart (image file) |
|
||||
| Typing indicator | `POST /web/typing` | `{channel_id}` |
|
||||
| Search | `POST /web/search` | `{query, type?, community_id?}` |
|
||||
| Slash command | `POST /web/command` | `{command, channel_id, community_id}` |
|
||||
| Create community | `POST /web/communities` | `{name, slug}` |
|
||||
| Create channel | `POST /web/channels` | `{name, type, visibility, category_id?}` |
|
||||
|
||||
All of these proxy to the API internally and return Datastar fragment responses.
|
||||
|
||||
## 6. Pages & Routes (Server-Rendered)
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `GET /` | Redirect to last community or setup wizard |
|
||||
| `GET /app` | Main chat application (full page render) |
|
||||
| `GET /app/channel/:id` | Direct link to a channel |
|
||||
| `GET /app/dm/:id` | Direct link to a DM |
|
||||
| `GET /setup` | Community creation wizard (first-time only) |
|
||||
|
||||
### 6.1 Setup Wizard Page
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ │
|
||||
│ Welcome to ajet chat! │
|
||||
│ │
|
||||
│ Create your community │
|
||||
│ │
|
||||
│ Name: [My Team ] │
|
||||
│ Slug: [my-team ] │
|
||||
│ chat.example.com/my-team │
|
||||
│ │
|
||||
│ [ Create Community ] │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Shown after first-ever OAuth login
|
||||
- Slug auto-generated from name (lowercase, hyphenated)
|
||||
- On submit: creates community + #general, redirects to /app
|
||||
|
||||
## 7. Connection Tracking
|
||||
|
||||
Web SM maintains per-user connection state:
|
||||
|
||||
```clojure
|
||||
{user-id {:sse-connection <http-kit-channel>
|
||||
:active-community uuid
|
||||
:active-channel uuid
|
||||
:nats-subs [sub-handles...]
|
||||
:last-seen instant}}
|
||||
```
|
||||
|
||||
- On SSE connect: subscribe to user's communities on NATS, track connection
|
||||
- On SSE disconnect: unsubscribe from NATS, clean up
|
||||
- On community switch: unsubscribe old community subjects, subscribe new ones
|
||||
- Multiple tabs: each tab is a separate SSE connection with independent state
|
||||
|
||||
## 8. Unread Tracking
|
||||
|
||||
**How unread counts are calculated:**
|
||||
|
||||
1. `channel_members.last_read_message_id` — stored in API DB
|
||||
2. When user views a channel: Web SM calls `POST /api/channels/:id/read` with the latest message ID
|
||||
3. Unread count = messages in channel with `id > last_read_message_id`
|
||||
4. Mention count = mentions targeting user in those unread messages
|
||||
5. On SSE events: Web SM pushes updated badge fragments for affected channels
|
||||
|
||||
**Sidebar rendering:**
|
||||
- Bold channel name if unread count > 0
|
||||
- Number badge for unread message count
|
||||
- Red dot if unread mentions exist
|
||||
- Community icon badge: sum of unread mentions across all channels in that community
|
||||
|
||||
## 9. Test Cases
|
||||
|
||||
### 9.1 Page Rendering
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| WEB-T1 | Initial page load | GET /app returns full HTML with sidebar, message list, input area |
|
||||
| WEB-T2 | Channel list renders | Sidebar shows categories + channels for active community |
|
||||
| WEB-T3 | DM list renders | DM section shows user's DM channels |
|
||||
| WEB-T4 | Community strip renders | Icon strip shows all user's communities |
|
||||
| WEB-T5 | Message list renders | Channel messages displayed with correct formatting |
|
||||
| WEB-T6 | Markdown rendering | Discord markdown renders correctly as HTML |
|
||||
| WEB-T7 | Mention rendering | `@<user:uuid>` renders as `@displayname` with highlight |
|
||||
| WEB-T8 | Image inline preview | Image attachments render as inline previews |
|
||||
| WEB-T9 | Thread indicator | Messages with replies show reply count and link |
|
||||
| WEB-T10 | Setup wizard | GET /setup shows community creation form for first-time users |
|
||||
|
||||
### 9.2 SSE & Real-Time
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| WEB-T11 | SSE connection established | Browser opens SSE, server subscribes to NATS |
|
||||
| WEB-T12 | New message appears | Message from another user appears in message list without refresh |
|
||||
| WEB-T13 | Message edit updates | Edited message content updates in-place |
|
||||
| WEB-T14 | Message delete removes | Deleted message disappears from list |
|
||||
| WEB-T15 | Reaction update | Adding/removing reaction updates reaction bar in real-time |
|
||||
| WEB-T16 | Typing indicator shows | Other user typing shows indicator below input |
|
||||
| WEB-T17 | Typing indicator clears | Indicator disappears after 15 seconds of no typing |
|
||||
| WEB-T18 | Presence update | User going online/offline updates sidebar dot |
|
||||
| WEB-T19 | New channel appears | Channel created by admin appears in sidebar |
|
||||
| WEB-T20 | Unread badge updates | New message in other channel updates unread count badge |
|
||||
| WEB-T21 | SSE reconnect | After connection drop, reconnects and catches up on missed events |
|
||||
| WEB-T22 | Community switch | Switching community updates sidebar and message list |
|
||||
| WEB-T23 | Channel switch | Switching channel loads new messages, marks old as read |
|
||||
|
||||
### 9.3 User Actions
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| WEB-T24 | Send message | Typing and pressing Enter sends message, appears in list |
|
||||
| WEB-T25 | Shift+Enter newline | Shift+Enter adds newline, does not send |
|
||||
| WEB-T26 | @mention autocomplete | Typing @ shows user dropdown, selecting inserts mention |
|
||||
| WEB-T27 | #channel autocomplete | Typing # shows channel dropdown |
|
||||
| WEB-T28 | /slash command | Typing / shows command autocomplete |
|
||||
| WEB-T29 | Edit message (hover) | Hover → edit icon → inline edit mode → save |
|
||||
| WEB-T30 | Edit after 1 hour | Edit button not shown for messages > 1 hour old |
|
||||
| WEB-T31 | Delete message | Hover → delete icon → confirmation → message removed |
|
||||
| WEB-T32 | Add reaction | Click + on reaction bar → emoji picker → reaction added |
|
||||
| WEB-T33 | Toggle reaction | Click existing reaction emoji to toggle on/off |
|
||||
| WEB-T34 | Image paste | Ctrl+V with clipboard image → upload → preview in input → send |
|
||||
| WEB-T35 | Image upload button | Click 📎 → file picker → select image → upload → send |
|
||||
| WEB-T36 | Open thread | Click thread indicator → thread panel opens on right |
|
||||
| WEB-T37 | Reply in thread | Type in thread input → reply appears in thread |
|
||||
| WEB-T38 | Search | Ctrl+K → search modal → type query → results shown → click to navigate |
|
||||
| WEB-T39 | Create channel | Admin clicks + → form → submits → channel appears in sidebar |
|
||||
| WEB-T40 | Join public channel | Click channel → join prompt → joined → messages load |
|
||||
| WEB-T41 | Leave channel | Right-click → leave → channel removed from sidebar |
|
||||
| WEB-T42 | Collapse category | Click category header → channels hidden → click again → shown |
|
||||
| WEB-T43 | Mark channel read | Opening channel marks it as read, badge clears |
|
||||
| WEB-T44 | Paginated loading | Scroll to "Load older" → click → older messages prepended |
|
||||
| WEB-T45 | Create community | Click + on community strip → wizard → community created |
|
||||
|
||||
### 9.4 Profile & Settings
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| WEB-T46 | User profile popover | Click username → popover with avatar, name, status, role |
|
||||
| WEB-T47 | Set status | Click own avatar → status input → save |
|
||||
| WEB-T48 | Set nickname | In community settings → nickname field → save |
|
||||
|
||||
### 9.5 Error Handling
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| WEB-T49 | API unreachable | Shows error banner, retries |
|
||||
| WEB-T50 | Message send fails | Error shown inline below input, message not lost |
|
||||
| WEB-T51 | Upload too large | Error shown, file not uploaded |
|
||||
| WEB-T52 | Rate limited | Error shown with retry countdown |
|
||||
|
||||
### 9.6 Responsive / Layout
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| WEB-T53 | Desktop layout | Full 3-column layout renders correctly |
|
||||
| WEB-T54 | Thread panel coexists | Thread panel + message list visible simultaneously |
|
||||
| WEB-T55 | Long messages wrap | Long messages wrap correctly, no horizontal scroll |
|
||||
| WEB-T56 | Code block rendering | Fenced code blocks render with syntax highlighting |
|
||||
| WEB-T57 | Spoiler tags | `||spoiler||` renders hidden, click to reveal |
|
||||
Reference in New Issue
Block a user