diff --git a/echo/.devcontainer/devcontainer.json b/echo/.devcontainer/devcontainer.json index f2f8dafb..06b419c6 100644 --- a/echo/.devcontainer/devcontainer.json +++ b/echo/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/universal { - "name": "dembrane/echo", + "name": "dembrane", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile // "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "dockerComposeFile": "docker-compose.yml", @@ -49,8 +49,8 @@ "5432": { "label": "postgres" }, - "9001": { - "label": "minio-ui" + "2222": { + "label": "ssh" } }, // Configure tool-specific properties. diff --git a/echo/.devcontainer/docker-compose.yml b/echo/.devcontainer/docker-compose.yml index a570981a..233f4d20 100644 --- a/echo/.devcontainer/docker-compose.yml +++ b/echo/.devcontainer/docker-compose.yml @@ -21,22 +21,6 @@ services: networks: - dembrane-network - neo4j: - image: neo4j:5.26.16-community - restart: unless-stopped - volumes: - - ./neo4j_data/logs:/logs - - ./neo4j_data/config:/config - - ./neo4j_data/data:/data - - ./neo4j_data/plugins:/plugins - ports: - - 7474:7474 # Neo4j Browser - - 7687:7687 # Neo4j Bolt protocol - environment: - - NEO4J_AUTH=neo4j/admin@dembrane - networks: - - dembrane-network - directus: build: context: ../directus @@ -104,6 +88,7 @@ services: - 8000:8000 - 5173:5173 - 5174:5174 + - 2222:22 environment: - DIRECTUS_SECRET=secret - DIRECTUS_TOKEN=admin @@ -120,9 +105,6 @@ services: - STORAGE_S3_SECRET=dembrane - STORAGE_S3_BUCKET=dembrane - STORAGE_S3_ENDPOINT=http://minio:9000 - - NEO4J_URI=bolt://neo4j:7687 - - NEO4J_USERNAME=neo4j - - NEO4J_PASSWORD=admin@dembrane volumes: - ../..:/workspaces:cached @@ -134,7 +116,6 @@ services: depends_on: - postgres - redis - - neo4j networks: dembrane-network: diff --git a/echo/.devcontainer/setup.sh b/echo/.devcontainer/setup.sh index 6350146e..54cf4a18 100755 --- a/echo/.devcontainer/setup.sh +++ b/echo/.devcontainer/setup.sh @@ -247,6 +247,66 @@ install_server_deps() { fi } +install_ssh_server() { + if command_exists sshd && [ -f /etc/ssh/sshd_config ]; then + log_info "openssh-server already installed" + else + ensure_apt_packages openssh-server + log_info "openssh-server installed" + fi + + # Generate host keys if missing + if [ ! -f /etc/ssh/ssh_host_ed25519_key ]; then + log_info "Generating SSH host keys..." + ssh-keygen -A + fi + + # Ensure /var/run/sshd exists (required for sshd to start) + mkdir -p /var/run/sshd + + # Configure sshd: allow root login (dev container runs as root), + # enable password auth for first-time setup, permit public key auth. + local sshd_config="/etc/ssh/sshd_config.d/99-devcontainer.conf" + cat > "$sshd_config" <<'CONF' +# Devcontainer SSH config +Port 22 +PermitRootLogin yes +PasswordAuthentication yes +PubkeyAuthentication yes +AuthorizedKeysFile .ssh/authorized_keys +PermitEmptyPasswords no +X11Forwarding yes +AllowAgentForwarding yes +AllowTcpForwarding yes +PrintMotd no +UsePAM yes +AcceptEnv LANG LC_* +Subsystem sftp /usr/lib/openssh/sftp-server +CONF + + # Set a default root password so initial SSH works + # (override in your own setup with ssh-copy-id to switch to key auth) + if ! passwd -S root 2>/dev/null | grep -q "^root P "; then + log_info "Setting default root password (change after first login)" + echo 'root:dembrane' | chpasswd + fi + + # Ensure .ssh dir exists for authorized_keys + mkdir -p /root/.ssh + chmod 700 /root/.ssh + touch /root/.ssh/authorized_keys + chmod 600 /root/.ssh/authorized_keys + + # Start sshd (not systemd-managed in container — run directly) + if pgrep -x sshd > /dev/null; then + log_info "sshd already running" + else + log_info "Starting sshd..." + /usr/sbin/sshd + fi + log_info "SSH server ready on port 22 (user: root, password: dembrane)" +} + install_postgresql_client() { if command_exists psql && psql --version | grep -q "psql (PostgreSQL) 16"; then log_info "PostgreSQL client 16 already installed: $(psql --version)" @@ -289,6 +349,7 @@ Options: --skip-server Skip server dependency installation --skip-python Skip managed Python setup for uv --skip-postgres Skip PostgreSQL client installation + --skip-ssh Skip SSH server installation Environment overrides: NODE_VERSION (default: ${NODE_VERSION}) @@ -305,6 +366,7 @@ parse_args() { SKIP_SERVER="false" SKIP_PYTHON="false" SKIP_POSTGRES="false" + SKIP_SSH="false" while [[ $# -gt 0 ]]; do case "$1" in @@ -340,6 +402,10 @@ parse_args() { SKIP_POSTGRES="true" shift ;; + --skip-ssh) + SKIP_SSH="true" + shift + ;; *) log_error "Unknown option: $1" show_help @@ -412,6 +478,12 @@ main() { log_info "Skipping PostgreSQL client installation" fi + if [ "$SKIP_SSH" = "false" ]; then + install_ssh_server + else + log_info "Skipping SSH server installation" + fi + if [ "$SKIP_FRONTEND" = "false" ]; then install_frontend_deps else diff --git a/echo/.gitignore b/echo/.gitignore index aed7e6a5..b62c6f0c 100644 --- a/echo/.gitignore +++ b/echo/.gitignore @@ -32,4 +32,4 @@ echo-gitops/ cypress/reports/ -cookies.txt \ No newline at end of file +cookies.txtscripts/__pycache__/ diff --git a/echo/.vscode/sessions.json b/echo/.vscode/sessions.json index c330c33e..670f1b71 100644 --- a/echo/.vscode/sessions.json +++ b/echo/.vscode/sessions.json @@ -1,7 +1,7 @@ { "$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v11/terminal-keeper.json", "active": "default", - "keepExistingTerminals": false, + "keepExistingTerminals": true, "sessions": { "default": [ { diff --git a/echo/CLAUDE.md b/echo/CLAUDE.md new file mode 100644 index 00000000..69932763 --- /dev/null +++ b/echo/CLAUDE.md @@ -0,0 +1,117 @@ +# CLAUDE.md + +Rules and conventions for working on the ECHO codebase. Follow these precisely. + +## Directus Rules (Critical) + +**Never hand-write Directus sync/snapshot JSON files.** To create or modify Directus collections: + +1. Write a Python script (e.g., `scripts/create_schema.py`) that uses the Directus REST API (`POST /collections`, `POST /fields`, `POST /relations`) with the admin token +2. Make scripts idempotent (check `collection_exists()` / `field_exists()` before creating) +3. Run the script step-by-step to verify each change +4. After all changes, pull the schema: `cd directus && bash sync.sh -u http://directus:8055 -e admin@dembrane.com -p admin pull` +5. Commit the sync output (the JSON files in `directus/sync/snapshot/`) + +See `scripts/create_schema.py` for the established pattern (Session 2 workspaces schema). + +### Python DirectusClient + +- `create_item` / `update_item` return `{"data": {...}}` — **MUST** unwrap with `["data"]` +- `get_items` / `get_item` return data directly (no wrapper) +- `get_items` requires `{"query": {filter, fields, sort, ...}}` wrapper +- `search()` silently returns `{"error": "..."}` on failure — always validate return is a list before iterating + +```python +# CORRECT +new = client.create_item("collection", {...})["data"] +items = client.get_items("collection", {"query": {"filter": {...}}}) +if not isinstance(items, list): + items = [] + +# WRONG — missing ["data"] unwrap +new = client.create_item("collection", {...}) +# WRONG — missing "query" wrapper +items = client.get_items("collection", {"filter": {...}}) +``` + +### TypeScript Directus SDK + +- Auto-unwraps everything — no `["data"]` needed +- If there's a type error with `.count`, add it to `typesDirectus.d.ts` and use `count("")` in fields + +See `memory/directus-rules.md` for comprehensive patterns. + +## Brand & UI Copy + +Follow `brand/STYLE_GUIDE.md` for all user-facing text: + +- **Never say "AI"** — use "language model" or just describe the action ("Generating your report..." not "Generating report with AI...") +- **Never say "successfully"** — just state what happened ("Saved" not "Successfully saved") +- **"dembrane" always lowercase**, even at sentence start +- **Never use bold for emphasis** — use Royal Blue (#4169e1) or italics +- Say "participants/hosts" not "users" +- Dutch translations: use informal "je/jij" form, keep English terms when they sound better (Dashboard, Upload, Chat) + +## UI Rules + +- **Never stack multiple Alert components** — show either the error alert or the info alert, not both +- **Don't use `@mantine/charts`** — use better charting libraries +- **Loading spinners**: always use `alwaysDembrane` prop on `DembraneLoadingSpinner` for whitelabel safety; never `animate-spin` on custom logos +- **Show emails only on hover** — don't display them by default in lists +- **Conversations come from QR codes or uploads** — never add "new conversation" buttons in the UI +- **Prefer text buttons over icon-only buttons** for important actions (e.g., "Go full screen" should be a text button) + +## Architecture Preferences + +- **BFF pattern**: move frontend Directus SDK calls to backend `/bff/` routes. Frontend should call aggregated API endpoints, not make multiple Directus queries +- **URL-driven state**: use URL search params (not React state) so state is shareable and persistent +- **SSE for progress**: use Server-Sent Events + Redis pub/sub for real-time progress (report generation, health streams) +- **No asyncio in Dramatiq actors**: use gevent pools + dramatiq groups instead. Report generation is fully synchronous +- **gevent.pool.Pool only in `network` queue** (uses `dramatiq-gevent`). CPU queue runs standard dramatiq +- **Use `gevent.sleep()` not `time.sleep()`** in network-queue actors + +## LLM Model Groups + +- `MULTI_MODAL_PRO` (Gemini 2.5 Pro) — chat, report generation, transcript correction. **Do not downgrade chat to Flash.** +- `MULTI_MODAL_FAST` (Gemini 2.5 Flash) — suggestions, verification, stateless endpoints, lightweight tasks +- `TEXT_FAST` (Azure GPT-4.1) — being deprecated, migrating to Gemini +- Report prompt templates are written IN the target language (not just instructing the LLM to write in that language) + +## Translations + +```bash +cd frontend +pnpm messages:extract # Extract new strings to .po files +# Edit .po files in src/locales/ (en-US, nl-NL, de-DE, fr-FR, es-ES, it-IT) +pnpm messages:compile # Compile for production +``` + +Use `` component or `` t` `` template literal from Lingui. + +## Branching Strategy & Deployment + +See [docs/branching_and_releases.md](docs/branching_and_releases.md) for the full guide. + +Quick reference: +- **Feature flow**: branch off `main` → (optional) merge to `testing` for testing → PR to `main` → auto-deploys to Echo Next +- **Releases**: tagged from `main` every ~2 weeks → auto-deploys to production +- **Hotfixes**: branch off release tag → fix → new release → cherry-pick into main +- Always check for Directus data migrations before deploying (see `docs/database_migrations.md`) + +## Transcription + +- AssemblyAI `universal-3-pro` supports: en, es, pt, fr, de, it +- Dutch ("nl") requires `universal-2` fallback — `universal-3-pro` does NOT support it +- Production uses webhook mode (`ASSEMBLYAI_WEBHOOK_URL`), polling is only a fallback + +## Dramatiq Tasks + +- Restart workers after changing task signatures (positional args are serialized) +- `SkipRetryOnUnrecoverableError` middleware skips retries for TypeError, SyntaxError, AttributeError, ImportError, NotImplementedError +- When invoking async code from Dramatiq actors, use `run_async_in_new_loop` from `dembrane.async_helpers` + +## Project Management + +- Linear for issue tracking — tickets are `ECHO-xxx` +- Two-week cycles/sprints +- GitOps repo: `dembrane/echo-gitops` (separate repo) diff --git a/echo/directus/.env.sample b/echo/directus/.env.sample index 723c8847..83f40f3c 100644 --- a/echo/directus/.env.sample +++ b/echo/directus/.env.sample @@ -13,4 +13,9 @@ EMAIL_SMTP_PORT=587 EMAIL_SMTP_USER="" EMAIL_SMTP_PASSWORD="" EMAIL_SMTP_SECURE=false -EMAIL_SMTP_IGNORE_TLS=false \ No newline at end of file +EMAIL_SMTP_IGNORE_TLS=false +# URL allow lists (Directus 11 requires these for auth redirect flows). +# Comma-separated. Add production + staging + local dev URLs. +PASSWORD_RESET_URL_ALLOW_LIST="https://dashboard.dembrane.com/password-reset" +USER_REGISTER_URL_ALLOW_LIST="https://dashboard.dembrane.com/verify-email" +USER_INVITE_URL_ALLOW_LIST="https://dashboard.dembrane.com/accept-invite" diff --git a/echo/directus/sync/collections/permissions.json b/echo/directus/sync/collections/permissions.json index 73946008..1cedfc79 100644 --- a/echo/directus/sync/collections/permissions.json +++ b/echo/directus/sync/collections/permissions.json @@ -1,183 +1,99 @@ [ { - "collection": "aspect_segment", + "collection": "referral_ledger", "action": "create", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "_sync_default_public_policy", - "_syncId": "f4b092d2-d1bf-44e7-ab55-50e8c6b38d5a" - }, - { - "collection": "aspect_segment", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "_sync_default_public_policy", - "_syncId": "9005c35e-d9fb-404a-af5f-f3f62690207e" - }, - { - "collection": "aspect_segment", - "action": "update", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "_sync_default_public_policy", - "_syncId": "f45b3686-b142-4fd1-9a07-4f53456f8ede" - }, - { - "collection": "aspect", - "action": "create", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "_sync_default_public_policy", - "_syncId": "b33fe2dd-43e4-414d-8162-7259e9adb8e5" - }, - { - "collection": "aspect", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "_sync_default_public_policy", - "_syncId": "f7921453-b6ae-41c2-a35e-5fe7f3359824" - }, - { - "collection": "aspect", - "action": "update", - "permissions": null, - "validation": null, + "permissions": {}, + "validation": {}, "presets": null, "fields": [ "*" ], - "policy": "_sync_default_public_policy", - "_syncId": "9857fbe4-d7d0-49f5-9331-969fe324542b" + "policy": "_sync_default_admin_policy", + "_syncId": "29475e72-f6fd-4ad0-b36f-bb1a4c032cc4" }, { - "collection": "conversation_segment", - "action": "create", - "permissions": null, - "validation": null, + "collection": "referral_ledger", + "action": "delete", + "permissions": {}, + "validation": {}, "presets": null, "fields": [ "*" ], - "policy": "_sync_default_public_policy", - "_syncId": "5f92f8af-cae1-49d7-8c14-3be26d12c634" + "policy": "_sync_default_admin_policy", + "_syncId": "3ccd189d-4811-4c3d-b02e-0429ad22f9bc" }, { - "collection": "conversation_segment", + "collection": "referral_ledger", "action": "read", - "permissions": null, - "validation": null, + "permissions": {}, + "validation": {}, "presets": null, "fields": [ "*" ], - "policy": "_sync_default_public_policy", - "_syncId": "1960bab3-2e2d-4916-8721-d55626dac3d1" + "policy": "_sync_default_admin_policy", + "_syncId": "945d431c-ca21-4a66-ac07-6f9be5e7d632" }, { - "collection": "conversation_segment", + "collection": "referral_ledger", "action": "update", - "permissions": null, - "validation": null, + "permissions": {}, + "validation": {}, "presets": null, "fields": [ "*" ], - "policy": "_sync_default_public_policy", - "_syncId": "d022ce15-9ea9-4fde-be52-afafb3cded3e" + "policy": "_sync_default_admin_policy", + "_syncId": "e01bf233-5751-4be9-810a-76f2d983f03e" }, { - "collection": "project_analysis_run", + "collection": "workspace_invite", "action": "create", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "_sync_default_public_policy", - "_syncId": "1512372d-b751-4eb5-bbd9-1e3894b3126f" - }, - { - "collection": "project_analysis_run", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "_sync_default_public_policy", - "_syncId": "cb731f22-2cba-40ee-add0-2784dee30482" - }, - { - "collection": "project_analysis_run", - "action": "update", - "permissions": null, - "validation": null, + "permissions": {}, + "validation": {}, "presets": null, "fields": [ "*" ], - "policy": "_sync_default_public_policy", - "_syncId": "160bd0c6-cbaf-4c71-b7f0-e4c7292b8d7e" + "policy": "_sync_default_admin_policy", + "_syncId": "1b816f75-991d-4f0d-9ad7-8c2adf111b2d" }, { - "collection": "view", - "action": "create", - "permissions": null, - "validation": null, + "collection": "workspace_invite", + "action": "delete", + "permissions": {}, + "validation": {}, "presets": null, "fields": [ "*" ], - "policy": "_sync_default_public_policy", - "_syncId": "f9da020f-d670-4426-a9f3-35446715606a" + "policy": "_sync_default_admin_policy", + "_syncId": "af65d560-728d-463d-91bd-2b5bf177a15e" }, { - "collection": "view", + "collection": "workspace_invite", "action": "read", - "permissions": null, - "validation": null, + "permissions": {}, + "validation": {}, "presets": null, "fields": [ "*" ], - "policy": "_sync_default_public_policy", - "_syncId": "692a50f8-7bdc-46fc-9761-366ce84856f2" + "policy": "_sync_default_admin_policy", + "_syncId": "dcc72bad-6556-4aeb-8951-cb6f0a703e8d" }, { - "collection": "view", + "collection": "workspace_invite", "action": "update", - "permissions": null, - "validation": null, + "permissions": {}, + "validation": {}, "presets": null, "fields": [ "*" ], - "policy": "_sync_default_public_policy", - "_syncId": "67f6430d-23ac-493c-8a71-ffb358aa89ef" + "policy": "_sync_default_admin_policy", + "_syncId": "3486ad7e-1f93-4f53-9484-943020a49f76" }, { "collection": "announcement_activity", @@ -337,60 +253,6 @@ "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", "_syncId": "d8d2c4ee-2431-4ea2-8f22-c2b3f4a01014" }, - { - "collection": "aspect_segment", - "action": "read", - "permissions": { - "_and": [ - { - "aspect": { - "view_id": { - "project_analysis_run_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "9c4c00f9-1b2c-4642-a912-fa4bc4d1a054" - }, - { - "collection": "aspect", - "action": "read", - "permissions": { - "_and": [ - { - "view_id": { - "project_analysis_run_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "5f279729-e0c9-43bd-bb08-1ac7426ea4e2" - }, { "collection": "conversation_artifact", "action": "create", @@ -417,7 +279,7 @@ }, { "collection": "conversation_artifact", - "action": "delete", + "action": "read", "permissions": { "_and": [ { @@ -437,11 +299,11 @@ "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "98f115f3-a7ac-40b4-8c2f-b4a5aa517c58" + "_syncId": "89ef9b07-e43d-49de-9145-ebe1c3b90dbf" }, { "collection": "conversation_artifact", - "action": "read", + "action": "update", "permissions": { "_and": [ { @@ -461,58 +323,60 @@ "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "89ef9b07-e43d-49de-9145-ebe1c3b90dbf" + "_syncId": "30e9e653-e09c-41ac-bdf8-4d51bc9e7c0a" }, { - "collection": "conversation_artifact", - "action": "update", + "collection": "conversation_link", + "action": "read", "permissions": { "_and": [ { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" + "_and": [ + { + "source_conversation_id": { + "project_id": { + "directus_user_id": { + "_eq": "$CURRENT_USER" + } + } } } - } + ] } ] }, "validation": null, "presets": null, "fields": [ - "*" + "id", + "date_updated", + "target_conversation_id", + "source_conversation_id", + "link_type", + "date_created" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "30e9e653-e09c-41ac-bdf8-4d51bc9e7c0a" + "_syncId": "e209e43b-6a5b-42da-b383-6e9ec7590a2a" }, { - "collection": "conversation_chunk", + "collection": "conversation_reply", "action": "create", "permissions": null, - "validation": { - "_and": [ - { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, + "validation": null, "presets": null, "fields": [ - "*" + "id", + "content_text", + "type", + "sort", + "conversation_id", + "date_created" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "3539ba7c-1070-4e91-9e29-d9ac9b1eeb3e" + "_syncId": "91d3410c-8b48-40b2-adf7-df202651afbe" }, { - "collection": "conversation_chunk", + "collection": "conversation_reply", "action": "delete", "permissions": { "_and": [ @@ -529,74 +393,59 @@ }, "validation": null, "presets": null, - "fields": [ - "*" - ], + "fields": null, "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "b61ba7ee-1075-42ed-aaf4-a9e979ceeb36" + "_syncId": "00a4168a-2842-4a88-8bb9-53109b54c880" }, { - "collection": "conversation_chunk", + "collection": "conversation_reply", "action": "read", - "permissions": { - "_and": [ - { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, + "permissions": null, "validation": null, "presets": null, "fields": [ - "*" + "id", + "content_text", + "type", + "sort", + "conversation_id", + "date_created" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "5c90dce3-7ce9-4d09-9cd5-7b85f82c2c51" + "_syncId": "6dcf040d-3975-4861-9530-3c2a9e0ec33a" }, { - "collection": "conversation_chunk", + "collection": "conversation_reply", "action": "update", - "permissions": { - "_and": [ - { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, + "permissions": null, "validation": null, "presets": null, "fields": [ - "*" + "id", + "content_text", + "type", + "sort", + "conversation_id", + "date_created" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "4039c10b-4765-4d30-9992-9ab2b326a7bc" + "_syncId": "9063ad1a-741f-4f35-b8ed-9809fdcc7468" }, { - "collection": "conversation_link", + "collection": "directus_activity", "action": "read", "permissions": { "_and": [ { - "_and": [ + "_or": [ { - "source_conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } + "user": { + "_eq": "$CURRENT_USER" + } + }, + { + "item": { + "_eq": "$CURRENT_USER" } } ] @@ -605,1192 +454,312 @@ }, "validation": null, "presets": null, - "fields": [ - "id", - "date_updated", - "target_conversation_id", - "source_conversation_id", - "link_type", - "date_created" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "e209e43b-6a5b-42da-b383-6e9ec7590a2a" - }, - { - "collection": "conversation_project_tag", - "action": "create", - "permissions": null, - "validation": { - "_and": [ - { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "562a8ec3-2134-41f8-bfaf-2d298d28b228" - }, - { - "collection": "conversation_project_tag", - "action": "delete", - "permissions": { - "_and": [ - { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "8ed5e576-d46a-4a76-8935-0f710e4e6c25" - }, - { - "collection": "conversation_project_tag", - "action": "read", - "permissions": { - "_and": [ - { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "71841e2c-c144-434b-a511-a9b5407b5b24" - }, - { - "collection": "conversation_project_tag", - "action": "update", - "permissions": { - "_and": [ - { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "8ffff4fe-d58c-4831-87e4-19c7162d9925" - }, - { - "collection": "conversation_reply", - "action": "create", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "id", - "content_text", - "type", - "sort", - "conversation_id", - "date_created" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "91d3410c-8b48-40b2-adf7-df202651afbe" - }, - { - "collection": "conversation_reply", - "action": "delete", - "permissions": { - "_and": [ - { - "conversation_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": null, - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "00a4168a-2842-4a88-8bb9-53109b54c880" - }, - { - "collection": "conversation_reply", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "id", - "content_text", - "type", - "sort", - "conversation_id", - "date_created" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "6dcf040d-3975-4861-9530-3c2a9e0ec33a" - }, - { - "collection": "conversation_reply", - "action": "update", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "id", - "content_text", - "type", - "sort", - "conversation_id", - "date_created" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "9063ad1a-741f-4f35-b8ed-9809fdcc7468" - }, - { - "collection": "conversation", - "action": "create", - "permissions": null, - "validation": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "743b9372-711a-47cd-8d93-d3783bc0fae8" - }, - { - "collection": "conversation", - "action": "delete", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "5331eb25-dc93-4529-9c31-46444f2652e9" - }, - { - "collection": "conversation", - "action": "read", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "f5fdcbe1-fe6b-4234-83aa-e6d441dda908" - }, - { - "collection": "conversation", - "action": "update", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "b35e076e-2e7c-4558-8ea7-8a1b19ac69b7" - }, - { - "collection": "directus_activity", - "action": "read", - "permissions": { - "_and": [ - { - "_or": [ - { - "user": { - "_eq": "$CURRENT_USER" - } - }, - { - "item": { - "_eq": "$CURRENT_USER" - } - } - ] - } - ] - }, - "validation": null, - "presets": null, - "fields": null, - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "def59c0a-db5f-4c16-9d90-958bfffa72a9" - }, - { - "collection": "directus_files", - "action": "read", - "permissions": { - "_and": [ - { - "_or": [ - { - "folder": { - "name": { - "_contains": "custom_logos" - } - } - }, - { - "folder": { - "name": { - "_contains": "avatars" - } - } - }, - { - "folder": { - "name": { - "_contains": "Public" - } - } - }, - { - "folder": { - "parent": { - "name": { - "_contains": "Public" - } - } - } - }, - { - "folder": { - "parent": { - "parent": { - "name": { - "_contains": "Public" - } - } - } - } - } - ] - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "id", - "title", - "description", - "tags", - "location", - "storage", - "focal_point_divider", - "focal_point_x", - "focal_point_y", - "storage_divider", - "filename_disk", - "filename_download", - "metadata", - "type", - "filesize", - "created_on", - "modified_by", - "modified_on", - "embed", - "uploaded_by", - "uploaded_on", - "width", - "folder", - "height", - "duration", - "charset", - "tus_id", - "tus_data", - "$thumbnail" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "2edbb99f-dc6c-46dd-9337-8e128aef9eaa" - }, - { - "collection": "directus_revisions", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "6170235d-70f2-4bc3-86d5-9bdd3e93e98e" - }, - { - "collection": "directus_users", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "disable_create_project", - "projects", - "whitelabel_logo", - "legal_basis", - "privacy_policy_url" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "25411e4e-fb6c-41e7-be7b-300ca3b503ef" - }, - { - "collection": "directus_users", - "action": "update", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "first_name", - "last_name", - "email", - "projects", - "password", - "disable_create_project", - "avatar", - "location", - "title", - "description", - "tags", - "language", - "text_direction", - "tfa_secret", - "email_notifications", - "appearance", - "theme_light", - "theme_light_overrides", - "theme_dark", - "theme_dark_overrides", - "status", - "role", - "token", - "id", - "last_page", - "last_access", - "whitelabel_logo" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "6377dace-23ad-47e6-bc68-cdf512a26c71" - }, - { - "collection": "languages", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "39305271-534a-40d6-9006-57931c1844d6" - }, - { - "collection": "project_analysis_run", - "action": "create", - "permissions": null, - "validation": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "f9c0fb03-bab2-4a1a-ae5e-78c43ee4c2b3" - }, - { - "collection": "project_analysis_run", - "action": "delete", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "71ca8087-0e58-427b-a216-316fc3f037c8" - }, - { - "collection": "project_analysis_run", - "action": "read", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "7033801a-55af-4700-bb3e-bc17a8e932c0" - }, - { - "collection": "project_analysis_run", - "action": "update", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "95c2b68a-4e99-4df8-9825-5cf23460e721" - }, - { - "collection": "project_chat_conversation", - "action": "create", - "permissions": null, - "validation": { - "_and": [ - { - "project_chat_id": { - "_nnull": true - } - } - ] - }, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "1099fee7-4468-4039-9e46-2321ef1d4147" - }, - { - "collection": "project_chat_conversation", - "action": "delete", - "permissions": { - "_and": [ - { - "project_chat_id": { - "project_id": { - "directus_user_id": { - "id": { - "_eq": "$CURRENT_USER" - } - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "8d2becb9-aa42-4c8b-9e25-39b8d99d20fc" - }, - { - "collection": "project_chat_conversation", - "action": "read", - "permissions": { - "_and": [ - { - "project_chat_id": { - "project_id": { - "directus_user_id": { - "id": { - "_eq": "$CURRENT_USER" - } - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], + "fields": null, "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "40d659f8-16a1-44a3-9820-086534619e4e" + "_syncId": "def59c0a-db5f-4c16-9d90-958bfffa72a9" }, { - "collection": "project_chat_conversation", - "action": "update", + "collection": "directus_files", + "action": "read", "permissions": { "_and": [ { - "project_chat_id": { - "project_id": { - "directus_user_id": { - "id": { - "_eq": "$CURRENT_USER" + "_or": [ + { + "folder": { + "name": { + "_contains": "custom_logos" } } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "3bafd321-c38b-4884-a49e-81c37c2b5364" - }, - { - "collection": "project_chat_message_conversation_1", - "action": "create", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "4e0dfa0e-1f92-4e77-add3-4127058f3785" - }, - { - "collection": "project_chat_message_conversation_1", - "action": "delete", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "f9ae9bb2-5426-4f02-915f-e569b08f23c2" - }, - { - "collection": "project_chat_message_conversation_1", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "21cc1ef3-e45c-47ea-add3-823248641340" - }, - { - "collection": "project_chat_message_conversation_1", - "action": "update", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "d5ebcf80-726f-4198-96c9-aa0299a0bc67" - }, - { - "collection": "project_chat_message_conversation", - "action": "create", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "1ab54698-10ac-4386-99e8-381da49492c5" - }, - { - "collection": "project_chat_message_conversation", - "action": "delete", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "c62556f8-46c2-4662-8fb4-ad2f325ae2d3" - }, - { - "collection": "project_chat_message_conversation", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "db12a077-dfb1-420c-a4a7-e8aac2018fa9" - }, - { - "collection": "project_chat_message_conversation", - "action": "update", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "f2311612-8caa-4592-bb50-6d360131673a" - }, - { - "collection": "project_chat_message", - "action": "create", - "permissions": null, - "validation": { - "_and": [ - { - "project_chat_id": { - "project_id": { - "directus_user_id": { - "id": { - "_eq": "$CURRENT_USER" + }, + { + "folder": { + "name": { + "_contains": "avatars" } } - } - } - } - ] - }, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "d0508f5d-8c4a-4b61-883c-eba2ea207d17" - }, - { - "collection": "project_chat_message", - "action": "delete", - "permissions": { - "_and": [ - { - "project_chat_id": { - "project_id": { - "directus_user_id": { - "id": { - "_eq": "$CURRENT_USER" + }, + { + "folder": { + "name": { + "_contains": "Public" } } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "a429339c-08bf-47b0-8fb5-1076d1ab42d0" - }, - { - "collection": "project_chat_message", - "action": "read", - "permissions": { - "_and": [ - { - "project_chat_id": { - "project_id": { - "directus_user_id": { - "id": { - "_eq": "$CURRENT_USER" + }, + { + "folder": { + "parent": { + "name": { + "_contains": "Public" + } } } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "64d232ee-3c98-464b-b12c-66d55b9a3266" - }, - { - "collection": "project_chat_message", - "action": "update", - "permissions": { - "_and": [ - { - "project_chat_id": { - "project_id": { - "directus_user_id": { - "id": { - "_eq": "$CURRENT_USER" + }, + { + "folder": { + "parent": { + "parent": { + "name": { + "_contains": "Public" + } + } } } } - } + ] } ] }, "validation": null, "presets": null, "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "a1b2d684-3347-4c0d-b08a-97c939f875c0" - }, - { - "collection": "project_chat", - "action": "create", - "permissions": null, - "validation": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, - "presets": null, - "fields": [ - "*" + "id", + "title", + "description", + "tags", + "location", + "storage", + "focal_point_divider", + "focal_point_x", + "focal_point_y", + "storage_divider", + "filename_disk", + "filename_download", + "metadata", + "type", + "filesize", + "created_on", + "modified_by", + "modified_on", + "embed", + "uploaded_by", + "uploaded_on", + "width", + "folder", + "height", + "duration", + "charset", + "tus_id", + "tus_data", + "$thumbnail" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "7902af81-50fe-4e50-bacc-c78409088eec" + "_syncId": "2edbb99f-dc6c-46dd-9337-8e128aef9eaa" }, { - "collection": "project_chat", - "action": "delete", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + "collection": "directus_revisions", + "action": "read", + "permissions": null, "validation": null, "presets": null, "fields": [ "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "fb7c57f6-129f-4803-8f02-5deb6818bf46" + "_syncId": "6170235d-70f2-4bc3-86d5-9bdd3e93e98e" }, { - "collection": "project_chat", + "collection": "directus_users", "action": "read", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + "permissions": null, "validation": null, "presets": null, "fields": [ - "*" + "disable_create_project", + "projects", + "whitelabel_logo", + "legal_basis", + "privacy_policy_url" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "f29a953b-83ce-42f0-ad0d-f80cfc2f6125" + "_syncId": "25411e4e-fb6c-41e7-be7b-300ca3b503ef" }, { - "collection": "project_chat", + "collection": "directus_users", "action": "update", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + "permissions": null, "validation": null, "presets": null, "fields": [ - "*" + "first_name", + "last_name", + "email", + "projects", + "password", + "disable_create_project", + "avatar", + "location", + "title", + "description", + "tags", + "language", + "text_direction", + "tfa_secret", + "email_notifications", + "appearance", + "theme_light", + "theme_light_overrides", + "theme_dark", + "theme_dark_overrides", + "status", + "role", + "token", + "id", + "last_page", + "last_access", + "whitelabel_logo" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "dcd6b757-bbbb-49ec-b5b5-be1cb252e45b" + "_syncId": "6377dace-23ad-47e6-bc68-cdf512a26c71" }, { - "collection": "project_report_metric", + "collection": "languages", "action": "read", - "permissions": { - "_and": [ - { - "project_report_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, + "permissions": null, "validation": null, "presets": null, "fields": [ "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "88ab1b92-f40f-49fd-aa8a-5acaeccfac54" + "_syncId": "39305271-534a-40d6-9006-57931c1844d6" }, { - "collection": "project_report_notification_participants", + "collection": "project_chat_message_conversation_1", "action": "create", "permissions": null, "validation": null, "presets": null, "fields": [ - "date_submitted", - "id", - "date_updated", - "email", - "project_id", - "email_opt_out_token", - "email_opt_in", - "conversation_id" + "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "a0b6258b-12f9-49a8-b2b2-c510b5b529c6" + "_syncId": "4e0dfa0e-1f92-4e77-add3-4127058f3785" }, { - "collection": "project_report_notification_participants", + "collection": "project_chat_message_conversation_1", + "action": "delete", + "permissions": null, + "validation": null, + "presets": null, + "fields": [ + "*" + ], + "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", + "_syncId": "f9ae9bb2-5426-4f02-915f-e569b08f23c2" + }, + { + "collection": "project_chat_message_conversation_1", "action": "read", "permissions": null, "validation": null, "presets": null, "fields": [ - "id", - "date_submitted", - "email", - "project_id", - "email_opt_in", - "email_opt_out_token", - "date_updated", - "conversation_id" + "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "fe47515c-7460-42e0-b88a-9786faa182ef" + "_syncId": "21cc1ef3-e45c-47ea-add3-823248641340" }, { - "collection": "project_report_notification_participants", + "collection": "project_chat_message_conversation_1", "action": "update", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + "permissions": null, "validation": null, "presets": null, "fields": [ - "id", - "date_submitted", - "email", - "project_id", - "email_opt_in", - "email_opt_out_token", - "date_updated", - "conversation_id" + "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "4d3a15ec-6809-4625-8b9c-d1dc6a191443" + "_syncId": "d5ebcf80-726f-4198-96c9-aa0299a0bc67" }, { - "collection": "project_report", + "collection": "project_chat_message_conversation", "action": "create", "permissions": null, - "validation": { - "_and": [ - { - "_and": [ - { - "project_id": { - "_nnull": true - } - } - ] - } - ] - }, + "validation": null, "presets": null, "fields": [ - "date_created", - "id", - "date_updated", - "show_portal_link", - "status", - "error_code", - "project_id", - "content", - "language" + "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "99046a4d-4d84-42a6-97cd-1341bf169d6e" + "_syncId": "1ab54698-10ac-4386-99e8-381da49492c5" }, { - "collection": "project_report", - "action": "read", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + "collection": "project_chat_message_conversation", + "action": "delete", + "permissions": null, "validation": null, "presets": null, "fields": [ - "date_created", - "id", - "date_updated", - "show_portal_link", - "status", - "error_code", - "project_id", - "content", - "language" + "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "931672e4-4235-4a04-bd4f-61c8422ae563" + "_syncId": "c62556f8-46c2-4662-8fb4-ad2f325ae2d3" }, { - "collection": "project_report", - "action": "update", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + "collection": "project_chat_message_conversation", + "action": "read", + "permissions": null, "validation": null, "presets": null, "fields": [ - "date_created", - "date_updated", - "id", - "status", - "error_code", - "project_id", - "show_portal_link", - "content", - "language" + "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "f7f7edd9-6fbd-4609-a987-498e0ee4e5ea" + "_syncId": "db12a077-dfb1-420c-a4a7-e8aac2018fa9" }, { - "collection": "project_tag", - "action": "create", + "collection": "project_chat_message_conversation", + "action": "update", "permissions": null, - "validation": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + "validation": null, "presets": null, "fields": [ "*" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "bf938907-c824-41a6-aec1-de49350351b8" + "_syncId": "f2311612-8caa-4592-bb50-6d360131673a" }, - { - "collection": "project_tag", - "action": "delete", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + { + "collection": "project_report_notification_participants", + "action": "create", + "permissions": null, "validation": null, "presets": null, "fields": [ - "*" + "date_submitted", + "id", + "date_updated", + "email", + "project_id", + "email_opt_out_token", + "email_opt_in", + "conversation_id" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "a52b787f-707e-407e-b343-558656405d04" + "_syncId": "a0b6258b-12f9-49a8-b2b2-c510b5b529c6" }, { - "collection": "project_tag", + "collection": "project_report_notification_participants", "action": "read", - "permissions": { - "_and": [ - { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - ] - }, + "permissions": null, "validation": null, "presets": null, "fields": [ - "*" + "id", + "date_submitted", + "email", + "project_id", + "email_opt_in", + "email_opt_out_token", + "date_updated", + "conversation_id" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "4e860a96-d5b0-471d-a6da-3f61efbee70a" + "_syncId": "fe47515c-7460-42e0-b88a-9786faa182ef" }, { - "collection": "project_tag", + "collection": "project_report_notification_participants", "action": "update", "permissions": { "_and": [ @@ -1806,10 +775,17 @@ "validation": null, "presets": null, "fields": [ - "*" + "id", + "date_submitted", + "email", + "project_id", + "email_opt_in", + "email_opt_out_token", + "date_updated", + "conversation_id" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "462f9a07-ae2f-4070-b91e-f12ed7eaff89" + "_syncId": "4d3a15ec-6809-4625-8b9c-d1dc6a191443" }, { "collection": "project_webhook", @@ -1899,86 +875,6 @@ "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", "_syncId": "410aab1d-8bd6-4e85-9142-cda007d2a185" }, - { - "collection": "project", - "action": "create", - "permissions": null, - "validation": { - "_and": [ - { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - ] - }, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "822be74a-a55d-4745-8bb5-f0030bd62f41" - }, - { - "collection": "project", - "action": "delete", - "permissions": { - "_and": [ - { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "96f78d3f-ae30-4268-beb1-ea8d7ea277f9" - }, - { - "collection": "project", - "action": "read", - "permissions": { - "_and": [ - { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "926e2877-5e9a-4b2f-883d-8f22dab545ef" - }, - { - "collection": "project", - "action": "update", - "permissions": { - "_and": [ - { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "9367c6a3-ddb6-424b-ba72-1b04e1f0644e" - }, { "collection": "verification_topic_translations", "action": "create", @@ -2099,78 +995,6 @@ "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", "_syncId": "9a302fae-f462-4fe7-8ba5-5662ce95e28e" }, - { - "collection": "view", - "action": "delete", - "permissions": { - "_and": [ - { - "project_analysis_run_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "95ace34c-2d7e-4b63-8c22-0a4ce92a28f5" - }, - { - "collection": "view", - "action": "read", - "permissions": { - "_and": [ - { - "project_analysis_run_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "b9e92ecb-d92d-44bb-b7c2-802453104893" - }, - { - "collection": "view", - "action": "update", - "permissions": { - "_and": [ - { - "project_analysis_run_id": { - "project_id": { - "directus_user_id": { - "_eq": "$CURRENT_USER" - } - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "*" - ], - "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", - "_syncId": "dc261b86-dfee-42fb-9b3c-2d0b6fd51c56" - }, { "collection": "directus_activity", "action": "read", @@ -2288,118 +1112,5 @@ ], "policy": "abf8a154-5b1c-4a46-ac9c-7300570f4f17", "_syncId": "996d6fa1-0ec8-4f52-8627-77b3648e065e" - }, - { - "collection": "project_report_metric", - "action": "create", - "permissions": null, - "validation": { - "_and": [ - { - "project_report_id": { - "_nnull": true - } - }, - { - "type": { - "_eq": "view" - } - } - ] - }, - "presets": null, - "fields": [ - "id", - "type", - "project_report_id", - "date_created", - "date_updated", - "ip" - ], - "policy": "abf8a154-5b1c-4a46-ac9c-7300570f4f17", - "_syncId": "634d73dd-3bdc-4edf-8be3-2f01c9956725" - }, - { - "collection": "project_report_metric", - "action": "read", - "permissions": { - "_and": [ - { - "project_report_id": { - "project_id": { - "_nnull": true - } - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "type", - "date_updated", - "id", - "date_created", - "project_report_id" - ], - "policy": "abf8a154-5b1c-4a46-ac9c-7300570f4f17", - "_syncId": "50549508-2da8-4123-ab2c-1d3414061464" - }, - { - "collection": "project_report", - "action": "read", - "permissions": { - "_and": [ - { - "status": { - "_eq": "published" - } - } - ] - }, - "validation": null, - "presets": null, - "fields": [ - "status", - "id", - "date_created", - "content", - "language", - "project_id", - "show_portal_link", - "date_updated", - "error_code" - ], - "policy": "abf8a154-5b1c-4a46-ac9c-7300570f4f17", - "_syncId": "8a8e0d56-e394-47af-8473-9c77b6a0870f" - }, - { - "collection": "project", - "action": "read", - "permissions": null, - "validation": null, - "presets": null, - "fields": [ - "id", - "created_at", - "updated_at", - "language", - "conversation_ask_for_participant_name_label", - "default_conversation_ask_for_participant_name", - "default_conversation_tutorial_slug", - "default_conversation_title", - "default_conversation_description", - "default_conversation_finish_text", - "tags", - "is_conversation_allowed", - "is_get_reply_enabled", - "is_project_notification_subscription_allowed", - "is_verify_enabled", - "custom_verification_topics", - "selected_verification_key_list", - "is_enhanced_audio_processing_enabled" - ], - "policy": "abf8a154-5b1c-4a46-ac9c-7300570f4f17", - "_syncId": "f7ef008d-dbf6-4af1-8c66-97711f0ccb3f" } ] diff --git a/echo/directus/sync/collections/policies.json b/echo/directus/sync/collections/policies.json index 1dff0924..278746a9 100644 --- a/echo/directus/sync/collections/policies.json +++ b/echo/directus/sync/collections/policies.json @@ -10,11 +10,8 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": 1 - }, - { - "role": null, - "sort": null } ], "_syncId": "_sync_default_admin_policy" @@ -41,6 +38,7 @@ "roles": [ { "role": "feebe863-90b1-41d1-a7ef-9694ddee3844", + "user": null, "sort": 1 } ], @@ -79,6 +77,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/echo/directus/sync/snapshot/collections/access_request.json b/echo/directus/sync/snapshot/collections/access_request.json new file mode 100644 index 00000000..5004a2ab --- /dev/null +++ b/echo/directus/sync/snapshot/collections/access_request.json @@ -0,0 +1,28 @@ +{ + "collection": "access_request", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "access_request", + "color": null, + "display_template": "{{user_id}} → {{workspace_id}} ({{status}})", + "group": null, + "hidden": false, + "icon": "meeting_room", + "item_duplication_fields": null, + "note": "Pending join requests from team members on open-to-team workspaces. Matrix v1.1 §6 Slack-style discovery.", + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": "requested_at", + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "access_request" + } +} diff --git a/echo/directus/sync/snapshot/collections/app_user.json b/echo/directus/sync/snapshot/collections/app_user.json new file mode 100644 index 00000000..1f7c3bfa --- /dev/null +++ b/echo/directus/sync/snapshot/collections/app_user.json @@ -0,0 +1,28 @@ +{ + "collection": "app_user", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "app_user", + "color": null, + "display_template": "{{display_name}}", + "group": null, + "hidden": false, + "icon": null, + "item_duplication_fields": null, + "note": null, + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "app_user" + } +} diff --git a/echo/directus/sync/snapshot/collections/notification.json b/echo/directus/sync/snapshot/collections/notification.json new file mode 100644 index 00000000..377ed102 --- /dev/null +++ b/echo/directus/sync/snapshot/collections/notification.json @@ -0,0 +1,28 @@ +{ + "collection": "notification", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "notification", + "color": null, + "display_template": "{{event_code}} → {{audience_user_id}}", + "group": null, + "hidden": false, + "icon": null, + "item_duplication_fields": null, + "note": null, + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "notification" + } +} diff --git a/echo/directus/sync/snapshot/collections/org.json b/echo/directus/sync/snapshot/collections/org.json new file mode 100644 index 00000000..32b1c6f8 --- /dev/null +++ b/echo/directus/sync/snapshot/collections/org.json @@ -0,0 +1,28 @@ +{ + "collection": "org", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "org", + "color": null, + "display_template": "{{name}}", + "group": null, + "hidden": false, + "icon": null, + "item_duplication_fields": null, + "note": null, + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "org" + } +} diff --git a/echo/directus/sync/snapshot/collections/chat.json b/echo/directus/sync/snapshot/collections/org_membership.json similarity index 80% rename from echo/directus/sync/snapshot/collections/chat.json rename to echo/directus/sync/snapshot/collections/org_membership.json index b03c799c..797db440 100644 --- a/echo/directus/sync/snapshot/collections/chat.json +++ b/echo/directus/sync/snapshot/collections/org_membership.json @@ -1,12 +1,12 @@ { - "collection": "chat", + "collection": "org_membership", "meta": { "accountability": "all", "archive_app_filter": true, "archive_field": null, "archive_value": null, "collapse": "open", - "collection": "chat", + "collection": "org_membership", "color": null, "display_template": null, "group": null, @@ -16,13 +16,13 @@ "note": null, "preview_url": null, "singleton": false, - "sort": 15, + "sort": null, "sort_field": null, "translations": null, "unarchive_value": null, "versioning": false }, "schema": { - "name": "chat" + "name": "org_membership" } } diff --git a/echo/directus/sync/snapshot/collections/project_membership.json b/echo/directus/sync/snapshot/collections/project_membership.json new file mode 100644 index 00000000..e30db96b --- /dev/null +++ b/echo/directus/sync/snapshot/collections/project_membership.json @@ -0,0 +1,28 @@ +{ + "collection": "project_membership", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "project_membership", + "color": null, + "display_template": null, + "group": null, + "hidden": false, + "icon": null, + "item_duplication_fields": null, + "note": null, + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "project_membership" + } +} diff --git a/echo/directus/sync/snapshot/collections/referral_ledger.json b/echo/directus/sync/snapshot/collections/referral_ledger.json new file mode 100644 index 00000000..58b38d52 --- /dev/null +++ b/echo/directus/sync/snapshot/collections/referral_ledger.json @@ -0,0 +1,28 @@ +{ + "collection": "referral_ledger", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "referral_ledger", + "color": null, + "display_template": "{{partner_team_id}} → {{workspace_id}} ({{partner_kickback_percent}}%)", + "group": null, + "hidden": false, + "icon": "account_balance", + "item_duplication_fields": null, + "note": "Matrix §10. Partner kickback agreements per workspace. Staff edits; partners read via GET /v2/orgs/:id/referral-ledger.", + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": "starts_at", + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "referral_ledger" + } +} diff --git a/echo/directus/sync/snapshot/collections/workspace.json b/echo/directus/sync/snapshot/collections/workspace.json new file mode 100644 index 00000000..1d5036fa --- /dev/null +++ b/echo/directus/sync/snapshot/collections/workspace.json @@ -0,0 +1,28 @@ +{ + "collection": "workspace", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "workspace", + "color": null, + "display_template": "{{name}}", + "group": null, + "hidden": false, + "icon": null, + "item_duplication_fields": null, + "note": null, + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "workspace" + } +} diff --git a/echo/directus/sync/snapshot/collections/workspace_invite.json b/echo/directus/sync/snapshot/collections/workspace_invite.json new file mode 100644 index 00000000..2bb2199f --- /dev/null +++ b/echo/directus/sync/snapshot/collections/workspace_invite.json @@ -0,0 +1,28 @@ +{ + "collection": "workspace_invite", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "workspace_invite", + "color": null, + "display_template": null, + "group": null, + "hidden": false, + "icon": null, + "item_duplication_fields": null, + "note": null, + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "workspace_invite" + } +} diff --git a/echo/directus/sync/snapshot/collections/workspace_membership.json b/echo/directus/sync/snapshot/collections/workspace_membership.json new file mode 100644 index 00000000..207d9cee --- /dev/null +++ b/echo/directus/sync/snapshot/collections/workspace_membership.json @@ -0,0 +1,28 @@ +{ + "collection": "workspace_membership", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "workspace_membership", + "color": null, + "display_template": null, + "group": null, + "hidden": false, + "icon": null, + "item_duplication_fields": null, + "note": null, + "preview_url": null, + "singleton": false, + "sort": null, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "workspace_membership" + } +} diff --git a/echo/directus/sync/snapshot/fields/access_request/actioned_at.json b/echo/directus/sync/snapshot/fields/access_request/actioned_at.json new file mode 100644 index 00000000..0a917d49 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/access_request/actioned_at.json @@ -0,0 +1,44 @@ +{ + "collection": "access_request", + "field": "actioned_at", + "type": "timestamp", + "meta": { + "collection": "access_request", + "conditions": null, + "display": null, + "display_options": null, + "field": "actioned_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "actioned_at", + "table": "access_request", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/access_request/actioned_by.json b/echo/directus/sync/snapshot/fields/access_request/actioned_by.json new file mode 100644 index 00000000..63943e8e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/access_request/actioned_by.json @@ -0,0 +1,44 @@ +{ + "collection": "access_request", + "field": "actioned_by", + "type": "uuid", + "meta": { + "collection": "access_request", + "conditions": null, + "display": null, + "display_options": null, + "field": "actioned_by", + "group": null, + "hidden": false, + "interface": "input", + "note": "app_user.id of the approver/rejecter", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 7, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "actioned_by", + "table": "access_request", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/access_request/deleted_at.json b/echo/directus/sync/snapshot/fields/access_request/deleted_at.json new file mode 100644 index 00000000..78eccd75 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/access_request/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "access_request", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "access_request", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 8, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "access_request", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/access_request/id.json b/echo/directus/sync/snapshot/fields/access_request/id.json new file mode 100644 index 00000000..baddf2ac --- /dev/null +++ b/echo/directus/sync/snapshot/fields/access_request/id.json @@ -0,0 +1,46 @@ +{ + "collection": "access_request", + "field": "id", + "type": "uuid", + "meta": { + "collection": "access_request", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "access_request", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/access_request/requested_at.json b/echo/directus/sync/snapshot/fields/access_request/requested_at.json new file mode 100644 index 00000000..29c84399 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/access_request/requested_at.json @@ -0,0 +1,44 @@ +{ + "collection": "access_request", + "field": "requested_at", + "type": "timestamp", + "meta": { + "collection": "access_request", + "conditions": null, + "display": null, + "display_options": null, + "field": "requested_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "requested_at", + "table": "access_request", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/access_request/status.json b/echo/directus/sync/snapshot/fields/access_request/status.json new file mode 100644 index 00000000..cad3d1e0 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/access_request/status.json @@ -0,0 +1,59 @@ +{ + "collection": "access_request", + "field": "status", + "type": "string", + "meta": { + "collection": "access_request", + "conditions": null, + "display": null, + "display_options": null, + "field": "status", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "Pending", + "value": "pending" + }, + { + "text": "Approved", + "value": "approved" + }, + { + "text": "Rejected", + "value": "rejected" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "status", + "table": "access_request", + "data_type": "character varying", + "default_value": "pending", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/access_request/user_id.json b/echo/directus/sync/snapshot/fields/access_request/user_id.json new file mode 100644 index 00000000..88118094 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/access_request/user_id.json @@ -0,0 +1,44 @@ +{ + "collection": "access_request", + "field": "user_id", + "type": "uuid", + "meta": { + "collection": "access_request", + "conditions": null, + "display": null, + "display_options": null, + "field": "user_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "user_id", + "table": "access_request", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/access_request/workspace_id.json b/echo/directus/sync/snapshot/fields/access_request/workspace_id.json new file mode 100644 index 00000000..49abfc4e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/access_request/workspace_id.json @@ -0,0 +1,44 @@ +{ + "collection": "access_request", + "field": "workspace_id", + "type": "uuid", + "meta": { + "collection": "access_request", + "conditions": null, + "display": null, + "display_options": null, + "field": "workspace_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "workspace_id", + "table": "access_request", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "workspace", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/chat/date_created.json b/echo/directus/sync/snapshot/fields/app_user/created_at.json similarity index 72% rename from echo/directus/sync/snapshot/fields/chat/date_created.json rename to echo/directus/sync/snapshot/fields/app_user/created_at.json index 102ea56d..2259bf37 100644 --- a/echo/directus/sync/snapshot/fields/chat/date_created.json +++ b/echo/directus/sync/snapshot/fields/app_user/created_at.json @@ -1,24 +1,22 @@ { - "collection": "chat", - "field": "date_created", + "collection": "app_user", + "field": "created_at", "type": "timestamp", "meta": { - "collection": "chat", + "collection": "app_user", "conditions": null, - "display": "datetime", - "display_options": { - "relative": true - }, - "field": "date_created", + "display": null, + "display_options": null, + "field": "created_at", "group": null, - "hidden": true, + "hidden": false, "interface": "datetime", "note": null, "options": null, "readonly": true, "required": false, "searchable": true, - "sort": 3, + "sort": 5, "special": [ "date-created" ], @@ -28,10 +26,10 @@ "width": "half" }, "schema": { - "name": "date_created", - "table": "chat", + "name": "created_at", + "table": "app_user", "data_type": "timestamp with time zone", - "default_value": null, + "default_value": "CURRENT_TIMESTAMP", "max_length": null, "numeric_precision": null, "numeric_scale": null, diff --git a/echo/directus/sync/snapshot/fields/app_user/directus_user_id.json b/echo/directus/sync/snapshot/fields/app_user/directus_user_id.json new file mode 100644 index 00000000..45c3ec9c --- /dev/null +++ b/echo/directus/sync/snapshot/fields/app_user/directus_user_id.json @@ -0,0 +1,44 @@ +{ + "collection": "app_user", + "field": "directus_user_id", + "type": "uuid", + "meta": { + "collection": "app_user", + "conditions": null, + "display": null, + "display_options": null, + "field": "directus_user_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "Maps to directus_users.id", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "directus_user_id", + "table": "app_user", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": true, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/app_user/display_name.json b/echo/directus/sync/snapshot/fields/app_user/display_name.json new file mode 100644 index 00000000..dedaa4a8 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/app_user/display_name.json @@ -0,0 +1,44 @@ +{ + "collection": "app_user", + "field": "display_name", + "type": "string", + "meta": { + "collection": "app_user", + "conditions": null, + "display": null, + "display_options": null, + "field": "display_name", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "display_name", + "table": "app_user", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/app_user/email.json b/echo/directus/sync/snapshot/fields/app_user/email.json new file mode 100644 index 00000000..7d710faa --- /dev/null +++ b/echo/directus/sync/snapshot/fields/app_user/email.json @@ -0,0 +1,44 @@ +{ + "collection": "app_user", + "field": "email", + "type": "string", + "meta": { + "collection": "app_user", + "conditions": null, + "display": null, + "display_options": null, + "field": "email", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "email", + "table": "app_user", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/app_user/id.json b/echo/directus/sync/snapshot/fields/app_user/id.json new file mode 100644 index 00000000..6e6e6a41 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/app_user/id.json @@ -0,0 +1,46 @@ +{ + "collection": "app_user", + "field": "id", + "type": "uuid", + "meta": { + "collection": "app_user", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "app_user", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/chat/date_updated.json b/echo/directus/sync/snapshot/fields/app_user/updated_at.json similarity index 72% rename from echo/directus/sync/snapshot/fields/chat/date_updated.json rename to echo/directus/sync/snapshot/fields/app_user/updated_at.json index 6a9a0ef8..78573e83 100644 --- a/echo/directus/sync/snapshot/fields/chat/date_updated.json +++ b/echo/directus/sync/snapshot/fields/app_user/updated_at.json @@ -1,24 +1,22 @@ { - "collection": "chat", - "field": "date_updated", + "collection": "app_user", + "field": "updated_at", "type": "timestamp", "meta": { - "collection": "chat", + "collection": "app_user", "conditions": null, - "display": "datetime", - "display_options": { - "relative": true - }, - "field": "date_updated", + "display": null, + "display_options": null, + "field": "updated_at", "group": null, - "hidden": true, + "hidden": false, "interface": "datetime", "note": null, "options": null, "readonly": true, "required": false, "searchable": true, - "sort": 5, + "sort": 6, "special": [ "date-updated" ], @@ -28,10 +26,10 @@ "width": "half" }, "schema": { - "name": "date_updated", - "table": "chat", + "name": "updated_at", + "table": "app_user", "data_type": "timestamp with time zone", - "default_value": null, + "default_value": "CURRENT_TIMESTAMP", "max_length": null, "numeric_precision": null, "numeric_scale": null, diff --git a/echo/directus/sync/snapshot/fields/conversation/deleted_at.json b/echo/directus/sync/snapshot/fields/conversation/deleted_at.json new file mode 100644 index 00000000..c30ddb8c --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "conversation", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "conversation", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 28, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "conversation", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/action.json b/echo/directus/sync/snapshot/fields/notification/action.json new file mode 100644 index 00000000..f9906843 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/action.json @@ -0,0 +1,79 @@ +{ + "collection": "notification", + "field": "action", + "type": "string", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "action", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Codified nav target. UI resolves the URL from ref_* fields.", + "options": { + "choices": [ + { + "text": "None", + "value": "NONE" + }, + { + "text": "Navigate to workspace", + "value": "NAVIGATE_WS" + }, + { + "text": "Navigate to project", + "value": "NAVIGATE_PROJECT" + }, + { + "text": "Navigate to report", + "value": "NAVIGATE_REPORT" + }, + { + "text": "Navigate to chat", + "value": "NAVIGATE_CHAT" + }, + { + "text": "Navigate to invite", + "value": "NAVIGATE_INVITE" + }, + { + "text": "Navigate to team settings", + "value": "NAVIGATE_TEAM_SETTINGS" + }, + { + "text": "Navigate to workspace settings", + "value": "NAVIGATE_WORKSPACE_SETTINGS" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "action", + "table": "notification", + "data_type": "character varying", + "default_value": "NONE", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/actor_user_id.json b/echo/directus/sync/snapshot/fields/notification/actor_user_id.json new file mode 100644 index 00000000..e0f1a555 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/actor_user_id.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "actor_user_id", + "type": "uuid", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "actor_user_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to app_user — who triggered the event.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "actor_user_id", + "table": "notification", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/audience_user_id.json b/echo/directus/sync/snapshot/fields/notification/audience_user_id.json new file mode 100644 index 00000000..4df7bfb9 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/audience_user_id.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "audience_user_id", + "type": "uuid", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "audience_user_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to app_user — the recipient.", + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "audience_user_id", + "table": "notification", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/created_at.json b/echo/directus/sync/snapshot/fields/notification/created_at.json new file mode 100644 index 00000000..aec5b659 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/created_at.json @@ -0,0 +1,46 @@ +{ + "collection": "notification", + "field": "created_at", + "type": "timestamp", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 20, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "created_at", + "table": "notification", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/event_code.json b/echo/directus/sync/snapshot/fields/notification/event_code.json new file mode 100644 index 00000000..faa5479f --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/event_code.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "event_code", + "type": "string", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "event_code", + "group": null, + "hidden": false, + "interface": "input", + "note": "Machine enum. WORKSPACE_ADDED, INVITE_ACCEPTED, REPORT_READY, etc.", + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "event_code", + "table": "notification", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/expires_at.json b/echo/directus/sync/snapshot/fields/notification/expires_at.json new file mode 100644 index 00000000..32a2e6f8 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/expires_at.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "expires_at", + "type": "timestamp", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "expires_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Hide from inbox after this timestamp.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 19, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "expires_at", + "table": "notification", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/id.json b/echo/directus/sync/snapshot/fields/notification/id.json new file mode 100644 index 00000000..e68646d1 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/id.json @@ -0,0 +1,46 @@ +{ + "collection": "notification", + "field": "id", + "type": "uuid", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "notification", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/message.json b/echo/directus/sync/snapshot/fields/notification/message.json new file mode 100644 index 00000000..2e29886c --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/message.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "message", + "type": "text", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "message", + "group": null, + "hidden": false, + "interface": "input-multiline", + "note": "Optional body. Markdown allowed.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 8, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "message", + "table": "notification", + "data_type": "text", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/params.json b/echo/directus/sync/snapshot/fields/notification/params.json new file mode 100644 index 00000000..5a3a1fd0 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/params.json @@ -0,0 +1,46 @@ +{ + "collection": "notification", + "field": "params", + "type": "json", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "params", + "group": null, + "hidden": false, + "interface": "input-code", + "note": "Event-specific params for future client-rendered i18n.", + "options": { + "language": "JSON" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 10, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "params", + "table": "notification", + "data_type": "json", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/read_at.json b/echo/directus/sync/snapshot/fields/notification/read_at.json new file mode 100644 index 00000000..d8dfd5b5 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/read_at.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "read_at", + "type": "timestamp", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "read_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "When the recipient marked this read. Null = unread.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 18, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "read_at", + "table": "notification", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/chat/user_created.json b/echo/directus/sync/snapshot/fields/notification/ref_chat_id.json similarity index 57% rename from echo/directus/sync/snapshot/fields/chat/user_created.json rename to echo/directus/sync/snapshot/fields/notification/ref_chat_id.json index 81d255d6..cc5fea9c 100644 --- a/echo/directus/sync/snapshot/fields/chat/user_created.json +++ b/echo/directus/sync/snapshot/fields/notification/ref_chat_id.json @@ -1,35 +1,31 @@ { - "collection": "chat", - "field": "user_created", + "collection": "notification", + "field": "ref_chat_id", "type": "uuid", "meta": { - "collection": "chat", + "collection": "notification", "conditions": null, - "display": "user", + "display": null, "display_options": null, - "field": "user_created", + "field": "ref_chat_id", "group": null, - "hidden": true, - "interface": "select-dropdown-m2o", + "hidden": false, + "interface": "input", "note": null, - "options": { - "template": "{{avatar}} {{first_name}} {{last_name}}" - }, - "readonly": true, + "options": null, + "readonly": false, "required": false, "searchable": true, - "sort": 2, - "special": [ - "user-created" - ], + "sort": 14, + "special": null, "translations": null, "validation": null, "validation_message": null, - "width": "half" + "width": "full" }, "schema": { - "name": "user_created", - "table": "chat", + "name": "ref_chat_id", + "table": "notification", "data_type": "uuid", "default_value": null, "max_length": null, @@ -42,7 +38,7 @@ "is_generated": false, "generation_expression": null, "has_auto_increment": false, - "foreign_key_table": "directus_users", + "foreign_key_table": "project_chat", "foreign_key_column": "id" } } diff --git a/echo/directus/sync/snapshot/fields/chat/user_updated.json b/echo/directus/sync/snapshot/fields/notification/ref_conversation_id.json similarity index 57% rename from echo/directus/sync/snapshot/fields/chat/user_updated.json rename to echo/directus/sync/snapshot/fields/notification/ref_conversation_id.json index 0962304f..91cd4e44 100644 --- a/echo/directus/sync/snapshot/fields/chat/user_updated.json +++ b/echo/directus/sync/snapshot/fields/notification/ref_conversation_id.json @@ -1,35 +1,31 @@ { - "collection": "chat", - "field": "user_updated", + "collection": "notification", + "field": "ref_conversation_id", "type": "uuid", "meta": { - "collection": "chat", + "collection": "notification", "conditions": null, - "display": "user", + "display": null, "display_options": null, - "field": "user_updated", + "field": "ref_conversation_id", "group": null, - "hidden": true, - "interface": "select-dropdown-m2o", + "hidden": false, + "interface": "input", "note": null, - "options": { - "template": "{{avatar}} {{first_name}} {{last_name}}" - }, - "readonly": true, + "options": null, + "readonly": false, "required": false, "searchable": true, - "sort": 4, - "special": [ - "user-updated" - ], + "sort": 16, + "special": null, "translations": null, "validation": null, "validation_message": null, - "width": "half" + "width": "full" }, "schema": { - "name": "user_updated", - "table": "chat", + "name": "ref_conversation_id", + "table": "notification", "data_type": "uuid", "default_value": null, "max_length": null, @@ -42,7 +38,7 @@ "is_generated": false, "generation_expression": null, "has_auto_increment": false, - "foreign_key_table": "directus_users", + "foreign_key_table": "conversation", "foreign_key_column": "id" } } diff --git a/echo/directus/sync/snapshot/fields/notification/ref_invite_id.json b/echo/directus/sync/snapshot/fields/notification/ref_invite_id.json new file mode 100644 index 00000000..4e1bbfd0 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/ref_invite_id.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "ref_invite_id", + "type": "uuid", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "ref_invite_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 17, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "ref_invite_id", + "table": "notification", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "workspace_invite", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/ref_org_id.json b/echo/directus/sync/snapshot/fields/notification/ref_org_id.json new file mode 100644 index 00000000..db97a763 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/ref_org_id.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "ref_org_id", + "type": "uuid", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "ref_org_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 11, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "ref_org_id", + "table": "notification", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "org", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/ref_project_id.json b/echo/directus/sync/snapshot/fields/notification/ref_project_id.json new file mode 100644 index 00000000..56efdf3e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/ref_project_id.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "ref_project_id", + "type": "uuid", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "ref_project_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 13, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "ref_project_id", + "table": "notification", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "project", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/ref_report_id.json b/echo/directus/sync/snapshot/fields/notification/ref_report_id.json new file mode 100644 index 00000000..030dd96e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/ref_report_id.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "ref_report_id", + "type": "uuid", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "ref_report_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 15, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "ref_report_id", + "table": "notification", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/ref_workspace_id.json b/echo/directus/sync/snapshot/fields/notification/ref_workspace_id.json new file mode 100644 index 00000000..850c0408 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/ref_workspace_id.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "ref_workspace_id", + "type": "uuid", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "ref_workspace_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 12, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "ref_workspace_id", + "table": "notification", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "workspace", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/scope.json b/echo/directus/sync/snapshot/fields/notification/scope.json new file mode 100644 index 00000000..b7f48503 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/scope.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "scope", + "type": "string", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "scope", + "group": null, + "hidden": false, + "interface": "input", + "note": "Breadcrumb: 'Org › Workspace › Project'. Frozen at emit time.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 9, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "scope", + "table": "notification", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/severity.json b/echo/directus/sync/snapshot/fields/notification/severity.json new file mode 100644 index 00000000..c47aad09 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/severity.json @@ -0,0 +1,59 @@ +{ + "collection": "notification", + "field": "severity", + "type": "string", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "severity", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Server-derived from event_code. Controls row styling.", + "options": { + "choices": [ + { + "text": "Info", + "value": "info" + }, + { + "text": "Action required", + "value": "action_required" + }, + { + "text": "Destructive", + "value": "destructive" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "severity", + "table": "notification", + "data_type": "character varying", + "default_value": "info", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/title.json b/echo/directus/sync/snapshot/fields/notification/title.json new file mode 100644 index 00000000..9f4abc01 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/title.json @@ -0,0 +1,44 @@ +{ + "collection": "notification", + "field": "title", + "type": "string", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "title", + "group": null, + "hidden": false, + "interface": "input", + "note": "Server-rendered headline. Plain text.", + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 7, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "title", + "table": "notification", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/notification/updated_at.json b/echo/directus/sync/snapshot/fields/notification/updated_at.json new file mode 100644 index 00000000..de4aa4b3 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/notification/updated_at.json @@ -0,0 +1,46 @@ +{ + "collection": "notification", + "field": "updated_at", + "type": "timestamp", + "meta": { + "collection": "notification", + "conditions": null, + "display": null, + "display_options": null, + "field": "updated_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 21, + "special": [ + "date-updated" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "updated_at", + "table": "notification", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org/created_at.json b/echo/directus/sync/snapshot/fields/org/created_at.json new file mode 100644 index 00000000..4616c4fa --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org/created_at.json @@ -0,0 +1,46 @@ +{ + "collection": "org", + "field": "created_at", + "type": "timestamp", + "meta": { + "collection": "org", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 6, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "created_at", + "table": "org", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org/created_by.json b/echo/directus/sync/snapshot/fields/org/created_by.json new file mode 100644 index 00000000..d94b9574 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org/created_by.json @@ -0,0 +1,44 @@ +{ + "collection": "org", + "field": "created_by", + "type": "uuid", + "meta": { + "collection": "org", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_by", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to app_user.id", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "created_by", + "table": "org", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/org/deleted_at.json b/echo/directus/sync/snapshot/fields/org/deleted_at.json new file mode 100644 index 00000000..eb50ef2c --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "org", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "org", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "org", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/chat/id.json b/echo/directus/sync/snapshot/fields/org/id.json similarity index 92% rename from echo/directus/sync/snapshot/fields/chat/id.json rename to echo/directus/sync/snapshot/fields/org/id.json index 72e1f4dc..26fd8707 100644 --- a/echo/directus/sync/snapshot/fields/chat/id.json +++ b/echo/directus/sync/snapshot/fields/org/id.json @@ -1,9 +1,9 @@ { - "collection": "chat", + "collection": "org", "field": "id", "type": "uuid", "meta": { - "collection": "chat", + "collection": "org", "conditions": null, "display": null, "display_options": null, @@ -27,7 +27,7 @@ }, "schema": { "name": "id", - "table": "chat", + "table": "org", "data_type": "uuid", "default_value": null, "max_length": null, diff --git a/echo/directus/sync/snapshot/fields/org/logo_url.json b/echo/directus/sync/snapshot/fields/org/logo_url.json new file mode 100644 index 00000000..6fd1f873 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org/logo_url.json @@ -0,0 +1,44 @@ +{ + "collection": "org", + "field": "logo_url", + "type": "string", + "meta": { + "collection": "org", + "conditions": null, + "display": null, + "display_options": null, + "field": "logo_url", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "logo_url", + "table": "org", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org/name.json b/echo/directus/sync/snapshot/fields/org/name.json new file mode 100644 index 00000000..abb3ee9d --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org/name.json @@ -0,0 +1,44 @@ +{ + "collection": "org", + "field": "name", + "type": "string", + "meta": { + "collection": "org", + "conditions": null, + "display": null, + "display_options": null, + "field": "name", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "name", + "table": "org", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org/updated_at.json b/echo/directus/sync/snapshot/fields/org/updated_at.json new file mode 100644 index 00000000..b84b5ae0 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org/updated_at.json @@ -0,0 +1,46 @@ +{ + "collection": "org", + "field": "updated_at", + "type": "timestamp", + "meta": { + "collection": "org", + "conditions": null, + "display": null, + "display_options": null, + "field": "updated_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 7, + "special": [ + "date-updated" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "updated_at", + "table": "org", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org_membership/created_at.json b/echo/directus/sync/snapshot/fields/org_membership/created_at.json new file mode 100644 index 00000000..14a9d7fb --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org_membership/created_at.json @@ -0,0 +1,46 @@ +{ + "collection": "org_membership", + "field": "created_at", + "type": "timestamp", + "meta": { + "collection": "org_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 7, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "created_at", + "table": "org_membership", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org_membership/custom_policies.json b/echo/directus/sync/snapshot/fields/org_membership/custom_policies.json new file mode 100644 index 00000000..1a1c765f --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org_membership/custom_policies.json @@ -0,0 +1,46 @@ +{ + "collection": "org_membership", + "field": "custom_policies", + "type": "json", + "meta": { + "collection": "org_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "custom_policies", + "group": null, + "hidden": false, + "interface": "input-code", + "note": "Extra policies beyond role preset. Usually empty.", + "options": { + "language": "json" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "custom_policies", + "table": "org_membership", + "data_type": "json", + "default_value": [], + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org_membership/deleted_at.json b/echo/directus/sync/snapshot/fields/org_membership/deleted_at.json new file mode 100644 index 00000000..e7782b1a --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org_membership/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "org_membership", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "org_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "org_membership", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org_membership/id.json b/echo/directus/sync/snapshot/fields/org_membership/id.json new file mode 100644 index 00000000..f9b3234f --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org_membership/id.json @@ -0,0 +1,46 @@ +{ + "collection": "org_membership", + "field": "id", + "type": "uuid", + "meta": { + "collection": "org_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "org_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org_membership/org_id.json b/echo/directus/sync/snapshot/fields/org_membership/org_id.json new file mode 100644 index 00000000..93ce57e7 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org_membership/org_id.json @@ -0,0 +1,44 @@ +{ + "collection": "org_membership", + "field": "org_id", + "type": "uuid", + "meta": { + "collection": "org_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "org_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "org_id", + "table": "org_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "org", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/org_membership/role.json b/echo/directus/sync/snapshot/fields/org_membership/role.json new file mode 100644 index 00000000..76b87f1f --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org_membership/role.json @@ -0,0 +1,59 @@ +{ + "collection": "org_membership", + "field": "role", + "type": "string", + "meta": { + "collection": "org_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "role", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "Owner", + "value": "owner" + }, + { + "text": "Admin", + "value": "admin" + }, + { + "text": "Member", + "value": "member" + } + ] + }, + "readonly": false, + "required": true, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "role", + "table": "org_membership", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org_membership/updated_at.json b/echo/directus/sync/snapshot/fields/org_membership/updated_at.json new file mode 100644 index 00000000..9955a3f1 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org_membership/updated_at.json @@ -0,0 +1,46 @@ +{ + "collection": "org_membership", + "field": "updated_at", + "type": "timestamp", + "meta": { + "collection": "org_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "updated_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 8, + "special": [ + "date-updated" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "updated_at", + "table": "org_membership", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/org_membership/user_id.json b/echo/directus/sync/snapshot/fields/org_membership/user_id.json new file mode 100644 index 00000000..9bc7379f --- /dev/null +++ b/echo/directus/sync/snapshot/fields/org_membership/user_id.json @@ -0,0 +1,44 @@ +{ + "collection": "org_membership", + "field": "user_id", + "type": "uuid", + "meta": { + "collection": "org_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "user_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "user_id", + "table": "org_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/project/deleted_at.json b/echo/directus/sync/snapshot/fields/project/deleted_at.json new file mode 100644 index 00000000..e5be34ee --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "project", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "project", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 39, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "project", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project/visibility.json b/echo/directus/sync/snapshot/fields/project/visibility.json new file mode 100644 index 00000000..4bf8f457 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project/visibility.json @@ -0,0 +1,55 @@ +{ + "collection": "project", + "field": "visibility", + "type": "string", + "meta": { + "collection": "project", + "conditions": null, + "display": null, + "display_options": null, + "field": "visibility", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "workspace = visible to all workspace members. private = explicit sharing.", + "options": { + "choices": [ + { + "text": "Workspace", + "value": "workspace" + }, + { + "text": "Private", + "value": "private" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 38, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "visibility", + "table": "project", + "data_type": "character varying", + "default_value": "workspace", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project/workspace_id.json b/echo/directus/sync/snapshot/fields/project/workspace_id.json new file mode 100644 index 00000000..d53f17df --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project/workspace_id.json @@ -0,0 +1,44 @@ +{ + "collection": "project", + "field": "workspace_id", + "type": "uuid", + "meta": { + "collection": "project", + "conditions": null, + "display": null, + "display_options": null, + "field": "workspace_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to workspace. NULL during migration.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 37, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "workspace_id", + "table": "project", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "workspace", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/project_chat/deleted_at.json b/echo/directus/sync/snapshot/fields/project_chat/deleted_at.json new file mode 100644 index 00000000..1af4cdd4 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_chat/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "project_chat", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "project_chat", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 12, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "project_chat", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project_membership/created_at.json b/echo/directus/sync/snapshot/fields/project_membership/created_at.json new file mode 100644 index 00000000..34c3f587 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_membership/created_at.json @@ -0,0 +1,46 @@ +{ + "collection": "project_membership", + "field": "created_at", + "type": "timestamp", + "meta": { + "collection": "project_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 7, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "created_at", + "table": "project_membership", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project_membership/custom_policies.json b/echo/directus/sync/snapshot/fields/project_membership/custom_policies.json new file mode 100644 index 00000000..3ff4fd98 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_membership/custom_policies.json @@ -0,0 +1,46 @@ +{ + "collection": "project_membership", + "field": "custom_policies", + "type": "json", + "meta": { + "collection": "project_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "custom_policies", + "group": null, + "hidden": false, + "interface": "input-code", + "note": "Extra policies beyond role preset. Usually empty.", + "options": { + "language": "json" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "custom_policies", + "table": "project_membership", + "data_type": "json", + "default_value": [], + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project_membership/granted_by.json b/echo/directus/sync/snapshot/fields/project_membership/granted_by.json new file mode 100644 index 00000000..94196be3 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_membership/granted_by.json @@ -0,0 +1,44 @@ +{ + "collection": "project_membership", + "field": "granted_by", + "type": "uuid", + "meta": { + "collection": "project_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "granted_by", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to app_user.id", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "granted_by", + "table": "project_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/project_membership/id.json b/echo/directus/sync/snapshot/fields/project_membership/id.json new file mode 100644 index 00000000..69123f60 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_membership/id.json @@ -0,0 +1,46 @@ +{ + "collection": "project_membership", + "field": "id", + "type": "uuid", + "meta": { + "collection": "project_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "project_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project_membership/project_id.json b/echo/directus/sync/snapshot/fields/project_membership/project_id.json new file mode 100644 index 00000000..6a2ec33e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_membership/project_id.json @@ -0,0 +1,44 @@ +{ + "collection": "project_membership", + "field": "project_id", + "type": "uuid", + "meta": { + "collection": "project_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "project_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "project_id", + "table": "project_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "project", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/project_membership/role.json b/echo/directus/sync/snapshot/fields/project_membership/role.json new file mode 100644 index 00000000..41a54d3c --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_membership/role.json @@ -0,0 +1,55 @@ +{ + "collection": "project_membership", + "field": "role", + "type": "string", + "meta": { + "collection": "project_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "role", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "Editor", + "value": "editor" + }, + { + "text": "Viewer", + "value": "viewer" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "role", + "table": "project_membership", + "data_type": "character varying", + "default_value": "editor", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project_membership/user_id.json b/echo/directus/sync/snapshot/fields/project_membership/user_id.json new file mode 100644 index 00000000..79b51330 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_membership/user_id.json @@ -0,0 +1,44 @@ +{ + "collection": "project_membership", + "field": "user_id", + "type": "uuid", + "meta": { + "collection": "project_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "user_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "user_id", + "table": "project_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/project_report/deleted_at.json b/echo/directus/sync/snapshot/fields/project_report/deleted_at.json new file mode 100644 index 00000000..a716bd42 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_report/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "project_report", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "project_report", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 16, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "project_report", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project_webhook/deleted_at.json b/echo/directus/sync/snapshot/fields/project_webhook/deleted_at.json new file mode 100644 index 00000000..99af9dd2 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project_webhook/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "project_webhook", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "project_webhook", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 12, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "project_webhook", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/referral_ledger/created_by_staff_id.json b/echo/directus/sync/snapshot/fields/referral_ledger/created_by_staff_id.json new file mode 100644 index 00000000..d1b83387 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/referral_ledger/created_by_staff_id.json @@ -0,0 +1,44 @@ +{ + "collection": "referral_ledger", + "field": "created_by_staff_id", + "type": "uuid", + "meta": { + "collection": "referral_ledger", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_by_staff_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "app_user.id of the staff creator", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 8, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "created_by_staff_id", + "table": "referral_ledger", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/referral_ledger/deleted_at.json b/echo/directus/sync/snapshot/fields/referral_ledger/deleted_at.json new file mode 100644 index 00000000..bd112fc8 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/referral_ledger/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "referral_ledger", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "referral_ledger", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 9, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "referral_ledger", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/referral_ledger/expires_at.json b/echo/directus/sync/snapshot/fields/referral_ledger/expires_at.json new file mode 100644 index 00000000..890e0774 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/referral_ledger/expires_at.json @@ -0,0 +1,44 @@ +{ + "collection": "referral_ledger", + "field": "expires_at", + "type": "timestamp", + "meta": { + "collection": "referral_ledger", + "conditions": null, + "display": null, + "display_options": null, + "field": "expires_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Optional. NULL = no expiry; set per deal or globally later.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "expires_at", + "table": "referral_ledger", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/referral_ledger/id.json b/echo/directus/sync/snapshot/fields/referral_ledger/id.json new file mode 100644 index 00000000..7c8995af --- /dev/null +++ b/echo/directus/sync/snapshot/fields/referral_ledger/id.json @@ -0,0 +1,44 @@ +{ + "collection": "referral_ledger", + "field": "id", + "type": "integer", + "meta": { + "collection": "referral_ledger", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "numeric", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "referral_ledger", + "data_type": "integer", + "default_value": "nextval('referral_ledger_id_seq'::regclass)", + "max_length": null, + "numeric_precision": 32, + "numeric_scale": 0, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": true, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/chat/title.json b/echo/directus/sync/snapshot/fields/referral_ledger/notes.json similarity index 78% rename from echo/directus/sync/snapshot/fields/chat/title.json rename to echo/directus/sync/snapshot/fields/referral_ledger/notes.json index 6bc177bb..aa42ca08 100644 --- a/echo/directus/sync/snapshot/fields/chat/title.json +++ b/echo/directus/sync/snapshot/fields/referral_ledger/notes.json @@ -1,22 +1,22 @@ { - "collection": "chat", - "field": "title", + "collection": "referral_ledger", + "field": "notes", "type": "text", "meta": { - "collection": "chat", + "collection": "referral_ledger", "conditions": null, "display": null, "display_options": null, - "field": "title", + "field": "notes", "group": null, "hidden": false, - "interface": "input", + "interface": "input-multiline", "note": null, "options": null, "readonly": false, "required": false, "searchable": true, - "sort": 6, + "sort": 7, "special": null, "translations": null, "validation": null, @@ -24,8 +24,8 @@ "width": "full" }, "schema": { - "name": "title", - "table": "chat", + "name": "notes", + "table": "referral_ledger", "data_type": "text", "default_value": null, "max_length": null, diff --git a/echo/directus/sync/snapshot/fields/referral_ledger/partner_kickback_percent.json b/echo/directus/sync/snapshot/fields/referral_ledger/partner_kickback_percent.json new file mode 100644 index 00000000..dedabdd0 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/referral_ledger/partner_kickback_percent.json @@ -0,0 +1,44 @@ +{ + "collection": "referral_ledger", + "field": "partner_kickback_percent", + "type": "integer", + "meta": { + "collection": "referral_ledger", + "conditions": null, + "display": null, + "display_options": null, + "field": "partner_kickback_percent", + "group": null, + "hidden": false, + "interface": "input", + "note": "Default 20% per matrix §10.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "partner_kickback_percent", + "table": "referral_ledger", + "data_type": "integer", + "default_value": 20, + "max_length": null, + "numeric_precision": 32, + "numeric_scale": 0, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/referral_ledger/partner_team_id.json b/echo/directus/sync/snapshot/fields/referral_ledger/partner_team_id.json new file mode 100644 index 00000000..45ae5419 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/referral_ledger/partner_team_id.json @@ -0,0 +1,44 @@ +{ + "collection": "referral_ledger", + "field": "partner_team_id", + "type": "uuid", + "meta": { + "collection": "referral_ledger", + "conditions": null, + "display": null, + "display_options": null, + "field": "partner_team_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "The team receiving the kickback.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "partner_team_id", + "table": "referral_ledger", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "org", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/referral_ledger/starts_at.json b/echo/directus/sync/snapshot/fields/referral_ledger/starts_at.json new file mode 100644 index 00000000..e2643d5d --- /dev/null +++ b/echo/directus/sync/snapshot/fields/referral_ledger/starts_at.json @@ -0,0 +1,44 @@ +{ + "collection": "referral_ledger", + "field": "starts_at", + "type": "timestamp", + "meta": { + "collection": "referral_ledger", + "conditions": null, + "display": null, + "display_options": null, + "field": "starts_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "starts_at", + "table": "referral_ledger", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/referral_ledger/workspace_id.json b/echo/directus/sync/snapshot/fields/referral_ledger/workspace_id.json new file mode 100644 index 00000000..256a6dee --- /dev/null +++ b/echo/directus/sync/snapshot/fields/referral_ledger/workspace_id.json @@ -0,0 +1,44 @@ +{ + "collection": "referral_ledger", + "field": "workspace_id", + "type": "uuid", + "meta": { + "collection": "referral_ledger", + "conditions": null, + "display": null, + "display_options": null, + "field": "workspace_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "workspace_id", + "table": "referral_ledger", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "workspace", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/billed_to_team_id.json b/echo/directus/sync/snapshot/fields/workspace/billed_to_team_id.json new file mode 100644 index 00000000..d2bb7fbf --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/billed_to_team_id.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "billed_to_team_id", + "type": "uuid", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "billed_to_team_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to org. Which team pays the subscription. NULL for pre-migration workspaces. Partner-owned workspaces point here pre-handoff.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 19, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "billed_to_team_id", + "table": "workspace", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "org", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/billed_to_workspace_id.json b/echo/directus/sync/snapshot/fields/workspace/billed_to_workspace_id.json new file mode 100644 index 00000000..a85d4eaa --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/billed_to_workspace_id.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "billed_to_workspace_id", + "type": "uuid", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "billed_to_workspace_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "Partner billing. NULL = org pays.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 7, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "billed_to_workspace_id", + "table": "workspace", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "workspace", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/created_at.json b/echo/directus/sync/snapshot/fields/workspace/created_at.json new file mode 100644 index 00000000..4fc7bcb8 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/created_at.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace", + "field": "created_at", + "type": "timestamp", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 14, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "created_at", + "table": "workspace", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/created_by.json b/echo/directus/sync/snapshot/fields/workspace/created_by.json new file mode 100644 index 00000000..35620d61 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/created_by.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "created_by", + "type": "uuid", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_by", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to app_user.id", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 13, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "created_by", + "table": "workspace", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/deleted_at.json b/echo/directus/sync/snapshot/fields/workspace/deleted_at.json new file mode 100644 index 00000000..a4dfd082 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 12, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "workspace", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/description.json b/echo/directus/sync/snapshot/fields/workspace/description.json new file mode 100644 index 00000000..717e1caf --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/description.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "description", + "type": "text", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "description", + "group": null, + "hidden": false, + "interface": "input-multiline", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "description", + "table": "workspace", + "data_type": "text", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/downgraded_at.json b/echo/directus/sync/snapshot/fields/workspace/downgraded_at.json new file mode 100644 index 00000000..c70de23a --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/downgraded_at.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "downgraded_at", + "type": "timestamp", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "downgraded_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Set when a staff tier change lowered this workspace's tier. Frontend renders the post-downgrade banner for 7 days from this timestamp (matrix v1.1 §3).", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 17, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "downgraded_at", + "table": "workspace", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/downgraded_from_tier.json b/echo/directus/sync/snapshot/fields/workspace/downgraded_from_tier.json new file mode 100644 index 00000000..7901ac05 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/downgraded_from_tier.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "downgraded_from_tier", + "type": "string", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "downgraded_from_tier", + "group": null, + "hidden": false, + "interface": "input", + "note": "The tier the workspace was on BEFORE the downgrade. Used so the banner can say 'downgraded from X to Y' without guessing. Cleared on next upgrade.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 18, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "downgraded_from_tier", + "table": "workspace", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/effective_client_team_id.json b/echo/directus/sync/snapshot/fields/workspace/effective_client_team_id.json new file mode 100644 index 00000000..f7012775 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/effective_client_team_id.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "effective_client_team_id", + "type": "uuid", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "effective_client_team_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to org. The client the workspace is for, when different from billed_to_team_id (partner-client arrangement). Set on handoff completion.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 20, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "effective_client_team_id", + "table": "workspace", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "org", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/handoff_status.json b/echo/directus/sync/snapshot/fields/workspace/handoff_status.json new file mode 100644 index 00000000..461a1c14 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/handoff_status.json @@ -0,0 +1,55 @@ +{ + "collection": "workspace", + "field": "handoff_status", + "type": "string", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "handoff_status", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Matrix §10. Set 'pending' when partner initiates; cleared on client accept (and effective_client_team_id flips).", + "options": { + "choices": [ + { + "text": "Pending (client accept)", + "value": "pending" + }, + { + "text": "Completed", + "value": "completed" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 21, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "handoff_status", + "table": "workspace", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/handoff_target_team_id.json b/echo/directus/sync/snapshot/fields/workspace/handoff_target_team_id.json new file mode 100644 index 00000000..1893a46a --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/handoff_target_team_id.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "handoff_target_team_id", + "type": "uuid", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "handoff_target_team_id", + "group": null, + "hidden": false, + "interface": "input", + "note": "Target client team during a pending handoff. Cleared on accept.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 22, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "handoff_target_team_id", + "table": "workspace", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "org", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/id.json b/echo/directus/sync/snapshot/fields/workspace/id.json new file mode 100644 index 00000000..3b73d882 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/id.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace", + "field": "id", + "type": "uuid", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "workspace", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/is_default.json b/echo/directus/sync/snapshot/fields/workspace/is_default.json new file mode 100644 index 00000000..8375b862 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/is_default.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "is_default", + "type": "boolean", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "is_default", + "group": null, + "hidden": false, + "interface": "boolean", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 8, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "is_default", + "table": "workspace", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/legal_basis.json b/echo/directus/sync/snapshot/fields/workspace/legal_basis.json new file mode 100644 index 00000000..6d12b87f --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/legal_basis.json @@ -0,0 +1,59 @@ +{ + "collection": "workspace", + "field": "legal_basis", + "type": "string", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "legal_basis", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "Consent", + "value": "consent" + }, + { + "text": "Client-managed", + "value": "client-managed" + }, + { + "text": "Dembrane Events", + "value": "dembrane-events" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 9, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "legal_basis", + "table": "workspace", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/logo_url.json b/echo/directus/sync/snapshot/fields/workspace/logo_url.json new file mode 100644 index 00000000..1f514d63 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/logo_url.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "logo_url", + "type": "string", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "logo_url", + "group": null, + "hidden": false, + "interface": "input", + "note": "Override org logo", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "logo_url", + "table": "workspace", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/name.json b/echo/directus/sync/snapshot/fields/workspace/name.json new file mode 100644 index 00000000..bbb3adb8 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/name.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "name", + "type": "string", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "name", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "name", + "table": "workspace", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/org_id.json b/echo/directus/sync/snapshot/fields/workspace/org_id.json new file mode 100644 index 00000000..1015762e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/org_id.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "org_id", + "type": "uuid", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "org_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "org_id", + "table": "workspace", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "org", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/privacy_policy_url.json b/echo/directus/sync/snapshot/fields/workspace/privacy_policy_url.json new file mode 100644 index 00000000..f7f98005 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/privacy_policy_url.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace", + "field": "privacy_policy_url", + "type": "string", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "privacy_policy_url", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 10, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "privacy_policy_url", + "table": "workspace", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/settings.json b/echo/directus/sync/snapshot/fields/workspace/settings.json new file mode 100644 index 00000000..ad8c39f4 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/settings.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace", + "field": "settings", + "type": "json", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "settings", + "group": null, + "hidden": false, + "interface": "input-code", + "note": "Feature flags, limits", + "options": { + "language": "json" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 11, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "settings", + "table": "workspace", + "data_type": "json", + "default_value": {}, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/tier.json b/echo/directus/sync/snapshot/fields/workspace/tier.json new file mode 100644 index 00000000..6716ac33 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/tier.json @@ -0,0 +1,67 @@ +{ + "collection": "workspace", + "field": "tier", + "type": "string", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "tier", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "Pilot", + "value": "pilot" + }, + { + "text": "Pioneer", + "value": "pioneer" + }, + { + "text": "Innovator", + "value": "innovator" + }, + { + "text": "Changemaker", + "value": "changemaker" + }, + { + "text": "Guardian", + "value": "guardian" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "tier", + "table": "workspace", + "data_type": "character varying", + "default_value": "pioneer", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/updated_at.json b/echo/directus/sync/snapshot/fields/workspace/updated_at.json new file mode 100644 index 00000000..21c022f8 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/updated_at.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace", + "field": "updated_at", + "type": "timestamp", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "updated_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 15, + "special": [ + "date-updated" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "updated_at", + "table": "workspace", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace/visibility.json b/echo/directus/sync/snapshot/fields/workspace/visibility.json new file mode 100644 index 00000000..2272a478 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace/visibility.json @@ -0,0 +1,55 @@ +{ + "collection": "workspace", + "field": "visibility", + "type": "string", + "meta": { + "collection": "workspace", + "conditions": null, + "display": null, + "display_options": null, + "field": "visibility", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Matrix v1.1 §6. open_to_team = discoverable by team admins (join) and members (request access). private = visible only to team admins in discovery. Innovator+ tier to create.", + "options": { + "choices": [ + { + "text": "Open to team", + "value": "open_to_team" + }, + { + "text": "Private", + "value": "private" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 16, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "visibility", + "table": "workspace", + "data_type": "character varying", + "default_value": "open_to_team", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/accepted_at.json b/echo/directus/sync/snapshot/fields/workspace_invite/accepted_at.json new file mode 100644 index 00000000..46f2f2dc --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/accepted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_invite", + "field": "accepted_at", + "type": "timestamp", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "accepted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 8, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "accepted_at", + "table": "workspace_invite", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/created_at.json b/echo/directus/sync/snapshot/fields/workspace_invite/created_at.json new file mode 100644 index 00000000..3dd9b5ba --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/created_at.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace_invite", + "field": "created_at", + "type": "timestamp", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 9, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "created_at", + "table": "workspace_invite", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/email.json b/echo/directus/sync/snapshot/fields/workspace_invite/email.json new file mode 100644 index 00000000..aaf89c69 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/email.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_invite", + "field": "email", + "type": "string", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "email", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "email", + "table": "workspace_invite", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/expires_at.json b/echo/directus/sync/snapshot/fields/workspace_invite/expires_at.json new file mode 100644 index 00000000..2b6f1ef3 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/expires_at.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_invite", + "field": "expires_at", + "type": "timestamp", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "expires_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "7 days from creation", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 7, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "expires_at", + "table": "workspace_invite", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/id.json b/echo/directus/sync/snapshot/fields/workspace_invite/id.json new file mode 100644 index 00000000..4b6803a0 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/id.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace_invite", + "field": "id", + "type": "uuid", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "workspace_invite", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/include_org_membership.json b/echo/directus/sync/snapshot/fields/workspace_invite/include_org_membership.json new file mode 100644 index 00000000..74953456 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/include_org_membership.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_invite", + "field": "include_org_membership", + "type": "boolean", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "include_org_membership", + "group": null, + "hidden": false, + "interface": "boolean", + "note": "True = add to org as member. False = external workspace access only.", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 10, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "include_org_membership", + "table": "workspace_invite", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/invited_by.json b/echo/directus/sync/snapshot/fields/workspace_invite/invited_by.json new file mode 100644 index 00000000..439e4370 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/invited_by.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_invite", + "field": "invited_by", + "type": "uuid", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "invited_by", + "group": null, + "hidden": false, + "interface": "input", + "note": "FK to app_user.id", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "invited_by", + "table": "workspace_invite", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/role.json b/echo/directus/sync/snapshot/fields/workspace_invite/role.json new file mode 100644 index 00000000..7a9c1483 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/role.json @@ -0,0 +1,59 @@ +{ + "collection": "workspace_invite", + "field": "role", + "type": "string", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "role", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Role to assign on acceptance", + "options": { + "choices": [ + { + "text": "Admin", + "value": "admin" + }, + { + "text": "Member", + "value": "member" + }, + { + "text": "Viewer", + "value": "viewer" + } + ] + }, + "readonly": false, + "required": true, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "role", + "table": "workspace_invite", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/workspace_id.json b/echo/directus/sync/snapshot/fields/workspace_invite/workspace_id.json new file mode 100644 index 00000000..823468e8 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_invite/workspace_id.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_invite", + "field": "workspace_id", + "type": "uuid", + "meta": { + "collection": "workspace_invite", + "conditions": null, + "display": null, + "display_options": null, + "field": "workspace_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "workspace_id", + "table": "workspace_invite", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "workspace", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/created_at.json b/echo/directus/sync/snapshot/fields/workspace_membership/created_at.json new file mode 100644 index 00000000..7d1b3fe4 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/created_at.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace_membership", + "field": "created_at", + "type": "timestamp", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 9, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "created_at", + "table": "workspace_membership", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/custom_policies.json b/echo/directus/sync/snapshot/fields/workspace_membership/custom_policies.json new file mode 100644 index 00000000..e9cdc07e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/custom_policies.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace_membership", + "field": "custom_policies", + "type": "json", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "custom_policies", + "group": null, + "hidden": false, + "interface": "input-code", + "note": "Extra policies beyond role preset. Usually empty.", + "options": { + "language": "json" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "custom_policies", + "table": "workspace_membership", + "data_type": "json", + "default_value": [], + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/deleted_at.json b/echo/directus/sync/snapshot/fields/workspace_membership/deleted_at.json new file mode 100644 index 00000000..6be752a2 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/deleted_at.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_membership", + "field": "deleted_at", + "type": "timestamp", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "deleted_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Soft delete timestamp", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 8, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "deleted_at", + "table": "workspace_membership", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/id.json b/echo/directus/sync/snapshot/fields/workspace_membership/id.json new file mode 100644 index 00000000..de75b309 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/id.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace_membership", + "field": "id", + "type": "uuid", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "workspace_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/is_external.json b/echo/directus/sync/snapshot/fields/workspace_membership/is_external.json new file mode 100644 index 00000000..f119264e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/is_external.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_membership", + "field": "is_external", + "type": "boolean", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "is_external", + "group": null, + "hidden": false, + "interface": "boolean", + "note": "True if user's primary org != workspace's org", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 7, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "is_external", + "table": "workspace_membership", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/role.json b/echo/directus/sync/snapshot/fields/workspace_membership/role.json new file mode 100644 index 00000000..63df3d2b --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/role.json @@ -0,0 +1,63 @@ +{ + "collection": "workspace_membership", + "field": "role", + "type": "string", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "role", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "Owner", + "value": "owner" + }, + { + "text": "Admin", + "value": "admin" + }, + { + "text": "Member", + "value": "member" + }, + { + "text": "Viewer", + "value": "viewer" + } + ] + }, + "readonly": false, + "required": true, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "role", + "table": "workspace_membership", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/source.json b/echo/directus/sync/snapshot/fields/workspace_membership/source.json new file mode 100644 index 00000000..8f11a3dd --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/source.json @@ -0,0 +1,55 @@ +{ + "collection": "workspace_membership", + "field": "source", + "type": "string", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "source", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "direct = explicitly invited. inherited = auto-added from org role.", + "options": { + "choices": [ + { + "text": "Direct", + "value": "direct" + }, + { + "text": "Inherited", + "value": "inherited" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "source", + "table": "workspace_membership", + "data_type": "character varying", + "default_value": "direct", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/updated_at.json b/echo/directus/sync/snapshot/fields/workspace_membership/updated_at.json new file mode 100644 index 00000000..c8c719cd --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/updated_at.json @@ -0,0 +1,46 @@ +{ + "collection": "workspace_membership", + "field": "updated_at", + "type": "timestamp", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "updated_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 10, + "special": [ + "date-updated" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "updated_at", + "table": "workspace_membership", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/user_id.json b/echo/directus/sync/snapshot/fields/workspace_membership/user_id.json new file mode 100644 index 00000000..4715d732 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/user_id.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_membership", + "field": "user_id", + "type": "uuid", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "user_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "user_id", + "table": "workspace_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "app_user", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/workspace_id.json b/echo/directus/sync/snapshot/fields/workspace_membership/workspace_id.json new file mode 100644 index 00000000..3ed803ea --- /dev/null +++ b/echo/directus/sync/snapshot/fields/workspace_membership/workspace_id.json @@ -0,0 +1,44 @@ +{ + "collection": "workspace_membership", + "field": "workspace_id", + "type": "uuid", + "meta": { + "collection": "workspace_membership", + "conditions": null, + "display": null, + "display_options": null, + "field": "workspace_id", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "workspace_id", + "table": "workspace_membership", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "workspace", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/relations/access_request/actioned_by.json b/echo/directus/sync/snapshot/relations/access_request/actioned_by.json new file mode 100644 index 00000000..a683d350 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/access_request/actioned_by.json @@ -0,0 +1,25 @@ +{ + "collection": "access_request", + "field": "actioned_by", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "access_request", + "many_field": "actioned_by", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "access_request", + "column": "actioned_by", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "access_request_actioned_by_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/access_request/user_id.json b/echo/directus/sync/snapshot/relations/access_request/user_id.json new file mode 100644 index 00000000..bcb1519f --- /dev/null +++ b/echo/directus/sync/snapshot/relations/access_request/user_id.json @@ -0,0 +1,25 @@ +{ + "collection": "access_request", + "field": "user_id", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "access_request", + "many_field": "user_id", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "access_request", + "column": "user_id", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "access_request_user_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/access_request/workspace_id.json b/echo/directus/sync/snapshot/relations/access_request/workspace_id.json new file mode 100644 index 00000000..ea208952 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/access_request/workspace_id.json @@ -0,0 +1,25 @@ +{ + "collection": "access_request", + "field": "workspace_id", + "related_collection": "workspace", + "meta": { + "junction_field": null, + "many_collection": "access_request", + "many_field": "workspace_id", + "one_allowed_collections": null, + "one_collection": "workspace", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "access_request", + "column": "workspace_id", + "foreign_key_table": "workspace", + "foreign_key_column": "id", + "constraint_name": "access_request_workspace_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/chat/user_created.json b/echo/directus/sync/snapshot/relations/chat/user_created.json deleted file mode 100644 index ce3d75cb..00000000 --- a/echo/directus/sync/snapshot/relations/chat/user_created.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "collection": "chat", - "field": "user_created", - "related_collection": "directus_users", - "meta": { - "junction_field": null, - "many_collection": "chat", - "many_field": "user_created", - "one_allowed_collections": null, - "one_collection": "directus_users", - "one_collection_field": null, - "one_deselect_action": "nullify", - "one_field": null, - "sort_field": null - }, - "schema": { - "table": "chat", - "column": "user_created", - "foreign_key_table": "directus_users", - "foreign_key_column": "id", - "constraint_name": "chat_user_created_foreign", - "on_update": "NO ACTION", - "on_delete": "NO ACTION" - } -} diff --git a/echo/directus/sync/snapshot/relations/chat/user_updated.json b/echo/directus/sync/snapshot/relations/chat/user_updated.json deleted file mode 100644 index 2460da26..00000000 --- a/echo/directus/sync/snapshot/relations/chat/user_updated.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "collection": "chat", - "field": "user_updated", - "related_collection": "directus_users", - "meta": { - "junction_field": null, - "many_collection": "chat", - "many_field": "user_updated", - "one_allowed_collections": null, - "one_collection": "directus_users", - "one_collection_field": null, - "one_deselect_action": "nullify", - "one_field": null, - "sort_field": null - }, - "schema": { - "table": "chat", - "column": "user_updated", - "foreign_key_table": "directus_users", - "foreign_key_column": "id", - "constraint_name": "chat_user_updated_foreign", - "on_update": "NO ACTION", - "on_delete": "NO ACTION" - } -} diff --git a/echo/directus/sync/snapshot/relations/notification/actor_user_id.json b/echo/directus/sync/snapshot/relations/notification/actor_user_id.json new file mode 100644 index 00000000..b793f05a --- /dev/null +++ b/echo/directus/sync/snapshot/relations/notification/actor_user_id.json @@ -0,0 +1,25 @@ +{ + "collection": "notification", + "field": "actor_user_id", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "notification", + "many_field": "actor_user_id", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "notification", + "column": "actor_user_id", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "notification_actor_user_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/notification/audience_user_id.json b/echo/directus/sync/snapshot/relations/notification/audience_user_id.json new file mode 100644 index 00000000..1eaefc10 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/notification/audience_user_id.json @@ -0,0 +1,25 @@ +{ + "collection": "notification", + "field": "audience_user_id", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "notification", + "many_field": "audience_user_id", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "notification", + "column": "audience_user_id", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "notification_audience_user_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/notification/ref_chat_id.json b/echo/directus/sync/snapshot/relations/notification/ref_chat_id.json new file mode 100644 index 00000000..04bfd8ba --- /dev/null +++ b/echo/directus/sync/snapshot/relations/notification/ref_chat_id.json @@ -0,0 +1,25 @@ +{ + "collection": "notification", + "field": "ref_chat_id", + "related_collection": "project_chat", + "meta": { + "junction_field": null, + "many_collection": "notification", + "many_field": "ref_chat_id", + "one_allowed_collections": null, + "one_collection": "project_chat", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "notification", + "column": "ref_chat_id", + "foreign_key_table": "project_chat", + "foreign_key_column": "id", + "constraint_name": "notification_ref_chat_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/notification/ref_conversation_id.json b/echo/directus/sync/snapshot/relations/notification/ref_conversation_id.json new file mode 100644 index 00000000..9b8ce6af --- /dev/null +++ b/echo/directus/sync/snapshot/relations/notification/ref_conversation_id.json @@ -0,0 +1,25 @@ +{ + "collection": "notification", + "field": "ref_conversation_id", + "related_collection": "conversation", + "meta": { + "junction_field": null, + "many_collection": "notification", + "many_field": "ref_conversation_id", + "one_allowed_collections": null, + "one_collection": "conversation", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "notification", + "column": "ref_conversation_id", + "foreign_key_table": "conversation", + "foreign_key_column": "id", + "constraint_name": "notification_ref_conversation_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/notification/ref_invite_id.json b/echo/directus/sync/snapshot/relations/notification/ref_invite_id.json new file mode 100644 index 00000000..ab73349e --- /dev/null +++ b/echo/directus/sync/snapshot/relations/notification/ref_invite_id.json @@ -0,0 +1,25 @@ +{ + "collection": "notification", + "field": "ref_invite_id", + "related_collection": "workspace_invite", + "meta": { + "junction_field": null, + "many_collection": "notification", + "many_field": "ref_invite_id", + "one_allowed_collections": null, + "one_collection": "workspace_invite", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "notification", + "column": "ref_invite_id", + "foreign_key_table": "workspace_invite", + "foreign_key_column": "id", + "constraint_name": "notification_ref_invite_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/notification/ref_org_id.json b/echo/directus/sync/snapshot/relations/notification/ref_org_id.json new file mode 100644 index 00000000..6d2c79b1 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/notification/ref_org_id.json @@ -0,0 +1,25 @@ +{ + "collection": "notification", + "field": "ref_org_id", + "related_collection": "org", + "meta": { + "junction_field": null, + "many_collection": "notification", + "many_field": "ref_org_id", + "one_allowed_collections": null, + "one_collection": "org", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "notification", + "column": "ref_org_id", + "foreign_key_table": "org", + "foreign_key_column": "id", + "constraint_name": "notification_ref_org_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/notification/ref_project_id.json b/echo/directus/sync/snapshot/relations/notification/ref_project_id.json new file mode 100644 index 00000000..f1268f3c --- /dev/null +++ b/echo/directus/sync/snapshot/relations/notification/ref_project_id.json @@ -0,0 +1,25 @@ +{ + "collection": "notification", + "field": "ref_project_id", + "related_collection": "project", + "meta": { + "junction_field": null, + "many_collection": "notification", + "many_field": "ref_project_id", + "one_allowed_collections": null, + "one_collection": "project", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "notification", + "column": "ref_project_id", + "foreign_key_table": "project", + "foreign_key_column": "id", + "constraint_name": "notification_ref_project_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/notification/ref_workspace_id.json b/echo/directus/sync/snapshot/relations/notification/ref_workspace_id.json new file mode 100644 index 00000000..8e91b174 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/notification/ref_workspace_id.json @@ -0,0 +1,25 @@ +{ + "collection": "notification", + "field": "ref_workspace_id", + "related_collection": "workspace", + "meta": { + "junction_field": null, + "many_collection": "notification", + "many_field": "ref_workspace_id", + "one_allowed_collections": null, + "one_collection": "workspace", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "notification", + "column": "ref_workspace_id", + "foreign_key_table": "workspace", + "foreign_key_column": "id", + "constraint_name": "notification_ref_workspace_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/org/created_by.json b/echo/directus/sync/snapshot/relations/org/created_by.json new file mode 100644 index 00000000..ab056433 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/org/created_by.json @@ -0,0 +1,25 @@ +{ + "collection": "org", + "field": "created_by", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "org", + "many_field": "created_by", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "org", + "column": "created_by", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "org_created_by_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/org_membership/org_id.json b/echo/directus/sync/snapshot/relations/org_membership/org_id.json new file mode 100644 index 00000000..f7c40a48 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/org_membership/org_id.json @@ -0,0 +1,25 @@ +{ + "collection": "org_membership", + "field": "org_id", + "related_collection": "org", + "meta": { + "junction_field": null, + "many_collection": "org_membership", + "many_field": "org_id", + "one_allowed_collections": null, + "one_collection": "org", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "org_membership", + "column": "org_id", + "foreign_key_table": "org", + "foreign_key_column": "id", + "constraint_name": "org_membership_org_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/org_membership/user_id.json b/echo/directus/sync/snapshot/relations/org_membership/user_id.json new file mode 100644 index 00000000..e9bcb655 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/org_membership/user_id.json @@ -0,0 +1,25 @@ +{ + "collection": "org_membership", + "field": "user_id", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "org_membership", + "many_field": "user_id", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "org_membership", + "column": "user_id", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "org_membership_user_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/project/workspace_id.json b/echo/directus/sync/snapshot/relations/project/workspace_id.json new file mode 100644 index 00000000..033ff9ab --- /dev/null +++ b/echo/directus/sync/snapshot/relations/project/workspace_id.json @@ -0,0 +1,25 @@ +{ + "collection": "project", + "field": "workspace_id", + "related_collection": "workspace", + "meta": { + "junction_field": null, + "many_collection": "project", + "many_field": "workspace_id", + "one_allowed_collections": null, + "one_collection": "workspace", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "project", + "column": "workspace_id", + "foreign_key_table": "workspace", + "foreign_key_column": "id", + "constraint_name": "project_workspace_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/project_membership/granted_by.json b/echo/directus/sync/snapshot/relations/project_membership/granted_by.json new file mode 100644 index 00000000..275321e7 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/project_membership/granted_by.json @@ -0,0 +1,25 @@ +{ + "collection": "project_membership", + "field": "granted_by", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "project_membership", + "many_field": "granted_by", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "project_membership", + "column": "granted_by", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "project_membership_granted_by_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/project_membership/project_id.json b/echo/directus/sync/snapshot/relations/project_membership/project_id.json new file mode 100644 index 00000000..201a80cd --- /dev/null +++ b/echo/directus/sync/snapshot/relations/project_membership/project_id.json @@ -0,0 +1,25 @@ +{ + "collection": "project_membership", + "field": "project_id", + "related_collection": "project", + "meta": { + "junction_field": null, + "many_collection": "project_membership", + "many_field": "project_id", + "one_allowed_collections": null, + "one_collection": "project", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "project_membership", + "column": "project_id", + "foreign_key_table": "project", + "foreign_key_column": "id", + "constraint_name": "project_membership_project_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/project_membership/user_id.json b/echo/directus/sync/snapshot/relations/project_membership/user_id.json new file mode 100644 index 00000000..2b299248 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/project_membership/user_id.json @@ -0,0 +1,25 @@ +{ + "collection": "project_membership", + "field": "user_id", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "project_membership", + "many_field": "user_id", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "project_membership", + "column": "user_id", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "project_membership_user_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/referral_ledger/created_by_staff_id.json b/echo/directus/sync/snapshot/relations/referral_ledger/created_by_staff_id.json new file mode 100644 index 00000000..e741fad4 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/referral_ledger/created_by_staff_id.json @@ -0,0 +1,25 @@ +{ + "collection": "referral_ledger", + "field": "created_by_staff_id", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "referral_ledger", + "many_field": "created_by_staff_id", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "referral_ledger", + "column": "created_by_staff_id", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "referral_ledger_created_by_staff_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/referral_ledger/partner_team_id.json b/echo/directus/sync/snapshot/relations/referral_ledger/partner_team_id.json new file mode 100644 index 00000000..6cb9ee94 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/referral_ledger/partner_team_id.json @@ -0,0 +1,25 @@ +{ + "collection": "referral_ledger", + "field": "partner_team_id", + "related_collection": "org", + "meta": { + "junction_field": null, + "many_collection": "referral_ledger", + "many_field": "partner_team_id", + "one_allowed_collections": null, + "one_collection": "org", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "referral_ledger", + "column": "partner_team_id", + "foreign_key_table": "org", + "foreign_key_column": "id", + "constraint_name": "referral_ledger_partner_team_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/referral_ledger/workspace_id.json b/echo/directus/sync/snapshot/relations/referral_ledger/workspace_id.json new file mode 100644 index 00000000..a89e819c --- /dev/null +++ b/echo/directus/sync/snapshot/relations/referral_ledger/workspace_id.json @@ -0,0 +1,25 @@ +{ + "collection": "referral_ledger", + "field": "workspace_id", + "related_collection": "workspace", + "meta": { + "junction_field": null, + "many_collection": "referral_ledger", + "many_field": "workspace_id", + "one_allowed_collections": null, + "one_collection": "workspace", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "referral_ledger", + "column": "workspace_id", + "foreign_key_table": "workspace", + "foreign_key_column": "id", + "constraint_name": "referral_ledger_workspace_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace/billed_to_team_id.json b/echo/directus/sync/snapshot/relations/workspace/billed_to_team_id.json new file mode 100644 index 00000000..f1ebc3b1 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace/billed_to_team_id.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace", + "field": "billed_to_team_id", + "related_collection": "org", + "meta": { + "junction_field": null, + "many_collection": "workspace", + "many_field": "billed_to_team_id", + "one_allowed_collections": null, + "one_collection": "org", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace", + "column": "billed_to_team_id", + "foreign_key_table": "org", + "foreign_key_column": "id", + "constraint_name": "workspace_billed_to_team_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace/billed_to_workspace_id.json b/echo/directus/sync/snapshot/relations/workspace/billed_to_workspace_id.json new file mode 100644 index 00000000..eb0e4437 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace/billed_to_workspace_id.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace", + "field": "billed_to_workspace_id", + "related_collection": "workspace", + "meta": { + "junction_field": null, + "many_collection": "workspace", + "many_field": "billed_to_workspace_id", + "one_allowed_collections": null, + "one_collection": "workspace", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace", + "column": "billed_to_workspace_id", + "foreign_key_table": "workspace", + "foreign_key_column": "id", + "constraint_name": "workspace_billed_to_workspace_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace/created_by.json b/echo/directus/sync/snapshot/relations/workspace/created_by.json new file mode 100644 index 00000000..e41f103a --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace/created_by.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace", + "field": "created_by", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "workspace", + "many_field": "created_by", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace", + "column": "created_by", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "workspace_created_by_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace/effective_client_team_id.json b/echo/directus/sync/snapshot/relations/workspace/effective_client_team_id.json new file mode 100644 index 00000000..ecc421eb --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace/effective_client_team_id.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace", + "field": "effective_client_team_id", + "related_collection": "org", + "meta": { + "junction_field": null, + "many_collection": "workspace", + "many_field": "effective_client_team_id", + "one_allowed_collections": null, + "one_collection": "org", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace", + "column": "effective_client_team_id", + "foreign_key_table": "org", + "foreign_key_column": "id", + "constraint_name": "workspace_effective_client_team_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace/handoff_target_team_id.json b/echo/directus/sync/snapshot/relations/workspace/handoff_target_team_id.json new file mode 100644 index 00000000..96b74614 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace/handoff_target_team_id.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace", + "field": "handoff_target_team_id", + "related_collection": "org", + "meta": { + "junction_field": null, + "many_collection": "workspace", + "many_field": "handoff_target_team_id", + "one_allowed_collections": null, + "one_collection": "org", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace", + "column": "handoff_target_team_id", + "foreign_key_table": "org", + "foreign_key_column": "id", + "constraint_name": "workspace_handoff_target_team_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace/org_id.json b/echo/directus/sync/snapshot/relations/workspace/org_id.json new file mode 100644 index 00000000..056d9f7a --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace/org_id.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace", + "field": "org_id", + "related_collection": "org", + "meta": { + "junction_field": null, + "many_collection": "workspace", + "many_field": "org_id", + "one_allowed_collections": null, + "one_collection": "org", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace", + "column": "org_id", + "foreign_key_table": "org", + "foreign_key_column": "id", + "constraint_name": "workspace_org_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace_invite/invited_by.json b/echo/directus/sync/snapshot/relations/workspace_invite/invited_by.json new file mode 100644 index 00000000..43520a7e --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace_invite/invited_by.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace_invite", + "field": "invited_by", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "workspace_invite", + "many_field": "invited_by", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace_invite", + "column": "invited_by", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "workspace_invite_invited_by_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace_invite/workspace_id.json b/echo/directus/sync/snapshot/relations/workspace_invite/workspace_id.json new file mode 100644 index 00000000..7bef5231 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace_invite/workspace_id.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace_invite", + "field": "workspace_id", + "related_collection": "workspace", + "meta": { + "junction_field": null, + "many_collection": "workspace_invite", + "many_field": "workspace_id", + "one_allowed_collections": null, + "one_collection": "workspace", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace_invite", + "column": "workspace_id", + "foreign_key_table": "workspace", + "foreign_key_column": "id", + "constraint_name": "workspace_invite_workspace_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace_membership/user_id.json b/echo/directus/sync/snapshot/relations/workspace_membership/user_id.json new file mode 100644 index 00000000..6752030b --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace_membership/user_id.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace_membership", + "field": "user_id", + "related_collection": "app_user", + "meta": { + "junction_field": null, + "many_collection": "workspace_membership", + "many_field": "user_id", + "one_allowed_collections": null, + "one_collection": "app_user", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace_membership", + "column": "user_id", + "foreign_key_table": "app_user", + "foreign_key_column": "id", + "constraint_name": "workspace_membership_user_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/workspace_membership/workspace_id.json b/echo/directus/sync/snapshot/relations/workspace_membership/workspace_id.json new file mode 100644 index 00000000..cff13374 --- /dev/null +++ b/echo/directus/sync/snapshot/relations/workspace_membership/workspace_id.json @@ -0,0 +1,25 @@ +{ + "collection": "workspace_membership", + "field": "workspace_id", + "related_collection": "workspace", + "meta": { + "junction_field": null, + "many_collection": "workspace_membership", + "many_field": "workspace_id", + "one_allowed_collections": null, + "one_collection": "workspace", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "workspace_membership", + "column": "workspace_id", + "foreign_key_table": "workspace", + "foreign_key_column": "id", + "constraint_name": "workspace_membership_workspace_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/templates/email-base.liquid b/echo/directus/templates/email-base.liquid index a50bd694..9919c0d6 100644 --- a/echo/directus/templates/email-base.liquid +++ b/echo/directus/templates/email-base.liquid @@ -1,370 +1,88 @@ +{% comment %} + Shared layout for Directus-sent emails (registration verify, password + reset, Directus user invite). Matches the designer's "Verify Email C" + direction: letter-style card on parchment, DM Sans Light, royal-blue + pill CTA, crowd banner pinned to the card bottom. Children provide + content via {% block content %}. +{% endcomment %} - {{ projectName }} Email Service - + {{ projectName }} + + + + - - - - - - -
-  ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ -
+ + + + +
+ + + + + + + + + + - - + + + + +
+ dembrane +
+ {% block content %}{{ html }}{% endblock %} - - - - - - -
- - - - - - - - + -

All the best,

-
- - - - - - -
- -
-
-
- {% block content %}{{ html }}{% endblock %} -
+

— the dembrane team

+
- - - - -
- - -

- The Dembrane Team
Administrator -

-
-
-
- -
+ +
+ +
- \ No newline at end of file + diff --git a/echo/directus/templates/password-reset.liquid b/echo/directus/templates/password-reset.liquid index 22884bd4..9e0f63a0 100644 --- a/echo/directus/templates/password-reset.liquid +++ b/echo/directus/templates/password-reset.liquid @@ -1,42 +1,24 @@ {% layout "email-base" %} {% block content %} -

Reset your Dembrane password

+

Reset your password.

-

- We have received a request to reset the password for your - Dembrane account. If you did not make this change, please - contact one of your administrators. Otherwise, to complete the process, click - the following link to confirm your email address and enter your new password. +

+ We got a request to set a new password for your dembrane account. The link expires in 24 hours.

- - - Reset Your Password - - + + +
+ Reset password +
-

Important: This link will expire in 24 hours.

+

+ Or paste this into your browser:
+ {{url}} +

+ +

+ Didn't request this? Ignore this email — your password stays as it is. +

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/echo/directus/templates/report-notification-en.liquid b/echo/directus/templates/report-notification-en.liquid index e32c6139..c4ef684b 100644 --- a/echo/directus/templates/report-notification-en.liquid +++ b/echo/directus/templates/report-notification-en.liquid @@ -3,72 +3,70 @@ - Dembrane Report Notification - + + dembrane · report ready - - + +
-
- + diff --git a/echo/directus/templates/report-notification-nl.liquid b/echo/directus/templates/report-notification-nl.liquid index cdbea0a0..29e98fec 100644 --- a/echo/directus/templates/report-notification-nl.liquid +++ b/echo/directus/templates/report-notification-nl.liquid @@ -1,74 +1,72 @@ - + - Dembrane Report Notification - + + dembrane · je rapport is klaar - -
+ - + + + + + +
- - - - - -
- Dembrane Logo - -

Dembrane

-
- -

- -

+

+ dembrane +
+

{% if conversation_name and conversation_name != "" %} - A report has been created for the conversation "{{ conversation_name }}" that you have contributed to. + Your report on "{{ conversation_name }}" is ready. {% else %} - A report has been created that you have contributed to. + Your report is ready. {% endif %} -

+ -

- Take a look here: View Report +

+ Take a look — we've pulled together what came out of the conversation you contributed to.

-

We believe in you!

-

The Dembrane Team

+ + +
+ View report +
-

- Please do not reply to this email. This is an automated message and replies will not be monitored. - For any questions or assistance, contact us at - info@dembrane.com. -

+

— the dembrane team

-
+

+ This is an automated message, so replies aren't monitored. Questions? + Reach us at info@dembrane.com. +

-

- If you no longer wish to receive these notifications, please click on - Unsubscribe +

+ Unsubscribe from these notifications.

+ +
+ +
- `. + +## Non-goals + +- No inline editing. Open a drawer or route to edit. +- No charts (`@mantine/charts` banned per CLAUDE.md). If a chart is truly useful, pick a better library when it's worth the lift. +- No pagination on rollup views this release — scales fine to hundreds of projects; paginate when it doesn't. diff --git a/echo/docs/workspaces-validate/screens/request-submitted.md b/echo/docs/workspaces-validate/screens/request-submitted.md new file mode 100644 index 00000000..111fbece --- /dev/null +++ b/echo/docs/workspaces-validate/screens/request-submitted.md @@ -0,0 +1,66 @@ +# Screen 3 — Request submitted (waiting) + +**Intent:** give the user honest, bounded feedback after they submit something that completes asynchronously. Explicit SLA. Status visible. Request remains visible until resolved. + +**Used by:** upgrade request, join request (request-to-join an open workspace), partner handoff pending, any other "you've asked, now we wait" moment. + +**Reference:** matrix §6 (request-to-join approval), §11 (upgrade request to staff inbox), brief pattern 4. + +--- + +## Shape + +Two render modes — inline card and dedicated page, depending on context. + +### Inline card (default) + +``` +┌──────────────────────────────────────────────┐ +│ Request sent │ +│ │ +│ We'll get back to you within 1 business │ +│ day. │ +│ │ +│ Request: Upgrade to innovator │ +│ Sent: 2026-04-23 10:42 │ +│ │ +│ [Cancel request] │ +└──────────────────────────────────────────────┘ +``` + +- Replaces the submit form / modal on success. Does not dismiss or toast-and-vanish. +- Status indicator visible: `pending` (default), `approved`, `rejected`, `cancelled`. +- SLA: "within 1 business day" for upgrade requests; "your organisation admins will review" for join requests (no hard SLA since organisation admins are the humans in the loop). +- Primary affordance: "Cancel request" for upgrade requests. Join requests are uncancellable once sent — they simply expire. + +### Dedicated page (rare) + +For flows where the wait is the whole experience — e.g. partner handoff pending. User lands here from a link in the notification / email. Same content, full-width layout, no surrounding form chrome. + +## Copy rules + +- "Request sent." Not "Successfully submitted your request." +- Name the request subject concretely: "Upgrade to innovator", "Join {workspace}", "Become owner of {workspace}". +- SLA copy is honest. If we can't hit 1 business day, don't promise it. +- Never "Please wait." Never "Our team will get back to you shortly." +- If the request was silently rejected (matrix §6 member rejection), the card never moves off `pending` from the member's perspective — they find out by the lack of any notification. Don't fake acceptance. + +## Status transitions + +- **pending** → **approved**: notification fires, card updates to "Request approved on {date}." + CTA to go to the new access (e.g. `[Open {workspace}]`). Email mirror: approved-and-here's-the-link. +- **pending** → **rejected**: for admin-approvable requests (upgrade), card updates to "Request declined on {date}. Reason: {reason or empty}." CTA: `[Submit a new request]`. +- **pending** → **cancelled** (by requester): card removed; toast "Request cancelled." +- **pending** → **expired**: card updates to "Request expired." After a defined TTL (not this release — add when volumes warrant). + +## Role awareness + +- Requester sees the full card — their own request. +- Workspace admin / organisation admin sees the *incoming* request in a separate list (screen 5 manage-list), not this screen. +- Member-as-requester only exists for join requests (matrix §11 locks upgrade-request to admin/billing). + +## Non-goals + +- No progress bar — we don't know how long approval will take. +- No "ETA by {hour}" — we can't predict a human's calendar. +- No email reminder cron this release (brief anti-goal). +- No auto-cancel on workspace deletion — requests on a soft-deleted workspace simply become unreachable; don't add cleanup code. diff --git a/echo/docs/workspaces-validate/screens/status-banner.md b/echo/docs/workspaces-validate/screens/status-banner.md new file mode 100644 index 00000000..9e7b9bd5 --- /dev/null +++ b/echo/docs/workspaces-validate/screens/status-banner.md @@ -0,0 +1,85 @@ +# Screen 2 — Status banner (3 intrusion levels) + +**Intent:** tell the user about a state they need to know, at the lightest intrusion level that still works. Three levels, each appropriate to a different urgency. + +**Used by:** quota/usage, Pilot hard-block, post-downgrade in-workspace banner, suspension notice (future), workspace soft-delete pending, partner handoff pending, role change first-visit confirmations. + +**Reference:** matrix §3 (post-downgrade 7-day banner), §8 (usage meter), brief §"The 7 canonical screen patterns" pattern 6. + +--- + +## The three levels + +### Level 1 — Inline indicator (passive) + +A chip / dot / sparkline adjacent to the relevant data. User sees it only if they're looking. + +Examples: +- "9 / 10 hours used" chip on the workspace usage widget. +- Yellow dot next to a workspace name in the switcher when at 80% hours. + +Copy shape: raw numbers or a short label. No CTA. No dismissal — it reappears when state holds. + +### Level 2 — Banner (noticed) + +Persistent strip under the header, inside a context (workspace, organisation). Visible on every route within the context until dismissed or until the state clears. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ⚠ Approaching Pilot limit — 9 / 10 hours used. │ +│ [See usage] [Upgrade] [dismiss ×] │ +└─────────────────────────────────────────────────────────────┘ +``` + +- Background: Golden Pollen `#ffd166` for warning; Royal Blue tinted for info; Cotton Candy `#ff9aa2` only for *error* states (tight scope — rarely used here). +- Copy: subject + one-line reason + up to two CTAs (one primary text button, one secondary). +- Dismissible. **Returns on next route load if state still holds for a hard-urgency case** (e.g. downgrade confirmation banner auto-returns when admin attempts a frozen feature — matrix §3). +- Never stacked. If multiple banners compete, show the highest-severity one only (brief §UI Rules). + +### Level 3 — Modal (blocks work) + +Full overlay with a primary path out. Used only when continuing would be misleading or destructive. + +``` +┌─ Pilot limit reached ──────────────────────────┐ +│ │ +│ You've used all 10 hours of the pilot. │ +│ │ +│ Host-side tools (chat, reports, analysis, │ +│ exports) are paused. │ +│ │ +│ Recording keeps working — your participants │ +│ are unaffected. │ +│ │ +│ [Go to usage] [Request upgrade] │ +└────────────────────────────────────────────────┘ +``` + +- Only for hard blocks. Pilot 10h cap is the primary one this release. +- Participant-reassurance line is non-negotiable when the block affects host-side only (matrix §8). +- Escape hatch = "Go to usage" (level 2 view), never "Dismiss". +- Appears on host-side routes only. Participant portal routes never render this. + +## Picking the level + +- Data is true but not urgent → **Level 1**. Default. +- User should change behavior soon → **Level 2**. Quota warnings, post-downgrade notice. +- Continuing would be misleading or lost work → **Level 3**. Hard blocks only. + +## Copy rules + +- Lead with the state, not the cause. "Approaching Pilot limit" not "Please be aware your usage has reached 90% of the pilot plan." +- Numbers are concrete. "9 / 10 hours" not "nearly full." +- Participant-reassurance line on any host-side block: "Recording keeps working — your participants are unaffected." Same copy every time — users learn to scan for it. +- Never "Successfully upgraded" — post-upgrade state is communicated via L2 banner "Upgraded to {tier}. New limits apply." +- Never "Please" / "Sorry for the inconvenience." + +## Email mirrors + +Each on-screen banner has an email twin for the same event. Email is always a lower urgency than the in-app surface — emails are asynchronous, so they don't modal. Matrix §3 downgrade email is an L2-equivalent. + +## Non-goals + +- No animated banner entrance. Layout shift is worse than a static surface. +- No "snooze for 7 days" — dismissal is per-session. Hard-urgency banners auto-return on frozen-feature-attempt (matrix §3). +- No stacked banners. One at a time. diff --git a/echo/docs/workspaces/architecture-review.md b/echo/docs/workspaces/architecture-review.md new file mode 100644 index 00000000..c18538ca --- /dev/null +++ b/echo/docs/workspaces/architecture-review.md @@ -0,0 +1,434 @@ +# Architecture Review: Workspaces PRD +## What makes a senior eng at Google/Apple have a heart attack + +--- + +## 1. CRITICAL: Mutable slugs in URLs + +**The problem:** Workspace slugs are used in URLs (`/:locale/:workspaceSlug/projects/...`) AND are editable in settings. This is a classic footgun. + +User bookmarks `app.dembrane.com/en/dietz-consulting/projects/abc`. Admin renames workspace slug to `dietz-nl`. Every bookmark, shared link, browser history entry, saved integration URL, and CI/CD webhook breaks instantly. The PRD says "no redirect" — that's a data loss event for users. + +**The fix:** Pick one: + +- **Option A (recommended):** Slugs are immutable after creation. Want a different URL? Too bad. This is what GitHub does with repo URLs (renames auto-redirect forever). +- **Option B:** Slugs are editable BUT old slugs redirect to new slug for 90 days. Requires a `workspace_slug_history` table. This is what Slack does with workspace URLs. +- **Option C:** Don't use slugs in URLs at all. Use short IDs (`/w/abc123/projects/...`). Slugs are display-only. This is what Notion does. + +**Recommendation:** Option A. Simplest. If someone really wants a different slug, they can create a new workspace and move projects. + +--- + +## 2. CRITICAL: No tenant isolation strategy + +**The problem:** All orgs and workspaces live in the same tables with no row-level security. A single missing `WHERE workspace_id = :ws_id` in any query leaks data across tenants. This is a compliance-ending bug for EU public sector clients under ISO 27001. + +**The fix:** + +```python +# WRONG — every endpoint does its own filtering +@router.get("/api/v1/workspaces/{ws_id}/projects") +async def list_projects(ws_id: str, user: User): + projects = await db.fetch_all( + "SELECT * FROM project WHERE workspace_id = :ws_id", {"ws_id": ws_id} + ) + # Oops, forgot to check if user can access this workspace + +# RIGHT — middleware sets tenant context, all queries are scoped +class WorkspaceContext: + """Dependency injection that validates access AND sets query scope.""" + + async def __call__(self, ws_id: str, user: User = Depends(get_current_user)): + access = await get_workspace_access(ws_id, user.id) + if not access: + raise HTTPException(403) + return WorkspaceScopedSession(ws_id=ws_id, user=user, role=access.role) + +workspace_ctx = WorkspaceContext() + +@router.get("/api/v1/workspaces/{ws_id}/projects") +async def list_projects(ctx: WorkspaceScopedSession = Depends(workspace_ctx)): + # ctx.query() automatically adds WHERE workspace_id = ctx.ws_id + projects = await ctx.query(project).fetch_all() +``` + +Also consider PostgreSQL Row-Level Security (RLS) as a defense-in-depth layer: + +```sql +ALTER TABLE project ENABLE ROW LEVEL SECURITY; + +CREATE POLICY project_workspace_isolation ON project + USING (workspace_id = current_setting('app.current_workspace_id')::uuid); +``` + +Set `app.current_workspace_id` at the start of each request. Even if application code has a bug, the DB won't return wrong-tenant rows. + +--- + +## 3. CRITICAL: Permission check is a multi-query waterfall on every request + +**The problem:** The `get_user_project_access` function does up to 4 sequential DB queries on every single API call: + +1. Check legacy ownership → query `project` +2. Check org membership → query `org_membership` +3. Check workspace membership → query `workspace_membership` +4. Check project_user → query `project_user` + +At 2-5ms per query, that's 8-20ms of permission overhead before you even start the actual work. Under load, this cascades. + +**The fix:** Single query with JOINs + cache. + +```sql +-- Single query: "what access does user X have to project Y?" +SELECT + p.id AS project_id, + p.workspace_id, + p.visibility, + p.directus_user_id, + w.org_id, + om.role AS org_role, + wm.role AS workspace_role, + pu.role AS project_user_role +FROM project p +LEFT JOIN workspace w ON w.id = p.workspace_id +LEFT JOIN org_membership om ON om.org_id = w.org_id AND om.user_id = :user_id +LEFT JOIN workspace_membership wm ON wm.workspace_id = p.workspace_id AND wm.user_id = :user_id +LEFT JOIN project_user pu ON pu.project_id = p.id AND pu.user_id = :user_id +WHERE p.id = :project_id; +``` + +One query, one round trip. Resolve access in application code from the joined result. + +For the workspace list (selector page), similar approach: + +```sql +-- All workspaces accessible to user X, with counts +SELECT + w.*, + COALESCE(om.role, NULL) AS org_role, + COALESCE(wm.role, NULL) AS ws_role, + (SELECT COUNT(*) FROM project WHERE workspace_id = w.id) AS project_count, + (SELECT COUNT(*) FROM workspace_membership WHERE workspace_id = w.id) AS member_count +FROM workspace w +LEFT JOIN org_membership om ON om.org_id = w.org_id AND om.user_id = :user_id +LEFT JOIN workspace_membership wm ON wm.workspace_id = w.id AND wm.user_id = :user_id +WHERE om.role IN ('owner', 'admin') OR wm.id IS NOT NULL +ORDER BY w.updated_at DESC; +``` + +**Optional cache layer:** For the workspace selector (called on every page load), cache the result per user for 30-60 seconds. Invalidate on membership changes. Even a simple in-memory dict with TTL is fine at your scale. No Redis needed. + +--- + +## 4. HIGH: Cascading deletes with no soft delete + +**The problem:** `ON DELETE CASCADE` from org → workspace → project means: +- Deleting an org nukes every workspace, every project, every conversation, every transcript +- No recovery. No audit trail. No "oops" button. +- An angry org admin (or a compromised account) can destroy a partner's entire client portfolio in one click. + +ISO 27001 auditors will flag this. + +**The fix:** + +```sql +-- Soft delete on all tenant-scoped tables +ALTER TABLE org ADD COLUMN deleted_at timestamptz; +ALTER TABLE workspace ADD COLUMN deleted_at timestamptz; +ALTER TABLE project ADD COLUMN ... ; -- if not already present + +-- All queries filter: WHERE deleted_at IS NULL +-- Cascade becomes: set deleted_at on children +-- Actual purge: scheduled job after 30 days, with email notification +``` + +Application-level delete flow: +1. User clicks delete → sets `deleted_at = now()` +2. Data disappears from all queries immediately +3. Emit `workspace.deleted` usage event +4. 30-day grace period — support can restore by setting `deleted_at = NULL` +5. Scheduled job hard-deletes after 30 days +6. Email notification to org owner when workspace is soft-deleted + +Remove all `ON DELETE CASCADE`. Replace with application-level soft delete propagation. + +--- + +## 5. HIGH: No idempotency on mutations + +**The problem:** User clicks "Create workspace" → network timeout → user clicks again → two workspaces created. Same with invites, project creation, etc. Mobile users with flaky connections will hit this constantly. + +**The fix:** Idempotency key pattern. + +```python +@router.post("/api/v1/workspaces") +async def create_workspace( + body: CreateWorkspaceRequest, + idempotency_key: str = Header(alias="Idempotency-Key"), + user: User = Depends(get_current_user), +): + # Check if we've seen this key before + existing = await db.fetch_one( + "SELECT response_body FROM idempotency_cache WHERE key = :key AND user_id = :uid", + {"key": idempotency_key, "uid": user.id} + ) + if existing: + return JSONResponse(content=json.loads(existing.response_body), status_code=201) + + # Create workspace... + result = await _create_workspace(body, user) + + # Cache the response (TTL: 24h) + await db.execute( + idempotency_cache.insert().values( + key=idempotency_key, user_id=user.id, + response_body=json.dumps(result), expires_at=now() + timedelta(hours=24) + ) + ) + return result +``` + +Frontend generates `Idempotency-Key: {uuid}` on form submission, reuses same key on retry. + +At minimum, do this for: workspace creation, invite sending, project creation. These are the most visible duplicate-creation bugs. + +--- + +## 6. HIGH: No pagination on any list endpoint + +**The problem:** `GET /workspaces/:ws_id/projects` returns all projects as a flat array. A workspace with 200 projects sends 200 rows on every page load. Usage events will be thousands of rows per month. + +**The fix:** Cursor-based pagination on all list endpoints. + +```python +@router.get("/api/v1/workspaces/{ws_id}/projects") +async def list_projects( + ctx: WorkspaceScopedSession = Depends(workspace_ctx), + cursor: str | None = Query(None), # Opaque cursor (base64 encoded created_at + id) + limit: int = Query(25, ge=1, le=100), +): + # Decode cursor, query with WHERE (created_at, id) < (cursor_ts, cursor_id) + # Return: { "items": [...], "next_cursor": "...", "has_more": bool } +``` + +Offset-based pagination is fine too at your scale, but cursor-based is better for real-time lists where items get added/removed. + +At minimum: projects, members, usage events, conversations. + +--- + +## 7. HIGH: usage_event table will eat your database + +**The problem:** Append-only, never deleted. `chat.query` alone could be 100+ events per workspace per day. At 50 workspaces × 100 events × 365 days = 1.8M rows/year. With jsonb `event_data`, that's non-trivial storage and index bloat. + +**The fix:** Plan for it now, implement when needed. + +```sql +-- Partition by month from day 1 (cheap to add, expensive to add later) +CREATE TABLE usage_event ( + id uuid NOT NULL, + trace_id varchar(100) NOT NULL, + org_id uuid, + workspace_id uuid, + -- ... + created_at timestamptz NOT NULL DEFAULT now() +) PARTITION BY RANGE (created_at); + +-- Create partitions (automate with pg_partman or a monthly cron) +CREATE TABLE usage_event_2026_04 PARTITION OF usage_event + FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); +CREATE TABLE usage_event_2026_05 PARTITION OF usage_event + FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); +``` + +Benefits: queries scoped to a billing period only scan one partition. Old partitions can be archived to cold storage. Indexes stay small per partition. + +**Do this in the initial migration.** Adding partitioning to an existing table requires a full rewrite. + +--- + +## 8. MEDIUM: FK coupling to directus_users + +**The problem:** Every new table has `FK → directus_users.id`. When you eventually migrate to Better Auth (or any other auth system), you'll need to: +1. Create new user table +2. Map old Directus IDs to new IDs +3. Update EVERY foreign key in EVERY table + +This is a multi-day, high-risk migration. + +**The fix:** Indirection layer. + +```sql +-- Create a thin user reference table that YOU control +CREATE TABLE app_user ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + directus_user_id uuid UNIQUE, -- current auth provider + -- future: better_auth_user_id uuid UNIQUE, + email varchar(255) NOT NULL, + display_name varchar(255), + created_at timestamptz NOT NULL DEFAULT now() +); + +-- All new tables FK to app_user.id, NOT directus_users.id +ALTER TABLE org_membership ADD COLUMN user_id uuid REFERENCES app_user(id); +ALTER TABLE workspace_membership ADD COLUMN user_id uuid REFERENCES app_user(id); +``` + +When you migrate auth, you update the `app_user` table once (add `better_auth_user_id`, drop `directus_user_id`). Zero changes to org/workspace/project tables. + +**Trade-off:** One more JOIN on user lookups. Worth it for migration safety. + +--- + +## 9. MEDIUM: No RBAC middleware — permission checks will be forgotten + +**The problem:** Every endpoint does its own permission check. New endpoints will inevitably forget. One intern adding a quick admin endpoint without the permission check = data breach. + +**The fix:** Declarative policy enforcement via decorators/dependencies. + +```python +# Define policies as a dependency +def require_policy(*policies: str): + """FastAPI dependency that checks workspace-level policies.""" + async def check(ctx: WorkspaceScopedSession = Depends(workspace_ctx)): + for policy in policies: + if not ctx.has_policy(policy): + raise HTTPException(403, f"Missing policy: {policy}") + return ctx + return Depends(check) + +# Usage — impossible to forget +@router.post("/api/v1/workspaces/{ws_id}/members") +async def invite_member( + body: InviteMemberRequest, + ctx = require_policy("member:invite"), # Enforced by the framework +): + ... + +@router.delete("/api/v1/workspaces/{ws_id}") +async def delete_workspace( + ctx = require_policy("*"), # Only owner +): + ... +``` + +Also: write a test that introspects all registered routes and verifies every workspace-scoped endpoint has a policy dependency. Fail CI if any endpoint is missing one. + +--- + +## 10. MEDIUM: No rate limiting on invite and creation endpoints + +**The problem:** Invite spam. A compromised or malicious account can send thousands of invite emails. Workspace creation spam fills the org with garbage. + +**The fix:** + +```python +from fastapi_limiter import RateLimiter + +@router.post("/api/v1/workspaces/{ws_id}/members", + dependencies=[Depends(RateLimiter(times=20, minutes=60))] # 20 invites/hour +) +async def invite_member(...): + ... + +@router.post("/api/v1/workspaces", + dependencies=[Depends(RateLimiter(times=5, minutes=60))] # 5 workspaces/hour +) +async def create_workspace(...): + ... +``` + +For MVP, even a simple in-memory counter per user is fine. Don't ship invite endpoints without this. + +--- + +## 11. MEDIUM: No event versioning on usage_event + +**The problem:** `event_data` is schemaless jsonb. When you change the shape (add a field, rename a field, remove a field), old events have the old shape and new events have the new shape. Billing aggregation queries that span months will break silently. + +**The fix:** Version every event schema. + +```json +{ + "v": 1, + "duration_seconds": 3600, + "conversation_id": "abc" +} +``` + +Aggregation code checks `v` and handles each version: + +```python +def get_audio_hours(event_data: dict) -> float: + v = event_data.get("v", 1) + if v == 1: + return event_data["duration_seconds"] / 3600 + elif v == 2: + return event_data["duration_ms"] / 3_600_000 # hypothetical future change +``` + +Cheap to add, painful to add later. + +--- + +## 12. MEDIUM: Global slug uniqueness is too restrictive + +**The problem:** Workspace slugs are globally unique. Two different orgs can't both have a workspace called "default". At scale, good slugs get consumed. Users get `default-47`. + +**The fix:** Slugs should be unique per org, not globally. The URL already has locale context — add org context too: + +``` +/:locale/:orgSlug/:workspaceSlug/projects +``` + +Or, if you don't want org in the URL (cleaner), make the uniqueness constraint: + +```sql +-- Instead of: UNIQUE (slug) +-- Use: UNIQUE (org_id, slug) +``` + +And use the workspace `id` (or a short hash) in the URL for routing, with slug as display-only. This is the Notion/Linear pattern. + +**If you keep global uniqueness:** At least namespace default workspaces: `{org_slug}-default` instead of just `default`. + +--- + +## 13. LOW: No request tracing / correlation + +**The problem:** User reports "I clicked invite and nothing happened." How do you debug? You have usage_events but no way to correlate a specific HTTP request to the events it generated, the queries it ran, and the response it returned. + +**The fix:** Generate a request ID on every API call, propagate through all logging and events. + +```python +@app.middleware("http") +async def add_request_id(request: Request, call_next): + request_id = request.headers.get("X-Request-ID", str(uuid4())) + # Set on context for all downstream logging + contextvars.request_id.set(request_id) + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response +``` + +Use this `request_id` as the `trace_id` in usage_events. Now you can trace: request → permission check → DB queries → usage events → response, all with one ID. + +--- + +## Summary: Priority Order + +| # | Issue | Severity | Effort | Do Now? | +|---|-------|----------|--------|---------| +| 1 | Mutable slugs in URLs | Critical | Low | **Yes** — decide before shipping | +| 2 | No tenant isolation | Critical | Medium | **Yes** — build the middleware pattern from day 1 | +| 3 | Permission query waterfall | High | Medium | **Yes** — write the single JOIN query | +| 4 | No soft delete | High | Low | **Yes** — add `deleted_at` columns in initial migration | +| 5 | No idempotency | High | Medium | Yes for create endpoints | +| 6 | No pagination | High | Low | Yes on all list endpoints | +| 7 | usage_event partitioning | High | Low | **Yes** — partition from day 1 (can't add later easily) | +| 8 | FK coupling to directus_users | Medium | Medium | Recommended — `app_user` indirection table | +| 9 | No RBAC middleware | Medium | Medium | Yes — saves you from future security bugs | +| 10 | No rate limiting on invites | Medium | Low | Yes — trivial to add | +| 11 | No event versioning | Medium | Trivial | Yes — just add `"v": 1` | +| 12 | Global slug uniqueness | Medium | Low | Decide before shipping | +| 13 | No request tracing | Low | Low | Nice to have | diff --git a/echo/docs/workspaces/codebase-exploration-report.md b/echo/docs/workspaces/codebase-exploration-report.md new file mode 100644 index 00000000..328da184 --- /dev/null +++ b/echo/docs/workspaces/codebase-exploration-report.md @@ -0,0 +1,1090 @@ +# Codebase Exploration Report +## Session 1: EXPLORE — Dembrane ECHO Platform +> **Date:** 2026-04-07 +> **Branch:** workspaces (based on main) +> **Status:** READ-ONLY exploration, no code changes +> **Directus:** v11.13.4 on PostgreSQL + +--- + +## 1. DIRECTUS SCHEMA MAP + +### 1.1 directus_users (Directus system collection) + +**Default Directus fields** (not in sync files, managed by Directus core): +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | Directus-managed | +| `first_name` | string, nullable | | +| `last_name` | string, nullable | | +| `email` | string, unique | | +| `password` | hash | Never exposed via API | +| `location` | string, nullable | | +| `title` | string, nullable | | +| `description` | text, nullable | | +| `tags` | json, nullable | | +| `avatar` | uuid, FK → directus_files | | +| `language` | string, nullable | | +| `tfa_secret` | string, nullable | 2FA secret (exposed as boolean `tfa_enabled` in /me) | +| `status` | string | active/invited/suspended/archived | +| `role` | uuid, FK → directus_roles | | +| `token` | string, nullable | Static API token | +| `last_access` | timestamp, nullable | | +| `last_page` | string, nullable | | +| `provider` | string | default/ldap/oauth2 | +| `external_identifier` | string, nullable | | +| `auth_data` | json, nullable | | +| `email_notifications` | boolean | | +| `appearance` | string, nullable | | +| `theme_dark` | string, nullable | | +| `theme_light` | string, nullable | | +| `theme_light_overrides` | json, nullable | | +| `theme_dark_overrides` | json, nullable | | + +**Custom fields** (from sync files): +| Field | Type | Nullable | Default | Notes | +|-------|------|----------|---------|-------| +| `disable_create_project` | boolean | yes | false | Locks user to single project | +| `hide_ai_suggestions` | boolean | yes | false | Hides chat suggestions | +| `legal_basis` | string | yes | "client-managed" | consent/client-managed/dembrane-events | +| `privacy_policy_url` | string | yes | null | Per-user privacy policy URL | +| `whitelabel_logo` | uuid, FK → directus_files | yes | null | on_delete: SET NULL | +| `quick_access_preferences` | json | yes | [] | Ordered template preferences | +| `projects` | alias (O2M) | — | — | O2M → project via project.directus_user_id | + +**Roles:** +| Role | Parent | admin_access | app_access | +|------|--------|-------------|------------| +| Administrator | — | true | true | +| Basic User | — | false | true | +| Enterprise User | Basic User | false | true | +| Read-Only | — | false | false | + +--- + +### 1.2 project + +| Field | Type | Nullable | Default | Notes | +|-------|------|----------|---------|-------| +| `id` | uuid, PK | no | — | | +| `name` | string | yes | null | | +| `context` | text | yes | null | Project description/context for LLM | +| `language` | string | yes | null | Project language code | +| `directus_user_id` | uuid, FK → directus_users | yes | null | Owner. on_delete: SET NULL | +| `is_conversation_allowed` | boolean | no | — | Controls participant portal access | +| `pin_order` | integer | yes | null | 1-3 for pinned projects | +| `created_at` | timestamp | yes | CURRENT_TIMESTAMP | | +| `updated_at` | timestamp | yes | CURRENT_TIMESTAMP | | +| `anonymize_transcripts` | boolean | yes | false | | +| `conversation_title_prompt` | text | yes | null | | +| `conversation_ask_for_participant_name_label` | string | yes | null | | +| `default_conversation_ask_for_participant_email` | boolean | yes | false | | +| `default_conversation_ask_for_participant_name` | boolean | yes | true | | +| `default_conversation_description` | text | yes | null | | +| `default_conversation_finish_text` | text | yes | null | | +| `default_conversation_title` | string | yes | null | | +| `default_conversation_transcript_prompt` | text | yes | null | | +| `default_conversation_tutorial_slug` | string | yes | "none" | | +| `enable_ai_title_and_tags` | boolean | yes | false | | +| `get_reply_mode` | string | yes | "summarize" | | +| `get_reply_prompt` | text | yes | null | | +| `image_generation_model` | string | yes | "PLACEHOLDER" | | +| `is_enhanced_audio_processing_enabled` | boolean | yes | false | | +| `is_get_reply_enabled` | boolean | yes | false | | +| `is_project_notification_subscription_allowed` | boolean | yes | false | | +| `is_verify_enabled` | boolean | yes | false | | +| `is_verify_on_finish_enabled` | boolean | yes | false | | +| `selected_verification_key_list` | text | yes | null | Comma-separated keys | +| **Alias fields (O2M):** | | | | | +| `conversations` | alias | — | — | O2M → conversation | +| `tags` | alias | — | — | O2M → project_tag | +| `project_chats` | alias | — | — | O2M → project_chat | +| `project_reports` | alias | — | — | O2M → project_report | +| `project_analysis_runs` | alias | — | — | O2M → project_analysis_run | +| `custom_verification_topics` | alias | — | — | O2M → verification_topic | +| `processing_status` | alias | — | — | O2M → processing_status | + +**Key relations from project:** +- `project.directus_user_id` → `directus_users` (SET NULL) +- `conversation.project_id` → `project` (CASCADE) +- `project_tag.project_id` → `project` (CASCADE) +- `project_chat.project_id` → `project` (CASCADE) +- `project_agentic_run.project_id` → `project` (CASCADE) +- `project_analysis_run.project_id` → `project` (CASCADE) +- `project_report.project_id` → `project` (SET NULL) +- `project_webhook.project_id` → `project` (SET NULL) +- `verification_topic.project_id` → `project` (SET NULL) +- `processing_status.project_id` → `project` (SET NULL) + +--- + +### 1.3 conversation + +| Field | Type | Nullable | Default | Notes | +|-------|------|----------|---------|-------| +| `id` | uuid, PK | no | — | | +| `project_id` | uuid, FK → project | no | — | on_delete: CASCADE | +| `duration` | float | yes | null | Duration in seconds | +| `title` | text | yes | null | AI-generated | +| `summary` | text | yes | null | AI-generated | +| `participant_name` | string | yes | null | | +| `participant_email` | string | yes | null | | +| `participant_user_agent` | string | yes | null | | +| `source` | string | yes | null | | +| `is_finished` | boolean | yes | false | | +| `is_all_chunks_transcribed` | boolean | yes | null | | +| `is_audio_processing_finished` | boolean | yes | false | | +| `is_anonymized` | boolean | yes | false | | +| `merged_audio_path` | text | yes | null | S3 path | +| `merged_transcript` | text | yes | null | | +| `created_at` | timestamp | yes | CURRENT_TIMESTAMP | | +| `updated_at` | timestamp | yes | CURRENT_TIMESTAMP | | +| **Alias fields:** | | | | | +| `chunks` | alias | — | — | O2M → conversation_chunk | +| `conversation_artifacts` | alias | — | — | O2M → conversation_artifact | +| `conversation_segments` | alias | — | — | O2M → conversation_segment | +| `tags` | alias | — | — | M2M → project_tag via conversation_project_tag | +| `project_chats` | alias | — | — | M2M → project_chat via project_chat_conversation | +| `project_chat_messages` | alias | — | — | O2M via junction | +| `replies` | alias | — | — | O2M → conversation_reply | +| `linked_conversations` | alias | — | — | O2M → conversation_link (source) | +| `linking_conversations` | alias | — | — | O2M → conversation_link (target) | + +**NOTE:** The field is named `duration` (not `duration_seconds` as PRD assumes). It stores seconds as a float. + +--- + +### 1.4 conversation_chunk + +| Field | Type | Nullable | Default | +|-------|------|----------|---------| +| `id` | uuid, PK | no | — | +| `conversation_id` | uuid, FK → conversation | no | — (CASCADE) | +| `path` | string | yes | null | S3 audio path | +| `transcript` | text | yes | null | Corrected transcript | +| `raw_transcript` | text | yes | null | Original from ASR | +| `source` | string | yes | null | | +| `timestamp` | timestamp | no | — | | +| `created_at` | timestamp | yes | CURRENT_TIMESTAMP | +| `updated_at` | timestamp | yes | CURRENT_TIMESTAMP | +| `desired_language` | string | yes | null | +| `detected_language` | string | yes | null | +| `detected_language_confidence` | float | yes | null | +| `diarization` | json | yes | null | +| `error` | text | yes | null | +| `hallucination_reason` | text | yes | null | +| `hallucination_score` | float | yes | null | +| `noise_ratio` | float | yes | null | +| `silence_ratio` | float | yes | null | +| `cross_talk_instances` | integer | yes | null | +| `runpod_job_status_link` | text | yes | null | +| `runpod_request_count` | integer | yes | null | +| `translation_error` | string | yes | null | + +--- + +### 1.5 project_chat + +| Field | Type | Nullable | Default | +|-------|------|----------|---------| +| `id` | uuid, PK | no | — | +| `project_id` | uuid, FK → project | yes | null (CASCADE) | +| `name` | string | yes | null | +| `chat_mode` | string | yes | null | overview/deep_dive/agentic | +| `auto_select` | boolean | yes | null | +| `user_created` | uuid, FK → directus_users | yes | null | +| `user_updated` | uuid, FK → directus_users | yes | null | +| `date_created` | timestamp | yes | null | +| `date_updated` | timestamp | yes | null | + +--- + +### 1.6 project_chat_message + +| Field | Type | Nullable | Default | +|-------|------|----------|---------| +| `id` | uuid, PK | no | — | +| `project_chat_id` | uuid, FK → project_chat | yes | null (CASCADE) | +| `message_from` | string | yes | null | user/assistant/dembrane | +| `text` | text | yes | null | +| `template_key` | string | yes | null | +| `tokens_count` | integer | yes | null | +| `date_created` | timestamp | yes | null | +| `date_updated` | timestamp | yes | null | + +--- + +### 1.7 project_report + +| Field | Type | Nullable | Default | +|-------|------|----------|---------| +| `id` | bigInteger, PK | no | — | +| `project_id` | uuid, FK → project | yes | null (SET NULL) | +| `content` | text | yes | null | +| `language` | string | yes | null | +| `status` | string | no | — | draft/generating/published/archived/scheduled/cancelled/error | +| `show_portal_link` | boolean | yes | null | +| `scheduled_at` | timestamp | yes | null | +| `error_code` | string | yes | null | +| `error_message` | text | yes | null | +| `user_instructions` | text | yes | null | +| `date_created` | timestamp | yes | null | +| `date_updated` | timestamp | yes | null | + +--- + +### 1.8 Other Collections (summary) + +| Collection | PK Type | Key Fields | Key Relations | +|-----------|---------|------------|---------------| +| `conversation_artifact` | uuid | content, key, topic_label, approved_at | conversation_id → conversation (CASCADE) | +| `conversation_segment` | integer | transcript, contextual_transcript, config_id, counter | conversation_id → conversation (CASCADE) | +| `conversation_link` | bigInteger | link_type, source_conversation_id, target_conversation_id | → conversation (SET NULL) | +| `conversation_reply` | uuid | content_text, type | reply → conversation (SET NULL) | +| `conversation_project_tag` | integer | — | conversation_id → conversation (CASCADE), project_tag_id → project_tag (CASCADE) | +| `conversation_segment_conversation_chunk` | integer | — | junction: conversation_segment (CASCADE) ↔ conversation_chunk (CASCADE) | +| `project_chat_conversation` | integer | — | junction: project_chat (CASCADE) ↔ conversation (CASCADE) | +| `project_tag` | uuid | text, sort | project_id → project (CASCADE) | +| `project_webhook` | uuid | name, url, events, secret, status | project_id → project (SET NULL) | +| `project_agentic_run` | uuid | status, agent_thread_id, directus_user_id | project_id → project (CASCADE), project_chat_id → project_chat (SET NULL) | +| `project_agentic_run_event` | bigInteger | event_type, payload (json), seq | project_agentic_run_id → project_agentic_run (CASCADE) | +| `project_analysis_run` | uuid | — | project_id → project (CASCADE) | +| `project_report_metric` | bigInteger | type, ip | project_report_id → project_report (SET NULL) | +| `project_report_notification_participants` | uuid | email, email_opt_in, email_opt_out_token | conversation_id → conversation (SET NULL) | +| `prompt_template` | uuid | title, content, description, icon, is_public, is_anonymous, language, tags, sort | user_created → directus_users | +| `verification_topic` | uuid (key-based) | All translated fields via junction | project_id → project (SET NULL) | +| `verification_topic_translations` | — | title, message per language | verification_topic_key → verification_topic (SET NULL) | +| `view` | uuid | name, summary, description, language, user_input | project_analysis_run_id → project_analysis_run (SET NULL) | +| `aspect` | uuid | name, short_summary, long_summary, image_url | view_id → view (SET NULL) | +| `aspect_segment` | uuid | description, verbatim_transcript, relevant_index | aspect → aspect (CASCADE), segment → conversation_segment (SET NULL) | +| `processing_status` | bigInteger | event, message, duration_ms, timestamp | multiple nullable FKs to project, conversation, etc. (all SET NULL) | +| `insight` | uuid | title, summary | project_analysis_run_id → project_analysis_run (SET NULL) | +| `chat` | uuid | title | user_created/user_updated → directus_users (appears unused/legacy) | +| `project_chat_message_metadata` | uuid | conversation, message_metadata, ratio, reference_text, type (reference/citation) | In TypeScript types but NOT in sync snapshot (newer collection) | +| `prompt_template_preference` | uuid | template_type (static/user), static_template_id, prompt_template_id, sort | In TypeScript types but NOT in sync snapshot | +| `prompt_template_rating` | uuid | prompt_template_id, rating, chat_message_id | In TypeScript types but NOT in sync snapshot | +| `announcement` | uuid | level, expires_at | system announcements with translations | +| `announcement_activity` | uuid | read, user_id | tracks which users read announcements | +| `languages` | string (code), PK | name, direction | Reference table | + +--- + +### 1.9 CASCADE Dependency Tree + +``` +project (DELETE CASCADE triggers) + ├── conversation (CASCADE) + │ ├── conversation_chunk (CASCADE) + │ │ └── conversation_segment_conversation_chunk (CASCADE) + │ ├── conversation_segment (CASCADE) + │ │ └── conversation_segment_conversation_chunk (CASCADE) + │ ├── conversation_artifact (CASCADE) + │ ├── conversation_project_tag (CASCADE) + │ └── project_chat_conversation (CASCADE) + ├── project_tag (CASCADE) + │ └── conversation_project_tag (CASCADE) + ├── project_chat (CASCADE) + │ ├── project_chat_message (CASCADE) + │ └── project_chat_conversation (CASCADE) + ├── project_agentic_run (CASCADE) + │ └── project_agentic_run_event (CASCADE) + └── project_analysis_run (CASCADE) + +project (SET NULL on delete — data preserved, FK nullified) + ├── project_report.project_id + ├── project_webhook.project_id + ├── verification_topic.project_id + └── processing_status.project_id +``` + +--- + +## 2. PYTHON API ROUTE MAP + +### Architecture + +- **Entry point:** `server/dembrane/main.py` +- **Router aggregation:** `server/dembrane/api/api.py` — mounts all sub-routers under `/api` +- **Auth:** `DependencyDirectusSession` decodes JWT from Directus session cookie using shared `DIRECTUS_SECRET` +- **Admin client:** Module-level `directus` instance with static admin token (`DIRECTUS_TOKEN` env var) +- **User-scoped client:** Per-request `DirectusClient` created from user's JWT, available as `session.client` +- **No global response envelope** — each endpoint returns its own shape + +### Route Table + +#### /api (root) +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/health` | None | Health check | — | No | + +#### /api/projects +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/projects/home` | Session | BFF: paginated projects list with pins, search, owner | R: project, directus_users | No | +| PATCH | `/api/projects/{id}/pin` | Session | Pin/unpin project (1-3 or null) | RW: project | No | +| POST | `/api/projects` | Session | Create project | W: project | No | +| GET | `/api/projects/{id}/transcripts` | Session | Download all transcripts as ZIP | R: project, conversation, conversation_chunk | No | +| POST | `/api/projects/{id}/create-library` | Session | Enqueue library generation task | R: project | No | +| POST | `/api/projects/{id}/create-view` | Session | Enqueue view creation task | R: project, project_analysis_run | No | +| POST | `/api/projects/{id}/create-report` | Session | Create report (immediate or scheduled) | RW: project_report | No | +| GET | `/api/projects/{id}/reports` | Session | List project reports | R: project_report | No | +| GET | `/api/projects/{id}/reports/latest` | Session | Get latest report | R: project_report | No | +| PATCH | `/api/projects/{id}/reports/{rid}` | Session | Update report | RW: project_report | No | +| DELETE | `/api/projects/{id}/reports/{rid}` | Session | **Hard delete** report | D: project_report | **YES** | +| POST | `/api/projects/{id}/reports/{rid}/cancel-schedule` | Session | Cancel scheduled report | W: project_report | No | +| GET | `/api/projects/{id}/reports/{rid}/detail` | Session | Report full content | R: project_report | No | +| GET | `/api/projects/{id}/reports/{rid}/views` | Session | Report view counts | R: project_report_metric | No | +| GET | `/api/projects/{id}/reports/{rid}/needs-update` | Session | Check for newer conversations | R: project_report, conversation | No | +| GET | `/api/projects/{id}/participants/count` | Session | Email-opted-in participant count | R: project_report_notification_participants | No | +| GET | `/api/projects/{id}/reports/{rid}/progress` | Session | SSE: report generation progress | R: project_report | No (SSE) | +| POST | `/api/projects/{id}/clone` | Session | Clone project with tags | RW: project | No | + +#### /api/projects/{id}/webhooks +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/projects/{id}/webhooks` | Session | List webhooks | R: project_webhook | No | +| GET | `/api/projects/{id}/webhooks/copyable` | Session | Copyable webhooks from other projects | R: project_webhook | No | +| POST | `/api/projects/{id}/webhooks` | Session | Create webhook | W: project_webhook | No | +| PATCH | `/api/projects/{id}/webhooks/{wid}` | Session | Update webhook | RW: project_webhook | No | +| DELETE | `/api/projects/{id}/webhooks/{wid}` | Session | **Hard delete** webhook | D: project_webhook | **YES** | +| POST | `/api/projects/{id}/webhooks/{wid}/test` | Session | Test webhook delivery | R: project_webhook | No | + +#### /api/conversations +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/conversations/health/stream` | None | SSE: conversation health monitoring | R: conversation | No (SSE) | +| GET | `/api/conversations/{id}/counts` | Session | Chunk counts | R: conversation_chunk | No | +| GET | `/api/conversations/{id}/content` | Session | Audio content (merge + redirect) | R: conversation_chunk; W: conversation | No | +| GET | `/api/conversations/{id}/chunks/{cid}/content` | Session | Single chunk audio (S3 redirect) | R: conversation_chunk | No | +| GET | `/api/conversations/{id}/transcript` | Session | Full transcript text | R: conversation_chunk | No | +| GET | `/api/conversations/{id}/emails` | Session | Participant emails | R: project_report_notification_participants | No | +| GET | `/api/conversations/{id}/token-count` | Session | LLM token count (cached) | R: conversation_chunk | No | +| POST | `/api/conversations/{id}/get-reply` | None | LLM reply (SSE stream) | R: conversation, conversation_chunk | No | +| POST | `/api/conversations/{id}/summarize` | Session | Generate summary | RW: conversation | No | +| POST | `/api/conversations/{id}/generate-title` | Session | Generate title | RW: conversation | No | +| POST | `/api/conversations/{id}/retranscribe` | Session | Clone + re-transcribe | RW: conversation, conversation_chunk, conversation_link | On error only | +| DELETE | `/api/conversations/{id}` | Session | **Hard delete** conversation + S3 files | D: conversation (service) | **YES** | + +#### /api/chats +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/chats/{id}/context` | Session | Chat context (conversations, token usage) | R: project_chat, project_chat_message, conversation | No | +| POST | `/api/chats/{id}/add-context` | Session | Add conversations to chat | RW: project_chat junction | No | +| POST | `/api/chats/{id}/delete-context` | Session | Remove conversation from chat | D: project_chat_conversation junction | **YES** (junction) | +| POST | `/api/chats/{id}/lock-conversations` | Session | Lock conversations into chat | W: project_chat_message | No | +| GET | `/api/chats/{id}/suggestions` | Session | LLM question suggestions | R: project_chat, project | No | +| POST | `/api/chats/{id}/initialize-mode` | Session | Set chat mode | RW: project_chat | No | +| POST | `/api/chats/{id}` | Session | Send message (SSE stream) | RW: project_chat_message | On error: deletes user msg | + +#### /api/participant (PUBLIC — no auth) +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/participant/projects/{id}` | None | Public project info | R: project, directus_users | No | +| POST | `/api/participant/projects/{id}/conversations/initiate` | None | Start conversation | W: conversation | No | +| GET | `/api/participant/projects/{id}/conversations/{cid}` | None | Conversation info | R: conversation | No | +| GET | `/api/participant/projects/{id}/conversations/{cid}/chunks` | None | List chunks | R: conversation_chunk | No | +| DELETE | `/api/participant/.../chunks/{chid}` | None | **Hard delete** chunk | D: conversation_chunk | **YES** | +| POST | `/api/participant/conversations/{cid}/upload-text` | None | Upload text chunk | W: conversation_chunk | No | +| POST | `/api/participant/conversations/{cid}/upload-chunk` | None | Upload audio chunk | W: conversation_chunk | No | +| POST | `/api/participant/conversations/{cid}/check-s3` | None | S3 connectivity test | R: conversation, project | No | +| POST | `/api/participant/conversations/{cid}/get-upload-url` | None | Presigned S3 URL | R: conversation | No | +| POST | `/api/participant/conversations/{cid}/confirm-upload` | None | Confirm S3 upload | RW: conversation_chunk | No | +| POST | `/api/participant/conversations/{cid}/finish` | None | Signal finished | — (dispatches task) | No | +| GET | `/api/participant/{id}/report/latest` | None | Latest published report | R: project_report | No | +| GET | `/api/participant/{id}/report/{rid}/detail` | None | Published report content | R: project_report | No | +| GET | `/api/participant/{id}/report/views` | None | Report view counts | R: project_report_metric | No | +| POST | `/api/participant/{id}/report/metric` | None | Record view metric | W: project_report_metric | No | +| POST | `/api/participant/report/subscribe` | None | Email subscription | RWD: project_report_notification_participants | **YES** (re-create) | +| POST | `/api/participant/{id}/report/unsubscribe` | None | Unsubscribe | W: project_report_notification_participants | No | +| GET | `/api/participant/report/unsubscribe/eligibility` | None | Check unsubscribe token | R: project_report_notification_participants | No | + +#### /api/agentic +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| POST | `/api/agentic/runs` | Session | Create agentic run | W: project_agentic_run, project_agentic_run_event | No | +| POST | `/api/agentic/runs/{id}/messages` | Session | Add follow-up message | RW: project_agentic_run_event, project_chat_message | No | +| GET | `/api/agentic/projects/{id}/conversations` | Session | List conversations for agent | R: conversation, conversation_chunk | No | +| POST | `/api/agentic/runs/{id}/stream` | Session | SSE: process + stream events | RW: project_agentic_run | No (SSE) | +| POST | `/api/agentic/runs/{id}/stop` | Session | Cancel active run | W: project_agentic_run | No | +| GET | `/api/agentic/runs/{id}` | Session | Run details | R: project_agentic_run | No | +| GET | `/api/agentic/runs/{id}/events` | Session | Run events (SSE or JSON) | R: project_agentic_run_event | No | + +#### /api/verify +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/verify/topics/{pid}` | None | Get verification topics | R: verification_topic, project | No | +| PUT | `/api/verify/topics/{pid}` | None | Update selected topics | RW: project | No | +| POST | `/api/verify/topics/{pid}/custom` | Session | Create custom topic | W: verification_topic, project | No | +| PATCH | `/api/verify/topics/{pid}/custom/{key}` | Session | Update custom topic | RW: verification_topic | No | +| DELETE | `/api/verify/topics/{pid}/custom/{key}` | Session | **Hard delete** topic | D: verification_topic; W: project | **YES** | +| GET | `/api/verify/artifacts/{cid}` | None | List approved artifacts | R: conversation_artifact | No | +| GET | `/api/verify/artifact/{aid}` | None | Single artifact | R: conversation_artifact | No | +| POST | `/api/verify/generate` | None | Generate artifact (LLM) | RW: conversation_artifact | No | +| PUT | `/api/verify/artifact/{aid}` | None | Update/revise artifact | RW: conversation_artifact | No | + +#### /api/user-settings +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/user-settings/me` | Session | Current user profile | R: directus_users (admin client) | No | +| PATCH | `/api/user-settings/password` | Session | Change password | Proxies to Directus auth | No | +| POST | `/api/user-settings/tfa/generate` | Session | Generate 2FA secret | Proxies to Directus | No | +| POST | `/api/user-settings/tfa/enable` | Session | Enable 2FA | Proxies to Directus | No | +| POST | `/api/user-settings/tfa/disable` | Session | Disable 2FA | Proxies to Directus | No | +| POST | `/api/user-settings/whitelabel-logo` | Session | Upload logo | W: directus_files, directus_users | No | +| DELETE | `/api/user-settings/whitelabel-logo` | Session | **Hard delete** logo file | D: directus_files; W: directus_users | **YES** (file) | +| PATCH | `/api/user-settings/name` | Session | Update display name | W: directus_users | No | +| POST | `/api/user-settings/avatar` | Session | Upload avatar | W: directus_files, directus_users | No | +| DELETE | `/api/user-settings/avatar` | Session | **Hard delete** avatar file | D: directus_files; W: directus_users | **YES** (file) | +| PATCH | `/api/user-settings/legal-basis` | Session | Set legal basis | RW: directus_users | No | + +#### /api/templates +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/templates/prompt-templates` | Session | List user templates | R: prompt_template | No | +| POST | `/api/templates/prompt-templates` | Session | Create template | W: prompt_template | No | +| PATCH | `/api/templates/prompt-templates/{id}` | Session | Update template | RW: prompt_template | No | +| DELETE | `/api/templates/prompt-templates/{id}` | Session | **Hard delete** template | D: prompt_template | **YES** | +| GET | `/api/templates/quick-access` | Session | Quick access preferences | R: directus_users | No | +| PUT | `/api/templates/quick-access` | Session | Save quick access prefs | W: directus_users | No | +| PATCH | `/api/templates/ai-suggestions` | Session | Toggle AI suggestions | W: directus_users | No | + +#### /api/home +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/home/search` | Session | Global search | R: project, conversation, conversation_chunk, project_chat | No | + +#### /api/stats +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| GET | `/api/stats/` | None | Aggregate platform stats (cached) | R: directus_users, project, conversation | No | + +#### /api/webhooks +| Method | Path | Auth | Description | Collections | Deletes? | +|--------|------|------|-------------|-------------|----------| +| POST | `/api/webhooks/assemblyai` | Webhook secret | AssemblyAI transcription callback | R: Redis; dispatches task | No | + +--- + +## 3. FRONTEND ROUTE MAP + +### Host Dashboard (mainRouter) + +All paths prefixed with `/:language?/` + +| Path | Component | Data Source | Delete Ops | +|------|-----------|-------------|------------| +| `/login` | LoginRoute | Directus SDK (auth) | — | +| `/register` | RegisterRoute | Directus SDK | — | +| `/check-your-email` | CheckYourEmailRoute | Static | — | +| `/password-reset` | PasswordResetRoute | Directus SDK | — | +| `/request-password-reset` | RequestPasswordResetRoute | Directus SDK | — | +| `/verify-email` | VerifyEmailRoute | Directus SDK | — | +| `/projects` | ProjectsHomeRoute | **Python API** (`/projects/home` BFF) | — | +| `/projects/:id` | Redirect → portal-editor | — | — | +| `/projects/:id/portal-editor` | ProjectPortalSettingsRoute | Directus SDK + Python API | Delete tags (Directus direct), delete verification topics (Python) | +| `/projects/:id/overview` | ProjectSettingsRoute | Directus SDK + Python API | **Delete project** (Directus SDK direct!), delete webhooks (Python) | +| `/projects/:id/chats/new` | NewChatRoute | Directus SDK + Python API | — | +| `/projects/:id/chats/:chatId` | ProjectChatRoute | Python API + Directus SDK | **Delete chat** (Directus SDK direct!) | +| `/projects/:id/conversation/:cid/overview` | ProjectConversationOverviewRoute | Directus SDK + Python API | **Delete conversation** (Python API) | +| `/projects/:id/conversation/:cid/transcript` | ProjectConversationTranscript | Directus SDK | — | +| `/projects/:id/library` | ProjectLibraryRoute | Directus SDK + Python API | — | +| `/projects/:id/library/views/:vid` | ProjectLibraryView | Directus SDK | — | +| `/projects/:id/library/views/:vid/aspects/:aid` | ProjectLibraryAspect | Directus SDK | — | +| `/projects/:id/report` | ProjectReportRoute | Python API | **Delete report** (Python API) | +| `/projects/:id/host-guide` | HostGuidePage | Directus SDK + SSE | — | +| `/settings` | UserSettingsRoute | Python API + Directus SDK | — | + +### Participant Portal (participantRouter) + +All paths prefixed with `/:language?/:projectId/` + +| Path | Component | Data Source | Delete Ops | +|------|-----------|-------------|------------| +| `/start` | ParticipantStartRoute | Python API | — | +| `/conversation/:cid` | ParticipantConversationAudioRoute | Python API | Delete chunk (Python API) | +| `/conversation/:cid/text` | ParticipantConversationTextRoute | Python API | — | +| `/conversation/:cid/refine` | RefineSelection | Python API | — | +| `/conversation/:cid/verify` | VerifySelection | Python API | — | +| `/conversation/:cid/verify/approve` | VerifyArtefact | Python API | — | +| `/conversation/:cid/finish` | ParticipantPostConversation | Python API | — | +| `/report` | ParticipantReport | Python API | — | +| `/unsubscribe` | ProjectUnsubscribe | Python API | — | + +--- + +## 4. DELETE OPERATION INVENTORY + +### 4a. Frontend → Directus Direct DELETE Calls + +| # | File | Hook/Function | Collection | Trigger | Metadata Lost | +|---|------|--------------|------------|---------|---------------| +| D1 | `components/project/hooks/index.ts:104` | `useDeleteProjectByIdMutation` | **project** | User clicks delete in ProjectDangerZone | **CATASTROPHIC**: ALL conversations (duration, transcripts, audio), ALL chunks, segments, artifacts, tags, chats, chat messages, analysis runs, agentic runs. Biggest data loss event possible. | +| D2 | `components/chat/hooks/index.ts:68` | `useDeleteChatMutation` | **project_chat** | User deletes chat from sidebar menu | All chat messages (text, tokens_count), conversation junction records | +| D3 | `components/project/hooks/index.ts:188` | `useDeleteTagByIdMutation` | **project_tag** | User deletes a project tag | All conversation-tag associations (CASCADE) | +| D4 | `components/conversation/hooks/index.ts:187` | `useUpdateConversationTagsMutation` | **conversation_project_tag** | User removes tags from conversation | Junction records only (low impact) | + +### 4b. Frontend → Python API Delete Endpoints + +| # | Frontend File | Python Endpoint | Collection | Trigger | Metadata Lost | +|---|------|--------|------------|---------|---------------| +| D5 | `lib/api.ts:1614` | `DELETE /api/conversations/{id}` | **conversation** | User clicks delete in ConversationDangerZone | duration, summary, title, transcript, participant info; S3 audio files also deleted | +| D6 | `lib/api.ts:1341` | `DELETE /api/projects/{id}/reports/{rid}` | **project_report** | User deletes report | Report content, language, status; metrics SET NULL | +| D7 | `lib/api.ts:1737` | `DELETE /api/projects/{id}/webhooks/{wid}` | **project_webhook** | User deletes webhook | Webhook URL, secret, events config | +| D8 | `lib/api.ts:1811` | `DELETE /api/templates/prompt-templates/{id}` | **prompt_template** | User deletes template | Template title, content, description | +| D9 | `components/settings/WhitelabelLogoCard.tsx:70` | `DELETE /api/user-settings/whitelabel-logo` | **directus_files** | User removes logo | File only (no billing impact) | +| D10 | `components/settings/AccountSettingsCard.tsx:87` | `DELETE /api/user-settings/avatar` | **directus_files** | User removes avatar | File only (no billing impact) | +| D11 | `lib/api.ts:965` | `POST /api/chats/{id}/delete-context` | **project_chat_conversation** | User removes conversation from chat | Junction record only | +| D12 | `lib/api.ts:58` | `DELETE /api/participant/.../chunks/{id}` | **conversation_chunk** | Participant deletes their chunk | Chunk transcript, S3 audio path (orphaned!) | +| D13 | (verify settings page) | `DELETE /api/verify/topics/{pid}/custom/{key}` | **verification_topic** | User deletes custom topic | Topic translations SET NULL | + +### 4c. Python API → Directus DELETE Calls (System-Initiated) + +| # | File | Function | Collection | Trigger | Metadata Lost | +|---|------|----------|------------|---------|---------------| +| D14 | `audio_utils.py:740` | split_audio_chunk | **conversation_chunk** | Audio chunk too large, auto-split | Original replaced by sub-chunks (data preserved) | +| D15 | `api/conversation.py:878` | retranscribe (error path) | **conversation** | Retranscription fails | Partial new conversation (cleanup) | +| D16 | `api/chat.py:1138,1160` + `service/chat.py:296` | ChatService.delete_message | **project_chat_message** | LLM stream error | User's chat message text | +| D17 | `api/participant.py:808` | report subscribe | **project_report_notification_participants** | Participant re-subscribes | Re-created immediately | + +### 4d. Directus Hooks/Flows That Delete Data + +| Flow | Status | Trigger | What It Does | +|------|--------|---------|-------------| +| Send Email | active | operation (manual trigger) | Sends email, no deletes | +| Send Email Base | active | manual on project | Sends email, no deletes | +| Send Report Emails | active | event on project_report update | Sends notification emails, no deletes | +| Validate create project | **inactive** (draft) | filter on project create | Would validate, no deletes | + +**No Directus flows or hooks perform delete operations.** + +### 4e. Automated Cleanup + +| # | Location | What | Trigger | +|---|----------|------|---------| +| D18 | `audio_utils.py:287` | S3 original audio after format conversion | Audio processing pipeline | +| D19 | `audio_utils.py:751` | S3 files on split failure (cleanup) | Error during chunk splitting | +| D20 | `api/project.py:369` | Local temp files after ZIP download | BackgroundTask after response | +| D21 | `coordination.py:441` | Redis coordination keys | After conversation finalization | + +--- + +## 5. AUTH FLOW + +### End-to-End Login + +``` +Frontend Login.tsx + → directus.login({ email, password }, { otp }) # Directus SDK + → Directus POST /auth/login (session mode) + → Directus sets HTTP-only cookie: directus_session_token + → Frontend stores nothing (browser cookie jar handles it) + → Redirect to /projects (or ?next= param) +``` + +### Session Validation (Frontend) + +``` +Protected.tsx → useAuthenticated() + → directus.refresh() # Directus SDK + → Directus POST /auth/refresh (using session cookie) + → If fails → logout + redirect to /login + → staleTime: 60s (re-validates every 60s) +``` + +### Get Current User (Python) + +**File:** `server/dembrane/api/dependency_auth.py` + +```python +async def require_directus_session(request: Request) -> DirectusSession: + # 1. Extract JWT from cookie or Authorization header + directus_cookie = request.cookies.get(DIRECTUS_SESSION_COOKIE_NAME) + auth_header = request.headers.get("Authorization") + + # 2. Decode JWT locally (NOT forwarded to Directus) + decoded = jwt.decode(token=to_decode, key=DIRECTUS_SECRET, algorithms=["HS256"]) + user_id = decoded.get("id") + is_admin = decoded.get("admin_access", False) + + # 3. Create per-request Directus client scoped to user's token + client = create_directus_client(token=to_decode) + + return DirectusSession(str(user_id), bool(is_admin), access_token=to_decode, client=client) +``` + +**Returns:** `DirectusSession` with `user_id` (string), `is_admin` (bool), `access_token`, `client` (user-scoped DirectusClient) + +### User Profile Fields (from /me endpoint) + +```python +USER_PROFILE_FIELDS = [ + "id", "first_name", "email", "avatar", "disable_create_project", + "tfa_secret", "whitelabel_logo", "legal_basis", "privacy_policy_url", + "hide_ai_suggestions", +] +# tfa_secret is replaced with boolean tfa_enabled before returning +``` + +--- + +## 6. DIRECTUS CLIENT PATTERN + +### Python DirectusClient + +**File:** `server/dembrane/directus.py` (975 lines) + +**Library:** `requests` (synchronous) + +**Two instances:** +1. **Admin client** (module-level): `directus = create_directus_client(token=settings.directus.token)` +2. **User-scoped** (per-request): `create_directus_client(token=user_jwt)` — returned as `session.client` + +**CRUD patterns:** + +```python +# CREATE — returns {"data": {...}} — MUST unwrap with ["data"] +new = directus.create_item("collection", {"field": "value"})["data"] + +# READ (list) — returns data directly (list), requires "query" wrapper +items = directus.get_items("collection", {"query": {"filter": {...}, "fields": [...]}}) +# Internally calls: self.search(f"/items/{collection}", query=query) +# Uses HTTP SEARCH method + +# READ (single) — returns data directly +item = directus.get_item("collection", item_id) + +# UPDATE — returns {"data": {...}} +updated = directus.update_item("collection", item_id, {"field": "value"})["data"] + +# DELETE — no return value +directus.delete_item("collection", item_id) + +# GET USERS — special method for directus_users +users = directus.get_users({"query": {"filter": {...}, "fields": [...]}}) +``` + +**Filter syntax (Directus operators):** + +```python +{"filter": {"field": {"_eq": value}}} +{"filter": {"field": {"_null": True}}} +{"filter": {"field": {"_in": [val1, val2]}}} +{"filter": {"_and": [{"field1": {"_eq": v1}}, {"field2": {"_eq": v2}}]}} +``` + +**Error handling:** + +```python +# Custom exceptions in directus.py: +DirectusGenericException # Base +DirectusAuthError # Auth failures +DirectusServerError # Connection errors +DirectusBadRequest # 4xx responses + +# Retry logic: up to 3 retries with exponential backoff +# Auto re-auth on 401/403 if email/password available +``` + +**Environment variables:** + +| Var | Required | Default | Purpose | +|-----|----------|---------|---------| +| `DIRECTUS_BASE_URL` | No | `http://directus:8055` | Internal Directus URL | +| `DIRECTUS_SECRET` | Yes | — | Shared JWT secret (HS256) | +| `DIRECTUS_TOKEN` | Yes | — | Admin static token | +| `DIRECTUS_SESSION_COOKIE_NAME` | No | `directus_session_token` | Cookie name | + +--- + +## 7. EXISTING PATTERNS + +### Multi-User / Sharing / Organisation Concepts + +**None exist.** Projects are owned by a single user via `project.directus_user_id`. All Directus permissions for "Basic User" filter by `directus_user_id = $CURRENT_USER`. No `shared_with`, `collaborators`, `organisation_id`, or `workspace` fields exist anywhere. + +### Soft Delete / Archive Patterns + +**None exist.** No `deleted_at` field on any collection. No archive functionality configured. The only "status" field with archive-like values is `project_report.status` which includes `"archived"`, but this is a workflow status, not a soft delete. + +### Schema Sync + +**Tool:** `directus-sync` (npm package) +**Config:** `directus/directus-sync.config.js` — dumpPath: `./sync`, specs disabled +**Wrapper:** `directus/sync.sh` — supports `push` (source → Directus), `pull` (Directus → source), `diff` +**Output:** +- `sync/collections/` — permissions.json, roles.json, policies.json, flows.json, operations.json, settings.json +- `sync/snapshot/collections/` — one JSON per collection +- `sync/snapshot/fields//` — one JSON per field +- `sync/snapshot/relations//` — one JSON per relation + +**No CI/CD validates schema.** Sync is manual. + +### Email + +**Handled entirely by Directus Flows** using Liquid templates. Templates in `directus/templates/`: +- `email-base.liquid` — base layout +- `password-reset.liquid`, `user-invite.liquid`, `user-registration.liquid` — auth +- `report-notification-en.liquid`, `report-notification-nl.liquid` — report notifications + +**Python does NOT send emails directly.** No SendGrid in the Python backend. This is important for the workspace invite flow — it will need to either use Directus email or add SendGrid to Python. + +### Background Tasks (Dramatiq) + +- **Broker:** Redis with lz4 compression +- **Queues:** `network` (gevent, most tasks), `cpu` (standard, compute-heavy) +- **17 actors** in `server/dembrane/tasks.py` +- **4 periodic jobs** via APScheduler in `scheduler.py` +- **Middleware:** Results, GroupCallbacks, Workflow, SkipRetryOnUnrecoverableError + +--- + +## 8. PROJECT STRUCTURE + +### Python API + +``` +server/dembrane/ + api/ # Routes — add new router files here + api.py # Router aggregator — register new routers here + dependency_auth.py # Auth dependencies + exceptions.py # HTTP exception constants + rate_limit.py # Redis rate limiters + project.py, conversation.py, chat.py, ... # Route files + service/ # Business logic — add new service classes here + __init__.py # Factory functions (build_*_service) + project.py, conversation.py, chat.py, ... + directus.py # DirectusClient wrapper + settings.py # All env var config (Pydantic BaseSettings) + tasks.py # Dramatiq actors + utils.py # General utilities + async_helpers.py # Thread pool + async helpers +``` + +### Frontend + +``` +frontend/src/ + Router.tsx # Route definitions — add new routes here + routes/ # Page components — add new pages here + auth/, project/, participant/, settings/ + components/ # Feature-organized — add new components in domain folders + /hooks/ # React Query hooks per domain + lib/ + api.ts # Centralized API calls — add new API functions here + directus.ts # Directus SDK setup + typesDirectus.d.ts # Directus schema types — add new collection types here + types.d.ts # Frontend-only types + locales/ # i18n translations +``` + +--- + +## 9. PRD RECONCILIATION + +### 9a. APP_USER GAP ANALYSIS + +The PRD defines `app_user` with: `id`, `directus_user_id`, `email`, `display_name`, `created_at`, `updated_at`. + +**Fields on directus_users that are actively used by the app but NOT in PRD's app_user:** + +| Field | Used Where | Recommendation | +|-------|-----------|----------------| +| `first_name` | /me endpoint, migration display_name construction, participant project view | **Denormalize** into app_user as `display_name` (concat first+last at migration time) | +| `last_name` | Same as first_name | Folded into `display_name` | +| `avatar` | /me endpoint, settings page | **Fetch from directus_users at runtime** — it's a file reference that changes | +| `disable_create_project` | /me endpoint, project creation gating | **Denormalize** — this is a domain concept | +| `whitelabel_logo` | /me endpoint, branding, participant portal | **Move to workspace** settings (per PRD's workspace.settings or workspace.logo_url) | +| `legal_basis` | /me endpoint, settings, participant consent | **Move to workspace** (PRD already has workspace.legal_basis) | +| `privacy_policy_url` | /me endpoint, settings, participant consent | **Move to workspace** (PRD already has workspace.privacy_policy_url) | +| `tfa_secret` | /me endpoint (as tfa_enabled boolean) | **Never denormalize** — sensitive, stays in directus_users | +| `quick_access_preferences` | Template quick access | **Stays in directus_users** — per-user UI preference | +| `hide_ai_suggestions` | Template suggestions toggle | **Stays in directus_users** — per-user UI preference | +| `email` | /me endpoint, various lookups | **Denormalize** (PRD already includes this) | +| `role` (Directus role ID) | Admin checks via JWT `admin_access` claim | **Do NOT denormalize** — resolved from JWT | + +**Summary:** app_user should add `disable_create_project` beyond what the PRD specifies. The whitelabel/legal_basis/privacy_policy fields should move from directus_users to workspace-level settings (which the PRD already plans). `avatar` stays as a runtime fetch from directus_users. + +### 9b. COLLECTION NAME CHECK + +| PRD Says | Actual Codebase | Match? | Notes | +|----------|----------------|--------|-------| +| `conversation` | `conversation` | YES | | +| `project` | `project` | YES | | +| `chat` | `project_chat` | **NO** | PRD says "chat (or whatever the chat/message collection is called)". Actual name is `project_chat` with messages in `project_chat_message` | +| `report` | `project_report` | **NO** | PRD says "report". Actual name is `project_report` | +| `conversation.duration_seconds` | `conversation.duration` | **NO** | PRD references `duration_seconds`. Actual field is `duration` (float, stores seconds) | +| — | `chat` collection | — | There's a **separate legacy `chat` collection** (uuid id, title, user_created/updated). Appears unused. Not the same as `project_chat`. | + +### 9c. API PATTERN CHECK + +| PRD Pattern | Actual Pattern | Match? | +|-------------|---------------|--------| +| `await directus.get(f"/items/project/{id}")` | `directus.get_item("project", id)` | **NO** — Python uses wrapper methods, not raw path construction | +| `await directus.patch(f"/items/conversation/{id}", {...})` | `directus.update_item("conversation", id, {...})` | **NO** — uses `update_item` not raw `patch` | +| `await directus.get("/items/workspace_membership", {"filter": ...})` | `directus.get_items("workspace_membership", {"query": {"filter": ...}})` | **NO** — requires `{"query": {...}}` wrapper | +| `await directus.post("/items/app_user", {...})` | `directus.create_item("app_user", {...})["data"]` | **NO** — uses `create_item` and must unwrap `["data"]` | +| `async def` (PRD shows async functions) | DirectusClient is **synchronous** (uses `requests` library) | **NO** — no `await` needed | +| `directus.search()` returns `{"error": ...}` on failure | Actual: returns dict, must check `isinstance(result, list)` | Correct in CLAUDE.md but PRD examples don't show this | + +**Critical:** The PRD's example code uses raw HTTP-style calls (`directus.get("/items/...")`, `directus.patch(...)`) but the actual codebase uses typed wrapper methods (`get_item`, `get_items`, `create_item`, `update_item`, `delete_item`). All PRD code examples need translation. + +### 9d. PERMISSION MODEL CHECK + +| PRD Assumption | Reality | Conflict? | +|----------------|---------|-----------| +| All deletes through Python API | Project delete currently goes Frontend → Directus SDK directly | **YES** — must reroute before soft delete | +| Chat delete through Python | Chat delete goes Frontend → Directus SDK directly | **YES** — must reroute | +| Directus roles have DELETE permissions | Basic User policy has DELETE on: conversation, project, project_chat, project_tag, view, aspect, conversation_artifact, etc. | Compatible — these can remain for backward compat during migration | +| Admin check via Directus | Auth uses JWT claim `admin_access` from Directus | Compatible | +| workspace_membership checks | No membership tables exist yet | N/A — new | + +### 9e. CONFLICT CHECK + +| Conflict | Details | Severity | +|----------|---------|----------| +| Legacy `chat` collection | A separate `chat` collection exists (not `project_chat`). It has `id`, `title`, `user_created`, `user_updated`. Appears unused but may confuse schema. | LOW — verify it's truly unused, then ignore | +| `project.directus_user_id` stays after workspace migration | PRD says to keep for backward compat. The field has `on_delete: SET NULL` to directus_users. No conflict, but need to decide when to stop using it. | LOW | +| `project_report.project_id` is SET NULL (not CASCADE) | Unlike most relations, deleting a project does NOT delete its reports. Reports survive project deletion. This is actually good for billing — but means soft delete of projects won't automatically "hide" their reports. | MEDIUM — reports need their own `deleted_at` filter, or need to join through project | +| `project_webhook.project_id` is SET NULL | Same as reports — webhooks survive project deletion | LOW | +| `verification_topic.project_id` is SET NULL | Topics survive project deletion | LOW | +| No `duration_seconds` field | PRD references `duration_seconds` but field is `duration` (float) | LOW — just use correct field name | +| `project_report.id` is bigInteger | Not UUID like other collections. PRD assumes UUID for all new collections. | LOW — only matters if we FK to it | + +### 9f. RISK REGISTER + +| # | Risk | Likelihood | Impact | Mitigation | +|---|------|-----------|--------|------------| +| 1 | **Project delete bypasses Python entirely** — goes Frontend → Directus SDK. Soft delete conversion requires rerouting this through Python first. If missed, projects can still be hard-deleted. | HIGH (it's the current behavior) | CRITICAL (destroys all data) | Session 3 must create a Python endpoint for project delete AND update the frontend hook to call it. Remove Directus DELETE permission for Basic User on project collection. | +| 2 | **Chat delete also bypasses Python** — same issue as project. Frontend calls `directus.request(deleteItem("project_chat", id))` directly. | HIGH | HIGH (destroys chat history) | Same mitigation as #1 — new Python endpoint, update frontend, restrict Directus permissions. | +| 3 | **CASCADE deletes are destructive** — deleting a project cascades to 10+ tables. Even with soft delete on project, if any code path still does a hard DELETE, all cascaded data is gone forever. Must ensure NO hard delete paths remain after conversion. | MEDIUM | CRITICAL | Audit all code paths in Session 3. Consider removing CASCADE constraints and replacing with application-level soft cascade. | +| 4 | **Email sending is in Directus, not Python** — the PRD assumes workspace invites will use SendGrid from Python. But the current codebase has NO email capability in Python. All email goes through Directus Flows with Liquid templates. | HIGH | MEDIUM (blocks invite flow) | Either: (a) add SendGrid to Python for invite emails, or (b) create a Directus Flow triggered via Directus API from Python, or (c) use Directus's built-in mail API from Python. | +| 5 | **Synchronous DirectusClient** — the DirectusClient uses `requests` (blocking). In FastAPI async endpoints, this blocks the event loop. The PRD shows `async def` + `await` patterns but the client is synchronous. At scale with workspace permission checks on every request, this could become a bottleneck. | MEDIUM | MEDIUM (performance) | For now, wrap in `run_in_thread_pool()` (already used elsewhere in the codebase). Long-term, consider an async client. | + +--- + +## 10. SUMMARY OF CRITICAL FINDINGS + +### Must Address Before Session 2 (Schema) + +1. **Collection name corrections for PRD:** `project_chat` (not "chat"), `project_report` (not "report"), `conversation.duration` (not `duration_seconds`) +2. **app_user needs `disable_create_project`** field added beyond PRD spec +3. **whitelabel_logo, legal_basis, privacy_policy_url** should move from directus_users to workspace — PRD already handles this via `workspace.settings`, `workspace.legal_basis`, `workspace.privacy_policy_url` + +### Must Address Before Session 3 (Soft Delete) + +1. **Route project delete through Python** — currently Frontend → Directus direct +2. **Route chat delete through Python** — currently Frontend → Directus direct +3. **Route tag delete through Python** — currently Frontend → Directus direct +4. **Email capability in Python** — needed for workspace invites (or use Directus mail API) + +### Must Address Before Session 4 (Core API) + +1. **Translate all PRD code examples** from raw HTTP patterns to actual DirectusClient wrapper methods +2. **All DirectusClient calls are synchronous** — use `run_in_thread_pool()` for async endpoints +3. **`get_items` requires `{"query": {...}}` wrapper** — PRD examples miss this +4. **`create_item` returns `{"data": {...}}`** — must unwrap with `["data"]` + +--- + +## 10a. CASCADE & Hard Delete Analysis + +### PostgreSQL Triggers + +**None exist.** Zero custom PostgreSQL triggers in this codebase. The `.devcontainer/docker-compose.yml` mounts `./init.sql:/docker-entrypoint-initdb.d/init.sql` but that path is an empty directory. No Directus extensions install triggers either (`directus/extensions/` is empty). + +### PostgreSQL Constraints Beyond Directus + +**None exist.** All database schema is managed exclusively through Directus's snapshot system. While `alembic` appears in `pyproject.toml` as a dependency, no actual Alembic migration directory or `alembic.ini` exists. No `.sql` files with `ALTER TABLE...ADD CONSTRAINT` or `CREATE INDEX` exist anywhere. + +All FK constraints and their `on_delete` behaviors are defined solely in `directus/sync/snapshot/relations/`. + +### Remaining Hard DELETE Paths After Soft Delete Conversion + +When we convert to soft delete (PATCH `deleted_at = now()`), the CASCADE constraints won't fire — they only trigger on actual SQL `DELETE`. However, these paths could still perform hard DELETEs: + +#### Frontend → Directus SDK Direct (MUST reroute before removing permissions) + +| # | File | Line | What | Collection | +|---|------|------|------|------------| +| 1 | `frontend/src/components/project/hooks/index.ts` | 105 | `useDeleteProjectByIdMutation` → `deleteItem("project", projectId)` | **project** | +| 2 | `frontend/src/components/chat/hooks/index.ts` | 72 | `useDeleteChatMutation` → `deleteItem("project_chat", payload.chatId)` | **project_chat** | +| 3 | `frontend/src/components/project/hooks/index.ts` | 193 | `useDeleteTagByIdMutation` → `deleteItem("project_tag", tagId)` | **project_tag** | + +These go through the user's Directus session token and rely on Directus DELETE permissions. + +#### Python Backend (use admin token — will still work after permission removal) + +| # | File | Line | What | Collection | +|---|------|------|------|------------| +| 4 | `server/dembrane/service/project.py` | 135 | `ProjectService.delete()` — dead code, not called from any route | project | +| 5 | `server/dembrane/service/conversation.py` | 372 | `ConversationService.delete()` — called from DELETE endpoint | conversation | +| 6 | `server/dembrane/api/project.py` | 850-852 | Report delete endpoint — uses admin client | project_report | +| 7 | `server/dembrane/service/chat.py` | 299 | `ChatService.delete_message()` — error cleanup | project_chat_message | +| 8 | `server/dembrane/audio_utils.py` | 740 | Chunk split — replaces original with sub-chunks | conversation_chunk | +| 9 | `server/dembrane/api/conversation.py` | 878 | Retranscription error cleanup | conversation | + +#### Directus Admin Panel + +**Yes, Administrator role users can hard-delete anything via the Directus UI.** The Administrator policy has `admin_access: true` granting full CRUD. This cannot be prevented without Directus hooks — but admin users are trusted internal staff, so this is acceptable. + +### Current DELETE Permissions (Basic User Policy) + +All under policy `37a60e48-dd00-4867-af07-1fb22ac89078` ("Basic User Policy"), which applies to both "Basic User" and "Enterprise User" roles: + +| Collection | Permission Filter | Scope | +|------------|------------------|-------| +| `project` | `directus_user_id._eq: $CURRENT_USER` | Owner only | +| `conversation` | `project_id.directus_user_id._eq: $CURRENT_USER` | Project owner only | +| `project_chat` | `project_id.directus_user_id._eq: $CURRENT_USER` | Project owner only | +| `project_report` | **No DELETE permission exists** | Deletion uses admin token from backend | +| `conversation_artifact` | via project owner chain | Project owner only | +| `conversation_chunk` | via project owner chain | Project owner only | +| `project_tag` | via project owner chain | Project owner only | +| `project_chat_message` | via project owner chain | Project owner only | +| `project_chat_conversation` | unrestricted (null filter) | Any user | +| `project_analysis_run` | via project owner chain | Project owner only | +| `project_webhook` | via project owner chain | Project owner only | +| `view` | via project owner chain | Project owner only | +| `verification_topic` | unrestricted (null filter) | Any user | + +### Recommendation: Remove DELETE Permissions + +**Yes — remove DELETE permissions on `project`, `conversation`, `project_chat`, and `project_tag` from the Basic User Policy after soft delete conversion.** + +**Migration order (must follow this sequence):** + +1. Create Python BFF soft-delete endpoints for project, chat, tag +2. Update frontend hooks to call BFF endpoints instead of Directus SDK +3. Remove DELETE permission on `project` from Basic User Policy +4. Remove DELETE permission on `project_chat` from Basic User Policy +5. Remove DELETE permission on `conversation` from Basic User Policy (already routed through Python, but defense-in-depth) +6. **Keep** DELETE on junction tables (`project_chat_conversation`, `conversation_project_tag`) — used for legitimate detach operations, not data destruction + +**Benefits:** +- Eliminates all user-initiated hard-delete paths on core data +- Makes the CASCADE chain (`project` → 10+ tables) impossible to trigger from user operations +- Forces all deletes through controlled backend endpoints with soft-delete + usage event emission + +**Risks:** +- If any frontend code still calls `deleteItem` via user session after permission removal, it gets a 403. Must complete step 2 before step 3. +- Backend service methods use the admin client and are unaffected by permission changes. + +--- + +## 10b. Legacy `chat` Collection Status + +### Verification Results + +The legacy `chat` collection (distinct from `project_chat`) has been thoroughly investigated: + +| Check | Result | +|-------|--------| +| Seed data | No references in `seed.py` or any fixture files | +| Python SDK calls | Zero: no `get_items("chat"`, `create_item("chat"`, etc. | +| Frontend SDK calls | Zero: no `readItems("chat"`, `createItem("chat"`, etc. | +| TypeScript types | No `Chat` interface in `typesDirectus.d.ts` (only `ProjectChat`) | +| Directus permissions | No DELETE/CREATE/UPDATE permissions for `chat` collection | +| Directus flows | No flows reference `chat` collection | +| Relations | Only standard audit fields (`user_created`, `user_updated` → `directus_users`). No other collection has FK to `chat`. | +| Tests | Zero references | + +The only grep hits for `"chat"` in code are: +- `APIRouter(tags=["chat"])` — OpenAPI tag for the `project_chat` router (not the collection) +- `"chat": {` in `service/__init__.py` — exception dict key for ChatService (operates on `project_chat`) + +### Conclusion + +**Legacy `chat` collection confirmed unused. To be removed in Session 2 schema work.** + +Files to remove from sync snapshot: +- `directus/sync/snapshot/collections/chat.json` +- `directus/sync/snapshot/fields/chat/*.json` (6 files) +- `directus/sync/snapshot/relations/chat/*.json` (2 files) + +The `chat` table should also be dropped from the database in each environment after confirming zero rows via Directus admin UI. + +--- + +## 10c. Email Capability for Workspace Invites + +### Current Email Configuration + +**Directus uses SendGrid via SMTP relay** (not the SendGrid API directly). + +From `directus/.env`: +``` +EMAIL_TRANSPORT="smtp" +EMAIL_FROM="do-not-reply@dembrane.com" +EMAIL_SMTP_HOST="smtp.sendgrid.net" +EMAIL_SMTP_PORT=587 +EMAIL_SMTP_USER="apikey" +EMAIL_SMTP_PASSWORD="SG.****" +``` + +The SendGrid API key is used as an SMTP password (standard SendGrid SMTP auth pattern). + +### Existing Email Templates + +| Template | Purpose | +|----------|---------| +| `email-base.liquid` | Base layout (Dembrane logo, styling) | +| `user-invite.liquid` | Directus user invitation ("Join {{ projectName }}") | +| `user-registration.liquid` | Email verification after registration | +| `password-reset.liquid` | Password reset | +| `report-notification-en.liquid` | English report notification | +| `report-notification-nl.liquid` | Dutch report notification | + +### Can We Trigger Directus Flows from Python? + +The "Send Email" flow has `trigger: "operation"` — it can only be invoked from within another Directus Flow, NOT via `POST /flows/trigger/:flow_id` (which requires `trigger: "manual"` or `trigger: "webhook"`). + +The "Send Email Base" flow has `trigger: "manual"` but is hardcoded to send to `sameer@dembrane.com` — a test/debug flow. + +**No webhook-triggered email flow exists currently.** + +### Python Email Dependencies + +**Zero.** No `sendgrid`, `python-sendgrid`, `emails`, `aiosmtplib`, or any email package in `pyproject.toml`. No `SENDGRID_API_KEY` or `SMTP_*` env vars in server settings. + +### Directus `/utils/mail` Endpoint + +**Not used anywhere in the codebase currently**, but the `DirectusClient` has generic `.post()` that could call it. This endpoint requires admin-level token (which the server already has). + +### Recommendation: Use Directus `POST /utils/mail/send` + +**Option 1 (Recommended): Directus `/utils/mail/send` from Python** + +```python +# Using the existing admin DirectusClient +directus.post("/utils/mail/send", json={ + "to": "invitee@example.com", + "subject": "You've been invited to collaborate", + "template": { + "name": "workspace-invite", + "data": { + "url": "https://app.dembrane.com/...", + "workspaceName": "Client Project", + "inviterName": "Sameer", + } + } +}) +``` + +**What's needed:** +- Create a new `workspace-invite.liquid` template in `directus/templates/` (extend `email-base.liquid`) +- No new Python dependencies +- No new environment variables +- Reuses existing SendGrid SMTP relay +- Works with the existing admin `DirectusClient` singleton + +**Other options considered but NOT recommended:** + +| Option | Why Not | +|--------|---------| +| Add `sendgrid` Python SDK | New dependency, duplicate transport config, need new env vars | +| Create webhook-triggered Directus Flow | More complex, constrained payload format, harder to maintain | +| Use `smtplib` directly | Reinventing the wheel, need SMTP config in Python | + +--- + +*End of Codebase Exploration Report — Session 1* diff --git a/echo/docs/workspaces/designer-brief-v2.md b/echo/docs/workspaces/designer-brief-v2.md new file mode 100644 index 00000000..f50e99c3 --- /dev/null +++ b/echo/docs/workspaces/designer-brief-v2.md @@ -0,0 +1,121 @@ +# Designer brief v2 — follow-ups after v2 review + +**For:** the designer +**From:** Sameer +**Date:** 2026-04-20 +**Ref:** builds on `designer-brief.md` + `designer-return.html` (your v2) + +--- + +## TL;DR + +Your v2 directions are accepted. This addendum lists the clarifications I need **before** we start mid-fi on the new screens, plus a newly scoped pattern (**creation wizards with dry-run preview**) that touches several of the asks you've already solved. + +Questions are grouped by ask. Answer inline in this file or Slack — whatever's faster. + +--- + +## New pattern — Creation wizards with dry-run preview + +Bigger direction change than any single ask. Affects workspace creation and project creation (both currently one-shot forms). We'll extend later to project context setup (not this release). + +**Scope locked (2026-04-20):** full multi-step flow — dedicated route, progress indicator, step-back, reviewable summary before create, cancel at each step. Not a modal, not an inline upgrade to the existing form. + +**The pattern:** +- Step 1 — what to call it +- Step 2 — **access decision with a sensible default and a live "here's what will happen if you proceed" preview** +- Step 3 — review summary (name + access choice + dry-run count again) +- Step 4 — [Create] +- Persistent progress indicator, back button on every step, cancel-with-confirm. + +**Workspace creation wizard** (step 2): +> **Access** +> ◉ Open to the organisation +> ○ Private workspace +> +> *(shown when "Open to the organisation" is selected:)* +> Who on the organisation inherits access? +> ☑ Organisation admins *(always inherit — can't uncheck)* +> ☐ Organisation members *(optional)* + +Private workspace copy: *"Only you will have access. Organisation admins won't be auto-added, even when joining the organisation later."* + +Dry-run line live-updates with the choice: *"3 organisation admins + 7 organisation members will inherit access."* or *"3 organisation admins will inherit access."* or *"Only you. Organisation admins won't inherit."* + +**Show step 2 even for solo organisations** (organisation of 1). Dry-run honestly reads "0 organisation admins will inherit" — don't hide the affordance. + +**Gating:** the *Private workspace* option is innovator+ tier. On lower tiers, the Private radio is disabled with the standard Ask 4 upgrade hint. + +**Project creation wizard** (step 2): +> ◉ Workspace — *12 people in [workspace name] will get access, including 3 inherited organisation admins.* +> ○ Private — *Just you, for now. Share with specific people later.* + +**What I need from you for this pattern:** + +1. **Wizard layout**: full page, modal, or drawer? I'm leaning drawer for workspace creation (lives inside settings context), full page for project creation (fresh mental space). Your call. +2. **Dry-run visual**: is the count a simple sentence, a row of avatars + count, or an expandable "see who" reveal? +3. **Private state iconography**: is there a lock icon treatment that travels from wizard → workspace card → matrix → settings? +4. **Error/empty states**: what does "0 organisation admins will be added" look like (solo organisation creating first workspace)? + +--- + +## Ask 1 — Organisation admin page + +1. **URL naming**: you mocked `/org/:orgId/members`. Our internal code says `org`, our user copy says "organisation". Keep URL as `/org/...` (shorter, matches code) or `/organisation/...` (matches copy)? Low stakes — pick one. +2. **"Invite to organisation" CTA**: I'm assuming this always sets `include_org_membership=true` so the invitee joins the organisation and auto-inherits every **open** workspace. Confirm? (Workspace-scoped invites still live inside each workspace's settings.) +3. **Matrix cell clicks**: what should clicking an empty `—` cell (user not in that workspace) do — open inline invite, or add at default role with a confirm toast? Same Q for the `— removed` cell (sticky-remove rule says don't auto re-add — so does the click prompt "re-add as direct member"?). +4. **Private workspaces in the matrix**: when a workspace is marked private, the column should signal that somehow — lock icon? Greyed cells until explicitly invited? Your call. +5. **Project management on the organisation page — NEW ASK.** Delete-workspace is blocked if the workspace has any non-deleted project (decision locked 2026-04-20). So from the organisation admin page, an owner/admin needs to be able to see every project across every workspace in the organisation and soft-delete any of them — otherwise winding down a partner engagement means walking into 20 workspaces one by one. Pick the right surface: + - **(i)** Third view on the organisation page: **List · Matrix · Projects**. Projects view = flat list of every project, filterable by workspace, row action = delete. Feels consistent with the existing view switcher. + - **(ii)** Click a workspace column header in the Matrix → opens a drawer showing that workspace's projects with delete actions. Contextual, but hides the feature behind a click. + - **(iii)** Separate route `/org/:id/projects` with its own page. Cleanest separation but adds a top-level nav item. + + My lean: **(i)** — reuses the view switcher pattern you already drew and keeps organisation management on one URL. Confirm? + +--- + +## Ask 2 — Tier management + +5. **"Request upgrade" CTA target**: when a organisation admin clicks this, what happens? + - (a) `mailto:` prefilled to a billing inbox + - (b) Python endpoint that emails billing via SendGrid + shows a toast + - (c) External form (Notion / CRM / Typeform) + - (d) No-op for this release + + Cheapest that still captures intent: (b). Need a target inbox address — likely `billing@dembrane.com`. Confirm? +6. **Staff tier dropdown** (Ask 2s): the "Reason (internal)" field — required or optional? I'd say required for audit hygiene, but it's your copy call. + +--- + +## Ask 3 — Private project sharing + +7. **Strip when project is public**: designer note says it reads "Visible to everyone in [workspace] · Make private". Always visible, or hide on public projects to reduce clutter? My preference: always visible — the strip is the tell for privacy state, and hiding it would make public the "invisible default" which is what we're trying to correct. +8. **Share modal — role default**: new person added gets `can read` or `can edit`? I'd say `can read` for safety but check. + +--- + +## Ask 4 — Upgrade prompts + +9. **"Ask an admin" (member-role CTA) — RESOLVED: no CTA.** Member-role users see the 4B/4C gate with copy like *"This feature requires [tier]. Ask one of your organisation admins to upgrade."* No button, no mailto, no admin-name list. Please redraw Ask 4 modal/overlay without the member-path primary CTA — member sees only "Not now" / close affordance, the gate message does the work. Admin-role view unchanged (still has "Request upgrade" primary). + +--- + +## Ask 5 — Selector + settings polish + +10. **Tier aggregation on organisation hero card**: your mock says "pioneer tier (aggregated)". When workspaces in a organisation are on mixed tiers, show: + - (a) the **highest** tier in the organisation + - (b) the **lowest** + - (c) "Mixed" + - (d) the default workspace's tier + + My gut: (c) "Mixed" with a tooltip listing each workspace's tier. + +11. **Private workspace toggle in workspace settings**: new concept that didn't exist in Ask 5. Lives under General, or does "Access" deserve its own tab? + +--- + +## How to answer + +Inline comments on this doc are fine. If a question has a quick visual answer, a sketch image dropped into Slack works. Batch or one-by-one — your call. + +When the wizard pattern is sketched (#1–4), that becomes the "Ask 6" we haven't numbered yet. diff --git a/echo/docs/workspaces/designer-brief.md b/echo/docs/workspaces/designer-brief.md new file mode 100644 index 00000000..c3768db5 --- /dev/null +++ b/echo/docs/workspaces/designer-brief.md @@ -0,0 +1,276 @@ +# Designer brief — Workspaces & Organisations release + +**For:** the designer joining us +**From:** Sameer +**Date:** 2026-04-20 +**Ship target:** end of this week + +--- + +## TL;DR + +We're adding two new layers above projects: **Organizations** (internally called "organisations") and **Workspaces**. The core app you see today — projects, conversations, chats, reports — doesn't change. What changes is the container around it: who can access what, who pays, and how organisations collaborate. + +Most of the plumbing is done. What I need from you is design for the flows that are either missing or currently too barebones to ship. + +--- + +## Before you start — log into the app + +Please spend 30 minutes clicking around before reading further. You'll understand what I'm saying much faster. + +1. Go to the app, register a fresh account +2. You'll land on **Onboarding** — complete it +3. You'll be taken to a **workspace dashboard** (`/w/.../projects`) +4. Click the gear icon → **workspace settings** +5. Click the logo → **workspace selector** (`/select-workspace`) +6. In settings, invite a member → observe the **invite modal** +7. Create a second account, accept the invite → observe **accept invite** flow + +Everything above already works. It's what's *there* that needs polish, plus several screens that don't exist yet. + +--- + +## The mental model (new) + +### Real-world picture + +``` +An organization +├── is the billing entity — one bill, one tier, one set of seat limits +├── contains one or more workspaces +└── has members with org-level roles (owner, admin, member) + +A workspace +├── is a collaboration container — holds projects +├── has its own tier (pilot / pioneer / innovator / changemaker / guardian) +├── inherits org admins/owners as members automatically +└── can have direct members (invited) and external members (from other orgs) + +A project (already exists) +├── lives in a workspace +├── is either workspace-visible or private +└── holds conversations, chats, reports +``` + +### Example + +> **"Human Collective"** is a consultancy (an **organization**). +> They have three workspaces inside: +> - *Default* — their own internal projects +> - *City of Amsterdam* — projects they run for the Amsterdam municipality +> - *Province of Utrecht* — projects they run for Utrecht +> +> Their three consultants are **organisation members** — they automatically see all three workspaces as admins. +> An Amsterdam civil servant has been invited as a **direct member** only to the Amsterdam workspace. She's marked **external** because she's not on the Human Collective organisation. + +### Access inheritance — the rule + +When you're added as a **organisation (org) member** with admin or owner role, you're automatically added to **all current and future workspaces** in that organisation as an inherited member. This is the magic that makes partner consultancies work — adding one person grants access to every client engagement. + +A workspace admin can *remove* an inherited member from their specific workspace. That removal sticks (no re-add). + +--- + +## What's already built (don't redesign) + +These flows exist and work. Style them if needed, but the structure is settled: + +| Screen | Route | State | +|---|---|---| +| Onboarding | `/onboarding` | Works. Asks for org name + optional organisation invites on signup. Slightly brand-thin. | +| Workspace selector | `/select-workspace` | Works. Card grid. Shows workspaces + organisation rollups. **Visually flat, needs hierarchy.** | +| Workspace dashboard | `/w/:id/projects` | Project list. Already shipped. | +| Workspace settings | `/w/:id/settings` | Single page with general info, members, pending invites. **Crammed — needs tabs or better structure.** | +| Invite modal | Inside settings | Works. Email + role. | +| Accept invite | `/invite/accept/...` | Works. HMAC-protected link. | +| Pending invites | `/my-invites` | Works. Accept / decline. | +| User settings | `/settings` | Exists, minimal. | + +Please work *from* these screens, not from a blank page. We don't want a rebuild — we want the next layer of polish plus a few new screens that match. + +--- + +## What I need you to design + +Five asks, in priority order. For each I've noted current state, the job-to-be-done, and key constraints. + +### 1. Organisation (Org) admin page — **the big new surface area** + +**Current state:** doesn't exist. +**Route it will live at:** `/org/:orgId/...` (name TBD — could be `/organisation/:organisationId`) +**Who sees it:** organisation owners and admins + +**Job-to-be-done:** +As an admin of a consultancy, I need one place to see everyone who has access to *any* of our workspaces, and manage their access. I want to answer: +- Who's on my organisation? What's their role? +- Which workspaces can each person access? +- Who's external (invited into one of our workspaces from outside)? +- How do I add someone to the whole organisation at once? + +**Key content:** +- Member list. Each row: name, email (on hover only), organisation role, workspace access (compact indicator — e.g. "3 of 3 workspaces" or a small icon grid) +- A clear split or filter between **organisation members** and **externals** +- Row action: change role, remove, view workspaces they access +- "Invite to organisation" primary action +- Empty state when organisation has just one person: nudge toward inviting + +**Think about:** +- What's the right default view when someone has 50+ members? +- How does the workspace-access column scale? (list? count? matrix?) +- Navigation: how does an admin get here from the workspace selector? + +**Not in scope:** billing tab, usage tab — those are future. + +--- + +### 2. Tier management UI + +**Current state:** tier is stored on each workspace (`pilot / pioneer / innovator / changemaker / guardian`) but there's no UI to change it. Right now I change it in the database manually. + +**Job-to-be-done:** +A organisation admin needs to see what tier each workspace is on, and needs to request an upgrade (or for internal dembrane admins, set it directly). + +**Two views needed:** + +**a. The "which tier am I on" display** +- Should appear somewhere in workspace settings +- Shows current tier + what's included +- Shows a compare table or link to compare tiers +- Primary CTA to upgrade (for now this just opens an email / contact form — no self-serve billing yet) + +**b. Tier set modal (dembrane-internal)** +- Only visible to dembrane staff accounts +- Simple dropdown to change tier +- Confirm dialog because this affects billing + +**Feature matrix to include in the compare view:** + +| Feature | Pioneer | Innovator | Changemaker | Guardian | +|---|---|---|---|---| +| Projects + conversations | ✓ | ✓ | ✓ | ✓ | +| Chats + reports | ✓ | ✓ | ✓ | ✓ | +| Data export | — | ✓ | ✓ | ✓ | +| Private project sharing | — | ✓ | ✓ | ✓ | +| Whitelabel branding | — | — | ✓ | ✓ | +| API access | — | — | ✓ | ✓ | + +--- + +### 3. Private project sharing modal + +**Current state:** the ability to mark a project as private exists in the data model, but there's no UI for it, and no way to share a private project with specific people. + +**Job-to-be-done:** +Sometimes an admin runs a project that shouldn't be visible to the whole workspace (e.g. sensitive stakeholder interviews). They need to mark it private and then hand-pick who can see it. + +**Screens:** +- **Visibility toggle** somewhere on the project settings / header: "Visible to everyone in this workspace" ↔ "Private — only shared people" +- **Share modal** (opens when project is private): + - List of current people with access (name, role: viewer / editor) + - Add person by email (must already be a workspace member — no cross-workspace sharing) + - Remove / change role + +**Gating:** +This whole feature is **Innovator tier and above**. For lower tiers, the visibility toggle should be disabled with a clear upgrade prompt — see ask 4. + +--- + +### 4. Tier upgrade prompts (a component, used in many places) + +**Current state:** when a tier doesn't unlock a feature, the backend blocks it but the frontend often either hides the feature completely or shows nothing useful. + +**Job-to-be-done:** +Show users what they're missing without making them feel blocked. Invite them to upgrade. + +**Component I need:** +- An inline "locked feature" treatment — could be a disabled button with a subtle lock + tooltip, or an overlay on a feature card +- A modal or drawer that opens when they click it, showing: + - The feature name + a one-line benefit + - The minimum tier required + - A "request upgrade" action +- Consistent across: private sharing, data export, whitelabel, API — so we only need to design this once + +--- + +### 5. Workspace selector + settings — polish pass + +**Current state:** both exist and function. Selector is a card grid. Settings is a single scrolling page. + +**What I want:** + +**Selector polish:** +- Stronger visual hierarchy when the user is a organisation admin (organisation-level context at top, workspaces below) +- Clearer treatment for **external** workspaces (where the user is a guest) +- Empty state when user has just one workspace — right now it auto-redirects, but if they somehow land here, the page feels underused +- Search / filter when they have many workspaces (10+) + +**Settings polish:** +- Split into tabs: **General** / **Members** / **Branding** / **Legal** / **Billing** +- "Branding" and "Legal" are mostly empty for now — design the shell, we'll fill content later +- Members tab should feel distinct from the organisation admin page — this is workspace-scoped only + +--- + +## Brand & UI rules you must follow + +From our style guide (`brand/STYLE_GUIDE.md`) — these are non-negotiable: + +- **"dembrane" is always lowercase**, even at the start of a sentence +- **Never say "AI"** — say "language model" or describe the action directly +- **Never say "successfully"** — just state what happened ("Saved", not "Successfully saved") +- **Say "participants and hosts"** not "users"; **"partners and clients"** not "customers" +- **Never use bold for emphasis** — use our Royal Blue (`#4169e1`) or italics +- **Don't stack multiple alert banners** — show one at a time +- **Primary font + spacing** — follow what's already in the app; we use Mantine +- **Logos / loading spinners** — there's a whitelabel system; use the `alwaysDembrane` variant only where the dembrane brand itself is appropriate (login, billing, organisation admin). Use the workspace's branded logo everywhere inside a workspace. + +Tone: warm but not gushing. Direct but not cold. Think IKEA meets Patagonia. We're a trusted colleague, not a corporate announcement. + +--- + +## How we'll work together + +- **File format:** Figma is fine. Share the link, I'll comment inline. +- **Review cadence:** I'll give same-day feedback on anything you share before 4pm. Past that, next morning. +- **Scope discipline:** if something feels like it needs more than what's here, flag it rather than building it — we're shipping this week. +- **Questions:** I'd rather get a Slack message with a half-formed question than a polished mock that solves the wrong problem. Ask early. + +--- + +## Week plan (so you know where your inputs are needed) + +| Day | You | Me | +|---|---|---| +| Mon | Explore the app, read this brief, ask questions | Build migration script + org API | +| Tue | Deliver wires for **organisation admin page** (#1) | Build org endpoints, review your wires | +| Wed | Deliver **tier management** + **project sharing** (#2, #3) | Implement organisation admin page frontend | +| Thu | Deliver **upgrade prompts** + **selector/settings polish** (#4, #5) | Build tier UI + sharing modal | +| Fri | Final review, polish pass on anything I implemented | Bug bash, migration dry-run | + +--- + +## Questions I expect you'll have + +**"Why 'organisations' and not 'organizations'?"** +Internally we call it `org` in the code and schema. Externally, "organisation" is warmer and clearer — most of our users don't run formal organizations. Use "organisation" in all user-facing copy. + +**"What happens when someone has access to 20 workspaces?"** +They see a searchable list view on the selector. We've got a visual treatment partly built but it's barebones — include this in ask #5. + +**"What about mobile?"** +Desktop first. The existing app is desktop-first. If you have strong opinions about mobile patterns for any of these screens, flag them but don't invest time. + +**"Do I need to design the migration modal for existing users?"** +Not this week. The migration runs silently; if we need a welcome modal after, we'll design it next sprint. + +--- + +## Reference material + +All in this repo if you have access, or I can send over: +- `/workspaces/echo/docs/workspaces/workspaces-prd-v3-final.md` — full PRD (long, skim the "Data Model" and "Edge Cases" sections) +- `/workspaces/echo/brand/STYLE_GUIDE.md` — voice, tone, color, vocabulary +- The app itself at [production URL] or [staging URL] + +That's it. Welcome aboard — excited to work with you on this. diff --git a/echo/docs/workspaces/designer-return.html b/echo/docs/workspaces/designer-return.html new file mode 100644 index 00000000..c3f18004 --- /dev/null +++ b/echo/docs/workspaces/designer-return.html @@ -0,0 +1,742 @@ + + + + + +Workspaces & Organisations — v2 chosen directions + + + + + + +
+ + +
+
+
v2 · 2026-04-20
+

Chosen directions, with your notes applied.

+

+ Each section below starts with a change note spelling out what moved from v1. Ask 1 combines 1B and 1C as + a view switcher. Ask 3 adds a persistent "Shared with" strip on the project overview. Ask 4 shows the + tier + role gate. Ask 5 surfaces per-workspace manage. Onboarding gets a real fix. +

+
+ Tweaks are preserved from v1 — flip tier=pilot to see gating (ask 4), role=staff for the set-tier + modal, density for scale. +
+
+ + +
+
ask 01

Organisation admin · list ⇄ matrix

+
+ Same page, two views. 1B (list) is the default — friendly, readable, search-forward. 1C (matrix) is a + one-click alternate for admins who want the full access grid. +
+
+ v2: combined 1B + 1C as a view switcher on the same URL. Shared filters/search. + Row actions & invite flow identical across views. External members get the pill + their own row group. +
+ +
+

1.Organisation admin page

+

/org/:orgId/members — owners & admins. Toggle view with the switcher top-right.

+
+
+
human collective › organisation · members
+
🔍 name, email+ Invite to organisation
+
+ +
+
+
Everyone with access · 12
+
9 on the organisation · 3 external · 3 workspaces
+
+
+ + +
+
+ + +
+
+ AllOrganisationExternal + sort: recent · role ▾ +
+ +
Organisation · inherited to all workspaces
+
+
+ owner + DefaultA'damUtr + +
+
+
+
ownerDefA'damUtr
+
adminDefA'damUtr
+
adminDefA'damUtr
+
+
+
owner+3
+
admin+3
+
admin+2
+
member+1
+
member+1
+
…44 more organisation members
+
+ +
External guest
+
externalviewerA'dam
+
externaleditorUtr
+
⋯ row menu: change role · view workspaces · remove.
+
+ + +
+
group: organisationclick a cell to change access
+
+
 
+
Default
+
A'dam
+
Utrecht
+ +
owner
+
admin
+
admin
+
admin
+ +
admin
+
admin
+
admin
+
admin
+ +
admin
+
admin
+
— removed
+
admin
+ +
ext
+
+
viewer
+
+ +
ext
+
+
+
editor
+
+
blue row = inherited. "— removed" reflects the sticky-remove rule. empty cell on external rows = not invited.
+
+
+
+ +
+ mobile +
List view collapses to stacked cards. Matrix view is hidden ≤ 768px with a "switch to list on mobile" toast.
+
+
+ + +
+
ask 02

Tier management · 2B

+
Feature matrix in the Billing tab. Current tier column highlighted. Staff see the extra set-tier control inline.
+
v2: went with 2B per Notion doc + PRD. Staff controls live inline (no separate modal in the first pass — confirm dialog still fires on change).
+ +
+
+

2.Compare + current tier

+

/w/:id/settings?tab=billing — visible to everyone in the workspace.

+
+
A'dam · settings › billing
+
+
+
Current tier
Renews · contact to change
+
innovator
+
+
+
Compare
+
+
+
pioneer
+
innovator
you
+
changemaker
+
guardian
+ +
Projects + convs
+
+ +
Chats + reports
+
+ +
Data export
+
+ +
Private sharing
+
+ +
Whitelabel
+
+ +
API access
+
+
+
+ Pricing discussed over email. + Request upgrade +
+
+
+ +
+

2s.Staff · set tier inline

+

Shown only when role = dembrane staff. Same tab — no hidden route.

+
+
A'dam · settings › billing
staff
+
+
dembrane staff controls
+
+ Set tier + innovator ▾ +
+
+ Reason (internal) + +
+
confirm dialog fires on change: "Set A'dam to changemaker?"
+
+
All other content (compare, request upgrade) stays visible — no separate staff route.
+
+
+
+ +
+ mobile +
Matrix becomes horizontal scroll with sticky feature column on ≤ 640px.
+
+
+ + +
+
ask 03

Private sharing · 3B modal + overview strip

+
The modal from 3B, plus a quiet "Shared with" strip on the project overview so it's always visible without opening the modal.
+
v2: added a persistent "Shared with" strip to the project overview page. The modal stays for editing; the strip is always-on presence. Gated at innovator+.
+ +
+ +
+

3.1Project overview · persistent strip

+

Lives under the project header, above content. Click → opens the modal (3.2).

+
+
project · Stakeholder interviews
Share
+
Stakeholder interviews
+
Last activity · 2 days ago
+ + +
+
+
+ Private + Shared with + + + 2 more +
+ Manage → +
+
+ +
+
project body · conversations · reports…
+
+
+
+
+
when public: strip reads "Visible to everyone in A'dam · Make private".
+
+
+ + +
+

3.2"Who can see this?" modal

+

Opens from the strip or from project settings. Verb labels, not nouns.

+
+
Who can see this project?
×
+
Stakeholder interviews · Private
+
you · can edit
+
can edit ▾
+
can read ▾
+
+
+ + can read ▾ +
+
Only people already in A'dam — no cross-workspace.
+
+
+
Everyone else in A'damno access
+
Link sharing offDone
+
+
+
+ +
mobile
Strip collapses to a single line with the pill + "Manage". Modal becomes a full sheet.
+
+ + +
+
ask 04

Upgrade prompts · 4B (feature card) + 4C (modal)

+
4B gates whole feature surfaces. 4C is the modal that opens from any gate. Both check tier and role — only organisation admins + owners see "Request upgrade" as primary; members get a softer "Ask an admin" CTA.
+
v2: added the tier + role gate. Non-admins see "Ask an admin" instead of "Request upgrade". Try flipping role=member + tier=pilot in Tweaks.
+ +
+
+

4.1Feature card · hatched overlay (4B)

+

For whole feature surfaces: Whitelabel, API, Data export. CTA changes with role.

+
+
settings · branding
+
+
+
Whitelabel branding
+
Replace the dembrane logo with your own across the workspace.
+
+
+
+
+
+
+ changemaker tier +
Bring your own brand once you're on changemaker.
+ Request upgrade + + +
only owners + admins see this CTA
+
+
+
+
when role=member: the primary button swaps to "Ask an admin" (ghost style) — same modal opens.
+
+ +
+

4.2Upgrade modal (4C)

+

Opens from any gate. One feature, one benefit, one tier, one CTA.

+
+
Private project sharing
×
+
Keep sensitive projects visible to a hand-picked few.
+
+
Your tierpioneer
+
Neededinnovator
+
+
We'll email you about pricing and next steps.
+ +
+ Not now + Request upgrade +
+
copy swaps on role: admin → "Request upgrade" · member → "Notify your admin".
+
+
+
+ +
mobile
4B overlay stays the same; card becomes the tap target. 4C is a full sheet.
+
+ + +
+
ask 05

Selector + settings polish · 5B with per-workspace manage

+
5B's organisation header on top, workspaces as a list — but with a per-row "manage" affordance so admins can jump straight into workspace settings without opening it first.
+
v2: added a hover "⚙ manage" affordance on each workspace row, plus the organisation-level "manage organisation" as before. Externals stay in their own quieter section. Settings tabs view (5C) kept for reference.
+ +
+
+

5.1Workspace selector

+

/select-workspace — organisation hero on top, workspaces below, externals quieter.

+
+
choose a workspace
🔍
+ +
+
+
+
Human Collective
+
3 workspaces · 12 people · pioneer tier (aggregated)
+
+
+ + +9 + Manage organisation +
+
+
+ +
+ 🔍 namesort: recent +
+ +
+ + Default + pioneer · 4 projects + ⚙ manage +
+
+ + A'dam + innovator · 12 projects + ⚙ manage +
+
+ + Utrecht + pioneer · 6 projects + ⚙ manage +
+ +
External
+
+ + Pilot 2026 + guest of GreenLabs + viewer +
+
⚙ manage shows on row hover / keyboard focus — keeps selector calm.
+
+
+ +
+

5.2Workspace settings · tabs (reference)

+

Already exists on the workspaces branch — just confirming labels + the inherited-pill.

+
+
A'dam · settings
+
+ General + Members + Branding + Legal + Billing +
+
+ Workspace-scoped. Organisation admins are inherited. + + Invite +
+
admin · inherited
+
editor
+
external · viewer
+
× on inherited row = sticky remove (matches the data-model rule).
+
+
+
+ +
mobile
Organisation hero collapses to a single-row strip; ⚙ manage on each workspace moves to a row kebab menu.
+
+ + +
+
fix

Onboarding — new users shouldn't see the migration screen

+
+ The current Welcome back, John screen is a migration prompt — it assumes you already have projects from before workspaces existed. + A freshly-registered account has nothing to migrate, so this copy is confusing and the "Use default" / "Organisation name" step is jarring. +
+
v2: split into two explicit paths. New user → ask organisation name during registration, then land in their empty default workspace. Existing user (no organisation yet) → current screen, re-copied as a migration.
+ +
+
+

ANew user · organisation name at signup

+

One extra field on the signup form. No separate onboarding route.

+
+
sign up
+
Create your account
+
Takes 30 seconds.
+
Email
+
Password
+
Your name
+
+
Organisation name · optional
+
+
If you're joining members later, skip this — they'll invite you in.
+
+
By signing up…Create account
+
on submit: auto-create organisation (fallback "John's organisation"), default workspace, land in /w/:id/projects empty state.
+
+
+ +
+

BExisting user · migration only

+

The current screen, re-copied to be honest about what it is. Only shown when user has pre-workspaces projects.

+
+
welcome back
+
Welcome back, john
+
We've added organisations so you can organise projects and share them with colleagues. Everything you had before is still here — we just need a name for your organisation.
+
+
Organisation name
+
+
Change this anytime in settings.
+
+
Use defaultGet started
+
gate: only shown if user.createdAt < workspaces-launch AND has no organisation yet. everyone else goes straight in.
+
+
+
+ +
+ the rule: the current screen is the migration screen. New accounts should never see it — they should land in an empty default workspace after signup, with organisation name already captured. Detection is trivial: if the user has no organisation membership AND their account predates the launch timestamp, show B; otherwise skip. +
+ +
mobile
Both screens are identical on mobile — single column, CTAs stack full-width.
+
+ +
+
next
+

+ When you're happy with these, I'll push the organisation admin page (ask 1) to mid-fi using Mantine — that's the heaviest surface. Rest can follow in parallel. +

+
+
+
+ + + + + + diff --git a/echo/docs/workspaces/execution-plan-final.md b/echo/docs/workspaces/execution-plan-final.md new file mode 100644 index 00000000..72b59af9 --- /dev/null +++ b/echo/docs/workspaces/execution-plan-final.md @@ -0,0 +1,593 @@ +# Execution Plan: Workspaces Implementation +## Refined for Solo Developer + Claude Code + +--- + +## Engineering Principles + +### What we follow + +**Strangler Fig Pattern** — Don't rewrite. Wrap. The workspace layer grows around existing functionality. At no point does the existing system stop working. + +**Expand and Contract** — First, add new fields and tables (expand). Then, migrate data. Then, update code to use new paths. Finally, deprecate old paths (contract). Never do these in the same commit. + +**Make the Change Easy, Then Make the Easy Change** (Kent Beck) — If a change is hard, first refactor to make it easy (that's a separate commit), THEN make the change. + +**One Commit, One Concern** — Each commit does exactly one thing. "Add deleted_at field to conversation" is one commit. "Convert conversation delete to soft delete" is another commit. Never both. + +**Reversibility** — Every commit should be revertible without data loss. Schema additions (new fields, new tables) are safe. Schema modifications (changing types, renaming) are risky. Data mutations (migration) need a backup. + +**The Campsite Rule** — Leave the code better than you found it, but don't refactor unrelated things in a workspace commit. Stay focused. + +### What we DON'T do + +**No Big Bang.** We don't build everything and then flip a switch. Each commit is deployable. + +**No Premature Abstraction.** Don't build a "generic soft delete framework." Just convert each delete one at a time. The pattern will emerge. + +**No Future-Proofing Beyond One Step.** `app_user` is one step of future-proofing (we know auth migration is coming). Don't add fields "just in case." Add them when needed. + +**No Gold Plating.** The usage dashboard doesn't need charts in the first pass. A JSON response from the API is enough. The UI comes later. + +**No Mixing Concerns.** A session about schema doesn't touch Python routes. A session about API doesn't touch frontend. Separation is discipline. + +--- + +## Session Plan + +Four sessions. Each produces commits on the `workspaces` branch. + +``` +Session 1: EXPLORE (no code changes) + ↓ +Session 2: SCHEMA (Directus collections + soft delete fields) + ↓ +Session 3: SOFT DELETE CONVERSION (reroute deletes through Python) + ↓ +Session 4: CORE API (workspace/org endpoints + migration script) +``` + +Frontend is a separate effort AFTER backend is solid. Not covered here. + +--- + +## Session 1: EXPLORE + +**Goal:** Understand the codebase. Reconcile PRD with reality. Produce updated PRD. +**Output:** `codebase-exploration-report.md` + `workspaces-prd-v4.md` (reconciled) +**Commits:** None. Read-only session. +**Duration estimate:** 1 session + +### What the agent does + +1. Map every Directus collection (especially `directus_users` — ALL custom fields) +2. Map every Python API route (method, path, what it does, auth pattern) +3. Map every frontend route (for awareness, not modification) +4. Inventory every delete operation in the codebase +5. Map the auth flow (cookie → Directus → Python) +6. Map the Directus client/wrapper used by Python +7. Check: what fields does `directus_users` have that `app_user` is missing? +8. Check: what Directus roles exist and what permissions are configured? +9. Check: does the project collection already have any sharing/organisation fields? +10. Check: is there an existing `deleted_at` or archive pattern anywhere? + +Then reconcile with PRD and produce updated version. + +### Prompt for Claude Code + +``` +Read the entire codebase. Do not make any changes. + +I'm planning a major feature: adding Organizations and Workspaces to this platform. +Before I start, I need to understand exactly what exists. + +Produce a report (`codebase-exploration-report.md`) covering: + +1. DIRECTUS SCHEMA + For every collection in the Directus schema (check the schema sync files + or query the Directus API): + - Collection name, all fields with types + - Relations to other collections + - Pay SPECIAL attention to directus_users — list EVERY field including custom ones + - Pay SPECIAL attention to project — list EVERY field + +2. PYTHON API ROUTES + For every route in the FastAPI app: + - Method + path + what it does (1 line) + - How it authenticates (admin token vs user cookie) + - What Directus collections it reads/writes + - Does it delete anything? If so, what collection and how? + +3. DELETE INVENTORY + Every place data is deleted, anywhere in the codebase: + - File path + line number (or function name) + - What collection + - Frontend→Directus direct, Frontend→Python, or Python→Directus? + - What metadata would be lost if this row is deleted? + (e.g., conversation delete loses duration_seconds) + +4. AUTH FLOW + - How does login work end-to-end? + - What does the Python API's "get current user" function look like? + - What user fields does it return? + - How is the Directus client configured in Python? (wrapper class? raw fetch?) + +5. DIRECTUS CLIENT PATTERN + - How does Python call Directus? Show the actual code pattern. + - Is there a wrapper/helper class? + - How are filters constructed? + - How is error handling done? + +6. EXISTING PATTERNS + - Any existing multi-user, sharing, or organisation concepts? + - Any existing soft delete or archive patterns? + - How is the Directus schema synced (show the config/setup)? + +After producing the report, read the attached PRD (workspaces-prd-v3-final.md) and +produce a reconciliation section at the end of the report noting: + - Fields missing from app_user that directus_users has + - Any collection names that don't match PRD assumptions + - Any API patterns that don't match PRD assumptions + - Any conflicts or surprises +``` + +**Attach:** `workspaces-prd-v3-final.md` + +### Success criteria +- [ ] Can answer: "What exact fields does directus_users have?" +- [ ] Can answer: "What are all the delete operations and what metadata do they lose?" +- [ ] Can answer: "What's the exact pattern for calling Directus from Python?" +- [ ] PRD discrepancies identified with specific corrections + +--- + +## Session 2: SCHEMA + +**Goal:** Create all new Directus collections. Add soft delete fields to existing collections. +**Output:** Series of commits, each adding one collection or modifying one existing collection. +**Duration estimate:** 1 session + +### Commit sequence + +``` +commit 1: "chore: create app_user collection" + - Create app_user in Directus with ALL fields from reconciliation + - Include directus_user_id as unique FK + - Sync schema via directus-extension-sync + +commit 2: "chore: create org and org_membership collections" + - org: id, name, slug, logo_url, created_by→app_user, deleted_at, timestamps + - org_membership: id, org_id→org, user_id→app_user, role, deleted_at, timestamps + - Sync schema + +commit 3: "chore: create workspace and workspace_membership collections" + - workspace: id, org_id→org, name, slug, tier, billed_to→workspace, + is_default, legal_basis, privacy_policy_url, logo_url, settings, + deleted_at, created_by→app_user, timestamps + - workspace_membership: id, workspace_id→workspace, user_id→app_user, + role, source, is_external, deleted_at, timestamps + - Sync schema + +commit 4: "chore: create workspace_invite and project_user collections" + - workspace_invite: id, workspace_id→workspace, email, role, + invited_by→app_user, token, expires_at, accepted_at, timestamps + - project_user: id, project_id→project, user_id→app_user, role, + granted_by→app_user, timestamps + - Sync schema + +commit 5: "chore: create usage_event collection" + - id, trace_id, org_id, workspace_id, project_id, user_id, + event_type, event_data (json), created_at + - No deleted_at (append-only, never deleted) + - Sync schema + +commit 6: "chore: add workspace_id and visibility fields to project" + - workspace_id: M2O → workspace, nullable + - visibility: string, default 'workspace' + - Sync schema + +commit 7: "chore: add deleted_at to existing collections" + - Add deleted_at (timestamp, nullable) to: + conversation, project, chat (whatever it's called), report + - Sync schema +``` + +### Prompt for Claude Code + +``` +We're on branch `workspaces`. Make the following changes as SEPARATE COMMITS. +Each commit should ONLY contain schema changes synced via directus-extension-sync. +Do NOT modify any Python code or frontend code in this session. + +Use the Directus admin API or whatever method this project uses to create collections +(check the codebase exploration report for the exact pattern). + +After each collection is created, sync the schema using the project's +directus-extension-sync setup. + +[Include commit sequence from above] + +Use the codebase exploration report to: +- Match the exact Directus field types used in this project +- Match the exact relation configuration pattern +- Match the schema sync workflow +- Include ALL app_user fields identified in the reconciliation + (not just the ones in the original PRD) + +IMPORTANT: This session is schema-only. No Python code. No frontend code. +No migration logic. Just Directus collections and schema sync. +``` + +**Attach:** `codebase-exploration-report.md` + `workspaces-prd-v4.md` + +### Success criteria +- [ ] All 7 commits on branch +- [ ] Schema synced after each commit +- [ ] Can CRUD each new collection via Directus API +- [ ] project.workspace_id exists and is nullable +- [ ] deleted_at exists on all required collections + +--- + +## Session 3: SOFT DELETE CONVERSION + +**Goal:** Convert all delete operations to soft deletes. Route everything through Python. +**Output:** Series of commits, one per delete operation converted. +**Duration estimate:** 1 session + +### Commit sequence (order determined by exploration report) + +``` +commit 1: "feat: add emit_usage_event utility" + - Create utility function in Python API + - Fire-and-forget, never fails parent operation + - Posts to usage_event collection via Directus API + - Always includes "v": 1 in event_data + +commit 2: "refactor: convert conversation delete to soft delete" + - Create/update Python endpoint: DELETE /api/v1/conversations/:id + - Soft delete: PATCH deleted_at via Directus API + - Emit usage event: conversation.deleted with duration_seconds snapshot + - Update frontend to call Python API (not Directus direct) + - Purge audio file if applicable + +commit 3: "refactor: convert project delete to soft delete" + - Same pattern + - Emit: project.deleted with conversation_count, total_audio_hours + +commit 4: "refactor: convert chat delete to soft delete" + - Same pattern + - Emit: chat.deleted + +commit 5: "refactor: convert report delete to soft delete" + - Same pattern + - Emit: report.deleted + +commit N: (one commit per remaining delete operation found in exploration) + +commit N+1: "refactor: add deleted_at filter to all read queries" + - Audit every Directus read call in Python API + - Add filter: { "deleted_at": { "_null": true } } + - Audit every Directus read call in frontend (if any) + - Add same filter + +commit N+2: "test: verify soft deletes work end-to-end" + - Test each delete: item disappears from reads, usage event emitted + - Document in commit message what was tested +``` + +### Prompt for Claude Code + +``` +We're on branch `workspaces`, continuing from the schema commits. + +TASK: Convert all delete operations in the codebase to soft deletes. + +STEP 1: Create the emit_usage_event utility (see PRD for implementation). + Put it wherever utility functions live in this codebase. + +STEP 2: For each delete operation listed in the codebase exploration report, + create a SEPARATE COMMIT that: + a) Creates or updates a Python API endpoint for the delete + b) Changes it from hard DELETE to PATCH { deleted_at: now() } + c) Emits a usage_event with metadata snapshot BEFORE soft deleting + (so we capture duration_seconds, counts, etc.) + d) Updates the frontend to call the Python endpoint + (if it was previously calling Directus directly) + e) Handles any binary file cleanup (audio files can be hard-deleted) + +STEP 3: Create ONE commit that adds { "deleted_at": { "_null": true } } filter + to ALL existing read queries across the codebase. + +METADATA TO SNAPSHOT (per collection): +- conversation: duration_seconds, audio_hours (duration/3600), project_id +- project: conversation_count, total_audio_hours (sum of conversations) +- chat: message_count, project_id +- report: project_id + +PATTERN: + async def delete_conversation(conversation_id, current_user): + conv = await directus.get(f"/items/conversation/{conversation_id}") + project = await directus.get(f"/items/project/{conv['project_id']}") + + await emit_usage_event( + "conversation.deleted", + {"v": 1, "duration_seconds": conv.get("duration_seconds", 0), ...}, + workspace_id=project.get("workspace_id"), + user_id=current_user.id, + ) + + await directus.patch(f"/items/conversation/{conversation_id}", { + "deleted_at": datetime.utcnow().isoformat() + }) + +Follow the EXACT Directus client pattern from this codebase. +Each commit = one delete operation converted. Atomic and reviewable. +``` + +**Attach:** `codebase-exploration-report.md` + `workspaces-prd-v4.md` + +### Success criteria +- [ ] Every delete operation is soft +- [ ] Every soft delete emits a usage event with billing metadata +- [ ] All read queries filter deleted_at IS NULL +- [ ] No frontend→Directus direct deletes remain +- [ ] Deleted items don't appear in any UI + +--- + +## Session 4: CORE API + MIGRATION + +**Goal:** Implement workspace/org API endpoints and the migration script. +**Output:** Series of commits building up the API surface. +**Duration estimate:** 1-2 sessions (this is the biggest one) + +### Commit sequence + +``` +FOUNDATION: +commit 1: "feat: add get_workspace_context middleware" + - FastAPI dependency that validates workspace access + - Returns WorkspaceContext with workspace_id, user, role + - 403 if no access + +commit 2: "feat: add permission resolution helpers" + - get_user_project_access() + - get_user_accessible_workspaces() + - Uses Directus API calls, returns resolved access objects + +WORKSPACE CRUD: +commit 3: "feat: GET /api/v1/workspaces — list accessible workspaces" + - Used by workspace selector + - Returns workspaces with role, source, counts, is_external + +commit 4: "feat: POST /api/v1/workspaces — create workspace" + - Creates workspace in user's org + - Auto-adds creator as owner + - Auto-adds org admins as inherited members + - Emits workspace.created + workspace.member_added events + +commit 5: "feat: GET/PATCH/DELETE workspace endpoints" + - Detail, update, soft delete + - Delete blocked if workspace has projects + +MEMBERSHIP: +commit 6: "feat: workspace membership CRUD" + - List (includes inherited org admins in separate section) + - Invite by email (existing user → immediate, new user → invite) + - Change role + - Remove (soft delete) + - Emits member_added / member_removed events + +commit 7: "feat: workspace invite flow" + - Create invite with secure token + - Send email via SendGrid + - Accept endpoint (validates token, creates membership) + - Post-registration hook: check for pending invites + +ORG: +commit 8: "feat: org CRUD + membership endpoints" + - List user's orgs + - Update org (name, logo) + - Org member management + - Role changes propagate inherited workspace memberships + +USAGE: +commit 9: "feat: usage summary endpoint" + - GET /api/v1/workspaces/:id/usage + - Aggregates from usage_event table + - Per-project breakdown + +commit 10: "feat: org billing rollup endpoint" + - GET /api/v1/orgs/:id/billing + - Sums across all org workspaces + +MIGRATION: +commit 11: "feat: migration script — create orgs and workspaces for existing users" + - Creates app_user for each directus_user + - Creates org + workspace + memberships + - Moves projects into default workspace + - Dry-run mode, idempotent, per-user error handling + - Progress logging + +commit 12: "feat: post-registration hook — auto-create org + workspace" + - New users get org + workspace on signup + - Called from Directus hook or Python endpoint after user creation + +PROJECT SHARING: +commit 13: "feat: project sharing endpoints (private projects)" + - project_user CRUD + - Tier-gated: innovator+ + +ADMIN: +commit 14: "feat: admin usage endpoint + tier management" + - Cross-org usage for manual invoicing + - Set workspace tier manually +``` + +### Prompt for Claude Code + +``` +We're on branch `workspaces`, continuing from soft delete commits. + +TASK: Implement the workspace and org API endpoints. One commit per endpoint group. + +CRITICAL RULES: +1. Follow the EXACT Directus client pattern from this codebase + (use the same wrapper/helper class for all Directus API calls) +2. Follow the EXACT auth pattern from this codebase + (match how existing endpoints get the current user) +3. Every workspace-scoped endpoint MUST use get_workspace_context dependency +4. Every mutation MUST emit a usage_event +5. Every read MUST filter deleted_at IS NULL +6. Match the existing code style (naming conventions, file organization, etc.) + +API RESPONSE SHAPES: See PRD for exact response JSON structures. + +PERMISSION MODEL: +- Org owner/admin → admin access to all org workspaces (via inherited membership rows) +- Workspace owner/admin/member/viewer → see workspace role policies in PRD +- Private projects → only creator + workspace admin + project_user entries + +MIGRATION SCRIPT: +- Must have --dry-run flag +- Must be idempotent (safe to re-run) +- Must handle per-user errors without stopping +- Must log progress +- Must create app_user with ALL fields from directus_users +- Test on local devcontainer before production + +Start with commits 1-2 (middleware + permission helpers) as they're used by everything else. +``` + +**Attach:** `codebase-exploration-report.md` + `workspaces-prd-v4.md` + +### Success criteria +- [ ] All endpoints return correct response shapes +- [ ] Permission checks work for all role combinations +- [ ] Invite flow works end-to-end (create → email → accept) +- [ ] Migration script works in dry-run mode +- [ ] Migration script works on local devcontainer +- [ ] Usage events emitted for all mutations +- [ ] Org inheritance creates/removes workspace memberships correctly + +--- + +## Anti-Patterns to Watch For + +### During Claude Code sessions + +| Anti-pattern | What it looks like | What to do instead | +|---|---|---| +| **Boiling the ocean** | Agent tries to do everything in one commit | Stop it. One commit, one concern. | +| **Inventing patterns** | Agent creates a new utility/framework not in the codebase | Ask: "How does the existing code do this?" | +| **Ignoring existing code** | Agent writes a fresh Directus client wrapper | Use the existing one. | +| **Optimistic testing** | "I've implemented the endpoints" but didn't test | Ask: "Call the endpoint. Show me the response." | +| **Silent failures** | emit_usage_event throws but nobody notices | Check: is there error logging? | +| **Schema drift** | Agent creates fields via API but doesn't sync schema | Every schema change → directus-extension-sync | +| **God commits** | One commit with 20 files changed | Break it up. | + +### During your review + +| Red flag | What it means | +|---|---| +| Agent modified files outside the feature scope | Scope creep. Revert. | +| Agent introduced a new dependency (pip/npm package) | Question it. Is it necessary? | +| Agent used raw SQL instead of Directus API | Wrong pattern. Rewrite. | +| Commit message is vague ("update files") | Rewrite the commit message. | +| No deleted_at filter on a new read query | Bug. Fix before merging. | +| Usage event has no "v" field in event_data | Fix. This will save you later. | + +--- + +## Pre-Flight Checklist (Before Session 1) + +- [ ] Create `workspaces` branch from main +- [ ] Verify local devcontainer is running and healthy +- [ ] Verify Directus admin access works locally +- [ ] Verify Python API starts and serves requests locally +- [ ] Verify directus-extension-sync works (pull current schema) +- [ ] Take a snapshot/backup of the local DB (for safe migration testing) +- [ ] Have the PRD v3 final ready to attach to Claude Code +- [ ] Have this execution plan ready to reference + +--- + +## Post-Session Verification (After Each Session) + +### After Session 2 (Schema) +```bash +# Verify collections exist +curl http://localhost:8055/items/app_user # Should return empty array +curl http://localhost:8055/items/org # Should return empty array +curl http://localhost:8055/items/workspace # Should return empty array +curl http://localhost:8055/items/usage_event # Should return empty array + +# Verify project has new fields +curl http://localhost:8055/items/project?limit=1&fields=id,workspace_id,visibility + +# Verify deleted_at on existing collections +curl http://localhost:8055/items/conversation?limit=1&fields=id,deleted_at +``` + +### After Session 3 (Soft Delete) +```bash +# Create a test conversation, then delete it +# Verify: conversation has deleted_at set (not hard deleted) +# Verify: usage_event created with duration_seconds +# Verify: conversation doesn't appear in normal queries +``` + +### After Session 4 (API) +```bash +# Run migration in dry-run +python migration.py --dry-run + +# Run migration for real (on local DB) +python migration.py + +# Verify: every user has app_user + org + workspace +curl http://localhost:8000/api/v1/workspaces \ + -H "Cookie: " +# Should return workspaces + +# Create a new workspace +curl -X POST http://localhost:8000/api/v1/workspaces \ + -H "Cookie: " \ + -H "Content-Type: application/json" \ + -d '{"name": "Test Client"}' +# Should return workspace with inherited memberships +``` + +--- + +## The Moment of Truth: Session 1 Prompt + +When you're ready, open Claude Code with the repo loaded and paste this: + +``` +I'm implementing a Workspaces & Organizations feature for this platform. + +FIRST: Read the attached PRD (workspaces-prd-v3-final.md) to understand what we're building. + +THEN: Explore this codebase thoroughly and produce a report (save as +docs/codebase-exploration-report.md) that maps: + +1. Every Directus collection with ALL fields (especially directus_users and project) +2. Every Python API route with auth pattern and Directus calls +3. Every delete operation (file, collection, method, what metadata is lost) +4. The exact auth flow and Directus client pattern +5. The schema sync setup (directus-extension-sync config) + +FINALLY: Compare the codebase against the PRD and note: +- What fields does directus_users have that PRD's app_user is missing? +- What collection names or patterns in the PRD don't match reality? +- Any existing concepts that overlap with workspaces (sharing, organisations, etc.)? +- What's the exact code pattern for calling Directus from Python? + +Save the reconciliation notes at the end of the report. + +DO NOT make any code changes. This is a read-only exploration session. +``` + +Attach: `workspaces-prd-v3-final.md` diff --git a/echo/docs/workspaces/failure-analysis.md b/echo/docs/workspaces/failure-analysis.md new file mode 100644 index 00000000..0e709641 --- /dev/null +++ b/echo/docs/workspaces/failure-analysis.md @@ -0,0 +1,391 @@ +# Workspaces PRD — Multi-Perspective Failure Analysis +## "Red Team" Review from 8 Specialist Perspectives + +--- + +## Agent 1: Security Engineer + +### How this fails + +**Privilege escalation via org inheritance.** The "org owner/admin gets admin on all workspaces" rule is computed at query time. If someone compromises an org owner account, they instantly have admin access to every client workspace. That's not one customer breached — it's ALL their clients breached simultaneously. For a consultancy with 20 client workspaces, one phished password = 20 data breaches. + +**Mitigation:** Add an explicit "require re-authentication for workspace switching" option that high-security clients can enable. At minimum, log workspace switches as security events with IP/device fingerprint. Consider making org-inherited access opt-out per workspace (client workspace admin can say "I don't want org admins auto-inheriting into my workspace"). + +**Invite token brute force.** Workspace invite tokens are the keys to the kingdom. If they're UUIDs (36 chars, 122 bits of entropy), they're fine. If they're short tokens for "nice URLs," they're brute-forceable. + +**Mitigation:** Use `secrets.token_urlsafe(32)` (256 bits). Rate-limit invite acceptance endpoint. Expire aggressively (7 days, single-use). + +**Cross-tenant data leakage via usage_event.** The usage_event table contains org_id, workspace_id, project_id, user_id. If the admin usage endpoint (`/api/v1/admin/usage`) has an authz bug, it exposes the entire activity graph of all customers. An attacker learns: which orgs exist, how many projects they have, when they're active, who's in which workspace. + +**Mitigation:** The admin endpoints should require a separate authentication mechanism (not just "is Directus admin"). Consider a separate admin API key or require 2FA verification for admin endpoints. + +**Exported usage data as intelligence.** Even legitimate access to usage data reveals competitive intelligence. If a partner can see their client's exact chat query counts, they can infer how dependent the client is on Dembrane (leverage in handoff negotiations). + +**Mitigation:** Decide what usage data partners should vs. shouldn't see about client workspaces they manage. The current design shows everything. + +### Recommendations +- [ ] Invite tokens: `secrets.token_urlsafe(32)`, single-use, 7-day expiry +- [ ] Log all workspace switches as security events +- [ ] Rate-limit invite acceptance: 5 attempts per token per hour +- [ ] Admin endpoints: separate auth mechanism or 2FA gate +- [ ] Consider per-workspace "disable org inheritance" flag for high-security clients +- [ ] Sanitize usage data visible to partners (aggregate, not per-user) + +--- + +## Agent 2: Data Engineer / DBA + +### How this fails + +**The soft delete gap (CTO's insight).** When a conversation is deleted, its audio duration metadata disappears. But billing needs that data. "You used 25 hours this month" becomes "you used 18 hours" because someone deleted 7 hours of conversations mid-month. The customer disputes the invoice. You have no proof. + +This extends beyond conversations. If a project is deleted, all its conversations vanish from usage calculations. If a workspace member is removed, you lose the "they were a billable seat for 15 days" data. + +**The fix: Universal soft delete with metadata preservation.** + +Every entity that affects billing needs a soft delete that preserves billing-relevant metadata: + +``` +conversation.deleted_at → keep: duration_seconds, audio_hours, created_at +project.deleted_at → keep: workspace_id, conversation_count_at_deletion +workspace_membership.deleted_at → keep: user_id, role, created_at (for seat-days calc) +workspace.deleted_at → keep: tier, org_id, member_count_at_deletion +``` + +Implementation pattern — every "delete" route must: +1. Route through Python API (not Directus direct) +2. Set `deleted_at = now()` instead of `DELETE` +3. Emit a usage_event with metadata snapshot +4. All SELECT queries add `WHERE deleted_at IS NULL` + +**Subagent task for implementation:** Audit every existing DELETE endpoint (Directus + Python), reroute through Python, convert to soft delete, update all queries. + +**The query performance cliff.** Permission resolution joins 4 tables. The workspace selector joins workspace + org_membership + workspace_membership + counts. These are fine at 10 workspaces but degrade at 100+ (which a large partner will have). + +**The fix:** +- Materialized view for "user accessible workspaces" refreshed on membership changes +- Or: denormalized `workspace_summary` table updated via triggers/events +- Index strategy: composite indexes on the JOIN paths + +```sql +-- Critical indexes for permission resolution +CREATE INDEX idx_org_membership_user_role ON org_membership(user_id, role); +CREATE INDEX idx_ws_membership_user ON workspace_membership(user_id) INCLUDE (workspace_id, role); +CREATE INDEX idx_project_workspace_visibility ON project(workspace_id, visibility) WHERE deleted_at IS NULL; +CREATE INDEX idx_project_user_project ON project_user(project_id, user_id); +``` + +**Migration atomicity.** The migration script creates org + workspace + membership + moves projects for EVERY existing user in one go. If it fails halfway, you have orphaned data. + +**The fix:** +- Wrap per-user migration in its own transaction (not one giant transaction) +- Add idempotency check (`IF EXISTS org_membership for this user, SKIP`) +- Run in batches of 50 with progress logging +- Dry-run mode that reports what it WOULD do + +### Recommendations +- [ ] Soft delete on: conversation, project, workspace, workspace_membership, org_membership +- [ ] Emit metadata snapshot in usage_event on every soft delete +- [ ] All deletes routed through Python API (no direct Directus deletes) +- [ ] Add `WHERE deleted_at IS NULL` to all existing queries (tracked as implementation subtask) +- [ ] Composite indexes for permission resolution +- [ ] Migration: per-user transactions, idempotent, batched, dry-run mode + +--- + +## Agent 3: Frontend Engineer + +### How this fails + +**Workspace context is ambient state.** Once a user selects a workspace, every API call needs `workspace_id`. Where does this live? If it's in the URL (slug), the frontend has to slug→ID resolve on every route transition. If it's in React context/store, a page refresh loses it. If it's in localStorage, it can desync with the URL. + +**The fix:** Single source of truth: +- URL slug is the authority: `/:locale/:workspaceSlug/...` +- On route mount, resolve slug→workspace (cache aggressively) +- Store resolved workspace in React context (but derive from URL, don't sync bidirectionally) +- If slug resolution fails (404), redirect to workspace selector +- localStorage stores `lastWorkspaceSlug` for the post-login redirect ONLY + +```typescript +// WorkspaceProvider pattern +function WorkspaceProvider({ children }: PropsWithChildren) { + const { workspaceSlug } = useParams(); + const { data: workspace, error } = useQuery( + ['workspace', workspaceSlug], + () => api.getWorkspaceBySlug(workspaceSlug), + { staleTime: 60_000 } // Cache for 60s + ); + + if (error?.status === 404) return ; + if (!workspace) return ; + + return ( + + {children} + + ); +} +``` + +**Stale workspace list.** User is on workspace selector. Another admin adds them to a new workspace. They don't see it until they refresh. Worse: they get removed from a workspace, navigate to it from a stale selector, and get a 403. + +**The fix:** +- Poll workspace list every 30s on the selector page +- On 403 from any workspace API call, invalidate workspace cache and redirect to selector with a toast: "You no longer have access to this workspace" + +**Deep linking breaks.** User shares `app.dembrane.com/en/dietz/projects/abc123` with a colleague. Colleague clicks it. They're not logged in. After login, the post-login router sends them to the workspace selector instead of the deep link. + +**The fix:** Store the intended URL pre-login, restore after authentication: + +```typescript +// Before redirect to login +sessionStorage.setItem('redirectAfterLogin', window.location.pathname); + +// After login +const redirect = sessionStorage.getItem('redirectAfterLogin'); +if (redirect) { + sessionStorage.removeItem('redirectAfterLogin'); + navigate(redirect); +} else { + navigate(postLoginRoute); +} +``` + +**Mobile: workspace selector on small screens.** The card view works on tablet but is cramped on phone. The list view is fine on phone but wastes space on desktop. + +**The fix:** Card view only on tablet+, always list view on mobile. Don't try to make cards responsive — just switch the component. + +### Recommendations +- [ ] WorkspaceProvider derives from URL, caches in context, never syncs bidirectionally +- [ ] Slug→ID resolution with aggressive caching (60s staleTime) +- [ ] 403 handling: invalidate cache, redirect to selector, toast +- [ ] Deep link preservation through login flow (sessionStorage) +- [ ] Mobile: list view only, card view tablet+ +- [ ] Polling on selector page (30s interval) + +--- + +## Agent 4: Product Manager (Customer Success) + +### How this fails + +**The "Default workspace" is confusing.** Every user gets a workspace called "Default" in an org called "{Name}'s Organization." For solo facilitators who never need multi-user features, this is mystery meat. "Why am I in an organization? I'm a freelancer." "What's a workspace? I just want my projects." + +**The fix:** +- For single-workspace users: HIDE all workspace/org language. Don't show "Default" anywhere. The experience should be identical to today — just "Projects." +- Only surface workspace concepts when the second workspace is created or when they're invited to someone else's workspace. +- The org name should be editable during onboarding (not auto-generated as "Sameer's Organization"). +- Consider: don't auto-name it at all. When the user first needs to see the org name (e.g., inviting someone), prompt them to name it. + +**Partner can't explain it to clients.** This is the imagination problem from the strategy doc, now in the product itself. A partner creates a workspace for Client 1 and invites C1X. C1X gets an email: "You've been invited to a workspace on Dembrane." C1X has never heard of Dembrane. They don't know what a workspace is. They bounce. + +**The fix:** +- Invite email must be customizable by the partner (at minimum: partner logo, partner name, custom message) +- The invite landing page should show: who invited you, what org, and a one-sentence explanation +- Consider: "You've been invited by [Partner Name] to collaborate on [Workspace Name]" +- Partner should be able to preview the invite email before sending + +**Tier confusion during workspace creation.** Partner creates a new workspace for a client. What tier is it? The mockdown shows a tier selection step, but the pricing page isn't in-app. The partner has to remember "Pioneer is €200, Innovator is €500..." + +**The fix:** +- Show tier comparison inline during workspace creation (not just a dropdown) +- Default to the partner's own workspace tier (reasonable guess) +- Show estimated monthly cost based on selection +- "Contact us" for Guardian tier (don't make it self-serve) + +**No onboarding for the workspace concept.** Existing users who've been using Dembrane for months suddenly see a workspace selector after the migration. "What changed? Did I lose my data?" + +**The fix:** +- First login after migration: show a one-time explainer modal +- "We've upgraded your account! Your projects are right where you left them, now inside your workspace. Here's what's new: [collaborate with organisation members, manage client projects, ...]" +- Include a "Learn more" link to documentation +- Make it dismissible but not skippable on first appearance + +### Recommendations +- [ ] Hide workspace/org language for single-workspace users +- [ ] Customizable invite emails (partner logo, custom message) +- [ ] Tier comparison card during workspace creation +- [ ] Post-migration onboarding modal for existing users +- [ ] Lazy naming: don't force org name on signup, prompt when needed + +--- + +## Agent 5: DevOps / SRE + +### How this fails + +**Migration script runs against production DB with no rollback.** The migration creates orgs and workspaces for every user and updates every project. If it corrupts data, there's no undo. + +**The fix:** +- Take a full DB snapshot before migration +- Run migration on a clone first, verify +- Migration script has a `--dry-run` flag that logs what it would do +- Migration is idempotent (can be re-run safely) +- Each user migration is its own transaction +- Progress reporting: "Migrated 450/2000 users, 3200/8500 projects" + +**No health checks for the new workspace layer.** The Python API now has critical new functionality. If the workspace endpoints go down, users can't log in (post-login router calls `/api/v1/workspaces`). But there's no specific health check for workspace functionality. + +**The fix:** +- `/api/v1/health` checks: DB connectivity, workspace table exists, can query workspace_membership +- Post-login router: if workspace endpoint fails, fall back to legacy project list (graceful degradation) +- Alert on workspace endpoint error rate > 1% + +**Alembic migration + Directus in same DB = collision risk.** Alembic manages workspace tables. Directus manages its own tables. If Directus runs a migration that affects shared resources (indexes, sequences, roles), it could conflict with Alembic's state. + +**The fix:** +- Use separate schemas: `public` for Directus, `app` for workspace tables +- Or: prefix all workspace tables with `app_` to avoid any naming collision +- Document: "Never modify app_* tables through Directus admin panel" + +### Recommendations +- [ ] DB snapshot before migration, clone-test first +- [ ] Migration: dry-run, idempotent, per-user transactions, progress logging +- [ ] Health check endpoint covering workspace layer +- [ ] Graceful degradation if workspace API is down +- [ ] Schema separation or table prefixing to avoid Directus collision + +--- + +## Agent 6: Business / Pricing Strategist + +### How this fails + +**Seat counting double-charges partners.** PX (org owner) is a billable seat in WS A, WS B, and WS C. If each workspace is on Pioneer (3 seats), PX consumes one seat in each. The partner is paying for PX three times. At €25/extra seat, that's €75/month for one person existing in multiple workspaces. + +This is the correct business model if each workspace is a separate client engagement (they're getting value in each). But it FEELS wrong to the partner. "I'm paying for myself three times?" + +**The fix (product, not pricing):** +- Be explicit about this in the UI: "Members who belong to multiple workspaces count as a seat in each" +- Consider: first N workspaces include the org admin as a "free seat" (avoids the psychological sting) +- Or: partner-tier pricing that includes "unlimited org admin seats across your workspaces" +- At minimum: show a clear breakdown so there are no surprises on the invoice + +**Audio hours are consumed even on failed transcriptions.** User uploads 2 hours of audio, transcription partially fails (bad audio quality, language mismatch). They re-upload. Now they've "used" 4 hours. With Pioneer at 25h/month and €5/extra hour, that's a €10 charge for a system failure. + +**The fix:** +- Only count successfully transcribed audio toward usage +- Failed/partial transcriptions should be flagged in usage events: `{ "status": "failed", "billable": false }` +- Show "billable hours" vs "total hours" in usage dashboard +- Allow admin to mark specific events as non-billable (for support resolution) + +**No trial/free tier means self-service friction.** The cheapest option is Pilot at €349 (one-time) or Pioneer at €200/month. There's no way for someone to try Dembrane without committing €200+. The strategy doc says "you need to experience it to imagine what it can do" — but the pricing prevents experiencing. + +**Consideration:** This is a deliberate filter for quality clients. But the workspace architecture should support a `free` or `trial` tier if you ever decide to add one. The `tier` field is a string so this is fine architecturally. Just note it. + +### Recommendations +- [ ] Explicit UI explanation of per-workspace seat counting for multi-workspace users +- [ ] Only count successfully processed audio as billable hours +- [ ] `billable: bool` field on relevant usage events +- [ ] Support marking events as non-billable (admin/support tool) +- [ ] Ensure tier field supports future `free`/`trial` values + +--- + +## Agent 7: Legal / Compliance + +### How this fails + +**Data residency is workspace-level, not just platform-level.** Dembrane is EU-hosted (good). But different clients may have different data residency requirements. A Dutch municipality may require data to stay in NL. A German client may require DE. Currently, all workspaces share the same infrastructure. + +**For now:** This is fine — all EU-hosted. But the workspace model should have a `region` field for future multi-region support. Don't build it, just don't design it out. + +**Soft delete retention periods.** GDPR gives users the right to erasure. Soft delete with 30-day retention is fine for business data, but if a user requests account deletion, all their personal data must be purged within 30 days (not just soft-deleted). + +**The fix:** +- Soft delete for billing/audit purposes: preserve metadata, strip PII +- User deletion (GDPR): hard delete PII, keep anonymized usage events +- Two different paths: "delete conversation" (soft, keep metadata) vs "delete my account" (hard, GDPR) + +```python +# Conversation soft delete (billing-safe) +async def soft_delete_conversation(conversation_id: str): + # Snapshot billing metadata before soft delete + await emit_usage_event("conversation.deleted", { + "duration_seconds": conversation.duration_seconds, + "audio_hours": conversation.audio_hours, + "project_id": str(conversation.project_id), + }) + await db.execute( + "UPDATE conversation SET deleted_at = now() WHERE id = :id", + {"id": conversation_id} + ) + # Audio file can be purged immediately (not needed for billing) + await delete_audio_file(conversation.audio_path) + +# GDPR account deletion (privacy-safe) +async def gdpr_delete_user(user_id: str): + # Anonymize usage events (keep for billing, strip PII) + await db.execute( + "UPDATE usage_event SET user_id = NULL, " + "event_data = event_data - 'participant_name' - 'email' " + "WHERE user_id = :uid", + {"uid": user_id} + ) + # Hard delete PII-containing records + await db.execute("DELETE FROM workspace_invite WHERE email IN (SELECT email FROM directus_users WHERE id = :uid)", {"uid": user_id}) + # Remove memberships + await db.execute("DELETE FROM workspace_membership WHERE user_id = :uid", {"uid": user_id}) + await db.execute("DELETE FROM org_membership WHERE user_id = :uid", {"uid": user_id}) + # Anonymize user record (don't delete — preserves FK integrity) + await db.execute( + "UPDATE directus_users SET first_name = 'Deleted', last_name = 'User', " + "email = 'deleted-' || id || '@deleted.dembrane.com' WHERE id = :uid", + {"uid": user_id} + ) +``` + +**Data processing agreements (DPA) scope changes.** Today, Dembrane processes data for one customer at a time. With workspaces, partner consultancies process data on behalf of their clients, through Dembrane. This is a sub-processor chain: Client → Partner → Dembrane. The DPA needs to reflect this. + +**For now:** Flag for legal review. Not a technical blocker, but the workspace feature changes the data processing relationship. + +### Recommendations +- [ ] Add `region` field to workspace (nullable, future use) +- [ ] Two deletion paths: soft delete (billing) vs GDPR erasure (privacy) +- [ ] GDPR deletion anonymizes usage events, doesn't delete them +- [ ] Audio files can be hard-deleted immediately on conversation delete +- [ ] Flag DPA update for legal team before B2B2B partner features go live + +--- + +## Agent 8: The Dev Who Has to Implement This + +### How this fails + +**Scope creep disguised as "best practices."** The PRD + architecture review + this failure analysis describe a system that would take a team of 4 engineers ~3 months. If one person is building this, half of the "best practices" need to be deferred or the feature never ships. + +**The actual critical path is:** +1. Tables + migration script (1 week) +2. Core API: workspace CRUD + membership + permission resolution (1 week) +3. Frontend: routing + selector + topbar (1 week) +4. Frontend: settings pages + usage dashboard (1 week) +5. Soft delete conversion + usage event instrumentation (1 week) +6. Testing + edge cases + polish (1 week) + +That's 6 weeks for a focused engineer. Every "nice to have" adds a week. + +**What to defer (build later, design for now):** +- `app_user` indirection table (just FK to directus_users for now, migrate later) +- Event versioning (add `"v": 1` but don't build multi-version aggregation) +- Materialized views for permission (single JOIN query is fine for now) +- Rate limiting (add after launch if abuse occurs) +- Request tracing (use existing logging) +- Customizable invite emails (plain text email first) +- Tier comparison during workspace creation (just a dropdown) + +**What must NOT be deferred:** +- Soft delete (can't retrofit without data loss) +- Tenant isolation middleware (can't retrofit without security audit) +- usage_event table partitioning (can't add to existing table) +- Immutable slugs decision (can't change once URLs are in the wild) +- Pagination on list endpoints (can't add without breaking clients) + +**The migration is the scariest part.** It touches every user and every project. If it's wrong, everything is wrong. Budget 2 full days for migration script development and testing. Run it on a DB clone at least 3 times before production. + +### Recommendations for implementation sequencing +- [ ] Week 1: Schema + migration + soft delete columns + app_user table decision +- [ ] Week 2: Core API (CRUD, permissions, invites) + tenant isolation middleware +- [ ] Week 3: Frontend routing + workspace selector + post-login router +- [ ] Week 4: Settings pages + member management + invite modal +- [ ] Week 5: Usage events + dashboard + soft delete conversion of existing endpoints +- [ ] Week 6: Testing, edge cases, migration dry-run on prod clone, deploy diff --git a/echo/docs/workspaces/gate-check-protocol.md b/echo/docs/workspaces/gate-check-protocol.md new file mode 100644 index 00000000..205eb8e5 --- /dev/null +++ b/echo/docs/workspaces/gate-check-protocol.md @@ -0,0 +1,285 @@ +# Gate Check Protocol +## "Surgical Timeout" Before Every Code Change Session + +--- + +## The Rule + +**No Claude Code session makes code changes without first running a Gate Check.** + +Session 1 (Explore) is exempt — it's read-only. +Sessions 2, 3, and 4 MUST run the Gate Check before the first commit. + +--- + +## How It Works + +Every session prompt includes a two-phase structure: + +``` +PHASE 1: GATE CHECK (mandatory, before any changes) + - Analyze what you're about to do + - Surface impacts, risks, and open questions + - Ask me questions until I say "proceed" + - Do NOT write any code until I explicitly say "proceed" + +PHASE 2: EXECUTE (only after I say "proceed") + - Make changes commit by commit + - Pause after each commit for my review +``` + +The Gate Check is NOT a formality. It's where the agent catches things like: +- "This collection has 3 Directus hooks that fire on delete — my soft delete conversion will trigger them" +- "The project.directus_user_id field has a unique constraint I didn't expect" +- "There's an existing archived_at field on conversations that conflicts with our deleted_at plan" + +--- + +## Gate Check Template + +Add this to every session prompt (Sessions 2, 3, 4): + +``` +IMPORTANT: This session has two phases. Do NOT skip Phase 1. + +=== PHASE 1: GATE CHECK === + +Before making ANY code changes, do the following: + +1. IMPACT ANALYSIS + List every file you plan to modify or create. For each: + - File path + - What changes + - What could break + +2. BLAST RADIUS + Answer these questions: + - What existing functionality could this break? + - What Directus hooks/flows/triggers will be affected? + - Are there any scheduled jobs or background processes that touch these collections? + - Will any existing API responses change shape? + - Will any existing frontend behavior change? + +3. IRREVERSIBILITY CHECK + For each change, classify it: + - SAFE: Additive change, easily reverted (new collection, new field, new endpoint) + - CAUTION: Modifies existing behavior (changed query filters, rerouted deletes) + - DANGER: Data mutation or destructive change (migration, schema modification) + + List all CAUTION and DANGER items explicitly. + +4. OPEN QUESTIONS + List anything you're unsure about. Examples: + - "I see a Directus flow called 'cleanup-orphans' — will it conflict with soft delete?" + - "The project collection has a field called 'status' — should deleted_at interact with it?" + - "There are 3 different patterns for calling Directus in this codebase — which should I use?" + +5. DEPENDENCIES + What must be true before these changes work? + - Are there collections from a previous session that must exist? + - Are there environment variables needed? + - Does the local DB need specific seed data? + +6. ASK ME + Based on the above, ask me specific questions. Keep asking until you have + no remaining uncertainties. Format as numbered questions I can answer quickly. + + Examples of GOOD questions: + - "The conversation collection has an 'audio_status' field with values + 'processing'/'complete'/'error'. Should soft-deleted conversations preserve + this field, or should we set it to a new 'deleted' status?" + - "I found 2 places where conversations are deleted: user-initiated delete + in the UI, and an automated cleanup that deletes conversations with + status='error' after 24h. Should the automated cleanup also become a + soft delete?" + - "The Directus permissions for the 'facilitator' role include DELETE on + conversations. After soft delete conversion, should I remove this + Directus permission (since deletes now go through Python)?" + + Examples of BAD questions (too vague): + - "Is this approach okay?" + - "Should I proceed?" + - "Any concerns?" + +DO NOT proceed to Phase 2 until I explicitly say "proceed" or "go ahead" or similar. + +=== PHASE 2: EXECUTE === + +After I confirm, make changes commit by commit. +After each commit, give me a one-line summary of what changed. +Pause after every 3 commits and ask: "Should I continue?" +``` + +--- + +## Session-Specific Gate Checks + +### Session 2: Schema — Gate Check Focus Areas + +The agent should specifically investigate and ask about: + +``` +SCHEMA-SPECIFIC CHECKS: + +a) For each existing collection getting a new field (deleted_at, workspace_id, visibility): + - Are there any Directus hooks/triggers that fire on UPDATE for this collection? + - Are there any computed/alias fields that might conflict? + - Are there any Directus permissions that restrict field creation? + - What's the current field count? Any approaching Directus limits? + +b) For the app_user collection: + - List EVERY field from directus_users that the Python API or frontend + currently reads or writes + - For each: should it be denormalized into app_user, or fetched from + directus_users at runtime? + - Are there any fields with sensitive data (passwords, tokens) that + should NOT be in app_user? + +c) For relations: + - How does this project configure Directus M2O/O2M relations? + - Are there any naming conventions for relation fields? + - Will the new FK fields conflict with existing ones? + +d) For schema sync: + - Show me the current directus-extension-sync config + - After I create these collections, what's the exact command to sync? + - Is there a CI/CD step that validates schema? + +ASK ME about anything that isn't clear from the codebase. +``` + +### Session 3: Soft Delete — Gate Check Focus Areas + +``` +SOFT-DELETE-SPECIFIC CHECKS: + +a) For each delete operation you found: + - Is this delete user-initiated, system-initiated, or both? + - If system-initiated (cron, background job, Directus flow): + should it ALSO become soft delete, or is hard delete correct? + - What's the current error handling if the delete fails? + +b) For Directus permissions: + - Which Directus roles currently have DELETE permission on affected collections? + - After converting to soft delete (PATCH instead of DELETE), do we need to + ADD UPDATE permission and REMOVE DELETE permission? + - Or do we leave Directus permissions as-is since Python uses admin token? + +c) For read query updates: + - How many read queries are there for each affected collection? + - Are any of these queries in Directus flows/hooks (not Python/frontend)? + - Are there any aggregate queries (COUNT, SUM) that would be affected? + - Are there any Directus dashboard/insights panels that show these collections? + +d) For the emit_usage_event utility: + - Where should this file live? (Show me the project's utility/helper structure) + - What's the existing logging pattern? (So error logging matches) + - Is there an existing request context/trace ID system to use for trace_id? + +e) For frontend changes: + - List each frontend file that calls Directus DELETE directly + - What's the existing error handling pattern in the frontend for API calls? + - Are there any optimistic UI updates that assume immediate deletion? + +ASK ME about each delete operation's intended behavior after conversion. +``` + +### Session 4: Core API + Migration — Gate Check Focus Areas + +``` +API-SPECIFIC CHECKS: + +a) For the API structure: + - Where do new route files go? Show the existing file/folder structure. + - How are routes registered? (FastAPI router includes, prefix patterns) + - What's the existing response format? (Envelope pattern? Raw data? Error format?) + - What's the existing input validation pattern? (Pydantic? Manual?) + +b) For auth: + - Show me the EXACT code of the current get_current_user (or equivalent) + - What does it return? (Directus user object? Custom user object? Just user ID?) + - How does it handle expired sessions? + - For admin endpoints: how do existing admin-only endpoints check admin status? + +c) For the migration script: + - What's the exact count of users in the local DB? In production? + - How long does a typical Directus API call take locally? (To estimate migration time) + - Is there a maintenance mode or can we run migration while the app is live? + - What's the rollback plan if migration goes wrong? + (Restore from backup? Reverse script?) + +d) For email (invites): + - Show me the existing SendGrid integration code + - What email templates exist? + - What's the FROM address used? + - Is there a template system or are emails constructed in code? + +e) For the workspace selector: + - The API endpoint GET /workspaces needs to be FAST (called on every login) + - How many Directus API calls will it need to assemble the response? + - Can we batch the queries? + +ASK ME about architectural decisions before implementing. +``` + +--- + +## What If the Gate Check Reveals a Problem? + +Three possible outcomes from a Gate Check: + +### 1. Minor clarification needed +Agent asks a question → you answer → agent proceeds. +Example: "Should deleted_at use ISO format or Unix timestamp?" → "ISO, match existing patterns" → proceed. + +### 2. PRD needs updating +Agent discovers something that contradicts the PRD. +Example: "The project collection already has a 'shared_with' JSON field that stores user IDs. This overlaps with the project_user collection in the PRD." + +**Action:** Update the PRD section before proceeding. The agent should propose the update, you approve it, agent writes it, THEN proceeds with code. + +### 3. Scope change needed +Agent discovers something that makes the planned changes significantly harder or riskier. +Example: "There are 47 read queries across the codebase that touch the conversation collection. Adding deleted_at filter to all of them in one commit is risky." + +**Action:** Replan. Break the session into smaller pieces. Or defer the risky part. + +--- + +## The Full Session Prompt Pattern + +Here's the complete prompt structure for any code-changing session: + +``` +You are working on the `workspaces` branch of the Dembrane ECHO platform. + +CONTEXT: +[Attach PRD + codebase exploration report + this session's specific task description] + +=== PHASE 1: GATE CHECK === +[Session-specific gate check from above] + +Do NOT write any code until I say "proceed." + +=== PHASE 2: EXECUTE === +[Session-specific commit sequence from execution plan] + +After each commit: +- Tell me: what changed, what files, what to verify +- Pause every 3 commits and ask if I want to continue + +If at any point you discover something unexpected: +- STOP +- Tell me what you found +- Ask how to handle it +- Wait for my response before continuing +``` + +--- + +## Summary + +The Gate Check turns Claude Code from "autonomous agent that might go off the rails" into "surgical assistant that explains every cut before making it." It costs 5-10 minutes per session but prevents the scenario where you review 15 commits and realize commit #3 made a wrong assumption that invalidated commits 4-15. + +> *Measure twice, cut once.* +> *Or in our case: Gate Check once, commit many.* diff --git a/echo/docs/workspaces/gripes.md b/echo/docs/workspaces/gripes.md new file mode 100644 index 00000000..41a02a24 --- /dev/null +++ b/echo/docs/workspaces/gripes.md @@ -0,0 +1,10 @@ +- No organisation found. Complete onboarding first. + +error messages like these are bad. i literally don't know as a user where to go. why not pre check and then take me there first and then help me create a ws + +- the create ws feature should be allowed only for ws owner / admin + +you can create ws in ur organisation only as a new user. so have the new workspace button under the organisation. as a dotted button that looks exactly like a ws just with a + button add ws + +this kinda solves the first problem by design. so fix this. create a workspace is great. BUT WHERE? this solves it + diff --git a/echo/docs/workspaces/inheritance-rules.md b/echo/docs/workspaces/inheritance-rules.md new file mode 100644 index 00000000..63af55d8 --- /dev/null +++ b/echo/docs/workspaces/inheritance-rules.md @@ -0,0 +1,247 @@ +# Workspace inheritance — codified rules (derived model) + +**Status:** canonical. Any code that answers "does user U have access to workspace W?" or "who is on workspace W?" must go through the resolvers defined here. **Inherited admin access is not stored — it is derived at query time.** + +**Supersedes:** the trigger-based fan-out model briefly codified in an earlier draft of this file on 2026-04-20. That model is gone. There are no `workspace_membership` rows with `source='inherited'`. If you see one, it's legacy from a pre-derivation-migration state and must be archived. + +--- + +## Core principle + +> **Rule-of-system:** every **open** workspace in a organisation includes every current + future organisation owner/admin as an inherited admin. Private workspaces never include anyone automatically. Inheritance is a **function of current state**, not a stored fact. If you change the state (promote, demote, flip privacy, remove from organisation), access recomputes on the next read — no triggers, no fan-out, no drift. + +Flags that drive the derivation: +- **`org_membership.role`** — only `owner` and `admin` contribute to inheritance. `member` does not. +- **`workspace.settings.inherit_organisation_admins`** — boolean, default `true`. `false` = private workspace; no inheritance. +- **`workspace.settings.inherit_organisation_members`** — boolean, default `false`. `true` = organisation members (org role `member`) also inherit. Exposed via the creation wizard's second checkbox (when the user picks "Open"). +- **`workspace.settings.sticky_removed`** — JSON array of `{user_id, removed_at, removed_by}`. If a user is in this list, they are never re-granted inherited access via any derivation path. + +--- + +## Access resolution — one function, priority order + +``` +user_can_access(workspace_id, user_id) → role | None +``` + +Resolution order: + +1. **Direct workspace membership.** `workspace_membership` row exists for `(ws, user)` with `deleted_at IS NULL`. Return its role. No further checks. +2. **Project-level share** (only relevant when caller is resolving access to a specific project inside a private project). Delegates to `get_user_project_access()` — see PRD §"Permission Resolution". +3. **Inherited admin** (derived): + - Workspace has `settings.inherit_organisation_admins == true`, **and** + - User has an active `org_membership` in the workspace's org with role `owner` or `admin`, **and** + - User is **not** in `workspace.settings.sticky_removed`. + - ⇒ return `'admin'`. +4. **Inherited member** (derived, only if workspace has `settings.inherit_organisation_members == true`): + - Workspace has `settings.inherit_organisation_admins == true` (admins-only open workspaces don't fan to members), **and** + - User has an active `org_membership` with role `member`, **and** + - User is **not** in `workspace.settings.sticky_removed`. + - ⇒ return `'member'`. +5. **Otherwise** — `None` (no access). + +Direct always wins. If a user is `source='direct'` with role `member`, and they're also a organisation admin, they see the workspace as a member (direct row beats derivation). This is intentional: direct is an explicit, audited state; derivation is ambient. + +--- + +## Member list resolution + +``` +get_effective_members(workspace_id) → list[EffectiveMember] +``` + +Returns the union of: +- every direct `workspace_membership` (source recorded as `'direct'`) +- every derived organisation admin (source `'inherited'`, role `'admin'`) — computed as in step 3 above +- every derived organisation member (source `'inherited'`, role `'member'`) — if `inherit_organisation_members=true`, computed as in step 4 + +Deduplication: if a user has a direct row, they appear only once with their direct role — the derivation is suppressed in the output. Users in `sticky_removed` never appear as inherited. + +This function is what the Organisations admin page, workspace members tab, and seat-count queries all call. + +--- + +## Storage model + +**Stored:** +- `org_membership` — (org_id, user_id, role) — the source of truth for organisation membership and role +- `workspace_membership` — only `source='direct'` rows. These represent explicit invites. +- `workspace.settings.inherit_organisation_admins` — boolean flag +- `workspace.settings.inherit_organisation_members` — boolean flag +- `workspace.settings.sticky_removed` — JSON array of tombstones + +**Not stored (derived):** +- Inherited admin access +- Inherited member access +- Effective role for users not in `workspace_membership` + +--- + +## Transitions — what happens when… + +Because access is derived, most transitions are no-ops for the inheritance layer. The state change on the stored table is enough; the next access check reflects it. + +| Event | What to store | What to call | +|---|---|---| +| Workspace created (via `POST /v2/workspaces`) | Insert one `workspace_membership` for the creator with `source='direct', role='owner'`. Set `settings.inherit_organisation_admins` + `inherit_organisation_members` per wizard choice. | `inheritance.on_workspace_created()` (small helper; no fan-out) | +| Organisation admin invites a new person to the organisation | Insert `org_membership` with the chosen role. | Nothing in inheritance — derivation picks them up on next read | +| Organisation member promoted to admin/owner | Update `org_membership.role`. | Nothing — derivation sees the new role immediately | +| Organisation admin demoted to member | Update `org_membership.role`. | Nothing — derivation stops including them on open workspaces where they were inherited. Direct rows survive (they're explicit). | +| Organisation member removed from the organisation | Soft-delete `org_membership`. | Call `inheritance.on_organisation_member_removed()` which soft-deletes their `source='direct'` workspace_membership rows in this org. Inherited access stops automatically. | +| External user added as an explicit workspace member | Insert `workspace_membership` with `source='direct', is_external=True`. No `org_membership`. | — | +| External user is later added to the organisation | Insert `org_membership`. `is_external=True` on any existing workspace_membership rows becomes stale — reconcile to `False` on those rows. Derivation now applies to any workspace they're a organisation admin for. | `inheritance.on_external_became_internal()` | +| Internal user later loses organisation membership (becomes external) | Soft-delete `org_membership`. Direct rows on workspaces survive (explicit state). Any rows in this org should set `is_external=True` so UI renders correctly. | `inheritance.on_internal_became_external()` | +| Workspace privacy flipped open → private | Update `settings.inherit_organisation_admins = false`. | Nothing — derivation stops granting inherited access on next read | +| Workspace privacy flipped private → open | Update `settings.inherit_organisation_admins = true`. | Nothing — derivation grants inherited access on next read, minus anyone in `sticky_removed` | +| Admin "removes" an inherited member from a workspace | Add `{user_id, removed_at, removed_by}` to `workspace.settings.sticky_removed`. | `inheritance.sticky_remove(workspace_id, user_id)` | +| Workspace soft-deleted | Soft-delete `workspace`. All direct memberships hidden by `deleted_at IS NULL` filters. Derivation returns None for a deleted workspace. | — | + +The only two triggers left are **(a) workspace creation** (set the flags + insert creator row) and **(b) sticky-remove on explicit kick**. Plus the external-↔-internal reconciliation helpers, which are one-liners. + +--- + +## Sticky removal — representation + +`workspace.settings.sticky_removed` is a JSON array of tombstones: + +```json +{ + "sticky_removed": [ + { "user_id": "uuid", "removed_at": "2026-04-21T10:30:00Z", "removed_by": "uuid" } + ] +} +``` + +Checks: +- `is_sticky_removed(workspace, user_id)` = `user_id in [t['user_id'] for t in workspace.settings.sticky_removed or []]` +- `sticky_remove(workspace_id, user_id, by_user_id)` appends a tombstone (idempotent — skip if already present) + +Reverse query ("which workspaces has user X been sticky-removed from?") isn't needed for the release. If it becomes load-bearing, promote to a dedicated table. + +--- + +## Module shape — `dembrane/inheritance.py` + +```python +"""Workspace inheritance resolvers. See docs/workspaces/inheritance-rules.md. + +Inheritance is derived at query time, not stored. All access decisions and +member-list queries must route through this module. +""" + +from __future__ import annotations + + +# ── Helpers ─────────────────────────────────────────────────────────── + +def workspace_follows_organisation_admins(workspace: dict) -> bool: + return (workspace.get("settings") or {}).get("inherit_organisation_admins", True) + + +def workspace_follows_organisation_members(workspace: dict) -> bool: + return (workspace.get("settings") or {}).get("inherit_organisation_members", False) + + +def is_sticky_removed(workspace: dict, user_id: str) -> bool: + tombstones = (workspace.get("settings") or {}).get("sticky_removed") or [] + return any(t.get("user_id") == user_id for t in tombstones) + + +# ── Resolvers (read-side) ───────────────────────────────────────────── + +async def user_can_access(workspace_id: str, user_id: str) -> str | None: + """Return the effective role for this user on this workspace, or None. + Implements steps 1–5 in the access-resolution order.""" + ... + + +async def get_effective_members(workspace_id: str) -> list[dict]: + """Return [{user_id, role, source: 'direct'|'inherited', is_external, ...}]. + Derivation runs here; direct rows deduplicate derived.""" + ... + + +# ── State transitions (write-side) ──────────────────────────────────── + +async def on_workspace_created( + workspace_id: str, + creator_app_user_id: str, + inherit_organisation_admins: bool, + inherit_organisation_members: bool, +) -> None: + """Set settings flags + insert creator as source='direct', role='owner'.""" + ... + + +async def on_organisation_member_removed(org_id: str, user_id: str) -> None: + """User left or was removed from the organisation. Soft-delete all their + source='direct' rows in this org. (Derived access stops automatically.)""" + ... + + +async def on_external_became_internal(org_id: str, user_id: str) -> None: + """User just got an org_membership. Reconcile is_external=False on + any existing workspace_membership rows in this org.""" + ... + + +async def on_internal_became_external(org_id: str, user_id: str) -> None: + """User lost org_membership. Set is_external=True on any surviving + workspace_membership rows in this org so UI labels correctly.""" + ... + + +async def sticky_remove( + workspace_id: str, user_id: str, by_user_id: str +) -> None: + """Append a tombstone to workspace.settings.sticky_removed (idempotent).""" + ... +``` + +No fan-out. No triggers. The resolvers replace every path that used to read `workspace_membership.source='inherited'` rows. + +--- + +## Migration — from stored to derived + +One-time, idempotent, dry-run-supported: + +1. For every `workspace_membership` row with `source='inherited'` and `deleted_at IS NULL`: verify the derived model would grant the same access. Log divergences. Archive the row (soft-delete with a note) — it's no longer the source of truth. +2. For every `workspace_membership` row with `source='inherited'` and `deleted_at IS NOT NULL`: convert to a `sticky_removed` tombstone on the workspace's settings. Then archive the row. +3. After this script runs once, no code should ever insert `source='inherited'` again. Delete the `source='inherited'` branches from `POST /v2/workspaces` and any other handler (there shouldn't be many — the existing fan-out loop in `workspaces.py` is the main one). + +Run in dry-run mode first. Expect `source='inherited'` rows only on workspaces that were created via the current fan-out path (very few today). + +--- + +## Call sites + +| Where | Pattern | Owns | +|---|---|---| +| `middleware.get_workspace_context` | `await user_can_access(workspace_id, session.user_id)` → 403 if None | Every scoped endpoint | +| `GET /v2/workspaces/:id/settings` (members list) | `get_effective_members(workspace_id)` | Members tab | +| `GET /v2/orgs/:id/members` (matrix view) | For each user × workspace, compute `user_can_access` | Organisations admin page | +| `GET /v2/workspaces` (selector) | `get_effective_members(ws).filter(user_id == me)` for each workspace | Workspace selector counts | +| `POST /v2/workspaces` | `on_workspace_created(...)` — no fan-out loop | Creation | +| `DELETE /v2/workspaces/:id/members/:uid` | If the removed user was inherited only: `sticky_remove(...)`. If direct: soft-delete the row. | Explicit kicks | +| `DELETE /v2/orgs/:id/members/:uid` | Soft-delete `org_membership` + `on_organisation_member_removed(...)` | Org membership management | +| External ↔ internal transitions | `on_external_became_internal` / `on_internal_became_external` | Invite accept with `include_org_membership=true`, and org removal | + +--- + +## Invariants (tests) + +1. **Single source of truth.** `user_can_access` is the only function that returns a role. No endpoint inspects `workspace_membership.source` directly to decide access. +2. **Direct wins.** If a user has both a direct row and a derived path, `user_can_access` returns the direct role and `get_effective_members` lists them once with `source='direct'`. +3. **Sticky forever (this release).** Once a user is tombstoned on a workspace, no state change (promotion, workspace re-opening, organisation re-join) re-grants them inherited access. They can only return via an explicit direct invite, which appends a direct row and is independent of the tombstone. +4. **Private means private.** When `inherit_organisation_admins=false`, `user_can_access` returns a role only via steps 1 or 2 (direct or project-share). Step 3/4 short-circuits. +5. **No orphan inherited rows.** After migration, `SELECT count(*) FROM workspace_membership WHERE source='inherited' AND deleted_at IS NULL` = 0 forever. +6. **External flag consistency.** A user with an active `org_membership` in this org never has `is_external=true` on any `workspace_membership` row in the same org. Reconciled by the two helpers above. + +--- + +## Open items (workshop) + +- **Sticky-forever vs expiring.** Currently sticky never clears. Do we want a "restore inherited access" path for the case where a past kick is no longer appropriate? Probably yes, but post-release. +- **Tier-per-organisation vs tier-per-workspace.** Separate decision (tracked in release-checklist.md §"Questions for the organisation"). Doesn't interact with this derivation model — it just moves where the tier flag lives. +- **Trigger #7 question from the old spec ("on open → private, do we kick existing inherited members?").** Resolved: there are no inherited members to kick in the derived model. Flipping `inherit_organisation_admins=false` makes them stop being members on the next read. Elegant. diff --git a/echo/docs/workspaces/reference.md b/echo/docs/workspaces/reference.md new file mode 100644 index 00000000..591e62eb --- /dev/null +++ b/echo/docs/workspaces/reference.md @@ -0,0 +1,379 @@ +# Dembrane Platform - App Overview & Screenshot Reference + +A complete snapshot of the current Dembrane platform for designer reference. +36 labelled screenshots covering both the host dashboard and the participant portal. + +--- + +## App Feature Tree + +``` +Dembrane Platform +│ +├── AUTH +│ ├── Login (email/password, optional 2FA PIN) +│ ├── Register (first name, last name, email, password) +│ ├── Verify Email +│ ├── Password Reset (request + reset) +│ └── Language picker (EN, NL, DE, FR, IT, ES, UA) +│ +├── HOME (after login) +│ ├── Pinned Projects (max 3, quick access cards) +│ ├── Projects List (infinite scroll, search, owner filter for admins) +│ ├── Create Project button +│ └── Header +│ ├── Logo / Home link +│ ├── Announcements bar +│ └── User Menu +│ ├── Settings +│ ├── Documentation +│ ├── Feedback portal +│ ├── Report an issue +│ ├── Slack community +│ ├── Language picker +│ └── Logout +│ +├── PROJECT (sidebar + main content layout) +│ │ +│ ├── Sidebar (resizable, collapsible on mobile) +│ │ ├── Home breadcrumb +│ │ ├── Project name (links to portal editor) +│ │ ├── Ask button (opens new chat) +│ │ ├── Library link +│ │ ├── Report button +│ │ ├── Chats accordion (list of past chats with title, date, menu) +│ │ ├── Conversations accordion +│ │ │ ├── Search conversations +│ │ │ ├── Upload button +│ │ │ ├── Options/Filters (Sort, Tags, Verified, Reset) +│ │ │ └── Conversation list (name, duration, date, tags, verified badge) +│ │ └── "Powered by Dembrane" footer +│ │ +│ ├── Project Overview +│ │ ├── QR code for participant portal link +│ │ ├── "Open for Participation?" toggle +│ │ ├── Ongoing conversations count +│ │ ├── Open guide / Copy link / Download QR buttons +│ │ │ +│ │ ├── [Tab] Portal Editor +│ │ │ ├── Conversation flow settings (ask for name, email, tags) +│ │ │ ├── AI title & tag generation settings +│ │ │ ├── Verification settings & topics +│ │ │ ├── GetReply mode settings +│ │ │ ├── Tutorial slug selection +│ │ │ ├── Finish text customization +│ │ │ └── Transcript anonymization toggle +│ │ │ +│ │ └── [Tab] Project Settings +│ │ ├── Name & context +│ │ ├── Upload section (add recordings) +│ │ ├── Export (download all transcripts) +│ │ ├── Host Guide link +│ │ ├── Webhooks (advanced, add/manage webhooks) +│ │ └── Actions (clone project, delete project) +│ │ +│ ├── Conversation Detail +│ │ ├── [Tab] Overview +│ │ │ ├── Summary (AI-generated, copy/regenerate buttons) +│ │ │ ├── Outcomes (approved artifacts from verify flow) +│ │ │ │ └── Expandable accordion per artifact (title, approval date, full content) +│ │ │ ├── Edit: name (portal-entered), title (AI-generated + Generate button), tags +│ │ │ ├── Move to another project (BETA) +│ │ │ ├── Download audio +│ │ │ └── Delete conversation +│ │ │ +│ │ └── [Tab] Transcript +│ │ ├── Full transcript with timestamps & speaker labels +│ │ ├── Copy transcript +│ │ └── Download transcript +│ │ +│ ├── Ask / Chat +│ │ ├── New Chat - Mode Selection +│ │ │ ├── Agentic (BETA) - multi-step analysis with tool execution +│ │ │ ├── Specific Details - select conversations, find exact quotes +│ │ │ └── Overview (BETA) - themes & patterns across all conversations +│ │ │ +│ │ └── Chat Interface +│ │ ├── Chat title + action buttons (copy, menu) +│ │ ├── System welcome message +│ │ ├── Context indicator (which conversations are loaded) +│ │ ├── User messages + AI responses (markdown, headings, bold, quotes) +│ │ ├── Conversation checkboxes in sidebar (select context for chat) +│ │ ├── Quick template buttons (Summarize, Compare & Contrast, Meeting Notes) +│ │ ├── Text input with "/" for template picker +│ │ ├── Message streaming with citations +│ │ ├── Save responses as templates +│ │ ├── Copy chat to markdown +│ │ └── Scroll to bottom button +│ │ +│ ├── Library (access-gated) +│ │ ├── Create library (requires conversations) +│ │ ├── Views list (auto-generated analysis) +│ │ │ └── View Detail +│ │ │ ├── View summary (markdown) +│ │ │ └── Aspect cards with insights +│ │ │ └── Aspect Detail (deep-dive analysis) +│ │ └── Request Access button (if not enabled) +│ │ +│ ├── Report +│ │ ├── Report list (multiple reports per project, language-specific) +│ │ ├── Update / generate report +│ │ ├── Published toggle +│ │ ├── Include portal link toggle +│ │ ├── Edit mode toggle +│ │ ├── Copy link / share +│ │ ├── Full report content (AI-generated Q&A/interview format) +│ │ ├── "Share your voice" CTA (links to participant portal) +│ │ ├── "X reading now" live indicator +│ │ └── Analytics (views count, timeline chart with milestones) +│ │ +│ └── Host Guide (protected, separate full-page view) +│ ├── Drag-and-drop section reordering +│ ├── Live conversation tracking via QR code +│ ├── Add/remove sections +│ ├── Fullscreen & print modes +│ └── Real-time participant tracking +│ +├── USER SETTINGS +│ ├── Account (profile info, email, delete account) +│ ├── Password management +│ ├── Two-factor authentication +│ ├── Appearance (font, font size) +│ ├── Whitelabel (custom logo upload) +│ ├── Legal basis selection +│ └── Audit logs viewer +│ +└── PARTICIPANT PORTAL (separate app/router, public-facing via QR/link) + │ + ├── Start / Onboarding (multi-slide carousel) + │ ├── Slide 1: Consent & Privacy + │ │ ├── Data controller info + │ │ ├── How recordings are processed + │ │ ├── Storage & deletion policy (EU servers, 30 day retention) + │ │ └── Consent checkbox (required to proceed) + │ │ + │ ├── Slide 2: Microphone Check + │ │ ├── Microphone device selector + │ │ ├── Live audio level meter + │ │ └── Skip button + │ │ + │ └── Slide 3: Ready to Begin + │ ├── Session name input (required) + │ ├── Tags selector (multi-select) + │ └── "Next" button → initiates conversation + │ + ├── Audio Conversation + │ ├── Welcome message + pattern image + │ ├── "Record" button (large, central) + │ ├── Text mode toggle button (switch to text input) + │ ├── Settings button (top-right) + │ ├── Wake lock (screen stays on while recording) + │ ├── S3 connectivity check (connection issue dialog if blocked) + │ └── After 60+ seconds: + │ ├── Refine options (explore / verify) + │ └── Finish (skip to end) + │ + ├── Text Conversation (alternative to audio) + │ ├── Text area input ("Type your response here") + │ ├── Submit button + │ └── Microphone toggle (switch back to audio) + │ + ├── Refine / Verify Flow + │ ├── Refine Selection + │ │ ├── "Make your contribution concrete" (verify option) + │ │ └── "Get immediate reply" (explore option, if enabled) + │ │ + │ ├── Verify Topic Selection + │ │ └── Topic cards: Actions, Agreements, Disagreements, Gems, Moments, Truths, Custom + │ │ + │ └── Verify Artifact Editor + │ ├── AI-generated artifact (markdown preview) + │ ├── Revise button (re-generate with verbal feedback, 30s cooldown) + │ ├── Edit button (manual markdown editor) + │ ├── Read Aloud button (audio playback) + │ └── Approve button → saves artifact, returns to recording + │ + ├── Finish Page + │ ├── Thank you / completion message (customizable per project) + │ ├── "Record another conversation" button + │ └── Email notification signup (optional) + │ ├── Email input + add button + │ ├── Email list with remove + │ ├── Privacy disclaimer + │ └── Submit confirmation + │ + ├── Public Report + │ ├── Published report content (same as host report, read-only) + │ ├── "X reading now" live indicator + │ ├── "Contribute" portal link (if enabled) + │ └── View tracking (anonymous) + │ + └── Unsubscribe + ├── Token-based verification + ├── "Unsubscribe from Notifications" button + └── Success/error messaging +``` + +--- + +## Screenshots Index + +### AUTH FLOW + +| File | Screen | Key Elements | +|------|--------|-------------| +| `01-login-page.png` | Login page (with credentials filled) | Email/password form, Login button, language picker, "Register as new user", "Forgot password?" link, Privacy Statements footer | + +--- + +### HOME / PROJECTS LIST + +| File | Screen | Key Elements | +|------|--------|-------------| +| `02-home-projects-list.png` | Home - Projects list (viewport) | "Home" heading, "Create" button, search bar, project cards with name/language/conversations/date/owner/pin | +| `02-home-projects-list-full.png` | Home - Projects list (full scroll) | Complete list of all projects | +| `03-home-user-menu-open.png` | Header user menu dropdown | Settings, Documentation, Feedback portal, Report an issue, Slack community, language picker, Logout | + +--- + +### PROJECT OVERVIEW & SETTINGS + +| File | Screen | Key Elements | +|------|--------|-------------| +| `04-project-overview.png` | Project overview (viewport) | Sidebar (Ask/Library/Report/Chats/Conversations), QR code, participation toggle, Project Settings tab (name, context, upload, export, webhooks, clone/delete) | +| `04-project-overview-full.png` | Project overview (full scroll) | All settings sections visible | +| `05-portal-editor.png` | Portal Editor tab (viewport) | Participant onboarding, conversation flow, verification, finish text settings | +| `05-portal-editor-full.png` | Portal Editor tab (full scroll) | All portal configuration options | + +--- + +### CONVERSATION DETAIL + +| File | Screen | Key Elements | +|------|--------|-------------| +| `06-conversation-detail.png` | Conversation overview tab (viewport) | Name + duration header, Overview/Transcript tabs, AI summary, Outcomes section, edit fields | +| `06-conversation-detail-full.png` | Conversation overview tab (full scroll) | Includes move-to-project, download audio, delete actions | +| `07-conversation-transcript.png` | Conversation transcript tab | Full transcript with timestamps and speaker labels | +| `21-conversation-with-artifacts.png` | Conversation with verified artifacts (collapsed) | 2 approved outcomes: "Breakthrough moments" and "What we think should happen" with approval dates | +| `22-conversation-artifacts-expanded.png` | Verified artifact expanded (viewport) | Full artifact content visible in accordion - rich markdown with headings, bold, structured argument | +| `22-conversation-artifacts-expanded-full.png` | Verified artifact expanded (full scroll) | Complete artifact text + edit conversation section below | + +--- + +### SIDEBAR DEEP FEATURES + +| File | Screen | Key Elements | +|------|--------|-------------| +| `18-sidebar-chats-expanded.png` | Sidebar with Chats accordion expanded | Chat list with titles, dates, per-chat action menus; Conversations accordion below with conversation checkboxes (for chat context selection) | +| `23-sidebar-conversation-filters.png` | Sidebar conversation filter options | Sort button, Tags filter, Verified filter, Reset to default - filter bar below search | + +--- + +### ASK / CHAT + +| File | Screen | Key Elements | +|------|--------|-------------| +| `11-ask-new-chat.png` | New chat mode selection | "What would you like to explore?" + 3 mode cards: Agentic (BETA), Specific Details, Overview (BETA) with example prompts | +| `19-chat-interface.png` | Active chat interface (viewport) | Chat title, system welcome, context indicator, user message, AI response (markdown with headings/sections), quick templates (Summarize, Compare & Contrast, Meeting Notes), text input | +| `19-chat-interface-full.png` | Active chat interface (full scroll) | Complete chat thread showing full AI analysis response with citations, section headings, follow-up Q&A | + +--- + +### LIBRARY + +| File | Screen | Key Elements | +|------|--------|-------------| +| `08-library.png` | Library page | "Request Access" button, "Create Library" disabled, "Your Views" with "Recurring Themes" template, access-gated alert | + +--- + +### REPORT + +| File | Screen | Key Elements | +|------|--------|-------------| +| `09-report.png` | Report page (viewport) | Report selector (3 reports), Published/portal link/edit mode toggles, AI-generated Q&A report content, "1 reading now" indicator | +| `09-report-full.png` | Report page (full scroll) | Complete report text + Analytics section (timeline chart, views count, milestones) | + +--- + +### HOST GUIDE + +| File | Screen | Key Elements | +|------|--------|-------------| +| `20-host-guide.png` | Host guide (viewport) | Full-page session management view with QR code, live participant tracking | +| `20-host-guide-full.png` | Host guide (full scroll) | Complete host guide with all sections | + +--- + +### USER SETTINGS + +| File | Screen | Key Elements | +|------|--------|-------------| +| `10-settings.png` | User settings (viewport) | Account-level settings | +| `10-settings-full.png` | User settings (full scroll) | Account info, password, 2FA, appearance (font/size), whitelabel logo, legal basis, audit logs | + +--- + +### PARTICIPANT PORTAL + +| File | Screen | Key Elements | +|------|--------|-------------| +| `12-participant-start.png` | Consent & privacy slide | Data controller info, storage/deletion policy, consent checkbox (unchecked), "I understand" button disabled | +| `12-participant-start-consent-checked.png` | Consent slide (checked) | Same as above with checkbox checked, "I understand" button now enabled | +| `13-participant-tutorial.png` | Microphone check slide | Microphone device selector, live audio level meter, "Skip" button, requesting mic access alert | +| `14-participant-ready.png` | "Ready to Begin?" slide | Session name input (required), tags multi-select, "Next" button | +| `15-participant-conversation-audio.png` | Audio conversation screen | Welcome heading + pattern image, "Record" button, text mode toggle, connection issue dialog (S3 check) | +| `15-participant-conversation-audio-clean.png` | Audio conversation screen (clean) | Same as above with connection dialog dismissed | +| `16-participant-conversation-text.png` | Text conversation screen | Text area ("Type your response here"), Submit button, microphone toggle to switch back to audio | +| `17-participant-report.png` | Participant-facing report (viewport) | Public report view - same content as host report, read-only, "Contribute" link | +| `17-participant-report-full.png` | Participant-facing report (full scroll) | Complete public report content | + +--- + +## User Flow Diagrams + +### Host Flow (authenticated) +``` +Login + └─> Home (Projects List) + ├─> Create Project + ├─> Settings (user account) + └─> Select Project + ├─> Project Overview + │ ├─> Portal Editor tab (configure participant experience) + │ └─> Project Settings tab (name, upload, export, webhooks, delete) + ├─> Conversation Detail + │ ├─> Overview (summary, artifacts, edit, move, delete) + │ └─> Transcript (view, copy, download) + ├─> Ask / Chat + │ ├─> New Chat (pick mode: Agentic / Details / Overview) + │ └─> Chat Interface (query conversations, get AI analysis) + ├─> Library (create views, explore aspects) + ├─> Report (generate, publish, share, analytics) + └─> Host Guide (live session management with QR) +``` + +### Participant Flow (public, via QR code or link) +``` +Scan QR / Open Link + └─> Start / Onboarding + ├─> Consent & Privacy (checkbox required) + ├─> Microphone Check (skippable) + └─> Ready to Begin (name + tags) + └─> Conversation + ├─> Audio Mode (record button, wake lock) + │ └─> After 60s+: + │ ├─> Refine → Verify Flow + │ │ ├─> Pick topic (actions/agreements/gems/etc.) + │ │ ├─> AI generates artifact + │ │ └─> Revise / Edit / Approve + │ └─> Finish + └─> Text Mode (type + submit) + └─> Finish + ├─> Thank you message + ├─> "Record another" button + └─> Email signup (optional) + +Public Report (separate URL, read-only) +Unsubscribe (email opt-out via token link) +``` diff --git a/echo/docs/workspaces/release-checklist.md b/echo/docs/workspaces/release-checklist.md new file mode 100644 index 00000000..5ae2e329 --- /dev/null +++ b/echo/docs/workspaces/release-checklist.md @@ -0,0 +1,475 @@ +# Workspaces Release — Master Checklist + +> **Target release:** end of the week of 2026-04-20 +> **Status doc:** this file. Tick items as they land. +> **Companion docs:** `workspaces-prd-v3-final.md`, `execution-plan-final.md`, `designer-return.html`, `designer-brief.md`, `designer-brief-v2.md`, `inheritance-rules.md` + +## Progress gauges + +``` +Schema [██████████] 100% +Soft delete [██████████] 100% +Core API [███████░░░] 65% +Frontend route [██████████] 95% +Settings UI [█████░░░░░] 50% +Project polish [██░░░░░░░░] 20% +Emails [████░░░░░░] 40% +Internal tools [░░░░░░░░░░] 0% +──────────────────────────────────── +Feature total [██████░░░░] ~65% +``` + +--- + +## Session 1 — EXPLORE `[██████████] 100%` +- [x] Codebase exploration report +- [x] PRD v3 final + reconciliation +- [x] Execution plan, architecture review, failure analysis + +## Session 2 — SCHEMA `[██████████] 100%` +- [x] `app_user` collection (6f34b48) +- [x] `org` + `org_membership` (31ab178) +- [x] `workspace` + `workspace_membership` (9492e65) +- [x] `workspace_invite` + `project_membership` (686a81a) — `project_membership` replaces PRD's `project_user` +- [x] `project.workspace_id` / `visibility` / `deleted_at` (705e1a9) +- [x] `deleted_at` on conversation / project_chat / project_report (1128430, 948890c) +- [~] `usage_event` — intentionally removed (35281fe); revisit post-release + +## Session 3 — SOFT DELETE `[██████████] 100%` +- [x] `AsyncDirectusClient` for FastAPI (0466d6f) +- [x] Conversation soft delete (6d23381) +- [x] Project soft delete (bee6bfc) +- [x] Chat soft delete (43e2d16) +- [x] Report soft delete (2b5e4e2) +- [x] Webhook soft delete (54afb0b) +- [x] Tag soft delete (25cb4e8) +- [x] `deleted_at IS NULL` on all reads (bb02811) +- [x] DELETE permissions removed from Basic User (24b94f9) + +## Session 4 — CORE API + ONBOARDING `[███████░░░] 65%` + +**Built** +- [x] `get_workspace_context` middleware + policy system (1925651) +- [x] `GET/POST /v2/workspaces` +- [x] Workspace settings CRUD (get, update, remove member, change role, resend/cancel invite) +- [x] `POST /v2/workspaces/:id/invite` (HMAC token, rate-limited) +- [x] Workspace-scoped projects: list/create/move (0d3ba76, 1637c85) +- [x] `/v2/me` (get/update/pending invites/accept/decline/accept-by-hash) +- [x] `/v2/onboarding/complete` (idempotent, auto-accepts pending invites) +- [x] Inherited org admins auto-added on workspace create +- [x] Tier policies enforced server-side + +**Decision: skip migration script.** Auto-onboarding covers the new-user case. Existing-user migration lives in the onboarding flow itself (see "Onboarding split" below). + +**Missing** +- [ ] Org API: `GET /v2/orgs`, `GET /v2/orgs/:id/members`, `PATCH/DELETE /v2/orgs/:id/members/:uid` +- [ ] Org admin promotion → inherited workspace memberships (verify + wire) +- [ ] `PATCH /v2/workspaces/:id/tier` (staff-only; see Internal tools below) +- [ ] `DELETE /v2/workspaces/:id` (soft delete; blocked if projects exist) +- [ ] `POST/DELETE /v2/projects/:id/members` (private project sharing, innovator+) +- [ ] Staff guard: `is_staff` check on directus_users; `/v2/me` returns `is_staff` flag +- [ ] `POST /v2/workspaces/:id/suspend` + `unsuspend` (staff-only, blocks all access) + +## Phase 3 — Frontend routing + selector `[██████████] 95%` +- [x] `/:locale/w/:workspaceId/...` routing (bb5c8f3) +- [x] Workspace selector + last-used memory (cac5561) +- [x] Post-login router + onboarding redirect +- [x] Topbar workspace switcher +- [x] 403 stale-state handling (5aa191b) +- [ ] Progressive solo experience (hide "workspace" language for 1-ws users) +- [ ] Selector polish per designer Ask 5: organisation hero card on top, per-row `⚙ manage` on hover, externals in quieter section + +## Phase 4 — Frontend settings + management `[█████░░░░░] 50%` + +**Built** +- [x] Workspace settings page (756aecc) — one-page layout +- [x] Member role change, remove, resend/cancel invite (b4a77d9) +- [x] Create workspace (single-step form) +- [x] User settings + `PATCH /v2/me` (eae799d) + +**Designer-locked directions (from `designer-return.html`)** +- [ ] **Ask 1 — Organisations admin page** `/org/:orgId/members`: list ⇄ matrix view switcher on same URL. Organisation section (inherited to all workspaces) + External section separate. Row menu: change role / view workspaces / remove. Matrix hidden ≤768px with "switch to list" toast. +- [ ] **Ask 2 — Tier management** on workspace settings `?tab=billing`: feature-matrix comparison, current tier highlighted, "Request upgrade" CTA (email-based). **Staff-only inline block** with Set tier dropdown + internal reason field + confirm dialog. No separate `/admin` route. +- [ ] **Ask 4 — Upgrade prompts**: 4B hatched overlay for feature surfaces (Whitelabel, API, Data export); 4C modal per feature. **Role-aware**: admin/owner see "Request upgrade" primary; member sees "Ask an admin" ghost — same modal opens. +- [ ] **Ask 5 — Selector polish**: organisation hero card (name, 3 workspaces, 12 people, tier agg, "Manage organisation" CTA), workspaces list with hover `⚙ manage`, external section with "guest of X" pill. +- [ ] Settings tab split (General / Members / Branding / Legal / Billing) — currently one page +- [ ] Delete workspace UI +- [ ] Migration onboarding modal ("your projects moved to [workspace]") + +## Phase 5 — Project changes + polish `[██░░░░░░░░] 20%` + +**Designer-locked directions** +- [ ] **Ask 3.1 — "Shared with" strip** on project overview page (under header, above content). Shows Private pill + avatars + "+N more" + "Manage →". When public: "Visible to everyone in [workspace] · Make private". +- [ ] **Ask 3.2 — "Who can see this project?" modal**: user list with `can edit` / `can read` dropdowns. Only users already in workspace (no cross-workspace). "Everyone else in [workspace]" = no access. Link sharing off. Verb labels, not nouns. Gated innovator+. +- [ ] Visibility toggle (workspace ↔ private) +- [ ] Tier-gated empty states with upgrade CTAs + +## Queued design directions (2026-04-21) + +Handed over by Sameer with the inbox spec. Each lives as its own +session — rewires below land where they map to an existing section. + +### Home (per organisation) `[░░░░░░░░░░] 0%` + +Organisation-scoped landing page (one per organisation the viewer belongs to): +- Thin organisation strip: avatars + viewer role chip + "Manage organisation" link +- Admin-only notice bar: pending access requests +- Workspace cards grid — every workspace in the organisation +- Discoverable list — admin sees `Join`, member sees `Request access` +- Pending requests section (member-only, own requests in flight) +- `+ New workspace` button — admin-only + +Replaces the current single-list selector as the per-organisation default. Global +selector survives for cross-organisation switching. + +### Workspace settings — tabbed `[░░░░░░░░░░] 0%` + +Current single-page → tabbed layout: +- **General** — name, description, logo +- **Members** — list with source pills (`inherited` / `direct` / `external`); + destructive actions in `⋯` menu; raw permissions behind a disclosure +- **Access** — two-state radio `Shared / Private`; privacy defaults +- **Billing** — tier compare matrix + upgrade-request + staff inline block + (folds in Ask 2 + 2s) +- **Danger zone** — delete workspace + +### Workspace create — multi-step `[░░░░░░░░░░] 0%` + +Replaces the one-step form with `Details → Visibility → Invite → Review`. +One primary action per step. +- `Visibility=Shared` shows a dry-run preview: "organisation members auto-inherit." +- `Invite` step picks from organisation roster OR enters email for externals. +- Review step shows everything before create. + +Supersedes the earlier "full multi-step flow" bullet — this is the concrete +step list. + +### Project sharing — list view `[░░░░░░░░░░] 0%` + +Replaces matrix with a list: +- Inherited-from-workspace rows shown read-only +- Direct grants editable +- Source pills: `workspace member` / `organisation member (project-only)` / `external` +- Soft warning at 10+ collaborators ("consider opening the project to the + whole workspace") + +Supersedes Ask 3.2's modal-as-matrix direction. + +--- + +## Onboarding split (designer fix) `[░░░░░░░░░░] 0%` + +Current `OnboardingRoute` is a **migration prompt** that new users shouldn't see. Split into two paths: + +- [ ] **New user path**: add optional "Organisation name" field to signup form. On submit → auto-create organisation (fallback `"{firstName}'s organisation"`), default workspace, land in `/w/:id/projects` empty state. No separate onboarding route. +- [ ] **Existing user path**: keep current screen, re-copy as migration. Show only if `user.createdAt < workspaces-launch-ts` AND no organisation membership yet. + +--- + +## Internal tools — "how do we block access?" `[░░░░░░░░░░] 0%` + +Three levers: + +1. **Tier downgrade** — staff sets `workspace.tier` via inline billing-tab control (designer Ask 2s). Policy layer blocks innovator+/changemaker+/guardian features automatically. Best for "stop letting them use feature X". +2. **Workspace suspend** — new `workspace.suspended_at` field. Middleware checks it in `get_workspace_context` → returns 403 with friendly "This workspace is paused — contact your admin". Best for "block all access" (non-payment, abuse, GDPR). +3. **Membership removal** — existing soft-delete path. Blocks a specific user. Best for individual offboarding. + +Deliverables: +- [ ] `workspace.suspended_at` field + middleware 403 + frontend friendly page +- [ ] `is_staff` boolean on `directus_users` (or role check); surfaced in `/v2/me` +- [ ] Staff-only inline block on workspace billing tab: + - [ ] Set tier (dropdown + reason field + confirm dialog) + - [ ] Suspend / Unsuspend (toggle + reason) + - [ ] Internal notes field (free text, staff-only visible) +- [ ] Staff-only inline block on org admin page: + - [ ] View all members across all org workspaces (matrix already handles this) + - [ ] Export CSV of members +- [ ] Audit log of staff actions (lightweight `staff_action` collection — append-only, `{actor, action, target, reason, created_at}`) + +--- + +## Emails `[████░░░░░░] 40%` + +Exists: `workspace_invite.html`, `workspace_added.html` — basic, brand-updated. + +**Polish** +- [ ] Shared layout partial (header/footer de-duplicated; consider MJML) +- [ ] Proper logo header, typography scale, Royal Blue accent +- [ ] Preview text (``) for inbox preview lines +- [ ] Footer: help link, company address, unsubscribe (where applicable) +- [ ] Plain-text fallback for both templates (currently HTML-only → spam risk) +- [ ] Inviter name + avatar/initial; workspace name prominent +- [ ] Test rendering: Gmail / Outlook / Apple Mail / dark mode + +**New templates (this release)** +- [ ] Welcome email after first signup (post-onboarding) +- [ ] Role changed notification ("You are now admin of X") +- [ ] Removed from workspace notification (GDPR-friendly wording) + +**Deferred to post-release** +- [ ] Invite reminder (24h before expiry — cron) +- [ ] Org admin promoted notification +- [ ] Workspace suspended/unsuspended notifications (tie to internal tools) + +--- + +## Decisions locked + +- **Inheritance semantics (2026-04-20):** **rule-of-system (option a)** — every *open* workspace in a organisation always includes every current + future organisation owner/admin as an inherited admin. Private workspaces (`workspace.settings.inherit_organisation_admins = false`) never auto-include. No time dimension. ⚠️ **Must be ratified by the organisation before release.** +- **Derived inheritance (2026-04-20):** inherited admin/member access is **computed at query time, not stored.** No `workspace_membership` rows with `source='inherited'`. One resolver (`user_can_access`) is the single source of truth. Sticky removal lives as JSON tombstones in `workspace.settings.sticky_removed`. Full spec + migration plan in `inheritance-rules.md`. Replaces the earlier trigger/fan-out codification (now gone from the doc). +- **Access buckets in the creation wizard (2026-04-20):** two booleans stored in `workspace.settings` — `inherit_organisation_admins` (default true, shown as a checked-disabled row) + `inherit_organisation_members` (default false, shown as an optional checkbox). Dropped "external" as a bucket — externals never inherit (their presence on a workspace is always an explicit `source='direct'` row). Show step 2 of the wizard even for solo organisations; dry-run reads "0 organisation admins will inherit." +- **Billing for private (2026-04-20):** **innovator+ tier at both workspace and project level** (designer's lean, accepted). Matches the existing gate on private-project sharing — one mental model: privacy is an innovator-tier capability. Solo users (organisation of 1) still see the option; it just has no behavioral effect. +- **30-day soft-delete SLA (2026-04-20):** accept the principle — rows with `deleted_at` older than 30 days are eligible for hard-delete. **Purger + Trash/Restore UI deferred to post-release.** Soft-delete alone is sufficient for ship. +- **Tier lives on the workspace (2026-04-20):** `workspace.tier` stays. Partner-client handoff model needs per-workspace flexibility — a partner might run three client workspaces on different tiers (one client on Guardian for whitelabel, another on Pioneer during pilot). Moving tier to the organisation would collapse that flexibility. Selector "mixed tier" display stays a real state and gets handled in UI. Ask 2 "Tier management" stays on the workspace billing tab per designer's v5 assumption. +- **Workspace-level configuration (2026-04-20):** whitelabel (logo + branding), trash / retention settings, and (future) custom prompts are **workspace-scoped**, not org-scoped. Storage: `workspace.logo_url` (already present) + `workspace.settings` JSON (already present) carry these. Org-level defaults may bubble down later (inheritance pattern for configuration, not access) — but the per-workspace override is always authoritative. +- **"Request upgrade" CTA (2026-04-20):** **real endpoint.** `POST /v2/workspaces/:id/upgrade-request` sends a styled email via SendGrid + returns a toast confirmation. Target inbox is configurable via env var `UPGRADE_REQUEST_INBOX` (default `sameer@dembrane.com` for now). Swap to a shared inbox at workshop. **Admin-role only.** +- **Member-role upgrade prompt (2026-04-20):** **no CTA.** Gate copy reads "This feature requires [tier]. Ask one of your organisation admins to upgrade." No button, no mailto, no admin-name list. Members can find admins via the members tab. The real friction is the gate, not a missing button; adding a CTA would be hollow flow. +- **Access-blocking levers:** tier downgrade + soft-delete workspace + membership removal. No `suspended_at` field this release. +- **`is_staff`:** derived from `auth.is_admin` (Directus Administrator role). No new schema. +- **Private workspace / private project flags:** stored in existing JSON/enum columns (`workspace.settings.inherit_organisation_admins`, `project.visibility`). No new columns. +- **Migration script:** skipped. Auto-onboarding + onboarding-split covers it. +- **Creation wizards (2026-04-20):** **full multi-step flow (option 2).** Dedicated routes (`/w/new`, `/w/:id/projects/new` — verify naming during S9), progress indicator, step-back, reviewable summary before create, cancel at each step. Applies to workspace creation and project creation. Designer to deliver Ask 6 wires for both. +- **Delete workspace (2026-04-20):** **option A — must be empty.** `DELETE /v2/workspaces/:id` returns 409 if any non-deleted projects exist. UI error message links to the organisation-page project surface (below) so admins can bulk-clean without walking into every workspace. +- **Organisation page — project management surface (2026-04-20):** Ask 1 expands to cover project-level actions across all organisation workspaces. Exact UI is open (see designer-brief-v2 Ask 1 follow-up). Goal: from the organisation admin page, an owner/admin can see every project in every workspace in the organisation and soft-delete any of them, so winding down a workspace is one page, not twenty. +- **Tier downgrade behavior (2026-04-20):** **Option A — freeze**, with **one hybrid exception: whitelabel is cleared on downgrade**. Existing premium artifacts (private shares, exports, API tokens) remain usable after downgrade but can't be added to; new attempts are gated. Whitelabel branding reverts to dembrane logo on downgrade — the confirmation dialog explicitly lists "your custom logo will be removed" before staff/admin proceeds. Generalizable rule: freeze by default, explicit-revert only where leaving state visible would brand-misrepresent the tier. +- **Tier gate auto-wiring in `has_policy()` (2026-04-20):** **included in S6.** `has_policy()` will look up `TIER_REQUIRED_FOR_POLICY[policy]` and deny if the workspace's current tier falls below. Removes the per-endpoint `ctx.require_tier()` requirement and closes the silent-gap risk flagged in `policies.py:21`. + +## Tier + role gating matrix + +Two independent axes. To use a feature you need the minimum tier **AND** the minimum role (within the workspace or org). Source: `server/dembrane/policies.py` + PRD + `docs/workspaces/reference.md` feature tree. + +### Tier gates (workspace.tier) + +| Feature | Min tier | Policy | Downgrade behavior | +|---|---|---|---| +| Projects + conversations + chat + reports (core) | pilot | — | — | +| Data export (transcripts / CSV / report download) | innovator | `workspace:export` | Freeze — existing files keep working; can't trigger new export | +| Private project sharing (add people to a private project) | innovator | `project:share` | Freeze — existing shares stay; no new shares can be added | +| Private project creation (`visibility='private'`) | innovator | `project:set_private` (new) | Freeze — stays private; can't newly mark private | +| Private workspace (opt out of organisation inheritance) | innovator | `workspace:set_private` (new) | Freeze — stays private; can't newly mark private | +| Whitelabel branding (custom logo) | changemaker | `workspace:whitelabel` | **Revert** — custom logo cleared, dembrane logo restored; downgrade dialog must explicitly warn | +| API / integration access | changemaker | `workspace:api_access` | Freeze — existing tokens keep working; no new tokens; rotation blocked | +| Webhooks | pilot *(current)* | none | Keep — free for all tiers today. Organisation Q: bump to changemaker with API? | +| Library / analysis views | pilot *(current, invite-gated)* | none | Organisation Q: should this be tier-gated at innovator+? | +| Agentic chat mode | pilot *(current BETA)* | none | Organisation Q: post-BETA, gate at innovator+? | + +### Role gates (workspace_membership.role) + +| Policy | viewer | member | admin | owner | +|---|:-:|:-:|:-:|:-:| +| project:read, conversation:read, report:view | ✓ | ✓ | ✓ | ✓ | +| project:create, project:update | — | ✓ | ✓ | ✓ | +| conversation:delete, chat:use, report:generate | — | ✓ | ✓ | ✓ | +| project:delete, project:share, project:move | — | — | ✓ | ✓ | +| report:delete | — | — | ✓ | ✓ | +| member:invite, member:manage | — | — | ✓ | ✓ | +| settings:manage, workspace:view_usage | — | — | ✓ | ✓ | +| workspace:export (+ innovator) | — | — | ✓ | ✓ | +| workspace:whitelabel (+ changemaker) | — | — | ✓ | ✓ | +| workspace:api_access (+ changemaker) | — | — | ✓ | ✓ | +| workspace:delete *(needs policy, owner-only)* | — | — | — | ✓ | +| upgrade-request submission | — | — | ✓ | ✓ | + +### Org-role gates (org_membership.role) + +| Policy | member | admin | owner | +|---|:-:|:-:|:-:| +| org:view | ✓ | ✓ | ✓ | +| org:manage_users, org:manage_settings, org:manage_billing | — | ✓ | ✓ | +| org:create_workspace, org:view_all_workspaces, org:view_usage | — | ✓ | ✓ | +| `*` (everything, transfer ownership) | — | — | ✓ | + +### Staff gates (`auth.is_admin`, i.e. Directus Administrator) + +| Action | Non-staff | Staff | +|---|:-:|:-:| +| View any workspace in Directus admin | — | ✓ | +| `PATCH /v2/admin/workspaces/:id/tier` | — | ✓ | +| Future: workspace audit log, suspend, force-transfer | — | ✓ | + +### Enforcement pattern + +- **Role** checks via `ctx.require_policy("...")` (middleware does the work; see `server/dembrane/api/v2/middleware.py`). +- **Tier** checks via `ctx.require_tier("innovator")` at the endpoint (explicit, auditable). +- **Staff** checks via `auth.is_admin` on the session object (JWT claim `admin_access`). +- **Gaps to close in S6:** wire `TIER_REQUIRED_FOR_POLICY` into `has_policy()` so tier gates fire automatically, not per-endpoint. Currently each tier-gated endpoint must call `ctx.require_tier()` by hand (`policies.py` has a TODO comment on this). + +## Questions for the organisation (workshop) + +> Sameer is running a workshop with the organisation to ratify these before release. Each item needs a call, not more discussion. + +### Access & inheritance +- [ ] **Inheritance rule-of-system (a) vs time-based (b).** Locked at (a). Confirm partners expect this when adding a new organisation admin retroactively. Sensitive past workspaces must be flipped to private *before* the admin joins — is that workflow acceptable, or do we need a smarter default? +- [ ] **Sticky removal across organisation re-join.** If person A is removed from workspace W (inherited) and later leaves the organisation and rejoins as admin, do they get auto-added to W again? PRD says no ("sticky"). Keep sticky forever, or expire it (e.g. 90 days)? +- [ ] **Last-admin protection.** What happens if the last owner of a organisation tries to leave or is removed? Block, or auto-promote someone? Who? +- [ ] **Cross-organisation membership.** Can a single user be an admin of two organisations at once? Technically yes. Product: do we want the UI to encourage this or nudge against it? + +### Tier +- [ ] **Tier gating matrix.** Confirm the matrix above matches the product strategy. Specific open gates: + - **Webhooks** — currently free. Promote to changemaker alongside API access? + - **Library / analysis views** — currently invite-gated. Move to tier-gated innovator+, or keep invite-gated? + - **Agentic chat mode** — post-BETA, does it stay free or gate at innovator+? +- [ ] **Downgrade behavior list.** Freeze is default; whitelabel reverts on downgrade. Any other feature that should revert rather than freeze? Candidates: portal editor "custom finish text", transcript anonymization (probably keep as compliance). +- [ ] **Quota/seat gates.** Tiers currently gate *features*. Do we also want to gate *quantities* (projects per workspace, audio hours per month, members per workspace)? Not in scope this release unless essential. +- [ ] **New workspace starting tier.** Always `pioneer`, or does it inherit the organisation's highest-active tier? +- [ ] **Upgrade request inbox.** `sameer@dembrane.com` for now. When do we switch to a shared inbox, and what's the address? +- [ ] **Staff definition.** Is "Directus Administrator role" the right population for set-tier + audit? Any non-Administrators we want to include, or any Administrators we want to exclude? + +### Billing & seats +- [ ] **Billable seat count.** Does a seat = direct members only, or direct + inherited? Affects how partners think about cost of adding organisation admins. + +### Copy & UI +- [ ] **External vs Guest.** Designer uses "guest of [organisation]", PRD uses "External". Pick one for all UI. +- [ ] **Role names.** owner / admin / member / viewer — keep or soften (e.g. "editor" instead of "member")? +- [ ] **Tier names.** pilot / pioneer / innovator / changemaker / guardian — friendly enough for partners, or rename for clarity? + +## Sessions ahead + +Each session = one focused Claude Code conversation that lands a coherent batch of commits. We extend the original 4-session pattern (EXPLORE → SCHEMA → SOFT DELETE → CORE API). Sessions 1–4 completed on workspaces branch prior to 2026-04-20. + +``` +✓ S1 EXPLORE — codebase + PRD reconciliation +✓ S2 SCHEMA — org/workspace/membership/invite collections +✓ S3 SOFT DELETE — project/conversation/chat/report/webhook/tag + read filters +✓ S4 CORE API v1 — workspace CRUD, invites, /v2/me, onboarding-complete +───────────────────────────────────────────────────────────────────────── +○ S5 ORGS + STAFF — /v2/orgs endpoints; is_staff in /v2/me (derived from auth.is_admin); answer access-inheritance semantics +○ S6 ACCESS RULES — workspace.settings.inherit_organisation_admins respected in: create-workspace, organisation-invite, org-admin promotion; tier PATCH (staff-only); upgrade-request endpoint; delete-workspace endpoint +○ S7 ORGANISATIONS ADMIN PAGE — Ask 1 list ⇄ matrix ⇄ projects (3-view switcher); row menu; Invite-to-organisation; project delete across organisation +○ S8 TIER MANAGEMENT UI — Ask 2 compare matrix + Ask 2s inline staff controls on billing tab +○ S9 CREATION WIZARDS — workspace + project creation wizards with dry-run preview; privacy toggle +○ S10 PRIVATE PROJECT SHARING — Ask 3 "Shared with" strip + "Who can see" modal + visibility toggle +○ S11 UPGRADE PROMPTS — Ask 4 4B hatched overlay + 4C modal, role-aware copy +○ S12 SELECTOR + SETTINGS POLISH — Ask 5 organisation hero + per-row manage + settings tab split +○ S13 ONBOARDING SPLIT — new-user organisation-name-at-signup vs migration screen +○ S14 EMAILS — shared layout partial, plain-text fallbacks, welcome/role-changed/removed templates +○ S15 BUG BASH + RELEASE — smoke tests across roles (owner/admin/member/external/staff); deploy testing → main +``` + +Dependencies: S7 depends on S5; S8 depends on S6 tier PATCH; S9 depends on S6 access-rule wiring; S10 depends on S9's visibility plumbing. + +--- + +## Release blockers (must ship) + +1. Organisations / Org admin page + org API (Ask 1) +2. Tier set/change via inline staff controls (Ask 2s) — **replaces "internal tools" migration story** +3. Workspace suspend/unsuspend (access-blocking primitive) +4. Delete workspace endpoint + UI +5. Onboarding split (new user vs migration) +6. Email polish + plain-text fallback + +## Deferred (post-release OK) + +- Private project sharing UI (Ask 3) — strongly prefer this week if time allows; designer is ready +- Workspace usage detail page +- Org billing rollup page +- Invite reminder cron +- `usage_event` reinstatement +- Staff action audit log (can start as simple log lines, formalize later) + +--- + +## Changelog for this doc + +- **2026-04-20**: initial master checklist; incorporated designer v2 directions; dropped migration script in favor of auto-onboarding + onboarding split; added internal tools / access-blocking track. + +## Session status — autonomous run 2026-04-20 (~2h) + +Commits landed on `workspaces` branch (not pushed): + +| # | Hash | Commit | +|---|---|---| +| 1 | 94cf40d | feat: derived inheritance module + tier auto-wire in has_policy | +| 2 | 818c774 | feat: surface is_staff in /v2/me | +| 3 | 9736a2c | feat: /v2/orgs endpoints for organisation management | +| 4 | (docs) | docs: workspaces release sources of truth | +| 5 | 4141262 | feat: tier mgmt, upgrade-request, delete workspace, downgrade effects | +| 6 | e26d725 | refactor: shared email layout + auto plain-text fallbacks | +| 7 | c51a7e0 | feat: private project sharing API — /v2/projects/:id/members CRUD | +| 8 | e66c483 | feat: onboarding split — differentiate new vs legacy users | + +### What landed + +- **S5 Orgs + staff** `[██████████] 100%` — `dembrane/inheritance.py` (derived resolvers + helpers), `/v2/orgs` CRUD + members, `is_staff` in `/v2/me`, middleware delegates to `user_can_access`, workspace creation no longer fans out inherited rows. +- **S6 Access rules + tier + delete + downgrade** `[█████████░] 95%` — `has_policy()` auto-enforces tier gates via `TIER_REQUIRED_FOR_POLICY`; `PATCH /v2/workspaces/:id/tier` (staff-only, reason required); `POST /v2/workspaces/:id/upgrade-request` (admin-only, configurable inbox via `UPGRADE_REQUEST_INBOX`); `DELETE /v2/workspaces/:id` (owner-only, blocked if projects); `dembrane/tier_downgrade.py` with `DOWNGRADE_EFFECTS` map + `preview_downgrade()` / `apply_downgrade_effects()`; privacy flags (`inherit_organisation_admins` / `_members`) accepted on create + patch. +- **S10 Private project sharing API** `[████░░░░░░] 40%` — backend complete (`/v2/projects/:id/members` CRUD, innovator+ gated, no cross-workspace). Frontend UI (strip + modal) blocked on design wires. +- **S13 Onboarding split** `[████████░░] 80%` — backend flag `has_legacy_projects` on `/v2/me`; `OnboardingRoute` copy splits three ways (invite / legacy / new). Hasn't been visually verified in a browser — the copy changes are additive and don't touch layout. +- **S14 Emails** `[███████░░░] 70%` — shared `_layout.html` partial, brand-compliant; existing `workspace_invite` + `workspace_added` refactored to extend; plain-text `.txt` fallbacks auto-picked by `send_email`; multipart/alternative wiring correct (plain first, html second). New templates (welcome, role-changed, removed) deferred until their endpoints need them. + +### What I did NOT touch (needs design wires before I can start) + +- **S7 Organisations admin page** (Ask 1 list ⇄ matrix ⇄ projects) — backend (`/v2/orgs/:id/members`) is ready, UI not. +- **S8 Tier management UI** (Ask 2 compare matrix + Ask 2s staff inline block) — backend ready. +- **S9 Creation wizards** (workspace + project, full multi-step with dry-run preview) — backend accepts the flags; UI blocked on wizard wires. +- **S10 frontend** — the strip on project overview + the share modal. +- **S11 Upgrade prompts** — backend gates fire correctly; UI component (hatched overlay + 4C modal, member-role = no CTA) blocked on wires. +- **S12 Selector polish** — designer has wires; retargeting needs attention to Ask 5's organisation hero + per-row manage. Not touched here. + +### Judgment calls made while you were out + +Tracked as `# Note:` comments in code at the decision site. Summary: +- Tier-gate auto-wire: added `workspace_tier` kwarg to `has_policy()` and resolve via `TIER_REQUIRED_FOR_POLICY`. Made `policies.py:24-25` include `workspace:set_private` + `project:set_private` so privacy is gated uniformly (matches designer's S1 assumption). +- Organisation-invite endpoint (`POST /v2/orgs/:id/members`) deliberately not built — reused the existing workspace-invite flow with `include_org_membership=true` targeting the organisation's default workspace. Saves a collection + keeps the invite email template single-source. +- `accessible_workspace_count` rollup in `/v2/orgs/:id/members` uses `user_can_access` per (user × workspace) pair — O(U × W) round-trips per list call. Fine at current scale; noted a TODO in-line to batch when a organisation grows past ~50 workspaces. +- Downgrade-effect: `revert` currently only clears `logo_url`. Any new tier-gated feature marked `"revert"` must add its clear-on-downgrade branch to `apply_downgrade_effects()`. +- Email multipart: SendGrid `.add_content()` requires plain-first, html-second order. Wired that way; `_build_message` verified via a unit-style sanity check. +- Onboarding split terminology: used "organisation" in user-facing copy (`Welcome, {name} · Set up your organisation`). "Organisation admin" / "admins follow organisation access" phrasing is consistent with Q6's accepted rename, though the global sweep across older docs hasn't happened yet. + +### Things I want your eyes on + +1. **Run the local stack and hit the new endpoints** — I didn't start the devcontainer to curl them. The code compiles and imports cleanly; behavior-testing is yours. +2. **Q6 terminology sweep** — I used "follow organisation access" phrasing in new code + comments. Older docs (`inheritance-rules.md`, `workspaces-prd-v3-final.md`) still say "inherit". Low-risk but inconsistent until swept. +3. **Legacy inherited rows migration** — `inheritance-rules.md` specifies a cleanup pass for any `source='inherited'` rows that might exist from older code paths. I didn't run it because nothing today writes those rows anymore (the fan-out code in `POST /v2/workspaces` is gone). If any legacy rows linger they'll just be ignored by derivation. Easy to script later if you want them archived. +4. **Frontend onboarding split** — browser-verify the three copy branches render correctly. Code compiles; visual verification is TODO. + +### Still blocked on you / workshop + +- 12 questions in "Questions for the organisation (workshop)" section above — all of them still open. +- Design wires for S7/S8/S9/S10-frontend/S11/S12 — the designer's v5 is the dependency. + +## Security + correctness audits + +Three parallel audit rounds ran against the workspace release. Summary of what landed and what's open. + +### What the audits shipped (as code) + +- **Upgrade-request XSS hardened.** Staff inbox was receiving raw f-string HTML — rewrote as an autoescaping Jinja template (`upgrade_request.html`), added per-user 5/hr rate limit, strip CR/LF from subject fields. +- **Whitelabel + logo URL validation.** Changing a workspace or organisation logo now fires `require_policy("workspace:whitelabel")` and validates the URL scheme (http/https only, 2048-char cap). +- **Role preset correction.** Admin preset didn't grant any tier-gated policy — only owner (`*`) could. Admins now have `project:set_private`, `workspace:set_private`, `workspace:whitelabel`, `workspace:api_access` — tier still auto-enforced via `has_policy`. +- **Derived model invariants.** Onboarding now writes creator rows as `source='direct'` (was `'inherited'`). Organisation-owner carve-out in `user_can_access` — owners always derive admin on their organisation's workspaces even when private. `DELETE /workspaces/:id/members/:uid` now writes a `sticky_removed` tombstone when the removed user would re-derive access. +- **Null-safety.** `workspace.settings=NULL` on legacy rows no longer 500s — normalised to `{}` before dict ops. +- **Migration.** `scripts/migrate_inherited_to_derived.py` dry-run-by-default, per-host lockfile against concurrent `--apply`, `script_start_iso` cutoff so re-runs don't false-tombstone just-archived rows, defensive handling of corrupted `sticky_removed` JSON. +- **Private-project email leak.** `GET /v2/projects/:id/members` now strips email from non-admin readers on private projects. +- **Gate doesn't mount gated children.** `FeatureGate` previously used `pointer-events: none` which left keyboard-level listeners firing inside the subtree. Now renders a pure placeholder. +- **Upgrade modal double-fire guard.** `disabled={sending}` + early return guard + defensive `detail` stringification. +- **Visibility PATCH pattern match.** `PATCH /v2/projects/:id/visibility` rejects externals, uses the shared access resolver. + +### Private-project read enforcement + +**Partially closed.** The list → click → open path is genuinely gated now. Enforced at: + +- `GET /v2/workspaces/:id/projects` — filters private projects the caller can't see +- `GET /v2/projects/:id` — returns 404 (not 403) on no-access so we don't confirm existence +- `PATCH /visibility` — tier-gated + rejects externals +- Frontend `ProjectAccessGuard` wraps the project detail tree + +**Open follow-up:** conversation / chat / report / library fetches go through the **Directus SDK** directly and don't know about visibility. A deep-linked URL to a specific chat or conversation of a private project will still resolve. Fix scope: tighten Directus permissions on `project` / `conversation` / `project_chat` / `project_report` reads to respect `visibility` + `project_membership`. Tracked as its own session. + +### Architecture verdict across rounds + +All rounds agreed: no auth bypass, no IDOR, no JWT forgery, no cross-tenant leakage. Core derivation + tier wiring is sound. + +### Deploy runbook + +1. Push Directus schema to prod (`directus/sync.sh push`) so `project.visibility` + `workspace.settings.sticky_removed` fields exist. +2. Dry-run the migration against prod, spot-check ~5 affected rows, then `--apply` **before** merging `workspaces` → `main`. +3. Merge + deploy server + frontend. +4. Set `SENDGRID_API_KEY`, `UPGRADE_REQUEST_INBOX` in prod env before first upgrade-request is triggered. +5. Re-run migration `--apply` once after deploy to post-verify: zero live `source='inherited'` rows. + +### Known-and-accepted + +- `_rollup_workspace_access` is O(users × workspaces) in the organisations page — fine at current scale, batch-refactor when a organisation passes ~50 workspaces. +- Tier PATCH + concurrent feature use has a sub-second window where a new share can slip in under the old tier. Acceptable for manual-billing; revisit when automated billing lands. +- `on_workspace_created` is two non-transactional Directus writes. Acceptable. diff --git a/echo/docs/workspaces/workspaces-prd-v3-final.md b/echo/docs/workspaces/workspaces-prd-v3-final.md new file mode 100644 index 00000000..2bf4f496 --- /dev/null +++ b/echo/docs/workspaces/workspaces-prd-v3-final.md @@ -0,0 +1,861 @@ +# Workspaces & Organizations — Final PRD +## Dembrane ECHO Platform + +> **Status:** Ready for implementation +> **Date:** April 2026 +> **Version:** 3 (final) + +--- + +## Guiding Principles + +> *"The Tao that can be told is not the eternal Tao."* — Lao Tzu +> +> The best infrastructure is invisible. A solo facilitator should never know they're "in a workspace." A partner managing 20 client engagements should feel the system anticipates their needs. **Wu Wei** — effortless action — is the design target. + +> *"योगः कर्मसु कौशलम्"* (Yogah karmasu kaushalam) — "Yoga is skill in action." — Bhagavad Gita 2.50 +> +> Every architectural decision should make the *next* decision easier, not harder. Skill is not in building more — it's in building the thing that makes everything else simpler. + +> *"水善利万物而不争"* — "Water benefits all things and does not compete." — Tao Te Ching, Ch. 8 +> +> The platform serves facilitators who serve communities. We are water — shaping ourselves to the container (workspace) while carrying what matters (conversations, insights, outcomes) downstream. + +### Architectural Dharma — Each Layer Has Its Duty + +| Layer | Dharma (duty) | Fails when... | +|-------|--------------|---------------| +| **Org** | Be the legal/billing boundary. Protect the business entity. | ...it leaks into the user's daily experience | +| **Workspace** | Be the collaboration boundary. Contain the work. | ...it becomes a prison (can't share, can't leave) | +| **Project** | Be where meaning is made. Hold conversations, insights, reports. | ...it's burdened with access control it doesn't need | +| **User** | Move freely between contexts. Carry identity, not permissions. | ...their access is confusing or surprising | + +> *"In the Arthashastra, Kautilya teaches that the strength of an alliance is not in its rigidity but in its flexibility — the ability to change relationships without changing structure."* +> +> This is why `billed_to_workspace_id` is a pointer, not a hierarchy. Relationships change. Structure should not need to. + +--- + +## TL;DR + +Introduce **Orgs** and **Workspaces** above projects. On signup, every user gets an Org + Default Workspace. Solo users never see these concepts. Multi-user and B2B2B features are additive. All data access through Directus HTTP API. All new tables as Directus collections. + +--- + +## The Real World + +``` +Partner 1 (consultancy) Org 1 + ├── manages Client 1 projects ├── Workspace A (Default) — own projects + └── manages Client 2 projects ├── Workspace B — Client 1's projects + └── Workspace C — Client 2's projects +``` + +**Two paths to collaboration:** + +| | Path A: Partner-led | Path B: Client-led | +|---|---|---| +| Who creates workspace | Partner | Client | +| Where it lives | Partner's org | Client's org | +| Who pays | Partner | Client | +| Partner users appear as | Direct members | External members | +| Handoff needed? | Yes (future) | No | + +> *Sun Tzu: "The supreme art of war is to subdue the enemy without fighting."* +> We don't compete with partners — we make them more powerful. The B2B2B model wins by making the partner successful with their clients. + +--- + +## Technical Stack (As-Is) + +| Layer | Technology | Access Pattern | +|-------|-----------|----------------| +| Frontend | React + Vite SPA | Calls Python API + some direct Directus calls (migrating to Python) | +| API | Python FastAPI | Calls Directus HTTP API (admin token + user cookie forwarding) | +| Database | PostgreSQL (via Directus) | No direct SQL from Python. All through Directus REST API. | +| Schema | Directus admin UI + directus-extension-sync | Push/pull schema changes | +| Auth | Directus (cookie-based) | Frontend gets cookie, forwards to Python, Python validates with Directus | +| Email | SendGrid | Transactional emails configured | + +**All new tables are Directus collections.** Created via Directus admin UI, synced via directus-extension-sync, accessed via Directus REST API from Python. + +--- + +## Data Model + +### New Collections + +#### `app_user` + +**Why:** Indirection layer between our domain tables and Directus auth. When we eventually migrate off Directus auth, we update this table once instead of every FK in every table. + +> *Kautilya: "A wise king does not build his palace on borrowed land."* +> Our domain model should not be structurally dependent on a third-party auth system's internal IDs. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | Our canonical user ID. All other tables FK to this. | +| `directus_user_id` | uuid, UNIQUE | Maps to `directus_users.id`. Current auth provider. | +| `email` | string | Denormalized for quick lookup without hitting Directus | +| `display_name` | string | | +| `created_at` | timestamp | | +| `updated_at` | timestamp | | + +**On user creation (Directus hook or Python post-registration):** Create corresponding `app_user` row. +**On user lookup:** Resolve `directus_user_id` → `app_user.id` once, use `app_user.id` everywhere. + +#### `org` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | | +| `name` | string | Default: "{user.display_name}'s Organization" | +| `slug` | string, UNIQUE | Display-only (NOT used in URLs) | +| `logo_url` | string, nullable | Default branding for workspaces | +| `created_by` | uuid, FK → `app_user.id` | | +| `deleted_at` | timestamp, nullable | Soft delete | +| `created_at` | timestamp | | +| `updated_at` | timestamp | | + +#### `org_membership` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | | +| `org_id` | uuid, FK → `org.id` | | +| `user_id` | uuid, FK → `app_user.id` | | +| `role` | string | `owner` / `admin` / `member` | +| `deleted_at` | timestamp, nullable | Soft delete (preserves seat-days for billing) | +| `created_at` | timestamp | | + +UNIQUE: `(org_id, user_id)` WHERE `deleted_at IS NULL` + +#### `workspace` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | **Used in URLs** as short ID | +| `org_id` | uuid, FK → `org.id` | Owning org | +| `name` | string | | +| `slug` | string | **Display-only.** Not in URLs. Unique per org (not globally). | +| `description` | string, nullable | | +| `logo_url` | string, nullable | Override org logo | +| `tier` | string, default `'pioneer'` | `pilot`/`pioneer`/`innovator`/`changemaker`/`guardian` | +| `billed_to_workspace_id` | uuid, nullable, FK → `workspace.id` | Partner billing stub. NULL = org pays. | +| `is_default` | boolean, default `false` | Auto-created workspace | +| `legal_basis` | string, nullable | `consent`/`client-managed`/`dembrane-events` | +| `privacy_policy_url` | string, nullable | | +| `settings` | json, default `{}` | Feature flags, limits | +| `deleted_at` | timestamp, nullable | Soft delete | +| `created_by` | uuid, FK → `app_user.id` | | +| `created_at` | timestamp | | +| `updated_at` | timestamp | | + +**URL pattern:** `/:locale/w/:workspaceId/projects` — uses `workspace.id` (short UUID), NOT slug. + +**Slug uniqueness:** UNIQUE per `(org_id, slug)` — two orgs can both have a "default" workspace. + +#### `workspace_membership` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | | +| `workspace_id` | uuid, FK → `workspace.id` | | +| `user_id` | uuid, FK → `app_user.id` | | +| `role` | string | `owner`/`admin`/`member`/`viewer` | +| `source` | string, default `'direct'` | `direct` = explicitly invited. `inherited` = auto-added from org role. | +| `is_external` | boolean, default `false` | User's primary org ≠ workspace's org | +| `deleted_at` | timestamp, nullable | Soft delete (preserves seat-days) | +| `created_at` | timestamp | | + +UNIQUE: `(workspace_id, user_id)` WHERE `deleted_at IS NULL` + +**Org inheritance behavior:** +- When workspace is created: org `owner` and `admin` members get auto-added as workspace_membership rows with `source='inherited'`, `role='admin'` +- When org member is promoted to admin/owner: auto-add inherited memberships to all org workspaces +- When org member is demoted from admin/owner: remove their `source='inherited'` memberships (but not `source='direct'`) +- Workspace admin can remove inherited members (just soft-delete the row) +- Removed inherited members are NOT re-added automatically + +**Workspace role policies:** + +| Role | Policies | +|------|----------| +| `viewer` | Read-only access to workspace-visible projects | +| `member` | `project:create`, `project:update` | +| `admin` | All member + `project:delete`, `project:share`, `member:invite`, `member:manage`, `settings:manage` | +| `owner` | `*` (everything including ownership transfer) | + +#### `project_user` + +For sharing private projects with specific users. **Tier-gated: innovator+.** + +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | | +| `project_id` | uuid, FK → `project.id` | | +| `user_id` | uuid, FK → `app_user.id` | | +| `role` | string, default `'editor'` | `editor`/`viewer` | +| `granted_by` | uuid, FK → `app_user.id` | | +| `created_at` | timestamp | | + +#### `workspace_invite` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | | +| `workspace_id` | uuid, FK → `workspace.id` | | +| `email` | string | | +| `role` | string | Role to assign on acceptance | +| `invited_by` | uuid, FK → `app_user.id` | | +| `token` | string, UNIQUE | `secrets.token_urlsafe(32)` — 256 bits | +| `expires_at` | timestamp | 7 days from creation | +| `accepted_at` | timestamp, nullable | | +| `created_at` | timestamp | | + +#### `usage_event` + +Append-only. **Never updated. Never deleted.** Source of truth for billing. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid, PK | | +| `trace_id` | string | Correlation ID (use request ID when available) | +| `org_id` | uuid, nullable | | +| `workspace_id` | uuid, nullable | | +| `project_id` | uuid, nullable | | +| `user_id` | uuid, nullable | | +| `event_type` | string | | +| `event_data` | json | **Always include `"v": 1`** for schema versioning | +| `created_at` | timestamp | | + +> *I Ching, Hexagram 1: "The Creative works sublime success."* +> The usage event log is the memory of the system. It never forgets, never argues, never lies. All billing disputes are settled by reading the log. + +**Event types:** + +| Type | Data (v1) | Emitted when | +|------|-----------|-------------| +| `org.created` | `{ "v": 1, "name": str }` | Signup | +| `workspace.created` | `{ "v": 1, "tier": str, "is_default": bool }` | Workspace creation | +| `workspace.member_added` | `{ "v": 1, "member_user_id": str, "role": str, "source": str, "is_external": bool }` | Member joins | +| `workspace.member_removed` | `{ "v": 1, "member_user_id": str, "role": str, "seat_days": int }` | Member removed (include days active for billing) | +| `project.created` | `{ "v": 1, "visibility": str }` | Project creation | +| `project.deleted` | `{ "v": 1, "conversation_count": int, "total_audio_hours": float }` | Project soft-deleted (snapshot billing metadata) | +| `conversation.deleted` | `{ "v": 1, "duration_seconds": int, "audio_hours": float }` | Conversation soft-deleted (preserve duration!) | +| `audio.uploaded` | `{ "v": 1, "duration_seconds": int, "conversation_id": str }` | Audio upload | +| `audio.processed` | `{ "v": 1, "duration_seconds": int, "conversation_id": str, "billable": bool }` | Transcription complete. `billable: false` for failures. | +| `chat.query` | `{ "v": 1, "mode": str }` | Chat message | +| `report.generated` | `{ "v": 1, "report_id": str }` | Report created | +| `report.deleted` | `{ "v": 1 }` | Report soft-deleted | + +### Modified Collections + +#### `project` (existing) + +Add fields: + +| Field | Type | Notes | +|-------|------|-------| +| `workspace_id` | uuid, nullable, FK → `workspace.id` | NULL only during migration window | +| `visibility` | string, default `'workspace'` | `workspace` / `private` | +| `deleted_at` | timestamp, nullable | Soft delete | + +#### `conversation` (existing) + +Add field: + +| Field | Type | Notes | +|-------|------|-------| +| `deleted_at` | timestamp, nullable | Soft delete. **Critical for billing** — preserves duration metadata. | + +#### Other collections needing `deleted_at` + +- `chat` (or whatever the chat/message collection is called) +- `report` + +--- + +## Soft Delete Strategy + +> *Ahimsa (अहिंसा) — non-harm. First, do no harm to user data. Second, do no harm to billing accuracy.* + +### The Rule + +**Every delete operation in the entire application must:** +1. Route through the Python API (no frontend→Directus direct deletes) +2. Set `deleted_at = now()` instead of hard deleting +3. Emit a `usage_event` with a metadata snapshot of billing-relevant fields +4. The actual data (audio files, etc.) can be purged, but metadata stays + +### Implementation Pattern + +```python +# In Python API — generic soft delete handler +async def soft_delete( + collection: str, + item_id: str, + metadata_snapshot: dict, + event_type: str, + workspace_id: str | None = None, + org_id: str | None = None, + user_id: str | None = None, +): + """Soft delete any item. Emit usage event with billing metadata.""" + + # 1. Set deleted_at via Directus API + await directus.patch(f"/items/{collection}/{item_id}", { + "deleted_at": datetime.utcnow().isoformat() + }) + + # 2. Emit usage event with metadata snapshot + await emit_usage_event( + event_type=event_type, + event_data={"v": 1, **metadata_snapshot}, + workspace_id=workspace_id, + org_id=org_id, + user_id=user_id, + ) + +# Example: deleting a conversation +async def delete_conversation(conversation_id: str, user: AppUser): + conv = await directus.get(f"/items/conversation/{conversation_id}") + project = await directus.get(f"/items/project/{conv['project_id']}") + + await soft_delete( + collection="conversation", + item_id=conversation_id, + metadata_snapshot={ + "duration_seconds": conv["duration_seconds"], + "audio_hours": conv["duration_seconds"] / 3600 if conv["duration_seconds"] else 0, + "project_id": conv["project_id"], + }, + event_type="conversation.deleted", + workspace_id=project["workspace_id"], + user_id=user.id, + ) + + # 3. Optionally purge audio file (not needed for billing) + if conv.get("audio_path"): + await storage.delete(conv["audio_path"]) +``` + +### GDPR Erasure (Separate Path) + +When a user requests account deletion (GDPR Article 17): +- Anonymize usage_events: set `user_id = NULL`, strip PII from `event_data` +- Remove memberships (soft delete with metadata snapshot) +- Anonymize the `app_user` record (don't hard delete — preserves FK integrity) +- Hard delete PII: invite records with their email, etc. + +### Subtask: Soft Delete Audit & Conversion + +**This is a prerequisite implementation task.** Before workspace features go live: + +1. **Audit:** Scan the entire codebase for all delete operations + - Frontend → Directus direct DELETE calls + - Frontend → Python API delete endpoints + - Python API → Directus DELETE calls + - Directus flows/hooks that delete items +2. **Add `deleted_at`** to: conversation, project, chat, report (workspace/org/membership tables are new and have it from day 1) +3. **Reroute** all frontend→Directus direct deletes through Python API endpoints +4. **Convert** each delete to soft delete + usage event emission +5. **Update all read queries** to filter `deleted_at IS NULL` (Directus filter: `{ "deleted_at": { "_null": true } }`) +6. **Test:** Verify deleted items don't appear in UI, usage events are emitted, billing metadata is preserved + +--- + +## Permission Resolution + +> *Confucius: "Let the ruler be a ruler, the minister a minister, the father a father, and the son a son."* +> Each role has clear responsibilities. Ambiguity in access control is a security vulnerability. + +```python +async def get_user_project_access(app_user_id: str, project_id: str) -> Access | None: + """Single resolution path. Check in order of specificity.""" + + # Fetch project (with deleted_at filter) + project = await directus.get(f"/items/project/{project_id}", { + "filter": {"deleted_at": {"_null": True}} + }) + if not project: + return None + + # 1. Legacy ownership (backward compat — phase out over time) + if project.get("directus_user_id"): + app_user = await get_app_user(app_user_id) + if app_user and app_user["directus_user_id"] == project["directus_user_id"]: + return Access(role="owner", source="legacy") + + if not project.get("workspace_id"): + return None + + # 2. Workspace membership (includes inherited org admins as explicit rows) + membership = await directus.get("/items/workspace_membership", { + "filter": { + "workspace_id": {"_eq": project["workspace_id"]}, + "user_id": {"_eq": app_user_id}, + "deleted_at": {"_null": True}, + }, + "limit": 1 + }) + + if membership and len(membership) > 0: + ws_role = membership[0]["role"] + + if project["visibility"] == "workspace": + return Access(role=ws_role, source="workspace") + + if project["visibility"] == "private": + if ws_role in ("admin", "owner"): + return Access(role=ws_role, source="workspace") + + # 3. Direct project share (private projects only) + if project["visibility"] == "private": + project_user = await directus.get("/items/project_user", { + "filter": { + "project_id": {"_eq": project_id}, + "user_id": {"_eq": app_user_id}, + }, + "limit": 1 + }) + if project_user and len(project_user) > 0: + return Access(role=project_user[0]["role"], source="project_share") + + return None +``` + +### Tenant Isolation Middleware + +**Every workspace-scoped endpoint** must use this dependency: + +```python +async def get_workspace_context( + workspace_id: str, # From URL path parameter + current_user: AppUser = Depends(get_current_user), +) -> WorkspaceContext: + """Validates user has access to this workspace. Returns scoped context.""" + + membership = await directus.get("/items/workspace_membership", { + "filter": { + "workspace_id": {"_eq": workspace_id}, + "user_id": {"_eq": current_user.id}, + "deleted_at": {"_null": True}, + }, + "limit": 1 + }) + + if not membership or len(membership) == 0: + raise HTTPException(status_code=403, detail="No access to this workspace") + + return WorkspaceContext( + workspace_id=workspace_id, + user=current_user, + role=membership[0]["role"], + source=membership[0]["source"], + ) + +# Usage — structurally impossible to forget the access check +@router.get("/api/v1/workspaces/{workspace_id}/projects") +async def list_projects(ctx: WorkspaceContext = Depends(get_workspace_context)): + return await directus.get("/items/project", { + "filter": { + "workspace_id": {"_eq": ctx.workspace_id}, + "deleted_at": {"_null": True}, + } + }) +``` + +--- + +## URL Structure + +> *"Use short IDs in URLs, slugs are display-only."* + +``` +/:locale/login +/:locale/register +/:locale/select-workspace +/:locale/w/:workspaceId/projects # Dashboard +/:locale/w/:workspaceId/projects/new # Create project +/:locale/w/:workspaceId/projects/:projectId # Project view +/:locale/w/:workspaceId/projects/:projectId/chats/:id # Chat +/:locale/w/:workspaceId/projects/:projectId/reports/:id # Report +/:locale/w/:workspaceId/settings # Workspace settings +/:locale/org/:orgId/settings # Org settings (also by ID) +/:locale/settings # User settings +``` + +`workspaceId` and `orgId` are UUIDs (or shortened UUIDs if you prefer — e.g., first 8 chars). Slugs appear in the UI (page titles, breadcrumbs, workspace cards) but never in URLs. + +--- + +## Frontend Architecture + +### Progressive Solo Experience + +> *Wu Wei: the best infrastructure is invisible.* + +``` +IF user has exactly 1 workspace AND is not org admin/owner: + → Auto-redirect to workspace dashboard + → Topbar shows ONLY logo + user avatar (no workspace name, no "change workspace") + → Settings gear goes to workspace settings (no "org" language) + → Subtle prompt: "Invite your organisation →" in sidebar footer + → The word "workspace" never appears + +IF user has 2+ workspaces OR is org admin/owner: + → Full workspace experience (selector, topbar with workspace name, etc.) +``` + +### Workspace Selector (Full Page) + +Route: `/:locale/select-workspace` + +Three variants based on context — see `ui-flows-mockdown.md` for complete specs: +- **Card View**: 2-3 workspaces, not org admin +- **List View**: >3 workspaces, searchable with Internal/External tabs +- **Partner View**: Org admin/owner, shows org context + management links + +### Post-Login Router + +```typescript +// After successful authentication +const workspaces = await api.getAccessibleWorkspaces(); +const lastWorkspace = localStorage.getItem('lastWorkspaceId'); +const isOrgAdmin = workspaces.some(w => !w.is_external && ['owner', 'admin'].includes(w.org_role)); + +// Check for deep link (saved before login redirect) +const deepLink = sessionStorage.getItem('redirectAfterLogin'); +if (deepLink) { + sessionStorage.removeItem('redirectAfterLogin'); + navigate(deepLink); + return; +} + +if (workspaces.length === 0) { + navigate('/error/no-workspace'); // Should never happen +} else if (workspaces.length === 1 && !isOrgAdmin) { + navigate(`/w/${workspaces[0].id}/projects`); +} else { + navigate('/select-workspace'); +} +``` + +### Stale State Handling + +- On 403 from any workspace API call → invalidate workspace cache, redirect to selector, show toast +- Workspace list on selector page → poll every 30 seconds +- WorkspaceContext provider → staleTime 60s, refetch on window focus + +--- + +## API Endpoints + +All under `/api/v1/`. Auth via Directus cookie forwarding. + +### Org +``` +GET /orgs # User's orgs +GET /orgs/:id # Org detail +PATCH /orgs/:id # Update (name, logo) +GET /orgs/:id/members # Org members +POST /orgs/:id/members # Add member +PATCH /orgs/:id/members/:uid # Change role +DELETE /orgs/:id/members/:uid # Remove (soft delete) +GET /orgs/:id/billing # Usage rollup across workspaces +``` + +### Workspace +``` +GET /workspaces # All accessible (for selector) +POST /workspaces # Create (in user's org) +GET /workspaces/:id # Detail +PATCH /workspaces/:id # Update +DELETE /workspaces/:id # Soft delete (must be empty) +GET /workspaces/:id/members # Members + inherited + pending invites +POST /workspaces/:id/members # Invite (by email) +PATCH /workspaces/:id/members/:uid # Change role +DELETE /workspaces/:id/members/:uid # Remove (soft delete) +GET /workspaces/:id/projects # Workspace projects +POST /workspaces/:id/projects # Create project +GET /workspaces/:id/usage # Usage summary +``` + +### Project +``` +GET /projects/:id/users # Project share list +POST /projects/:id/users # Share (private projects, innovator+) +DELETE /projects/:id/users/:uid # Revoke share +DELETE /projects/:id # Soft delete project +``` + +### Conversations, Chats, Reports (existing, modified) +``` +DELETE /conversations/:id # Soft delete (via Python, not Directus direct) +DELETE /chats/:id # Soft delete +DELETE /reports/:id # Soft delete +``` + +### Admin (Dembrane internal) +``` +GET /admin/usage # Cross-org usage for manual invoicing +PATCH /admin/workspaces/:id/tier # Set workspace tier manually +``` + +--- + +## Migration Strategy + +### Existing User Migration + +> *I Ching, Hexagram 18 — "Work on What Has Been Spoiled": Correct the situation carefully. Acting too hastily brings misfortune.* + +**Pre-migration:** +1. Full database backup +2. Run migration on a DB clone first +3. Dry-run mode: log what would be created without writing + +**Migration script (runs via Python API against Directus HTTP API):** + +```python +async def migrate_existing_users(dry_run: bool = True): + """Idempotent. Safe to re-run. Per-user error handling.""" + + users = await directus.get("/users", {"limit": -1, "fields": ["id", "first_name", "last_name", "email"]}) + + for i, user in enumerate(users): + try: + # Idempotency: skip if app_user already exists + existing = await directus.get("/items/app_user", { + "filter": {"directus_user_id": {"_eq": user["id"]}}, + "limit": 1 + }) + if existing and len(existing) > 0: + logger.info(f"[{i+1}/{len(users)}] SKIP {user['email']} — already migrated") + continue + + if dry_run: + logger.info(f"[{i+1}/{len(users)}] WOULD migrate {user['email']}") + continue + + # Create app_user + app_user_id = str(uuid4()) + await directus.post("/items/app_user", { + "id": app_user_id, + "directus_user_id": user["id"], + "email": user.get("email", ""), + "display_name": f"{user.get('first_name', '')} {user.get('last_name', '')}".strip(), + }) + + # Create org + org_name = f"{user.get('first_name', 'My')}'s Organization" + org_id = str(uuid4()) + org_slug = slugify(org_name) + await directus.post("/items/org", { + "id": org_id, "name": org_name, "slug": org_slug, "created_by": app_user_id, + }) + await directus.post("/items/org_membership", { + "id": str(uuid4()), "org_id": org_id, "user_id": app_user_id, "role": "owner", + }) + + # Create default workspace + ws_id = str(uuid4()) + await directus.post("/items/workspace", { + "id": ws_id, "org_id": org_id, "name": "Default", + "slug": "default", "is_default": True, "tier": "pioneer", + "created_by": app_user_id, + }) + await directus.post("/items/workspace_membership", { + "id": str(uuid4()), "workspace_id": ws_id, "user_id": app_user_id, + "role": "owner", "source": "inherited", + }) + + # Move user's projects into default workspace + projects = await directus.get("/items/project", { + "filter": {"directus_user_id": {"_eq": user["id"]}}, + "fields": ["id"], + "limit": -1, + }) + for proj in projects: + await directus.patch(f"/items/project/{proj['id']}", { + "workspace_id": ws_id, + }) + + # Emit usage events + await emit_usage_event("org.created", {"v": 1, "name": org_name}, + org_id=org_id, user_id=app_user_id) + await emit_usage_event("workspace.created", + {"v": 1, "tier": "pioneer", "is_default": True}, + org_id=org_id, workspace_id=ws_id, user_id=app_user_id) + + logger.info(f"[{i+1}/{len(users)}] OK {user['email']} — org:{org_id} ws:{ws_id} projects:{len(projects)}") + + except Exception as e: + logger.error(f"[{i+1}/{len(users)}] FAIL {user['email']} — {e}") + # Continue to next user — don't fail the whole migration + continue +``` + +### New User Signup (Post-Deploy) + +1. User registers via Directus +2. Python post-registration endpoint (called by Directus hook or frontend): + - Create `app_user` + - Create org + org_membership(owner) + - Create default workspace + workspace_membership(owner, source='inherited') + - Emit usage events +3. Post-login router auto-redirects to workspace dashboard + +--- + +## Implementation Sequence + +> *Kautilya: "The work which has been begun should be completed."* +> Each phase ships a working increment. No phase depends on a later phase. + +### Phase 0: Soft Delete Conversion (Prerequisite) + +**This must happen BEFORE workspace features.** It's a standalone task. + +1. Add `deleted_at` field to: `conversation`, `project`, `chat`, `report` +2. Audit all delete operations in the codebase (frontend + Python) +3. Create Python API delete endpoints for each collection +4. Reroute all frontend delete calls through Python API +5. Convert each to soft delete + usage event emission +6. Update all Directus read queries to filter `{ "deleted_at": { "_null": true } }` +7. Test: deleted items invisible, usage events emitted, metadata preserved + +**Deliverable:** Every delete in the system goes through Python, is soft, and emits billing metadata. + +### Phase 1: Schema + Data Model + +1. Create Directus collections: `app_user`, `org`, `org_membership`, `workspace`, `workspace_membership`, `project_user`, `workspace_invite`, `usage_event` +2. Add fields to existing collections: `project.workspace_id`, `project.visibility`, plus `deleted_at` on workspace/org/membership tables +3. Sync schema via directus-extension-sync +4. Write `emit_usage_event` utility function +5. Write migration script (dry-run tested) + +**Deliverable:** All tables exist. Migration script ready to run. + +### Phase 2: Migration + Core API + +1. Run migration on production (after clone testing) +2. Implement tenant isolation middleware (`get_workspace_context`) +3. Implement permission resolution (`get_user_project_access`) +4. Workspace CRUD endpoints +5. Org CRUD endpoints +6. Membership management endpoints +7. Invite flow (create invite → send email via SendGrid → accept on registration) +8. Instrument existing code paths to emit usage events + +**Deliverable:** Full API working. Testable via Postman/curl. + +### Phase 3: Frontend — Routing + Selector + +1. Add workspace-scoped routing (`/:locale/w/:workspaceId/*`) +2. Post-login router logic (with deep link preservation) +3. Workspace selector page (card/list/partner variants) +4. Topbar changes (workspace name, change workspace button) +5. Progressive solo experience (hide workspace language for single-workspace users) +6. Legacy URL handling (redirect old URLs to workspace-scoped) +7. WorkspaceContext provider with stale state handling + +**Deliverable:** Users can log in, see workspace selector, navigate to workspace. + +### Phase 4: Frontend — Settings + Management + +1. Workspace settings page (General, Members, Branding, Legal, Billing tabs) +2. Org settings page (General, Members, Billing tabs) +3. Member invite modal +4. Usage dashboard (per-workspace + org-level billing rollup) +5. New workspace creation flow (3-step wizard) +6. User settings updates (workspace list, org list) + +**Deliverable:** Full workspace and org management in UI. + +### Phase 5: Frontend — Project Changes + +1. Project visibility (workspace/private) — private gated to innovator+ +2. Private project sharing UI (project_user management) +3. Create project within workspace context (auto-set workspace_id) +4. Empty states and tier-gating upgrade prompts +5. Post-migration onboarding modal for existing users + +**Deliverable:** Complete feature. Ready for production. + +--- + +## Tier Feature Matrix (UI Enforcement) + +| Feature | Pioneer | Innovator | Changemaker | Guardian | +|---------|:-------:|:---------:|:-----------:|:--------:| +| Projects + conversations | ✓ | ✓ | ✓ | ✓ | +| Chats + reports | ✓ | ✓ | ✓ | ✓ | +| Data export | — | ✓ | ✓ | ✓ | +| Private project sharing | — | ✓ | ✓ | ✓ | +| Whitelabel branding | — | — | ✓ | ✓ | +| API/integration access | — | — | ✓ | ✓ | + +**Enforcement:** Python API checks `workspace.tier` before allowing tier-gated operations. Frontend hides/disables UI elements with upgrade prompts. + +--- + +## Edge Cases + +| Scenario | Handling | +|---|---| +| Solo user, 1 workspace | Progressive: no workspace language shown. "Invite your organisation →" prompt. | +| Delete workspace with projects | Blocked. "Delete or move all projects first." | +| Last owner leaves workspace | Blocked. "Transfer ownership first." | +| Invite to unregistered email | Create workspace_invite, send email. On signup, auto-add. Expire 7 days. | +| User removed from workspace | Soft delete membership. Loses access to all workspace projects. project_user entries also soft-deleted. | +| Inherited member removed | Soft delete the `source='inherited'` row. NOT re-added automatically. | +| External user | `is_external=true` on membership. Tagged "External" in UI. Counts as seat. | +| 403 on workspace API call | Frontend: invalidate cache, redirect to selector, toast "Access changed." | +| Audio transcription fails | Usage event: `{ "billable": false }`. Doesn't count toward hours. | +| User requests GDPR deletion | Anonymize usage events + app_user. Hard delete PII. Preserve billing metadata. | +| Deep link through login | sessionStorage preserves URL, restore after auth. | + +--- + +## What's NOT In This Phase + +- Automated billing / Stripe +- Better Auth migration +- Workspace handoff (transfer between orgs) +- Kickback/commission tracking +- Google OAuth +- Project transfer between workspaces +- API key management +- Real-time / WebSocket updates +- Network effects features + +--- + +## Companion Documents + +| Document | Contains | +|----------|---------| +| `ui-flows-mockdown.md` | Complete screen-by-screen specs (Flows 1-24) | +| `b2b2b-strategy.md` | Partner model, Path A/B, billing, onboarding | +| `architecture-review.md` | Detailed security/performance/compliance recommendations | +| `failure-analysis.md` | 8-perspective red organisation analysis | + +--- + +## Final Wisdom + +> *Thiruvalluvar (திருவள்ளுவர்), Kural 391:* +> *"செய்க பொருளைச் செறுநர் செருக்கறுக்கும்"* +> *"Acquire wealth — it cuts the arrogance of foes."* +> +> Build the billing infrastructure now, even though billing is manual. The usage event log is your wealth. It cuts disputes, proves value, and funds everything else. + +> *Sun Tzu: "Every battle is won before it is ever fought."* +> +> The migration script, the soft delete conversion, the tenant isolation middleware — these are boring battles fought before the exciting feature work begins. Win them thoroughly. + +> *Lao Tzu: "A journey of a thousand miles begins with a single step."* +> +> Phase 0 (soft delete conversion) is the single step. It's not glamorous. It doesn't ship a visible feature. But without it, every phase that follows is built on sand. diff --git a/echo/frontend/AGENTS.md b/echo/frontend/AGENTS.md index fa27e2ec..553d9c61 100644 --- a/echo/frontend/AGENTS.md +++ b/echo/frontend/AGENTS.md @@ -10,7 +10,7 @@ - Saw a pattern (≥3 uses)? Ask: “Document this pattern?” - Fixed a bug? Ask: “Add this to warnings?” - Completed a repeatable workflow? Ask: “Document this workflow?” -- Resolved confusion for the team? Ask: “Add this clarification?” +- Resolved confusion for the organisation? Ask: “Add this clarification?” - Skip documenting secrets, temporary hacks, or anything explicitly excluded. ## Project Snapshot diff --git a/echo/frontend/package.json b/echo/frontend/package.json index ef384bfe..0bf0701b 100644 --- a/echo/frontend/package.json +++ b/echo/frontend/package.json @@ -40,6 +40,7 @@ "@mantine/notifications": "^7.17.8", "@mdxeditor/editor": "^3.40.0", "@phosphor-icons/react": "^2.1.10", + "@posthog/react": "^1.9.0", "@react-pdf/renderer": "^4.3.0", "@sentry/react": "^8.55.0", "@tabler/icons-react": "^3.34.1", @@ -69,6 +70,7 @@ "motion": "^11.18.2", "next-themes": "^0.4.6", "plausible-tracker": "^0.3.9", + "posthog-js": "^1.367.0", "re-resizable": "^6.11.2", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/echo/frontend/pnpm-lock.yaml b/echo/frontend/pnpm-lock.yaml index d8aeb4d5..9d707266 100644 --- a/echo/frontend/pnpm-lock.yaml +++ b/echo/frontend/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@posthog/react': + specifier: ^1.9.0 + version: 1.9.0(@types/react@19.0.12)(posthog-js@1.367.0)(react@19.0.0) '@react-pdf/renderer': specifier: ^4.3.0 version: 4.3.0(react@19.0.0) @@ -164,6 +167,9 @@ importers: plausible-tracker: specifier: ^0.3.9 version: 0.3.9 + posthog-js: + specifier: ^1.367.0 + version: 1.367.0 re-resizable: specifier: ^6.11.2 version: 6.11.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1597,10 +1603,78 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.1': + resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.208.0': + resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.208.0': + resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.208.0': + resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.1': + resolution: {integrity: sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.208.0': + resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@phosphor-icons/react@2.1.10': resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} engines: {node: '>=10'} @@ -1612,6 +1686,22 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@posthog/core@1.25.2': + resolution: {integrity: sha512-h2FO7ut/BbfwpAXWpwdDHTzQgUo9ibDFEs6ZO+3cI3KPWQt5XwczK1OLAuPprcjm8T/jl0SH8jSFo5XdU4RbTg==} + + '@posthog/react@1.9.0': + resolution: {integrity: sha512-lVdTsWT5+PtHBu44gSQ7QohbLjAYqHkFAIGAQ+HV8Eh9yj+OcnQ7mXCmyhaMlTBD3z7D0H1eWMp4vQaFnsIyWQ==} + peerDependencies: + '@types/react': '>=16.8.0' + posthog-js: '>=1.257.2' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@posthog/types@1.367.0': + resolution: {integrity: sha512-FUcTEAeKhuHKyCcTQPx/sTN3s8S+PusPsiP8T/LrG/T7pDkwMfNZG0/P630JX6fT6qiW0moVvVSsaXgZDJF7wg==} + '@preact/signals-core@1.14.0': resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==} @@ -1620,6 +1710,36 @@ packages: peerDependencies: preact: 10.x + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -2442,6 +2562,9 @@ packages: '@types/showdown@2.0.6': resolution: {integrity: sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2774,6 +2897,9 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -3015,6 +3141,9 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dotenv@16.4.7: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} @@ -3174,6 +3303,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -3576,6 +3708,9 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4116,6 +4251,9 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.367.0: + resolution: {integrity: sha512-jWNwB8XjlVUC9PbGaIlmsyohUDMBrwf7cvLuOY3lIOmWVO3L6VxTE3GZShjxpFKQtmWcPxFbf1hcbct1YCb6xg==} + preact@10.29.0: resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==} @@ -4142,6 +4280,10 @@ packages: property-information@7.0.0: resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -4161,6 +4303,9 @@ packages: qrcode-generator@1.4.4: resolution: {integrity: sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4890,6 +5035,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-vitals@5.2.0: + resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -6495,8 +6643,82 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 + + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/semantic-conventions@1.40.0': {} + '@phosphor-icons/react@2.1.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: react: 19.0.0 @@ -6505,6 +6727,17 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@posthog/core@1.25.2': {} + + '@posthog/react@1.9.0(@types/react@19.0.12)(posthog-js@1.367.0)(react@19.0.0)': + dependencies: + posthog-js: 1.367.0 + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + + '@posthog/types@1.367.0': {} + '@preact/signals-core@1.14.0': {} '@preact/signals@1.3.4(preact@10.29.0)': @@ -6512,6 +6745,29 @@ snapshots: '@preact/signals-core': 1.14.0 preact: 10.29.0 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -7314,6 +7570,9 @@ snapshots: '@types/showdown@2.0.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -7643,6 +7902,8 @@ snapshots: cookie@1.0.2: {} + core-js@3.49.0: {} + cosmiconfig@7.1.0: dependencies: '@types/parse-json': 4.0.2 @@ -7893,6 +8154,10 @@ snapshots: '@babel/runtime': 7.27.0 csstype: 3.1.3 + dompurify@3.3.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.4.7: {} downshift@7.6.2(react@19.0.0): @@ -8123,6 +8388,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -8519,6 +8786,8 @@ snapshots: chalk: 5.4.1 is-unicode-supported: 1.3.0 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -9317,6 +9586,22 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.367.0: + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@posthog/core': 1.25.2 + '@posthog/types': 1.367.0 + core-js: 3.49.0 + dompurify: 3.3.3 + fflate: 0.4.8 + preact: 10.29.0 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.2.0 + preact@10.29.0: {} prebuild-install@7.1.3: @@ -9356,6 +9641,21 @@ snapshots: property-information@7.0.0: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.13.14 + long: 5.3.2 + proxy-from-env@2.1.0: {} pseudolocale@2.1.0: @@ -9372,6 +9672,8 @@ snapshots: qrcode-generator@1.4.4: {} + query-selector-shadow-dom@1.0.1: {} + queue-microtask@1.2.3: {} queue@6.0.2: @@ -10189,6 +10491,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-vitals@5.2.0: {} + webidl-conversions@4.0.2: {} webpack-virtual-modules@0.6.2: diff --git a/echo/frontend/posthog-setup-report.md b/echo/frontend/posthog-setup-report.md new file mode 100644 index 00000000..f104fd6b --- /dev/null +++ b/echo/frontend/posthog-setup-report.md @@ -0,0 +1,33 @@ + +# PostHog post-wizard report + +The wizard has completed a deep integration of PostHog analytics into the Echo/Dembrane frontend. PostHog (`posthog-js` + `@posthog/react`) was installed, initialized in `src/main.tsx`, and the app wrapped with `PostHogProvider`. User identification via `posthog.identify()` is called on login and registration. Session reset via `posthog.reset()` is called on logout. Nine business-critical events are now tracked across six files. + +| Event | Description | File | +|---|---|---| +| `user_logged_in` | Host successfully logs in; calls `posthog.identify(email)` | `src/routes/auth/Login.tsx` | +| `user_login_failed` | Host login attempt fails (wrong password, invalid OTP, etc.) | `src/routes/auth/Login.tsx` | +| `user_registered` | Host submits the registration form; calls `posthog.identify(email)` | `src/routes/auth/Register.tsx` | +| `user_logged_out` | Host logs out; calls `posthog.reset()` | `src/components/auth/hooks/index.ts` | +| `project_created` | Host creates a new project from the projects home page | `src/routes/project/ProjectsHome.tsx` | +| `chat_mode_selected` | Host selects a chat mode (overview, deep_dive, agentic) | `src/routes/project/chat/ProjectChatRoute.tsx` | +| `chat_message_sent` | Host sends a message in the chat interface | `src/routes/project/chat/ProjectChatRoute.tsx` | +| `report_generated` | Host triggers report generation (immediate or scheduled) | `src/components/report/CreateReportForm.tsx` | +| `conversation_upload_started` | Host begins uploading conversation audio files | `src/components/dropzone/UploadConversationDropzone.tsx` | + +## Next steps + +We've built some insights and a dashboard for you to keep an eye on user behavior, based on the events we just instrumented: + +- **Dashboard**: [Analytics basics](https://eu.posthog.com/project/160282/dashboard/625219) +- **Insight**: [User Login & Registration Funnel](https://eu.posthog.com/project/160282/insights/sfG1jkEN) — conversion from registration to first login +- **Insight**: [Daily Active Users (Logins)](https://eu.posthog.com/project/160282/insights/58s1MgzV) — daily unique users logging in +- **Insight**: [Project & Report Creation Trend](https://eu.posthog.com/project/160282/insights/h9dm8arV) — weekly project creation and report generation activity +- **Insight**: [Chat Engagement Funnel](https://eu.posthog.com/project/160282/insights/jMr58R6h) — funnel from chat mode selection to first message sent +- **Insight**: [Conversation Upload Trend](https://eu.posthog.com/project/160282/insights/ofstTlUa) — weekly conversation audio uploads + +### Agent skill + +We've left an agent skill folder in your project. You can use this context for further agent development when using Claude Code. This will help ensure the model provides the most up-to-date approaches for integrating PostHog. + + diff --git a/echo/frontend/public/dembrane-logo-new.png b/echo/frontend/public/dembrane-logo-new.png new file mode 100644 index 00000000..f0144547 Binary files /dev/null and b/echo/frontend/public/dembrane-logo-new.png differ diff --git a/echo/frontend/public/dembrane-logo-new.svg b/echo/frontend/public/dembrane-logo-new.svg new file mode 100644 index 00000000..4881f9b2 --- /dev/null +++ b/echo/frontend/public/dembrane-logo-new.svg @@ -0,0 +1,99 @@ + + + + + + + + + diff --git a/echo/frontend/public/illustrations/onboarding-banner.png b/echo/frontend/public/illustrations/onboarding-banner.png new file mode 100644 index 00000000..6e8ddc13 Binary files /dev/null and b/echo/frontend/public/illustrations/onboarding-banner.png differ diff --git a/echo/frontend/src/App.tsx b/echo/frontend/src/App.tsx index 0fb5e7ea..06f536a5 100644 --- a/echo/frontend/src/App.tsx +++ b/echo/frontend/src/App.tsx @@ -4,8 +4,11 @@ import "@mantine/dates/styles.css"; import "@mantine/dropzone/styles.css"; import { MantineProvider } from "@mantine/core"; +import "@mantine/core/styles.css"; import { DatesProvider } from "@mantine/dates"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ModalsProvider } from "@mantine/modals"; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { detectAndEmitPilotBlock } from "./lib/pilotBlock"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { useEffect } from "react"; import { RouterProvider } from "react-router/dom"; @@ -13,11 +16,40 @@ import { I18nProvider } from "./components/layout/I18nProvider"; import { USE_PARTICIPANT_ROUTER } from "./config"; import { AppPreferencesProvider } from "./hooks/useAppPreferences"; import { WhitelabelLogoProvider } from "./hooks/useWhitelabelLogo"; +import type { PropsWithChildren } from "react"; +import { + WorkspaceContext, + useWorkspaceProvider, +} from "./hooks/useWorkspace"; + +function WorkspaceProvider({ children }: PropsWithChildren) { + const value = useWorkspaceProvider(true); + return ( + + {children} + + ); +} import { analytics } from "./lib/analytics"; import { mainRouter, participantRouter } from "./Router"; import { theme } from "./theme"; -const queryClient = new QueryClient(); +// Pilot hard-block (matrix §8): intercept 402 + copy-locked body from +// host-side mutations and fan out a level-3 modal. Detection is +// copy-substring since we control both the backend body and the frontend +// match — see lib/pilotBlock.ts. +const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onError: (error) => { + detectAndEmitPilotBlock(error); + }, + }), + queryCache: new QueryCache({ + onError: (error) => { + detectAndEmitPilotBlock(error); + }, + }), +}); const router = USE_PARTICIPANT_ROUTER ? participantRouter : mainRouter; @@ -86,9 +118,18 @@ export const App = () => { - - - + + {/* I18nProvider must wrap ModalsProvider: Mantine's + modal portal re-enters the tree outside any + non-context-aware ancestor, so inside + modals.openConfirmModal children needs Lingui + context available from this level down. */} + + + + + + diff --git a/echo/frontend/src/Router.tsx b/echo/frontend/src/Router.tsx index 10c9b9db..2bc1f4be 100644 --- a/echo/frontend/src/Router.tsx +++ b/echo/frontend/src/Router.tsx @@ -4,14 +4,17 @@ import { createLazyRoute, } from "./components/common/LazyRoute"; import { Protected } from "./components/common/Protected"; +import { WorkspaceRedirect } from "./components/common/WorkspaceRedirect"; import { ErrorPage } from "./components/error/ErrorPage"; import { AuthLayout } from "./components/layout/AuthLayout"; // Layout components - keep as regular imports since they're used frequently import { BaseLayout } from "./components/layout/BaseLayout"; +import { WorkspaceLayout } from "./components/layout/WorkspaceLayout"; import { LanguageLayout } from "./components/layout/LanguageLayout"; import { ParticipantLayout } from "./components/layout/ParticipantLayout"; import { ProjectConversationLayout } from "./components/layout/ProjectConversationLayout"; import { ProjectLayout } from "./components/layout/ProjectLayout"; +import { ProjectAccessGuard } from "./components/project/ProjectAccessGuard"; import { ProjectLibraryLayout } from "./components/layout/ProjectLibraryLayout"; import { ProjectOverviewLayout } from "./components/layout/ProjectOverviewLayout"; import { ParticipantConversationAudioContent } from "./components/participant/ParticipantConversationAudioContent"; @@ -29,6 +32,7 @@ import { ProjectConversationOverviewRoute } from "./routes/project/conversation/ import { ProjectConversationTranscript } from "./routes/project/conversation/ProjectConversationTranscript"; // Tab-based routes - import directly for now to debug import { + ProjectAccessRoute, ProjectPortalSettingsRoute, ProjectSettingsRoute, } from "./routes/project/ProjectRoutes"; @@ -107,6 +111,159 @@ const HostGuidePage = createLazyNamedRoute( () => import("./routes/project/HostGuidePage"), "HostGuidePage", ); +const OnboardingRoute = createLazyNamedRoute( + () => import("./routes/onboarding/OnboardingRoute"), + "OnboardingRoute", +); +const WorkspaceSelectorRoute = createLazyNamedRoute( + () => import("./routes/workspaces/WorkspaceSelectorRoute"), + "WorkspaceSelectorRoute", +); +const CreateWorkspaceRoute = createLazyNamedRoute( + () => import("./routes/workspaces/CreateWorkspaceRoute"), + "CreateWorkspaceRoute", +); +const CreateProjectRoute = createLazyNamedRoute( + () => import("./routes/project/CreateProjectRoute"), + "CreateProjectRoute", +); +const WorkspaceSettingsRoute = createLazyNamedRoute( + () => import("./routes/workspaces/WorkspaceSettingsRoute"), + "WorkspaceSettingsRoute", +); +const AcceptInviteRoute = createLazyNamedRoute( + () => import("./routes/invite/AcceptInviteRoute"), + "AcceptInviteRoute", +); +const MyInvitesRoute = createLazyNamedRoute( + () => import("./routes/invite/MyInvitesRoute"), + "MyInvitesRoute", +); +const OrganisationRoute = createLazyNamedRoute( + () => import("./routes/organisation/OrganisationRoute"), + "OrganisationRoute", +); +const AdminSettingsRoute = createLazyNamedRoute( + () => import("./routes/admin/AdminSettingsRoute"), + "AdminSettingsRoute", +); +// Project route children — shared between /projects and /w/:workspaceId/projects +const projectRouteChildren = [ + { + element: , + index: true, + }, + { + element: , + path: "new", + }, + { + children: [ + { + children: [ + { + children: [ + { + element: , + index: true, + }, + { + element: , + path: "overview", + }, + { + element: , + path: "portal-editor", + }, + { + // "Access & usage" tab (2026-04-24) — dedicated + // surface for per-project usage, sharing, and the + // list of who can actually see the project. + element: , + path: "access", + }, + { + // /sharing tab retired 2026-04-23 — bookmark redirect + // now points at the new /access tab. + element: , + path: "sharing", + }, + ], + element: , + path: "", + }, + { + element: , + path: "chats/new", + }, + { + element: , + path: "chats/:chatId", + }, + { + element: , + path: "chats/:chatId/debug", + }, + { + children: [ + { + element: , + index: true, + }, + { + element: , + path: "overview", + }, + { + element: , + path: "transcript", + }, + { + element: , + path: "debug", + }, + ], + element: , + path: "conversation/:conversationId", + }, + + { + children: [ + { + element: , + path: "views/:viewId/aspects/:aspectId", + }, + { + element: , + path: "views/:viewId", + }, + { + element: , + index: true, + }, + ], + element: , + path: "library", + }, + { + element: , + path: "report", + }, + { + element: , + path: "debug", + }, + ], + element: ( + + + + ), + }, + ], + path: ":projectId", + }, +]; export const mainRouter = createBrowserRouter([ { @@ -163,6 +320,79 @@ export const mainRouter = createBrowserRouter([ ), path: "verify-email", }, + { + // Onboarding - one-time setup after first login + element: ( + + + + ), + path: "onboarding", + }, + { + // Accept invite — public (email link target). Handles logged-out, + // logged-in-wrong-email, and logged-in-matching-email states. + element: , + path: "invite/accept", + }, + { + // My pending invites (authenticated list view with accept/decline) + element: ( + + + + ), + path: "invites", + }, + { + // Workspace selector + create — canonical path is /w. + children: [ + { + element: , + index: true, + }, + { + element: , + path: "new", + }, + { + element: , + path: ":workspaceId", + }, + { + // Splat so the tab lives in the path + // (/w/:workspaceId/settings/:tab). The component parses + // the trailing segment. + element: , + path: ":workspaceId/settings/*", + }, + ], + element: ( + + + + ), + path: "w", + }, + { + // Organisation (org) admin surface. Canonical path is /o/:organisationId — + // matches the /w/:workspaceId pattern. + children: [ + { + // Splat so tab state lives in the path + // (/o/:organisationId/:tab) — matches the project-tab pattern. + // The component parses the trailing segment itself. + element: , + path: ":organisationId/*", + }, + ], + element: ( + + + + ), + path: "o", + }, { // Host Guide - standalone page, protected but no header/layout element: ( @@ -173,100 +403,32 @@ export const mainRouter = createBrowserRouter([ path: "projects/:projectId/host-guide", }, { + // Workspace-scoped projects: /w/:workspaceId/projects/... + // This is the PRIMARY route — workspace ID in URL makes it shareable children: [ { - element: , - index: true, + children: projectRouteChildren, + element: , + path: "projects", }, + ], + element: ( + + + + ), + path: "w/:workspaceId", + }, + { + // Legacy /projects — redirects to /w/:workspaceId/projects + // Kept for backward compat (bookmarks, existing links) + children: [ { - children: [ - { - children: [ - { - children: [ - { - element: , - index: true, - }, - { - element: , - path: "overview", - }, - { - element: , - path: "portal-editor", - }, - ], - element: , - path: "", - }, - { - element: , - path: "chats/new", - }, - { - element: , - path: "chats/:chatId", - }, - { - element: , - path: "chats/:chatId/debug", - }, - { - children: [ - { - element: , - index: true, - }, - { - element: , - path: "overview", - }, - { - element: , - path: "transcript", - }, - { - element: , - path: "debug", - }, - ], - element: , - path: "conversation/:conversationId", - }, - - { - children: [ - { - element: , - path: "views/:viewId/aspects/:aspectId", - }, - { - element: , - path: "views/:viewId", - }, - { - element: , - index: true, - }, - ], - element: , - path: "library", - }, - { - element: , - path: "report", - }, - { - element: , - path: "debug", - }, - ], - element: , - }, - ], - path: ":projectId", + element: , + index: true, }, + // Direct project access still works (falls through to v1) + ...projectRouteChildren.slice(1), ], element: ( @@ -289,6 +451,21 @@ export const mainRouter = createBrowserRouter([ ), path: "settings", }, + { + // Staff-only — billing rollup, at-risk watch, partners, upgrades. + // Client-side guard lives inside AdminSettingsRoute (reads + // meV2.is_staff); backend /v2/admin/* also gates on is_admin. + children: [ + { element: , index: true }, + { element: , path: ":tab" }, + ], + element: ( + + + + ), + path: "admin", + }, { element: , path: "*", diff --git a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx deleted file mode 100644 index 0faa6d0d..00000000 --- a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { ActionIcon, Button, Group, Stack, Text } from "@mantine/core"; -import { X } from "@phosphor-icons/react"; -import { testId } from "@/lib/testUtils"; -import { useUnreadAnnouncements } from "./hooks"; - -export const AnnouncementDrawerHeader = ({ - onClose, - onMarkAllAsRead, - isPending, -}: { - onClose: () => void; - onMarkAllAsRead: () => void; - isPending: boolean; -}) => { - const { data: unreadCount } = useUnreadAnnouncements(); - const hasUnreadAnnouncements = unreadCount && unreadCount > 0; - - return ( - - - - Announcements - - - - - - - {hasUnreadAnnouncements && ( - - {unreadCount}{" "} - {unreadCount === 1 - ? t`unread announcement` - : t`unread announcements`} - - )} - {hasUnreadAnnouncements && ( - - )} - - - ); -}; diff --git a/echo/frontend/src/components/announcement/AnnouncementErrorState.tsx b/echo/frontend/src/components/announcement/AnnouncementErrorState.tsx deleted file mode 100644 index 592d8192..00000000 --- a/echo/frontend/src/components/announcement/AnnouncementErrorState.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { Alert, Box, Button, Stack, Text } from "@mantine/core"; -import { IconAlertCircle, IconRefresh } from "@tabler/icons-react"; - -interface AnnouncementErrorStateProps { - onRetry: () => void; - isLoading?: boolean; -} - -export const AnnouncementErrorState = ({ - onRetry, - isLoading = false, -}: AnnouncementErrorStateProps) => { - return ( - - } - color="red" - variant="light" - title={Error loading announcements} - > - - - Failed to get announcements - - - - - - ); -}; diff --git a/echo/frontend/src/components/announcement/AnnouncementIcon.tsx b/echo/frontend/src/components/announcement/AnnouncementIcon.tsx deleted file mode 100644 index 409166b2..00000000 --- a/echo/frontend/src/components/announcement/AnnouncementIcon.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { ActionIcon, Box, Group, Indicator, Loader, Text } from "@mantine/core"; -import { FlagBannerIcon } from "@phosphor-icons/react"; -import { useAnnouncementDrawer } from "@/components/announcement/hooks"; -import { getTranslatedContent } from "@/components/announcement/hooks/useProcessedAnnouncements"; -import { useLanguage } from "@/hooks/useLanguage"; -import { testId } from "@/lib/testUtils"; -import { useLatestAnnouncement, useUnreadAnnouncements } from "./hooks"; - -export const AnnouncementIcon = () => { - const { open } = useAnnouncementDrawer(); - const { language } = useLanguage(); - const { data: latestAnnouncement, isLoading: isLoadingLatest } = - useLatestAnnouncement(); - const { data: unreadCount, isLoading: isLoadingUnread } = - useUnreadAnnouncements(); - - const title = latestAnnouncement - ? getTranslatedContent(latestAnnouncement as Announcement, language).title - : ""; - - const isUnread = latestAnnouncement - ? !latestAnnouncement.activity?.some( - (activity: AnnouncementActivity) => activity.read === true, - ) - : false; - - const showPreview = - isUnread && title && latestAnnouncement?.level === "info"; - - const isLoading = isLoadingLatest || isLoadingUnread; - - return ( - - - - {unreadCount || 0} - - } - size={20} - disabled={(unreadCount || 0) === 0} - withBorder - > - - {isLoading ? ( - - ) : ( - - )} - - - - - {showPreview && ( - - - {title} - - - )} - - ); -}; diff --git a/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx b/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx deleted file mode 100644 index 3e06a361..00000000 --- a/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Box, Group, Stack, ThemeIcon } from "@mantine/core"; -import { IconInfoCircle } from "@tabler/icons-react"; - -export const AnnouncementSkeleton = () => ( - - {[1, 2, 3, 4, 5, 6].map((i) => ( - - - - - {/* Use a generic icon skeleton */} - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -); diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx deleted file mode 100644 index 4725a4f0..00000000 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { - Box, - Center, - Collapse, - Divider, - Group, - Loader, - ScrollArea, - Stack, - Text, - ThemeIcon, - UnstyledButton, -} from "@mantine/core"; -import { - CaretDown, - CaretUp, - CheckCircle, - Sparkle, -} from "@phosphor-icons/react"; -import { useEffect, useMemo, useState } from "react"; -import { useInView } from "react-intersection-observer"; -import { useAnnouncementDrawer } from "@/components/announcement/hooks"; -import { - useProcessedAnnouncements, - useWhatsNewProcessed, -} from "@/components/announcement/hooks/useProcessedAnnouncements"; -import { useLanguage } from "@/hooks/useLanguage"; -import { analytics } from "@/lib/analytics"; -import { AnalyticsEvents as events } from "@/lib/analyticsEvents"; -import { testId } from "@/lib/testUtils"; -import { Drawer } from "../common/Drawer"; -import { AnnouncementDrawerHeader } from "./AnnouncementDrawerHeader"; -import { AnnouncementErrorState } from "./AnnouncementErrorState"; -import { AnnouncementItem } from "./AnnouncementItem"; -import { AnnouncementSkeleton } from "./AnnouncementSkeleton"; -import { WhatsNewItem } from "./WhatsNewItem"; -import { - useInfiniteAnnouncements, - useMarkAllAsReadMutation, - useMarkAsReadMutation, - useMarkAsUnreadMutation, - useWhatsNewAnnouncements, -} from "./hooks"; - -export const Announcements = () => { - const { isOpen, close } = useAnnouncementDrawer(); - const { language } = useLanguage(); - const markAsReadMutation = useMarkAsReadMutation(); - const markAsUnreadMutation = useMarkAsUnreadMutation(); - const markAllAsReadMutation = useMarkAllAsReadMutation(); - const [openedOnce, setOpenedOnce] = useState(false); - const [readExpanded, setReadExpanded] = useState(false); - const [whatsNewExpanded, setWhatsNewExpanded] = useState(false); - - const { ref: loadMoreRef, inView } = useInView(); - - // Track when drawer is opened for the first time - useEffect(() => { - if (isOpen && !openedOnce) { - setOpenedOnce(true); - try { - analytics.trackEvent(events.ANNOUNCEMENT_CREATED); - } catch (error) { - console.warn("Analytics tracking failed:", error); - } - } - }, [isOpen, openedOnce]); - - const { - data: announcementsData, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - isError, - refetch, - } = useInfiniteAnnouncements({ - enabled: openedOnce, - options: { - initialLimit: 10, - }, - }); - - const { data: whatsNewData } = useWhatsNewAnnouncements({ - enabled: openedOnce, - }); - - // Flatten all announcements from all pages - const allAnnouncements = - announcementsData?.pages.flatMap( - (page) => (page as { announcements: Announcement[] }).announcements, - ) ?? []; - - // Process announcements with translations and read status - const processedAnnouncements = useProcessedAnnouncements( - allAnnouncements, - language, - ); - - // Split into unread and read - const unreadAnnouncements = useMemo( - () => processedAnnouncements.filter((a) => !a.read), - [processedAnnouncements], - ); - const readAnnouncements = useMemo( - () => processedAnnouncements.filter((a) => a.read), - [processedAnnouncements], - ); - - // Auto-expand read section when there are no unread items - // biome-ignore lint/correctness/useExhaustiveDependencies: only react to unread/read count changes - useEffect(() => { - if (unreadAnnouncements.length === 0 && readAnnouncements.length > 0) { - setReadExpanded(true); - } - }, [unreadAnnouncements.length, readAnnouncements.length]); - - // Process "What's new" announcements, excluding those already in the main list - const whatsNewRaw = useWhatsNewProcessed(whatsNewData ?? [], language); - const whatsNewAnnouncements = useMemo(() => { - const mainIds = new Set(processedAnnouncements.map((a) => a.id)); - return whatsNewRaw.filter((a) => !mainIds.has(a.id)); - }, [whatsNewRaw, processedAnnouncements]); - - // Load more announcements when user scrolls to bottom - useEffect(() => { - if (inView && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); - - const handleMarkAsRead = async (id: string) => { - markAsReadMutation.mutate({ announcementId: id }); - }; - - const handleMarkAsUnread = async (id: string, activityIds: string[]) => { - markAsUnreadMutation.mutate({ announcementId: id, activityIds }); - }; - - const handleMarkAllAsRead = async () => { - markAllAsReadMutation.mutate(); - }; - - const handleRetry = () => { - refetch(); - }; - - return ( - - } - classNames={{ - body: "p-0", - content: "border-0", - header: "border-b", - title: "px-3 w-full", - }} - withCloseButton={false} - styles={{ - content: { - maxWidth: "95%", - }, - }} - {...testId("announcement-drawer")} - > - - - - {isError ? ( - - ) : isLoading ? ( - - ) : processedAnnouncements.length === 0 && - whatsNewAnnouncements.length === 0 ? ( - - - No announcements available - - - ) : ( - <> - {/* Unread announcements */} - {unreadAnnouncements.map((announcement, index) => ( - - ))} - - {isFetchingNextPage && ( -
- -
- )} - - {/* Earlier (read) section */} - {readAnnouncements.length > 0 && ( - <> - - setReadExpanded(!readExpanded) - } - > - - - - Earlier - - {readExpanded ? ( - - ) : ( - - )} - - - } - labelPosition="left" - /> - - - - {readAnnouncements.map( - (announcement, index) => ( - - ), - )} - - - - )} - - {/* Release notes */} - {whatsNewAnnouncements.length > 0 && ( - <> - - setWhatsNewExpanded(!whatsNewExpanded) - } - > - - - - Release notes - - {whatsNewExpanded ? ( - - ) : ( - - )} - - - } - labelPosition="left" - /> - - - - {whatsNewAnnouncements.map((announcement) => ( - - ))} - - - - )} - - {/* Infinite scroll sentinel */} -
- - )} - - - - - ); -}; diff --git a/echo/frontend/src/components/announcement/WhatsNewItem.tsx b/echo/frontend/src/components/announcement/WhatsNewItem.tsx deleted file mode 100644 index a6ecf3c8..00000000 --- a/echo/frontend/src/components/announcement/WhatsNewItem.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { - Box, - Collapse, - Group, - Stack, - Text, - ThemeIcon, - UnstyledButton, -} from "@mantine/core"; -import { CaretDown, CaretRight, Sparkle } from "@phosphor-icons/react"; -import { useState } from "react"; -import { Markdown } from "@/components/common/Markdown"; -import { testId } from "@/lib/testUtils"; -import type { ProcessedAnnouncement } from "./hooks/useProcessedAnnouncements"; -import { useFormatDate } from "./utils/dateUtils"; - -interface WhatsNewItemProps { - announcement: ProcessedAnnouncement; -} - -export const WhatsNewItem = ({ announcement }: WhatsNewItemProps) => { - const [expanded, setExpanded] = useState(false); - const formatDate = useFormatDate(); - - return ( - - setExpanded(!expanded)} - w="100%" - > - - {expanded ? ( - - ) : ( - - )} - - - - - {announcement.title} - - - {formatDate(announcement.created_at)} - - - - - - - - - - - ); -}; diff --git a/echo/frontend/src/components/auth/hooks/index.ts b/echo/frontend/src/components/auth/hooks/index.ts index bf6a2b4c..fa783a3d 100644 --- a/echo/frontend/src/components/auth/hooks/index.ts +++ b/echo/frontend/src/components/auth/hooks/index.ts @@ -4,6 +4,7 @@ import { registerUser, registerUserVerify, } from "@directus/sdk"; +import { usePostHog } from "@posthog/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { useLocation, useSearchParams } from "react-router"; @@ -11,8 +12,23 @@ import { toast } from "@/components/common/Toaster"; import { ADMIN_BASE_URL, API_BASE_URL } from "@/config"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { directus } from "@/lib/directus"; +import { isAuthPath } from "../utils/authPaths"; import { throwWithMessage } from "../utils/errorUtils"; +const buildLoginQuery = ({ + next, + reason, +}: { + next?: string; + reason?: string; +}): string => { + const params = new URLSearchParams(); + if (next) params.set("next", next); + if (reason) params.set("reason", reason); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +}; + export const useCurrentUser = ({ enabled = true, }: { @@ -22,10 +38,9 @@ export const useCurrentUser = ({ enabled, queryFn: async () => { try { - const response = await fetch( - `${API_BASE_URL}/user-settings/me`, - { credentials: "include" }, - ); + const response = await fetch(`${API_BASE_URL}/user-settings/me`, { + credentials: "include", + }); if (!response.ok) return null; return response.json(); } catch (_error) { @@ -60,9 +75,7 @@ export const useResetPasswordMutation = () => { } }, onSuccess: () => { - toast.success( - "Password reset successfully. Please login with new password.", - ); + toast.success("Password reset. Log in with your new password."); navigate("/login"); }, }); @@ -85,7 +98,7 @@ export const useRequestPasswordResetMutation = () => { toast.error(e.message); }, onSuccess: () => { - toast.success("Password reset email sent successfully"); + toast.success("Check your email for reset instructions."); navigate("/check-your-email"); }, }); @@ -96,56 +109,100 @@ export const useVerifyMutation = (doRedirect = true) => { return useMutation({ mutationFn: async (data: { token: string }) => { + // 15s ceiling — without it, a hung Directus / proxy would + // leave the page spinning forever (original infinite-loading bug). + const timeout = new Promise((_, reject) => + setTimeout( + () => reject(new Error("Verification timed out. Try again.")), + 15_000, + ), + ); try { - const response = await directus.request(registerUserVerify(data.token)); + const response = await Promise.race([ + directus.request(registerUserVerify(data.token)), + timeout, + ]); return response; } catch (e) { throwWithMessage(e); } }, - onError: (e) => { - toast.error(e.message); - }, + // No toast here — the verify page shows the status inline, so a + // parallel toast is double-signalling. Errors surface via the + // verifyMutation.isError branch on the page. onSuccess: () => { - toast.success("Email verified successfully."); if (doRedirect) { + // Redirect with a "?verified=1" hint so /login can show + // "Your email is verified. Log in to continue." Shorter + // delay than before — 1.5s is enough to read the page + // state before we move the user along. setTimeout(() => { - // window.location.href = `/login?new=true`; - navigate("/login?new=true"); - }, 4500); + navigate("/login?verified=1"); + }, 1500); + } + }, + }); +}; + +// Probes whether an email is already registered, so Register.tsx can +// block before Directus's anti-enumeration silent-200 traps the user on +// "Check your email" forever. Failures collapse to "available" so an +// outage of the probe never blocks a legit signup. +export const useCheckEmailMutation = () => { + return useMutation({ + mutationFn: async ( + email: string, + ): Promise<{ status: "available" | "registered" | "invalid" }> => { + try { + const res = await fetch(`${API_BASE_URL}/v2/auth/check-email`, { + body: JSON.stringify({ email }), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + if (!res.ok) { + return { status: "available" }; + } + return res.json(); + } catch (_e) { + return { status: "available" }; } }, }); }; export const useRegisterMutation = () => { - const navigate = useI18nNavigate(); return useMutation({ mutationFn: async (payload: Parameters) => { try { - const response = await directus.request(registerUser(...payload)); - return response; + return await directus.request(registerUser(...payload)); } catch (e) { + // Map the raw Directus error to a user-facing message, then + // re-throw so react-query marks the mutation as failed and + // onError / the inline Alert both fire. Previously only the + // "no permission" case re-threw; every other failure fell + // through as undefined and looked like a success, which + // bounced users to the "Check your email" step even when + // registration actually failed (e.g. validation errors). + let mapped: Error = new Error("Registration failed"); try { throwWithMessage(e); } catch (inner) { - if (inner instanceof Error) { - if (inner.message === "You don't have permission to access this.") { - throw new Error( - "Oops! It seems your email is not eligible for registration at this time. Please consider joining our waitlist for future updates!", - ); - } - } + if (inner instanceof Error) mapped = inner; } + if (mapped.message === "You don't have permission to access this.") { + throw new Error( + "Oops! It seems your email is not eligible for registration at this time. Please consider joining our waitlist for future updates!", + ); + } + throw mapped; } }, - onError: (e) => { - toast.error(e.message); - }, - onSuccess: () => { - toast.success("Please check your email to verify your account."); - navigate("/check-your-email"); - }, + // Success handling lives inline on the Register page — the + // stepper advances to step 2 ("Check your email"). No toast + + // no redirect, since the inline state already shows the user + // exactly what's next. Failures surface via the inline Alert + // that reads from `registerMutation.error`. }); }; @@ -181,6 +238,7 @@ export const useLoginMutation = () => { export const useLogoutMutation = () => { const queryClient = useQueryClient(); const navigate = useI18nNavigate(); + const posthog = usePostHog(); return useMutation({ mutationFn: async ({ @@ -203,28 +261,29 @@ export const useLogoutMutation = () => { }, onError: (_error, { next, reason, doRedirect }) => { if (doRedirect) { - navigate( - "/login" + - (next ? `?next=${encodeURIComponent(next)}` : "") + - (reason ? `&reason=${reason}` : ""), - ); + navigate(`/login${buildLoginQuery({ next, reason })}`); } }, onMutate: async () => { await queryClient.cancelQueries(); + // Wipe cache before re-setting session=false — prevents the next user + // from briefly seeing the previous user's workspaces/projects. + queryClient.removeQueries(); queryClient.setQueryData(["auth", "session"], false); - queryClient.removeQueries({ exact: false, queryKey: ["users", "me"] }); + if (typeof window !== "undefined") { + try { + sessionStorage.removeItem("dembrane_ws_selected"); + } catch {} + } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["auth", "session"] }); }, onSuccess: (_data, { next, reason, doRedirect }) => { + posthog?.capture("user_logged_out"); + posthog?.reset(); if (doRedirect) { - navigate( - "/login" + - (next ? `?next=${encodeURIComponent(next)}` : "") + - (reason ? `&reason=${reason}` : ""), - ); + navigate(`/login${buildLoginQuery({ next, reason })}`); } }, }); @@ -249,15 +308,21 @@ export const useAuthenticated = (doRedirect = false) => { useEffect(() => { if (sessionQuery.isError && doRedirect && !hasLoggedOutRef.current) { hasLoggedOutRef.current = true; + // Preserve full URL through /login; skip auth pages to avoid loops. + const nextUrl = isAuthPath(location.pathname) + ? undefined + : location.pathname + location.search + location.hash; logoutMutation.mutate({ doRedirect, - next: location.pathname, + next: nextUrl, reason: searchParams.get("reason") ?? "", }); } }, [ doRedirect, + location.hash, location.pathname, + location.search, logoutMutation, searchParams, sessionQuery.isError, diff --git a/echo/frontend/src/components/auth/utils/authPaths.ts b/echo/frontend/src/components/auth/utils/authPaths.ts new file mode 100644 index 00000000..f8c659b2 --- /dev/null +++ b/echo/frontend/src/components/auth/utils/authPaths.ts @@ -0,0 +1,6 @@ +// Matches unauthenticated routes (locale-prefix tolerant) so they're never used as ?next=. +const AUTH_PATH_RE = + /^\/(?:[a-z]{2}-[A-Z]{2}\/)?(?:login|register|verify-email|request-password-reset|password-reset|check-your-email)(?:$|\/|\?)/; + +export const isAuthPath = (pathname: string): boolean => + AUTH_PATH_RE.test(pathname); diff --git a/echo/frontend/src/components/chat/ChatTemplatesMenu.tsx b/echo/frontend/src/components/chat/ChatTemplatesMenu.tsx index 4fcd4a55..fcfde398 100644 --- a/echo/frontend/src/components/chat/ChatTemplatesMenu.tsx +++ b/echo/frontend/src/components/chat/ChatTemplatesMenu.tsx @@ -52,8 +52,14 @@ type ChatTemplatesMenuProps = { title: string; content: string; icon: string | null; + scope?: "user" | "workspace"; + can_edit?: boolean; }>; - onCreateUserTemplate?: (payload: { title: string; content: string }) => void; + onCreateUserTemplate?: (payload: { + title: string; + content: string; + scope?: "user" | "workspace"; + }) => void; onUpdateUserTemplate?: (payload: { id: string; title: string; @@ -63,6 +69,9 @@ type ChatTemplatesMenuProps = { isCreatingTemplate?: boolean; isUpdatingTemplate?: boolean; isDeletingTemplate?: boolean; + // Passed through to the TemplatesModal create form. True when the + // caller has a workspace and membership lets them share templates. + canCreateWorkspaceTemplate?: boolean; // Quick access quickAccessItems?: QuickAccessItem[]; onSaveQuickAccess?: (items: QuickAccessItem[]) => void; @@ -165,6 +174,7 @@ export const ChatTemplatesMenu = ({ onExternalClose, saveAsTemplateContent, onClearSaveAsTemplate, + canCreateWorkspaceTemplate = false, }: ChatTemplatesMenuProps) => { const [opened, { open, close }] = useDisclosure(false); @@ -381,6 +391,7 @@ export const ChatTemplatesMenu = ({ onToggleAiSuggestions={onToggleAiSuggestions} saveAsTemplateContent={saveAsTemplateContent} onClearSaveAsTemplate={onClearSaveAsTemplate} + canCreateWorkspaceTemplate={canCreateWorkspaceTemplate} /> ); diff --git a/echo/frontend/src/components/chat/TemplatesModal.tsx b/echo/frontend/src/components/chat/TemplatesModal.tsx index afc0d0e0..3bc7135b 100644 --- a/echo/frontend/src/components/chat/TemplatesModal.tsx +++ b/echo/frontend/src/components/chat/TemplatesModal.tsx @@ -71,10 +71,13 @@ type TemplatesModalProps = { title: string; content: string; icon: string | null; + scope?: "user" | "workspace"; + can_edit?: boolean; }>; onCreateUserTemplate?: (payload: { title: string; content: string; + scope?: "user" | "workspace"; }) => Promise | void; onUpdateUserTemplate?: (payload: { id: string; @@ -92,6 +95,10 @@ type TemplatesModalProps = { onToggleAiSuggestions?: (hide: boolean) => void; saveAsTemplateContent?: string | null; onClearSaveAsTemplate?: () => void; + // When true, the create form shows a "Share with organisation" toggle. Set + // false for contexts without a workspace (e.g. agentic playground) or + // when the caller is an external guest who can't create organisation templates. + canCreateWorkspaceTemplate?: boolean; }; type UnifiedTemplate = { @@ -99,6 +106,10 @@ type UnifiedTemplate = { title: string; content: string; source: "dembrane" | "user"; + // Only meaningful for source='user': distinguishes personal from + // workspace-shared templates. Undefined for 'dembrane' (built-in). + scope?: "user" | "workspace"; + canEdit?: boolean; key: string; }; @@ -178,6 +189,7 @@ export const TemplatesModal = ({ onToggleAiSuggestions, saveAsTemplateContent, onClearSaveAsTemplate, + canCreateWorkspaceTemplate = false, }: TemplatesModalProps) => { const [view, setView] = useState("browse"); const [searchQuery, setSearchQuery] = useState(""); @@ -185,6 +197,10 @@ export const TemplatesModal = ({ const [animateList, enableAnimations] = useAutoAnimate(); const [formTitle, setFormTitle] = useState(""); const [formContent, setFormContent] = useState(""); + // Defaults to 'workspace' when the caller can create organisation templates — + // that's the more useful setting in most chats. User can flip to + // personal via the switch. + const [formScope, setFormScope] = useState<"user" | "workspace">("user"); const [editingId, setEditingId] = useState(null); const [deletingTemplateId, setDeletingTemplateId] = useState( null, @@ -261,12 +277,14 @@ export const TemplatesModal = ({ const handleStartCreate = () => { setFormTitle(""); setFormContent(""); + setFormScope(canCreateWorkspaceTemplate ? "workspace" : "user"); setView("create"); }; const handleDuplicate = (title: string, content: string) => { setFormTitle(`${title} (${t`copy`})`); setFormContent(content); + setFormScope(canCreateWorkspaceTemplate ? "workspace" : "user"); setView("create"); }; @@ -286,6 +304,7 @@ export const TemplatesModal = ({ try { await onCreateUserTemplate?.({ content: formContent.trim(), + scope: canCreateWorkspaceTemplate ? formScope : "user", title: formTitle.trim(), }); setView("browse"); @@ -337,9 +356,11 @@ export const TemplatesModal = ({ } for (const tmpl of userTemplates) { items.push({ + canEdit: tmpl.can_edit ?? true, content: tmpl.content, id: tmpl.id, key: encodeTemplateKey("user", tmpl.id), + scope: tmpl.scope ?? "user", source: "user", title: tmpl.title, }); @@ -451,6 +472,32 @@ export const TemplatesModal = ({ maxRows={14} autosize /> + {view === "create" && canCreateWorkspaceTemplate && ( + + + + + Share with organisation + + + + Organisation templates are visible to everyone in this + workspace. Leave off to keep it personal. + + + + + setFormScope( + e.currentTarget.checked ? "workspace" : "user", + ) + } + aria-label={t`Share with organisation`} + /> + + + )} {view === "create" && ( diff --git a/echo/frontend/src/components/chat/hooks/index.ts b/echo/frontend/src/components/chat/hooks/index.ts index 824cb87a..549ef4b9 100644 --- a/echo/frontend/src/components/chat/hooks/index.ts +++ b/echo/frontend/src/components/chat/hooks/index.ts @@ -1,12 +1,5 @@ -import { - aggregate, - createItem, - deleteItem, - type Query, - readItem, - readItems, - updateItem, -} from "@directus/sdk"; +import type { Query } from "@directus/sdk"; +import { t } from "@lingui/core/macro"; import { useMutation, useQuery, @@ -17,13 +10,14 @@ import { import { toast } from "@/components/common/Toaster"; import { type ChatMode, + deleteChatById, getChatHistory, getChatSuggestions, getProjectChatContext, initializeChatMode, lockConversations, } from "@/lib/api"; -import { directus } from "@/lib/directus"; +import { bff } from "@/lib/bff"; export const useChatHistory = (chatId: string) => { return useQuery({ @@ -37,7 +31,7 @@ export const useAddChatMessageMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: Partial) => - directus.request(createItem("project_chat_message", payload as any)), + bff.post("/chat-messages", payload), onSuccess: (_, vars) => { queryClient.invalidateQueries({ queryKey: ["chats", "context", vars.project_chat_id], @@ -69,7 +63,7 @@ export const useDeleteChatMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: { chatId: string; projectId: string }) => - directus.request(deleteItem("project_chat", payload.chatId)), + deleteChatById(payload.chatId), onSuccess: (_, vars) => { queryClient.invalidateQueries({ queryKey: ["projects", vars.projectId, "chats"], @@ -77,7 +71,10 @@ export const useDeleteChatMutation = () => { queryClient.invalidateQueries({ queryKey: ["chats", vars.chatId], }); - toast.success("Chat deleted successfully"); + toast.success(t`Chat deleted`); + }, + onError: (error: Error) => { + toast.error(error.message || t`Failed to delete chat`); }, }); }; @@ -90,15 +87,18 @@ export const useUpdateChatMutation = () => { // for invalidating the chat query projectId: string; payload: Partial; - }) => - directus.request( - updateItem("project_chat", payload.chatId, { - project_id: { - id: payload.projectId, - }, - ...payload.payload, - }), - ), + }) => { + // project_id is a side-channel for cache invalidation; the + // BFF PATCH accepts only name/chat_mode, not project_id. + const body: Record = {}; + if (typeof payload.payload?.name === "string") { + body.name = payload.payload.name; + } + if (typeof payload.payload?.chat_mode === "string") { + body.chat_mode = payload.payload.chat_mode; + } + return bff.patch(`/chats/${payload.chatId}`, body); + }, onSuccess: (_, vars) => { queryClient.invalidateQueries({ queryKey: ["projects", vars.projectId, "chats"], @@ -146,13 +146,7 @@ export const useInitializeChatModeMutation = () => { export const useChat = (chatId: string) => { return useQuery({ - queryFn: () => - directus.request( - readItem("project_chat", chatId, { - // Only fetch fields used in chat UI: id, name, project_id - fields: ["id", "name", "project_id"], - }), - ), + queryFn: () => bff.get(`/chats/${chatId}`), queryKey: ["chats", chatId], }); }; @@ -162,26 +156,14 @@ export const useProjectChats = ( query?: Partial>, ) => { return useSuspenseQuery({ - queryFn: () => - directus.request( - readItems("project_chat", { - fields: [ - "id", - "project_id", - "date_created", - "date_updated", - "name", - "chat_mode", - ], - filter: { - project_id: { - _eq: projectId, - }, - }, - sort: "-date_created", - ...query, - }), - ), + queryFn: async () => { + void query; // advanced query filter not forwarded to BFF + const { chats } = await bff.get<{ chats: ProjectChat[]; total: number }>( + "/chats", + { project_id: projectId, limit: 200 }, + ); + return chats; + }, queryKey: ["projects", projectId, "chats", query], }); }; @@ -200,33 +182,20 @@ export const useInfiniteProjectChats = ( lastPage?.nextOffset, initialPageParam: 0, queryFn: async ({ pageParam = 0 }) => { - const response = await directus.request( - readItems("project_chat", { - fields: [ - "id", - "project_id", - "date_created", - "date_updated", - "name", - "chat_mode", - ], - filter: { - project_id: { - _eq: projectId, - }, - ...(query?.filter && query.filter), - }, + void query; + const { chats } = await bff.get<{ chats: ProjectChat[]; total: number }>( + "/chats", + { + project_id: projectId, limit: initialLimit, offset: pageParam * initialLimit, - sort: "-date_created", - ...query, - }), + }, ); return { - chats: response, + chats, nextOffset: - response.length === initialLimit ? pageParam + 1 : undefined, + chats.length === initialLimit ? pageParam + 1 : undefined, }; }, queryKey: ["projects", projectId, "chats", "infinite", query], @@ -240,22 +209,12 @@ export const useProjectChatsCount = ( ) => { return useSuspenseQuery({ queryFn: async () => { - const response = await directus.request( - aggregate("project_chat", { - aggregate: { - count: "*", - }, - query: { - filter: { - project_id: { - _eq: projectId, - }, - ...(query?.filter && query.filter), - }, - }, - }), + void query; + const { total } = await bff.get<{ chats: ProjectChat[]; total: number }>( + "/chats", + { project_id: projectId, limit: 1 }, ); - return response[0].count; + return total; }, queryKey: ["projects", projectId, "chats", "count", query], }); diff --git a/echo/frontend/src/components/chat/hooks/useUserTemplates.ts b/echo/frontend/src/components/chat/hooks/useUserTemplates.ts index 9a40240c..87dd03f6 100644 --- a/echo/frontend/src/components/chat/hooks/useUserTemplates.ts +++ b/echo/frontend/src/components/chat/hooks/useUserTemplates.ts @@ -13,34 +13,39 @@ import { // ── Prompt Templates CRUD ── -export const useUserTemplates = () => { +export const useUserTemplates = (workspaceId?: string | null) => { return useQuery({ - queryFn: getPromptTemplates, - queryKey: ["prompt_templates"], + queryFn: () => getPromptTemplates(workspaceId), + queryKey: ["prompt_templates", workspaceId ?? "__personal__"], }); }; -export const useCreateUserTemplate = () => { +export const useCreateUserTemplate = (workspaceId?: string | null) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: { title: string; content: string; icon?: string | null; - }) => createPromptTemplate(payload), + scope?: "user" | "workspace"; + }) => + createPromptTemplate({ + ...payload, + workspace_id: payload.scope === "workspace" ? workspaceId : null, + }), onSuccess: async (newTemplate) => { - // Optimistically add the new template to the cache for instant UI feedback queryClient.setQueryData( - ["prompt_templates"], + ["prompt_templates", workspaceId ?? "__personal__"], (old) => (old ? [...old, newTemplate] : [newTemplate]), ); - // Force refetch to get the canonical data from the server - await queryClient.refetchQueries({ queryKey: ["prompt_templates"] }); + await queryClient.refetchQueries({ + queryKey: ["prompt_templates", workspaceId ?? "__personal__"], + }); }, }); }; -export const useUpdateUserTemplate = () => { +export const useUpdateUserTemplate = (workspaceId?: string | null) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: { @@ -53,23 +58,26 @@ export const useUpdateUserTemplate = () => { return updatePromptTemplate(id, data); }, onSuccess: async () => { - await queryClient.refetchQueries({ queryKey: ["prompt_templates"] }); + await queryClient.refetchQueries({ + queryKey: ["prompt_templates", workspaceId ?? "__personal__"], + }); }, }); }; -export const useDeleteUserTemplate = () => { +export const useDeleteUserTemplate = (workspaceId?: string | null) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => deletePromptTemplate(id), onSuccess: async (_, deletedId) => { - // Remove immediately from cache queryClient.setQueryData( - ["prompt_templates"], + ["prompt_templates", workspaceId ?? "__personal__"], (old) => old?.filter((t) => t.id !== deletedId) ?? [], ); queryClient.invalidateQueries({ queryKey: ["quick_access_preferences"] }); - await queryClient.refetchQueries({ queryKey: ["prompt_templates"] }); + await queryClient.refetchQueries({ + queryKey: ["prompt_templates", workspaceId ?? "__personal__"], + }); }, }); }; diff --git a/echo/frontend/src/components/common/AccessDeniedPanel.tsx b/echo/frontend/src/components/common/AccessDeniedPanel.tsx new file mode 100644 index 00000000..d29c6d8e --- /dev/null +++ b/echo/frontend/src/components/common/AccessDeniedPanel.tsx @@ -0,0 +1,37 @@ +import { Trans } from "@lingui/react/macro"; +import { Button, Container, Stack, Text, Title } from "@mantine/core"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; + +// Shared 401/403/404 panel — replaces the infinite loader (settings) +// and fake empty state (projects) that previously read as "broken." +export function AccessDeniedPanel({ + testId = "access-denied-panel", +}: { + testId?: string; +} = {}) { + const navigate = useI18nNavigate(); + + return ( + + + + <Trans>You don't have access to this workspace.</Trans> + + + + Ask a workspace admin for an invite, or pick a different workspace + from your list. + + + + + + ); +} diff --git a/echo/frontend/src/components/common/FetchErrorPanel.tsx b/echo/frontend/src/components/common/FetchErrorPanel.tsx new file mode 100644 index 00000000..37a2c3a5 --- /dev/null +++ b/echo/frontend/src/components/common/FetchErrorPanel.tsx @@ -0,0 +1,50 @@ +import { Trans } from "@lingui/react/macro"; +import { Alert, Button, Container, Group, Stack } from "@mantine/core"; +import { type ReactNode } from "react"; + +interface FetchErrorPanelProps { + onRetry: () => void; + message: ReactNode; + /** Server-provided string that overrides `message` when truthy. */ + detail?: string | null; + secondaryAction?: { label: ReactNode; onClick: () => void }; + testId?: string; +} + +// Counterpart to AccessDeniedPanel — for 401/403/404 use that instead. +export function FetchErrorPanel({ + onRetry, + message, + detail, + secondaryAction, + testId = "fetch-error-panel", +}: FetchErrorPanelProps) { + return ( + + + + {detail ?? message} + + + + {secondaryAction && ( + + )} + + + + ); +} diff --git a/echo/frontend/src/components/common/Logo.tsx b/echo/frontend/src/components/common/Logo.tsx index efcf4c60..179223a6 100644 --- a/echo/frontend/src/components/common/Logo.tsx +++ b/echo/frontend/src/components/common/Logo.tsx @@ -1,4 +1,4 @@ -import { Group, type GroupProps } from "@mantine/core"; +import { Group, type GroupProps, Loader } from "@mantine/core"; import aiconlLogo from "@/assets/aiconl-logo.png"; import aiconlLogoHQ from "@/assets/aiconl-logo-hq.png"; @@ -18,13 +18,15 @@ export const LogoDembrane = ({ hideLogo, hideTitle, alwaysDembrane, ...props }: return ( - {!hideLogo && ( + {!hideLogo && effectiveLogoUrl === undefined ? ( + + ) : !hideLogo ? ( Logo - )} + ) : null} ); }; @@ -51,5 +53,4 @@ export const Logo = (props: LogoProps) => { ); }; -// Export logomark for use in spinners and loading indicators export const DembraneLogomark = dembraneLogomark; diff --git a/echo/frontend/src/components/common/UsageFreshness.tsx b/echo/frontend/src/components/common/UsageFreshness.tsx new file mode 100644 index 00000000..eae53ac5 --- /dev/null +++ b/echo/frontend/src/components/common/UsageFreshness.tsx @@ -0,0 +1,36 @@ +import { t } from "@lingui/core/macro"; +import { Anchor, Text } from "@mantine/core"; +import { formatRelativeAgo } from "@/lib/time"; + +interface Props { + dataUpdatedAt: number | undefined | null; + refreshing: boolean; + onRefresh: () => void; +} + +/** + * "Updated X ago · Refresh" line used at the bottom of every usage + * surface. One component for one pattern — so when we change the + * wording or styling we change it in one place. + * + * Render as a plain text line with an inline anchor — no button + * chrome, no icons. Keeps the focus on the usage numbers; the + * refresh is there when you need it, not competing for attention. + */ +export function UsageFreshness({ dataUpdatedAt, refreshing, onRefresh }: Props) { + return ( + + {t`Updated ${formatRelativeAgo(dataUpdatedAt ?? undefined)} · `} + + {refreshing ? t`Refreshing…` : t`Refresh`} + + + ); +} diff --git a/echo/frontend/src/components/common/UserAvatar.tsx b/echo/frontend/src/components/common/UserAvatar.tsx index 245f67e7..26019362 100644 --- a/echo/frontend/src/components/common/UserAvatar.tsx +++ b/echo/frontend/src/components/common/UserAvatar.tsx @@ -6,6 +6,33 @@ type UserAvatarProps = { size?: number; }; +/** + * Initials from "First Last" style names: first letter of each of the + * first two space-separated tokens ("Anna Bakker" → "AB"). Falls back + * to the first two characters of the first name, and finally "?" when + * the user hasn't filled either out. Fixes the AN / SS bug where a + * single first_name was sliced to its first two letters. + */ +const deriveInitials = ( + firstName: string | null | undefined, + lastName?: string | null, +): string => { + const fn = (firstName ?? "").trim(); + const ln = (lastName ?? "").trim(); + if (fn && ln) { + return `${fn[0]}${ln[0]}`.toUpperCase(); + } + if (fn) { + // Handle "Anna Bakker" coming through as a single first_name field. + const parts = fn.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return fn.slice(0, 1).toUpperCase(); + } + return "?"; +}; + export const UserAvatar = ({ size = 32 }: UserAvatarProps) => { const { data: user } = useCurrentUser(); @@ -13,8 +40,10 @@ export const UserAvatar = ({ size = 32 }: UserAvatarProps) => { const avatarUrl = avatarFileId ? `${DIRECTUS_PUBLIC_URL}/assets/${avatarFileId}?width=${size * 2}&height=${size * 2}&fit=cover` : null; - const initials = - (user?.first_name as string)?.slice(0, 2)?.toUpperCase() ?? "?"; + const initials = deriveInitials( + user?.first_name as string | null | undefined, + (user as { last_name?: string | null } | undefined)?.last_name, + ); return ( { + const { workspaceId, workspaces, isLoading, isError, refetch } = + useWorkspace(); + + if (isLoading) { + return ( + + + + ); + } + + // Without this, a failed fetch reads as "no workspaces" — same UI as a brand-new account. + if (isError) { + return ( + + We couldn't load your workspaces. Check your connection and try + again. + + } + /> + ); + } + + if (workspaceId) { + return ; + } + + if (workspaces.length > 0) { + return ; + } + + return null; +}; diff --git a/echo/frontend/src/components/conversation/MoveConversationButton.tsx b/echo/frontend/src/components/conversation/MoveConversationButton.tsx index a3319a32..90ccfc02 100644 --- a/echo/frontend/src/components/conversation/MoveConversationButton.tsx +++ b/echo/frontend/src/components/conversation/MoveConversationButton.tsx @@ -21,6 +21,7 @@ import { useInView } from "react-intersection-observer"; import { useParams } from "react-router"; import { FormLabel } from "@/components/form/FormLabel"; import { useInfiniteProjects } from "@/components/project/hooks"; +import { useWorkspace } from "@/hooks/useWorkspace"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { analytics } from "@/lib/analytics"; import { AnalyticsEvents as events } from "@/lib/analyticsEvents"; @@ -51,6 +52,8 @@ export const MoveConversationButton = ({ mode: "onChange", }); + const { workspaceId } = useWorkspace(); + const projectsQuery = useInfiniteProjects({ options: { initialLimit: 10, @@ -60,6 +63,10 @@ export const MoveConversationButton = ({ id: { _neq: projectId as string, }, + // Scope to current workspace to prevent cross-workspace moves + ...(workspaceId && { + workspace_id: { _eq: workspaceId }, + }), ...(debouncedSearchValue && { name: { _icontains: debouncedSearchValue, @@ -92,7 +99,9 @@ export const MoveConversationButton = ({ onSuccess: () => { close(); navigate( - `/projects/${data.targetProjectId}/conversation/${conversation.id}/overview`, + workspaceId + ? `/w/${workspaceId}/projects/${data.targetProjectId}/conversation/${conversation.id}/overview` + : `/projects/${data.targetProjectId}/conversation/${conversation.id}/overview`, ); }, }, diff --git a/echo/frontend/src/components/conversation/OngoingConversationsSummaryCard.tsx b/echo/frontend/src/components/conversation/OngoingConversationsSummaryCard.tsx index 97f6c903..9b032873 100644 --- a/echo/frontend/src/components/conversation/OngoingConversationsSummaryCard.tsx +++ b/echo/frontend/src/components/conversation/OngoingConversationsSummaryCard.tsx @@ -1,11 +1,10 @@ -import { aggregate } from "@directus/sdk"; import { t } from "@lingui/core/macro"; import { ActionIcon, Group, Stack, Text } from "@mantine/core"; import { UsersThreeIcon } from "@phosphor-icons/react"; import { IconRefresh } from "@tabler/icons-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; -import { directus } from "@/lib/directus"; +import { bff } from "@/lib/bff"; import { SummaryCard } from "../common/SummaryCard"; const TIME_INTERVAL_SECONDS = 30; @@ -16,52 +15,27 @@ export const OngoingConversationsSummaryCard = ({ projectId: string; }) => { const queryClient = useQueryClient(); - // Track previous state to detect changes + // Track previous state to detect changes so we refetch the + // conversations list when the count transitions (a conversation + // went live or ended) — that way the list reflects fresh chunks + // without the user having to hit refresh. const [hasOngoingConversations, setHasOngoingConversations] = useState(false); - // const hasOngoingConversationsRef = useRef(false); const conversationChunksQuery = useQuery({ queryFn: async () => { - const result = await directus.request( - aggregate("conversation_chunk", { - aggregate: { - countDistinct: ["conversation_id"], - }, - query: { - filter: { - conversation_id: { - project_id: projectId, - }, - source: { - // @ts-expect-error source is not typed - _nin: ["DASHBOARD_UPLOAD", "CLONE"], - }, - timestamp: { - // @ts-expect-error gt is not typed - _gt: new Date( - Date.now() - TIME_INTERVAL_SECONDS * 1000, - ).toISOString(), - }, - }, - }, - }), + const { count } = await bff.get<{ count: number }>( + "/conversations/live-count", + { project_id: projectId, window_seconds: TIME_INTERVAL_SECONDS }, ); - const currentCount = Number( - // @ts-expect-error aggregate response type is not properly typed - (result[0]?.countDistinct?.conversation_id as string) ?? "0", - ); - - if (currentCount > 0 || hasOngoingConversations) { + if (count > 0 || hasOngoingConversations) { queryClient.invalidateQueries({ queryKey: ["projects", projectId, "conversations"], }); - setHasOngoingConversations(false); } + setHasOngoingConversations(count > 0); - setHasOngoingConversations(currentCount > 0); - - return currentCount; + return count; }, queryKey: ["conversation_chunks", projectId], refetchInterval: 30000, diff --git a/echo/frontend/src/components/conversation/hooks/index.ts b/echo/frontend/src/components/conversation/hooks/index.ts index 76f9946c..c99cd3e7 100644 --- a/echo/frontend/src/components/conversation/hooks/index.ts +++ b/echo/frontend/src/components/conversation/hooks/index.ts @@ -1,13 +1,4 @@ -import { - aggregate, - createItems, - deleteItems, - type Query, - type QueryFields, - readItem, - readItems, - updateItem, -} from "@directus/sdk"; +import type { Query, QueryFields } from "@directus/sdk"; import { t } from "@lingui/core/macro"; import * as Sentry from "@sentry/react"; import { @@ -32,6 +23,7 @@ import { retranscribeConversation, selectAllContext, } from "@/lib/api"; +import { bff } from "@/lib/bff"; import { directus } from "@/lib/directus"; export const useInfiniteConversationChunks = ( @@ -53,25 +45,14 @@ export const useInfiniteConversationChunks = ( lastPage.nextOffset, initialPageParam: 0, queryFn: async ({ pageParam = 0 }) => { - const response = await directus.request( - readItems("conversation_chunk", { - fields: [ - "id", - "conversation_id", - "transcript", - "path", - "timestamp", - "error", - ], - filter: { - conversation_id: { - _eq: conversationId, - }, - }, + const response = await bff.get( + `/conversations/${conversationId}/chunks`, + { limit: initialLimit, offset: pageParam * initialLimit, - sort: ["timestamp"], - }), + sort: "timestamp", + fields: "id,conversation_id,transcript,path,timestamp,error", + }, ); return { @@ -95,7 +76,7 @@ export const useUpdateConversationByIdMutation = () => { id: string; payload: Partial; }) => - directus.request(updateItem("conversation", id, payload)), + bff.patch(`/conversations/${id}`, payload), onSuccess: (values, variables) => { queryClient.setQueryData( ["conversations", variables.id], @@ -127,96 +108,17 @@ export const useUpdateConversationTagsMutation = () => { conversationId: string; projectTagIdList: string[]; }) => { - let validTagsIds: string[] = []; - try { - const validTags = await directus.request( - readItems("project_tag", { - fields: ["id"], - filter: { - id: { - _in: projectTagIdList, - }, - project_id: { - _eq: projectId, - }, - }, - }), - ); - - validTagsIds = validTags.map((tag) => tag.id); - } catch (_error) { - validTagsIds = []; - } - - const tagsRequest = await directus.request( - readItems("conversation_project_tag", { - fields: [ - "id", - { - project_tag_id: ["id"], - }, - ], - filter: { - conversation_id: { _eq: conversationId }, - }, - }), - ); - - const needToDelete = tagsRequest.filter( - (conversationProjectTag) => - conversationProjectTag.project_tag_id && - !validTagsIds.includes( - (conversationProjectTag.project_tag_id as ProjectTag).id, - ), - ); - - const needToCreate = validTagsIds.filter( - (tagId) => - !tagsRequest.some( - (conversationProjectTag) => - (conversationProjectTag.project_tag_id as ProjectTag).id === - tagId, - ), - ); - - // slightly esoteric, but basically we only want to delete if there are any tags to delete - // otherwise, directus doesn't accept an empty array - const deletePromise = - needToDelete.length > 0 - ? directus.request( - deleteItems( - "conversation_project_tag", - needToDelete.map((tag) => tag.id), - ), - ) - : Promise.resolve(); - - // same deal for creating - const createPromise = - needToCreate.length > 0 - ? directus.request( - createItems( - "conversation_project_tag", - needToCreate.map((tagId) => ({ - conversation_id: { - id: conversationId, - } as Conversation, - project_tag_id: { - id: tagId, - } as ProjectTag, - })), - ), - ) - : Promise.resolve(); - - // await both promises - await Promise.all([deletePromise, createPromise]); - - return directus.request( - readItem("conversation", conversationId, { - fields: ["*"], - }), - ); + // Delegates add + remove delta to the server — it validates + // every tag belongs to the project, computes the diff, and + // returns the fresh junction list. + await bff.post("/conversation-project-tags/replace", { + conversation_id: conversationId, + project_tag_ids: projectTagIdList, + }); + // Project id is a side-channel for cache invalidation; the + // server doesn't need it but we keep the arg name stable. + void projectId; + return bff.get(`/conversations/${conversationId}`); }, onSuccess: (_values, variables) => { queryClient.invalidateQueries({ @@ -259,11 +161,9 @@ export const useMoveConversationMutation = () => { targetProjectId: string; }) => { try { - await directus.request( - updateItem("conversation", conversationId, { - project_id: targetProjectId, - }), - ); + await bff.post(`/conversations/${conversationId}/move`, { + target_project_id: targetProjectId, + }); } catch (_error) { toast.error("Failed to move conversation."); } @@ -670,18 +570,11 @@ export const useConversationChunks = ( ) => { return useQuery({ queryFn: () => - directus.request( - readItems("conversation_chunk", { - fields: fields as any, - filter: { - conversation_id: { - _eq: conversationId, - }, - }, - limit: 1, // Only need to check if chunks exist - sort: "timestamp", - }), - ), + bff.get(`/conversations/${conversationId}/chunks`, { + limit: 1, + sort: "timestamp", + fields: fields.join(","), + }), queryKey: ["conversations", conversationId, "chunks"], refetchInterval, }); @@ -710,62 +603,15 @@ export const useConversationsByProjectId = ( return useQuery({ queryFn: async () => { - const conversations = await directus.request( - readItems("conversation", { - deep: { - chunks: { - _limit: loadChunks ? 1000 : 1, - _sort: ["-timestamp", "-created_at"], - }, - }, - fields: [ - ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, - { - tags: [ - { - project_tag_id: ["id", "text", "created_at"], - }, - ], - }, - { - chunks: [ - "id", - "conversation_id", - "transcript", - "source", - "path", - "timestamp", - "created_at", - "error", - ], - }, - ], - // @ts-expect-error TODO - filter: { - chunks: { - ...(loadWhereTranscriptExists && { - _some: { - transcript: { - _nempty: true, - }, - }, - }), - }, - project_id: { - _eq: projectId, - }, - ...(filterBySource && { - source: { - _in: filterBySource, - }, - }), - }, - limit: 1000, - sort: "-updated_at", - ...query, - }), - ); - + void query; // @TODO: advanced query params not supported by BFF yet + void loadWhereTranscriptExists; + const conversations = await bff.get("/conversations", { + project_id: projectId, + include_chunks: true, + include_tags: true, + sources: filterBySource?.join(","), + limit: 1000, + }); return conversations; }, queryKey: [ @@ -851,57 +697,13 @@ export const useConversationById = ({ useQueryOpts?: Partial>; }) => { return useQuery({ - queryFn: () => - directus.request( - readItem("conversation", conversationId, { - // @ts-expect-error TODO - fields: [ - ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, - { - linking_conversations: [ - "id", - { - source_conversation_id: ["id", "participant_name"], - }, - "link_type", - ], - }, - { - linked_conversations: [ - "id", - { - target_conversation_id: ["id", "participant_name"], - }, - "link_type", - ], - }, - { - tags: [ - { - project_tag_id: ["id", "text"], - }, - ], - }, - ...(loadConversationChunks - ? [ - { - chunks: [ - "id", - "conversation_id", - "transcript", - "source", - "path", - "timestamp", - "created_at", - "error", - ], - }, - ] - : []), - ], - ...query, - }), - ), + queryFn: async () => { + void query; // reserved for future fields param on BFF + return bff.get(`/conversations/${conversationId}`, { + include_chunks: loadConversationChunks, + include_tags: true, + }); + }, queryKey: ["conversations", conversationId, loadConversationChunks, query], ...useQueryOpts, }); @@ -926,65 +728,16 @@ export const useInfiniteConversationsByProjectId = ( lastPage.nextOffset, initialPageParam: 0, queryFn: async ({ pageParam = 0 }) => { - const conversations = await directus.request( - readItems("conversation", { - deep: { - chunks: { - _limit: loadChunks ? 1000 : 1, - _sort: ["-timestamp", "-created_at"], - }, - }, - fields: [ - ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, - { - tags: [ - { - project_tag_id: ["id", "text"], - }, - ], - }, - { - chunks: [ - "id", - "conversation_id", - "transcript", - "source", - "path", - "timestamp", - "created_at", - "error", - ], - }, - { - conversation_artifacts: ["id", "approved_at"], - }, - ], - // @ts-expect-error TODO - filter: { - chunks: { - ...(loadWhereTranscriptExists && { - _some: { - transcript: { - _nempty: true, - }, - }, - }), - }, - project_id: { - _eq: projectId, - }, - ...(filterBySource && { - source: { - _in: filterBySource, - }, - }), - }, - limit: initialLimit, - offset: pageParam * initialLimit, - sort: "-updated_at", - ...query, - }), - ); + void query; // advanced query params not yet supported by BFF + const conversations = await bff.get("/conversations", { + project_id: projectId, + include_chunks: Boolean(loadChunks), + include_tags: true, + sources: filterBySource?.join(","), + transcript_required: Boolean(loadWhereTranscriptExists), + limit: initialLimit, + offset: pageParam * initialLimit, + }); return { conversations: conversations, @@ -1050,22 +803,12 @@ export const useConversationsCountByProjectId = ( ) => { return useQuery({ queryFn: async () => { - const response = await directus.request( - aggregate("conversation", { - aggregate: { - count: "*", - }, - query: { - filter: { - project_id: { - _eq: projectId, - }, - ...(query?.filter && query.filter), - }, - }, - }), + void query; + const { count } = await bff.get<{ count: number }>( + "/conversations/count", + { project_id: projectId }, ); - return response[0].count; + return count; }, queryKey: ["projects", projectId, "conversations", "count", query], }); @@ -1094,63 +837,27 @@ export const useRemainingConversationsCount = ( chatContextQuery.data !== undefined && options?.enabled !== false, queryFn: async () => { - // Build filter for conversations matching current filters - const filterQuery: any = { - project_id: { - _eq: projectId, - }, - }; - - // Apply tag filter if provided - if (filters?.tagIds && filters.tagIds.length > 0) { - filterQuery.tags = { - _some: { - project_tag_id: { - id: { _in: filters.tagIds }, - }, - }, - }; - } - - // Apply verified filter if requested - if (filters?.verifiedOnly) { - filterQuery.conversation_artifacts = { - _some: { - approved_at: { - _nnull: true, - }, - }, - }; - } - - // Get count of conversations already in context const conversationsInContext = chatContextQuery.data?.conversations ?? []; - const conversationIdsInContext = new Set( - conversationsInContext.map((c) => c.conversation_id), + const conversationIdsInContext = Array.from( + new Set(conversationsInContext.map((c) => c.conversation_id)), ); - - // If we have conversations in context, exclude them from the filter - if (conversationIdsInContext.size > 0) { - filterQuery.id = { - _nin: Array.from(conversationIdsInContext), - }; - } - - const response = await directus.request( - aggregate("conversation", { - aggregate: { - countDistinct: ["id"], - }, - query: { - filter: filterQuery, - ...(filters?.searchText?.trim() && { - search: filters.searchText.trim(), - }), - }, - }), + const { count } = await bff.get<{ count: number }>( + "/conversations/remaining-count", + { + project_id: projectId, + exclude_ids: + conversationIdsInContext.length > 0 + ? conversationIdsInContext.join(",") + : undefined, + tag_ids: + filters?.tagIds && filters.tagIds.length > 0 + ? filters.tagIds.join(",") + : undefined, + verified_only: filters?.verifiedOnly ? true : undefined, + search_text: filters?.searchText?.trim() || undefined, + }, ); - - return Number(response[0]?.countDistinct?.id) || 0; + return count; }, queryKey: [ "projects", @@ -1175,36 +882,11 @@ export const useConversationHasTranscript = ( return useQuery({ enabled: enabled, queryFn: async () => { - const response = await directus.request( - aggregate("conversation_chunk", { - aggregate: { - count: "*", - }, - query: { - filter: { - _and: [ - { - conversation_id: { - _eq: conversationId, - }, - }, - { - transcript: { - _nnull: true, - }, - }, - { - transcript: { - _nempty: true, - }, - }, - ], - }, - }, - }), + const { count } = await bff.get<{ count: number }>( + `/conversations/${conversationId}/chunk-count`, + { transcript_required: true }, ); - const count = response[0]?.count; - return typeof count === "number" ? count : Number(count) || 0; + return count; }, queryKey: ["conversations", conversationId, "chunks", "transcript-count"], refetchInterval, diff --git a/echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx b/echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx index 262b12b2..2f108930 100644 --- a/echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx +++ b/echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx @@ -15,6 +15,7 @@ import { Tooltip, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; +import { usePostHog } from "@posthog/react"; import { IconAlertCircle, IconArrowRight, @@ -253,6 +254,7 @@ export const UploadConversationDropzone = ( ) => { // Modal state const [opened, { open, close }] = useDisclosure(false); + const posthog = usePostHog(); // File selection and upload tracking state const [selectedFiles, setSelectedFiles] = useState([]); @@ -477,6 +479,11 @@ export const UploadConversationDropzone = ( const handleUpload = useCallback(() => { if (selectedFiles.length === 0) return; + posthog?.capture("conversation_upload_started", { + file_count: selectedFiles.length, + project_id: props.projectId, + }); + // Start the upload process using our uploader hook uploader.uploadFiles({ chunks: selectedFiles, @@ -487,7 +494,7 @@ export const UploadConversationDropzone = ( tagIdList: [], timestamps: selectedFiles.map(() => new Date()), }); - }, [selectedFiles, props.projectId, uploader]); + }, [selectedFiles, props.projectId, uploader, posthog]); if (projectQuery.isLoading) { return ; diff --git a/echo/frontend/src/components/inbox/Inbox.tsx b/echo/frontend/src/components/inbox/Inbox.tsx new file mode 100644 index 00000000..fc4c49ea --- /dev/null +++ b/echo/frontend/src/components/inbox/Inbox.tsx @@ -0,0 +1,491 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + ActionIcon, + Avatar, + Badge, + Box, + Button, + Center, + Drawer, + Group, + Indicator, + Loader, + ScrollArea, + Stack, + Tabs, + Text, + UnstyledButton, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconBell, IconCheck } from "@tabler/icons-react"; +import { formatRelative } from "date-fns"; +import type React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useInView } from "react-intersection-observer"; +import { + useInfiniteAnnouncements, + useMarkAllAsReadMutation as useAnnouncementsMarkAllAsReadMutation, + useMarkAsReadMutation as useAnnouncementMarkAsReadMutation, + useMarkAsUnreadMutation as useAnnouncementMarkAsUnreadMutation, + useUnreadAnnouncements, +} from "@/components/announcement/hooks"; +import { useProcessedAnnouncements } from "@/components/announcement/hooks/useProcessedAnnouncements"; +import { AnnouncementItem } from "@/components/announcement/AnnouncementItem"; +import { + resolveNotificationHref, + useMarkAllNotificationsRead, + useMarkNotificationRead, + useNotifications, + useUnreadNotificationCount, + type NotificationRow, +} from "@/hooks/useNotifications"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; +import { useLanguage } from "@/hooks/useLanguage"; +import { avatarUrl } from "@/lib/avatar"; + +/** + * Unified Inbox drawer — single entry point in the header. + * + * Replaces the separate announcement icon + notifications icon pair with + * one bell that opens a two-tab drawer: + * + * For you Personal notifications — events that target this user + * specifically (workspace added, role changed, report + * ready, destructive events). Severity styling is driven + * by `_SEVERITY_BY_EVENT` in + * `server/dembrane/notifications.py`. + * + * Announcements Admin broadcasts — existing `announcement` collection. + * Never action-required, never destructive by design. + * + * The unread badge sums both streams so users only track one number. + * "Mark all read" applies to the active tab so users don't nuke + * announcements when they meant to clear notifications (or vice versa). + */ +export const Inbox = () => { + const [opened, { open, close }] = useDisclosure(false); + const [activeTab, setActiveTab] = useState<"for-you" | "announcements">( + "for-you", + ); + const navigate = useI18nNavigate(); + const { language } = useLanguage(); + + const { data: notifications = [], isLoading: loadingNotifs } = + useNotifications(); + const { data: unreadNotifs = 0 } = useUnreadNotificationCount(); + const markNotifRead = useMarkNotificationRead(); + const markAllNotifsRead = useMarkAllNotificationsRead(); + + const { ref: loadMoreRef, inView } = useInView(); + const { + data: announcementsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: loadingAnnouncements, + } = useInfiniteAnnouncements({ + enabled: opened, + options: { initialLimit: 10 }, + }); + const { data: unreadAnnouncements = 0 } = useUnreadAnnouncements(); + const markAnnouncementRead = useAnnouncementMarkAsReadMutation(); + const markAnnouncementUnread = useAnnouncementMarkAsUnreadMutation(); + const markAllAnnouncementsRead = useAnnouncementsMarkAllAsReadMutation(); + + const allAnnouncements = + announcementsData?.pages.flatMap( + (page) => (page as { announcements: Announcement[] }).announcements, + ) ?? []; + const processedAnnouncements = useProcessedAnnouncements( + allAnnouncements, + language, + ); + const unreadAnnouncementRows = useMemo( + () => processedAnnouncements.filter((a) => !a.read), + [processedAnnouncements], + ); + const readAnnouncementRows = useMemo( + () => processedAnnouncements.filter((a) => a.read), + [processedAnnouncements], + ); + + // Infinite-scroll sentinel. Fire-and-forget inside useEffect so the + // fetch doesn't run during render (which caused max-depth churn on + // the Announcements tab in the 2026-04-23 audit). + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const totalUnread = unreadNotifs + unreadAnnouncements; + + const handleNotificationClick = (row: NotificationRow) => { + if (!row.read) markNotifRead.mutate(row.id); + const href = resolveNotificationHref(row); + if (href) { + navigate(href); + close(); + } + }; + + const handleMarkRead = (row: NotificationRow) => { + if (!row.read) markNotifRead.mutate(row.id); + }; + + const handleMarkAllReadForActiveTab = () => { + if (activeTab === "for-you") { + markAllNotifsRead.mutate(); + } else { + markAllAnnouncementsRead.mutate(); + } + }; + + const markAllPending = + activeTab === "for-you" + ? markAllNotifsRead.isPending + : markAllAnnouncementsRead.isPending; + + return ( + <> + 0 ? totalUnread : undefined} + disabled={totalUnread === 0} + withBorder + > + + + + + + + + Inbox + + + + } + > + + setActiveTab((value as "for-you" | "announcements") ?? "for-you") + } + variant="default" + keepMounted={false} + > + + 0 ? ( + + {unreadNotifs} + + ) : null + } + > + For you + + 0 ? ( + + {unreadAnnouncements} + + ) : null + } + > + Announcements + + + + + + {loadingNotifs ? ( +
+ +
+ ) : notifications.length === 0 ? ( +
+ + + + You're all caught up. + + +
+ ) : ( + + {notifications.map((row) => ( + handleNotificationClick(row)} + onMarkRead={() => handleMarkRead(row)} + /> + ))} + + )} +
+
+ + + + {loadingAnnouncements ? ( +
+ +
+ ) : processedAnnouncements.length === 0 ? ( +
+ + Nothing from dembrane right now. + +
+ ) : ( + + {unreadAnnouncementRows.map((a, index) => ( + + markAnnouncementRead.mutate({ announcementId: id }) + } + onMarkAsUnread={(id, activityIds) => + markAnnouncementUnread.mutate({ + announcementId: id, + activityIds, + }) + } + index={index} + /> + ))} + {readAnnouncementRows.map((a, index) => ( + + markAnnouncementRead.mutate({ announcementId: id }) + } + onMarkAsUnread={(id, activityIds) => + markAnnouncementUnread.mutate({ + announcementId: id, + activityIds, + }) + } + index={index} + /> + ))} + {isFetchingNextPage && ( +
+ +
+ )} +
+ + )} + + + + + + ); +}; + +/** + * Render inline **bold** markers as . Notifications come from + * the server with markdown-style emphasis (e.g. "Added to **Workspace + * X**"); before this helper the raw asterisks were showing through. + * Kept tiny on purpose — a full markdown parser is overkill for one + * line of text, and inline pasting of arbitrary HTML is a no-go. + */ +function renderInlineMarkdown(text: string): React.ReactNode { + if (!text) return null; + const parts = text.split(/(\*\*[^*]+\*\*)/g); + return parts.map((part, i) => { + if (part.startsWith("**") && part.endsWith("**") && part.length > 4) { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: parts array is derived from a static text split and never reorders + + {part.slice(2, -2)} + + ); + } + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: parts array is derived from a static text split and never reorders + {part} + ); + }); +} + +function NotificationRowItem({ + row, + onClick, + onMarkRead, +}: { + row: NotificationRow; + onClick: () => void; + onMarkRead: () => void; +}) { + const createdLabel = row.created_at + ? formatRelative(new Date(row.created_at), new Date()) + : ""; + const isDestructive = row.severity === "destructive"; + const isActionRequired = row.severity === "action_required"; + const unreadBg = isDestructive + ? "rgba(192,57,43,0.045)" + : isActionRequired + ? "rgba(65,105,225,0.04)" + : "rgba(65,105,225,0.03)"; + + // Clicking the row fires `onClick` (mark-read + navigate when there's + // an action). Notifications without a navigation target (matrix §6 + // "silent rejection", e.g. status info) used to render as a disabled + // button — impossible to mark read. Now the row is always clickable + // and falls back to a plain mark-read when there's no action. + return ( + + + {/* Explicit mark-read on unread rows. Clicking the row already + fires onClick which marks read + (optionally) navigates, + but this gives a keyboard/touch target that means "just + clear it, don't take me anywhere" — matters when the row + has a navigation target the user doesn't want to follow. */} + {!row.read && ( + { + e.stopPropagation(); + onMarkRead(); + }} + style={{ + position: "absolute", + top: 0, + right: 0, + }} + > + + + )} + {row.actor_user_id ? ( + + {(row.actor_name || "?").slice(0, 2).toUpperCase()} + + ) : ( + + + + )} + + + + + {renderInlineMarkdown(row.title)} + + {!row.read && ( + + )} + {isActionRequired && ( + + Action needed + + )} + + {row.scope && ( + + {row.scope} + + )} + {row.message && ( + + {renderInlineMarkdown(row.message)} + + )} + {createdLabel && ( + + {createdLabel} + + )} + + + + ); +} diff --git a/echo/frontend/src/components/layout/AuthLayout.tsx b/echo/frontend/src/components/layout/AuthLayout.tsx index 5d44019d..aa214076 100644 --- a/echo/frontend/src/components/layout/AuthLayout.tsx +++ b/echo/frontend/src/components/layout/AuthLayout.tsx @@ -1,6 +1,6 @@ import { LoadingOverlay } from "@mantine/core"; import { type PropsWithChildren, useEffect } from "react"; -import { Outlet, useSearchParams } from "react-router"; +import { Outlet, useLocation, useSearchParams } from "react-router"; import { useAuthenticated } from "@/components/auth/hooks"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { Toaster } from "../common/Toaster"; @@ -11,6 +11,10 @@ import { useTransitionCurtain, } from "./TransitionCurtainProvider"; +// Token-consuming routes render their own auth-aware UI; redirecting +// authed users away would race the token call (verify-email infinite-loading bug). +const SKIP_REDIRECT_PATHS = ["/verify-email", "/password-reset"]; + export const AuthLayout = (props: PropsWithChildren) => ( @@ -22,20 +26,30 @@ const AuthLayoutInner = (props: PropsWithChildren) => { const navigate = useI18nNavigate(); const auth = useAuthenticated(); const { isActive } = useTransitionCurtain(); + const location = useLocation(); + const skipRedirect = SKIP_REDIRECT_PATHS.some((p) => + location.pathname.endsWith(p), + ); useEffect(() => { - if (auth.isAuthenticated && !isActive) { + if (auth.isAuthenticated && !isActive && !skipRedirect) { const nextLink = query.get("next") ?? "/projects"; navigate(nextLink); } - }, [auth.isAuthenticated, isActive, navigate, query]); + }, [auth.isAuthenticated, isActive, navigate, query, skipRedirect]); return ( <> -
+
-
+
{ const logoutMutation = useLogoutMutation(); const { data: user } = useCurrentUser({ enabled: isAuthenticated }); + const { data: meV2 } = useV2Me({ enabled: isAuthenticated }); + const needsOnboarding = meV2?.onboarding_completed === false; + const hasPendingInvites = meV2?.has_pending_invites === true; + const isStaff = meV2?.is_staff === true; + const { + workspaceId, + workspaceName, + workspace, + workspaces, + isLoading: workspaceLoading, + setWorkspace, + } = useWorkspace(); + const location = useLocation(); + // Hide workspace breadcrumb on selector, create-wizard, org, and admin pages. + const pathNoLocale = location.pathname.replace( + /^\/[a-z]{2}(-[A-Z]{2})?(?=\/)/, + "", + ); + const hideWorkspaceBreadcrumb = + pathNoLocale === "/w" || + pathNoLocale === "/w/" || + pathNoLocale.startsWith("/w/new") || + pathNoLocale.startsWith("/o/") || + pathNoLocale.startsWith("/admin") || + pathNoLocale.startsWith("/settings") || + pathNoLocale.startsWith("/onboarding") || + pathNoLocale.startsWith("/invites"); const navigate = useI18nNavigate(); const { runTransition } = useTransitionCurtain(); const { setLogoUrl } = useWhitelabelLogo(); useEffect(() => { - if (user?.whitelabel_logo) { - setLogoUrl(`${DIRECTUS_PUBLIC_URL}/assets/${user.whitelabel_logo}`); - } else { + if (!isAuthenticated) { setLogoUrl(null); + return; } - }, [user?.whitelabel_logo, setLogoUrl]); + + const insideWorkspace = !hideWorkspaceBreadcrumb; + + // Wait for workspace data before resolving — avoids logo flash on refresh. + if (insideWorkspace && workspaceLoading) return; + + const workspaceLogo = insideWorkspace + ? (logoUrl(workspace?.logo_url) ?? logoUrl(workspace?.org_logo_url)) + : undefined; + const resolved = + workspaceLogo ?? + (user?.whitelabel_logo + ? `${DIRECTUS_PUBLIC_URL}/assets/${user.whitelabel_logo}` + : null); + setLogoUrl(resolved ?? null); + }, [ + isAuthenticated, + hideWorkspaceBreadcrumb, + workspaceLoading, + workspace?.logo_url, + workspace?.org_logo_url, + user?.whitelabel_logo, + setLogoUrl, + ]); let docUrl: string; switch (language) { @@ -109,8 +169,11 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => { message: t`See you soon`, }); + const path = location.pathname + location.search + location.hash; + await logoutMutation.mutateAsync({ doRedirect: true, + next: isAuthPath(location.pathname) ? undefined : path, }); }; @@ -143,21 +206,130 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => { px="md" > - + {/* Logo click: inside a workspace → that workspace's project + list. Outside any workspace context → the workspace + selector (/w). Previously fell back to /projects, which + was the legacy dembrane home and confusing once organisations + existed. */} + + {workspaceName && isAuthenticated && !hideWorkspaceBreadcrumb && ( + + + + + / + + + {workspaceName} + + + + + + + Switch workspace + + + {/* Sort by organisation, then workspace name (2026-04-24). + Before: API order, which made the list feel + random. Now: predictable alphabetical grouping + so users can find "the X workspace on organisation Y". */} + {[...workspaces] + .sort((a, b) => { + const t = (a.org_name || "").localeCompare( + b.org_name || "", + ); + if (t !== 0) return t; + return (a.name || "").localeCompare(b.name || ""); + }) + .map((ws) => { + const isCurrent = ws.id === workspaceId; + return ( + + ) : null + } + onClick={() => { + if (isCurrent) return; + setWorkspace(ws.id); + navigate(`/w/${ws.id}/projects`); + }} + > + + + {ws.name} + + {ws.org_name && ( + + {ws.org_name} + + )} + + + ); + })} + + + navigate("/w")}> + All workspaces + + + + )} {!loading && isAuthenticated && user ? ( - - {ENABLE_ANNOUNCEMENTS && ( - <> - - - + + {/* Staff shortcut — only visible when meV2.is_staff is + true. Purple so it reads as "out-of-model" vs. the + blue primary. Routes to /admin (billing rollup, + at-risk, partners, upgrades). */} + {isStaff && ( + )} + {/* Unified Inbox — one bell, two tabs (For you + + Announcements). Replaces the prior split icons. + ENABLE_ANNOUNCEMENTS still controls whether the + broadcast channel is live, but the bell itself + stays so personal notifications remain reachable. */} + { truncate {...testId("header-user-name")} > - {user.first_name ?? "User"} + {user.first_name ?? t`there`} { {/* Primary */} + {needsOnboarding && ( + } + onClick={() => navigate("/onboarding")} + color="blue" + > + Set up workspace + + )} + {hasPendingInvites && ( + } + onClick={() => navigate("/invites")} + color="blue" + > + You have a pending invite + + )} } onClick={handleSettingsClick} diff --git a/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx b/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx index eb210dc7..6010d5aa 100644 --- a/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx +++ b/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx @@ -1,6 +1,16 @@ import { t } from "@lingui/core/macro"; -import { Box, Divider, LoadingOverlay, Stack } from "@mantine/core"; +import { Trans } from "@lingui/react/macro"; +import { + Badge, + Box, + Divider, + Group, + LoadingOverlay, + Stack, + Tooltip, +} from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; +import { IconLock } from "@tabler/icons-react"; import { useParams } from "react-router"; import { useProjectById } from "@/components/project/hooks"; import { testId } from "@/lib/testUtils"; @@ -16,7 +26,9 @@ export const ProjectOverviewLayout = () => { query: { fields: [ "id", + "name", "language", + "visibility", "is_conversation_allowed", "default_conversation_title", ], @@ -24,6 +36,12 @@ export const ProjectOverviewLayout = () => { }); useDocumentTitle(t`Project Overview | Dembrane`); + const project = projectQuery.data; + const isPrivate = project?.visibility === "private"; + // The project name already lives in the sidebar title (ProjectSidebar). + // Usage / tier info deliberately absent — project pages are where + // participants work, not where billing happens. Usage lives on the + // workspace settings surface. return ( { style={{ backgroundColor: "var(--app-background)" }} > + {project && (isPrivate || project.language) && ( + + {isPrivate && ( + + + + )} + {project.language && ( + + {String(project.language).toUpperCase()} + + )} + + )}
@@ -46,6 +78,7 @@ export const ProjectOverviewLayout = () => { tabs={[ { label: t`Portal Editor`, value: "portal-editor" }, { label: t`Project Settings`, value: "overview" }, + { label: t`Access & usage`, value: "access" }, ]} loading={projectQuery.isLoading} {...testId("project-overview-tabs")} diff --git a/echo/frontend/src/components/layout/WorkspaceLayout.tsx b/echo/frontend/src/components/layout/WorkspaceLayout.tsx new file mode 100644 index 00000000..24c3c52e --- /dev/null +++ b/echo/frontend/src/components/layout/WorkspaceLayout.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { Outlet, useParams } from "react-router"; +import { DowngradeBanner } from "@/components/workspace/DowngradeBanner"; +import { PilotBlockModal } from "@/components/workspace/PilotBlockModal"; +import { SeatCapBanner } from "@/components/workspace/SeatCapBanner"; +import { useWorkspace } from "@/hooks/useWorkspace"; + +/** + * Layout wrapper for /w/:workspaceId/* routes. + * Reads workspaceId from URL and syncs it to workspace context. + * Mounts the 7-day downgrade banner (matrix §3), the seat / guest cap + * banner (matrix §7 + status-banner.md L2), and the Pilot-block modal + * (matrix §8) so they're available on every workspace-scoped route. + * Renders the route via . + * + * Banner stacking: status-banner.md says "never stacked." DowngradeBanner + * + SeatCapBanner can in theory overlap (workspace was downgraded AND + * seats are full), but in practice the downgrade banner is 7-day-bounded + * and rarely co-occurs with cap-reached. Both are dismissible per-session + * so a user with both seen-once never sees them stacked again. + */ +export const WorkspaceLayout = () => { + const { workspaceId: urlWorkspaceId } = useParams<{ workspaceId: string }>(); + const { workspaceId: contextWorkspaceId, setWorkspace } = useWorkspace(); + + // Sync URL param to workspace context + useEffect(() => { + if (urlWorkspaceId && urlWorkspaceId !== contextWorkspaceId) { + setWorkspace(urlWorkspaceId); + } + }, [urlWorkspaceId, contextWorkspaceId, setWorkspace]); + + return ( + <> + + + + + + ); +}; diff --git a/echo/frontend/src/components/members/InviteMemberCard.tsx b/echo/frontend/src/components/members/InviteMemberCard.tsx new file mode 100644 index 00000000..c77fe21b --- /dev/null +++ b/echo/frontend/src/components/members/InviteMemberCard.tsx @@ -0,0 +1,110 @@ +import { + Box, + Group, + Paper, + Stack, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import { IconUserPlus } from "@tabler/icons-react"; +import type { MouseEventHandler, ReactNode } from "react"; + +interface Props { + label: ReactNode; + helperText?: ReactNode; + onClick: MouseEventHandler; + disabled?: boolean; + icon?: ReactNode; + // Tooltip shown on hover. Useful when the card is disabled — the + // disabled state dims helperText to near-unreadable, so the tooltip + // surfaces the same explanation in a high-contrast layer. + tooltip?: ReactNode; +} + +/** + * Dotted-border card that lives as the first row in a Members list. + * Clicking opens the scope-specific invite flow (OrganisationInviteWizard, + * WorkspaceInviteWizard, ProjectSharingModal). Sits in the list so the + * invite affordance has the same visual weight as a member row — + * replaces the old "Invite member" button floating in the header. + */ +export function InviteMemberCard({ + label, + helperText, + onClick, + disabled, + icon, + tooltip, +}: Props) { + const card = ( + + + + + {icon ?? } + + + + {label} + + {helperText && ( + + {helperText} + + )} + + + + + ); + + if (!tooltip) return card; + return ( + + {card} + + ); +} diff --git a/echo/frontend/src/components/members/MembersToolbar.tsx b/echo/frontend/src/components/members/MembersToolbar.tsx new file mode 100644 index 00000000..fb8cce01 --- /dev/null +++ b/echo/frontend/src/components/members/MembersToolbar.tsx @@ -0,0 +1,69 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Group, SegmentedControl, Text, TextInput } from "@mantine/core"; +import { IconSearch } from "@tabler/icons-react"; + +interface FilterSpec { + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; +} + +interface Props { + search: string; + onSearchChange: (value: string) => void; + filter?: FilterSpec; + searchPlaceholder?: string; + count?: { shown: number; total: number }; + error?: string | null; +} + +/** + * Shared toolbar for Members surfaces (Organisation / Workspace / Project). + * + * Matches the pattern from the Organisation tab so the three scopes look the + * same. Search width is constrained so the filter + count stay on the + * right edge on wide screens and collapse beneath on narrow ones. + */ +export function MembersToolbar({ + search, + onSearchChange, + filter, + searchPlaceholder, + count, + error, +}: Props) { + return ( + + + } + placeholder={searchPlaceholder ?? t`Search name or email`} + size="sm" + value={search} + onChange={(e) => onSearchChange(e.currentTarget.value)} + style={{ flex: 1, maxWidth: 320 }} + /> + {filter && ( + + )} + + {error ? ( + + {error} + + ) : count ? ( + + + Showing {count.shown} of {count.total} + + + ) : null} + + ); +} diff --git a/echo/frontend/src/components/members/index.ts b/echo/frontend/src/components/members/index.ts new file mode 100644 index 00000000..3145d058 --- /dev/null +++ b/echo/frontend/src/components/members/index.ts @@ -0,0 +1,2 @@ +export { MembersToolbar } from "./MembersToolbar"; +export { InviteMemberCard } from "./InviteMemberCard"; diff --git a/echo/frontend/src/components/organisation/OrganisationCapBanner.tsx b/echo/frontend/src/components/organisation/OrganisationCapBanner.tsx new file mode 100644 index 00000000..4bf8c075 --- /dev/null +++ b/echo/frontend/src/components/organisation/OrganisationCapBanner.tsx @@ -0,0 +1,122 @@ +import { Plural, Trans } from "@lingui/react/macro"; +import { ActionIcon, Anchor, Group, Paper, Text } from "@mantine/core"; +import { IconX } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; + +interface WorkspaceCapInfo { + id: string; + name: string; + tier: string; + member_count: number; + member_invite_blocked?: boolean; + guest_invite_blocked?: boolean; +} + +interface Props { + organisationId: string; + workspaces: WorkspaceCapInfo[]; +} + +const DISMISS_KEY_PREFIX = "dembrane_orgcap_banner_dismissed:"; + +/** + * Level-2 status banner for org-scoped cap state (status-banner.md). + * + * Renders when one or more workspaces in the organisation have hit a + * member or guest cap. Persistent strip under the header on every + * /o/:organisationId/* route until dismissed (per-session). + * + * Why org-level: an organisation admin might be on the People tab adding + * someone and not realise that one of their workspaces is full — + * pre-warn them before they pick a frozen workspace card. The + * workspace cards in the invite wizard already disable individually, + * but the banner gives a route-level "heads up something needs your + * attention." + */ +export const OrganisationCapBanner = ({ + organisationId, + workspaces, +}: Props) => { + const navigate = useI18nNavigate(); + + const blockedWorkspaces = workspaces.filter( + (w) => w.member_invite_blocked || w.guest_invite_blocked, + ); + + // Per-session dismissal keyed on org + the set of blocked workspaces + + // their cap state. If a new workspace fills up later, the key changes + // and the banner returns. + const stateKey = blockedWorkspaces + .map( + (w) => + `${w.id}:${w.member_invite_blocked ? "M" : ""}${w.guest_invite_blocked ? "G" : ""}`, + ) + .join("|"); + const dismissKey = `${DISMISS_KEY_PREFIX}${organisationId}:${stateKey}`; + + const [dismissed, setDismissed] = useState(false); + useEffect(() => { + setDismissed(sessionStorage.getItem(dismissKey) === "1"); + }, [dismissKey]); + + if (blockedWorkspaces.length === 0 || dismissed) return null; + + const handleDismiss = () => { + sessionStorage.setItem(dismissKey, "1"); + setDismissed(true); + }; + + const first = blockedWorkspaces[0]; + const others = blockedWorkspaces.length - 1; + + return ( + + + + {others === 0 ? ( + + "{first.name}" is at capacity on {first.tier}. New invites to that + workspace are blocked. + + ) : ( + + "{first.name}" and{" "} + {" "} + are at capacity. New invites to those workspaces are blocked. + + )}{" "} + navigate(`/w/${first.id}/settings/billing`)} + > + See usage + + + + + + + + ); +}; diff --git a/echo/frontend/src/components/organisation/OrganisationInviteWizard.tsx b/echo/frontend/src/components/organisation/OrganisationInviteWizard.tsx new file mode 100644 index 00000000..99906568 --- /dev/null +++ b/echo/frontend/src/components/organisation/OrganisationInviteWizard.tsx @@ -0,0 +1,513 @@ +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { + Alert, + Avatar, + Badge, + Box, + Button, + Checkbox, + Group, + Loader, + Modal, + Paper, + Radio, + Stack, + Stepper, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { IconLock, IconUserPlus } from "@tabler/icons-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { toast } from "@/components/common/Toaster"; +import { API_BASE_URL } from "@/config"; +import { avatarUrl, memberInitials } from "@/lib/avatar"; + +export interface OrganisationInviteWizardWorkspace { + id: string; + name: string; + tier: string; + member_count: number; + is_private?: boolean; + // Cap-blocked flags from /v2/orgs/:id/workspaces. Org-level invites are + // always is_org_member=true, so member_invite_blocked is the relevant + // signal for whether this workspace card should be disabled. + member_invite_blocked?: boolean; + guest_invite_blocked?: boolean; +} + +export interface OrganisationInviteWizardMember { + app_user_id: string; + display_name: string; + avatar: string | null; + // workspace_id → role mapping, used to derive avatar bubbles per + // workspace card without a second round-trip. + direct_workspace_roles?: Record; +} + +interface Props { + opened: boolean; + onClose: () => void; + workspaces: OrganisationInviteWizardWorkspace[]; + // Existing organisation members — used to paint avatar bubbles on each + // workspace card so the admin can eyeball who's already in. + members: OrganisationInviteWizardMember[]; +} + +async function inviteToWorkspace( + workspaceId: string, + email: string, + role: string, +) { + const res = await fetch( + `${API_BASE_URL}/v2/workspaces/${workspaceId}/invite`, + { + body: JSON.stringify({ email, is_org_member: true, role }), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "POST", + }, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Failed to invite to workspace"); + } + return res.json() as Promise<{ + status: string; + email: string; + email_sent: boolean; + }>; +} + +/** + * Organisation-level invite wizard (2026-04-24 ask). + * + * Two steps: email + organisation role → pick workspaces. + * + * Organisation-scope invites don't exist as a single backend endpoint — the + * matrix §3 model is that someone "joins the organisation" by joining their + * first workspace. So this wizard fans out: it calls the workspace + * invite endpoint with is_org_member=true for every selected workspace. + * The first call creates the organisation membership; subsequent calls reuse it. + * + * Step 2 cards show tier + member count + a few avatar bubbles of + * current members so the admin can eyeball who's already in without + * leaving the flow. + */ +export function OrganisationInviteWizard({ + opened, + onClose, + workspaces, + members, +}: Props) { + const queryClient = useQueryClient(); + const [step, setStep] = useState(0); + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"member" | "admin">("member"); + const [selected, setSelected] = useState>(new Set()); + // Per-workspace error messages from the last submit attempt. Keyed by + // workspace_id. Lets us paint a red strip with the actual reason on + // each failing card instead of the generic "couldn't send any" toast. + const [errorByWorkspace, setErrorByWorkspace] = useState< + Record + >({}); + + const reset = () => { + setStep(0); + setEmail(""); + setRole("member"); + setSelected(new Set()); + setErrorByWorkspace({}); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const toggle = (id: string, disabled = false) => { + if (disabled) return; + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelected(next); + // Clear any stale per-workspace error on the row the user just + // re-toggled so they don't see a red strip for a row they're no + // longer about to submit (or have re-armed for a retry). + if (errorByWorkspace[id]) { + setErrorByWorkspace((prev) => { + const copy = { ...prev }; + delete copy[id]; + return copy; + }); + } + }; + + // Build "who's already in each workspace" previews from the organisation + // members list. We take up to 4 avatars per workspace — one glance. + const previewsByWorkspace = useMemo(() => { + const map = new Map(); + for (const ws of workspaces) { + const people = members + .filter((m) => m.direct_workspace_roles?.[ws.id]) + .slice(0, 4); + map.set(ws.id, people); + } + return map; + }, [workspaces, members]); + + const submit = useMutation({ + mutationFn: async () => { + const targets = Array.from(selected); + if (targets.length === 0) { + throw new Error(t`Pick at least one workspace.`); + } + const results = await Promise.allSettled( + targets.map((ws) => inviteToWorkspace(ws, email.trim(), role)), + ); + // Capture {workspaceId, message} for every rejection so the + // caller can show actual reasons (e.g. "An invite is already + // pending for this email", "User is already a member") instead + // of a generic try-again. + const failed = results + .map((r, i) => + r.status === "rejected" + ? { + message: + r.reason instanceof Error + ? r.reason.message + : "Failed to invite", + workspaceId: targets[i], + } + : null, + ) + .filter((x): x is { workspaceId: string; message: string } => + Boolean(x), + ); + const ok = results.length - failed.length; + // Row was created but the broker refused the email — admin must resend. + const emailFailed = results.filter( + (r) => r.status === "fulfilled" && r.value.email_sent === false, + ).length; + return { emailFailed, failed, ok }; + }, + onError: (err: Error) => toast.error(err.message), + onSuccess: ({ ok, failed, emailFailed }) => { + queryClient.invalidateQueries({ queryKey: ["v2", "organisation"] }); + queryClient.invalidateQueries({ queryKey: ["v2", "workspaces"] }); + queryClient.invalidateQueries({ queryKey: ["v2", "workspace-settings"] }); + // Persist per-workspace failures on the wizard so the cards + // show the specific reason inline. + const errMap: Record = {}; + for (const f of failed) errMap[f.workspaceId] = f.message; + setErrorByWorkspace(errMap); + + if (failed.length === 0 && emailFailed === 0) { + toast.success( + ok === 1 + ? t`Invite sent to 1 workspace.` + : t`Invites sent to ${ok} workspaces.`, + ); + handleClose(); + return; + } + + if (failed.length === 0 && emailFailed > 0) { + // Two static variants instead of inline `s` — non-English plurals don't survive that. + const partialMsg = + emailFailed === 1 + ? t`Added ${ok}, but 1 email didn't go out. Resend from the Members tab.` + : t`Added ${ok}, but ${emailFailed} emails didn't go out. Resend from the Members tab.`; + toast.error( + emailFailed === ok + ? t`Added, but the invite email didn't go out. Resend it from the workspace's Members tab.` + : partialMsg, + ); + handleClose(); + return; + } + + // Build a concrete toast that says *why* things failed instead of + // "try again". If every failure shares the same reason (the + // common case: "already pending" / "already a member"), surface + // that reason directly. + const distinctReasons = Array.from(new Set(failed.map((f) => f.message))); + const reason = + distinctReasons.length === 1 + ? distinctReasons[0] + : t`Multiple reasons (see workspace list).`; + if (ok === 0) { + toast.error(t`Couldn't send the invite. ${reason}`); + } else { + toast.error( + t`Sent ${ok} of ${ok + failed.length}. ${failed.length === 1 ? failed[0].message : reason}`, + ); + } + }, + }); + + const emailTrimmed = email.trim(); + const emailValid = emailTrimmed.length > 0 && emailTrimmed.includes("@"); + const canAdvanceFromEmail = emailValid; + const canSubmit = emailValid && selected.size > 0; + + return ( + Invite someone to the organisation} + centered + size="lg" + > + + { + if (i <= step) setStep(i); + }} + size="sm" + iconSize={28} + > + + + setEmail(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && canAdvanceFromEmail) { + e.preventDefault(); + setStep(1); + } + }} + /> + setRole(v as "member" | "admin")} + > + + + + Member + + + + Can see and work inside the workspaces you give them + access to. + + + + } + /> + + + Admin + + + + Can invite others, manage workspaces, and change + roles across the organisation. + + + + } + /> + + + + + + + + + + Pick which workspaces this person should land in. They'll join + the organisation through their first workspace. + + + + {workspaces.length === 0 && ( + + + No workspaces yet. Create one first, then come back to + invite people. + + + )} + + + {workspaces.map((ws) => { + const isSelected = selected.has(ws.id); + const avatars = previewsByWorkspace.get(ws.id) ?? []; + // Org invites add an organisation member to the workspace — + // always a non-guest. Member cap is what matters here. The + // guest cap doesn't gate org invites. + const capBlocked = !!ws.member_invite_blocked; + const wsError = errorByWorkspace[ws.id]; + const card = ( + toggle(ws.id, capBlocked)} + style={{ + backgroundColor: isSelected + ? "var(--mantine-color-blue-0)" + : undefined, + borderColor: wsError + ? "var(--mantine-color-yellow-5)" + : isSelected + ? "var(--mantine-color-blue-5)" + : undefined, + cursor: capBlocked ? "not-allowed" : "pointer", + opacity: capBlocked ? 0.6 : 1, + }} + > + + + + toggle(ws.id, capBlocked)} + onClick={(e) => e.stopPropagation()} + aria-label={t`Select ${ws.name}`} + /> + + + + {ws.name} + + {ws.is_private && ( + + )} + + + + + {ws.tier} + + + + + + {capBlocked && ( + + Seats full + + )} + + + + {avatars.length > 0 && ( + + {avatars.map((p) => ( + + {memberInitials(p.display_name)} + + ))} + + )} + + {wsError && ( + + {wsError} + + )} + + + ); + if (!capBlocked) return {card}; + return ( + + {card} + + ); + })} + + + + + + + + {step === 0 ? ( + + ) : ( + + )} + + + + ); +} diff --git a/echo/frontend/src/components/project/PinnedProjectCard.tsx b/echo/frontend/src/components/project/PinnedProjectCard.tsx index 855f6281..8344bf86 100644 --- a/echo/frontend/src/components/project/PinnedProjectCard.tsx +++ b/echo/frontend/src/components/project/PinnedProjectCard.tsx @@ -14,6 +14,7 @@ import { IconExternalLink, IconPinFilled } from "@tabler/icons-react"; import { formatRelative } from "date-fns"; import { Icons } from "@/icons"; import { testId } from "@/lib/testUtils"; +import { formatDurationFromHours } from "@/lib/time"; import { I18nLink } from "../common/i18nLink"; const LANGUAGE_LABELS: Record = { @@ -33,13 +34,17 @@ export const PinnedProjectCard = ({ onSearchOwner, }: { project: Project; - onUnpin: (projectId: string) => void; + // Omitted for guests/externals — the card renders the pin badge + // as a visual cue but without the unpin affordance. + onUnpin?: (projectId: string) => void; isUnpinning?: boolean; onSearchOwner?: (term: string) => void; }) => { const link = `/projects/${project.id}/overview`; const conversationCount = project.conversations_count ?? project?.conversations?.length ?? 0; + const audioHours = + (project as unknown as { audio_hours?: number }).audio_hours ?? 0; const languageLabel = project.language ? (LANGUAGE_LABELS[project.language] ?? project.language.toUpperCase()) : null; @@ -78,25 +83,40 @@ export const PinnedProjectCard = ({ {languageLabel} )} - - { - e.preventDefault(); - e.stopPropagation(); - onUnpin(project.id); - }} - > - - - + {onUnpin ? ( + + { + e.preventDefault(); + e.stopPropagation(); + onUnpin(project.id); + }} + > + + + + ) : ( + // Read-only pin indicator for viewers without write + // permission (guest workspaces). + + )} + {audioHours > 0 && ( + <> + {formatDurationFromHours(audioHours)} + {" • "} + + )} {conversationCount} Conversations • Edited{" "} {formatRelative( diff --git a/echo/frontend/src/components/project/ProjectAccessGuard.tsx b/echo/frontend/src/components/project/ProjectAccessGuard.tsx new file mode 100644 index 00000000..c17646e1 --- /dev/null +++ b/echo/frontend/src/components/project/ProjectAccessGuard.tsx @@ -0,0 +1,115 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Button, Center, Loader, Stack, Text, Title } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useParams } from "react-router"; +import { API_BASE_URL } from "@/config"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; + +interface V2ProjectDetail { + id: string; + name: string | null; + workspace_id: string | null; + visibility: "workspace" | "private"; + role: string; + source: string; + language: string | null; + updated_at: string | null; +} + +async function fetchProjectDetail( + projectId: string, +): Promise<{ ok: true; data: V2ProjectDetail } | { ok: false; status: number }> { + const res = await fetch(`${API_BASE_URL}/v2/projects/${projectId}`, { + credentials: "include", + }); + if (!res.ok) return { ok: false, status: res.status }; + return { ok: true, data: await res.json() }; +} + +/** + * Guards project detail routes against users who don't have access. + * + * Wraps the project detail tree with an upfront v2 access check. If the + * backend returns 404 (which it does both for deleted projects AND for + * private projects the caller isn't shared on — the endpoint deliberately + * doesn't distinguish), renders the designer-approved copy. + * + * Note: conversations / chats / reports of a private project are + * currently reachable via the Directus SDK paths (which don't know about + * visibility). A Directus-permissions update is the proper fix — tracked + * as an open follow-up. This guard covers the URL-pasting case at the + * project-detail entry, which is where most unauthorized access lands. + */ +export const ProjectAccessGuard = ({ children }: { children: ReactNode }) => { + const { projectId } = useParams(); + const navigate = useI18nNavigate(); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ["v2", "project-detail", projectId], + queryFn: () => fetchProjectDetail(projectId as string), + enabled: Boolean(projectId), + // No stale window — visibility changes or share revocations need to + // propagate on next navigation, not up to 30s later. react-query + // still dedupes concurrent requests during a single mount. + staleTime: 0, + retry: false, + }); + + if (!projectId) return <>{children}; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (data && data.ok) { + return <>{children}; + } + + // Distinguish 404 ("you don't have access / not found") from other + // failure modes (500 / network error / expired session). The 404 path + // is deliberate ambiguous copy; every other error gets a retry affordance. + const status = data && !data.ok ? data.status : 0; + const is404 = status === 404; + + return ( +
+ + + {is404 ? ( + <Trans>This isn't available to you</Trans> + ) : ( + <Trans>Something went wrong</Trans> + )} + + + {is404 ? ( + + The link may be private, or it may have moved. Ask the person who + shared it to check. + + ) : ( + + We couldn't load this project. Check your connection and try + again. + + )} + + {is404 ? ( + + ) : ( + + )} + +
+ ); +}; diff --git a/echo/frontend/src/components/project/ProjectAnalysisRunStatus.tsx b/echo/frontend/src/components/project/ProjectAnalysisRunStatus.tsx index d28b6a0f..968d9b77 100644 --- a/echo/frontend/src/components/project/ProjectAnalysisRunStatus.tsx +++ b/echo/frontend/src/components/project/ProjectAnalysisRunStatus.tsx @@ -1,7 +1,6 @@ -import { readItems } from "@directus/sdk"; import { Trans } from "@lingui/react/macro"; import { useQuery } from "@tanstack/react-query"; -import { directus } from "@/lib/directus"; +import { bff } from "@/lib/bff"; import { CloseableAlert } from "../common/ClosableAlert"; import { useLatestProjectAnalysisRunByProjectId } from "./hooks"; @@ -14,37 +13,18 @@ export const ProjectAnalysisRunStatus = ({ projectId ?? "", ); - // one off query + // Count of new chunks since the latest analysis run. Moved to a BFF + // endpoint that scopes to this project — the original frontend read + // forgot to pass project_id and over-counted across the whole DB. const conversationChunksQuery = useQuery({ enabled: !!latestRunQuery.data, queryFn: async () => { - const projectAnalysisRun = latestRunQuery.data; - if (!projectAnalysisRun) { - return 0; - } - - const data = await directus.request( - readItems("conversation_chunk", { - fields: ["id"], - filter: { - timestamp: { - // @ts-expect-error _gt is not typed - _gt: projectAnalysisRun.created_at, - }, - }, - limit: 1, - }), + const run = latestRunQuery.data as { id?: string } | null; + if (!run?.id) return 0; + const { count } = await bff.get<{ count: number }>( + `/analysis-runs/${run.id}/new-chunks-count`, ); - - if (data.length === 0) { - return 0; - } - - try { - return data.length; - } catch { - return 0; - } + return count; }, queryKey: ["conversationChunksProcessingPending", projectId], }); @@ -55,27 +35,6 @@ export const ProjectAnalysisRunStatus = ({ return null; } - // if (data.processing_status === "DONE") { - // return ( - // - // {!!conversationChunksQuery.data && conversationChunksQuery.data > 0 ? ( - // - // - // New conversations have been added since the library was generated. - // Regenerate the library to process them. - // - // - // ) : ( - // <> - // )} - //
- // This project library was generated on{" "} - // {new Date(data.created_at ?? new Date()).toLocaleString()}. - //
- //
- // ); - // } - return (
{conversationChunksQuery.data && conversationChunksQuery.data > 0 ? ( @@ -86,7 +45,6 @@ export const ProjectAnalysisRunStatus = ({ ) : null} - {/* {data.processing_status}: {data.processing_message}{" "} */}
); }; diff --git a/echo/frontend/src/components/project/ProjectListItem.tsx b/echo/frontend/src/components/project/ProjectListItem.tsx index f439fc2e..8cd7e49a 100644 --- a/echo/frontend/src/components/project/ProjectListItem.tsx +++ b/echo/frontend/src/components/project/ProjectListItem.tsx @@ -1,13 +1,92 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { ActionIcon, Badge, Group, Paper, Stack, Text, Tooltip } from "@mantine/core"; -import { IconPin, IconPinFilled } from "@tabler/icons-react"; +import { ActionIcon, Avatar, Badge, Box, Group, Paper, Stack, Text, Tooltip } from "@mantine/core"; +import { IconLock, IconPin, IconPinFilled } from "@tabler/icons-react"; import { formatRelative } from "date-fns"; import type { PropsWithChildren } from "react"; import { Icons } from "@/icons"; +import { avatarUrl } from "@/lib/avatar"; import { testId } from "@/lib/testUtils"; +import { formatDurationFromHours } from "@/lib/time"; import { I18nLink } from "../common/i18nLink"; +/** + * Access bubbles rendered on the project list card. + * + * Design call (2026-04-21): + * - Up to 3 real avatars + a rounded `+N` overflow bubble in Royal Blue. + * - Single group tooltip: "Shared with Alice, Bob, Carol and 12 others". + * - Lives in a fixed-width slot so bubbles align down the column. + * - Private project with count=0 still shows a single creator placeholder + * so the grid doesn't get holes. + */ +function AccessBubbles({ project }: { project: Project }) { + const preview = ( + project as unknown as { + access_preview?: Array<{ + display_name: string; + avatar: string | null; + }>; + access_count?: number; + } + ).access_preview; + const count = + (project as unknown as { access_count?: number }).access_count ?? + preview?.length ?? + 0; + + if (!preview) return null; + // Empty preview for a project we got in the list — rare but render a + // placeholder bubble so the column alignment doesn't break. + if (preview.length === 0) { + return ( + + ? + + ); + } + + const shown = preview.slice(0, 3); + const overflow = Math.max(0, count - shown.length); + const visibleNames = shown.map((p) => p.display_name).filter(Boolean); + const tooltipLabel = + overflow > 0 + ? t`Shared with ${visibleNames.join(", ")} and ${overflow} others` + : t`Shared with ${visibleNames.join(", ")}`; + + return ( + + + {shown.map((p, i) => ( + + {(p.display_name || "?").slice(0, 2).toUpperCase()} + + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + + ); +} + const LANGUAGE_LABELS: Record = { en: "EN", nl: "NL", @@ -47,9 +126,9 @@ export const ProjectListItem = ({ withBorder {...testId(`project-list-item-${project.id}`)} > - - - + + + {project.name} + {/* Muted lock marks private projects on the list. */} + {(project as unknown as { visibility?: string }) + .visibility === "private" && ( + + + + )} {languageLabel && ( {languageLabel} @@ -65,6 +155,14 @@ export const ProjectListItem = ({ )} + {((project as unknown as { audio_hours?: number }).audio_hours ?? 0) > 0 && ( + <> + {formatDurationFromHours( + (project as unknown as { audio_hours?: number }).audio_hours ?? 0, + )} + {" • "} + + )} {project.conversations_count ?? project?.conversations?.length ?? @@ -78,6 +176,10 @@ export const ProjectListItem = ({ {(ownerName || ownerEmail) && ( <> {" • "} + {/* Show name by default; email only on hover via tooltip. + Matches the "don't display emails by default in lists" + rule from CLAUDE.md + brand style guide. Falls back to + email when the owner has no display_name (rare). */} - {ownerName ?? ownerEmail} + {ownerName || t`Unknown`} )} - {onTogglePin && ( - - { - e.preventDefault(); - e.stopPropagation(); - if (isPinned || canPin) { - onTogglePin(project.id); - } - }} + + {/* Access bubbles — dedicated slot directly left of the pin. + Fixed min-width keeps them aligned down the column so rows + scan cleanly. See design-subagent decision 2026-04-21. */} + + + + + {onTogglePin && ( + - {isPinned ? : } - - - )} + { + e.preventDefault(); + e.stopPropagation(); + if (isPinned || canPin) { + onTogglePin(project.id); + } + }} + > + {isPinned ? : } + + + )} + diff --git a/echo/frontend/src/components/project/ProjectSharingModal.tsx b/echo/frontend/src/components/project/ProjectSharingModal.tsx new file mode 100644 index 00000000..5f0d3b04 --- /dev/null +++ b/echo/frontend/src/components/project/ProjectSharingModal.tsx @@ -0,0 +1,274 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + ActionIcon, + Alert, + Avatar, + Button, + Group, + Modal, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { IconTrash, IconX } from "@tabler/icons-react"; +import { useState } from "react"; +import { toast } from "@/components/common/Toaster"; +import { + useAddProjectShare, + useChangeProjectShareRole, + useProjectShares, + useRevokeProjectShare, + useSetProjectVisibility, +} from "@/hooks/useProjectSharing"; +import { avatarUrl, memberInitials } from "@/lib/avatar"; + +interface ProjectSharingModalProps { + projectId: string; + opened: boolean; + visibility: "workspace" | "private"; + workspaceName?: string; + onClose: () => void; +} + +/** + * "Who can see this project?" modal (designer Ask 3 / W3.2). + * + * Verb-first labels: "can edit" / "can read". Only users already in the + * workspace — no cross-workspace sharing (server enforces this). + * + * When visibility='workspace', the modal offers the Make Private action + * with an innovator+ hint if the server rejects. When visibility='private', + * it shows the share list + add affordance. + */ +export function ProjectSharingModal({ + projectId, + opened, + visibility, + workspaceName, + onClose, +}: ProjectSharingModalProps) { + const { data: shares, isLoading } = useProjectShares(projectId); + const addShare = useAddProjectShare(projectId); + const changeRole = useChangeProjectShareRole(projectId); + const revoke = useRevokeProjectShare(projectId); + const setVisibility = useSetProjectVisibility(projectId); + + const [newEmail, setNewEmail] = useState(""); + const [newRole, setNewRole] = useState<"viewer" | "editor">("viewer"); + + const handleAdd = async () => { + const email = newEmail.trim(); + if (!email) return; + try { + await addShare.mutateAsync({ email, role: newRole }); + setNewEmail(""); + setNewRole("viewer"); + toast.success(t`Added`); + } catch (err) { + toast.error(err instanceof Error ? err.message : t`Couldn't add person`); + } + }; + + const handleMakePrivate = async () => { + try { + await setVisibility.mutateAsync("private"); + // Designer's Q3 recommendation: confirm the state + point at + // the next action without preaching. + toast.success(t`Private. Add people to share it.`); + } catch (err) { + const msg = err instanceof Error ? err.message : t`Couldn't change visibility`; + toast.error(msg); + } + }; + + const handleMakeOpen = async () => { + try { + await setVisibility.mutateAsync("workspace"); + toast.success( + workspaceName + ? t`Project is now visible to everyone in ${workspaceName}` + : t`Project is now visible to the workspace`, + ); + onClose(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : t`Couldn't change visibility`, + ); + } + }; + + const title = ( + + Who can see this project? + + ); + + if (visibility === "workspace") { + return ( + + + + + {workspaceName ? ( + + This project is visible to everyone in {workspaceName}. + + ) : ( + + This project is visible to everyone in the workspace. + + )} + + + + Make it private to share with specific people only. Private + projects require the innovator plan or above. + + + + + + + + + + ); + } + + return ( + + + + + Only people already in this workspace can be added. Invite them to + the workspace first if they aren't here yet. + + + + {/* Current shares */} + + {isLoading && } + {!isLoading && (shares?.length ?? 0) === 0 && ( + + Just you, for now. + + )} + {shares?.map((share) => ( + + + {memberInitials(share.display_name, share.email)} + + + + {share.display_name || share.email || t`Unknown`} + + {/* Email always shown next to the name when we got one + back — the server already redacts for non-managers + of private projects. */} + {share.email && share.email !== share.display_name && ( + + {share.email} + + )} + + { + if (val) setNewRole(val as "viewer" | "editor"); + }} + size="sm" + w={120} + /> + + + + {/* Footer: flip back to public */} + + + + + + + ); +} diff --git a/echo/frontend/src/components/project/ProjectSharingStrip.tsx b/echo/frontend/src/components/project/ProjectSharingStrip.tsx new file mode 100644 index 00000000..4f3e71ce --- /dev/null +++ b/echo/frontend/src/components/project/ProjectSharingStrip.tsx @@ -0,0 +1,132 @@ +import { Trans } from "@lingui/react/macro"; +import { + Avatar, + Badge, + Button, + Group, + Loader, + Paper, + Text, +} from "@mantine/core"; +import { IconLock, IconUsers } from "@tabler/icons-react"; +import { useState } from "react"; +import { useProjectShares } from "@/hooks/useProjectSharing"; +import { avatarUrl, memberInitials } from "@/lib/avatar"; +import { ProjectSharingModal } from "./ProjectSharingModal"; + +interface ProjectSharingStripProps { + projectId: string; + visibility: "workspace" | "private"; + workspaceName?: string; +} + +/** + * Persistent "Shared with" strip on the project overview (designer Ask 3 / W3). + * + * Two resting states: + * - visibility='workspace': "Visible to everyone in [workspace] · Make private" + * Framing is intentional — public is the default, private is the action. + * - visibility='private': "[Private pill] Shared with [avatars] +N more · Manage" + * + * Clicking Manage opens the share modal. Making a project private is + * innovator+ gated server-side; UI shows the upgrade path via the modal. + */ +export function ProjectSharingStrip({ + projectId, + visibility, + workspaceName, +}: ProjectSharingStripProps) { + const [modalOpen, setModalOpen] = useState(false); + const { data: shares, isLoading } = useProjectShares(projectId); + + const isPrivate = visibility === "private"; + const shareCount = shares?.length ?? 0; + + return ( + <> + + + {isPrivate ? ( + <> + } + > + Private + + {isLoading ? ( + + ) : shareCount === 0 ? ( + + Just you. Share with specific people → + + ) : ( + <> + + Shared with + + + {shares?.slice(0, 3).map((s) => ( + + {memberInitials(s.display_name, s.email)} + + ))} + + {shareCount > 3 && ( + + +{shareCount - 3} more + + )} + + )} + + + ) : ( + <> + + + {workspaceName ? ( + Visible to everyone in {workspaceName} + ) : ( + Visible to everyone in this workspace + )} + + + + )} + + + + setModalOpen(false)} + /> + + ); +} diff --git a/echo/frontend/src/components/project/ProjectTagsInput.tsx b/echo/frontend/src/components/project/ProjectTagsInput.tsx index 734ec78f..6aecceee 100644 --- a/echo/frontend/src/components/project/ProjectTagsInput.tsx +++ b/echo/frontend/src/components/project/ProjectTagsInput.tsx @@ -42,7 +42,10 @@ import { useUpdateProjectTagByIdMutation, } from "./hooks"; -export const ProjectTagPill = ({ tag }: { tag: ProjectTag }) => { +export const ProjectTagPill = ({ + tag, + projectId, +}: { tag: ProjectTag; projectId: string }) => { const deleteTagMutation = useDeleteTagByIdMutation(); const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false); @@ -115,7 +118,7 @@ export const ProjectTagPill = ({ tag }: { tag: ProjectTag }) => { confirmLabel={Delete} confirmColor="red" onConfirm={() => { - deleteTagMutation.mutate(tag.id); + deleteTagMutation.mutate({ tagId: tag.id, projectId }); closeConfirm(); }} /> @@ -298,6 +301,7 @@ export const ProjectTagsInput = (props: { project: Project }) => { ))} diff --git a/echo/frontend/src/components/project/ProjectUsageAndSharing.tsx b/echo/frontend/src/components/project/ProjectUsageAndSharing.tsx new file mode 100644 index 00000000..9e88aa1a --- /dev/null +++ b/echo/frontend/src/components/project/ProjectUsageAndSharing.tsx @@ -0,0 +1,658 @@ +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { + Avatar, + Badge, + Box, + Group, + Loader, + Paper, + Stack, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { UsageFreshness } from "@/components/common/UsageFreshness"; +import { InviteMemberCard, MembersToolbar } from "@/components/members"; +import { API_BASE_URL } from "@/config"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import { useV2Me } from "@/hooks/useV2Me"; +import { useProjectShares } from "@/hooks/useProjectSharing"; +import { avatarUrl, memberInitials } from "@/lib/avatar"; +import { displayRole } from "@/lib/roles"; +import { formatDurationFromHours } from "@/lib/time"; +import { ProjectSharingModal } from "./ProjectSharingModal"; +import { ProjectSharingStrip } from "./ProjectSharingStrip"; + +interface WorkspaceProjectUsage { + id: string; + name: string; + audio_hours: number; + conversation_count: number; +} + +interface WorkspaceUsageResponse { + tier: string; + tier_tagline: string; + projects: WorkspaceProjectUsage[]; +} + +interface WorkspaceSettingsMember { + id: string; + user_id: string; + display_name: string; + email: string; + avatar: string | null; + role: string; + source: string; + is_external: boolean; +} + +interface WorkspaceSettingsResponse { + members: WorkspaceSettingsMember[]; +} + +async function fetchWorkspaceUsage( + workspaceId: string, +): Promise { + const res = await fetch( + `${API_BASE_URL}/v2/workspaces/${workspaceId}/usage`, + { credentials: "include" }, + ); + if (!res.ok) return null; + return res.json(); +} + +interface ConversationUsageRow { + id: string; + title: string | null; + hours: number; + is_deleted: boolean; +} + +interface ConversationUsageResponse { + active: ConversationUsageRow[]; + deleted: ConversationUsageRow[]; + total_hours: number; + active_hours: number; + deleted_hours: number; +} + +async function fetchConversationUsage( + projectId: string, +): Promise { + const res = await fetch( + `${API_BASE_URL}/v2/projects/${projectId}/conversation-usage`, + { credentials: "include" }, + ); + if (!res.ok) return null; + return res.json(); +} + +async function fetchWorkspaceSettings( + workspaceId: string, +): Promise { + const res = await fetch( + `${API_BASE_URL}/v2/workspaces/${workspaceId}/settings`, + { credentials: "include" }, + ); + if (!res.ok) return null; + return res.json(); +} + +interface Props { + projectId: string; + visibility: "workspace" | "private"; +} + +/** + * "Usage and sharing" surface — now the content of the Access & usage + * tab (2026-04-24). Three blocks: + * + * 1. Per-project usage (audio hours + conversation count this cycle). + * Piggybacks on the workspace /usage endpoint — already cached. + * 2. Who has access: actual people with names/emails/roles. For + * private projects this comes from the project_membership shares; + * for workspace-visible projects we pull the workspace settings + * member list so the tab can answer "who is this project exposed + * to?" without bouncing to workspace settings. + * 3. The visibility toggle strip (Make private / Manage). + */ +export function ProjectUsageAndSharing({ projectId, visibility }: Props) { + const { workspaceId, workspace } = useWorkspace(); + const { data: meV2 } = useV2Me(); + const myAppUserId = meV2?.id ?? null; + const { data: shares, isLoading: sharesLoading } = useProjectShares(projectId); + const [memberSearch, setMemberSearch] = useState(""); + const [memberFilter, setMemberFilter] = useState< + "all" | "admins" | "members" | "guests" + >("all"); + const [inviteOpen, setInviteOpen] = useState(false); + + const { + data: usage, + isLoading: usageLoading, + dataUpdatedAt: usageUpdatedAt, + refetch: refetchUsage, + } = useQuery({ + queryKey: ["v2", "workspace-usage", workspaceId, 0], + queryFn: () => (workspaceId ? fetchWorkspaceUsage(workspaceId) : null), + enabled: Boolean(workspaceId), + staleTime: 60_000, + }); + + const { + data: convUsage, + dataUpdatedAt: convUsageUpdatedAt, + refetch: refetchConvUsage, + } = useQuery({ + queryKey: ["v2", "project-conv-usage", projectId], + queryFn: () => fetchConversationUsage(projectId), + enabled: Boolean(projectId), + staleTime: 60_000, + }); + + // Surface the older of the two timestamps so "Updated X ago" is + // truthful about how stale the card is as a whole. + const usageDataUpdatedAt = Math.min( + usageUpdatedAt || Date.now(), + convUsageUpdatedAt || Date.now(), + ); + const [usageRefreshing, setUsageRefreshing] = useState(false); + const handleUsageRefresh = async () => { + setUsageRefreshing(true); + try { + await Promise.all([refetchUsage(), refetchConvUsage()]); + } finally { + setUsageRefreshing(false); + } + }; + + const isWorkspaceVisible = visibility === "workspace"; + + // Fetch workspace members only when we need them (workspace-visible + // projects). Private projects rely on the shares list below. + const { data: wsSettings, isLoading: membersLoading } = useQuery({ + queryKey: ["v2", "workspace-settings", workspaceId], + queryFn: () => (workspaceId ? fetchWorkspaceSettings(workspaceId) : null), + enabled: Boolean(workspaceId && isWorkspaceVisible), + staleTime: 60_000, + }); + + const projectUsage = usage?.projects?.find((p) => p.id === projectId) ?? null; + + // ── "Who has access" rows ── + // For workspace-visible projects, we render the workspace member + // roster. For private, we render the explicit share rows. Both get + // projected into the same shape (name/email/role/avatar/external) so + // the list renders uniformly. + type AccessRow = { + key: string; + user_id: string; + display_name: string; + email: string; + avatar: string | null; + role: string; + is_external?: boolean; + }; + + const accessRows: AccessRow[] = isWorkspaceVisible + ? ROLE_SORT( + (wsSettings?.members ?? []).map((m) => ({ + key: m.id, + user_id: m.user_id, + display_name: m.display_name, + email: m.email, + avatar: m.avatar, + role: m.role, + is_external: m.is_external, + })), + ) + : (shares ?? []).map((s) => ({ + key: s.user_id, + user_id: s.user_id, + display_name: s.display_name, + email: s.email, + avatar: s.avatar, + role: s.role, + })); + + const accessCount = accessRows.length; + const accessLoading = isWorkspaceVisible ? membersLoading : sharesLoading; + const hasGuestRows = useMemo( + () => accessRows.some((r) => r.is_external), + [accessRows], + ); + const filteredAccessRows = useMemo(() => { + const q = memberSearch.trim().toLowerCase(); + return accessRows.filter((r) => { + if (memberFilter === "admins") { + if (r.is_external) return false; + if (!(r.role === "owner" || r.role === "admin")) return false; + } + if (memberFilter === "members") { + if (r.is_external) return false; + if (r.role === "owner" || r.role === "admin") return false; + } + if (memberFilter === "guests" && !r.is_external) return false; + if (!q) return true; + return ( + (r.display_name || "").toLowerCase().includes(q) || + (r.email || "").toLowerCase().includes(q) + ); + }); + }, [accessRows, memberSearch, memberFilter]); + + return ( + + + + <Trans>Access & usage</Trans> + + + + What this project is using, and who can see it. + + + + + {/* Usage block — small, quiet. Tier info lives on the workspace + usage card; this is just per-project consumption. */} + + + + + Usage this cycle + + {usage?.tier && ( + + + {usage.tier} + + + )} + + + {usageLoading && !projectUsage && } + + {!usageLoading && projectUsage && ( + + + + Audio + + + {formatDurationFromHours(projectUsage.audio_hours)} + + + + + Conversations + + + {projectUsage.conversation_count} + + + + )} + + {!usageLoading && !projectUsage && ( + + No usage yet this cycle. + + )} + + {/* Per-conversation breakdown (2026-04-24 ask). Stacked bar: + each active conversation is its own segment; soft-deleted + conversations are aggregated into a single red "deleted" + bucket at the end. Hover reveals name + hours per segment. */} + {convUsage && convUsage.total_hours > 0 && ( + + + + Breakdown · {convUsage.active.length} active + + {convUsage.deleted.length > 0 && ( + <> + {" · "} + + {convUsage.deleted.length} deleted + + + )} + + + {convUsage.active.map((row) => { + const pct = (row.hours / convUsage.total_hours) * 100; + if (pct <= 0) return null; + return ( + + + {row.title || t`Untitled conversation`} + + + {formatDurationFromHours(row.hours)} + + + } + withArrow + > + + + ); + })} + {convUsage.deleted_hours > 0 && ( + + + Deleted · {formatDurationFromHours(convUsage.deleted_hours)} + + {convUsage.deleted.slice(0, 8).map((d) => ( + + {d.title || t`Untitled conversation`} + {" · "} + {formatDurationFromHours(d.hours)} + + ))} + {convUsage.deleted.length > 8 && ( + + + +{convUsage.deleted.length - 8} more + + + )} + + } + withArrow + position="top" + > + + + )} + + + + + + Active · {formatDurationFromHours(convUsage.active_hours)} + + + {convUsage.deleted_hours > 0 && ( + + + + Deleted · {formatDurationFromHours(convUsage.deleted_hours)} + + + )} + + + )} + + + + + {/* The visibility toggle — keeps the "Make private" / "Manage" + action in one predictable place. Sits above the list so + the action is above the fold regardless of roster size. */} + + + {/* Members — same shape as Organisation + Workspace: toolbar, dotted + invite card as first row, then one Paper per person. The + "Make private / Manage" toggle lives on the strip above; + this list is read-only for workspace-visible projects and + editable (via the modal) for private ones. */} + + + + <Trans>Members</Trans> + + + {accessLoading ? ( + Loading… + ) : isWorkspaceVisible ? ( + + {" "} + in {workspace?.name ?? t`this workspace`} + + ) : accessCount === 0 ? ( + Just you — plus workspace admins. + ) : ( + <> + + {" · "} + plus workspace admins + + )} + + + + + setMemberFilter(v as typeof memberFilter), + options: [ + { value: "all", label: t`All` }, + { value: "admins", label: t`Admins` }, + { value: "members", label: t`Members` }, + ...(hasGuestRows + ? [{ value: "guests", label: t`Guests` }] + : []), + ], + }} + count={{ + shown: filteredAccessRows.length, + total: accessCount, + }} + /> + + + {/* Invite card — for a private project it opens the + add-share modal. For a workspace-visible project + it opens the same modal which surfaces "Make + private" as the path to specific-member access. */} + Make private to invite specific members
+ ) : ( + Share with someone + ) + } + helperText={ + isWorkspaceVisible ? ( + + This project is visible to everyone in the workspace. + + ) : ( + Add a member and pick their access. + ) + } + onClick={() => setInviteOpen(true)} + /> + + {accessLoading ? ( + + ) : accessRows.length === 0 ? ( + + + {isWorkspaceVisible ? ( + No one's on the workspace yet. + ) : ( + + No explicit shares. Workspace admins still have access. + + )} + + + ) : ( + filteredAccessRows.map((row) => ( + + + + + {memberInitials(row.display_name, row.email)} + + + + + {row.display_name || row.email} + {row.user_id === myAppUserId && ( + + {" "} + (You) + + )} + + {row.is_external && ( + + Guest + + )} + + {row.email && + row.email !== row.display_name && ( + + {row.email} + + )} + + + + {displayRole(row.role)} + + + + )) + )} + {!accessLoading && + accessRows.length > 0 && + filteredAccessRows.length === 0 && ( + + No one matches that filter. + + )} + + + + setInviteOpen(false)} + /> + + ); +} + +// Sort helper — internals before externals, then by role weight, then by +// name. Matches the WorkspaceSettings members tab so the two lists feel +// the same. Kept outside the component to avoid re-creating the weight +// map on every render. +const ROLE_WEIGHT: Record = { + owner: 0, + admin: 1, + billing: 2, + member: 3, + viewer: 4, + editor: 3, +}; +function ROLE_SORT( + rows: T[], +): T[] { + return [...rows].sort((a, b) => { + const aExt = a.is_external ? 1 : 0; + const bExt = b.is_external ? 1 : 0; + if (aExt !== bExt) return aExt - bExt; + const ar = ROLE_WEIGHT[a.role] ?? 99; + const br = ROLE_WEIGHT[b.role] ?? 99; + if (ar !== br) return ar - br; + return (a.display_name || a.email || "").localeCompare( + b.display_name || b.email || "", + ); + }); +} diff --git a/echo/frontend/src/components/project/hooks/index.ts b/echo/frontend/src/components/project/hooks/index.ts index c27742dc..71d89b74 100644 --- a/echo/frontend/src/components/project/hooks/index.ts +++ b/echo/frontend/src/components/project/hooks/index.ts @@ -1,11 +1,4 @@ -import { - createItem, - deleteItem, - type Query, - readItem, - readItems, - updateItem, -} from "@directus/sdk"; +import type { Query } from "@directus/sdk"; import { t } from "@lingui/core/macro"; import { useInfiniteQuery, @@ -15,6 +8,7 @@ import { } from "@tanstack/react-query"; import { toast } from "@/components/common/Toaster"; import { useAddChatContextMutation } from "@/components/conversation/hooks"; +import { API_BASE_URL } from "@/config"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { api, @@ -22,13 +16,14 @@ import { cloneProjectById, createCustomVerificationTopic, deleteCustomVerificationTopic, + deleteProjectById, + deleteTagById, getLatestProjectAnalysisRunByProjectId, getVerificationTopics, type UpdateCustomTopicPayload, updateCustomVerificationTopic, type VerificationTopicsResponse, } from "@/lib/api"; -import { directus } from "@/lib/directus"; // ── BFF: Projects Home ────────────────────────────────────────────────── @@ -54,18 +49,21 @@ interface BffProjectsHomeResponse { export const useProjectsHome = ({ search, limit = 15, + workspaceId, }: { search?: string; limit?: number; + workspaceId?: string | null; }) => { return useInfiniteQuery({ - queryKey: ["projects", "home", search], + queryKey: ["projects", "home", workspaceId ?? null, search], initialPageParam: 0, getNextPageParam: (lastPage: BffProjectsHomeResponse, _allPages, lastPageParam) => lastPage.has_more ? lastPageParam + 1 : undefined, queryFn: async ({ pageParam = 0 }) => { const params = new URLSearchParams(); if (search) params.set("search", search); + if (workspaceId) params.set("workspace_id", workspaceId); params.set("offset", String(pageParam * limit)); params.set("limit", String(limit)); const resp = await api.get( @@ -88,27 +86,109 @@ export const useTogglePinMutation = () => { }) => { return api.patch(`/projects/${projectId}/pin`, { pin_order }); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["projects"] }); + // Optimistic update: move the project between pinned / list + // immediately so the UI responds to the click. Without this the + // user waits on the full refetch before the card jumps — on a + // slow connection it looks like nothing happened. Rolls back + // on error. Applies to BOTH the v1 (/projects/home) cache and + // the v2 workspace-projects cache — pre-fix the mutation only + // invalidated v1, so pinning on /w/:id/projects looked broken. + onMutate: async ({ projectId, pin_order }) => { + await queryClient.cancelQueries({ queryKey: ["projects", "home"] }); + await queryClient.cancelQueries({ queryKey: ["v2", "workspace-projects"] }); + + type PageShape = { + pinned: Array<{ id: string; pin_order: number | null } & Record>; + projects: Array<{ id: string; pin_order: number | null } & Record>; + }; + type CacheShape = { pages: PageShape[]; pageParams: unknown[] }; + + const applyOptimistic = (data: CacheShape | undefined): CacheShape | undefined => { + if (!data?.pages?.length) return data; + const firstPage = data.pages[0]; + const moving = + firstPage.pinned.find((p) => p.id === projectId) ?? + data.pages.flatMap((p) => p.projects).find((p) => p.id === projectId); + if (!moving) return data; + + const nextFirst: PageShape = { + ...firstPage, + pinned: + pin_order == null + ? firstPage.pinned.filter((p) => p.id !== projectId) + : [ + ...firstPage.pinned.filter((p) => p.id !== projectId), + { ...moving, pin_order }, + ].sort( + (a, b) => (a.pin_order ?? 0) - (b.pin_order ?? 0), + ), + projects: firstPage.projects.map((p) => + p.id === projectId ? { ...p, pin_order } : p, + ), + }; + const nextPages = [nextFirst, ...data.pages.slice(1)].map((page, i) => + i === 0 + ? page + : { + ...page, + projects: page.projects.map((p) => + p.id === projectId ? { ...p, pin_order } : p, + ), + }, + ); + return { ...data, pages: nextPages }; + }; + + const snapshots: Array<[readonly unknown[], CacheShape | undefined]> = []; + for (const [key, data] of queryClient.getQueriesData({ + queryKey: ["projects", "home"], + })) { + snapshots.push([key, data]); + queryClient.setQueryData(key, applyOptimistic(data)); + } + for (const [key, data] of queryClient.getQueriesData({ + queryKey: ["v2", "workspace-projects"], + })) { + snapshots.push([key, data]); + queryClient.setQueryData(key, applyOptimistic(data)); + } + return { snapshots }; }, - onError: (error: any) => { + onError: (error: any, _vars, ctx) => { + if (ctx?.snapshots) { + for (const [key, data] of ctx.snapshots) { + queryClient.setQueryData(key, data); + } + } const detail = error?.response?.data?.detail; toast.error(detail ?? t`Failed to update pin`); }, + onSettled: () => { + // Reconcile with the server regardless — optimistic state is a + // guess; this is the ground truth. Both caches must be busted; + // the v1 invalidate-alone version pre-fix missed the v2 key + // and was the reported "pinned projects doesn't work without + // refresh" bug. + queryClient.invalidateQueries({ queryKey: ["projects", "home"] }); + queryClient.invalidateQueries({ queryKey: ["v2", "workspace-projects"] }); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + }, }); }; export const useDeleteProjectByIdMutation = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (projectId: string) => - directus.request(deleteItem("project", projectId)), + mutationFn: (projectId: string) => deleteProjectById(projectId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"], }); queryClient.resetQueries(); - toast.success("Project deleted successfully"); + toast.success(t`Project deleted`); + }, + onError: (error: Error) => { + toast.error(error.message || t`Failed to delete project`); }, }); }; @@ -149,14 +229,33 @@ export const useCloneProjectByIdMutation = () => { export const useCreateProjectTagMutation = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (payload: { + mutationFn: async (payload: { project_id: { id: string; directus_user_id: string; }; text: string; sort?: number; - }) => directus.request(createItem("project_tag", payload as any)), + }) => { + const res = await fetch( + `${API_BASE_URL}/v2/bff/tags`, + { + body: JSON.stringify({ + project_id: payload.project_id.id, + text: payload.text, + sort: payload.sort, + }), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "POST", + }, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Failed to create tag"); + } + return res.json(); + }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ["projects", variables.project_id.id], @@ -169,14 +268,26 @@ export const useCreateProjectTagMutation = () => { export const useUpdateProjectTagByIdMutation = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ + mutationFn: async ({ id, payload, }: { id: string; project_id: string; payload: Partial; - }) => directus.request(updateItem("project_tag", id, payload)), + }) => { + const res = await fetch(`${API_BASE_URL}/v2/bff/tags/${id}`, { + body: JSON.stringify(payload), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "PATCH", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Failed to update tag"); + } + return (await res.json()) as ProjectTag; + }, onSuccess: (_values, variables) => { queryClient.invalidateQueries({ queryKey: ["projects", variables.project_id], @@ -189,13 +300,16 @@ export const useDeleteTagByIdMutation = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (tagId: string) => - directus.request(deleteItem("project_tag", tagId)), + mutationFn: (payload: { tagId: string; projectId: string }) => + deleteTagById(payload.projectId, payload.tagId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"], }); - toast.success("Tag deleted successfully"); + toast.success(t`Tag deleted`); + }, + onError: (error: Error) => { + toast.error(error.message || t`Failed to delete tag`); }, }); }; @@ -212,25 +326,25 @@ export const useCreateChatMutation = () => { id: string; }; }) => { - const project = await directus.request( - readItem("project", payload.project_id.id, { - fields: ["is_enhanced_audio_processing_enabled"], - }), - ); - - const chat = await directus.request( - createItem("project_chat", { - // Don't set chat_mode here - use initialize-mode endpoint instead - auto_select: - payload.conversationId && - project.is_enhanced_audio_processing_enabled - ? false - : !!project.is_enhanced_audio_processing_enabled, - project_id: payload.project_id, + // BFF picks up auto_select default from the project's enhanced + // audio flag when we don't force a value. Only override when a + // specific conversation was passed — same rule as before. + const res = await fetch(`${API_BASE_URL}/v2/bff/chats`, { + body: JSON.stringify({ + project_id: payload.project_id.id, + auto_select: payload.conversationId ? false : undefined, }), - ); + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Failed to create chat"); + } + const chat = (await res.json()) as { id: string }; - if (payload.navigateToNewChat && chat && chat.id) { + if (payload.navigateToNewChat && chat?.id) { navigate(`/projects/${payload.project_id.id}/chats/${chat.id}`); } @@ -263,8 +377,25 @@ export const useLatestProjectAnalysisRunByProjectId = (projectId: string) => { export const useUpdateProjectByIdMutation = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - directus.request(updateItem("project", id, payload)), + mutationFn: async ({ + id, + payload, + }: { + id: string; + payload: Partial; + }) => { + const res = await fetch(`${API_BASE_URL}/v2/bff/projects/${id}`, { + body: JSON.stringify(payload), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "PATCH", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Failed to update project"); + } + return (await res.json()) as Project; + }, onSuccess: (_values, variables) => { queryClient.invalidateQueries({ queryKey: ["projects", variables.id], @@ -309,20 +440,18 @@ export const useInfiniteProjects = ({ lastPage.nextOffset, initialPageParam: 0, queryFn: async ({ pageParam = 0 }) => { - const response = await directus.request( - readItems("project", { - ...query, - limit: initialLimit, - offset: pageParam * initialLimit, - }), + void query; // advanced filter shapes not forwarded to BFF + const response = await fetch( + `${API_BASE_URL}/v2/bff/projects?limit=${initialLimit}&offset=${pageParam * initialLimit}`, + { credentials: "include" }, ); - + if (!response.ok) { + return { nextOffset: undefined, projects: [] as Project[] }; + } + const data = (await response.json()) as Project[]; return { - nextOffset: - response.length === initialLimit ? pageParam + 1 : undefined, - projects: response.map((r) => ({ - ...r, - })), + nextOffset: data.length === initialLimit ? pageParam + 1 : undefined, + projects: data, }; }, queryKey: ["projects", query], @@ -349,8 +478,44 @@ export const useProjectById = ({ query?: Partial>; }) => { return useQuery({ - queryFn: () => - directus.request(readItem("project", projectId, query)), + // BFF migration (2026-04-24): the frontend used to call Directus + // directly via readItem("project", ...), but Directus row-level + // ACL doesn't know about our v2 inheritance/sharing model — a + // workspace member reaching a project through a derived organisation + // admin row was 403'ing on the Directus read. The /bff endpoint + // runs the access check through get_user_project_access and + // returns the full project row (with sorted tags) under the + // admin client. Keeps the same return shape so callers don't + // change. + queryFn: async () => { + const rawFields = Array.isArray(query?.fields) ? query.fields : []; + const includeTags = rawFields.some( + (f) => + (typeof f === "string" && f === "tags") || + (typeof f === "object" && f !== null && "tags" in f), + ); + // Collect scalar field names (ignore wildcard `*` and tag + // relation entries). When a caller passes a narrow list we + // forward it to the BFF so the response stays small — used + // by summary-card callers who just need one boolean. Empty + // or `*` means "give me everything". + const scalarFields = rawFields + .filter((f): f is string => typeof f === "string" && f !== "*" && f !== "tags"); + const url = new URL( + `${API_BASE_URL}/v2/projects/${projectId}/bff`, + window.location.origin, + ); + if (!includeTags) url.searchParams.set("include_tags", "false"); + if (scalarFields.length > 0) { + url.searchParams.set("fields", scalarFields.join(",")); + } + const res = await fetch(url.toString(), { credentials: "include" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Failed to load project"); + } + return (await res.json()) as Project; + }, queryKey: ["projects", projectId, query], }); }; diff --git a/echo/frontend/src/components/report/CreateReportForm.tsx b/echo/frontend/src/components/report/CreateReportForm.tsx index 8a4c1a0f..6943aa14 100644 --- a/echo/frontend/src/components/report/CreateReportForm.tsx +++ b/echo/frontend/src/components/report/CreateReportForm.tsx @@ -14,10 +14,7 @@ import { Text, Tooltip, } from "@mantine/core"; -import { - isDateFarEnough, - ScheduleDateTimePicker, -} from "./ScheduleDateTimePicker"; +import { usePostHog } from "@posthog/react"; import { IconArrowLeft, IconClock, @@ -37,6 +34,10 @@ import { languageOptionsByIso639_1 } from "../language/LanguagePicker"; import { ConversationStatusTable } from "./ConversationStatusTable"; import { useCreateProjectReportMutation } from "./hooks"; import { ReportFocusSelector } from "./ReportFocusSelector"; +import { + isDateFarEnough, + ScheduleDateTimePicker, +} from "./ScheduleDateTimePicker"; function getLanguageLabel(iso: string): string { return languageOptionsByIso639_1.find((o) => o.value === iso)?.label ?? iso; @@ -78,6 +79,7 @@ export const CreateReportForm = ({ onSuccess }: { onSuccess: () => void }) => { const [detailModalOpened, setDetailModalOpened] = useState(false); const [showSchedule, setShowSchedule] = useState(false); const [scheduledDate, setScheduledDate] = useState(null); + const posthog = usePostHog(); const hasConversations = conversationCounts && conversationCounts.total > 0; const hasFinishedConversations = @@ -88,6 +90,12 @@ export const CreateReportForm = ({ onSuccess }: { onSuccess: () => void }) => { error instanceof AxiosError && error.response?.status === 409; const handleCreate = (schedule?: boolean) => { + posthog?.capture("report_generated", { + has_user_instructions: !!userInstructions, + language, + project_id: projectId, + scheduled: !!schedule, + }); mutate( { language, diff --git a/echo/frontend/src/components/report/hooks/index.ts b/echo/frontend/src/components/report/hooks/index.ts index 51442d5c..f7d0fdc7 100644 --- a/echo/frontend/src/components/report/hooks/index.ts +++ b/echo/frontend/src/components/report/hooks/index.ts @@ -1,4 +1,3 @@ -import { readItem, readItems } from "@directus/sdk"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { cancelScheduledReport, @@ -16,7 +15,7 @@ import { listProjectReports, updateProjectReport, } from "@/lib/api"; -import { directus } from "@/lib/directus"; +import { bff } from "@/lib/bff"; export const useUpdateProjectReportMutation = () => { const queryClient = useQueryClient(); @@ -197,93 +196,38 @@ export const useLatestProjectReport = (projectId: string) => { export const useProjectReportTimelineData = (projectReportId: string) => { return useQuery({ queryFn: async () => { - const projectReport = await directus.request( - readItem("project_report", projectReportId, { - fields: ["id", "date_created", "project_id"], - }), - ); - - if (!projectReport?.project_id) { - throw new Error("No project_id found on this report"); - } - - const allProjectReports = await directus.request( - readItems("project_report", { - fields: ["id", "date_created"], - filter: { - project_id: { _eq: projectReport.project_id }, - }, - limit: 1000, - sort: "date_created", - }), - ); - - const project = await directus.request( - readItem("project", projectReport.project_id.toString(), { - fields: ["id", "created_at"], - }), - ); - - const conversations = await directus.request( - readItems("conversation", { - fields: ["id", "created_at"], - filter: { - project_id: { _eq: projectReport.project_id }, - }, - limit: 1000, - }), - ); - - let conversationChunkAgg: { conversation_id: string; count: number }[] = - []; - if (conversations.length > 0) { - const conversationIds = conversations.map((c) => c.id); - const chunkCountsAgg = await directus.request< - Array<{ conversation_id: string; count: number }> - >( - readItems("conversation_chunk", { - aggregate: { count: "*" }, - filter: { conversation_id: { _in: conversationIds } }, - groupBy: ["conversation_id"], - }), - ); - conversationChunkAgg = chunkCountsAgg; - } - - const projectReportMetrics = await directus.request< - ProjectReportMetric[] - >( - readItems("project_report_metric", { - fields: ["id", "date_created", "project_report_id"], - filter: { - project_report_id: { - project_id: { _eq: project.id }, - }, - }, - limit: 1000, - sort: "date_created", - }), - ); + // One BFF round-trip replaces the old 6-query sequence — + // server-side access-checks the report then aggregates + // siblings + conversations + chunk counts + metrics. + const data = await bff.get<{ + report: { id: string; date_created: string; project_id: string }; + all_reports: { id: string; date_created: string }[]; + project_created_at: string | null; + conversations: { + id: string; + created_at: string; + chunk_count: number; + }[]; + metrics: ProjectReportMetric[]; + }>(`/reports/${projectReportId}/timeline`); return { - allReports: allProjectReports.map((r) => ({ + allReports: data.all_reports.map((r) => ({ createdAt: r.date_created, id: r.id, })), - conversationChunks: conversations.map((conv) => { - const aggRow = conversationChunkAgg.find( - (row) => row.conversation_id === conv.id, - ); - return { - chunkCount: aggRow?.count ?? 0, - conversationId: conv.id, - createdAt: conv.created_at, - }; - }), - conversations: conversations, - projectCreatedAt: project.created_at, - projectReportMetrics, - reportCreatedAt: projectReport.date_created, + conversationChunks: data.conversations.map((conv) => ({ + chunkCount: conv.chunk_count, + conversationId: conv.id, + createdAt: conv.created_at, + })), + conversations: data.conversations.map((c) => ({ + id: c.id, + created_at: c.created_at, + })) as unknown as Conversation[], + projectCreatedAt: data.project_created_at, + projectReportMetrics: data.metrics, + reportCreatedAt: data.report.date_created, }; }, queryKey: ["reports", projectReportId, "timelineData"], diff --git a/echo/frontend/src/components/settings/MyAccessCard.tsx b/echo/frontend/src/components/settings/MyAccessCard.tsx new file mode 100644 index 00000000..9cde8881 --- /dev/null +++ b/echo/frontend/src/components/settings/MyAccessCard.tsx @@ -0,0 +1,236 @@ +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { + Badge, + Button, + Card, + Group, + Loader, + Stack, + Text, + Title, +} from "@mantine/core"; +import { IconExternalLink, IconPlus } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { API_BASE_URL } from "@/config"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; +import { displayRole, roleColor } from "@/lib/roles"; + +interface Workspace { + id: string; + name: string; + org_id: string; + org_name: string; + role: string; + tier: string; + is_external: boolean; + project_count: number; + member_count: number; +} + +interface OrganisationRollup { + id: string; + name: string; + role: string; + workspace_count: number; + total_members: number; + total_projects: number; +} + +interface WorkspacesResponse { + workspaces: Workspace[]; + organisations: OrganisationRollup[]; +} + +async function fetchAccess(): Promise { + const res = await fetch(`${API_BASE_URL}/v2/workspaces`, { + credentials: "include", + }); + if (!res.ok) return null; + return res.json(); +} + +/** + * "My access" card on user settings. Gives the caller a complete read + * of what they can reach across organisations + workspaces + where they stand + * in each. Uses the same /v2/workspaces response the selector uses, so + * the cache warms the other surface and vice versa. + * + * Project-level access is implicit: a workspace membership unlocks + * every workspace-visible project; direct-project-only access (via + * project sharing) is rare and the workspace card links let the user + * drill in when they need per-project detail. + */ +export const MyAccessCard = () => { + const navigate = useI18nNavigate(); + const { data, isLoading } = useQuery({ + queryKey: ["v2", "workspaces"], + queryFn: fetchAccess, + staleTime: 60_000, + }); + + // Group workspaces under their organisation so the list reads as + // "organisation → workspaces in that organisation with your role." + const byOrganisation = useMemo(() => { + const out = new Map< + string, + { organisation: OrganisationRollup | null; workspaces: Workspace[] } + >(); + if (!data) return out; + for (const ws of data.workspaces) { + const key = ws.org_id || "__orphan__"; + const organisation = data.organisations.find((t) => t.id === ws.org_id) ?? null; + const existing = out.get(key); + if (existing) existing.workspaces.push(ws); + else out.set(key, { organisation, workspaces: [ws] }); + } + return out; + }, [data]); + + if (isLoading) { + return ( + + + + + + ); + } + + const totalOrganisations = data?.organisations.length ?? 0; + const totalWorkspaces = data?.workspaces.length ?? 0; + + return ( + + + + + + <Trans>What you can reach</Trans> + + + + {" · "} + + + + + + + {byOrganisation.size === 0 ? ( + + + You're not in any organisation yet. Create a workspace to start a + organisation, or ask a member for an invite. + + + ) : ( + + {Array.from(byOrganisation.values()).map(({ organisation, workspaces }) => ( + + {/* Organisation header sits flush-left so the eye reads + "organisation → workspaces" as a hierarchy. Only the + workspace rows are indented + rule'd. */} + + + + {organisation?.name ?? t`(direct workspace access)`} + + {organisation && ( + + {displayRole(organisation.role)} + + )} + + {organisation && ( + + )} + + + + {workspaces.map((ws) => ( + + navigate(`/w/${ws.id}/projects`) + } + > + + + {ws.name} + + + {ws.is_external + ? t`Guest` + : displayRole(ws.role)} + + + + + {" · "} + + {ws.tier} + + + + ))} + + + ))} + + )} + + + ); +}; diff --git a/echo/frontend/src/components/workspace/AccessRequestsList.tsx b/echo/frontend/src/components/workspace/AccessRequestsList.tsx new file mode 100644 index 00000000..f5737cbd --- /dev/null +++ b/echo/frontend/src/components/workspace/AccessRequestsList.tsx @@ -0,0 +1,166 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + ActionIcon, + Badge, + Box, + Divider, + Group, + Paper, + Stack, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { IconCheck, IconX } from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@/components/common/Toaster"; +import { API_BASE_URL } from "@/config"; + +interface AccessRequestRow { + id: string; + workspace_id: string; + user_id: string; + user_display_name: string | null; + user_email: string | null; + status: string; + requested_at: string; +} + +async function fetchRequests(workspaceId: string): Promise { + const res = await fetch( + `${API_BASE_URL}/v2/workspaces/${workspaceId}/access-requests`, + { credentials: "include" }, + ); + if (!res.ok) return []; + const data = await res.json(); + return data.requests ?? []; +} + +async function postAction( + workspaceId: string, + reqId: string, + action: "approve" | "reject", +) { + const res = await fetch( + `${API_BASE_URL}/v2/workspaces/${workspaceId}/access-requests/${reqId}/${action}`, + { method: "POST", credentials: "include" }, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || `Couldn't ${action}`); + } + return res.json(); +} + +/** + * Pending access-requests list on the workspace settings page (matrix §6). + * + * Shows organisation-member requests-to-join that need admin approval. Hides + * itself when there are no pending rows — no empty-state clutter next + * to the members table. Approve writes a direct Member row; Reject is + * silent to the requester. + */ +export const AccessRequestsList = ({ workspaceId }: { workspaceId: string }) => { + const queryClient = useQueryClient(); + + const { data } = useQuery({ + queryKey: ["v2", "access-requests", workspaceId], + queryFn: () => fetchRequests(workspaceId), + staleTime: 15_000, + }); + + const rows = data ?? []; + + const invalidateAll = () => { + queryClient.invalidateQueries({ queryKey: ["v2", "access-requests", workspaceId] }); + queryClient.invalidateQueries({ queryKey: ["v2", "workspace-settings", workspaceId] }); + queryClient.invalidateQueries({ queryKey: ["v2", "workspaces"] }); + }; + + const approveMutation = useMutation({ + mutationFn: (reqId: string) => postAction(workspaceId, reqId, "approve"), + onSuccess: () => { + toast.success(t`Request approved`); + invalidateAll(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const rejectMutation = useMutation({ + mutationFn: (reqId: string) => postAction(workspaceId, reqId, "reject"), + onSuccess: () => { + toast.success(t`Request declined`); + invalidateAll(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (rows.length === 0) return null; + + return ( + + + + + <Trans>Access requests</Trans> + + + {rows.map((r) => ( + + + + + {r.user_display_name || r.user_email || t`Organisation member`} + + {r.user_email && r.user_display_name && ( + + + · + + + )} + + Pending + + + + + approveMutation.mutate(r.id)} + aria-label={t`Approve`} + > + + + + + rejectMutation.mutate(r.id)} + aria-label={t`Decline`} + > + + + + + + + ))} + + + + ); +}; diff --git a/echo/frontend/src/components/workspace/DiscoverableWorkspaces.tsx b/echo/frontend/src/components/workspace/DiscoverableWorkspaces.tsx new file mode 100644 index 00000000..de537752 --- /dev/null +++ b/echo/frontend/src/components/workspace/DiscoverableWorkspaces.tsx @@ -0,0 +1,213 @@ +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { + Button, + Collapse, + Group, + Paper, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; +import { IconChevronDown, IconLock, IconPlus } from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "@/components/common/Toaster"; +import { API_BASE_URL } from "@/config"; + +interface DiscoverableWorkspace { + id: string; + name: string; + visibility: string; + action: "join" | "request-access" | "pending" | "member"; + pending_request_id: string | null; +} + +async function fetchDiscoverable(orgId: string): Promise { + const res = await fetch( + `${API_BASE_URL}/v2/orgs/${orgId}/discoverable-workspaces`, + { credentials: "include" }, + ); + if (!res.ok) return []; + const data = await res.json(); + return data.workspaces ?? []; +} + +async function postJoin(workspaceId: string) { + const res = await fetch(`${API_BASE_URL}/v2/workspaces/${workspaceId}/join`, { + method: "POST", + credentials: "include", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Couldn't join"); + } + return res.json(); +} + +async function postRequestAccess(workspaceId: string) { + const res = await fetch( + `${API_BASE_URL}/v2/workspaces/${workspaceId}/access-requests`, + { method: "POST", credentials: "include" }, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Couldn't send request"); + } + return res.json(); +} + +/** + * Matrix §6 Slack-style discovery surface for the home page. + * + * Shows workspaces in the organisation that the user has NO direct membership + * to (filtered server-side). Organisation admins see all with Join; organisation + * members see open workspaces with Request access (or Pending when a + * request is already out). + */ +export const DiscoverableWorkspaces = ({ orgId }: { orgId: string }) => { + const queryClient = useQueryClient(); + // Collapsed by default (2026-04-24 ask). The list can be long on + // bigger organisations and most of the time the user isn't here to discover + // new workspaces — they're here to enter one they already belong to. + const [open, setOpen] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: ["v2", "discoverable-workspaces", orgId], + queryFn: () => fetchDiscoverable(orgId), + staleTime: 30_000, + // Privacy toggles elsewhere don't invalidate this query on other users' + // clients — refetch when the tab regains focus so lists catch up quickly. + refetchOnWindowFocus: "always", + }); + + const joinable = (data ?? []).filter( + (w) => w.action === "join" || w.action === "request-access" || w.action === "pending", + ); + + const joinMutation = useMutation({ + mutationFn: postJoin, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["v2", "workspaces"] }); + queryClient.invalidateQueries({ + queryKey: ["v2", "workspaces-context"], + }); + queryClient.invalidateQueries({ + queryKey: ["v2", "discoverable-workspaces", orgId], + }); + toast.success(t`Joined`); + }, + onError: (error: Error) => toast.error(error.message), + }); + + const requestMutation = useMutation({ + mutationFn: postRequestAccess, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["v2", "discoverable-workspaces", orgId], + }); + toast.success(t`Request sent`); + }, + onError: (error: Error) => toast.error(error.message), + }); + + if (isLoading || joinable.length === 0) return null; + + return ( + + setOpen((v) => !v)} + aria-expanded={open} + style={{ + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: "2px 0", + }} + > + + + Discoverable in this organisation + + + + + + + + {joinable.map((ws) => ( + + + + {ws.visibility === "private" && ( + + )} + + {ws.name} + + {ws.visibility === "private" && ( + + Private + + )} + + {ws.action === "join" && ( + + )} + {ws.action === "request-access" && ( + + )} + {ws.action === "pending" && ( + + Request sent + + )} + + + ))} + + + + ); +}; diff --git a/echo/frontend/src/components/workspace/DowngradeBanner.tsx b/echo/frontend/src/components/workspace/DowngradeBanner.tsx new file mode 100644 index 00000000..090b17f7 --- /dev/null +++ b/echo/frontend/src/components/workspace/DowngradeBanner.tsx @@ -0,0 +1,112 @@ +import { Trans } from "@lingui/react/macro"; +import { ActionIcon, Anchor, Group, Paper, Text } from "@mantine/core"; +import { IconX } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import { onFrozenFeatureAttempt } from "@/lib/frozenFeatureAttempt"; + +/** + * 7-day post-downgrade banner (matrix v1.1 §3). + * + * Reads `workspace.downgraded_at` + `downgraded_from_tier` from the + * workspace summary. Renders for 7 days past the stamp. + * + * Dismissable per-session. Matrix spec says it auto-returns on frozen- + * feature-attempt — that's a follow-up (FeatureGate would need to fire a + * signal here). For this first pass, dismissal is simple session-local. + */ +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const DISMISS_KEY_PREFIX = "dembrane_downgrade_banner_dismissed:"; + +export const DowngradeBanner = () => { + const { workspace } = useWorkspace(); + const navigate = useI18nNavigate(); + const downgradedAt = workspace?.downgraded_at; + const tier = workspace?.tier; + const workspaceId = workspace?.id; + + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (!workspaceId || !downgradedAt) return; + const stored = sessionStorage.getItem( + `${DISMISS_KEY_PREFIX}${workspaceId}:${downgradedAt}`, + ); + setDismissed(stored === "1"); + }, [workspaceId, downgradedAt]); + + // Matrix §3: auto-return on frozen-feature-attempt. When FeatureGate + // opens its tier modal, clear the dismissal so this banner comes back. + // No-op for workspaces without an active downgrade — the outer early- + // returns guard that path. + useEffect(() => { + if (!workspaceId || !downgradedAt) return; + return onFrozenFeatureAttempt(() => { + sessionStorage.removeItem( + `${DISMISS_KEY_PREFIX}${workspaceId}:${downgradedAt}`, + ); + setDismissed(false); + }); + }, [workspaceId, downgradedAt]); + + if (!workspace || !downgradedAt || !tier || !workspaceId) return null; + + const stamp = Date.parse(downgradedAt); + if (Number.isNaN(stamp)) return null; + + const now = Date.now(); + if (now - stamp > SEVEN_DAYS_MS) return null; + if (dismissed) return null; + + const sinceDate = new Date(stamp).toLocaleDateString(undefined, { + day: "numeric", + month: "long", + year: "numeric", + }); + + const handleDismiss = () => { + sessionStorage.setItem( + `${DISMISS_KEY_PREFIX}${workspaceId}:${downgradedAt}`, + "1", + ); + setDismissed(true); + }; + + return ( + + + + + This workspace was downgraded to {tier} on {sinceDate}. Some + features are limited. + {" "} + navigate(`/w/${workspaceId}/settings/billing`)} + > + Learn more + + + + + + + + ); +}; diff --git a/echo/frontend/src/components/workspace/FeatureGate.tsx b/echo/frontend/src/components/workspace/FeatureGate.tsx new file mode 100644 index 00000000..ca9a7eaa --- /dev/null +++ b/echo/frontend/src/components/workspace/FeatureGate.tsx @@ -0,0 +1,398 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + Avatar, + Badge, + Box, + Button, + Group, + Modal, + Stack, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import { IconLock } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; +import { toast } from "@/components/common/Toaster"; +import { TierCapacityMatrix } from "@/components/workspace/TierCapacityMatrix"; +import { API_BASE_URL } from "@/config"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import { avatarUrl } from "@/lib/avatar"; +import { emitFrozenFeatureAttempt } from "@/lib/frozenFeatureAttempt"; + +/** + * Tier-gating UI primitives for the ECHO platform. + * + * Locks in the designer's Ask 4 decisions (D9, D20): + * - Gate affordance = modal only, no hover tooltip (hover reads as + * marketing pressure). + * - Role-aware: admin/owner sees "Request upgrade" primary. Member + * sees no primary CTA — only dismiss ("ask a organisation admin" is the + * message, not a button). + * - "dembrane" lowercase, no "AI", no "successfully" per brand rules. + * + * Exports: + * - — hatched overlay for whole feature surfaces (4B) + * - — one-feature, one-CTA modal (4C), usable standalone + * - requiredTierCopy — shared "This feature requires X plan" text + */ + +export type Tier = + | "pilot" + | "pioneer" + | "innovator" + | "changemaker" + | "guardian"; + +const TIER_LABEL: Record = { + changemaker: "changemaker", + guardian: "guardian", + innovator: "innovator", + pilot: "pilot", + pioneer: "pioneer", +}; + +interface FeatureGateProps { + /** Currently-resolved workspace tier. */ + currentTier: Tier; + /** Minimum tier the wrapped feature requires. */ + requiredTier: Tier; + /** "Whitelabel branding" / "Data export" / "API access" etc. */ + featureName: string; + /** One-line benefit sentence. */ + benefit: string; + /** `true` if the caller has admin/owner role in this workspace. */ + canRequestUpgrade: boolean; + /** Workspace id so the modal can POST /v2/workspaces/:id/upgrade-request. */ + workspaceId: string; + /** The gated feature's normal render — shown under the hatched overlay. */ + children: ReactNode; +} + +const TIER_ORDER: Tier[] = [ + "pilot", + "pioneer", + "innovator", + "changemaker", + "guardian", +]; + +function meetsTier(current: Tier, required: Tier): boolean { + return TIER_ORDER.indexOf(current) >= TIER_ORDER.indexOf(required); +} + +/** + * Wraps a feature card with a hatched overlay when the tier doesn't meet + * the minimum. The entire card becomes a click target that opens the + * upgrade modal. If the tier is already met, renders children as-is. + */ +export function FeatureGate({ + currentTier, + requiredTier, + featureName, + benefit, + canRequestUpgrade, + workspaceId, + children, +}: FeatureGateProps) { + const [modalOpen, setModalOpen] = useState(false); + + if (meetsTier(currentTier, requiredTier)) { + return <>{children}; + } + + // Deliberately do NOT render children when gated. `pointer-events: none` + // was a tempting dimming trick but doesn't stop keyboard-level event + // listeners, async code paths, or focus-trap components inside the + // gated subtree (round-2 audit, Security M1). The only safe boundary + // is "don't mount the feature at all when the tier doesn't meet" — + // render just the gate placeholder card. + // Matrix §3: attempting a frozen feature re-shows the post-downgrade + // banner if it was dismissed. We fire on every gate-open; DowngradeBanner + // only reacts when the current workspace has an active downgrade, so this + // is a cheap no-op on never-downgraded workspaces. + const openModal = () => { + emitFrozenFeatureAttempt(); + setModalOpen(true); + }; + + return ( + <> + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openModal(); + } + }} + > + + } + > + {TIER_LABEL[requiredTier]} + + + {featureName} + + + {benefit} + + + + setModalOpen(false)} + currentTier={currentTier} + requiredTier={requiredTier} + featureName={featureName} + benefit={benefit} + canRequestUpgrade={canRequestUpgrade} + workspaceId={workspaceId} + /> + + ); +} + +interface UpgradeModalProps { + opened: boolean; + onClose: () => void; + currentTier: Tier; + requiredTier: Tier; + featureName: string; + benefit: string; + /** Admin/owner sees Request Upgrade. Member sees message-only per D9. */ + canRequestUpgrade: boolean; + workspaceId: string; +} + +/** + * Ask 4C — one feature, one benefit, one tier, one CTA. + * + * Admin path: "Request upgrade" posts to /v2/workspaces/:id/upgrade-request. + * Member path: informational only; the copy says "ask a organisation admin" but + * there's no button — Q3 decision (D9). Keeping the message honest: + * there's nothing we can do for them, only their admin can. + */ +interface OrganisationAdminRow { + user_id: string; + display_name: string | null; + avatar: string | null; + role: string; +} + +/** + * Renders the matrix §11 member-path copy with actual admin faces + + * names so "ask a organisation admin" is concrete, not abstract. + * + * Silent fallback to the generic message if the org lookup fails — no + * broken state. + */ +function OrganisationAdminChips() { + const { workspace } = useWorkspace(); + const orgId = workspace?.org_id; + + const { data } = useQuery({ + enabled: Boolean(orgId), + queryFn: async (): Promise => { + if (!orgId) return []; + const res = await fetch(`${API_BASE_URL}/v2/orgs/${orgId}/members`, { + credentials: "include", + }); + if (!res.ok) return []; + const rows = (await res.json()) as OrganisationAdminRow[]; + return Array.isArray(rows) + ? rows.filter((r) => r.role === "admin" || r.role === "owner") + : []; + }, + queryKey: ["v2", "organisation-admins", orgId], + staleTime: 5 * 60 * 1000, + }); + + const admins = data ?? []; + if (admins.length === 0) { + // Fallback — generic message when we can't resolve the admin list. + return ( + + + A organisation admin can request this upgrade. Ask someone with the + admin role. + + + ); + } + + const firstThree = admins.slice(0, 3); + const names = firstThree + .map((a) => a.display_name || t`a organisation admin`) + .join(", "); + const more = + admins.length > firstThree.length + ? ` +${admins.length - firstThree.length}` + : ""; + + return ( + + + Ask a organisation admin to request this upgrade. + + + + {firstThree.map((a) => ( + + + + ))} + + + {names} + {more} + + + + ); +} + +export function UpgradeModal({ + opened, + onClose, + currentTier, + requiredTier, + featureName, + benefit, + canRequestUpgrade, + workspaceId, +}: UpgradeModalProps) { + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + + const handleRequest = async () => { + // Guard against double-fire: Mantine's `loading` prop doesn't disable + // the button, so a fast double-click would fire two POSTs before the + // first setSending(true) paints (round-2 audit, Reliability H2). + if (sending) return; + setSending(true); + try { + const res = await fetch( + `${API_BASE_URL}/v2/workspaces/${workspaceId}/upgrade-request`, + { + body: JSON.stringify({ + message: message.trim() || undefined, + target_tier: requiredTier, + }), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "POST", + }, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + const detail = + typeof data.detail === "string" + ? data.detail + : t`Couldn't send the request`; + throw new Error(detail); + } + toast.success(t`Request sent. We'll be in touch.`); + onClose(); + setMessage(""); + } catch (err) { + toast.error(err instanceof Error ? err.message : t`Couldn't send`); + } finally { + setSending(false); + } + }; + + return ( + {featureName}
} + centered + size="md" + > + + + {benefit} + + + {/* Matrix §1 requires the full capacity matrix visible in- + product on the upgrade-request modal. fromTier clips the + table to tiers strictly above the current; highlightTier + calls out the minimum tier the gate needs. */} + + + {canRequestUpgrade ? ( + <> +
- + diff --git a/echo/directus/templates/user-invite.liquid b/echo/directus/templates/user-invite.liquid index 0d4a3333..26d2d7d7 100644 --- a/echo/directus/templates/user-invite.liquid +++ b/echo/directus/templates/user-invite.liquid @@ -1,38 +1,24 @@ {% layout "email-base" %} {% block content %} -

You've been invited to Dembrane!

+

You're invited to dembrane.

-

- You have been invited to join Dembrane. Please click the - button below to accept this invitation and join the project: +

+ Click below to accept the invitation and get started with {{ projectName }}.

- - - Join {{ projectName }} - - +
+ - + + + + + +
- - - - - -
- Dembrane Logo - -

Dembrane

-
- -

- -

+

+ dembrane +
+

{% if conversation_name and conversation_name != "" %} - Er is een rapport aangemaakt voor het gesprek "{{ conversation_name }}" waaraan je hebt bijgedragen. + Je rapport over "{{ conversation_name }}" is klaar. {% else %} - Er is een rapport aangemaakt waaraan je hebt bijgedragen. + Je rapport is klaar. {% endif %} -

+ -

- Bekijk het hier: Rapport bekijken +

+ Bekijk wat er uit het gesprek is gekomen waar je aan hebt bijgedragen.

-

We geloven in je!

-

Het Dembrane Team

+ + +
+ Rapport bekijken +
-

- Beantwoord deze e-mail niet. Dit is een geautomatiseerd bericht en antwoorden worden niet gemonitord. - Voor vragen of hulp, neem contact met ons op via - info@dembrane.com. -

+

— het dembrane team

-
+

+ Dit is een automatisch bericht, reacties worden niet gelezen. Vragen? + Mail ons op info@dembrane.com. +

-

- Als je deze meldingen niet meer wilt ontvangen, klik dan op - Afmelden +

+ Afmelden voor deze meldingen.

+ +
+ +
+ Join {{ projectName }} +
-{% endblock %} \ No newline at end of file +

+ Or paste this into your browser:
+ {{url}} +

+ +

+ Didn't expect this? Ignore this email — nothing will happen. +

+ +{% endblock %} diff --git a/echo/directus/templates/user-registration.liquid b/echo/directus/templates/user-registration.liquid index 17bbb916..bdd7e627 100644 --- a/echo/directus/templates/user-registration.liquid +++ b/echo/directus/templates/user-registration.liquid @@ -1,40 +1,24 @@ -{% layout 'email-base' %} +{% layout "email-base" %} {% block content %} -{% block content %} +

One tap and you're in.

-

Verify your email address

+

+ Confirm your email to finish setting up your dembrane account. The link expires in 24 hours. +

-

- Thanks for registering at Dembrane! + + +
+ Verify email +
- To complete your registration you need to verify your email address by opening the following verification link. - Please feel free to ignore this email if you have not personally initiated the registration. +

+ Or paste this into your browser:
+ {{url}}

- - - Verify email - - +

+ Didn't sign up? Ignore this email — nothing will happen. +

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/echo/docs/audit-report.md b/echo/docs/audit-report.md new file mode 100644 index 00000000..74bd5965 --- /dev/null +++ b/echo/docs/audit-report.md @@ -0,0 +1,215 @@ +# QA Audit Report — Workspaces +Date: 2026-04-23 · Auditor: Claude (automated, 8 bucket agents) +Branch: workspaces · Frontend: http://localhost:5173 + +--- + +## 1. SHOWSTOPPERS + +### Auth / Session +- `[fresh-user /auth/login]` After registering a new account and clicking Login with Chrome autofill, the session resolved to `emma@seed.dembrane.dev` (a different user) — real workspace data (Partner Consulting, Client Alpha/Beta, 4 conversations) was visible to the fresh registrant. Possible autofill + permissive login bug; warrants backend investigation. +- `[fresh-user /auth/register→/en-US/login]` After completing registration, the app redirects to `/en-US/login?next=%2Fprojects` with the email field cleared and no "check your inbox" message. The user is left on the login page with no recovery path. + +### Blank app on login (Cara & Dan) +- `[cara@seed /en-US/projects]` `/api/v2/me` returns `orgs: []` for Cara despite her being in "Acme Research" org per admin. She sees a completely blank app — no workspaces, no projects. Seed data or org-membership sync bug. +- `[dan@seed /en-US/projects]` Same blank-app issue. Even if orgs were populated, billing API is 404 so his Billing role grants nothing visible. + +### Invite API broken for all users +- `[all users /api/v2/orgs/{id}/invites]` GET and POST both return **404** for every role (Owner, Admin, Member). The invite button in workspace settings opens the dialog but any send will fail silently or return 404. Workspace-level invite endpoint (`/api/v2/workspaces/{id}/invites`) also 404. + +### Billing API broken for all users +- `[all users /api/v2/orgs/{id}/billing]` Returns **404** for every user regardless of role. Billing tab in workspace/organisation settings fails to load for everyone, including Owners on paid tiers. + +### React crash: Remove member & Leave workspace +- `[emma /en-US/w//settings]` Clicking the trash icon next to any workspace member crashes the entire React app with `Uncaught Error: Trans component was rendered without I18nProvider`. The page goes blank; no API call is made; the member is not removed. Root cause: `` (LinguiJS) component inside a Mantine Portal loses the I18nProvider context. One fix resolves both. +- `[emma /en-US/w//settings]` "Leave workspace" button triggers the identical I18nProvider crash. Workspace departure is completely broken for all multi-member workspaces. + +### Navigation bugs +- `[all users /en-US/w → Manage organisation]` The "Manage organisation" button on the workspaces list page navigates to `/en-US/o` (missing the organisation UUID), rendering a blank page. The organisation management page is unreachable via this UI entry point. +- `[all users /en-US/w//settings]` Clicking the role dropdown (e.g., "Admin" textbox) for a workspace member navigates to the Danger tab instead of opening the role selector. Role changes via workspace settings are broken. + +### Inbox / Notifications +- `[all users /en-US/w (Inbox)]` Clicking the "Announcements" tab inside the Inbox dialog dismisses the dialog rather than switching tabs. The modal loses focus and closes; announcement content is completely unreachable via click. + +### Limits / Billing UX +- `[finn /en-US/w//settings?tab=billing]` Pilot billing tab reads "Request an upgrade above" but renders no upgrade button above that text. The CTA is a dead reference. +- `[finn /en-US/o/?tab=people]` Organisation People tab shows "Showing 0 of 0" members despite Finn being the sole owner. Header correctly says "1 person" — display bug in the tab body. + +--- + +## 2. CONFUSION + +### Onboarding & first visit +- `[fresh-user /auth/register]` Wizard says "Three quick steps and you're in" but step 3 is an out-of-band inbox verification. Copy oversells speed. +- `[fresh-user /en-US/w]` The product never explains the organisation / workspace / project hierarchy. Three nouns appear on the landing page (organisation header → workspace cards → project counts) with no introductory sentence. +- `[fresh-user /en-US/o/...]` "Innovator" and "Pioneer" tags on workspace cards are never explained. Read as plan tiers, roles, or achievement badges — no tooltip. +- `[fresh-user /auth/register step 2]` "Create account" button goes immediately disabled after click with no spinner or "Creating…" text. Looks like the click failed. +- `[fresh-user]` URL scheme inconsistency: registration at `/auth/register`; post-action redirect to `/en-US/login`. Locale prefix appears without explanation. +- `[fresh-user /en-US/o/...]` Organisation name helper says "Shown on the workspace selector and in email subject lines." The "workspace selector" has not been introduced to a first-time visitor. + +### Role / permission visibility +- `[all users /en-US/w]` User role (Owner / Admin / Member / Billing) is only visible per-workspace card. No global role indicator in the nav or account menu. Users can't easily answer "what am I in this organisation?" +- `[dan@seed /en-US/]` "Billing" role is never rendered anywhere in the UI that Dan himself would see. The role's capabilities are also moot because the billing API is 404. +- `[hank@seed /en-US/w]` Hank owns "Alpha Inc" (0 workspaces, 0 projects) but his real workspace is "Client Beta" (Partner Consulting). The workspaces page shows his Alpha Inc org banner with zeros while his actual work lives in another org — the cross-org access is not explained. + +### Invite & guest +- `[ben /en-US/w//settings (invite dialog)]` External person type description ("workspace-only access, doesn't count as a seat") doesn't list what an External user *cannot* see (billing, other workspaces, organisation member emails). Inviters don't know where the data boundary is. +- `[ben /en-US/w//settings (invite dialog)]` The Workspace Role selector (Member / Billing / Admin) remains visible after selecting "External" person type. It's unclear whether an External-type invitee can hold the Billing workspace role and thereby see billing data. +- `[finn /en-US/w//settings (invite dialog)]` Invite form opens freely with no mention of seat limits even though Finn is at/near tier limits. Seat counter (0/2) shown on the projects header is absent from the invite flow. + +### Limits +- `[finn /en-US/w]` "AT LIMIT" badge on workspace card does not label *what* is at limit (audio hours, seats, or projects). User must navigate to billing to find out. +- `[finn /en-US/o/?tab=usage]` "1 AT LIMIT" label appears on the Projects row in the Usage tab — but the limit is on audio hours, not projects. The label placement implies a project count limit that doesn't exist. + +### Destructive flows +- `[anna /en-US/w//settings?tab=danger]` Workspace delete uses the term "soft-delete" in its warning copy. Non-technical users expect permanent deletion; the retention-window nuance is buried and not actionable. +- `[anna /en-US/w/ delete project]` Neither delete-project confirmation modal shows the count of conversations to be destroyed. The scope of deletion is invisible to the user. +- `[F3/F4]` The React crash (blank white page) is visually indistinguishable from a network timeout. Users will attempt to reload and retry, repeatedly, not knowing the action failed. + +### Notifications +- `[ben /en-US/w (Inbox)]` Badge "1" on the Inbox bell while "For you" tab says "You're all caught up." The badge appears to count Announcements only, with no visual distinction between "For you" badge and "Announcements" badge. + +--- + +## 3. POLISH + +### Copy +- `[/en-US/w/.../settings]` "No logo set — dembrane default will be used." — brand name should be "Dembrane" (capitalised). Same error in 2 other locations in project-defaults settings. +- `[/en-US/w]` "1 people" on organisation overview card — ungrammatical; should be "1 person" or "1 member". +- `[/en-US/w/.../settings?tab=danger]` "Clear the 1 project(s) first." — `(s)` pluralisation is awkward; should use conditional ("Clear the project first" / "Clear all projects first"). +- `[/en-US/w/.../settings?tab=billing]` "…email upgrades@dembrane.com for now." — "for now" is filler; cut. +- `[/auth/register footer]` "Dembrane B.V. 2026, all rights reserved." — comma after "B.V." creates odd rhythm; "© 2026 Dembrane B.V. All rights reserved." is cleaner. +- `[/auth/register]` Privacy policy link opens in same tab — clicking it abandons the signup flow. Should open in a new tab. +- `[/en-US/w/.../settings]` Em dashes in radio labels ("Open to the organisation — organisation admins get access automatically", "Private — only people you explicitly invite") and hint text clash with the casual brand voice. Tier taglines use em dashes intentionally (punchy); inline radio usage does not. + +### Brand name +- Three occurrences of "dembrane" (lowercase) in product copy. All should be "Dembrane": (1) logo-set hint in workspace settings, (2) "Upload a custom logo to replace the dembrane logo" in project defaults, (3) "Using default dembrane logo." + +### Accessibility +- `[/en-US/w/.../settings (invite dialog)]` Close button (×) on invite dialog has no aria-label — screen reader unfriendly. +- `[/en-US/w/.../projects]` Project row action button (`opacity-0 group-hover:opacity-100`) has no aria-label and is invisible until hover. + +### Dutch (nl-NL) translation +- `[/nl-NL/login]` "Create an account" button not translated (should be "Account aanmaken"). +- `[/nl-NL/w]` Workspace summary stats fully English: "1 workspace · 1 person · 1 projects · 10.3 h this month". "1 projects" is also a grammar bug in English. +- `[/nl-NL/w]` "AT LIMIT" badge not translated. +- `[/nl-NL/w]` "Manage organisation", "Manage", "Add workspace", "Owner" all English. +- `[/nl-NL/w/.../settings]` ~80% English bleed: Billing tab, Danger tab, Privacy & defaults heading, all access/membership copy, all CTA buttons ("Upload logo", "Add workspace", "Invite member"). +- Formality: Dutch copy correctly uses "je/jij" throughout — register is on-brand. +- Page title bug: "Login | dembrane" shown as document title while URL was `/en-US/projects`. + +--- + +## 4. MODEL DISCOVERY + +**Verdict: PARTIALLY FOUND** + +The product clearly supports a consulting-firm-delivers-for-clients pattern, but it is not a labeled, first-class UI concept. + +### How it was found +- Emma's organisation is literally named "Partner Consulting". Her two workspaces are named "Client Alpha" and "Client Beta". +- In workspace settings, the organisation name appears inline below the workspace name and tier tag: `Client Alpha / Pioneer / for your first real engagements. / Partner Consulting`. This is the only explicit visual signal of the relationship. +- Hank (who owns a separate, empty org "Alpha Inc") has workspace-Admin access to "Client Beta" (Partner Consulting). This hints at a cross-org workspace-membership capability — unexplained anywhere in the UI. + +### What the UI communicates +- The model is implied entirely through naming conventions. There is no label, tooltip, or onboarding copy explaining "your organisation can manage workspaces on behalf of clients." +- A new user would see "Partner Consulting" → "Client Alpha" / "Client Beta" and either infer the relationship (savvy) or think those are internal project codenames (common mistake). + +### Isolation: does it hold? +- YES. Anna ("Acme Research") and Emma ("Partner Consulting") have zero visibility into each other's workspaces — entirely separate orgs with no cross-org discovery surface. Isolation appears solid. + +### What could not be confirmed +- Whether a client company gets its own login to their named workspace. Hank's cross-org access hints this is possible, but the mechanism and any access-limiting copy were not found. +- The `external_count: 0` field in Emma's org API response is inconsistent with Hank having workspace access — suggests external-member counting may be broken, not just unexplained. + +### Pages that should have referenced it and didn't +- `/en-US/o/` (Organisation settings overview) — no "client workspaces" or "managed on behalf of" framing. +- `/en-US/w//settings` (Workspace settings, Members tab) — no "external org" label on Hank's entry, no explanation of cross-org access. +- Invite dialog — no option labeled "Client access" or "External organisation"; the "External" person type is seat-based framing, not org-relationship framing. +- Billing tab — no mention of per-client billing or workspace-level billing separation. + +### Hints seen but unconfirmed +- "for your first real engagements." (Pioneer tier tagline) — implies client-facing delivery work. +- "Whitelabel Project" workspace in Anna's "Acme Research" — may indicate white-label delivery model; no further context. +- `DISCOVERABLE IN THIS ORGANISATION` section — intra-organisation workspace discovery exists; no cross-organisation equivalent found. + +--- + +## 5. DESIGN CONSISTENCY + +Observations about pattern divergence — not individual bugs, but surfaces that expose two or more different implementations of the same concept. + +### Confirmations for destructive actions (3 different patterns) +- Delete workspace → inline Danger tab, text-input name-match, no modal +- Delete project → double sequential modal ("Cannot be undone" → "Are you absolutely sure?") +- Remove member / Leave workspace → intended single modal (currently crashes) +Three destructive flows, three UX patterns. A user who deleted a project learns nothing about how workspace deletion will feel. + +### Settings tab implementation (inconsistent across surfaces) +- Workspace settings: query-param tabs (`?tab=billing`, `?tab=danger`) — URL-addressable, back/forward works +- Organisation page: Mantine tab component (Overview / Usage / People) — in-page state, not URL segments +- Project settings: buried inside a "Settings" tab within the project overview — not a `/settings` route at all +These behave differently on reload, share, and keyboard navigation. The role-change dropdown accidentally triggering Danger tab navigation is a symptom of mixing tab implementations on the same page. + +### Loading / submission feedback (inconsistent) +- Create account button: instantly disabled, no spinner, no "Creating…" label — looks like a failed click +- Delete workspace: shows a success toast after completion; no spinner during deletion +No single loading-state convention across forms. + +### Logo upload (two different input types for the same concept) +- Organisation logo: raw `https://` URL text field +- Workspace logo: file upload picker +Same concept, different affordance depending on which settings level you're on. + +### Tier / plan display (no consistent visual slot) +- Workspace cards: tier badge alongside role — "Owner / Innovator", "Owner / Pioneer", "Pilot" (Pilot uses no slash separator) +- "AT LIMIT" badge appears on workspace card, projects page header, and organisation Usage tab — three different visual treatments +- Billing tab: Innovator/Pioneer shows a full tier comparison table; Pilot shows a placeholder — same tab, completely different content structure per tier + +### Member / role management (two surfaces, different interactions) +- Workspace members (settings Overview tab): inline trash icon + role dropdown in the same row +- Organisation People tab: separate surface, different controls +No shared member-row component. + +### Empty-state consistency +- Finn's workspace (no projects): blank app, no empty-state illustration, no CTA +- Cara and Dan (broken org membership): also blank app — visually indistinguishable from Finn's intentional empty state + +--- + +## 6. COPY CONSISTENCY + +Axes where the product uses multiple words or formats for the same concept, creating terminology drift that is especially visible during a demo. + +### "Member" is overloaded across 3 meanings +- **Person type** in invite dialog: "Member" (seat-counted) vs "External" (no seat) +- **Workspace role** in invite dialog and member list: "Member / Billing / Admin" +- **Tab label** on the organisation page: "People" (not "Members") + +A user inviting someone holds three definitions of "member" simultaneously — person type, workspace role, and organisation-level concept — with no disambiguation. The organisation tab saying "People" while the workspace settings says "Members" adds a fourth word for the same thing. + +### Action verbs for destructive actions are inconsistent +- "Delete workspace" / "Delete project" — but "Remove member" / "Leave workspace" / "Clear projects" +No single verb for "take something away." A user who just learned "Delete" will not expect "Remove" to be the remove-member action, and "Leave" to be the self-removal action. + +### Number / unit formatting has no standard +- `2.6h this month` (workspace card) +- `10.3 / 10 hours` (AT LIMIT badge on projects header) +- `10.3 h this month` (nl-NL workspace card) +- `10.3 hours in April 2026` (organisation Usage tab) + +Four formats for the same unit across screens a demo guest sees in under 60 seconds. Decide on one: `10.3 h` (short) or `10.3 hours` (spelled out), then apply it everywhere. + +### Tier names appear with zero introduction +Pilot / Pioneer / Innovator / Changemaker / Guardian appear as workspace card tags and in billing copy with no tooltip, no glossary entry, and no onboarding mention. A demo guest who asks "what's a Pioneer?" has no in-product answer. Even a one-line tooltip ("Pioneer — for your first client engagements. 5 seats, 50 h/month.") would resolve this. + +### "Workspace" vs "organisation" usage is inconsistent in helper text +- "Shown on the **workspace selector** and in email subject lines." (organisation name field helper) — uses "workspace selector", a surface not named elsewhere in the UI +- "Open to the **organisation** — organisation admins get access automatically" (workspace access radio) — "organisation" here means the parent org +- "Workspace-only access" (invite dialog External description) — "workspace" here means a single workspace, not the product concept + +The same two words carry different referents on different surfaces with no consistent glossary backing them up. + +--- + +*Report generated from 8 parallel audit agents. Raw bucket files in `echo/audit/buckets/*.md`.* diff --git a/echo/docs/workspaces-validate/.gitignore b/echo/docs/workspaces-validate/.gitignore new file mode 100644 index 00000000..1e72e889 --- /dev/null +++ b/echo/docs/workspaces-validate/.gitignore @@ -0,0 +1 @@ +qa/_shots \ No newline at end of file diff --git a/echo/docs/workspaces-validate/00-DOC-AUDIT.md b/echo/docs/workspaces-validate/00-DOC-AUDIT.md new file mode 100644 index 00000000..db032b24 --- /dev/null +++ b/echo/docs/workspaces-validate/00-DOC-AUDIT.md @@ -0,0 +1,251 @@ +# Doc audit + repo conventions + +Session start: 2026-04-23. Audit of the state I inherited before doing any work. + +**Read order for future-you:** `05-PROGRESS.md` → `04-QUESTIONS-FOR-SAMEER.md` → `00-PLAN.md` → this file. + +--- + +## Repo conventions + +What the code actually does today, as of branch `workspaces` @ `cfa758e`. These take precedence over anything the matrix or the checklist says — reconcile via `02-DELTA.md`, don't silently "correct" the code. + +### Tier names + +Match the matrix: `pilot | pioneer | innovator | changemaker | guardian`. Single source: `server/dembrane/policies.py:17` (`TIER_ORDER`). Tier gate map at `policies.py:22-29` (`TIER_REQUIRED_FOR_POLICY`). Tier lives on `workspace.tier`. + +### Role names + +**Code ≠ matrix.** Matrix says `Admin / Billing / Member / Guest`. Code says: +- Workspace roles: `owner / admin / member / viewer` (`policies.py:53-93`). +- Org roles: `member / admin / owner` (`policies.py:34-48`). +- Project share roles: `viewer / editor` (`policies.py:98-114`). +- "Guest" in the matrix = `workspace_membership.is_external=true` in code. No role called `guest`. +- **No `billing` role anywhere** in code. Matrix introduces it as a new axis. + +Brief says rename at UI only; don't touch DB fields. That stays true for `is_external` and `owner/admin/member/viewer` enum values. `billing` role is *new* and will need schema — open question, see `04-QUESTIONS-FOR-SAMEER.md`. + +### Staff + +Implemented as `auth.is_admin` (Directus Administrator JWT claim), surfaced to frontend via `/v2/me.is_staff` (`server/dembrane/api/v2/me.py:40,92,142`). Matrix v1.1 introduces a narrower `staff:can_set_tier` policy — not yet in code. + +### Upgrade inbox + +- Env var: `UPGRADE_REQUEST_INBOX` (`server/dembrane/settings.py:337-343`). +- **Default today:** `sameer@dembrane.com`. +- Matrix v1.1 default: `upgrades@dembrane.com`. Switch default + ship inbox before cutover. + +### Visibility / discovery model — the big divergence + +Code stores two booleans in `workspace.settings`: +- `inherit_organisation_admins` (default true) — drives derived admin access +- `inherit_organisation_members` (default false) — drives derived member access +- `sticky_removed` — JSON tombstones in `workspace.settings` + +Plus `dembrane/inheritance.py` with `user_can_access` that walks org membership to derive access at query time. Migration + audit rounds already hardened this path (see `docs/workspaces/inheritance-rules.md` and `scripts/migrate_inherited_to_derived.py`). + +Matrix v1.1 retires all of this. Replacement model: +- Single `workspace.visibility` enum: `open_to_organisation | private`. +- Access is direct-only — no derivation walker. +- Organisation admins "Join" an open/private workspace via explicit action → writes `source='direct', role='admin'` row. +- Organisation members "Request access" on open workspaces → admin approves → writes `source='direct', role='member'`. +- `sticky_removed` retires; rejoins are normal explicit actions. + +**Prerequisite:** backfill explicit Admin rows for anyone currently surviving on derived access. Stop-condition work — Sameer confirms affected row count before apply. See `flows/derivation-walkback.md` (to be written). + +### Notification event codes (live) + +Grep'd from `server/dembrane/` — emit sites across `invites.py / me.py / onboarding.py / orgs.py / project_sharing.py / projects.py / workspace_settings.py / workspaces.py / tasks.py`: + +``` +WORKSPACE_CREATED WORKSPACE_ADDED WORKSPACE_REMOVED +WORKSPACE_ROLE_CHANGED ORGANISATION_MEMBER_ADDED ORGANISATION_ROLE_CHANGED +ORGANISATION_REMOVED INVITE_ACCEPTED INVITE_DECLINED +INVITE_CANCELLED PROJECT_NOW_PRIVATE PROJECT_NOW_WORKSPACE +PROJECT_SHARE_ADDED PROJECT_SHARE_ROLE_CHANGED PROJECT_SHARE_REVOKED +REPORT_READY REPORT_FAILED UPGRADE_REQUEST_SENT +TIER_DOWNGRADED +``` + +Severity map + `emit()` flow at `server/dembrane/notifications.py`. Dual channel (inbox + email) is the established pattern; don't invent a new one. + +**Matrix expects additions** (per brief "Notifications enum + in-app bell" bullet): +- `MEMBERSHIP_REQUESTED` (member → admins, request-to-join an open workspace) +- `MEMBERSHIP_REQUEST_APPROVED` / `MEMBERSHIP_REQUEST_REJECTED` +- `UPGRADE_REQUEST_ACTIONED` (approved / denied) +- `PAYMENT_FAILED` (placeholder — payments are manual this release; may log only) +- `QUOTA_AT_80` / `QUOTA_AT_95` / `QUOTA_AT_100` +- `PARTNER_HANDOFF_PENDING` / `PARTNER_HANDOFF_ACCEPTED` + +Keep wiring through `emit()` / `emit_to_audience()` (`notifications.py:102-272`). Source code is mid-refactor in the uncommitted working tree (`M server/dembrane/notifications.py` ~170 lines changed) — confirm with Sameer before touching. + +### Soft-delete pattern + +`deleted_at IS NULL` on every read. Applies to `project / conversation / project_chat / project_report / webhook / tag / workspace / org_membership / workspace_membership`. Destructive actions set the timestamp; hard-delete permission removed from Basic User (`24b94f9`). Honor this — never `DELETE FROM`. + +### Policy enforcement pattern + +- Roles: `ctx.require_policy("…")` in `server/dembrane/api/v2/middleware.py`. +- Tiers: auto-enforced via `has_policy(..., workspace_tier=...)` + `TIER_REQUIRED_FOR_POLICY`. Don't add new `require_tier()` calls per-endpoint; extend the map. +- Staff: `auth.is_admin` on the session JWT. +- Downgrade effects: `dembrane/tier_downgrade.py` → `DOWNGRADE_EFFECTS` map + `apply_downgrade_effects()`. New `"revert"` branches land here. + +### Frontend route shape + +- `/:locale/w/:workspaceId/...` — workspace-scoped (routes under `frontend/src/routes/`) +- `/:locale/o/:organisationId` — organisation admin page (matrix view, `OrganisationRoute.tsx`, 534 lines) +- Workspace selector (home) — `WorkspaceSelectorRoute.tsx`, 442 lines. Already has organisation hero, avatar bubbles, per-row manage (Ask 5 shipped). +- Workspace settings — `WorkspaceSettingsRoute.tsx`, 893 lines, one page (tabs not yet split). +- Workspace create — `CreateWorkspaceRoute.tsx`, 271 lines, **single-step form** (wizard not yet built). +- Inbox drawer — `frontend/src/components/inbox/Inbox.tsx`, 420 lines. +- FeatureGate + UpgradeModal — `frontend/src/components/workspace/FeatureGate.tsx`, 318 lines (S11 shipped). +- Onboarding — `frontend/src/routes/onboarding/OnboardingRoute.tsx`. + +### i18n + +`@lingui/core/macro` with `` + `t\`\`` template. Locales at `frontend/src/locales/` for en-US / nl-NL / de-DE / fr-FR / es-ES / it-IT. Flow: `pnpm messages:extract` → edit .po → `pnpm messages:compile`. Dutch is informal (je/jij). + +### Branch state + +Current branch: `workspaces`. Unpushed. Commits ahead of `main` since autonomous run: + +Autonomous baseline (per checklist "Session status" section): +`94cf40d 818c774 9736a2c 43f8649 4141262 e26d725 c51a7e0 e66c483 00613dd` ++ audit/security passes `15c7d1a 2f543ac ff93e68 0120a72 f2bfb2f 0cbb3b9`. + +On top (brief's "8 prior" is out of date — these shipped too): +``` +001ef0a feat: S10 private project sharing UI + visibility toggle endpoint +d042a85 feat: S11 tier-gate FeatureGate + UpgradeModal components +f2cf0a0 feat: S12 selector polish — organisation hero + hover-manage + guest-of pills +8aba15d fix: round-2 audit — 7 of 8 critical/high findings addressed +4646825 feat: private projects — read-time enforcement on common surfaces +deb6597 fix(workspace): security + footgun pass from audits +a85d2fe feat(workspace): organisation page, sharing tab, access bubbles, /w URL, hard rules +997ab26 feat(workspace): /o/ organisation route, settings pages, emails in lists, audit fixes +505ba73 feat(ux): dotted +workspace card, /w sweep, audit fixes +b71a2d7 feat(inbox): notifications table + service + emit backfill +1b2ed00 feat(inbox): notifications drawer + more emit coverage +00b3218 feat(inbox): more emit sites + dead-code sweep +cfa758e feat(inbox): close INVITE_ACCEPTED + ORGANISATION_MEMBER_ADDED on remaining paths +``` + +**Uncommitted work (tree state at session start):** + +Modified (in-flight — do not disturb without checking with Sameer): +- `server/dembrane/notifications.py` (~170 lines rewrite), `server/dembrane/api/v2/notifications.py` (~263 lines rewrite), `server/dembrane/tasks.py` (1 line) +- `server/email_templates/` — all 4 existing templates plus `_layout.html` +- `directus/templates/` — all 6 Liquid templates +- `frontend/src/components/project/ProjectListItem.tsx / ProjectSharingModal.tsx / ProjectSharingStrip.tsx` +- `frontend/src/components/layout/Header.tsx` +- `frontend/src/hooks/useNotifications.ts`, `frontend/src/routes/auth/Register.tsx`, `CheckYourEmail.tsx` +- `frontend/src/routes/organisation/OrganisationRoute.tsx` (3 lines) +- `frontend/src/components/auth/hooks/index.ts` +- `scripts/create_schema.py` (~136 lines), `scripts/preseed_workspace.py` +- `docs/workspaces/release-checklist.md` + +Deleted: +- `frontend/src/components/announcement/` — 6 files (announcements dead-code sweep) +- `frontend/src/components/notifications/NotificationsDrawer.tsx` (replaced by `inbox/Inbox.tsx`) + +New (untracked): +- `docs/workspaces-validate/` — this dir +- `frontend/src/components/inbox/` — new inbox dir (`Inbox.tsx` committed in `b71a2d7`; directory itself looks untracked because index files may be) +- `frontend/src/lib/avatar.ts` — used by `OrganisationRoute.tsx` and `WorkspaceSelectorRoute.tsx` + +`git diff --stat` shows -1921 / +703 — net -1218 lines. Announcement + old-drawer removal dominates. **Confirm with Sameer whether this uncommitted tree represents "in progress, resume" or "abandoned, revert" before I build on top of it.** + +--- + +## Inventory of related docs + +### Already treated as canonical (do not duplicate) + +| Path | Role | +|---|---| +| `docs/workspaces-validate/matrix.md` | Customer contract — source of truth for behavior | +| `docs/workspaces/release-checklist.md` | Engineering ground truth for build status | +| `brand/STYLE_GUIDE.md` | Copy + color + component conventions | +| `brand/README.md` | Short brand summary | +| `CLAUDE.md` (repo root) | Standing organisation instructions — overrides briefs on conflict | + +### Live companion design docs (referenced from matrix + checklist) + +| Path | Treat as | +|---|---| +| `docs/workspaces/designer-return.html` | Visual wires for Asks 1–5 (designer v2) | +| `docs/workspaces/designer-brief-v2.md` | Sameer → designer clarification asks | +| `docs/workspaces/designer-brief.md` | Original v1 brief — superseded by v2 | +| `docs/workspaces/workspaces-prd-v3-final.md` | Product requirements v3 | +| `docs/workspaces/inheritance-rules.md` | **Stale vs matrix v1.1.** Matrix retires derivation; this doc codifies it. Keep until the walk-back lands, then archive. | +| `docs/workspaces/execution-plan-final.md` | Autonomous-run plan — historical | +| `docs/workspaces/architecture-review.md` | Pre-autonomous arch notes — historical | +| `docs/workspaces/codebase-exploration-report.md` | S1 exploration — historical | +| `docs/workspaces/failure-analysis.md` | Risk register — check before shipping | +| `docs/workspaces/gate-check-protocol.md` | Session-by-session ship criteria | +| `docs/workspaces/gripes.md` | Designer/developer complaints — useful signal | +| `docs/workspaces/reference.md` | Feature tree reference | + +### Supporting engineering docs + +| Path | Role | +|---|---| +| `docs/branching_and_releases.md` | Branch → release flow | +| `docs/database_migrations.md` | Directus migration pattern | +| `docs/directus_sdk_patterns.md` | Python + TS Directus SDK idioms | +| `docs/frontend_translations.md` | i18n workflow | +| `docs/style-guides/` | More component-level guides | + +### This session's working docs (this folder) + +| Path | Role | +|---|---| +| `matrix.md` | Contract, read-only | +| `00-DOC-AUDIT.md` | This file | +| `00-PLAN.md` | Working plan | +| `02-DELTA.md` | Gap analysis | +| `03-DECISIONS.md` | Append-only log | +| `04-QUESTIONS-FOR-SAMEER.md` | Pending + answered | +| `05-PROGRESS.md` | Rolling status | +| `flows/` | One per user flow | +| `screens/` | One per canonical screen pattern | +| `migration/` | M1–M6 specs | + +--- + +## Key files to know (cross-reference index) + +Server: +- `server/dembrane/policies.py` — tier + role matrix +- `server/dembrane/inheritance.py` — derivation resolver (pending retirement) +- `server/dembrane/tier_downgrade.py` — DOWNGRADE_EFFECTS map +- `server/dembrane/notifications.py` — emit() + audience helpers (in flux) +- `server/dembrane/settings.py` — env vars incl. UPGRADE_REQUEST_INBOX +- `server/dembrane/api/v2/middleware.py` — `get_workspace_context`, `user_can_access` wiring +- `server/dembrane/api/v2/me.py` — `/v2/me`, `is_staff`, pending invites, notifications list +- `server/dembrane/api/v2/workspaces.py` — CRUD + tier PATCH + upgrade-request +- `server/dembrane/api/v2/workspace_settings.py` — member mgmt, invites, role change +- `server/dembrane/api/v2/orgs.py` — organisation endpoints +- `server/dembrane/api/v2/project_sharing.py` — per-project CRUD (innovator+) +- `server/dembrane/api/v2/onboarding.py` — onboarding-complete +- `server/dembrane/api/v2/invites.py` — HMAC token invites +- `server/email_templates/` — Jinja templates extending `_layout.html` +- `scripts/create_schema.py` — Directus schema builder (idempotent) +- `scripts/migrate_inherited_to_derived.py` — historical migration (to be reused/retired) + +Frontend: +- `frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx` — home +- `frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx` — one-page settings +- `frontend/src/routes/workspaces/CreateWorkspaceRoute.tsx` — single-step create +- `frontend/src/routes/organisation/OrganisationRoute.tsx` — organisation admin matrix +- `frontend/src/routes/organisation/OrganisationSettingsRoute.tsx` — organisation settings +- `frontend/src/routes/onboarding/OnboardingRoute.tsx` — new-vs-legacy onboarding +- `frontend/src/components/workspace/FeatureGate.tsx` — tier-gate overlay + upgrade modal +- `frontend/src/components/inbox/Inbox.tsx` — notifications drawer +- `frontend/src/components/project/ProjectSharingStrip.tsx` + `ProjectSharingModal.tsx` — Ask 3 UI +- `frontend/src/hooks/useNotifications.ts`, `useV2Me.ts`, `useWorkspace.ts`, `useMyInvites.ts` — core data hooks +- `frontend/src/lib/avatar.ts` — avatar URL helper + +Directus: +- `directus/sync/snapshot/` — schema snapshot (committed after `sync.sh pull`) +- `directus/templates/` — transactional email Liquid templates (separate from product emails in `server/email_templates/`) diff --git a/echo/docs/workspaces-validate/00-PLAN.md b/echo/docs/workspaces-validate/00-PLAN.md new file mode 100644 index 00000000..86a3b928 --- /dev/null +++ b/echo/docs/workspaces-validate/00-PLAN.md @@ -0,0 +1,130 @@ +# Working plan + +Session start: 2026-04-23. Branch: `workspaces`. + +Goal (brief): bring workspaces release across the finish line — release blockers built + tested + committed, flow/screen specs match what shipped, matrix ↔ checklist ↔ product mutually consistent. + +## Sequencing rationale + +Two hard constraints drive order: + +1. **Derivation walkback (Flow 0) precedes Flows 1 / 4 / 6.** Matrix v1.1 retires derived inheritance; any UI built on top of the old `user_can_access` walker will be wrong. +2. **Stop conditions before destructive ops.** Backfill explicit Admin rows is a stop condition — dry-run first, Sameer confirms row count, then apply. + +## Phases + +### Phase A — Orient (done) + +- Read matrix, checklist, brand, CLAUDE, companion design docs. +- Inventory repo + doc state → `00-DOC-AUDIT.md`. +- Gap analysis → `02-DELTA.md`. +- Bundle blocking questions → `04-QUESTIONS-FOR-SAMEER.md`. +- Seed `03-DECISIONS.md`, `05-PROGRESS.md`, this plan. + +### Phase B — First sync with Sameer (gate) + +Halt. Send over: +- Doc audit + delta + questions +- Proposed phase order below +- Flag: uncommitted working tree (Q4) needs call before I touch notifications / emails / scripts / auth routes. + +Do not proceed past this gate without at minimum Q1, Q2, Q3, Q4 answered. + +### Phase C — Canonical screens (brief §"The 7 canonical screen patterns") + +Write specs for all 7 in `screens/` before instantiating flows. One file per pattern. Each spec is: +- Name + intent (1–2 sentences) +- Copy skeleton (role-aware where applicable) +- Component references (existing + to-build) +- Variants (empty / loading / error / success) +- Non-goals + +Patterns (order): +1. `feature-locked.md` — already largely shipped in `FeatureGate.tsx`; reverse-document + flag gaps. +2. `status-banner.md` — 3 intrusion levels. Needed by every tier-gate + quota flow. +3. `request-submitted.md` — upgrade request, join request, handoff pending. +4. `destructive-confirm.md` — delete workspace + demote + downgrade. +5. `manage-list.md` — members, invites, settings rows. +6. `empty-state.md` — first-encounter hero + action. +7. `readonly-data.md` — usage rollup, member list, audit log, referral ledger. + +### Phase D — Flow specs (brief priority order) + +One file per flow under `flows/`. Each flow ≤ 1 page, referencing canonical screens. + +Priority 0 first (derivation walkback — backend, not UI but spec it); then 1–15 per brief. Target: top 5 flows for second sync, then iterate. + +### Phase E — Build (release blockers + matching flows) + +Work release blockers + remaining checklist tasks in dependency order. Small commits, one session tag per commit (e.g. `S9: workspace creation wizard — visibility step`). Update `05-PROGRESS.md` after each commit. + +Release blockers (brief §"Release blockers"): +1. Organisations admin page expansion (Ask 1 list ⇄ matrix ⇄ projects) — S7 +2. Tier set/change staff inline (Ask 2s) — S8 +3. Workspace suspend — **per matrix reconciliation, not this release.** Drop from blocker list; access-blocking is covered by tier downgrade + soft-delete + membership removal. +4. Delete workspace endpoint + UI — endpoint done; UI needs wiring to settings tab + project-exists error +5. Onboarding split — S13, partial +6. Email polish + plain-text fallback — mostly done per autonomous notes; verify live (Q4 working tree) + +Matrix-added blockers beyond checklist (`02-DELTA.md`): +- Derivation walkback (backfill + simplify resolver + drop sticky_removed) +- `workspace.visibility` enum migration +- Slack-style discovery endpoints (join + request-access) +- `billing` role (pending Q1) +- Hour meter + Pilot hard-block (pending Q3) +- M1 CSV migration tool (pending Q7) +- `staff:can_set_tier` policy + default-inbox switch +- Tier capacity matrix surface (billing tab + upgrade modal) +- Downgrade confirmation dialog + 7-day banner + admin email +- Honesty disclosure on private workspace creation + +### Phase F — Migration specs (writing only; execution later) + +`migration/M1.md` through `M6.md` mirror matrix sections. Not executed this session. + +### Phase G — Final sync + release + +Once blockers land + smoke tests green + flow specs match product: +- Run existing test suite before each commit (per brief Dev Loop). +- No push until Sameer confirms at delta gate. +- Final commit bundle on `workspaces` branch. + +## Time budget (rough) + +Brief suggests A is 30 min. Realistic: +- A: done +- B: gate — depends on Sameer +- C: 2–3 hours (7 screen specs) +- D: 3–4 hours (15 flows, top 5 first) +- E: biggest chunk — days. Phase into its own planning passes. +- F: 1–2 hours once E settles +- G: ship + +## Escape valve + +If I find mid-work that any of: +- The derivation walkback can't cleanly backfill (would require dropping a customer's access) +- The uncommitted working tree is incoherent (Q4) +- `billing` role ripples deeper than expected (schema → email templates → invite token flow) +- Hour meter derivation from `conversation.duration` doesn't match what customers see today + +→ stop, log in `05-PROGRESS.md`, add to `04-QUESTIONS-FOR-SAMEER.md`, continue on unblocked work. + +## What I'm explicitly not doing + +Per brief anti-goals: +- No pricing page, no marketing surface +- No rename of `owner/admin/member/viewer` or `is_external` at the DB layer (except adding `billing` pending Q1, and the visibility enum pending Q2) +- No `suspended_at` field +- No invite reminder cron, trash/restore UI, org billing rollup, audit log UI, usage_event reinstatement (unless Q3 flips) +- No post-release features +- No features matrix explicitly leaves open (languages, library views, agentic chat stay open) +- Not touching the participant portal for any gate + +## Handoff shape for next session + +If this session times out mid-phase: +- `05-PROGRESS.md` is the authoritative resume point +- Unshipped items stay in `02-DELTA.md` with updated status +- Open questions stay in `04-QUESTIONS-FOR-SAMEER.md` +- Commit log + checklist tell the code story diff --git a/echo/docs/workspaces-validate/02-DELTA.md b/echo/docs/workspaces-validate/02-DELTA.md new file mode 100644 index 00000000..f4061c32 --- /dev/null +++ b/echo/docs/workspaces-validate/02-DELTA.md @@ -0,0 +1,250 @@ +# Delta — matrix v1.1 vs code + checklist + +Gap pass over every section of `matrix.md`, each canonical screen pattern, and each flow. **Status key:** + +- `✅ built` — code matches matrix; no action +- `⚠️ partial` — code exists but diverges from matrix in a specific way +- `❌ missing` — not in code yet +- `🧹 walkback` — in code today but matrix v1.1 says remove + +Code pointers are to `workspaces` branch `cfa758e`. Checklist refers to `docs/workspaces/release-checklist.md`. + +--- + +## Matrix §1 — Tier × capacity + +| Row | Status | Notes | +|---|---|---| +| Tier names (pilot→guardian) | ✅ built | `policies.py:17` | +| Per-tier taglines ("Pilot — one month to try it", etc.) | ❌ missing | Brief requires every tier-name surface to pair a tagline. Needs a central i18n table. | +| Price display | ❌ missing | Matrix specifies €349 / €200 / €500 / €1500 / €5000. Not surfaced in product. Needs a single source for matrix + modal. | +| Seat overage rates | ❌ missing | €25 / €30 / €60 per seat. Not in code. | +| Included hours | ❌ missing | 10 / 25 / 50 / 100. Not tracked (no usage meter enforcing hour cap). | +| Hour overage rates | ❌ missing | €5 / €4 / €3. Not surfaced. | +| Guest cap | ❌ missing | 2 / 5 / 20 / 50 / unlimited. Not enforced. | +| Training included | ❌ missing | Informational only — surface in upgrade modal copy. | +| Capacity matrix visible on billing tab + upgrade modal | ❌ missing | Brief: "must be visible on the workspace billing tab AND in the upgrade-request modal." S8 not built. | + +## Matrix §2 — Tier × feature + +Most tier gates live via `TIER_REQUIRED_FOR_POLICY` in `policies.py`. Comparison: + +| Matrix feature gate | Code policy | Tier in code | Match? | +|---|---|---|---| +| Private projects (✓ innovator+) | `project:set_private` | `innovator` | ✅ | +| Private workspaces (✓ innovator+) | `workspace:set_private` | `innovator` | ✅ | +| Data export (✓ innovator+) | `workspace:export` | `innovator` | ✅ | +| Private project sharing (✓ innovator+) | `project:share` | `innovator` | ✅ | +| Whitelabel (✓ changemaker+) | `workspace:whitelabel` | `changemaker` | ✅ | +| API access (✓ changemaker+) | `workspace:api_access` | `changemaker` | ✅ | +| Webhooks (✓ changemaker+) | *no policy* | open | ❌ missing — matrix says changemaker+; needs new policy + gate | +| All 7 languages (open to all) | n/a | n/a | ✅ open | +| Agentic chat (open to all) | `chat:use` | pilot | ✅ | +| Library/analysis views (invite-gated) | n/a | n/a | ✅ open | +| Projects/conversations/chat/reports core | various | pilot | ✅ | + +## Matrix §3 — Downgrade behavior + +| Row | Status | Notes | +|---|---|---| +| Freeze-by-default, revert for whitelabel | ⚠️ partial | `tier_downgrade.py` implements map + `apply_downgrade_effects()`. Whitelabel revert branch lives there. Needs verification each gate (API, private) freezes correctly. | +| Confirmation dialog listing frozen + reverted features | ❌ missing | S8 UI work. Backend has `preview_downgrade()` per autonomous-run notes. | +| In-workspace banner for 7 days post-downgrade | ❌ missing | New requirement — persists in `workspace.settings` with dismissal tracking + auto-return-on-frozen-feature-attempt. | +| Post-downgrade email to admins + billing | ⚠️ partial | Notification `TIER_DOWNGRADED` exists in severity map, emit site TBD; no email template. | + +## Matrix §4 — Role × capability + +**Conflict with code — needs decision.** Matrix: `Admin / Billing / Member / Guest`. Code: `owner / admin / member / viewer` (+ `is_external` for Guest). + +| Matrix role | Code analog | Gap | +|---|---|---| +| Admin | `owner` ∪ `admin` in code | Matrix collapses to one role. Code has two tiers; ~all admin-only capabilities are already given to `admin` preset except `workspace:delete` + `*`-wildcards → `owner` only. | +| Billing | — | **Not in code.** Needs either a new preset + DB value OR a flag. | +| Member | `member` | ✅ match | +| Guest | `is_external=true` + `member` role | Matrix says guest permissions are identical to member within their workspaces. Code already mirrors member preset when external. Verify: currently no separate preset, so externals get the same `WORKSPACE_ROLE_PRESETS[role]`. Project `delete conversations` flag differs per matrix (guest can't delete, member can) — partially matches since externals aren't blocked from `conversation:delete`. Needs audit. | +| `viewer` in code | no matrix equivalent | Matrix drops viewer. Used anywhere in prod? Checklist lists it. Decide whether to collapse. | + +Key mismatches vs matrix §4 role table: +- **Matrix: Member can delete projects = ✗; code: `member` preset *lacks* `project:delete`** → ✅ already aligned (good). +- **Matrix: Billing can update payment method, see invoices, request upgrade** — zero of these exist in code. +- **Matrix: Guest cannot delete conversations** — code has `conversation:delete` in `member` preset; externals run member preset ⇒ ⚠️ mis-gated. +- **Matrix: Last admin cannot demote self or be removed.** Partial enforcement in code (`workspace_settings.py` has some guards) but not audited end-to-end for the collapsed role model. Per brief stop condition. + +## Matrix §5 — Organisation-level roles + +| Row | Status | Notes | +|---|---|---| +| Organisation Admin / Organisation Billing / Organisation Member | ⚠️ partial | Code has `org` table with `owner / admin / member`. Matrix collapses `owner → admin` at organisation level and adds `billing`. | +| **Organisation-level access is direct-only. No derivation.** | 🧹 walkback | Current code derives organisation-admin access to all open workspaces via `inheritance.py`. Matrix retires this. Backfill + drop `inherit_organisation_admins`. | +| Being organisation admin does not auto-admin every workspace | 🧹 walkback | Same as above. | +| Last-admin protection at organisation + workspace | ⚠️ partial | Workspace has partial guard; organisation-level not confirmed. Audit. | +| "View every workspace in organisation (open + private)" for organisation admin | ⚠️ partial | Today: organisation admin *auto-has* access. Matrix: organisation admin can *discover + join*, but join is an explicit action. | + +## Matrix §6 — Workspace visibility & discovery (Slack-style) + +This is the single biggest backend walkback. + +| Row | Status | Notes | +|---|---|---| +| `workspace.visibility` enum (`open_to_organisation | private`) | ❌ missing (as single enum) | Today modelled as two booleans (`inherit_organisation_admins`, `inherit_organisation_members`). Decide: rename schema, or map UI to existing booleans. | +| UI labels "Open to organisation" / "Private" | ❌ missing | Copy + chip. | +| Organisation admin sees all (open + private) + "Join" CTA | 🧹 walkback → rebuild | Currently *auto-has*. Matrix: explicit Join → writes `source='direct', role='admin'`. | +| Organisation billing sees all (view-only, no join) | ❌ missing | No billing role. | +| Organisation member sees open only + "Request access" | ❌ missing | Need new flow: request → admin approval → write direct row. | +| Guest — no discovery | ✅ built | Externals have no org membership; no derivation reaches them. | +| Honesty disclosure on private creation | ❌ missing | Create wizard must show "Organisation admins can still discover and join this workspace." | +| Request-to-join approval | ❌ missing | Needs new endpoint + notification audience (workspace admins + organisation admins) + approve/reject flow. | +| Organisation admin "Join" immediate, reversible | ❌ missing | New endpoint; writes direct row on click. | +| Sticky removal retired | 🧹 walkback | `workspace.settings.sticky_removed` in code; purge as part of walk-back. | +| Default visibility = `open_to_organisation` | ✅ built | Default in code is `inherit_organisation_admins=true`. | + +**Subtasks for walkback** (ordered): + +1. Add `workspace.visibility` enum (Directus schema via `create_schema.py`) OR redefine: use `settings.inherit_organisation_admins` as the visibility bit (private=false). Recommend the former for clarity. +2. Backfill: for every user whose current access depends on `user_can_access` derivation, write an explicit `source='direct'` `workspace_membership` row with the derived role. Dry-run + show Sameer row count before apply (stop condition). +3. Simplify `user_can_access` to direct-row lookup only. Remove organisation-admin / organisation-member derivation branches. +4. Remove the `sticky_removed` tombstone logic (read + write sites in `workspace_settings.py` member removal path). +5. Add endpoints: `POST /v2/workspaces/:id/join` (organisation admin self-join), `POST /v2/workspaces/:id/access-requests` (organisation member request), `POST /v2/workspaces/:id/access-requests/:id/approve|reject`. +6. Add notification events: `MEMBERSHIP_REQUESTED`, `MEMBERSHIP_REQUEST_APPROVED`, `MEMBERSHIP_REQUEST_REJECTED`. +7. Update `policies.py`: retire `workspace:set_private` policy name → migrate to `workspace:set_visibility` (or keep; UI only flips enum). +8. Remove `on_organisation_member_removed` / `on_external_became_internal` / `on_internal_became_external` helpers or simplify — direct rows don't need reconciliation hooks. + +## Matrix §7 — Seats & billing + +| Row | Status | Notes | +|---|---|---| +| Seat = active workspace access (member/admin/billing) | ❌ missing | No seat counter. `workspace_membership` rows exist; count is easy but no billing surface exists. | +| Guests not billed, count against guest cap | ❌ missing | Guest cap not enforced. | +| Organisation membership alone is not billable | ✅ implicit | No billing on organisations. | + +## Matrix §8 — Hours & usage + +| Row | Status | Notes | +|---|---|---| +| Hour meter per workspace | ❌ missing | No hour counter. Checklist §Schema Session 2 shows `usage_event` was intentionally removed. Matrix requires it back in some form. | +| Calendar-month reset | ❌ missing | — | +| Overage billing per tier | ❌ missing | — | +| **Pilot hard block at 10h — host-side only** | ❌ missing | Core blocker. Blocks chat/analysis/transcripts/reports/exports/new-project creation. Participant portal exempt. | +| Usage rollups — project/workspace/organisation levels | ❌ missing | Flow `usage-rollup` — no backend surface. `WorkspaceSelectorRoute.tsx` shows `usage.audio_hours` per workspace; source TBD. | +| Raw numbers for members; €-forecasts for admin+billing | ❌ missing | Design decision; depends on above. | + +## Matrix §9 — New workspace defaults + +| Row | Status | Notes | +|---|---|---| +| Default tier = pilot | ⚠️ partial | Needs verification in `POST /v2/workspaces`. Checklist decision says pilot is new-customer-only; existing migrates to Pioneer minimum (M1). | +| Default visibility = open_to_organisation | ✅ built | | +| Creator gets `source='direct', role='owner'` | ✅ built | Audit fix `15c7d1a` ensures this. Matrix says `role='admin'` (collapsed). Role-rename scope. | +| No other rows on create | ✅ built (after 94cf40d + audit passes) | Inheritance retired the fan-out already. | +| Seeded workspaces bypass Pilot default | ❌ missing | Migration tooling (M1). | + +## Matrix §10 — Partner-client model + +| Row | Status | Notes | +|---|---|---| +| `billed_to_team_id` on workspace | ❌ missing | Schema add. | +| `effective_client_team_id` on workspace (nullable) | ❌ missing | Schema add. | +| Handoff flow: partner initiate → client accept → billing flip | ❌ missing | New endpoints + notifications. | +| Workspace stays at current tier on transfer | ⚠️ implicit | Will be ✅ once handoff ships without tier mutation. | +| `referral_ledger` collection | ❌ missing | Schema add. Fields per matrix: id, workspace_id, partner_team_id, partner_kickback_percent (default 20), starts_at, expires_at (nullable), notes, created_by_staff_id. | +| Partner retains no operational access unless retained as guest | ✅ implicit | Depends on membership not being auto-created on handoff. | + +## Matrix §11 — Upgrade flow + +| Row | Status | Notes | +|---|---|---| +| Requesters = admin or billing | ⚠️ partial | Code admin-only (`workspaces.py:702` emit). Add billing role first. | +| `staff:can_set_tier` policy | ❌ missing | New policy. Today `PATCH /v2/workspaces/:id/tier` is gated on `auth.is_admin`. Narrow it. | +| `POST /v2/workspaces/:id/upgrade-request` | ✅ built | `workspaces.py:627+`. | +| Upgrade inbox `upgrades@dembrane.com` | ⚠️ default wrong | Currently `sameer@dembrane.com`. Change default + create shared inbox. | +| Capacity matrix in upgrade modal | ❌ missing | Surface work. | +| Member CTA = "Ask one of your organisation admins to upgrade" (no button) | ⚠️ partial | FeatureGate exists; confirm member variant matches. | + +--- + +## Canonical screen patterns (brief §"The 7 canonical screen patterns") + +| # | Pattern | Status | +|---|---|---| +| 1 | Manage entity list + edit | ⚠️ partial — settings pages exist but not templated; no shared pattern file yet | +| 2 | Feature locked (role-aware) — hatched overlay + modal | ⚠️ partial — `FeatureGate.tsx` + `UpgradeModal` exist (S11). Confirm role-aware copy per matrix §11. | +| 3 | First encounter / empty state | ⚠️ partial — empty states exist ad-hoc; no shared pattern | +| 4 | Request submitted — waiting (request-to-join, upgrade) | ❌ missing | +| 5 | Confirm destructive action | ⚠️ partial — delete-workspace confirmation exists; type-to-confirm for deletion TBD | +| 6 | Status banner (3 intrusion levels) | ❌ missing — no inline/banner/modal shared component for quota states | +| 7 | Read-only data view (usage rollup, referral ledger, member list, audit log) | ⚠️ partial — organisation page matrix is close; no usage-rollup, no ledger, no audit log | + +Write specs for all 7 in `screens/` before instantiating flows. + +--- + +## Flow list (brief priority order) + +| # | Flow | Status | Notes | +|---|---|---|---| +| 0 | derivation-walkback | ❌ missing | **Backend work. Prerequisite for 1, 4, 6.** Backfill = stop condition. | +| 1 | upgrade-request | ⚠️ partial | Backend + FeatureGate exist. Missing: participant-reassurance copy, tier capacity matrix in modal, pilot hard-block surfacing. | +| 2 | onboarding-invited | ⚠️ partial | `OnboardingRoute.tsx` handles invite path. Verify workspace-guest variant. | +| 3 | onboarding-solo | ⚠️ partial | Checklist "new user path" deferred — auto-create organisation + Pilot workspace on signup. | +| 4 | home-per-organisation | ⚠️ partial | `WorkspaceSelectorRoute.tsx` has organisation hero + workspace cards + guest section (S12 done). Missing: discovery section per matrix §6, request-access CTA. | +| 5 | usage-rollup | ❌ missing | Core blocker — requires hour meter (see §8). | +| 6 | invite-and-join | ✅ built | HMAC invite + accept flow exists. Confirm billing role variant. | +| 7 | workspace-creation (S9) | ❌ missing | Multi-step wizard not built. `CreateWorkspaceRoute.tsx` is single-step. | +| 8 | admin-workspace-settings (S12 settings tab split) | ⚠️ partial | 893-line one-page file. Needs General / Members / Access / Billing / Danger split + downgrade confirmation + tier matrix on billing tab. | +| 9 | admin-organisation-settings (S7) | ⚠️ partial | `OrganisationRoute.tsx` matrix view exists. Missing: list ⇄ matrix ⇄ projects 3-view switcher + bulk project delete. | +| 10 | role-change-flow | ⚠️ partial | Notifications emit (`WORKSPACE_ROLE_CHANGED`, `ORGANISATION_ROLE_CHANGED`). Missing: downgrade banner + admin-summary email. | +| 11 | tier-gated-click | ✅ built | Via FeatureGate. | +| 12 | private-project-sharing (S10) | ✅ built | Backend + UI shipped (`001ef0a`, `a85d2fe`). | +| 13 | guest-experience | ⚠️ partial | Externals are correctly scoped today; need audit pass. | +| 14 | billing-role-flow | ❌ missing | Needs billing role first. | +| 15 | referral-ledger-view | ❌ missing | Schema + partner-portal view + staff edit. | + +--- + +## Matrix-defined additions that are NOT in the checklist at all + +These are matrix requirements the checklist doesn't yet track — raise in first sync. + +- Per-tier taglines on every tier surface +- Tier capacity matrix rendered inside product (billing tab + upgrade modal) +- Billing role (schema, policies, preset, UI chip, organisation-level too) +- Slack-style discovery (retire derivation, add join + request-access endpoints) +- `workspace.visibility` enum migration (schema) +- Honesty disclosure on private creation +- Downgrade confirmation dialog listing every frozen/reverted feature +- 7-day in-workspace downgrade banner (auto-return on frozen-feature-attempt) +- Post-downgrade admin email +- Hour meter + calendar-month reset + Pilot hard-block +- Guest cap enforcement per tier +- Usage rollups at project / workspace / organisation with role-differentiated views +- `referral_ledger` schema + partner view +- `billed_to_team_id` / `effective_client_team_id` schema + handoff flow +- `staff:can_set_tier` policy +- Upgrade inbox switch to `upgrades@dembrane.com` +- Migration CSV tool (M1) +- Member raw-usage visibility + admin/billing €-forecast differentiation + +--- + +## Matrix-invariant items explicitly deferred + +Per brief — do not build this session: +- Invite reminder cron +- Trash/restore UI (post-release) +- `usage_event` reinstatement ← **conflicts with §8 hour meter.** Unless "usage_event" is the specific table name being deferred and a different mechanism meets §8, this blocks §8. Open question. +- Org billing rollup page +- Audit log UI +- Customer pricing page +- Suspend/unsuspend (`workspace.suspended_at`) — explicitly out per matrix reconciliation + +--- + +## Open conflicts that belong in 04-QUESTIONS-FOR-SAMEER.md + +1. Role rename scope — can we add `billing` role or is it UI-only? (DB: `billing` doesn't map to existing enum, so UI-only is impossible.) +2. Visibility schema — new `workspace.visibility` enum vs keep booleans + rename at UI? +3. Hour meter — §8 requires it; checklist defers `usage_event`. Which wins? +4. Uncommitted working tree — resume or revert? (~1221 line delta across notifications + emails + scripts + auth routes) +5. Viewer role — collapse (matrix has no viewer) or keep? +6. Webhooks gate — matrix says changemaker+; code leaves open. Enable the gate? +7. Existing-customer role mapping for M1 — cross-reference current DB rows vs matrix role set before cutover. diff --git a/echo/docs/workspaces-validate/03-DECISIONS.md b/echo/docs/workspaces-validate/03-DECISIONS.md new file mode 100644 index 00000000..5c2ff6ba --- /dev/null +++ b/echo/docs/workspaces-validate/03-DECISIONS.md @@ -0,0 +1,23 @@ +# Decisions log + +Append-only. One decision per entry. Dated. Short. + +--- + +## 2026-04-23 + +- **D1.** Treat matrix v1.1 as the contract. Checklist's "Decisions locked" for derivation + sticky removal is superseded by matrix §5–§6; reconcile via the walkback flow, not by preserving old behavior. +- **D2.** Build `workspaces-validate/` as descriptive (of what shipped), not prospective. Flow + screen specs document the product; they don't design it. +- **D3.** Working docs follow the layout listed in the brief; `MEMORY.md` / `03-DECISIONS.md` / `04-QUESTIONS-FOR-SAMEER.md` / `05-PROGRESS.md` are all at the root of `workspaces-validate/`, not nested. +- **D4.** `00-DOC-AUDIT.md` carries the "Repo conventions" header per brief; `02-DELTA.md` references it rather than repeating. +- **D5.** Do not touch the uncommitted working tree until Q4 is answered. Build only on top of the last commit (`cfa758e`) for now. +- **D6.** Session 1 outputs: audit, delta, plan, questions, decisions, progress. No implementation until Sameer clears the first gate. +- **D7.** [Q1] `billing` role lands in schema this release. Fifth `workspace_membership.role` value with its own preset. Mirror at organisation level too (matrix §5 lists three organisation roles: Admin / Billing / Member). +- **D8.** [Q2] Add `workspace.visibility` enum (`open_to_organisation | private`). **Remove** `workspace.settings.inherit_organisation_admins` and `inherit_organisation_members` directly — Sameer confirms not in prod yet. Also purge `sticky_removed` tombstones as part of the same walkback. +- **D9.** [Q3] Hour meter = derived. Sum `conversation.duration` where `workspace_id=X AND deleted_at IS NULL AND created_at` within current calendar month. Expose via `/v2/workspaces/:id/usage`. No new `usage_event` table. Pilot hard-block = read-time check against 10h cap before host-side endpoints run. +- **D10.** [Q4] Commit the uncommitted in-flight work as a single coherent commit. **Exclude** `docs/workspaces/` and `docs/workspaces-validate/` from upcoming code commits — docs get their own commits, separated from feature work. +- **D11.** [Q5] Remove `viewer` role. No migration — rely on robust error handling for any stray rows. Drop the preset; if a DB read returns `role='viewer'`, treat as `member` + log a warning. +- **D12.** [Q6] Add `workspace:webhooks` policy. Gate at `changemaker+` via `TIER_REQUIRED_FOR_POLICY`. Freeze-on-downgrade (existing webhooks keep firing, no new configs). +- **D13.** [Q7] Defer M1 CSV tool + deployment-day thinking. Not this session. +- **D14.** [Q8] Switch upgrade-inbox default to `upgrades@dembrane.com` everywhere — `settings.py` default, docstrings, any hardcoded references in endpoints, docs. +- **D15.** Validation plan: four-axis reviewer pattern (security / human-first / brand / copy) at three cadences (spec-time / build-time / phase-boundary). Codified in `06-VALIDATION-PLAN.md`; findings logged in `06-AUDIT-LEDGER.md`. Baseline cleared audits not re-run unless I modify that code. diff --git a/echo/docs/workspaces-validate/04-QUESTIONS-FOR-SAMEER.md b/echo/docs/workspaces-validate/04-QUESTIONS-FOR-SAMEER.md new file mode 100644 index 00000000..864a7bb4 --- /dev/null +++ b/echo/docs/workspaces-validate/04-QUESTIONS-FOR-SAMEER.md @@ -0,0 +1,82 @@ +# Questions for Sameer + +**Convention:** each question is tagged in its heading: +- `🔴 blocking` — blocks other work +- `🟡 non-blocking` — can proceed without +- `✅ answered ` — resolved + +Answered questions keep their body for context. Answer goes inline right under the heading as `**Answer:**`. Decisions derived from answers land in `03-DECISIONS.md`. New questions go to the top. + +--- + +## Pending + +*(none)* + +--- + +## Resolved + +### [Q1 · ✅ answered 2026-04-23] Role-rename scope: can `billing` role land in schema this release? + +**Answer:** go for it (Option A). Add `billing` as a fifth `workspace_membership.role` value with its own preset. See D7. + +**Context:** Matrix §4 introduces four workspace roles — **Admin / Billing / Member / Guest**. Code today has `owner / admin / member / viewer` at the workspace level plus `is_external=true` for Guest. `billing` is not a renaming of anything — it is a net-new role with its own capability set (update payment, see invoices, request upgrades, see usage + € forecasts, cannot create projects, cannot invite). + +**In code:** +- `server/dembrane/policies.py:53-93` — `WORKSPACE_ROLE_PRESETS`: `viewer / member / admin / owner` +- `workspace_membership.role` is a free-text field in Directus (not a DB-enum), so adding values is cheap. + +**Sub-question (open):** Is "Billing" at the *organisation* level a rename of the organisation `admin` role's billing capabilities, or a separate preset? Matrix §5 lists three organisation roles (Admin / Billing / Member). Default: treat as separate preset, mirror workspace approach. Revisit if it breaks on implementation. + +--- + +### [Q2 · ✅ answered 2026-04-23] Visibility schema — enum vs keep booleans? + +**Answer:** Option A. Add `workspace.visibility` enum (`open_to_organisation | private`). Remove old columns directly — not in prod yet. See D8. + +**Context:** Matrix §6 uses a single `workspace.visibility` enum. Code stores two booleans in `workspace.settings` JSON (`inherit_organisation_admins`, `inherit_organisation_members`). Matrix v1.1 retires the inheritance model entirely, so the second boolean becomes meaningless. + +Also purge `sticky_removed` tombstones as part of the walkback. + +--- + +### [Q3 · ✅ answered 2026-04-23] Hour meter vs deferred `usage_event` + +**Answer:** Option B. Derive from `conversation.duration` via a usage API route. Soft-delete respected via `deleted_at` filter. See D9. + +**Context:** Matrix §8 requires a per-workspace hour meter with calendar-month reset, overage billing, and Pilot hard-block at 10 hours. + +**Implementation shape:** `/v2/workspaces/:id/usage` sums `conversation.duration` where `workspace_id=X AND deleted_at IS NULL AND created_at` within current calendar month. No new `usage_event` table. Pilot hard-block = read-time check against 10h cap before host-side endpoints run. + +--- + +### [Q4 · ✅ answered 2026-04-23] Uncommitted working tree — resume or revert? + +**Answer:** Commit the in-flight changes as one commit. Going forward, exclude `docs/workspaces/` and `docs/workspaces-validate/` from code commits — doc churn gets its own commits. See D10. Committed as `bc3310c`. + +--- + +### [Q5 · ✅ answered 2026-04-23] Collapse `viewer` role? + +**Answer:** remove viewer. No migration. Rely on good error handling for any stray rows. See D11. + +**Approach:** drop the preset; if a DB read returns `role='viewer'`, treat as `member` + log a warning. + +--- + +### [Q6 · ✅ answered 2026-04-23] Webhooks gate — turn on at changemaker+? + +**Answer:** yes. Add `workspace:webhooks` policy, gate at changemaker via `TIER_REQUIRED_FOR_POLICY`, freeze-on-downgrade. See D12. + +--- + +### [Q7 · ✅ answered 2026-04-23] Pilot tier assignment for current customers at cutover + +**Answer:** deferred — deployment thinking happens later. M1 CSV tool is not this session. See D13. + +--- + +### [Q8 · ✅ answered 2026-04-23] Upgrade inbox switch + +**Answer:** `upgrades@dembrane.com` everywhere — default in `settings.py`, docstrings, endpoint copy, docs. See D14. diff --git a/echo/docs/workspaces-validate/05-PROGRESS.md b/echo/docs/workspaces-validate/05-PROGRESS.md new file mode 100644 index 00000000..c48ab03c --- /dev/null +++ b/echo/docs/workspaces-validate/05-PROGRESS.md @@ -0,0 +1,287 @@ +# Progress + +Rolling status. Most recent entry on top. Update after every commit or at phase boundaries. + +--- + +## 2026-04-24 — overnight pass (Claude, while Sameer slept) + +**Directive:** "im going to bed so do everything…" — scoped to: +1. Chat templates workspace-scoping (explicit ask earlier in the session) +2. Route-by-route human pass for the four personas +3. Specific pains Sameer flagged live — usage-strip too billing-y on project page, rename Billing → "Usage and Tier" + +**Commits landed (newest first):** +- `aaf5641` — recovery affordances on check-your-email (spam tip, wrong-address, support mailto) on both Register step 2 and the standalone route. Closes `[rough]` pain entry from qa/pains.md. +- `8872267` — project pin + create gated for guest/external workspace access. Guests no longer see the Create button or the interactive Pin/Unpin; pinned projects still render with a dimmed read-only pin icon so the signal still reads. +- `52802e1` — three validation-log pains: onboarding partial-state retry (workspace existed but membership was missing), include_org_membership alias on invite payload, UserAvatar initials (Anna Bakker → AB, not AN). +- `b6f6656` — chat templates workspace-scoping end-to-end: schema step 15 (workspace_id + scope on prompt_template), backend /templates/prompt-templates rewrite with membership + is_external gating, frontend hooks/menu/modal wired through, "Share with organisation" switch in the create form. +- `aa971a4` — chore: ignore scripts/__pycache__ (stop leaking .pyc into commits). +- `183e709` — killed the project-page usage strip entirely (Sameer: "thats the first thing i see? eek"), renamed Billing tab on /w settings and Usage tab on /o to "Usage and Tier" — friendlier for first real engagements. +- `0ed35c5` — dropped duplicate project heading on overview (sidebar already owns it), fixed pin gating at the backend (permission now follows workspace membership, not project ownership). +- `88b3d0b` — the big one: 500 on /api/v2/onboarding/complete (retired kwargs on on_workspace_created) + /w showing "No workspaces yet" when the user belongs to a organisation but has no workspace yet. Both surfaces now cleared. + +**Route-by-route audit outcome:** +- `/projects/:id/*` — fixed (57). Duplicate heading removed, usage strip removed, pin + create guest-gated. +- `/w/:id/settings/*` — reviewed (58). Role gating via policies is already consistent; no fixes needed beyond the Billing→"Usage and Tier" rename. Member / billing / admin / guest all see correct tab set. +- `/w/:id/projects` — fixed (59). Gating in place. +- `/o/:id/*` — reviewed (60). Already gated via `isAdmin`; no drift found. +- `/w`, `/w/new`, `/onboarding`, `/invites`, `/settings` — reviewed (61). /invites empty + toast flow clean. /settings has its own section structure and looked sound. Auth flows got the recovery affordances. + +**Deferred decisions for Sameer:** +- `[hurt?]` silent auto-add when inviting an existing user — needs a product call (send an invite vs. auto-add; Sameer flagged this in pains.md). No code change made. +- Workspace cards surfacing "approaching cap" / "downgraded" visually — design-level call; not touched. +- Chat templates UI: organisation-vs-personal row badge + gating edit/delete buttons when can_edit=false — backend delivers `can_edit` + `scope` but the modal rows don't yet visually distinguish personal from organisation. Deferred to keep commit scope contained; follow-up ticket. + +**Need to run before first local start:** +``` +cd /workspaces/echo +python scripts/create_schema.py --step 15 +cd directus && bash sync.sh -u http://directus:8055 -e admin@dembrane.com -p admin pull +# then commit the directus/sync/snapshot/* diff +``` + +--- + +## 2026-04-23 — session start + +**Branch:** `workspaces` at `cfa758e`. Dirty working tree (see Q4). + +**Phase:** A (orient) complete → B (first sync gate with Sameer). + +**Shipped this session:** +- `00-DOC-AUDIT.md` — inventory + Repo conventions header +- `02-DELTA.md` — matrix vs code gap pass (all 11 matrix sections + 7 canonical screens + 15 flows) +- `04-QUESTIONS-FOR-SAMEER.md` — 8 pending (Q1–Q8, Q1/Q2/Q3/Q4 blocking) +- `00-PLAN.md` — phase sequence A→G +- `03-DECISIONS.md` — D1–D6 +- This file + +**Code changes:** none. No commits. + +**Blocked on:** +- Sameer first-gate sync (Q1 billing role, Q2 visibility schema, Q3 hour meter, Q4 uncommitted tree) + +**Next action on resume:** +- If Sameer has answered Q1–Q4: start Phase C (canonical screen specs). +- If not: nothing useful to do yet; sit. + +## 2026-04-23 — Q1–Q8 answered + validation plan approved + +**Answers locked into D7–D14** (`03-DECISIONS.md`). Summary: +- Q1 billing role → ship in schema +- Q2 visibility → add enum, drop old booleans + sticky_removed (not in prod) +- Q3 hours → derive from `conversation.duration` via `/v2/workspaces/:id/usage` +- Q4 uncommitted tree → commit as one, exclude doc folders from code commits going forward +- Q5 viewer → remove, no migration, error-handle strays +- Q6 webhooks → gate at changemaker+ +- Q7 M1 tool → defer +- Q8 upgrade inbox → `upgrades@dembrane.com` everywhere + +**Validation plan** — `06-VALIDATION-PLAN.md`, ledger at `06-AUDIT-LEDGER.md`. Four axes (security / human-first / brand / copy), three cadences (spec / build / phase-boundary). + +**Next action on resume:** commit in-flight work per D10 (excluding the two doc folders), then Phase C (canonical screen specs) in parallel with the derivation-walkback flow (D8, prerequisite for flows 1/4/6). + +## 2026-04-23 — First build batch shipped + +**Commits landed:** +- `bc3310c` — in-flight polish (notifications unification, email templates, auth copy, dead-code sweep of announcements) +- `ec67257` — default upgrade inbox → `upgrades@dembrane.com` (D14) +- `b9c0b48` — backfill script for direct-membership walkback (S0, dry-run default) + +**Docs written this batch** (all uncommitted per D10): +- `flows/derivation-walkback.md` — the full S0 backend spec (backfill → resolver simplification → settings purge → new endpoints) +- 7 canonical screen specs: `screens/feature-locked.md`, `status-banner.md`, `request-submitted.md`, `destructive-confirm.md`, `manage-list.md`, `empty-state.md`, `readonly-data.md` +- `06-VALIDATION-PLAN.md` + `06-AUDIT-LEDGER.md` + +**Backfill dry-run on dev:** 2 proposed rows across 2 orgs, 2 workspaces (each a "Default" workspace where an org member lacks a direct row). Clean run. Not applied. Production run is deferred per Q7 (deployment thinking later). + +**Next batch — in order:** +- #12 Remove `viewer` preset + add `billing` preset (D7, D11) — small, policies.py only +- #10 Schema pass: `workspace.visibility` enum, `workspace:webhooks` policy, billing role literal (D7, D8, D12) +- #13 `/v2/workspaces/:id/usage` — derive hours from `conversation.duration` (D9) +- #14 Pilot hard-block (depends on #10 + #13) + +Validation-subagent dispatches: none yet. First full build-time dispatch will land when #13's endpoint is ready (security + design + copy relevant; brand n/a until UI). + +## 2026-04-23 — Second build batch shipped + +**Commits landed (continuing from first batch):** +- `28ff732` — S6 roles: viewer retired, billing preset added, upgrade:request policy wired, invite + change-role endpoints use member/billing/admin/owner hierarchy. Legacy role normalization at middleware context build. +- `a815f15` — S10a: `workspace.visibility` enum (`open_to_organisation | private`). First slice of matrix §6. Backfilled from `settings.inherit_organisation_admins`. Directus snapshot pulled. + +**Still open from matrix §6 walkback** (blocked on backfill --apply in prod per Q7 deferred): +- Simplify `inheritance.user_can_access` to direct-only +- Drop `settings.inherit_organisation_admins / inherit_organisation_members` flags +- Purge `settings.sticky_removed` tombstones +- Add new endpoints: join / access-requests / approve / reject +- Add notifications: `MEMBERSHIP_REQUESTED`, `MEMBERSHIP_REQUEST_APPROVED` (rejection silent per matrix) + +**Next batch:** +- #13 `/v2/workspaces/:id/usage` endpoint (D9) — self-contained, derives from `conversation.duration` with `deleted_at` filter + current-calendar-month bound. Role-differentiated response: raw numbers for members; overage forecast + tier recommendation for admin/billing. +- #14 Pilot hard-block — depends on #13. Read-time check against 10h cap before host-side endpoints. Participant portal exempt. Banner + modal via `screens/status-banner.md`. + +**Validation status:** no subagent dispatches this session. Mental review only on the role change (security: no bypass; copy: brand-compliant). For #13 I'll dispatch the full build-time axes (security + design + copy) before committing — new endpoint, new attack surface. + +## 2026-04-23 — Third build batch shipped + +**Commits landed (continuing):** +- `e734319` — S13: GET `/v2/workspaces/:id/usage` + `tier_capacity` canonical matrix module. Security subagent dispatched on this; four findings (F1 missing view_usage on member preset → fixed; F2 Pilot fallback NULL → fixed; F3 elevated-role guest logging → fixed; F4 billing-as-seat → false positive, kept per matrix §7). Logged in `06-AUDIT-LEDGER.md`. +- `21e666f` — S14: Pilot hard-block. New `require_no_pilot_block(ctx)` FastAPI dep; wired to `POST /v2/workspaces/:id/projects`. Matrix-§8 verbatim 402 copy with participant-reassurance line. + +**Validation this batch:** Security subagent on #13 (four findings, three fixed same-commit, one kept). #14 got mental review only — small scope, follows pattern laid down in #13, participant portal untouched. + +**Still to wire for full §8 coverage (follow-up):** chat / agentic / transcript-view / report-generate / data-export endpoints — all v1 routes that don't take a `WorkspaceContext`. A `require_no_pilot_block_for(workspace_id)` variant lands with those. + +**Session end-state:** +- 7 commits on `workspaces` past `cfa758e`: `bc3310c`, `ec67257`, `b9c0b48`, `28ff732`, `a815f15`, `e734319`, `21e666f`. +- `docs/workspaces-validate/` complete with audit, delta, 7 canonical screens, derivation-walkback flow spec, validation plan, audit ledger, progress, decisions, questions. +- Backfill script dry-runs clean on dev (2 proposals); not applied — stop condition per Q7. + +**Blockers that did NOT land:** +- Pilot hard-block on v1 host-side endpoints (chat / agentic / transcript / report / export). +- Derivation walkback resolver simplification + settings purge (deferred to prod-backfill session). +- Request-to-join + organisation-admin-join endpoints (matrix §6). +- Downgrade confirmation dialog + 7-day banner (matrix §3). +- Honesty disclosure on private-workspace create (matrix §6). +- Workspace creation wizard (S9). +- Settings tab split (flow 8). +- Organisation admin page expansion to matrix ⇄ list ⇄ projects (S7). +- Frontend wiring for the usage endpoint + Pilot-block UI banner. + +These remain release blockers. Next session should pick from this list in dependency order — usage-frontend + downgrade-dialog are tightest coupling to what shipped this session. + +## 2026-04-23 — Fourth build batch shipped + +**Commits (continuing):** +- `b49c30d` — S3a post-downgrade tracking + email to admin+billing audience (matrix §3). `workspace.downgraded_at` + `downgraded_from_tier` fields; email template `tier_downgraded.{html,txt}`; new `audience_workspace_admins_and_billing` helper. +- `c8fae01` — S6 Slack-style discovery (matrix §6). `access_request` collection + CRUD; `POST /workspaces/:id/join` (organisation admin self-join); `POST /workspaces/:id/access-requests` (member request); approve + reject (silent rejection); `GET /orgs/:id/discoverable-workspaces`. Notifications `MEMBERSHIP_REQUESTED` / `MEMBERSHIP_REQUEST_APPROVED`. +- `385bf8f` — S14b Pilot hard-block extended to v1 chat + agentic (`POST /api/chat/{id}`, `POST /api/agentic/runs`, `POST /api/agentic/runs/{id}/messages`). New `check_no_pilot_block_for_project` helper. +- `1feb963` — Set `workspace.visibility` on create + honesty disclosure on Private in `CreateWorkspaceRoute.tsx`. + +**Session end-state:** +- 11 commits on `workspaces` past `cfa758e`: `bc3310c`, `ec67257`, `b9c0b48`, `28ff732`, `a815f15`, `e734319`, `21e666f`, `b49c30d`, `c8fae01`, `385bf8f`, `1feb963`. +- Schema steps 10 (visibility), 11 (downgrade tracking), 12 (access_request) all applied + snapshot synced. +- Audit ledger has F1–F5 logged; 4 fixed, 1 false-positive. + +**Still not landed** (release-relevant): +- Derivation walkback execution (prod backfill → resolver simplification → settings purge) — Q7-deferred. +- Report / export endpoints Pilot-block (S14c follow-up). +- Workspace creation wizard (S9) — single-form + honesty disclosure is tactical minimum. +- Settings tab split (flow 8). +- Organisation admin 3-view (S7). +- Frontend wiring for: usage endpoint rendering, Pilot-block level-3 modal, 7-day downgrade banner, home discovery section, access-requests list. +- Tier capacity matrix UI surface (billing tab + upgrade modal — backend done via `tier_capacity.py`, frontend TBD). + +Backend for matrix §3 + §6 + §8 + §11 is largely in. Frontend is the next big chunk. + +## 2026-04-23 — Fifth build batch shipped (same-day continuation) + +**Commits (continuing):** +- `c403b8c` — matrix §6 approve/reject guard widened to accept organisation admins without direct workspace membership; legacy `inherit_organisation_admins` / `inherit_organisation_members` writes dropped on create; resolver prefers `workspace.visibility` enum; UI drops retired members-inherit checkbox. +- `c59b681` — matrix §3 downgrade email moved to Dramatiq network-queue actor (`task_send_downgrade_email`). Staff PATCH returns immediately. +- `f90ab7b` — matrix §3 + §8 frontend surfaces: `PilotBlockModal` (level-3 canonical, matrix-§8 verbatim copy + participant-reassurance line); `DowngradeBanner` (7-day, dismissable per-session); global `pilotBlock` signal bus wired through QueryClient MutationCache + QueryCache onError handlers. +- `f1b7e30` — matrix §6 frontend: `DiscoverableWorkspaces` section on the home page (inside each organisation group); `AccessRequestsList` on workspace settings (pending-only). Closes the discovery → request → approve loop end-to-end. +- `58d462f` — matrix §8 frontend: `UsageCard` on workspace settings. Role-differentiated display (members raw, admin/billing overage forecast + next-tier hint); progress bars with traffic-light colouring; "At limit" / "Approaching limit" badges. + +**Backfill run (D1 in audit ledger):** `scripts/backfill_direct_memberships.py --apply` ran on dev Directus. 2 proposals written, re-run `--dry-run` shows 0. Prod execution still pending Q7. + +**Session end-state:** +- 18 commits on `workspaces` past `cfa758e`. +- Matrix §3 + §6 + §8 have both backend + first-pass frontend wiring landed. +- Frontend: `WorkspaceLayout` now hosts the banner + modal; `WorkspaceSelectorRoute` shows discovery; `WorkspaceSettingsRoute` shows usage + access-requests. + +**Still not landed** (next-session candidates): +- Derivation walkback execution in prod + post-backfill resolver simplification + settings-flag purge — Q7-deferred. +- Report / export endpoint Pilot-block (S14c) — explicitly deprioritised per user "UI should make it clear" direction. +- Workspace creation wizard (S9 multi-step). +- Settings tab split (flow 8) — bigger refactor; usage + access-requests + billing all currently on one page. +- Organisation admin 3-view (S7) — Ask 1 projects view on /o/:orgId. +- Tier capacity matrix UI (full matrix render; currently only the next-tier hint on UsageCard). + +## 2026-04-23 — Sixth build batch shipped (caching + HCD fixes) + +**Caching + loop-closing commits:** +- `5de8891` — Redis cache on `/v2/workspaces/:id/usage` (30-min TTL + `?refresh=true`). UI refresh button. Cache busts on tier PATCH. Broken `?tab=billing` anchors dropped across UsageCard / PilotBlockModal / DowngradeBanner. +- `35b1705` — FeatureGate → DowngradeBanner auto-return on frozen-feature-attempt (matrix §3 finally fully honored). + +**HCD audit + converged fixes:** +- Four-role audit dispatched in parallel (Admin+OrganisationAdmin / Member+OrganisationMember / Billing+OrganisationBilling / Guest+Staff). Raw findings synthesised in `07-HCD-AUDIT.md` across 15 concerns, clustered into Tier 1 / 2 / 3 fix lists. +- `893be3f` — Tier 1 (pattern + copy): shared `TierBadge` component + `lib/tiers.ts` tagline map (matrix §1 honored on every tier surface); raw policy badges dropped from "Your access"; Privacy & defaults hidden for non-admins; OrganisationSettings "owners" → "organisation admins"; OrganisationRoute derivation footer rewritten per matrix §5/§6; organisation-level Guests filter + count retired (matrix §5). +- `7139a30` — Tier 2 (action affordances): Request-upgrade button on UsageCard for admin/owner/billing; Leave-workspace button for every role ("Your access" section, with a clear consequence-spelled-out confirm modal + last-admin protection in the backend); Guest chip + UsageCard hidden when is_external=true. + +Tier 3 HCD items deferred: Private-radio tier-gating in settings, organisation-level usage rollup, invoices/payment UI (no backend anyway), staff "global workspaces" route, matrix-cell click routing on OrganisationRoute, invite-as-guest path, admin chips for members, silent-rejection pending TTL. + +**Session totals:** 25 commits past `cfa758e`. Backfill `--apply` complete on dev. All matrix §3 / §6 / §8 / §11 contract items now have both backend + user-visible frontend. HCD audit consumed + most universal concerns closed. + +## 2026-04-23 — Seventh build batch (HCD follow-through + matrix §1 surfaces) + +**High-impact commits (user asked for "best impact wise"):** +- `e4387e9` — Organisation-level usage rollup (matrix §8 organisation-scope). New `GET /v2/orgs/:id/usage` aggregates hours / seats / guests / projects / €-forecast across all organisation workspaces with 30-min cache + `?refresh=true` + tier-change invalidation. Frontend `OrganisationUsageRollup` strip at top of `/o/:orgId`. Closes HCD concern #7 (biggest organisation-admin + organisation-billing gap). +- `c2c3fb1` — Full tier capacity matrix rendered in-product (matrix §1). New `GET /v2/workspaces/tier-capacities` exposes the canonical TIER_CAPACITIES data. New `TierCapacityMatrix` component renders the full table. Wired into `UpgradeModal`, replacing the pair of single-tier cards with a comparative matrix. Matrix §1 contract item finally honored in the upgrade surface. +- `963fb0b` — HCD tier-3 bundle: + - Admin chips in FeatureGate member path (HCD #13): "ask a organisation admin" now renders admin avatars + names fetched from `/v2/orgs/:id/members`. + - Private tier-gate in both settings + create (HCD #11 / matrix §2): Pioneer admin can no longer click Private and hit a cryptic 403. Disabled option + inline "Available on innovator" hint. Existing-private preserved on downgrade (matrix §3 freeze). + - Workspace-column dead-clicks on /o/ matrix (HCD #9): non-admins see plain dimmed text instead of anchors that 403. + +**Session totals: 28 commits past `cfa758e`.** + +**HCD concern closure count:** +- Closed this session: #1 (tier+tagline), #2 (policy badges), #3 (Privacy hidden), #4 (Request upgrade button), #5 (shown-here-but-dead-link), #6 (derivation footer), #7 (organisation rollup), #8 (owners→admins), #9 (dead clicks), #10 (Guest chip + hide), #11 (Private tier-gate), #13 (admin chips), #14 (Guest role badge shown). +- Still open (tier-3 deferred): + - #5/#12 Invoices + payment UI (no backend) + - #12 Staff console (post-release per matrix) + - #14 Invite-as-guest UI path + - #15 Silent-rejection pending TTL + - #16 Settings tab split + - #17 S7 organisation admin projects view + - #18 Workspace creation wizard multi-step + +Biggest remaining wins-per-line-of-code: workspace creation wizard + settings tab split (foundation for further role-scoped landing). + +## 2026-04-23 — Eighth build batch (settings tab split shipped) + +- `79fc727` — Workspace settings Overview / Billing tab split. URL-driven via `?tab=`. Role-based default lands Billing-role on Billing tab, everyone else on Overview. Guest bypasses tabs entirely (matrix §4 exclusions already applied). Full `TierCapacityMatrix` rendered on billing tab with current tier highlighted — matrix §1 now honored on both the upgrade modal AND the billing tab (contract was "at minimum" both surfaces). PilotBlockModal + DowngradeBanner now route to `?tab=billing` since that's where the context lives. + +**Session total: 29 commits past `cfa758e`.** + +HCD concern #16 (settings tab split) closed. Remaining tier-3 items: workspace creation wizard multi-step (S9), S7 organisation admin projects view, invite-as-guest path. Staff console + invoices UI remain matrix-deferred (no v1 backend). + +## 2026-04-23 — Ninth build batch (seat-count bug + per-workspace breakdown + S7) + +**User-triggered debug + continuation:** +- `bdd16dd` — **Seat count dedup fix.** Admin reported seat count showed 5 when they expected fewer. Investigation revealed the count was actually correct (5 distinct workspace-user pairs with seat-worthy roles) but pre-walkback legacy `source='inherited'` rows were co-existing with `source='direct'` rows for the same pair. On one workspace this caused the same user to be counted twice. Fix: dedupe by (workspace_id, user_id) before seat counting, prefer direct over inherited. Applied to both `/v2/workspaces/:id/usage` and `/v2/orgs/:id/usage`. Legacy rows archived on dev via `migrate_inherited_to_derived.py --apply`; invariant #5 restored. +- `5ec8cd4` — **Organisation matrix direct-role visibility fix.** Matrix was only showing derivation-based access (organisation owner → admin everywhere; organisation admin → admin on open workspaces). Direct workspace memberships for non-admin organisation members were invisible. Fix: extend `OrgMemberResponse` with `direct_workspace_roles: dict[workspace_id, role]` (one batched read per page load). OrganisationRoute cells prefer the direct role, fall back to derivation, color-code by role. +- `1ff2d5a` — **Per-workspace breakdown on organisation usage rollup** (user ask). `/v2/orgs/:id/usage` now returns a sorted `workspaces[]` list: at-cap first, then approaching, then hours desc. OrganisationUsageRollup gains a collapsible table with progress bars + click-through to each workspace's billing tab. Matrix §8 organisation-scope now concrete for admins — they can spot hot workspaces without guessing. +- `868ffba` — **S7: organisation admin Projects view.** Matrix §4 wind-down workflow. New `GET /v2/orgs/:id/projects` (admin-only) returns every project across organisation workspaces with conversation counts. `OrganisationProjectsTable` with search + workspace filter + per-row delete (confirm modal with conversation-count impact). View toggle on OrganisationRoute: People ↔ Projects (admin-only). + +**Session total: 33 commits past `cfa758e`.** + +Matrix §4 delete-workspace workflow now has a proper Admin surface. Organisation rollups actionable. HCD concern #7 (organisation admin financial summary) + #17 (S7 projects view) closed. + +## 2026-04-23 — Tenth build batch (wizard + invite-as-guest + role chips) + +- `da70a10` — **S9 workspace creation wizard.** 3-step Stepper (Name → Access → Review) with back + cancel at each step. Review step's "What each role will experience" list covers you / organisation admins / organisation members / guests per matrix §6 Slack-style model. Private radio stays tier-gated (matrix §2 innovator+). Cancel confirms when form has content. +- `81cbb5b` — **Invite-as-guest UI path.** New "Invite as" radio (organisation member vs guest) above the workspace-role radio. Guest path hides the role picker (hard-rule clamp to member-equivalent per matrix §4). Modal title swaps between "Invite a member" and "Invite a guest". Backend already supported `is_org_member=false` — UI just didn't expose it. +- `31d2963` — **Home cards: role chip + tier badge side-by-side.** Matrix §2 HCD decision: role must be visible on every workspace card alongside tier. Role chip color-coded (Admin/Owner blue, Billing yellow, Member gray). Guests get a single gray "Guest" chip with no tier badge. + +**Session total: 36 commits past `cfa758e`.** + +HCD concerns #14 (guest identity clarity) + #15 (invite-as-guest path) + #18 (wizard) now closed. Virtually the entire tier-3 HCD list has landed — remaining deferred items are matrix-deferred (invoices/payment/staff console). + +## 2026-04-23 — Eleventh build batch (Danger tab + cap-at-a-glance across home) + +- `a9f45b7` — **Matrix §4 delete-workspace UI in a Danger tab.** Backend relaxed from owner-only to admin+owner (matrix §4 grants Admin ✓). Red-tinted Paper with two states: if projects exist → "Clear N first" + link to the organisation projects view (S7); if empty → type-to-confirm input requiring exact workspace name. Completes the matrix §4 admin capability trio (invite / manage / delete). +- `961c72b` — **Organisation hero health hint.** `/v2/orgs/:id/usage` data inline in each organisation's hero card on home: hours this cycle, €-forecast (admin/billing only, server-gated), at-limit + approaching badges when any workspace is hot. Admins spot trouble organisations at a glance without clicking into /o/. +- `3602536` — **Per-workspace card cap warnings.** WorkspaceUsage schema extended with `hours_included / hours_pct / at_cap / approaching_cap`, populated server-side from tier_capacity. Cards show red "At limit" / yellow "Approaching" badges in the meta row. Matrix §8 cap info now visible at every surface users land on. + +**Session total: 39 commits past `cfa758e`.** + +At-a-glance cap status is consistent across three tiers: home workspace cards → organisation hero cards → workspace settings billing tab → organisation admin /o/ rollup. A user who spends 20 seconds on the app knows exactly which workspace/organisation is at or approaching a limit. + +**Notes for future-me:** +- Brief says `workspaces-release-checklist.md`; actual path is `docs/workspaces/release-checklist.md`. +- Companion design docs live at `docs/workspaces/` (not `docs/workspaces-validate/`). +- The brief's "8 prior commits from the autonomous run (2026-04-20)" is out of date — actual commit count on this branch past `main` is ~29 (see git log in audit). The post-8 commits include S10 UI, S11 FeatureGate, S12 selector polish, audit rounds, inbox implementation, organisation route, etc. +- `frontend/src/components/inbox/Inbox.tsx` replaces the deleted `NotificationsDrawer.tsx`; confirm the swap is wired in `Header.tsx`. +- Upgrade inbox default is still `sameer@dembrane.com` in `settings.py:340` — matrix says `upgrades@dembrane.com`. diff --git a/echo/docs/workspaces-validate/06-AUDIT-LEDGER.md b/echo/docs/workspaces-validate/06-AUDIT-LEDGER.md new file mode 100644 index 00000000..78ff07a5 --- /dev/null +++ b/echo/docs/workspaces-validate/06-AUDIT-LEDGER.md @@ -0,0 +1,30 @@ +# Audit ledger + +Append-only. One row per finding. Findings from subagent validation dispatches (see `06-VALIDATION-PLAN.md`) land here so future sessions can see what's cleared and what's outstanding. + +## Baseline (pre-session) + +Three audit rounds shipped fixes before this session. Treat their scope as cleared unless I change that code. Reference commits for the baseline: + +- `f2bfb2f` — audit summary + fix/defer ledger for the 5-perspective review +- `8aba15d` — round-2 audit (7 of 8 critical/high findings addressed) +- `deb6597` — security + footgun pass from audits +- `4646825` — private projects: read-time enforcement on common surfaces +- `15c7d1a`, `2f543ac`, `ff93e68`, `0120a72` — inheritance + security spot fixes + +Open from the baseline (per release-checklist.md §"Private-project read enforcement"): +- Deep-linked chat/conversation URL to a private project's chat bypasses `ProjectAccessGuard` because the Directus SDK path doesn't know about visibility. Fix: tighten Directus permissions on `project / conversation / project_chat / project_report` reads. Tracked as its own session. + +## Findings + +### Columns + +| ID | Date | Severity | Axis | File:line | Issue | Fix | Resolution | +|---|---|---|---|---|---|---|---| +| F1 | 2026-04-23 | high | security | workspaces.py:get_workspace_usage | Initial member preset lacked `workspace:view_usage` → members would have gotten 403 despite matrix §4 granting them "View usage & overage". | Added `workspace:view_usage` to member preset; endpoint also explicitly rejects `is_external=true` (guest) callers, matching matrix §4 (members ✓ guest ✗). | Fixed in same commit. | +| F2 | 2026-04-23 | nit | security | workspaces.py:get_workspace_usage | Default tier fallback `or "pilot"` meant NULL-tier rows would silently activate Pilot hard-block. | Changed to `or ""` → falls through to the unknown-tier path (treated as unlimited / no block). Reviewed in commit. | Fixed in same commit. | +| F3 | 2026-04-23 | medium | security | workspaces.py:get_workspace_usage | A guest (`is_external=true`) with an elevated role would be counted only as guest, skipping seat billing. | State is blocked at invite + change-role write paths already. Added defensive `logger.warning` so ops can spot any drift. | Fixed in same commit. | +| F4 | 2026-04-23 | false-pos | security | workspaces.py:get_workspace_usage | Agent flagged `billing` role in seat counter as possibly wrong. | Matrix §7: "Seat = active workspace access. One seat per person per workspace, for members, admins, and billing." Billing counts as seat. Intentional. | N/A — keep. | +| F5 | 2026-04-23 | nit | security+copy | workspaces.py:set_workspace_tier + tier_downgraded.{html,txt} | Mental review only (no subagent dispatch — scope small). Auth guard unchanged. Subject via `_strip_header_unsafe`. Jinja autoescape covers template fields; all substitutions are admin-controlled strings. Copy: "dembrane" never written as "Dembrane", no bold, no "successfully/please/click here", Royal Blue accent for workspace name, Graphite text. | N/A — shipped as-is. | +| D1 | 2026-04-23 | — | dev-action | scripts/backfill_direct_memberships.py | Ran `--apply` on dev Directus per user-confirmed stop condition. Wrote 2 direct rows across 2 orgs (both seed "Default" workspaces). Re-run with `--dry-run` shows 0 proposals → idempotent + complete on dev. Prod run pending Q7 deployment thinking. | Applied on dev. | + diff --git a/echo/docs/workspaces-validate/06-VALIDATION-PLAN.md b/echo/docs/workspaces-validate/06-VALIDATION-PLAN.md new file mode 100644 index 00000000..0a4a9be1 --- /dev/null +++ b/echo/docs/workspaces-validate/06-VALIDATION-PLAN.md @@ -0,0 +1,146 @@ +# Validation plan — subagent reviewers + +Four axes, dispatched in parallel at three cadences. Findings land in `06-AUDIT-LEDGER.md`. Critical findings are stop conditions. + +## Axes + +### Security (cybersec) + +Scope — dispatch when any of these change: +- API endpoint (route, method, payload, permission guard) +- Policy definition or `TIER_REQUIRED_FOR_POLICY` map +- Directus collection permission +- Middleware (`get_workspace_context`, `user_can_access`) +- Migration script +- Environment variable that controls access + +What it checks: +- Auth bypass — can a session without the required role reach the endpoint? +- IDOR — swap a `:projectId` / `:workspaceId` / `:orgId` for one the caller shouldn't see +- Tier-gate bypass — does every gated surface pass `workspace_tier` into `has_policy()`? Does the Directus SDK path honor the same gates as the BFF path? +- Cross-tenant leakage on list endpoints — org A can't see org B's workspaces/projects +- Input validation — Jinja autoescape on all user-provided template inputs, URL scheme allowlist for logos, HMAC token replay + expiry, CR/LF strip on subject-like fields +- Rate limits on abuse-prone endpoints (upgrade-request, invite send, join-request) +- `deleted_at IS NULL` on reads; destructive paths set the timestamp +- Migration safety — dry-run default, lockfile for `--apply`, `script_start_iso` cutoff, corrupted-JSON tolerance +- **Participant portal never blocks** — recording / upload / transcription survive any tier state, including Pilot hard-block +- **Last-admin protection** — cannot demote self or be removed if last admin at workspace or organisation +- `staff:can_set_tier` narrower than `auth.is_admin` where matrix requires + +### Human-first design + +Scope — dispatch for: +- New flow spec (`flows/*.md`) +- New canonical screen spec (`screens/*.md`) +- Flow implementation at commit time + +What it checks: +- Matrix invariants present (recording is a participant act; tier gates never touch the participant portal; role + tier visible on every workspace card) +- Role change UX: toast + notification + first-visit banner; no affordance disappears mid-click +- State is URL-driven where shareable — tabs, filters, selected entity +- Confirmation gravity matches action: type-to-confirm for delete-workspace; plain confirm for role change; no confirm for idempotent settings +- Honesty disclosures present — private-workspace create shows "Organisation admins can still discover and join this workspace" +- Request/wait states exist for async actions — upgrade request, join request, handoff pending each render a "submitted, waiting" screen +- Member / Admin / Billing / Guest views are genuinely differentiated, not labelled +- Progressive solo experience — 1-workspace user doesn't see "workspaces" language +- Degradation: feature-locked surfaces render a placeholder, never mount the gated subtree (keyboard / pointer hygiene) + +### Brand + +Scope — dispatch for: +- Any UI change (`frontend/src/**/*.tsx`, CSS tokens) +- Email templates (product `server/email_templates/` and Directus `directus/templates/`) +- System-generated strings + +What it checks (from `brand/STYLE_GUIDE.md`): +- "dembrane" lowercase, always +- No bold — Royal Blue `#4169e1` or italics for emphasis +- Palette: Parchment `#f6f4f1` canvas, Graphite `#2d2d2c` text, Royal Blue primary action. System states Golden Pollen / Cotton Candy only for warning/error +- DM Sans with stylistic alternates ss01-ss06 +- Phosphor icons, regular weight, paired with labels where clarity matters +- `alwaysDembrane` prop on `DembraneLoadingSpinner` in whitelabel-safe contexts +- One clear action per card +- No `@mantine/charts`; no stock or AI-generated imagery +- Never two alerts stacked; either error or info, not both +- Email: brand logo header, typography scale, plain-text fallback + +### Copy + +Scope — dispatch for: +- Every user-facing string, error, toast, empty state +- Every email body (subject + preview text + body + signature) +- i18n `.po` files (all locales) + +What it checks: +- Vocabulary: "language model" not "AI"; "participants / hosts" not "users"; "partners / clients" not "customers"; "the platform" or "dembrane" not "the tool" +- Never "successfully", never "please", never "click here", never "in order to", never "we apologize" +- Labels above inputs; placeholder is not a label substitute +- Validation errors inline, close to the field +- Loading copy is active voice ("Analyzing…" not "Please wait while we process") +- Empty states welcome, don't scold ("No conversations yet. Start your first one.") +- Dutch uses je/jij/jou informal; keep English terms where they sound better (Dashboard / Upload / Chat) +- Show emails on hover only; don't display by default in lists +- No "new conversation" buttons in UI — conversations come from QR or upload +- Prefer text buttons over icon-only for important actions +- Matrix participant-reassurance line present on any hard-block copy + +## Cadences + +### Spec-time (fast, two axes) + +When: as `flows/*.md` and `screens/*.md` drafts land. +Dispatch: **brand + copy**, in parallel. +Turnaround target: under 2 minutes. +What I do with output: fix inline before the draft ships to the build queue. + +### Build-time (full, four axes) + +When: code for a blocker lands but before I commit. +Dispatch: **security + design + brand + copy**, all four parallel, one message. +Input scope: exact file diffs of the pending commit. +Turnaround: under 5 minutes; subagents return structured lists, not prose. +What I do with output: +- `critical` → fix before commit +- `high` → fix before commit unless explicitly deferred with reason +- `medium` → log in audit ledger + fix in same phase +- `nit` → log + +### Phase-boundary (cross-cutting) + +When: before pinging Sameer for a gate sync. +Dispatch: all four on the full changeset since last gate + the relevant flow specs. +Input: list of files changed + list of flows implemented in the phase. +Turnaround: under 10 minutes. +What I do with output: ledger + fix list before the sync message. + +## Output format expected from each subagent + +``` +Finding N +- Severity: critical | high | medium | nit +- Axis: security | design | brand | copy +- File: server/dembrane/api/v2/workspaces.py:627 +- Issue: +- Fix: +``` + +No prose, no preamble, no summary paragraphs. Bullet-style output so I can paste into the ledger. + +## Not-excessive discipline + +- Only dispatch axes relevant to the change. Backend policy edit: security only. +- Skip all axes for pure doc writes. +- Skip the security axis on code already cleared in the three prior audit rounds unless I modified it. The `audit-summary` and `fix-ledger` commits (`f2bfb2f`, `8aba15d`, `deb6597`, `4646825`) are the clearance baseline. +- Always pass explicit file-path scope. Never "audit the whole release." +- Never launch `/ultrareview` — user-triggered per CLAUDE.md. + +## Stop conditions (from brief) + +- `critical` security finding affecting access control → halt, log in `05-PROGRESS.md`, ask Sameer. +- Migration blast radius uncertain → halt at dry-run. +- Matrix invariant violated (tier gate reaches participant portal, last-admin protection bypassable) → halt. +- Brand or copy finding does **not** halt — it goes in the ledger and gets fixed in the same phase. + +## Ledger + +`06-AUDIT-LEDGER.md` — append-only. One entry per finding. Severity / axis / file / issue / fix / resolution-commit. So the next session can see what's already cleared and what's deferred. diff --git a/echo/docs/workspaces-validate/07-HCD-AUDIT.md b/echo/docs/workspaces-validate/07-HCD-AUDIT.md new file mode 100644 index 00000000..4785ddc3 --- /dev/null +++ b/echo/docs/workspaces-validate/07-HCD-AUDIT.md @@ -0,0 +1,126 @@ +# HCD audit — per-role walkthrough (2026-04-23) + +Four subagents dispatched in parallel, each playing two roles. Surfaces audited: workspace settings, organisation settings, organisation admin page, home selector, usage card, discovery, access requests, FeatureGate. + +**Roles covered:** Workspace Admin, Organisation Admin, Workspace Member, Organisation Member, Workspace Billing, Organisation Billing, Guest, Staff. + +Raw output archived in the agent-run logs. Synthesised concerns below. + +--- + +## Concerns clustered by theme + +### 1. Tier signalling — every role +- Tier appears as a bare lowercase badge ("pioneer") on workspace header, organisation drawer, and selector cards. +- Matrix §1 requires a tagline everywhere the tier name appears. +- Only `UsageCard` (settings page) pairs the tier with its tagline. Everywhere else violates contract. + +### 2. Engineer-speak leaks into user copy — Admin, Member, Guest +- "Your access" block renders raw policy strings (`member:manage`, `settings:manage`, `workspace:view_invoices`) as badges. +- Violates `STYLE_GUIDE.md` "user-facing copy, no technical strings" rule. + +### 3. Disabled-not-hidden controls for non-admins — Member, Billing, Guest +- Privacy & defaults block shows description + logo URL + access radio in a greyed state for members, billing, and guests. +- Users wonder "is this broken?" rather than "this isn't for me." + +### 4. Missing "Request upgrade" CTA on admin/billing surfaces — Admin, Billing +- Matrix §11 grants admin + billing the upgrade-request capability. +- UsageCard shows "Next tier" as text only — no CTA. +- No button anywhere else on the page either. + +### 5. No invoices / payment method / billing surfaces — Billing (both) +- Policies `workspace:view_invoices`, `workspace:update_payment` granted but zero UI. +- Invite modal description promises "Sees usage, invoices, and payment" — overselling. + +### 6. Organisation admin page misrepresents role model — Organisation Admin, Organisation Member +- Matrix cells collapse every role to "admin" or "—" — contract has four roles. +- Footer: "Access shown is derived from organisation role." Contradicts matrix §5 "Organisation-level access is direct-only. No derivation." +- Workspace column headers route to `/w/:id/projects` even when caller has no access → 403. +- "Guests" filter + count exposed at organisation level — matrix §5 says no organisation-level Guest. + +### 7. Organisation-level rollups absent — Organisation Admin, Organisation Billing +- Matrix §8 requires organisation-level usage rollup. +- Matrix §5 grants organisation-billing `view_usage + view_invoices + update_payment`. All missing UI. +- Organisation Billing on `/o/:organisationId` sees a people-matrix that's useless to them. + +### 8. "Owners" language where matrix says "admins" — Organisation Admin +- `OrganisationSettingsRoute.tsx` alert: "Only organisation admins and owners can change organisation settings. You're a billing." +- Matrix §5 role model has Admin / Billing / Member — no Owner at organisation level. +- Also ungrammatical ("You're a billing") for billing role. + +### 9. No "Leave workspace" action — Member, Guest +- Users can't remove themselves. No affordance. + +### 10. Guest identity disappears inside a workspace — Guest +- Card says "guest of {organisation}"; settings page says role "Member" (is_external=true mapping is internal). +- UsageCard, privacy, pending invites, "Your access" all render for guests even though they have no handle on any of them. +- Shows them "Guests: 3 / 10" — they're one of those guests. + +### 11. Private radio not tier-gated in UI — Admin +- Pioneer admin can click "Private" even though matrix §2 requires Innovator+. +- No inline hint; mutation would fail server-side with a confusing error. + +### 12. Staff surfaces have no distinct identity — Staff +- Tier PATCH endpoint exists, no inline UI control. +- Matrix §11 `staff:can_set_tier` policy placeholder but no surface. +- Staff-only controls (when added) risk looking like admin controls. + +### 13. Admin list not surfaced for members — Member, Organisation Member +- Matrix §11 "Ask a organisation admin" is abstract — no avatars, no names. + +### 14. Invite-as-guest path hidden — Admin +- `sendInvite(workspaceId, email, role, true)` — `is_org_member` hardcoded true. +- No UI path to invite an external collaborator even though backend supports it. + +### 15. Workspace column dead-clicks — Organisation Member +- Matrix cells link to routes that 403 for non-members. +- Discovery UI isn't reachable from the matrix — user has to navigate back to `/w`. + +--- + +## Converged fix plan + +Two tiers. Tier 1 is pattern-level + universal copy. Tier 2 is action affordances. Tier 3 is new surfaces (deferred). + +### Tier 1 — Shipping now (pattern + copy) + +| # | Fix | Root concern | +|---|---|---| +| A | Shared `TierBadge` component: `{tier}` + tagline everywhere the tier appears | 1 | +| B | Drop raw policy badges from "Your access"; replace with human sentences (or remove) | 2 | +| C | Hide Privacy & defaults when `!canEdit` (member / billing / guest see nothing instead of disabled fields) | 3, 10 | +| D | OrganisationSettings alert: "owners" → "organisation admins"; role fallback copy ("You're a billing") → gracefully labelled | 8 | +| E | OrganisationRoute: drop "derived from organisation role" footer, replace with matrix §5/§6 wording | 6 | +| F | OrganisationRoute: drop organisation-level "Guests" filter + count — matrix §5 has no organisation Guest | 6, 10 | + +### Tier 2 — Shipping next commit (action affordances) + +| # | Fix | Root concern | +|---|---|---| +| G | UsageCard: Admin + Billing see a "Request upgrade" primary button next to the "Next tier" line | 4 | +| H | "Your access": Member + Guest see a "Leave workspace" action | 9 | +| I | Guest persistent "Guest of {organisation}" chip in workspace header + hide UsageCard for guests | 10 | + +### Tier 3 — Deferred / bigger surfaces + +| # | Fix | Deferred because | +|---|---|---| +| J | Tier-gate the Private radio in WorkspaceSettingsRoute | Small — bundled with wizard rework when it lands | +| K | Organisation-level usage + billing rollup | Bigger surface; pairs with OrganisationRoute 3-view (S7) | +| L | Invoices / payment UI | No backend; matrix explicitly defers self-serve billing to v2 | +| M | Staff "global workspaces" view | Matrix explicitly defers staff audit log UI | +| N | Workspace-column click routing on organisation matrix | Pairs with S7 organisation admin 3-view rework | +| O | Invite-as-guest path | Pairs with the invite flow rework when wizard lands | +| P | Admin-chips "Ask these admins" for members | Pairs with deeper organisation directory rework | +| Q | Silent-rejection UX for members | Matrix locks silent rejection; can add pending-TTL as post-release | + +Tier 3 items all land as follow-up sessions. Tier 1 + 2 account for ~80% of the user-visible concerns and can ship in two focused commits today. + +--- + +## Decisions locked in this pass + +- **"Your access" raw policy badges are removed** — simpler than writing a translation dict. Role badge alone suffices. Power-users who want detail can read the matrix. +- **Guests do not see UsageCard** at all — matrix §4 "View usage" row is ✗ for Guest. Matches contract. +- **Organisation-level Guest concept retires from UI** — not from `is_external` data (brief anti-goal). Just hide it in filters/counts. +- **Leave workspace** soft-deletes the caller's direct `workspace_membership`. Existing `DELETE /v2/workspaces/:id/members/:membership_id` should work with the caller's own membership_id, but last-admin protection applies. diff --git a/echo/docs/workspaces-validate/flows/derivation-walkback.md b/echo/docs/workspaces-validate/flows/derivation-walkback.md new file mode 100644 index 00000000..f6de3414 --- /dev/null +++ b/echo/docs/workspaces-validate/flows/derivation-walkback.md @@ -0,0 +1,185 @@ +# Flow 0 — Derivation walkback (backend) + +**Priority:** blocker. Prerequisite for flows 1 (upgrade-request reassurance), 4 (home-per-organisation discovery), 6 (invite-and-join). Not a UI flow — a schema + resolver simplification. + +**Matrix reference:** §5 "Organisation-level access is direct-only. No derivation." §6 "Slack-style discovery." + +**Code reference:** `server/dembrane/inheritance.py` (full derivation resolver to retire), `docs/workspaces/inheritance-rules.md` (spec to archive after this flow lands). + +--- + +## What changes + +**Before (today):** +- Access to a workspace is computed at read time via `user_can_access()` walking `org_membership` + `workspace.settings.inherit_organisation_admins / inherit_organisation_members / sticky_removed`. +- Organisation admins auto-have admin role on every open workspace in their organisation. +- Organisation owners auto-have admin role on every workspace (private included). +- Removing a derived user writes a `sticky_removed` tombstone. + +**After:** +- Single `workspace.visibility` enum: `open_to_organisation | private`. +- Access is stored-direct-only. `workspace_membership` is the source of truth; `user_can_access()` does a single row lookup. +- Organisation admins see all organisation workspaces in discovery. Organisation members see `open_to_organisation` only. +- Joining is always an explicit action → writes `source='direct', role='admin'` (admin click) or `source='direct', role='member'` (member request + admin approval). +- `sticky_removed` retires. Rejoins are normal explicit actions. + +--- + +## Sequence (land in this order) + +### 1. Schema migration (scripts/create_schema.py) + +Idempotent Python additions: +- `workspace.visibility` enum column: `open_to_organisation | private`, nullable initially, will flip to required after backfill. +- `access_request` collection: `id, workspace_id, user_id, status, requested_at, actioned_at, actioned_by, deleted_at`. + +Directus data migration (single SQL via REST API): +- `UPDATE workspace SET visibility = CASE WHEN (settings->>'inherit_organisation_admins')::bool IS NOT FALSE THEN 'open_to_organisation' ELSE 'private' END WHERE visibility IS NULL AND deleted_at IS NULL`. + +### 2. Backfill script — the stop condition + +**File:** `scripts/backfill_direct_memberships.py`. Dry-run default; `--apply` requires explicit flag. + +Algorithm: +``` +for each active workspace W: + effective = inheritance.get_effective_members(W.id) # current derivation-aware + for each row in effective where row.source == 'inherited': + if direct row exists for (W.id, row.user_id) with deleted_at IS NULL: + skip (direct always won; no-op) + else: + propose INSERT workspace_membership ( + workspace_id = W.id, + user_id = row.user_id, + role = row.role, # 'admin' or 'member' per derivation + source = 'direct', + is_external = False + ) +``` + +Output (dry-run): +- Per-org summary: org_name, workspace_count, users_affected, rows_to_insert. +- Per-workspace breakdown: ws_name, visibility, direct_rows_today, rows_to_insert. +- Grand total row count. +- CSV export for manual review. + +Stop condition per brief: **before `--apply`, paste the row-count summary in `04-QUESTIONS-FOR-SAMEER.md` and wait for explicit confirmation.** Never auto-apply. + +Safeguards: +- Per-host lockfile pattern from `migrate_inherited_to_derived.py` (idempotent, prevents concurrent apply). +- `script_start_iso` cutoff — only consider workspaces + org_memberships created before the script started, so re-runs don't re-insert for rows added mid-run. +- Skip rows for users in `sticky_removed` — they were explicitly kicked; they get zero rows. Matches matrix §6 "sticky removal is retired" — the tombstone history stays buried, affected users just don't auto-rejoin. + +### 3. Resolver simplification (`server/dembrane/inheritance.py`) + +Shrinks to: +- `user_can_access(workspace_id, user_id)` — single `_get_direct_membership` lookup; returns `(role, 'direct') | None`. Deleted the entire derivation branch. +- `get_effective_members(workspace_id)` — drops the `org_rows` fan-in; returns direct rows only. +- `get_user_project_access` — unchanged except `source` is always `'direct' | 'project_share' | 'legacy'`, never `'inherited'`. + +Deletes: +- `workspace_follows_organisation_admins`, `workspace_follows_organisation_members`, `is_sticky_removed`. +- `sticky_remove`, `sticky_unremove`. +- Organisation-owner carve-out (no longer needed — owners write a direct row via migration, not implicit derivation). + +Keeps (simplified): +- `on_workspace_created` — still writes creator as direct owner. No settings flags written (visibility is a column). +- `on_organisation_member_removed` — still soft-deletes the user's direct rows across the organisation's workspaces. Matches Slack "kicked from Slack → out of every channel." If product disagrees, separate decision. + +### 4. Settings purge + +Once the resolver no longer reads them, strip from `workspace.settings` JSON: +- `inherit_organisation_admins` +- `inherit_organisation_members` +- `sticky_removed` + +Single UPDATE via Directus, plus future-workspace `on_workspace_created` no longer writes these keys. + +Tombstones that exist today are naturally orphaned — the backfill already decided their fate (no row = no access; the user can rejoin via the explicit discovery path if they're discoverable again). + +### 5. New endpoints (unlock flows 4 + 6) + +**`POST /v2/workspaces/:id/join`** — organisation admin self-join. +- Guard: caller is `org_membership` admin/owner in this workspace's org. No workspace-level policy (you can't require a policy you don't have yet). +- Rate-limited (5/hr/user). +- Idempotent: 409 if direct row already exists. +- Writes `workspace_membership (role='admin', source='direct')`. +- Emits `WORKSPACE_ADDED` to joiner; no broadcast. + +**`POST /v2/workspaces/:id/access-requests`** — organisation member request-to-join, open workspaces only. +- Guard: caller is `org_membership` member in org; workspace is `visibility='open_to_organisation'`. +- Idempotent: 409 if pending request exists. +- Writes `access_request (status='pending')`. +- Emits `MEMBERSHIP_REQUESTED` to audience = workspace admins + organisation admins. + +**`GET /v2/workspaces/:id/access-requests`** — admin view. +- Guard: `member:manage` policy on workspace OR organisation admin. +- Returns pending requests. + +**`POST /v2/workspaces/:id/access-requests/:req_id/approve`** +- Writes `workspace_membership (role='member', source='direct')`. +- Marks request `status='approved', actioned_at, actioned_by`. +- Emits `MEMBERSHIP_REQUEST_APPROVED` to requester + `WORKSPACE_ADDED`. + +**`POST /v2/workspaces/:id/access-requests/:req_id/reject`** +- Marks request `status='rejected'`. +- **No notification to requester** per matrix §6 "Rejection is silent from the member's perspective." + +Additional: +- `PATCH /v2/workspaces/:id/visibility` — admin-only, flips `workspace.visibility`. Gated by `workspace:set_private` (innovator+ for `private`). + +### 6. Notification emits + +Add event codes to `_SEVERITY_BY_EVENT` in `notifications.py`: +- `MEMBERSHIP_REQUESTED` → severity `action_required`. +- `MEMBERSHIP_REQUEST_APPROVED` → severity `info`. +- `MEMBERSHIP_REQUEST_REJECTED` → **not emitted** (silent rejection). + +### 7. Surface changes (UI flows downstream — not this flow) + +Listed here for dependency clarity: +- Home page (`flows/home-per-organisation.md`): discovery section with Join / Request-access CTAs. +- Workspace settings → Members tab: pending access requests list, approve/reject buttons. +- Workspace create wizard (`flows/workspace-creation.md`): visibility radio + honesty disclosure on Private. + +--- + +## Invariants (post-walkback) + +1. `SELECT count(*) FROM workspace_membership WHERE source='inherited' AND deleted_at IS NULL` = 0. +2. `user_can_access` never reads `org_membership` or `workspace.settings`. +3. `workspace.settings` contains no `inherit_organisation_admins | inherit_organisation_members | sticky_removed` keys for any active row. +4. Organisation admin promotion/demotion does NOT change workspace access — workspace rows are explicit. +5. External user (is_external=true) is always direct; derivation never produced externals anyway. Post-walkback, externals can be freely invited without any reconciliation logic. +6. Last-admin protection holds — `DELETE /v2/workspaces/:id/members/:uid` refuses if the target is the last `role='admin'` row with `deleted_at IS NULL`. + +--- + +## Tests that must pass before apply + +1. Dry-run against a seeded test DB — verify: + - Every derivation-only member shows up in the proposal. + - Every user with both a direct row and a derived path is skipped. + - Every sticky-removed user is skipped. +2. Apply to a scratch DB — verify `get_effective_members()` output is identical before and after the simplification (same user set, same roles). +3. Smoke test all three role paths after simplification: admin, member, guest. +4. Participant portal access unchanged — walkback does not touch participant auth at all. + +--- + +## Subagents to dispatch after build + +Per `06-VALIDATION-PLAN.md`, at build-time for this flow: +- **Security** — scoped to `inheritance.py` diff, new endpoints, backfill script. Check for: access-check bypass via missing direct row, migration idempotency, lockfile hygiene, join endpoint abuse (rate-limit + guard), access-request cross-workspace leakage. +- **Human-first** — scoped to the spec itself + eventually the flow UIs that consume this. Check: participant portal never touched, last-admin protection documented, silent rejection is honest UX (matrix requires it). + +Copy + brand: N/A — backend-only flow until the UI flows consume it. + +--- + +## Out of scope for this flow + +- UI for join / request-access / approve-reject (covered in flows 4 + 6 + 9). +- `access_request` model deeper fields (reason text, expiry) — not in matrix; add post-release if we see abuse. +- Bulk re-join (former employees returning to a organisation) — out of scope; they rejoin per workspace. +- Cross-organisation workspace discovery — matrix §6 restricts discovery to organisation scope; don't widen. diff --git a/echo/docs/workspaces-validate/matrix.md b/echo/docs/workspaces-validate/matrix.md new file mode 100644 index 00000000..d4c8d27c --- /dev/null +++ b/echo/docs/workspaces-validate/matrix.md @@ -0,0 +1,347 @@ +# dembrane workspaces — capacity, permissions, migration + +Single source of truth for the workspaces + organisations + tiers release. +Engineering builds from this. Design references it. Sales quotes from it. + +Version 1.1 · Slack-style discovery model (replaces derivation); workshop pass 2026-04-23. + +--- + +## 1. Tier × capacity matrix + +| | Pilot | Pioneer | Innovator | Changemaker | Guardian | +|---|---|---|---|---|---| +| **Price** | €349 one-time | €200/mo | €500/mo | €1500/mo | €5000/mo | +| **Duration** | 1 month | ongoing | ongoing | ongoing | ongoing | +| **Included seats** | 2 | 3 | 10 | 20 | unlimited* | +| **Seat overage** | — | €25/seat | €30/seat | €60/seat | — | +| **Included hours** | 10 | 25 | 50 | 100 | unlimited* | +| **Hour overage** | **hard block** | €5/hr | €4/hr | €3/hr | — | +| **Guest cap** | 2 | 5 | 20 | 50 | unlimited | +| **Training included** | 2 people | — | — | — | negotiable | + +*Guardian unlimited subject to trained technical personnel and adequate AI infrastructure.* + +Tier names ship as-is (Pilot / Pioneer / Innovator / Changemaker / Guardian). Every surface that shows a tier name in the product must pair it with a short descriptive tagline — the aspirational names aren't self-explanatory. Examples: "Pilot — one month to try it." "Pioneer — for your first real engagements." "Innovator — privacy and data portability." "Changemaker — your brand, your integrations." "Guardian — enterprise scale." + +The tier capacity matrix (this section) must be visible inside the product at minimum on the workspace billing tab and in the upgrade-request modal. Customers should never have to leave the app to understand what each tier gets them. + +## 2. Tier × feature matrix + +| | Pilot | Pioneer | Innovator | Changemaker | Guardian | +|---|---|---|---|---|---| +| Projects, conversations, chat, reports | ✓ | ✓ | ✓ | ✓ | ✓ | +| Agentic chat | ✓ | ✓ | ✓ | ✓ | ✓ | +| Library / analysis views | invite-gated | invite-gated | invite-gated | invite-gated | invite-gated | +| All 7 languages | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Private projects** | ✗ | ✗ | ✓ | ✓ | ✓ | +| **Private workspaces** | ✗ | ✗ | ✓ | ✓ | ✓ | +| **Data export** | ✗ | ✗ | ✓ | ✓ | ✓ | +| **Whitelabel (custom logo)** | ✗ | ✗ | ✗ | ✓ | ✓ | +| **API access** | ✗ | ✗ | ✗ | ✓ | ✓ | +| **Webhooks** | ✗ | ✗ | ✗ | ✓ | ✓ | +| Support | email | email | dedicated | dedicated | account owner | +| EU hosted, GDPR, ISO 27001 | ✓ | ✓ | ✓ | ✓ | ✓ | + +## 3. Downgrade behavior (per feature) + +Default rule: **freeze, don't revert.** One exception: whitelabel. + +| Feature | Behavior on downgrade | +|---|---| +| Private projects | Existing stay private. Cannot create new private projects. | +| Private project sharing | Existing shares keep working. Cannot add new shares. | +| Private workspaces | Existing stay private. Cannot create new private workspaces. | +| Data export | Already-downloaded exports unaffected. New exports blocked. | +| API tokens | Existing tokens keep working. No new tokens, no rotation. | +| Webhooks | Existing webhooks keep firing. No new webhook configs. | +| **Whitelabel** | **Reverts.** Custom logo cleared, dembrane wordmark restored. Warn on downgrade dialog. | + +**Downgrade comms (required across every downgrade, whether freeze or revert):** + +1. **Confirmation dialog** before the downgrade is executed. Lists every feature that will freeze and every feature that will revert, with plain-language impact ("your custom logo will be removed", "you won't be able to add new private project shares"). Admin or staff must acknowledge explicitly. +2. **In-workspace banner for 7 days post-downgrade.** "This workspace was downgraded to [Tier] on [date]. Some features are limited. [Learn more]" The banner is dismissible but auto-returns if the admin tries a frozen feature. +3. **Post-downgrade email** to every admin + billing-role user on the workspace. Summarizes what changed, what's now limited, and what remains available. Sent within 1 minute of the downgrade. + +Clarity is the job. Don't try to soften freeze-vs-revert with vague copy — users need to know exactly what they can and can't do. + +## 4. Role × capability matrix + +Four roles. Apply at workspace level. Organisation-level admin/billing mirror workspace-level for organisation operations. + +| Capability | Admin | Billing | Member | Guest | +|---|:---:|:---:|:---:|:---:| +| **Projects & content** | | | | | +| Create projects | ✓ | | ✓ | | +| Edit own projects | ✓ | | ✓ | ✓¹ | +| Edit any project in workspace | ✓ | | ✓ | | +| Delete projects | ✓ | | | | +| Move projects across workspaces | ✓ | | | | +| Make projects private (tier-gated) | ✓ | | | | +| Share private projects (tier-gated) | ✓ | | | | +| **Conversations** | | | | | +| Record, upload, edit conversations | ✓ | | ✓ | ✓¹ | +| Delete conversations | ✓ | | ✓ | | +| **Analysis** | | | | | +| Run chat / agentic | ✓ | | ✓ | ✓¹ | +| Generate reports | ✓ | | ✓ | ✓¹ | +| Publish reports | ✓ | | ✓ | | +| **Workspace management** | | | | | +| Invite members | ✓ | | | | +| Invite guests | ✓ | | | | +| Change member roles | ✓ | | | | +| Remove members | ✓ | | | | +| Change workspace settings (name, visibility, branding) | ✓ | | | | +| View usage & overage | ✓ | ✓ | ✓ | | +| **Billing** | | | | | +| See invoices | ✓ | ✓ | | | +| Update payment method | | ✓ | | | +| Request tier upgrade | ✓ | ✓ | | | +| **Destructive** | | | | | +| Delete workspace | ✓² | | | | +| Transfer workspace (between organisations) | staff only | | | | + +¹ Guest permissions are identical to member within the workspaces they're invited to. Difference is they have no organisation-level presence and are tier-capped, not billed. + +² Delete workspace requires confirmation. Last admin cannot demote self or be removed. + +## 5. Organisation-level roles + +Same names as workspace roles: **Admin / Billing / Member** (no organisation-level Guest — guests exist only at the workspace level). Scope disambiguated by UI context ("Organisation admin" vs "Workspace admin"). + +| Capability | Organisation admin | Organisation billing | Organisation member | +|---|:---:|:---:|:---:| +| Invite people to the organisation | ✓ | | | +| Create workspaces | ✓ | | | +| See every workspace in organisation (open + private) | ✓ | ✓ | Open only | +| Join any organisation workspace explicitly | ✓ (becomes Admin) | — | Request access to open WS only | +| View organisation-level usage rollup | ✓ | ✓ | Raw numbers only (no €) | +| Change organisation settings | ✓ | | | +| Delete organisation | ✓² | | | + +**Organisation-level access is direct-only. No derivation.** Being a organisation admin does not automatically make you an admin on every workspace — it just lets you *discover and join* them. Every join is an explicit action and writes a `source='direct'` membership row. Last-admin protection applies at both the organisation level and the workspace level. + +## 6. Workspace visibility & discovery (Slack-style) + +Every workspace has a `visibility` field: `open_to_organisation` | `private`. + +- `open_to_organisation` — **UI label: "Open to organisation"**. Visible in discovery to all organisation members and organisation admins. +- `private` — **UI label: "Private"** (tier-gated: innovator+). Visible in discovery only to organisation admins. Completely invisible to organisation members. + +**Who sees what in organisation discovery:** + +| Viewer | Open workspace | Private workspace | Default action | +|---|---|---|---| +| Organisation admin | ✓ visible | ✓ visible | "Join" button — auto-grants Admin role | +| Organisation billing | ✓ visible | ✓ visible | View-only (usage); cannot join unless explicitly added | +| Organisation member | ✓ visible | ✗ hidden | "Request access" on open WS — approval writes Member row | +| Guest | Only WS they were invited to | Only WS they were invited to | No discovery — no organisation-level visibility | + +**Honesty disclosure when creating private workspaces:** the create-workspace flow must show the creator a clear line: "Organisation admins can still discover and join this workspace." Private protects from organisation members, not from organisation admins. Don't hide this. + +**Request-to-join approval (open workspaces only, members only):** + +- A organisation member clicks "Request access" on an open workspace. +- Notification fires to every workspace admin on that workspace AND every organisation admin. Either can approve. +- On approval, the member gets a `source='direct'` Member row on the workspace. +- Rejection is silent from the member's perspective (no explanation surfaced). + +**Organisation admin joining a workspace:** + +- Organisation admin clicks "Join" on any workspace (open or private). +- Immediately gets a `source='direct'` Admin row. No approval gate. +- They can leave the workspace at any time — like Slack, joining and leaving are explicit and reversible. + +**Sticky removal is retired.** If a person is removed from a workspace and later rejoins the organisation or discovers the workspace again, they rejoin normally by explicit action. No tombstones. + +**Default for a new workspace:** `open_to_organisation`. Private is a deliberate, tier-gated choice. + +## 7. Seats & billing + +**Seat = active workspace access.** One seat per person per workspace, for members, admins, and billing. + +- Same person in 3 workspaces = 3 seats. +- Guests are not billed but count against tier's guest cap. +- Organisation membership alone is not billable — only workspace access is. + +## 8. Hours & usage + +**Hour = one hour of recording** (live OR uploaded — same meter). + +- Counted per workspace. +- Reset on the **calendar month boundary** (first day of the month, workspace-local to the subscription record). +- Overage: billed at tier rate (Pioneer €5, Innovator €4, Changemaker €3). +- Pilot: **hard block on host-side operations** at 10 hours (see below). Upgrade required to continue working with the data. +- No other tier hard-blocks — Pioneer and above bill overage and keep going. + +**Hard block at Pilot limit — host-side only.** The participant portal never blocks. Recording keeps working. Audio continues uploading and transcribing. What blocks is host-side operations: chat / agentic analysis, viewing transcripts, generating or updating reports, exporting data, creating new projects. The upgrade screen must explicitly reassure: "Recording keeps working — your participants are unaffected." + +**Usage rollups shown at three levels:** + +1. **Project level** — hours consumed by conversations in this project, current cycle. +2. **Workspace level** — total hours + seat count + guest count, current cycle, per-project breakdown, overage warnings. +3. **Organisation level** — rollup across all workspaces in the organisation. Shows which workspaces are over, which tier each is on, aggregate spend. + +**Visibility by role:** + +- **Members** see raw usage numbers (hours, seats, projects) at every level. They do not see euro amounts or overage cost forecasts. +- **Admins and Billing** see everything members see, plus billing implications: euro overage forecasts, projected monthly cost, tier recommendations. + +Member transparency on raw numbers is intentional — members should be able to gauge their own contribution to quota consumption without having to ask an admin. + +## 9. New workspace defaults + +- **Tier:** Pilot. +- **Visibility:** `open_to_organisation`. +- Admin on create: the creator gets a `source='direct'` Admin row. No other rows are written at creation time. Other organisation members and admins discover the workspace through the Slack-style discovery model (Section 6). +- Tier upgrade requires explicit request (admin or billing → staff). +- **Exception:** seeded workspaces (migration, internal demo, staff-created special cases) bypass this — tier set at creation by staff per M1. + +## 10. Partner-client model + +**Subscription ownership during engagement:** +- Partner creates the workspace and pays the subscription. +- Workspace billing attribution: `billed_to_team_id` (partner) + `effective_client_team_id` (nullable, filled post-handoff). + +**Handoff:** +- Clean subscription transfer. Partner initiates → client accepts → billing attribution flips. +- Workspace stays at its current tier. No re-tiering on transfer. +- Partner retains no operational access unless explicitly retained as guest. + +**Referral kickback:** +- 20% of the workspace's monthly tier cost, paid monthly to the partner. +- Tracked per-workspace in `referral_ledger` table. +- Each ledger entry has optional `expires_at` — no global default. Expiry set per deal or globally later. +- Partner can offer additional discount to client (funded from partner's share or at partner's cost — their call). + +**Ledger fields (at minimum):** +``` +referral_ledger + id + workspace_id + partner_team_id + partner_kickback_percent (default 20) + starts_at + expires_at (nullable) + notes + created_by_staff_id +``` + +## 11. Upgrade flow + +- **Requesters:** admin or billing on a workspace. +- **Executors:** staff with the `staff:can_set_tier` policy. This is a new policy, narrower than Directus Administrator — not every Directus admin has pricing authority. Add to policies.py alongside existing staff policies. +- **Mechanism:** `POST /v2/workspaces/:id/upgrade-request` → email to upgrade inbox. +- **Upgrade inbox:** `upgrades@dembrane.com`. Configured via `UPGRADE_REQUEST_INBOX` env var. Set up before cutover. +- **The upgrade-request modal must show the full tier capacity matrix** (Section 1). Customers should never have to guess what a tier includes when deciding to upgrade. +- **Members see:** "Ask one of your organisation admins to upgrade." No CTA, no mailto, no admin list. The friction is the gate, not a missing button. + +--- + +# Migration strategy + +Applies to existing customers, existing projects, and existing partner relationships at cutover. + +## M1. Tier mapping for current customers + +Map each current customer to a tier at cutover. Criteria: + +| Current customer profile | Map to | +|---|---| +| Active engagement, has private projects or exports in use | Innovator (seeded, not Pilot) | +| Active engagement, uses whitelabel or API | Changemaker (seeded) | +| Enterprise commitment in place | Guardian (seeded) | +| Pilot / evaluation / unclear status | Pioneer (seeded, NOT Pilot — avoid hour block on existing users) | +| Inactive >90 days | Pioneer (seeded), flag for churn review | + +**Rule:** existing customers never get dropped onto Pilot by the migration. Pilot is new-customer-only. Pioneer is the safe floor. + +Build a CSV export tool (internal only): +- Customer name, current usage (last 90 days hours + seats), current feature usage (private/whitelabel/API/export), proposed tier, notes field for staff override. +- Review manually before cutover. No automated tier assignment ships without human sign-off per customer. + +## M2. Organisation mapping + +Every current customer account becomes a organisation at cutover. + +- Existing users → organisation admins (their current account is effectively admin today). +- Each customer's projects → grouped into one workspace within that organisation by default. +- If a customer clearly has multiple distinct engagements (different clients, different contexts), staff can pre-split into multiple workspaces before cutover. +- Organisation name defaults to customer/org name; user can rename. + +Edge case: shared accounts (multiple people using one login). Flag in the CSV. Contact directly — need to split into real accounts before workspace migration or they'll fight over admin seat. + +## M3. Partner relationships + +For each current partner: + +1. **Identify partner-client relationships** already in play (consultancies running client engagements on dembrane). +2. **Onboard the partner to the new model:** + - 1:1 call to explain workspaces, tiers, subscription ownership. + - Educate on kickback mechanics, financial planning implications (monthly recurring, capped at 20%, optional expiry). + - Agree which existing projects belong to which client — create the workspace topology with them. +3. **Seed the ledger** with current active partner-client pairings. `starts_at` = migration cutover date. `expires_at` = null unless partner negotiates otherwise. +4. **Handoff plan per workspace** — if the client is ready to take over billing, schedule the transfer. Otherwise partner continues. + +## M4. Floodgate strategy + +Release is not a silent deploy. Staged rollout prevents the "everyone hit with Pilot 10-hour block on Tuesday morning" scenario. + +**Phase 0 (pre-cutover, -7 days):** +- Staff reviews every customer in the internal CSV tool. +- Tiers seeded per M1. Organisations and workspaces created per M2. +- Partner calls completed per M3. +- Nothing user-visible changes. + +**Phase 1 (cutover day):** +- New UI ships. Users see organisations, workspaces, tier indicator. +- All existing customers are on their seeded tier (Pioneer or higher). **No one is on Pilot.** +- In-app announcement: "Workspaces are here. Here's what's new." Links to short doc. + +**Phase 2 (cutover +7 days):** +- Monitor: usage, support tickets, upgrade requests, any hour-block hits (should be zero — existing customers are Pioneer minimum, which has €5/hr overage, not block). +- Follow-up calls scheduled for customers flagged in CSV as "tier uncertain." + +**Phase 3 (cutover +14 days onwards):** +- New signups route through Pilot by default. +- Pilot hour-block hits WILL happen — this is expected and desirable, it's the upsell moment. +- Upgrade screens shown on block → sales call booking link → staff responds within 24h. + +**Internal tooling needed before cutover:** +- CSV export of all customers with current usage + proposed tier. +- Directus view: workspaces approaching hour limit, current usage, tier. +- Directus view: pending upgrade requests with SLA timer. +- Flag column in customer CSV: "tier uncertain — needs review." + +## M5. Comms + +- **Email to all existing customers** 3 days before cutover. Plain language. "We're adding organisation collaboration. Nothing you do today changes. Here's what's new for you." Link to doc + video. +- **In-app banner** on cutover day for 7 days. +- **Partner-specific email** separately, with their kickback terms attached. + +## M6. Rollback plan + +If cutover goes sideways: +- Workspaces feature can be hidden behind a feature flag per organisation. +- Underlying data model is additive — no destructive migration. +- Rollback = flip the flag, users see the old UI, data remains in new structure, no loss. + +--- + +## Open items (not blocking cutover) + +- [ ] Exact guest caps for Pilot (currently 2) — revisit after first 10 Pilot signups. +- [ ] Self-serve billing (v2 — staff-executed upgrades are fine for v1). +- [ ] Decision on whether Guardian's "unlimited" has soft internal caps for capacity planning. +- [ ] Library / analysis views — stays invite-gated for now; revisit once usage patterns are clearer. +- [ ] **v3**: organisation + workspace admins can mandate MFA for their members. Setting stored on `org` / `workspace` with a grace window; members without MFA get a forced-setup screen on next login and can't use the workspace until it's configured. Enterprise ask — not for cutover. + +## Locked decisions reference + +This document reflects decisions from: +1. Resolution doc (Q1–Q8 answered) + role model (Admin, Billing, Member, Guest — no Owner, no Viewer). +2. Pricing strategy doc (Pilot → Guardian, hours + seats primitives). +3. First follow-up pass: referral ledger with optional expiry, usage rollups at every level, migration strategy. +4. **Workshop pass 2026-04-23:** Slack-style workspace discovery replacing derived inheritance; sticky removal dropped; last-admin protection (block); cross-organisation admin allowed freely; downgrade comms pattern (dialog + 7-day banner + email); member raw-usage visibility; calendar-month quota reset; `upgrades@dembrane.com` inbox; new `staff:can_set_tier` policy; seats = direct members only (guests excluded); tier names ship as-is with taglines + visible capacity matrix in product; partner full-visibility on owned workspaces pre-handoff. + +Changes to this document require sign-off from Sameer + Jorim. \ No newline at end of file diff --git a/echo/docs/workspaces-validate/note.md b/echo/docs/workspaces-validate/note.md new file mode 100644 index 00000000..8237d3e7 --- /dev/null +++ b/echo/docs/workspaces-validate/note.md @@ -0,0 +1,28 @@ +Missing a top level breadcrumb + +@ + +
+ + PENDING +
+ in WorkspaceSettingsRoute (at /src/routes/workspaces/WorkspaceSettingsRoute.tsx) + in /src/components/error/ErrorBoundary.tsx + in LazyRoute (at /src/components/common/LazyRoute.tsx) + +// These badges are not WCAG compliant i think. Try full color pill + graphite text + +Organisation seats vs workspace seats need to be figured out +- Wrong: Pick members to bring into this workspace. They'll keep their organisation seat — no extra cost. + +In general the grey text is hard to read + ++ New organisation workspace = + New organisation +Remove: tap to see what's included + +Entitiy that holds billing for multiple workspaces = Organisation +Organisation -> Organisation + +ws manage from /w = guest? + +b1 lang (cc tip) - hr overage - pay as u go \ No newline at end of file diff --git a/echo/docs/workspaces-validate/qa/_overnight-summary.md b/echo/docs/workspaces-validate/qa/_overnight-summary.md new file mode 100644 index 00000000..ea8ae02c --- /dev/null +++ b/echo/docs/workspaces-validate/qa/_overnight-summary.md @@ -0,0 +1,76 @@ +# Overnight QA summary — 2026-04-23 + +Scope: ran through the Workspaces validation plan using seed users (Anna, Ben, Cara, Dan, Emma, Finn, Grace, Hank) after Sameer said "keep going." One session. Everything below links to evidence files in this directory. + +## Modules walked (each with findings) + +| # | Module | Status | Key evidence | +|---|---|---|---| +| 1 | Onboarding — solo register → email verify → first workspace | ✅ walked end-to-end earlier in the day as `solo1`; the hard blocker at `/api/v2/onboarding/complete` is captured in [brief-01-onboarding-fixes.md](brief-01-onboarding-fixes.md) | shots 00–06 | +| 2 | Invitations & access — admin invites all roles | ✅ verified — invites for registered users auto-add, unregistered go to pending | shot 12 | +| 3 | Role changes — promote / demote / remove + last-admin protection | ✅ verified backend rules all hold | shot 15 | +| 4 | Tier gates — pilot-only features, request-upgrade flow | ✅ verified on Access + Usage and Tier tabs; upgrade dialog works | shots 16, 17 | +| 5 | Workspace settings — every tab | ✅ walked General / Members / Access / Usage and Tier / Danger | shots 11, 12, 16, 17, 18 | +| 6 | Organisation page — Overview, Usage, People | ✅ walked all 3 tabs; Needs Attention panel surfaces all 3 seed-data cap/downgrade conditions correctly | shots 19, 20 | +| 7 | Billing role — login as Emma | ✅ verified what billing sees vs hides; 2 fresh pains | shots 23, 24, 25 | +| 8 | Projects — private project + sharing modal | ✅ walked end-to-end on "Brand Rollout" (Whitelabel Project, Innovator tier) | shots 21, 22 | +| 9 | Multi-organisation — two organisations, grouping, switching | ✅ verified via Hank (owner Alpha Inc + member of Partner Consulting) and Emma (owner Partner Consulting + member Acme Research) | shot 25 | +| 10 | Pilot hour block | ⏸ deferred — needs a dev shortcut to burn hours fast | +| 11 | Participant portal | ⏸ deferred — QR/device flow | +| 12 | Onboarding redo with fresh user | ⏸ deferred — email verification needs Sameer to paste the link | + +## Blockers (read first) + +All the session-blocking items from the first hour are captured in [brief-01-onboarding-fixes.md](brief-01-onboarding-fixes.md) — that brief is self-contained and meant for a fresh Claude Code to execute. Until it lands, `solo1` stays stuck. + +## Top new findings tonight (from [pains.md](pains.md)) + +These are the ones worth triaging first — they hit flows that are supposed to be the happy path: + +1. **`[hurt?]` Inviting an existing user auto-adds them silently — no accept, no consent, no notification to the invitee.** Cara ends up in Anna's workspace with zero signal. Either switch to an accept step for registered users, or fire a `WORKSPACE_MEMBER_ADDED` notification. Need Sameer's call. +2. **`[rough]` Join/members/projects list all suffer from stale cache.** First mount renders old or empty data; a reload fixes it. Saw it on: Cara's first projects page after invite, Members tab before/after Cara was added, Share modal after adding Ben. Common cause = missing `invalidateQueries`. Same family as Sameer's own "joining a ws doesnt refetch" observation. +3. **`[rough]` Billing user sees a broken Projects tab.** Emma lands on `/w//projects` with "All projects" + "Create" button but empty list. Either redirect billing to `/settings/billing`, or empty-state with copy "Billing accounts don't see projects." +4. **`[rough]` Billing-role workspace card on `/w` has no "Manage" button.** Emma has no one-click path into the one place she needs (Usage and Tier). +5. **`[rough]` Seats 4/3 on Pioneer shown as plain text** with no overage warning on the workspace-level Usage and Tier card. The organisation-level Usage "Needs Attention" panel catches it, but the workspace card itself is silent. +6. **`[rough]` "Usage and Tier" tab label sits at `/settings/billing`.** Either rename URL segment or rename tab. +7. **`[rough]` Private-project share modal doesn't refresh after Add.** Same refetch family as above. Add succeeded server-side (verified via API) but the dialog still said "Just you, for now." +8. **`[rough]` `include_org_membership` alias not honored by the invite endpoint** despite the schema + docstring claim. Canonical `is_org_member` works; the declared alias silently defaults to `false` and rejects billing invites with a misleading "Guests can't…" message. +9. **`[note]` Organisation rollup counts "people" differently on Home (7, includes externals) vs Organisation page (4, organisation seats only).** Same label, different numbers. + +The full pain list is in [pains.md](pains.md), grouped by severity tag (`[block]`, `[hurt]`, `[rough]`, `[note]`). + +## Confirmed working + +So you don't re-test these tomorrow: + +- 3-step register flow on `/register`, email-verify redirect, post-verify login handoff. +- Post-patch (Sameer's devcontainer hot-fix), `POST /api/v2/onboarding/complete` returns 200 and drops the user into their Default workspace. +- Invite flow via the modal from `/settings/members`: all role/external combinations work once you use the canonical `is_org_member` parameter name. +- Role-change hard rules — all verified via API: + - Promote external → admin → `400` (guest rule) + - Self-remove last owner → `400` + - Demote last owner → `400` + - Demote admin → member → `200` +- Workspace settings role-gated UI: + - Admin sees 5 tabs (General, Members, Access, Usage and Tier, Danger) + - Billing sees 3 (General read-only, Members read-only, Usage and Tier full-access including Request Upgrade) +- Private project flow: `Make private` → modal → add workspace-member-only people with `viewer` role. API boundary enforces workspace-scoped sharing. +- Tier gate honesty on Access tab: Pioneer shows "Private" radio disabled with "Available on innovator and above"; organisation-admins-can-still-find-and-join disclosure present. +- Organisation → Usage "Needs Attention" panel correctly lists: Q1 Discovery approaching hour cap, Default at seat cap, Whitelabel downgraded recently. +- Multi-organisation home layout: Emma sees Acme Research + Partner Consulting organisations grouped, with correct per-organisation Manage/Add affordances based on her role. + +## Accounts state (for resuming) + +See [accounts.md](accounts.md) for the full table. Diff from when you left: +- I used Anna (`anna@seed.dembrane.dev`) extensively for admin flows. +- On the Default workspace (`2cf9fa15-…`), I added Cara (admin), Dan/Finn as externals were already there, Emma (billing-role). I demoted Ben from admin → member. Two pending invites exist: `frank@seed.dembrane.dev` (seed) and `new-person-1@unregistered.example` (my test for pending-invite-for-unregistered-email path). +- On Whitelabel Project (Innovator tier), I made "Brand Rollout" **private** and added Ben as viewer. Revert that if you want a clean seed. +- `solo1` is still in the broken state (no `workspace_membership` row) — see brief-01 for the fix. + +## What I'd do next when you're back + +1. Apply brief-01 so `solo1` un-sticks and future users don't hit the same partial-write bug. +2. Decide the consent question — silent auto-add vs accept step vs notification-only. +3. Audit React Query invalidations on mutations touching members / projects / workspaces / project_sharing. Three separate pains above are the same root cause. +4. Spend 20 minutes on the Billing role empty-Projects UX — a one-file fix in the Projects route (redirect or empty-state) would unblock a real demo persona. +5. Figure out how I should handle email verification and the pending-invite-accept email for overnight runs. If there's a Mailhog/Mailpit we can wire in, or a dev endpoint that returns the token, I can cover the two deferred modules next run. diff --git a/echo/docs/workspaces-validate/qa/_shots/00-login.png b/echo/docs/workspaces-validate/qa/_shots/00-login.png new file mode 100644 index 00000000..77d15e44 Binary files /dev/null and b/echo/docs/workspaces-validate/qa/_shots/00-login.png differ diff --git a/echo/docs/workspaces-validate/qa/_shots/01-register-verify.png b/echo/docs/workspaces-validate/qa/_shots/01-register-verify.png new file mode 100644 index 00000000..8446c4a8 Binary files /dev/null and b/echo/docs/workspaces-validate/qa/_shots/01-register-verify.png differ diff --git a/echo/docs/workspaces-validate/qa/_shots/02-post-verify-login.png b/echo/docs/workspaces-validate/qa/_shots/02-post-verify-login.png new file mode 100644 index 00000000..8be1a6be Binary files /dev/null and b/echo/docs/workspaces-validate/qa/_shots/02-post-verify-login.png differ diff --git a/echo/docs/workspaces-validate/qa/_shots/03-onboarding-team.png b/echo/docs/workspaces-validate/qa/_shots/03-onboarding-team.png new file mode 100644 index 00000000..906382e1 Binary files /dev/null and b/echo/docs/workspaces-validate/qa/_shots/03-onboarding-team.png differ diff --git a/echo/docs/workspaces-validate/qa/_shots/04-onboarding-team-full.png b/echo/docs/workspaces-validate/qa/_shots/04-onboarding-team-full.png new file mode 100644 index 00000000..a65cdbf9 Binary files /dev/null and b/echo/docs/workspaces-validate/qa/_shots/04-onboarding-team-full.png differ diff --git a/echo/docs/workspaces-validate/qa/_shots/05-post-onboarding-landing.png b/echo/docs/workspaces-validate/qa/_shots/05-post-onboarding-landing.png new file mode 100644 index 00000000..dc465b68 Binary files /dev/null and b/echo/docs/workspaces-validate/qa/_shots/05-post-onboarding-landing.png differ diff --git a/echo/docs/workspaces-validate/qa/_shots/06-home-empty.png b/echo/docs/workspaces-validate/qa/_shots/06-home-empty.png new file mode 100644 index 00000000..c48b503e Binary files /dev/null and b/echo/docs/workspaces-validate/qa/_shots/06-home-empty.png differ diff --git a/echo/docs/workspaces-validate/qa/_shots/10-anna-home.png b/echo/docs/workspaces-validate/qa/_shots/10-anna-home.png new file mode 100644 index 00000000..a3ef91f5 Binary files /dev/null and b/echo/docs/workspaces-validate/qa/_shots/10-anna-home.png differ diff --git a/echo/docs/workspaces-validate/qa/accounts.md b/echo/docs/workspaces-validate/qa/accounts.md new file mode 100644 index 00000000..18b71a9c --- /dev/null +++ b/echo/docs/workspaces-validate/qa/accounts.md @@ -0,0 +1,31 @@ +# QA Accounts + +Format: one row per account. All live on localhost:5173. + +| Label | Email | Password | Role(s) | Notes | +|-------|-------|----------|---------|-------| +| solo1 | sam.pashikanti+solo1@gmail.com | demo1234 | organisation owner (Sameer's Organisation), workspace Default | app_user_id `8842e94f-1b88-4fc2-b785-70a944e0df0b`, directus_user `623ef97f-03f3-4c3b-8923-1dc43f5b338e`, org `3160f520-087c-41c8-9938-90dbd395bd73`, ws `a41f59dd-7384-40b1-895b-51779dc64d60`. **In broken state** — no `workspace_membership` row → empty `/w` home. See pains.md | +| anna | anna@seed.dembrane.dev | demo1234 | seed, unknown state | | +| ben | ben@seed.dembrane.dev | demo1234 | seed, unknown state | | +| cara | cara@seed.dembrane.dev | demo1234 | seed, unknown state | | +| dan | dan@seed.dembrane.dev | demo1234 | seed, unknown state | | +| emma | emma@seed.dembrane.dev | demo1234 | seed, unknown state | | +| finn | finn@seed.dembrane.dev | demo1234 | seed, unknown state | | +| grace | grace@seed.dembrane.dev | demo1234 | seed, unknown state | | +| hank | hank@seed.dembrane.dev | demo1234 | seed, unknown state | | + +## Mapped display names (from the seed script Sameer shared) + +- anna → Anna Bakker +- ben → Ben Cortez +- cara → Cara Dubois +- dan → Dan Eriksen +- emma → Emma Friedman +- finn → Finn Garcia +- grace → Grace Hughes +- hank → Hank Irving + +## Conventions +- Email pattern: `sam.pashikanti+
` elements or an equivalent `aria-label`. +- Sort state announced via `aria-sort`. +- Cells linking to another view use actual `` tags, not clickable `