# Knowledge Base Module

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `customers-module`, `system-i18n`  
**Referenced by:** `ai-assistant`, `tenant-portals`, `settings-module`

---

## Overview

Internal wiki and client-facing knowledge vaults. Staff create articles in a hierarchical space structure. Customer vaults are spaces restricted to specific customers — customers see them in the portal. Articles support rich text (Tiptap v2 JSONB), file attachments, PDF viewer, and signed R2 URLs. AI assistant can search the KB via Vectorize for RAG answers.

---

## Data Model

```sql
kb_spaces (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  slug TEXT NOT NULL,
  type TEXT DEFAULT 'internal',        -- 'internal' | 'vault'
  customer_id UUID,                    -- vault: locked to a specific customer (nullable for internal)
  icon TEXT,                           -- emoji or icon name
  is_public BOOLEAN DEFAULT false,     -- true = visible to all portal users (not recommended for vaults)
  description TEXT,
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, slug)
)

kb_articles (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  space_id UUID NOT NULL,
  parent_id UUID,                      -- nullable (root article = null; tree structure)
  title TEXT NOT NULL,
  slug TEXT NOT NULL,
  content JSONB NOT NULL,              -- Tiptap v2 JSON
  status TEXT DEFAULT 'DRAFT',         -- 'DRAFT' | 'PUBLISHED'
  position NUMERIC NOT NULL,           -- fractional indexing within parent
  published_at TIMESTAMPTZ,
  created_by UUID NOT NULL,
  updated_by UUID,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE (space_id, slug)
)

kb_attachments (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  article_id UUID NOT NULL,
  filename TEXT NOT NULL,
  r2_key TEXT NOT NULL,
  file_type TEXT NOT NULL,
  file_size_bytes INTEGER NOT NULL,
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
)
```

---

## Features

### Spaces List (`/kb`)

Left sidebar: list of all spaces. Icons + names. "Internal" spaces at top, "Vault" spaces grouped by customer below.

"New space" button → modal: name, type (internal/vault), customer (if vault), icon.

### Article Tree (sidebar within space)

Hierarchical tree: parent articles → child articles. Drag-reorder within same parent. Position managed via fractional indexing (same pattern as tasks).

### Article Editor (`/kb/:spaceSlug/:articleSlug/edit`)

Full-page Tiptap v2 editor. Toolbar: headings, bold/italic/underline, lists, blockquote, code block, divider, table, image/file attach.

**Images and files:**
- Paste or drag image → uploads to R2 → embedded as a stable proxy URL (`GET /api/kb/images/:key`) that re-validates article visibility at request time
- File attach toolbar button → upload to R2 → `kb_attachments` record → rendered as download link in article
- PDF files: rendered in-page via `<embed>` or `<object>` tag (browser native PDF viewer)

**PDF viewer pattern:**
- R2 signed URL (60min TTL) → `<object data="{signedUrl}" type="application/pdf" ...>`
- Mobile fallback: download link if browser PDF rendering unavailable

Auto-save: Tiptap onChange → debounced 2s → `PATCH /api/kb/articles/:id`. Visual save indicator (Unsaved changes / Saved).

Publish toggle: DRAFT articles visible only to staff. PUBLISHED articles visible to portal users (if in a vault space they have access to).

### Article Read View (`/kb/:spaceSlug/:articleSlug`)

Read-only rendered view of a published or draft article. Separate from the editor route (`/kb/:spaceSlug/:articleSlug/edit` — see `kb-article-editor` spec).

**Layout:**
```
┌─────────────────────────────────────────────────────────┐
│ ← Space Name > Parent Article > Article Title           │  ← breadcrumb
│                                                          │
│  ┌── Article Title ──────────────────────────┐  ┌──TOC─┐│
│  │  <rendered Tiptap content>                │  │ # H2 ││
│  │  ...                                      │  │  H3  ││
│  │  [Attachments: file1.pdf, file2.docx]     │  │ # H2 ││
│  └───────────────────────────────────────────┘  └──────┘│
│                                                          │
│  Related: Article A · Article B (siblings in space)      │
└─────────────────────────────────────────────────────────┘
```

- **Breadcrumbs:** space name → parent article title (if nested) → current article title. Each crumb links to its respective page.
- **TOC (Table of Contents):** extracted from h1/h2/h3 headings in the Tiptap JSON; sticky on scroll; visible if ≥ 3 headings. Clicking a heading smooth-scrolls to it.
- **Related articles:** sibling articles (same parent in tree); max 5 links; ordered by `position`.
- **Attachments footer:** list of `kb_attachments` records with type icon, filename, size; each triggers signed-URL fetch on click.
- **Edit button:** shown to users with `kb:write` — navigates to `/kb/:spaceSlug/:articleSlug/edit`.
- **DRAFT watermark:** articles with `status = 'DRAFT'` show a "Draft — not visible to portal" banner at top; staff can still view.
- **View count:** incremented fire-and-forget on each staff view via `PUT /api/kb/articles/:id/view` (Worker-side, not transactional). Displayed in the article settings panel (editor spec).

**Portal equivalent:** `/portal/:tenantSlug/kb/:spaceSlug/:articleSlug` — same rendered layout, DRAFT watermark hidden, edit button hidden. Access enforced by `portalQuery` checking `kb_spaces.customer_id`.

**Route distinction:**

| Route | Mode |
|-------|------|
| `/kb/:spaceSlug/:articleSlug` | Read view (this spec) |
| `/kb/:spaceSlug/:articleSlug/edit` | Full editor (kb-article-editor spec) |

### Mobile route behavior

At phone widths, KB space navigation becomes a capped top panel; article-tree and version-history sidebars are hidden. Article content and editor controls use 16px inline padding; header actions wrap without horizontal overflow. Article TOC remains desktop-only.

Rationale: preserve reading and editing workflows inside the mobile app frame.

### Search

Full-text search via Vectorize (tenant namespace). AI assistant uses this for RAG (see `ai-assistant` spec). Manual search bar in KB sidebar: `GET /api/kb/search?q=` → returns matching articles ranked by relevance.

On article create/update: embed content → upsert vector in Vectorize namespace `tenant:{tenantId}` (same namespace as AI assistant spec 6) with metadata `{ source: 'kb', articleId, spaceId }`. AI assistant RAG queries filter by `source = 'kb'` when searching KB; queries without filter search all content types. Vectorize namespaces are exact-match — a separate `:kb` suffix would be invisible to the assistant's RAG queries.

### Client Vault Access

Vault spaces linked to a `customer_id`. Portal users authenticated as that customer can read all PUBLISHED articles in that vault at `/portal/:tenantSlug/kb/:spaceSlug`.

Staff can also share a vault with multiple customers (future: `kb_space_customers` join table — out of scope for V1; V1 is one customer per vault).

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View internal KB | `kb:read` |
| Create/edit articles | `kb:write` |
| Publish articles | `kb:publish` |
| Delete articles | `kb:delete` |
| Manage spaces | `kb:write` |
| View vault (staff) | `kb:read` |

Portal customers: can read PUBLISHED articles in their vault spaces only (enforced by `customer_id` check on space).

---

## API Endpoints

```
GET    /api/kb/spaces                      → list spaces (staff: all; portal: accessible vaults)
POST   /api/kb/spaces                      → create space
PATCH  /api/kb/spaces/:id                  → update space
DELETE /api/kb/spaces/:id                  → delete (cascade articles)

GET    /api/kb/spaces/:id/articles         → article tree
POST   /api/kb/articles                    → create article
GET    /api/kb/articles/:id                → article detail + content; when status is DRAFT and the article was rejected from review, includes `latestRejection` (`feedback`, `rejectedAt`, `rejectedBy`) from the most recent rejection audit entry — closes the submit-for-review→reject loop for authors
PATCH  /api/kb/articles/:id                → update content / publish / reorder
DELETE /api/kb/articles/:id                → delete

POST   /api/kb/articles/:id/attachments    → upload file → R2 + record
GET    /api/kb/articles/:id/attachments    → list attachments
GET    /api/kb/attachments/:id/url         → signed R2 URL (60min TTL; staff)
GET    /api/portal/kb/attachments/:id/url → signed R2 URL (60min TTL; portal)
GET    /api/kb/images/:key                 → inline image proxy (re-validates article access; streams from private R2)

GET    /api/kb/search?q=                   → semantic/FTS search
```

---

## Security Notes

- R2 keys never exposed directly. Attachment downloads use signed URL endpoints with 60min TTL; inline images use a stable proxy URL that re-checks article visibility per request.
- SVG uploads: rejected (XSS risk same-origin). Allowlist: PDF, JPG, PNG, WEBP, GIF, MP4, DOCX, XLSX.
- Images embedded in Tiptap content stored in R2 and served via `GET /api/kb/images/:key` — not inline base64 and not expiring signed URLs baked into JSONB (which would break on later views).

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Tiptap v2 JSONB | Not Markdown | Richer formatting; consistent with task descriptions; JSONB queryable |
| Tree structure via parent_id | Not separate sections table | Sufficient depth for KB; fractional index handles ordering |
| Vault = space with customer_id | Not separate vault model | DRY; same article/edit/publish flow; access filter is one extra WHERE clause |
| Signed URLs for attachments | Not public R2 | Vault files are confidential; signed URLs enforce access control at retrieval time |
| Stable proxy for inline images | `GET /api/kb/images/:key` | Embedded image URLs persist in JSONB; a 60min signed URL would expire and break renders; proxy re-validates access at request time while keeping the bucket private |
| Vectorize embed on save | Not batch cron | Real-time search freshness; article saves are infrequent (no quota concern) |
| Shared Vectorize namespace with AI assistant | `tenant:{id}` + metadata filter, not separate `:kb` namespace | Vectorize namespaces are exact-match partitions — separate namespace = assistant RAG never finds KB content; metadata filter `source=kb` enables scoped KB search without isolation cost |
| V1: one customer per vault | Not many-to-many | Simplest model; join table added when multi-customer vault needed |
