Compare commits
80 Commits
1256664361
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| aa73dee05d | |||
| 973b6a77eb | |||
| 3c5c6964e2 | |||
| 2753a8a367 | |||
| af45982176 | |||
| 0ee63c139f | |||
| 22eaf0f654 | |||
| 8878ee8c54 | |||
| ba264f401d | |||
| c4254749a4 | |||
| 4faf05fee9 | |||
| 9882177c59 | |||
| f16d7986c7 | |||
| ddd628ab6f | |||
| 2aa2893973 | |||
| 2f24a95ef4 | |||
| d02cd152de | |||
| fa93173b1f | |||
| 7a0fb2ca97 | |||
| ade1d68015 | |||
| ebf553eb68 | |||
| cb9a0b9250 | |||
| 00a2cc474a | |||
| 3f1f89dfbe | |||
| c5237a237a | |||
| 191c9748c5 | |||
| 778e98134c | |||
| 99f9ac84fb | |||
| 0ffe21a0c2 | |||
| 256a5dcea0 | |||
| 54f426e4f0 | |||
| 0273d1c70f | |||
| 3e9951b3a8 | |||
| 0c9b27684b | |||
| 167171a682 | |||
| 00d22be714 | |||
| b4104b9769 | |||
| 32aa04503d | |||
| c8c2d0811b | |||
| ce33834d56 | |||
| b164ef945c | |||
| 18498ee9fa | |||
| 40c98522dc | |||
| 50f9b3213d | |||
| 0c6650f3c6 | |||
| d28c873d40 | |||
| 9b075f46f9 | |||
| bba5fae061 | |||
| 74cca2d534 | |||
| 7d7c75915a | |||
| 65023ce404 | |||
| b44f795ba4 | |||
| 37407cb0af | |||
| 10f3545a25 | |||
| 8ce647552a | |||
| b3afd44d00 | |||
| 8768af96c1 | |||
| cea0a27118 | |||
| 6dc9e4ffee | |||
| 3298401772 | |||
| e39caa3c80 | |||
| 85a78eb21e | |||
| 3e66c7a5db | |||
| 9e0411bfe6 | |||
| f2d590b04d | |||
| dd6a4cd4fa | |||
| 77dd5bac82 | |||
| b918a21083 | |||
| 8c70b6d31f | |||
| 64c498a97a | |||
| 5abee1b5b7 | |||
| 58ebaf1982 | |||
| 6fc03fa4c3 | |||
| 0a8544b6fc | |||
| f2ef8922ce | |||
| 48c2e6589c | |||
| ba2d5f4193 | |||
| e49a71feaf | |||
| ac7f790303 | |||
| 24a87f6fb6 |
@@ -0,0 +1,12 @@
|
||||
# IIOS Project Memory — Index
|
||||
|
||||
Repo-local, versioned project memory. The full canonical rules are in the repo root
|
||||
**`CLAUDE.md`**; these files are granular, per-topic memories that accumulate across sessions.
|
||||
Read the relevant ones before working in that area; add/update files as the project evolves.
|
||||
|
||||
- [project_iios_overview.md](project_iios_overview.md) — what IIOS is (generic interaction OS; kernel + specializations + platform ports)
|
||||
- [feedback_generic_safety.md](feedback_generic_safety.md) — THE #1 rule: no chat/domain vocabulary in the kernel
|
||||
- [reference_run_and_test.md](reference_run_and_test.md) — run command, isolated `iios_test` DB, and the `replay.spec` flake workaround
|
||||
- [project_recent_features.md](project_recent_features.md) — the "real-providers" era: Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications
|
||||
- [reference_chat_web_consumer.md](reference_chat_web_consumer.md) — chat-web is the reference app driving IIOS feature work
|
||||
- [feedback_workflow.md](feedback_workflow.md) — commits (email + co-author), IIOS_DEV_TOKENS off in prod, TDD, build-before-restart
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: feedback_generic_safety
|
||||
description: THE #1 locked rule — the kernel must never hardcode chat/domain vocabulary
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
**The kernel must stay generic.** No `'dm'` / `'group'` / `'reaction'` / `'emoji'` / `'mention'`
|
||||
literals in kernel or messaging *logic*. If a reviewer asked "is this a chat backend now?" the
|
||||
answer must stay **no**.
|
||||
|
||||
**Why:** IIOS is a generic interaction OS; chat is one consumer. Baking chat meaning into the
|
||||
kernel destroys reuse (support/community/meetings share the same core).
|
||||
|
||||
**How to apply:** domain meaning lives in exactly three places, never the kernel —
|
||||
1. **OPA policy** (`DevOpaPort` → real OPA): DM-cap, group-admin, governed-join, media limits,
|
||||
notification triggers.
|
||||
2. **Opaque attributes** the kernel stores but never interprets: `thread.metadata.membership`
|
||||
(`dm`/`group`), interaction annotations (opaque `annotationType`+`value` → app writes
|
||||
`reaction`/`pin`/`save`), `mentions[]` (opaque userId notify-list; kernel never parses `@`).
|
||||
3. **The app** (chat-web): rendering + product semantics.
|
||||
|
||||
Reading an opaque attr inside a **policy/notification gate** (`membership === 'dm'` in
|
||||
`DevOpaPort` or `notification.projector.ts`) is OK — that file IS the policy plane. Everywhere
|
||||
else, keep generic. Verify before committing:
|
||||
`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits only
|
||||
in policy/notification/app-facing layers or comments. Also run `pnpm boundary`.
|
||||
Related: [[project_iios_overview]].
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: feedback_workflow
|
||||
description: Working conventions — commits, prod flags, TDD, rebuild-before-restart
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email
|
||||
**`maaz@insigniaconsultancy.com`**, co-author every commit with Claude. The user commits
|
||||
directly to `main` in this project (fine); branch only if asked.
|
||||
- **⚠ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token
|
||||
minting, chaos, retention sweep). The single most important prod-hardening flag.
|
||||
- **TDD** — write the failing spec first; verify it fails; implement; verify it passes.
|
||||
- **Rebuild before restart** — after backend changes, `nest build` then restart from `dist`;
|
||||
`lsof -ti :3200 | xargs kill -9` first or the old build keeps serving (stale-dist bites).
|
||||
- **New kernel capability = a generic primitive only** — add domain meaning in OPA policy + the
|
||||
app, never the kernel. See [[feedback_generic_safety]].
|
||||
- The user prefers **direct, fast iteration** (implement → verify end-to-end → commit), not
|
||||
heavyweight multi-agent/spec ceremony. Include "how to test" in summaries; report failures honestly.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: project_iios_overview
|
||||
description: What IIOS is — a generic multi-tenant interaction OS; kernel + specializations + swappable platform ports
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
IIOS (Insignia Interaction OS) = one NestJS service (`@insignia/iios-service`) + SDKs, in a
|
||||
pnpm monorepo (`packages/*`). Every interaction (chat message, ticket, routed post, AI
|
||||
suggestion, meeting) is the **same kernel object** inside a **tenant scope** (`IiosScope`:
|
||||
org/app/tenant/…), behind the **same fail-closed gates**, emitting the **same audit trail**.
|
||||
|
||||
Products (messaging, inbox, support, routing, AI, calendar, media, notifications) are thin
|
||||
**specializations** on top of a tiny kernel — never the reverse (`pnpm boundary` enforces it).
|
||||
|
||||
Platform seams are **ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = permissive
|
||||
`LocalDevPorts`): session, opa, cmp (consent), mdm, sas, capability, plus `StoragePort` (media)
|
||||
and `NotificationPort` (push). **Dev stubs swap to real adapters with zero consumer changes.**
|
||||
Every op passes `decideOrThrow(ports, {action,…})` fail-closed. Events go through a
|
||||
transactional outbox → `OutboxBus` → idempotent projectors (inbox, notifications).
|
||||
|
||||
See the repo `CLAUDE.md` and `docs/IIOS_API_AND_SDK_GUIDE.md` (as-built reference) for detail.
|
||||
Related: [[feedback_generic_safety]].
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: project_recent_features
|
||||
description: The "real-providers" era — Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Beyond P0–P8 (kernel → messaging → inbox → support → adapters → routing → AI →
|
||||
calendar/meetings), recent work (mostly driven by the chat app, the start of P9 "real
|
||||
providers"):
|
||||
|
||||
- **Real Supabase auth** — `SessionVerifier` verifies real OIDC tokens against issuer JWKS
|
||||
(ES256, no secret), a **multi-issuer registry** routed by the `iss` claim → per-issuer `appId`
|
||||
scope (`AUTH_ISSUERS` JSON, or `SUPABASE_URL` single-issuer shorthand). `userId = email`.
|
||||
Legacy HS256 app-token path (`APP_SECRETS`) stays for dev/tests.
|
||||
- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive (opaque
|
||||
`annotationType`+`value`); socket `annotate` event → `annotation` broadcast;
|
||||
`GET /v1/threads/my-annotations?type=save`.
|
||||
- **@mentions → Inbox** — `send(... mentions[])` (opaque userId list) → `InboxProjector` fans out
|
||||
a `MENTION` inbox item to mentioned participants; reading resolves it.
|
||||
- **Media** — `StoragePort` (dev = local disk `MEDIA_DIR`; prod swap to S3/Supabase), presigned
|
||||
upload/download (signed HS256 tokens, OPA-gated size/type, tenant-fenced), `attachment` on
|
||||
`MessageDto`; parts use generic `MEDIA_REF/VOICE_REF/FILE_REF`.
|
||||
- **Notifications** — presence-gated Web Push: `NotificationProjector` runs 3 gates
|
||||
(policy=DM/mention/reply-to-you · presence=`focus_thread` signal · per-thread `muted`) →
|
||||
swappable `NotificationPort` (Web Push/VAPID); dead sub (410) pruned. Presence is in-memory
|
||||
(single-instance) → Redis for multi-replica.
|
||||
- `senderId` (stable externalId) on `MessageDto` for reliable "is this mine?".
|
||||
|
||||
~205 tests. Keep `docs/IIOS_API_AND_SDK_GUIDE.md` current when adding endpoints.
|
||||
Related: [[reference_chat_web_consumer]], [[feedback_generic_safety]].
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: reference_chat_web_consumer
|
||||
description: chat-web is the reference consumer app that drives IIOS feature work
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**chat-web** (separate repo, `~/Documents/insignia-work/chat-web`) is the reference app on IIOS —
|
||||
a 1:1 + group chat UI (Vite + React + TanStack Router/Query + socket.io-client + supabase-js).
|
||||
It's frontend-only; IIOS provides identity, threads, messages, realtime, reactions, mentions,
|
||||
media, notifications.
|
||||
|
||||
Feature work usually spans **both repos**: a generic primitive/port in iios + the app UI in
|
||||
chat-web. The layer split we follow: **service** owns storage/auth/governance, the **SDK layer**
|
||||
(`chat-web/src/lib/*`, mirrors `@insignia/iios-kernel-client`) owns client plumbing
|
||||
(e.g. `uploadMedia`/`mediaUrl`, `registerPush`), the **app** owns rendering.
|
||||
|
||||
chat-web uses real Supabase login (`VITE_SUPABASE_URL` + anon key in its `.env`); identity =
|
||||
email. Run it with `pnpm dev`. Commit both repos with git email `maaz@insigniaconsultancy.com`.
|
||||
Related: [[project_recent_features]].
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: reference_run_and_test
|
||||
description: How to run the service + the test-DB isolation and the replay.spec flake workaround
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**Infra:** Postgres in docker `iios-db` on **:5434** (db `iios`), Redis on :6379. If Docker is
|
||||
down: `open -a OrbStack` then `docker start iios-db`.
|
||||
|
||||
**Run** (dev auth via Supabase; media + push enabled):
|
||||
```
|
||||
pnpm --filter @insignia/iios-service exec nest build # rebuild after backend changes
|
||||
SUPABASE_URL=https://<ref>.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \
|
||||
APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \
|
||||
VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:dev@insignia \
|
||||
node packages/iios-service/dist/main.js # :3200 ; GET /health
|
||||
```
|
||||
Restarting: `lsof -ti :3200 | xargs kill -9` first (stale instance → EADDRINUSE / old build served).
|
||||
|
||||
**Tests:** `pnpm test` (Vitest) runs against an **isolated `iios_test` DB** (globalSetup creates
|
||||
+ migrates it; `DATABASE_URL` overridden) — it **never wipes the dev `iios` DB**. TDD: spec next
|
||||
to code; DB specs use `resetDb()`.
|
||||
|
||||
**⚠ Known flake — `outbox/replay.spec`:** clock/ordering-sensitive, pre-existing. If it fails in a
|
||||
full run, it's stale `iios_test` state, NOT a regression. Fix:
|
||||
`docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"`
|
||||
then re-run. Prove it's not yours by stashing changes and re-running.
|
||||
|
||||
`pnpm boundary` = import-boundary check (must stay OK). Smokes: `packages/iios-service/scripts/smoke-*.mjs`.
|
||||
@@ -0,0 +1,27 @@
|
||||
name: publish-sdks
|
||||
|
||||
# Publish the @insignia/iios-* SDK packages to the Gitea npm registry on a version tag.
|
||||
# Requires: Gitea Actions enabled + a runner, and a repo secret GITEA_PUBLISH_TOKEN
|
||||
# (a token with `write:package` scope for the `insignia` org).
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r build
|
||||
- run: pnpm -r publish --no-git-checks
|
||||
env:
|
||||
# maps to ${GITEA_TOKEN} in .npmrc; private packages (service, testkit) are skipped
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_PUBLISH_TOKEN }}
|
||||
@@ -0,0 +1,6 @@
|
||||
# @insignia SDK packages publish to / install from the Gitea package registry.
|
||||
# Auth comes from the GITEA_TOKEN env var (never commit the token itself).
|
||||
# publish → token needs the `write:package` scope
|
||||
# install → token needs `read:package`
|
||||
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
|
||||
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
|
||||
@@ -0,0 +1,122 @@
|
||||
# IIOS — Claude Code Rules
|
||||
|
||||
> **Project memory:** granular, versioned per-topic notes live in **`.claude/memory/`** — read
|
||||
> `.claude/memory/MEMORY.md` (the index) and the relevant entries before working in an area, and
|
||||
> add/update memories as the project evolves. This file is the canonical rules; those are the
|
||||
> accumulating notes.
|
||||
|
||||
## What IIOS is
|
||||
|
||||
**IIOS (Insignia Interaction OS)** is a **generic, multi-tenant "interaction OS"** — one
|
||||
NestJS service (`@insignia/iios-service`) + a family of SDKs. Every interaction (a chat
|
||||
message, a support ticket, a routed post, an AI suggestion, a meeting) is the **same kernel
|
||||
object** inside a **tenant scope**, behind the **same fail-closed gates** (policy/consent),
|
||||
emitting the **same audit trail**. Products (messaging, support, community, AI, meetings) are
|
||||
thin **specializations** on top of the kernel — never the other way around.
|
||||
|
||||
`chat-web` (separate repo) is the reference consumer app.
|
||||
|
||||
## THE #1 LOCKED RULE — generic-safety (read this before touching the kernel)
|
||||
|
||||
**The kernel must never hardcode chat/domain vocabulary.** No `'dm'` / `'group'` /
|
||||
`'reaction'` / `'emoji'` / `'mention'` literals in kernel or messaging *logic*. If a reviewer
|
||||
asked *"is this a chat backend now?"* the answer must stay **no**.
|
||||
|
||||
Domain meaning lives in **three** places, never the kernel:
|
||||
1. **OPA policy** (the policy plane — `DevOpaPort` now, real OPA later). The DM-cap,
|
||||
group-admin, governed-join, media-limit, and notification-trigger rules live here.
|
||||
2. **Opaque thread/interaction attributes** the kernel stores but never interprets:
|
||||
`thread.metadata.membership` (`'dm'|'group'`), interaction annotations (opaque
|
||||
`annotationType` + `value` → the app writes `reaction`/`pin`/`save`), `mentions[]` (an
|
||||
opaque userId notify-list the kernel fans out; it never parses `@`).
|
||||
3. **The app** (chat-web) — rendering + product semantics.
|
||||
|
||||
Reading an opaque attribute inside a **policy/notification gate** (e.g. `membership === 'dm'`
|
||||
in `DevOpaPort` or `notification.projector.ts`) is allowed — that file *is* the policy plane,
|
||||
not the kernel. Everywhere else, keep it generic. Verify with a grep before committing:
|
||||
`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits
|
||||
should only be in the policy/notification/app-facing layers or comments.
|
||||
|
||||
`pnpm boundary` enforces the layer dependency law (specializations import the kernel, never
|
||||
the reverse). Run it; don't break it.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Kernel primitives** (generic): `IiosScope` (six-vector: org/app/tenant/bu/…), `IiosSourceHandle`
|
||||
(externalId = userId; stays `UNVERIFIED` until MDM resolves → `canonicalEntityId`), `IiosActorRef`,
|
||||
`IiosThread` (subject/metadata), `IiosThreadParticipant`, `IiosInteraction` (+ `parentInteractionId`
|
||||
reply link), `IiosMessagePart` (media as `contentRef`), `IiosInteractionAnnotation` (generic).
|
||||
- **Platform ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = `LocalDevPorts`):
|
||||
session, opa, cmp (consent), mdm, sas, capability — plus a `StoragePort` (media) and
|
||||
`NotificationPort` (push). **Dev stubs → real adapters with zero consumer changes.** Every
|
||||
op passes `decideOrThrow(ports, {action,…})` **fail-closed**.
|
||||
- **Session (auth):** `SessionVerifier` verifies (a) real OIDC tokens (Supabase/`AUTH_ISSUERS`)
|
||||
against the issuer JWKS (ES256, no secret), routed by `iss` → per-issuer `appId` scope; or
|
||||
(b) legacy dev HS256 app tokens (`APP_SECRETS`, keyed by `appId`). `userId = email` for OIDC.
|
||||
- **Events:** transactional outbox → `OutboxBus` → **projectors** (inbox, notifications).
|
||||
Projectors are **idempotent** (`claim()` on `IiosProcessedEvent` + projection cursor).
|
||||
Delivery is at-least-once → clients dedupe by message `id`.
|
||||
- **Scope isolation:** every row is tagged by `scopeId` (org+app+tenant). By-id ops call
|
||||
`assertOwns` (tenant fence → 403). Always `select`/scope Prisma queries.
|
||||
|
||||
## Tech stack
|
||||
|
||||
NestJS 11 · Prisma 6 / PostgreSQL 16 (docker `iios-db` on **:5434**, db `iios`) · Redis
|
||||
(socket.io adapter, multi-replica) · socket.io (`/message` namespace) · Vitest · pnpm
|
||||
monorepo (`packages/*`). `iios-service` is a **modular monolith** (HTTP + WS + relay +
|
||||
projectors in one process).
|
||||
|
||||
## Running & testing
|
||||
|
||||
```bash
|
||||
docker start iios-db # Postgres :5434 (OrbStack; `open -a OrbStack` if down)
|
||||
pnpm --filter @insignia/iios-service exec nest build
|
||||
# run (dev auth via Supabase; media + notifications enabled):
|
||||
SUPABASE_URL=https://<ref>.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \
|
||||
APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \
|
||||
VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:dev@insignia \
|
||||
node packages/iios-service/dist/main.js # → :3200 ; GET /health
|
||||
```
|
||||
|
||||
- **`pnpm test`** — Vitest. Runs against an **isolated `iios_test` DB** (globalSetup creates +
|
||||
migrates it; `DATABASE_URL` overridden). **It never wipes the dev `iios` DB.** ~205 tests.
|
||||
- **TDD**: write the failing spec first (see `*.spec.ts` next to the code). DB specs use
|
||||
`resetDb()` + real Postgres.
|
||||
- **Flaky `outbox/replay.spec`**: it's clock/ordering-sensitive and pre-existing. If it fails
|
||||
in a full run, `docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"`
|
||||
then re-run — it's stale test-DB state, not a regression.
|
||||
- **`pnpm boundary`** — import-boundary check (must stay OK).
|
||||
- Smokes: `packages/iios-service/scripts/smoke-*.mjs` (run against a live service).
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email
|
||||
**`maaz@insigniaconsultancy.com`**, and co-author every commit with Claude. Branch off `main`
|
||||
before committing if asked; otherwise the session has committed directly to `main`.
|
||||
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token
|
||||
minting). Single most important prod flag.
|
||||
- New kernel capability = a **generic primitive** only (see the #1 rule). Add domain meaning in
|
||||
policy + app.
|
||||
- Prisma: always `select` to avoid leaking `passwordHash`/PII; org/tenant scope every `where`.
|
||||
|
||||
## What's built (state)
|
||||
|
||||
P0–P8: kernel → messaging → inbox → support → adapters → routing → AI → calendar/meetings.
|
||||
Recent (the "P9 real-providers" era, mostly driven by the chat app):
|
||||
- **Real Supabase auth** — multi-issuer JWKS verification (`SessionVerifier`); first stubbed
|
||||
port turned real.
|
||||
- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive
|
||||
(opaque type/value), `annotate` socket event, `GET /v1/threads/my-annotations`.
|
||||
- **@mentions → Inbox** — `mentions[]` on send → `MENTION` inbox item (projector).
|
||||
- **Media** — `StoragePort` (dev local disk → prod S3/Supabase), presigned upload/download,
|
||||
attachment on `MessageDto`.
|
||||
- **Notifications** — presence-gated Web Push: `NotificationProjector` (policy/presence/mute
|
||||
gates) + swappable `NotificationPort`, `focus_thread` presence signal, per-thread mute.
|
||||
- `senderId` (stable externalId) on messages for reliable "is this mine?".
|
||||
|
||||
## Docs (as-built)
|
||||
|
||||
- `docs/IIOS_API_AND_SDK_GUIDE.md` — the **as-built REST/socket/SDK reference** (endpoints,
|
||||
shapes, env, vocab). Keep it current when adding endpoints.
|
||||
- `docs/DEPLOYMENT.md` — deploy/topology/env/scaling.
|
||||
- `docs/IIOS_OVERVIEW_FOR_CEO.md` — plain-language capability tour.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Publishing the IIOS SDKs (Gitea package registry)
|
||||
|
||||
The `@insignia/iios-*` **frontend SDKs** publish to our self-hosted Gitea npm registry:
|
||||
|
||||
```
|
||||
https://git.lynkedup.cloud/api/packages/insignia/npm/
|
||||
```
|
||||
|
||||
**Published (public in the registry):** `iios-contracts`, `iios-kernel-client`, `iios-adapter-sdk`,
|
||||
and the React hook packages `iios-message-web`, `iios-inbox-web`, `iios-support-web`,
|
||||
`iios-ai-web`, `iios-community-web`, `iios-meeting-web`.
|
||||
**Kept private (never published):** `iios-service` (the deployed backend) and `iios-testkit` (dev fakes).
|
||||
|
||||
> be-crm does **not** consume these — it talks to IIOS over REST. The SDKs are a **frontend** concern
|
||||
> (chat-web, the CRM support UI, mobile).
|
||||
|
||||
## One-time: get a token
|
||||
Gitea → **Settings → Applications → Generate Token**:
|
||||
- to **publish**: scope `write:package`
|
||||
- to **install** (private packages): scope `read:package`
|
||||
|
||||
Export it (never commit it):
|
||||
```bash
|
||||
export GITEA_TOKEN=<your-gitea-token>
|
||||
```
|
||||
The repo `.npmrc` already routes the `@insignia` scope to Gitea and reads `${GITEA_TOKEN}`.
|
||||
|
||||
## Publish
|
||||
```bash
|
||||
pnpm release:dry # build all + pack (no upload) — verify the 9 packages pack cleanly
|
||||
pnpm release # build all + publish the non-private packages to Gitea
|
||||
```
|
||||
`pnpm -r publish` automatically **skips** `private` packages, so only the 9 SDKs go out.
|
||||
Versions are **immutable** — bump before re-publishing (edit `version`, or adopt `changesets`).
|
||||
|
||||
Or push a tag and let CI do it (see `.gitea/workflows/publish-sdks.yml`; needs Gitea Actions +
|
||||
a runner + the `GITEA_PUBLISH_TOKEN` secret):
|
||||
```bash
|
||||
git tag v0.1.0 && git push origin v0.1.0
|
||||
```
|
||||
|
||||
## Consume (in chat-web / the CRM front-end)
|
||||
Add an `.npmrc` to the consuming repo:
|
||||
```ini
|
||||
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
|
||||
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
|
||||
```
|
||||
Then:
|
||||
```bash
|
||||
export GITEA_TOKEN=<read-token>
|
||||
pnpm add @insignia/iios-kernel-client @insignia/iios-contracts
|
||||
```
|
||||
This replaces the **vendored** client that chat-web copies today — one source of truth for all frontends.
|
||||
+18
-2
@@ -46,7 +46,18 @@ Every knob is an environment variable — see [`packages/iios-service/.env.examp
|
||||
for the full, commented list. Highlights:
|
||||
|
||||
- **Secrets** (inject from a vault, never bake into the image): `DATABASE_URL`,
|
||||
`APP_SECRETS` (per-app JWT signing keys), `ADAPTER_SECRETS` (webhook HMAC keys).
|
||||
`APP_SECRETS` (per-app HS256 JWT keys), `ADAPTER_SECRETS` (webhook HMAC keys),
|
||||
`MEDIA_SECRET` (signs presigned media upload/download URLs).
|
||||
- **Auth (real IdP):** set `SUPABASE_URL` or `AUTH_ISSUERS` to trust real OIDC access
|
||||
tokens, verified against the issuer's public **JWKS** (ES256) — **no secret to store**.
|
||||
`AUTH_ISSUERS` is a JSON registry `[{url, appId, orgId?}]` routing many issuers → isolated
|
||||
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
|
||||
- **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for
|
||||
prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change.
|
||||
- **Notifications (Web Push):** set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`
|
||||
to enable push (unset → the engine no-ops). ⚠️ Presence (the "are you viewing this thread?"
|
||||
gate) is **in-memory / single-instance** today — with N>1 replicas, back `PresenceService`
|
||||
with Redis so the projector on one replica sees focus from another.
|
||||
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
|
||||
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
|
||||
single most important prod-hardening flag.
|
||||
@@ -76,7 +87,12 @@ for the full, commented list. Highlights:
|
||||
> by message `id`** (the payload always carries one).
|
||||
- **Platform ports** (OPA policy, CMP consent, MDM, CRRE, SAS) — today in-process permissive
|
||||
stubs (`LocalDevPorts`). For production, point these at real external services; the service
|
||||
already calls them **fail-closed**.
|
||||
already calls them **fail-closed**. The **session** port already verifies real OIDC tokens
|
||||
(Supabase/JWKS) when configured.
|
||||
- **Media storage** (`StoragePort`) — dev = **local disk** (`MEDIA_DIR`). ⚠️ Local disk is
|
||||
**single-instance and non-durable**; with N>1 replicas or for persistence, bind it to shared
|
||||
**object storage** (S3/R2/Supabase Storage). Bytes always flow **client ↔ storage directly**
|
||||
via presigned URLs — they never transit the service — so this scales independently.
|
||||
|
||||
## Scaling & release strategy
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ Use it on every call: `-H "authorization: Bearer <token>"`.
|
||||
- **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation.
|
||||
- Token TTL: 2h.
|
||||
|
||||
**Real IdP tokens (production path).** Beyond the dev HS256 tokens, `SessionVerifier` also verifies **real OIDC access tokens** (ES256) against a trusted issuer's public **JWKS** — set `SUPABASE_URL` (single issuer) or `AUTH_ISSUERS` (a registry mapping many issuers → app scopes). The verifier routes a token by its `iss` claim, so two projects/IdPs map to two isolated `appId` scopes on one service, and app A's tokens can't reach app B. No shared secret needed. The dev token path stays for tests/local.
|
||||
|
||||
### Error responses (what QA will see)
|
||||
| Status | When |
|
||||
|---|---|
|
||||
@@ -101,12 +103,24 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
||||
### 5.3 Threads & Messaging (native chat)
|
||||
| Method | Path | Body / Headers | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/v1/threads` | — | `ThreadSummary[]` — your threads (members, unread, last message, `membership`) |
|
||||
| POST | `/v1/threads` | `{membership?, creatorRole?, subject?}` | `{threadId, status, history}` (201) — `membership`/`creatorRole`/`subject` are **opaque app attributes** the kernel stores but never interprets |
|
||||
| POST | `/v1/threads/:id/participants` | `{userId, role?}` | `{threadId, participantCount}` (201) — **governed** (DM cap / group-admin via OPA) |
|
||||
| GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` |
|
||||
| POST | `/v1/threads/:id/messages` | `{content, contentRef?}` + `idempotency-key?` header | `Message` (201) |
|
||||
| POST | `/v1/threads/:id/messages` | `{content, contentRef?, mimeType?, sizeBytes?, checksumSha256?, parentInteractionId?}` + `idempotency-key?` header | `Message` (201) |
|
||||
| GET | `/v1/threads/my-annotations` | `?type=save` | `{message, threadId, threadSubject}[]` — messages you annotated (e.g. saved) |
|
||||
|
||||
**Realtime (Socket.IO, namespace `/message`):** connect, then emit client→server events:
|
||||
- `open_thread` `{threadId?|otherUserId}`, `send_message` `{threadId, content, idempotencyKey?}`, `read` `{threadId}`, `delivered` `{threadId}`.
|
||||
- Server emits to the thread room: `message` (a Message) and `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`.
|
||||
A **`Message`** carries `{id, threadId, senderId, senderName, content, attachment?, parentInteractionId?, annotations[], traceId, createdAt}`. `senderId` is the sender's stable externalId (email/username) — the reliable "is this mine?" check. `attachment` = `{contentRef, mimeType, sizeBytes, kind}` (§5.10). `annotations` = `[{type, value, users[]}]` — the reaction/pin/save aggregate.
|
||||
|
||||
**Realtime (Socket.IO, namespace `/message`).** Token verified on connect (`auth.token`), then emit client→server:
|
||||
- `open_thread` `{threadId?, membership?, creatorRole?, subject?}` — opens, or **creates** when no id; a **governed join** for membership threads. Returns `{threadId, status, history}` or `{error}` (acked, never a throw — clients don't hang).
|
||||
- `send_message` `{threadId, content, contentRef?, mimeType?, sizeBytes?, parentInteractionId?, mentions?}` — `mentions` is an **opaque userId notify-list** (the app parses `@`; the kernel never does).
|
||||
- `add_participant` `{threadId, userId, role?}` · `read` `{threadId, interactionId}` · `delivered` `{…}` · `typing` `{threadId}`.
|
||||
- `annotate` `{threadId, interactionId, type, value}` — toggle a **generic annotation** (chat app uses `type: reaction|pin|save`; `value` = emoji, or empty).
|
||||
|
||||
Server → thread room: `message` (a Message), `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`, `typing` `{threadId, userId}`, `annotation` `{interactionId, type, value, op: add|remove, users[], userId}`.
|
||||
|
||||
**Governance & primitives (all fail-closed via OPA).** Membership (`iios.thread.participant.add`, `iios.thread.join`), annotating (`iios.interaction.annotate` — participant-only), and send all gate on policy. **Reactions/pins/saves are one generic primitive** — an *interaction annotation* the kernel stores + aggregates as opaque `(type, value)` but never interprets (the same primitive backs pins/saves/flags/tags). **Mentions → Inbox:** `mentions[]` flows into the message event; the inbox projector fans out a `MENTION` inbox item to each mentioned participant (never the sender); reading the thread resolves it.
|
||||
|
||||
### 5.4 Inbox (work queue)
|
||||
| Method | Path | Query / Body | Returns |
|
||||
@@ -114,6 +128,8 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
||||
| GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` |
|
||||
| PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` |
|
||||
|
||||
Item **kinds** include `NEEDS_REPLY` (unreplied thread activity, one per owner+thread) and `MENTION` (someone @-mentioned you; `priority: HIGH`, one per source message). Reading the thread resolves both to `DONE`.
|
||||
|
||||
### 5.5 Support (tickets, escalation, callbacks, queues, agents)
|
||||
| Method | Path | Body / Query | Returns |
|
||||
|---|---|---|---|
|
||||
@@ -179,6 +195,100 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
||||
|
||||
`ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.**
|
||||
|
||||
### 5.10 Media (attachments)
|
||||
|
||||
The DB stores a **reference**; the bytes live behind a **storage port** (dev = local disk `MEDIA_DIR`; prod = swap to S3/R2/Supabase). Bytes go **client ↔ storage directly** via short-lived signed URLs — they never pass through the kernel.
|
||||
|
||||
| Method | Path | Auth | Body | Returns |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/v1/media/presign-upload` | Bearer | `{mime, sizeBytes}` | `{objectKey, uploadUrl}` — **OPA-gated** (size/type) |
|
||||
| PUT | `/v1/media/upload/:token` | token in URL | raw bytes | `{objectKey, sizeBytes, checksumSha256}` |
|
||||
| POST | `/v1/media/presign-download` | Bearer | `{contentRef, mime?}` | `{url}` — **tenant-fenced**, signed 1h |
|
||||
| GET | `/v1/media/blob/:token` | token in URL | — | streams the bytes with their `Content-Type` |
|
||||
|
||||
**Attaching to a message:** `send`/`send_message` accept `{contentRef, mimeType, sizeBytes, checksumSha256?}`. The stored `Message` then carries `attachment: { contentRef, mimeType, sizeBytes, kind }` where `kind ∈ {image, video, audio, file}` (a friendly view of the generic `MEDIA_REF`/`VOICE_REF`/`FILE_REF` part the kernel writes).
|
||||
|
||||
**Governance (fail-closed, built in):**
|
||||
- **Upload policy** `iios.media.upload` — **≤ 25 MB** and an allowlist (`image/*`, `video/*`, `audio/*`, `application/pdf`, common Office/text/zip). A violation → **403** *before* any bytes are sent.
|
||||
- **Signed tokens** (HS256, `MEDIA_SECRET`) — upload URL lives **5 min**, download URL **1 h**; a tampered/expired token → 403.
|
||||
- **Tenant fence** — object keys are prefixed with the caller's `scopeId`; a download for an object outside your scope → 403.
|
||||
|
||||
**The flow (with governance):**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant C as Client (SDK uploadMedia)
|
||||
participant API as IIOS Media API
|
||||
participant OPA as OPA policy
|
||||
participant ST as StoragePort (disk → S3)
|
||||
participant K as Message kernel
|
||||
|
||||
C->>API: POST /v1/media/presign-upload {mime, sizeBytes}
|
||||
API->>OPA: decide iios.media.upload (≤25MB? type allowed?)
|
||||
alt denied
|
||||
OPA-->>C: 403 (too large / type not allowed)
|
||||
else allowed
|
||||
API-->>C: { objectKey, uploadUrl } (signed, 5-min)
|
||||
C->>ST: PUT bytes → uploadUrl (direct, not via kernel)
|
||||
ST-->>C: { sizeBytes, checksumSha256 }
|
||||
C->>K: send_message { contentRef, mime, size }
|
||||
K-->>C: Message { attachment }
|
||||
C->>API: POST /v1/media/presign-download { contentRef }
|
||||
API->>API: tenant-fence (objectKey in my scope?)
|
||||
API-->>C: { url } (signed, 1-hour)
|
||||
C->>ST: GET url → bytes (stream, inline render / download)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.11 Notifications (push, presence-gated)
|
||||
|
||||
The engine reacts to `message.sent` and pushes to **absent** recipients through a swappable
|
||||
**NotificationPort** (Web Push/VAPID now; email/FCM later). The DB holds only push
|
||||
subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays `IiosInboxItem`.
|
||||
|
||||
| Method | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/v1/notifications/vapid-public-key` | — | `{key}` — the public VAPID key the client needs to subscribe |
|
||||
| POST | `/v1/notifications/subscribe` | `{kind?, endpoint, keys:{p256dh, auth}, userAgent?}` | `{ok:true}` — store/refresh the caller's push subscription |
|
||||
| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | `{ok:true}` |
|
||||
| POST | `/v1/threads/:id/mute` · `/unmute` | — | `{threadId, muted}` — per-thread notification mute for the caller |
|
||||
|
||||
**Presence (socket):** the app emits `focus_thread` `{threadId | null}` on the `/message`
|
||||
namespace whenever the foreground conversation changes (or the tab blurs). This is how the
|
||||
engine knows you're *viewing* a thread — **room membership ≠ viewing**, because the sidebar
|
||||
joins every thread room for live updates. `GET /v1/threads` returns each thread's `muted`.
|
||||
|
||||
**The three gates (fail-closed, in the notification policy — not the kernel):**
|
||||
1. **Policy** — DM always; a group message only if you were `@`-mentioned **or** it's a reply to you.
|
||||
2. **Presence** — skip if you're currently focused on that thread (you saw it live).
|
||||
3. **Mute** — skip if you muted the thread.
|
||||
A dead subscription (Web Push `404/410`) is pruned. DM-vs-group is read from the opaque
|
||||
`membership` thread attribute here in the *notification policy*; the kernel never branches on it.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"]
|
||||
PROJ --> LOOP{{"for each recipient (never the sender)"}}
|
||||
LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"}
|
||||
G1 -->|no| X1["skip"]
|
||||
G1 -->|yes| G2{"PRESENCE: focused on this thread?"}
|
||||
G2 -->|yes| X2["skip — seen live"]
|
||||
G2 -->|no| G3{"MUTE: thread muted?"}
|
||||
G3 -->|yes| X3["skip"]
|
||||
G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"]
|
||||
PORT --> WP["Web Push (VAPID)"]
|
||||
WP -->|sent| OUT["→ browser push service → service worker → OS notification"]
|
||||
WP -->|"gone (404/410)"| PRUNE["prune dead subscription"]
|
||||
```
|
||||
|
||||
**Client (SDK layer, `lib/notifications.ts` → belongs in `@insignia/iios-kernel-client`):**
|
||||
`registerPush()` (permission → register service worker → `PushManager.subscribe` with the
|
||||
VAPID key → POST the subscription), `muteThread(threadId, muted)`, and `MessageSocket.focus(threadId)`.
|
||||
A service worker renders the OS notification on `push` and deep-links to the thread on click.
|
||||
|
||||
---
|
||||
|
||||
## 6. SDK reference
|
||||
@@ -191,7 +301,10 @@ import { RestClient } from '@insignia/iios-kernel-client';
|
||||
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
|
||||
```
|
||||
Methods (all return typed promises):
|
||||
- **Messaging:** `getThreadMessages(threadId)`, `sendMessage(threadId, content, {contentRef?, idempotencyKey?})`
|
||||
- **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
|
||||
- **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates.
|
||||
- **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.*
|
||||
- **Notifications:** `registerPush()` (service worker + `PushManager.subscribe` + POST subscription), `muteThread(threadId, muted)`, `MessageSocket.focus(threadId)` (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering.
|
||||
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
|
||||
- **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)`
|
||||
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
|
||||
@@ -269,7 +382,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/<name>`)
|
||||
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
|
||||
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
|
||||
|
||||
Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check: `pnpm boundary`.
|
||||
Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`.
|
||||
|
||||
---
|
||||
|
||||
@@ -289,7 +402,12 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
|
||||
|---|---|---|
|
||||
| `PORT` | `3200` | HTTP port |
|
||||
| `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres |
|
||||
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app JWT secrets (`{appId: secret}`) |
|
||||
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app HS256 JWT secrets (`{appId: secret}`) |
|
||||
| `SUPABASE_URL` / `AUTH_ISSUERS` | — | trusted OIDC issuer(s) — verify real IdP tokens (ES256) via JWKS. `AUTH_ISSUERS` is a JSON array `[{url, appId, orgId?}]` mapping issuers → app scopes; `SUPABASE_URL` is the single-issuer shorthand |
|
||||
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
|
||||
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
|
||||
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs |
|
||||
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | — | Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: `node -e "console.log(require('web-push').generateVAPIDKeys())"` |
|
||||
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
|
||||
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
|
||||
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
|
||||
@@ -305,7 +423,9 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
|
||||
|
||||
- **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
|
||||
- **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL
|
||||
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
|
||||
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, **MENTION**, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
|
||||
- **Message part kind:** TEXT, HTML, MARKDOWN, MEDIA_REF, FILE_REF, VOICE_REF, LOCATION, STRUCTURED_JSON · **Attachment kind (DTO):** image, video, audio, file
|
||||
- **Interaction annotation (app-level, opaque to the kernel):** `type` = reaction | pin | save … ; `value` = emoji (reactions) or empty
|
||||
- **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT
|
||||
- **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
|
||||
- **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
|
||||
|
||||
@@ -40,7 +40,7 @@ Today the external world (real WhatsApp, real AI models, real calendars, the rea
|
||||
| **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) |
|
||||
| **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — |
|
||||
|
||||
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (46 tables across 9 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
|
||||
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (53 tables across 19 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
|
||||
|
||||
---
|
||||
|
||||
@@ -159,7 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t
|
||||
| Calendar/Zoom providers | Simulated sync | P9 |
|
||||
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
|
||||
|
||||
**Proof it works:** 95 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
||||
**Proof it works:** 205 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
||||
|
||||
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, media sharing** (images/video/audio/docs on a swappable storage port), and **presence-gated push notifications** (Web Push via a swappable notification port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off.
|
||||
|
||||
---
|
||||
|
||||
@@ -175,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann
|
||||
|
||||
---
|
||||
|
||||
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 46 Postgres tables across 9 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar). Boundary-enforced dependency law; 95 passing tests; 7 end-to-end smoke scripts.*
|
||||
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 54 Postgres tables across 20 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media → notifications). Boundary-enforced dependency law; 205 passing tests; 8+ end-to-end smoke scripts. First real-provider swaps live: Supabase auth (JWKS-verified), a media storage port, and Web Push notifications.*
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Email Attachments — Implementation Plan
|
||||
|
||||
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · Extends the SMTP provider + mail plumbing.
|
||||
|
||||
## Goal
|
||||
|
||||
Let an external email carry attachments (e.g. an invoice PDF). The kernel already STORES attachments
|
||||
as message parts (`contentRef` + mime + size); the media `StoragePort` holds the bytes. The one gap is
|
||||
the **email envelope** — `SmtpProvider` (and the payload) don't carry attachments. Close that.
|
||||
|
||||
## Design (locked)
|
||||
|
||||
- **Payload carries REFS, not bytes:** the EMAIL payload gains
|
||||
`attachments?: [{ filename, contentRef, mimeType? }]`. Refs keep the outbound-command ledger small
|
||||
(and keep T8's PII redaction cheap) — bytes are fetched at send time.
|
||||
- **Resolver seam:** `AttachmentResolver = (contentRef) => Promise<{ filename?; content: Buffer; contentType? } | null>`.
|
||||
`SmtpProvider` takes an optional resolver; on send it resolves each ref and attaches
|
||||
(nodemailer `attachments: [{ filename, content, contentType }]`).
|
||||
- **Fail closed on a missing attachment:** if a declared attachment can't be resolved, the send is
|
||||
`FAILED` (so it retries) — NOT sent without it. A receipt/invoice missing its file is worse than a
|
||||
retry. (No resolver wired at all + attachments present → also FAILED, same reasoning.)
|
||||
- **Wiring:** `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`;
|
||||
`CapabilityProviderRegistry` `@Optional() @Inject(STORAGE_PORT)` → builds the resolver from
|
||||
`storage.get(contentRef)` → passes it to `SmtpProvider`. `@Optional` so contexts without storage
|
||||
still boot (attachments simply can't resolve → FAILED if any are declared).
|
||||
- **Scope:** SMTP path only. The HTTP relay `EmailProvider` attachment support is a separate follow-up
|
||||
(it would base64 the bytes into the relay POST). INTERNAL/mirror attachments already work via message
|
||||
parts and are not this plan.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/capability/smtp.provider.ts # attachments in EmailPayload + resolve+attach in send()
|
||||
src/capability/smtp.provider.spec.ts # attach resolved bytes; missing → FAILED
|
||||
src/capability/capability.registry.ts # inject STORAGE_PORT → resolver → SmtpProvider
|
||||
src/capability/capability.module.ts # import MediaModule
|
||||
src/media/media.module.ts # export STORAGE_PORT
|
||||
src/templates/templated-sender.ts # accept + pass `attachments`
|
||||
src/mail/mail.service.ts # accept + pass `attachments` (external send)
|
||||
```
|
||||
|
||||
## Tasks (TDD)
|
||||
|
||||
**T1 — SmtpProvider attaches / fails closed**
|
||||
- Inject a stub resolver. Tests: two refs → nodemailer `attachments` has both (filename + content +
|
||||
contentType); a ref the resolver returns `null` for → outcome `FAILED`, nothing sent; no attachments
|
||||
in payload → unchanged (plain send still SENT).
|
||||
|
||||
**T2 — registry wires the resolver from STORAGE_PORT**
|
||||
- `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; registry injects it
|
||||
`@Optional`. Test: with a fake storage bound, `forChannel('EMAIL')` SMTP resolves an attachment;
|
||||
without storage, the registry still constructs (attachments would FAIL, but boot is fine).
|
||||
|
||||
**T3 — pass-through: TemplatedSender + MailService**
|
||||
- `sendTemplated`/`sendExternalWithMirror` accept `attachments` and place them in the EMAIL payload.
|
||||
Tests: the outbound command's payload carries the attachment refs.
|
||||
|
||||
**T4 — gate + real send**
|
||||
- Full suite + boundary + build. Manual: a real Ethereal send with a small attachment → SENT, and the
|
||||
Ethereal message shows the attachment.
|
||||
|
||||
## Risks
|
||||
- **Fail-closed is deliberate** — don't silently send an invoice email without the invoice.
|
||||
- **Payload holds refs, not bytes** — so the ledger and T8 redaction stay small; the resolver reads
|
||||
bytes only at send time.
|
||||
- **`@Optional` storage** — a context without `STORAGE_PORT` boots fine but can't send attachments;
|
||||
that's correct (fail-closed), not a silent drop.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Email / Message Template Module — Implementation Plan
|
||||
|
||||
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Target:** first templates sending by Friday go-live.
|
||||
|
||||
## Goal
|
||||
|
||||
A reusable template module so **any** message the system sends — welcome, payment receipt,
|
||||
onboarding reminders, drip — is produced from a stored (or ad-hoc) template with variables filled
|
||||
in, then handed to the **existing** outbound pipeline. One render path, one send path, provenance
|
||||
recorded for every send.
|
||||
|
||||
From the July 16 meeting: *"template का एक पूरा module बनाना है… template में value भरोगे, और वो
|
||||
outbound क्यू में डाल दोगे।"*
|
||||
|
||||
## What already exists (the substrate — do NOT rebuild)
|
||||
|
||||
- `OutboundService.send(channelType, target, payload, idempotencyKey?, scopeId?, purpose?)` —
|
||||
idempotency + per-target/per-tenant rate limits + delivery ledger (`IiosOutboundCommand`).
|
||||
- `CapabilityBroker` — policy gate + obligations + provider selection for egress.
|
||||
- `EMAIL` is a registered channel; `EmailProvider` exists (**HTTP**, not SMTP — see Out of Scope).
|
||||
- `EMAIL` is a first-class `IiosInteractionKind`; `IiosMessagePartKind` has `HTML`/`TEXT`.
|
||||
- `IiosActorKind` includes `SERVICE`/`BOT` (system sender is first-class).
|
||||
- `InboxModule` uses `OnModuleInit` — copy that pattern for the template seeder.
|
||||
|
||||
The module adds **content/rendering** in front of this. It never talks to a provider directly.
|
||||
|
||||
## Design decisions (locked)
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | **Channel-generic**: one template per `(key, channel)`; channels `EMAIL` / `SMS` / `INTERNAL` | Vivek wants the same confirmation on email *and* SMS; INTERNAL = app-to-app, no SMTP |
|
||||
| 2 | **Seed-from-files, DB-is-truth**: templates are files in the repo, seeded into the DB on boot if absent | Copy is version-controlled + code-reviewed; a later admin UI can edit the DB with no deploy; Friday needs no UI |
|
||||
| 3 | **Handlebars** rendering | Auto-escapes HTML (customer names go into email → XSS risk), logic-less, no code execution, one dep |
|
||||
| 4 | **Global default + scope override**: `scopeId` nullable — `NULL` = platform default, set = tenant override; resolve scoped-first-else-global | Seeds cleanly at boot (IIOS scopes are created lazily, so a boot seeder has no scope to seed into); leaves room for white-label |
|
||||
| 5 | **Provenance on `IiosOutboundCommand`** (4 columns), not a new `template_snapshot` table | Rendered content is already in `payload`; only provenance is missing. Honours the SOT's intent at 4 columns |
|
||||
| 6 | **`TemplateSource` = stored `{key,version?}` OR `{inline:{subject,html,text}}`** | Marketing hands over finished HTML (*"Maaz, HTML भेज सकते हैं"*); inline still renders vars + records provenance |
|
||||
| 7 | **Integration pattern B**: pure `render()` + thin `sendTemplated()` composer | Single entry point for callers; provenance guaranteed by construction; `OutboundService` stays content-agnostic |
|
||||
| 8 | **Caller = a service** (be-crm's system token); **recipient = a `target` address, never a login** | System mail has no user on the sending side; the recipient may have no account yet |
|
||||
|
||||
## Caller & auth model
|
||||
|
||||
Every send is authenticated as the **calling app/service** (e.g. be-crm via its `APP_SECRETS`
|
||||
entry), verified by `SessionVerifier` like every other IIOS endpoint. The recipient is a plain
|
||||
`target` string — **not** an IIOS principal and **not** required to be logged in or registered.
|
||||
Sending a welcome email to an anonymous payer is the normal case: the app is the sender, the
|
||||
address is data. `scopeId` for the send is derived from the caller's principal (`org/app/tenant`).
|
||||
|
||||
## Data model
|
||||
|
||||
### New table — `IiosMessageTemplate`
|
||||
|
||||
```prisma
|
||||
enum IiosTemplateChannel {
|
||||
EMAIL
|
||||
SMS
|
||||
INTERNAL
|
||||
}
|
||||
|
||||
/// The template SOURCE. DB is runtime truth; platform defaults are seeded from repo files on boot.
|
||||
/// Versions are immutable: a change writes a new (higher) version, never edits in place.
|
||||
model IiosMessageTemplate {
|
||||
id String @id @default(cuid())
|
||||
/// NULL = platform default (seeded). Set = a tenant scope's override of the same key.
|
||||
scopeId String?
|
||||
scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||
key String // "welcome", "payment.receipt", "onboarding.reminder"
|
||||
channel IiosTemplateChannel
|
||||
locale String @default("en")
|
||||
version Int @default(1)
|
||||
subject String? // EMAIL only
|
||||
bodyHtml String? // EMAIL / INTERNAL
|
||||
bodyText String? // SMS, and EMAIL plaintext fallback
|
||||
/// Declared variable names — render throws if a declared var is missing (fail loud).
|
||||
variables Json?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scopeId, key, channel, locale, version])
|
||||
@@index([key, channel, locale, active])
|
||||
}
|
||||
```
|
||||
|
||||
`IiosScope` needs the back-relation `messageTemplates IiosMessageTemplate[]`.
|
||||
|
||||
**Resolution:** `resolve(key, channel, locale, scopeId?)` returns the highest-`version` `active`
|
||||
row, preferring `scopeId = <caller scope>` and falling back to `scopeId IS NULL`. No match → 404.
|
||||
|
||||
### Provenance columns on `IiosOutboundCommand`
|
||||
|
||||
```prisma
|
||||
templateKey String?
|
||||
templateVersion Int?
|
||||
templateLocale String?
|
||||
renderedHash String? // sha256 of the rendered content — replay/audit
|
||||
```
|
||||
|
||||
All nullable: non-templated sends (if any) leave them null.
|
||||
|
||||
**How provenance is written (review finding):** `OutboundService.send` **creates the command row
|
||||
itself** (both the RATE_LIMITED and PENDING paths), so a caller cannot stamp provenance after the
|
||||
fact without a racy update. Therefore `send()` gains one optional trailing arg —
|
||||
`provenance?: { templateKey; templateVersion; templateLocale; renderedHash }` — written into the
|
||||
same `create()` in both paths. `OutboundService` stays content-agnostic: it does not render or
|
||||
resolve templates, it only persists four opaque strings it is handed. This is what makes
|
||||
decision 7's "provenance guaranteed by construction" true. **This is a change to an existing file**
|
||||
(`adapters/outbound.service.ts`) and its spec — call it out in the PR.
|
||||
|
||||
### Migrations (hand-written, non-destructive)
|
||||
|
||||
1. `add_message_template` — the enum + table + `IiosScope` back-relation. **Plus a partial unique
|
||||
index for global rows (review finding):** the `@@unique([scopeId, key, channel, locale, version])`
|
||||
does **not** prevent duplicate *platform-default* rows, because Postgres treats `NULL` scopeId as
|
||||
distinct (`NULL != NULL`) — the same footgun handled in the inbox idempotency migration. Add:
|
||||
`CREATE UNIQUE INDEX "IiosMessageTemplate_global_key" ON "IiosMessageTemplate"("key","channel","locale","version") WHERE "scopeId" IS NULL;`
|
||||
so a double-seed or race cannot create two defaults for the same key.
|
||||
2. `add_outbound_template_provenance` — the 4 nullable columns on `IiosOutboundCommand`.
|
||||
|
||||
## Module structure
|
||||
|
||||
```
|
||||
packages/iios-service/src/templates/
|
||||
template.channel.ts // IiosTemplateChannel re-export/helpers if needed
|
||||
template.model.ts // TemplateSource, RenderedContent, CreateSendInput types
|
||||
template.repository.ts // resolve(): scoped ?? global, highest active version
|
||||
template.renderer.ts // Handlebars compile+render; escaping; missing-var throw
|
||||
template.service.ts // render(source, vars) — pure; resolve + renderer
|
||||
templated-sender.ts // sendTemplated(): render -> OutboundService.send -> stamp provenance
|
||||
template.seeder.ts // OnModuleInit: seed file defaults into DB if (key,channel,locale,version) absent
|
||||
template.controller.ts // POST /v1/templates/send, POST /v1/templates/preview
|
||||
template.dto.ts // SendTemplateDto, PreviewTemplateDto (class-validator)
|
||||
template.module.ts
|
||||
seeds/
|
||||
welcome.email.ts // { key, channel, locale, version, subject, html, text, variables }
|
||||
payment-receipt.email.ts
|
||||
payment-receipt.sms.ts
|
||||
onboarding-reminder.email.ts
|
||||
templates.spec.ts // TDD, real Postgres (localhost:5434), mirrors inbox.spec.ts
|
||||
```
|
||||
|
||||
**Scope boundary:** `render()` is channel-generic (it can render an `INTERNAL` template to
|
||||
`{subject,html,text}`). `sendTemplated()` covers **external** channels (`EMAIL`/`SMS`) via
|
||||
`OutboundService`. `INTERNAL` *delivery* (render → create an in-app `Interaction`, no SMTP) reuses
|
||||
`render()` but is wired by the messaging/mirror spec — this module never imports the messaging layer.
|
||||
|
||||
## Contract
|
||||
|
||||
```ts
|
||||
type TemplateSource =
|
||||
| { key: string; version?: number }
|
||||
| { inline: { subject?: string; html?: string; text?: string } };
|
||||
|
||||
interface RenderedContent { subject?: string; html?: string; text?: string }
|
||||
|
||||
// template.renderer.ts — PURE (no I/O): given a template's raw strings + vars, produce content.
|
||||
// template.service.ts render() — resolves the template from the DB (I/O), then calls the pure renderer.
|
||||
// For an inline source there is no DB row, so declared-variable validation is skipped — inline
|
||||
// content is the caller's responsibility; only stored templates enforce their declared `variables`.
|
||||
render(source: TemplateSource, vars: Record<string, unknown>, opts: { channel: IiosTemplateChannel; locale?: string; scopeId?: string }): Promise<RenderedContent>
|
||||
|
||||
// templated-sender.ts — external egress
|
||||
sendTemplated(input: {
|
||||
source: TemplateSource;
|
||||
channel: 'EMAIL' | 'SMS';
|
||||
target: string; // email address / phone
|
||||
vars: Record<string, unknown>;
|
||||
scopeId?: string;
|
||||
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
|
||||
purpose?: string;
|
||||
}): Promise<IiosOutboundCommand>
|
||||
```
|
||||
|
||||
**HTTP** (`SessionVerifier`-auth'd; `scopeId` from the caller's principal):
|
||||
- `POST /v1/templates/send` → `sendTemplated`. OPA action `iios.template.send`;
|
||||
inline source additionally gated on `iios.template.send.inline` (arbitrary HTML to customers).
|
||||
- `POST /v1/templates/preview` → `render` only, returns `RenderedContent`. No send. For marketing/QA.
|
||||
|
||||
## Task-by-task (TDD)
|
||||
|
||||
Each task: write the failing test → run it (confirm red) → implement → run (green) → commit.
|
||||
|
||||
**T1 — Renderer (`template.renderer.ts`)**
|
||||
- Tests: substitutes `{{firstName}}`; **escapes `<script>` in a name**; `{{#if}}`/`{{#each}}`;
|
||||
a declared-but-missing variable throws; renders `bodyHtml` and `bodyText` independently.
|
||||
- Impl: Handlebars, `noEscape:false`; validate declared `variables` present.
|
||||
|
||||
**T2 — Migrations + repository (`template.repository.ts`)**
|
||||
- Migration 1 (table+enum). Regenerate client.
|
||||
- Tests: `resolve` returns highest active version; **scoped overrides global**; unknown key → NotFound;
|
||||
inactive versions ignored.
|
||||
|
||||
**T3 — `render()` service tying resolve+renderer, incl. inline source**
|
||||
- Tests: stored `{key}` resolves+renders; `{inline}` renders without a DB row; wrong channel → 404.
|
||||
|
||||
**T4 — Provenance: `OutboundService.send` param + migration + `sendTemplated()`**
|
||||
- Migration 2 (4 columns).
|
||||
- Modify `OutboundService.send` to accept the optional `provenance` arg and write it into the command
|
||||
`create()` on both the RATE_LIMITED and PENDING paths. Extend `outbound.service.spec.ts`: a send
|
||||
with provenance persists all four fields; a send without leaves them null (no regression).
|
||||
- Then `sendTemplated()`. Tests: composes render→`OutboundService.send`; **stamps
|
||||
`templateKey/version/locale/renderedHash`** on the command; **idempotent per key** (replay → one
|
||||
`IiosOutboundCommand`); inline → `templateKey` null, `renderedHash` set.
|
||||
|
||||
**T5 — Seeder (`template.seeder.ts`, `seeds/*`)**
|
||||
- Tests: boot seeds the file defaults as `scopeId NULL`; **re-seed is idempotent** (no dupes);
|
||||
a bumped file version inserts a new row, leaves the old.
|
||||
|
||||
**T6 — Controller + DTOs**
|
||||
- Tests (HTTP, boot against sandbox provider so no real mail): `POST /send` renders+queues;
|
||||
unknown key → 404; bad body → 400; missing auth → 400/401; `POST /preview` returns content, sends nothing.
|
||||
|
||||
**T7 — Whole-suite gate**
|
||||
- `vitest run` (all packages), `npm run boundary`, `npm run build` all green.
|
||||
- Manual: boot locally, `POST /v1/templates/send` with the `welcome` seed via sandbox, inspect the
|
||||
`IiosOutboundCommand` row for payload + provenance.
|
||||
|
||||
**T8 — PII redaction of outbound commands (fast-follow; lever #2)**
|
||||
- Extend `RetentionService` so its sweep also redacts aged `IiosOutboundCommand` rows: raw `target`
|
||||
and rendered `payload` → redacted, while `templateKey/version/renderedHash` + `scopeId` are kept
|
||||
for audit. Reuse the existing redact-in-place pattern (currently applied to `iiosMessagePart`).
|
||||
- Tests: a command past its window has `target`/`payload` redacted but provenance intact; a command
|
||||
under compliance hold is skipped; audit row `retention.redacted` written.
|
||||
- Independent of T1–T7 — can land immediately after the module without blocking Friday.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Unknown key/channel/locale → `NotFoundException` (404).
|
||||
- Declared variable missing → throw (400) — never send a half-rendered receipt.
|
||||
- Transport failure → surfaced as `FAILED` on the command by `OutboundService`, never thrown to caller.
|
||||
- Idempotent replay (same key) → returns the existing command; never double-sends.
|
||||
|
||||
## Out of scope (separate specs — this module unblocks them)
|
||||
|
||||
1. **`SmtpProvider`** (nodemailer, `accounts@`/`ceo@lynkeduppro.com`) in IIOS's capability registry.
|
||||
Today's `EmailProvider` is HTTP; SMTP is a new provider flipped on by env. Without it, sends land
|
||||
in the sandbox.
|
||||
2. **`POST /webhooks/stripe` in be-crm** — verify Stripe signature → mint a **service token** →
|
||||
call `POST /v1/templates/send` (welcome + receipt), keyed on the Stripe object id. This is the
|
||||
"backend" the frontend-only payment site lacks.
|
||||
3. **Inbox mirror** (post-registration): render an email → also create an `Interaction(kind=EMAIL)`
|
||||
so it appears in the customer's in-app inbox. `INTERNAL` delivery lives here. Mirror only *after*
|
||||
registration (no inbox exists before).
|
||||
|
||||
## PII minimization
|
||||
|
||||
Sending email means IIOS must *touch* the recipient's address (you can't mail without it) and the
|
||||
rendered body holds their name — so `IiosOutboundCommand.target`/`payload` become PII. You cannot
|
||||
avoid IIOS touching it; you avoid it **accumulating** and being **cheaply reachable**. Three levers,
|
||||
by impact:
|
||||
|
||||
1. **Rotate the `Qwerty@a2` signing secret — precondition for prod, highest impact, not code.**
|
||||
The stored PII is only dangerous because any leaked token can be brute-forced back to the secret,
|
||||
letting an attacker forge a token and read the outbound table. A strong random secret leaves the
|
||||
PII in place but **unreachable by forgery** — 90% of the risk closed by one env change. **Gate:
|
||||
do not point the real SMTP provider at production until this is rotated.**
|
||||
|
||||
2. **Redact the raw address + body after delivery (fast-follow task — see T8).**
|
||||
IIOS keeps `templateKey/version/renderedHash` + the opaque `scopeId` for audit, but the raw
|
||||
`target` and rendered `payload` are redacted-in-place once past their retention window. The
|
||||
`RetentionService` already does exactly this for interaction message parts (`bodyText →
|
||||
'[redacted]'`); it just doesn't cover `IiosOutboundCommand` yet. Reuse the same sweep.
|
||||
|
||||
3. **Tokenized recipient (future, when MDM ships).** The clean model is: callers pass a `userId`,
|
||||
IIOS resolves the address from MDM *at send time* and never persists it. Not available today
|
||||
(MDM isn't deployed; `externalId` is a UUID with no email). **Design accommodation now:** keep
|
||||
`target: string` for Friday, but treat it as an opaque "where to send" so it can later become
|
||||
`{ userId }` resolved via MDM without changing the `sendTemplated` contract shape.
|
||||
|
||||
**Rejected shortcut:** having be-crm send SMTP directly so IIOS never sees the address. It "avoids"
|
||||
the PII but breaks the inbox mirror and the single-send-path (IIOS would have no record to copy into
|
||||
the inbox) — trading a solvable security problem for a broken feature.
|
||||
|
||||
## Other risks
|
||||
|
||||
- **Friday, no test env:** first real sends go to paying customers from an unproven path. Insist on a
|
||||
sandbox target / 100%-off test coupon before wiring the real SMTP provider (compounds with lever #1
|
||||
above — don't send real mail from prod until the secret is rotated *and* a safe test target exists).
|
||||
@@ -0,0 +1,113 @@
|
||||
# Inbox Mirror + INTERNAL Delivery — Implementation Plan
|
||||
|
||||
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** every message the app
|
||||
sends appears in the customer's in-app inbox; and users can send app-to-app "mail" with no SMTP.
|
||||
|
||||
## Goal
|
||||
|
||||
Two capabilities on one mechanism:
|
||||
1. **Mirror** — when an external EMAIL is sent, also record it as an in-app interaction so the
|
||||
customer sees a copy in their CRM inbox. Vivek: *"जो भी communication…उसकी एक copy inbox में चाहिए ही चाहिए."*
|
||||
2. **INTERNAL delivery** — a user sends a mail-style message (subject + body) to another user with
|
||||
**no SMTP**; it lands only in the recipient's in-app inbox. Vivek: *"app-to-app…without smtp."*
|
||||
|
||||
Both reduce to the same primitive: **render a template → create an `Interaction(kind=EMAIL)` with
|
||||
subject + HTML + TEXT parts on a thread.** External additionally does the SMTP send (already built).
|
||||
|
||||
## Architectural guardrail (carried from the earlier inbox work)
|
||||
|
||||
This is the **mail-style inbox (a projection over `Interaction`s)** — NOT the `InboxItem` work-surface.
|
||||
- An email/message becomes an `Interaction(kind=EMAIL)` on a thread. The inbox view lists interactions.
|
||||
- An `InboxItem` is created ONLY when the projector decides action is needed (NEEDS_REPLY/MENTION) —
|
||||
that's the existing projector, unchanged. **We do not write InboxItems here.** Mixing them is the
|
||||
KG-15 "inbox fatigue" failure.
|
||||
|
||||
## What already exists (reuse, do NOT rebuild)
|
||||
|
||||
- `IngestService.ingest(req, idempotencyKey)` — the generic create-an-interaction entry: resolves
|
||||
source handle → actor → channel → thread, writes `Interaction` (kind from `req.kind`) + parts +
|
||||
outbox event, idempotent per (scope, idempotencyKey). Inbound email already uses it to make
|
||||
`EMAIL` interactions with HTML/TEXT parts — **the exact model for the outbound mirror.**
|
||||
- `TemplateService.render()` (exported) → `{subject, html, text}`.
|
||||
- `TemplatedSender.sendTemplated()` → SMTP egress (built).
|
||||
- `IiosMessagePartKind` has `HTML` + `TEXT`; `IiosInteractionKind` has `EMAIL`.
|
||||
|
||||
## Design (locked)
|
||||
|
||||
- **New `MailService`** (new `src/mail/` module) orchestrates `TemplateService` + `IngestService` +
|
||||
`TemplatedSender` + `ActorResolver`. Templates/outbound stay unaware of each other.
|
||||
- `postInternal(...)` — render → `ingest()` an `EMAIL` interaction on a per-email thread. No SMTP.
|
||||
- `sendExternalWithMirror(...)` — render → `TemplatedSender.sendTemplated()` (SMTP) → **and** mirror
|
||||
via `ingest()` **iff the recipient is a registered user** (timing rule below).
|
||||
- **Visibility (resolved review finding):** `ingest()` creates the interaction + thread but adds NO
|
||||
participants, and `listThreads` shows only threads where the caller is a participant. So after each
|
||||
ingest the MailService `ensureParticipant`s **both** the sender's actor and the recipient's actor
|
||||
(`ActorResolver.resolveActor` → `ensureParticipant`). Without this the mirror is invisible.
|
||||
- **`ingest()` returns `threadId`** — used directly to add the two participants.
|
||||
- **Rendered content → parts:** part 0 `HTML` (bodyHtml), part 1 `TEXT` (bodyText); `subject` → the
|
||||
thread subject (email threads share a subject). Attachments are the separate attachments plan.
|
||||
- **Idempotency:** the ingest idempotencyKey = the send's key (e.g. `mirror:<stripe_session>`), so a
|
||||
retried send never doubles the inbox copy.
|
||||
- **Reply/threading:** `parentInteractionId` for in-thread replies (already modeled); a mirrored
|
||||
email's `inReplyTo` maps to the parent interaction.
|
||||
|
||||
## The timing rule (locked, from the meeting)
|
||||
|
||||
**Mirror only AFTER the recipient is registered.** The welcome/receipt go out *before* registration —
|
||||
there is no in-app inbox to mirror into yet. So `sendExternalWithMirror` mirrors only when the target
|
||||
resolves to a registered actor; pre-registration sends are email-only. Vivek: *"just time app pe
|
||||
register kar liya, uske baad se jitna communication…uske inbox mein chahiye."*
|
||||
|
||||
## Thread model (DECIDED: one thread per email)
|
||||
|
||||
**Each send is its own thread / inbox entry; a reply threads onto it.** Matches email semantics and
|
||||
pairs with the reply (`parentInteractionId`) feature. Implementation: the ingest `externalThreadId`
|
||||
is **derived from the send's idempotency key**, so a retried send reuses the same thread (no dupe)
|
||||
while distinct emails get distinct threads. A reply posts onto the parent's thread.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/mail/mail.service.ts # new — postInternal, sendExternalWithMirror
|
||||
src/mail/mail.service.spec.ts # new — DB-backed
|
||||
src/mail/mail.module.ts # new — imports TemplateModule + AdaptersModule + interactions
|
||||
src/mail/mail.controller.ts # new? — OR extend template.controller with a `deliverInternal` route
|
||||
```
|
||||
(Whether INTERNAL gets its own HTTP route or rides the template controller is a small call made at build time.)
|
||||
|
||||
## Task-by-task (TDD) — pending the thread-model decision
|
||||
|
||||
**T1 — `renderToParts()` helper**: `{subject,html,text}` → `IngestInteractionRequest.parts` +
|
||||
thread subject. Test: HTML+TEXT parts produced; empty parts omitted.
|
||||
|
||||
**T2 — `postInternal()`**: render an INTERNAL template → `ingest()` an `EMAIL` interaction on the
|
||||
thread between sender + recipient (thread model per the decision). Test: interaction created with
|
||||
kind EMAIL + parts; idempotent per key; lands on the recipient's thread.
|
||||
|
||||
**T3 — `sendExternalWithMirror()`**: render → `sendTemplated` (SMTP/sandbox) → mirror `ingest()`
|
||||
**only if** the recipient resolves to a registered actor. Test: registered → one outbound command +
|
||||
one mirror interaction; unregistered → outbound only, no mirror; idempotent (replay → no dupes).
|
||||
|
||||
**T4 — controller/module wiring + HTTP verify** (route for INTERNAL send; mirror invoked from the
|
||||
external send path). Boot + drive over HTTP against the sandbox.
|
||||
|
||||
**T5 — gate**: full suite + boundary + build; manual: send external → confirm a mirror interaction
|
||||
appears on the recipient's thread.
|
||||
|
||||
## Out of scope (follow-ons)
|
||||
- **Frontend mail-inbox view** — surfacing `EMAIL` interactions as a mail-style inbox in the CRM
|
||||
(the current CRM inbox is the InboxItem work-surface; the mail view is separate UI).
|
||||
- **Attachments** (separate plan). **Stripe webhook** (be-crm) — the trigger.
|
||||
|
||||
## Risks
|
||||
- **Don't write InboxItems here** (KG-15). Interactions only; the projector owns InboxItems.
|
||||
- **Idempotency must cover BOTH** the SMTP send and the mirror ingest, or a retried webhook doubles
|
||||
the inbox copy. Same key threaded through both.
|
||||
- **Unregistered recipients:** resolving "is this a registered user?" must be cheap and correct, or a
|
||||
pre-registration send could either error or wrongly mirror into a non-existent inbox.
|
||||
- **Review finding — recipient participation:** `ingest()` resolves and attaches the *source* actor.
|
||||
For the interaction to appear in the *recipient's* inbox, the **recipient must be a thread
|
||||
participant.** T2/T3 must ensure this — either by making the thread's participant set include the
|
||||
recipient at create time, or an explicit `ensureParticipant` after ingest. A mirror the recipient
|
||||
isn't a participant of is invisible — silent failure. Cover it with an assertion in the tests
|
||||
("recipient can list the thread / the interaction shows in their inbox query").
|
||||
@@ -0,0 +1,113 @@
|
||||
# Insignia Platform — Live State (from the mesh-verify probe, 2026-07-10)
|
||||
|
||||
> Distilled from the authenticated `mesh-verify.lynkedup.cloud` dashboard (`/api/results` +
|
||||
> `/api/journey`). This is the **real** platform IIOS is meant to plug into — service inventory,
|
||||
> the identity/session/governance flow, and the exact contracts to wire IIOS's platform ports.
|
||||
> Tokens redacted (the raw JSON dumps contain live SAT/PAT/refresh tokens — do not commit them).
|
||||
|
||||
## Cluster / mesh
|
||||
- **Cluster:** `lynkedup-tech` (NYC2 / DigitalOcean). **Istio** (istio-envoy) + **SPIFFE/SPIRE**,
|
||||
trust domain **`spiffe://insignia.tech`** (SVIDs like `spiffe://insignia.tech/ns/sre/sa/default`,
|
||||
`.../sa/realmdm-sas`). mTLS **PERMISSIVE**. Reached by ClusterIP DNS.
|
||||
- **Namespaces:** `sre` (MDM, OPA, misc), `cmp` (consent platform), `insignia` (identity/session/
|
||||
app-facing services), `istio-system`.
|
||||
- Public edges: `*.lynkedup.cloud` (behind oauth2-proxy → Keycloak).
|
||||
|
||||
## The identity/session/governance flow (7 steps — the doctrine)
|
||||
> **Separate authorities:** Session Broker *authenticates*, OPA *authorizes*, CMP decides *purpose*,
|
||||
> RealMDM *resolves identity*. Purpose-proof ≠ authorization. The PAT carries the external `sub`
|
||||
> only as a **SHA-256 hash**, never raw; `canonical_person_id` is null until MDM VERIFIES (MDM never
|
||||
> blocks login).
|
||||
|
||||
1. **Anonymous consent (CMP Edge)** — browser CMP SDK → `POST /edge/v1/cache-policy` → EdDSA-signed
|
||||
cache-category manifest; a ConsentReceipt goes to CMP over gRPC. Purpose proof only.
|
||||
2. **External login (Supabase)** → **SAT** (ES256 JWT). Claims: `iss` (project `/auth/v1`), opaque
|
||||
UUID `sub`, `aud=authenticated`, `email`, `user_metadata` (full_name, avatar_url), `aal`, `amr`.
|
||||
Proves the *session*, not the person/permission. Verified via Supabase **JWKS** (kid-selected).
|
||||
3. **Session Broker exchange SAT → PAT** — `POST /v1/sessions/exchange` (Bearer SAT +
|
||||
`X-Client-Authorization` = BFF Keycloak client-creds, aud=session-broker). Broker verifies the
|
||||
SAT, calls the MDM bridge, resolves scope from **memberships**, mints the **PAT** (~5 min).
|
||||
3b. **Workload identity (SPIFFE/mTLS)** — each meshed pod gets an X.509-SVID; OPA receives **both**
|
||||
the user (`principal`) and the caller (`caller.spiffe_id`) — different layers, never merged.
|
||||
4. **MDM Auth-Subject Bridge** — `POST /v1/auth-subjects/resolve {issuer, subject}` → `{platform_
|
||||
principal_id, canonical_person_id (null until VERIFIED), link_state (PENDING/VERIFIED),
|
||||
link_version, match_method}`. Keyed on **iss+sub** (never email). Idempotent.
|
||||
5. **OPA decision** — `POST /v1/decisions` → `{decision_id, allow, reason_codes, obligations,
|
||||
policy_version}`. Obligations = masks / row-filters / denied fields / audit level / ttl. A
|
||||
decision, not a 50-line entitlement JWT — the PEP MUST enforce every obligation.
|
||||
6. **AppShell assembles the ACE** — combines PAT + OPA obligations + CMP consent into an App
|
||||
Context Envelope (HttpOnly cookie): `capabilities[]` (policy-derived), `ui_obligations`
|
||||
(hide/mask/step_up), `consent`. **No tokens, no raw PII in the browser.**
|
||||
7. **CRM renders** — applies OPA `row_filter` (SQL WHERE) + `mask_fields` + `deny_fields`
|
||||
server-side; rows reference `canonical_person_id`, not email.
|
||||
|
||||
## Service inventory + real endpoints
|
||||
**Identity / session (`insignia` ns):**
|
||||
- **Session Broker** `session-broker.insignia:80` — `POST /v1/sessions/exchange` (SAT→PAT).
|
||||
- **Memberships** `memberships.insignia` (public `insignia-memberships.lynkedup.cloud`) —
|
||||
`POST /v1/internal/resolve`, `GET /v1/memberships` (Bearer PAT) → scope tuple + `allowed[]`.
|
||||
- **AppShell BFF** `appshell-bff.insignia:80` (public `insignia-appshell.lynkedup.cloud`) —
|
||||
`GET /apps/crm-web/bootstrap` (Bearer PAT) → ACE.
|
||||
- **Profile** `profile.insignia:80` — `GET /v1/profile`, `GET /v1/stats`. Backed by **sqlite3**
|
||||
(`/data/profiles.db`, PVC `insignia-profile-data`, WAL, persistent, single-replica RWO).
|
||||
- **CRM** `crm.insignia:80` — `GET /v1/leads?view=list` (applies obligations).
|
||||
- **Policy Gateway** `policy-gateway.insignia:80` — `POST /v1/decisions`, `POST /v1/decisions/batch`.
|
||||
|
||||
**MDM (`sre` ns):** `mdm-kernel.sre:80` (`/v1/auth-subjects/resolve`, `/healthz`, `/readyz`; auth =
|
||||
Keycloak service token **aud=realmdm**) · `mdm-ai.sre:80` · `mdm-sas.sre:9090` (**gRPC** — tokenization/SAS).
|
||||
|
||||
**OPA / policy (`sre` ns):** `opa.sre:8181` (`/health`, `GET /v1/data` = live policy tree;
|
||||
default-deny) · `realmdm-opa.sre:8181` · `opal-server.sre:7002` (OPAL policy distribution).
|
||||
|
||||
**CMP (`cmp` ns), backed by Postgres + NATS + Redis:** `cmp-core:8080` (real `CheckConsent` gRPC;
|
||||
service token aud=cmp) · `cmp-admin:8086` · `cmp-evidence:8081` · `cmp-sync:8085` ·
|
||||
`cmp-tollgate:8082` (NATS JetStream gating) · `cmp-media:8083` · `cmp-worker:8084`. Plus
|
||||
`insignia-consent-edge.insignia` (`POST /edge/v1/cache-policy`) and `insignia-consent-adapter.insignia`
|
||||
(`POST /v1/consent/evaluate {purpose}` → `{permitted, legal_basis, state, consent_epoch}`).
|
||||
|
||||
**Other (`sre` ns):** `poi-api:5000` · `roof:8002` (YOLO segmentation) · `commit:8081` ·
|
||||
`presign:8080` · `egs:8090` · `artifact-retrieval:8082` (was 503 on probe day) · `cmp-docs`.
|
||||
|
||||
## The two contracts IIOS must match
|
||||
|
||||
### PAT (Platform Access Token) — what IIOS should verify
|
||||
`iss = https://identity.insignia.internal` · **ES256** (EC P-256), verify via the broker **JWKS**
|
||||
(`.../.well-known/jwks.json`, kid-selected — **no shared secret**) · `aud` includes the app (e.g.
|
||||
`crm-web`) · `lifetime ~300s`. Claims:
|
||||
```
|
||||
sub = platform_principal_id app_id tenant_id org_id bu_id
|
||||
region environment role aal amr auth_source
|
||||
external_subject_hash = sha256:… (NOT the raw external sub)
|
||||
canonical_person_id (null until VERIFIED)
|
||||
session_epoch · policy_epoch · consent_epoch (stale-detection)
|
||||
sid (platform_session_id) · typ = platform-access+jwt
|
||||
```
|
||||
|
||||
### OPA decision — `POST http://policy-gateway.insignia.svc.cluster.local/v1/decisions`
|
||||
Request `{ input: { principal{platform_principal_id, canonical_person_id, auth_source, aal, roles,
|
||||
memberships[]}, caller{spiffe_id}, resource{type, id, tenant_id, classification[]}, action,
|
||||
context{purpose, device_trust, consent_receipt_ids[], network_zone, time} } }`
|
||||
Response `{ decision_id, allow, reason_codes[], obligations{ row_filter, allow_fields[], mask_fields{},
|
||||
deny_fields[], audit, decision_ttl_seconds }, policy_version }`.
|
||||
|
||||
## What this means for wiring IIOS's ports
|
||||
1. **Auth — verify the PAT, not the Supabase SAT.** IIOS today verifies the Supabase SAT directly
|
||||
(a dev shortcut). In the real platform the **Session Broker** does SAT→PAT; a platform workload
|
||||
verifies the **PAT**. The PAT already carries the full scope tuple + `platform_principal_id`, so
|
||||
`MessagePrincipal` maps ~1:1: `userId = platform_principal_id` (→ `canonical_person_id` once
|
||||
VERIFIED), `appId = app_id`, `orgId = org_id`, `tenantId = tenant_id`, `+ buId`. Wire it by adding
|
||||
the broker as an `AUTH_ISSUERS` entry (iss `https://identity.insignia.internal`, ES256, its JWKS).
|
||||
2. **OPA — point `OpaPort` at the Policy Gateway** (`POST /v1/decisions`). Build `{principal, caller.
|
||||
spiffe_id, resource, action, context}` from the PAT + the op; `decideOrThrow` maps `allow` → proceed
|
||||
and **must enforce the obligations** (masks/row-filter/deny). The gateway's input is richer than
|
||||
IIOS's current `{action,…}` — that's the adapter's job to assemble.
|
||||
3. **MDM — usually don't call it.** The PAT already carries `platform_principal_id`/`canonical_person_id`
|
||||
(the broker resolved at login). Only call `/v1/auth-subjects/resolve` if IIOS is the identity-exchange
|
||||
edge (it isn't — the Broker is).
|
||||
4. **CMP — consent gate** via `insignia-consent-adapter /v1/consent/evaluate {purpose}` when processing
|
||||
content for AI/analytics/marketing.
|
||||
5. **SAS — tokenization/masking** is `mdm-sas` (gRPC) — the "tokenize sensitive parts before storage"
|
||||
requirement.
|
||||
|
||||
*Source: two API payloads captured 2026-07-10 (`result.json` = probes, `journey.json` = the CRM
|
||||
first-vertical-slice journey). Re-capture from the authenticated dashboard to refresh.*
|
||||
@@ -0,0 +1,139 @@
|
||||
# SMTP Provider — Implementation Plan
|
||||
|
||||
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** make external email *actually leave the building* (welcome / receipt), the critical path for Friday.
|
||||
|
||||
## Goal
|
||||
|
||||
Add an `SmtpProvider` so the `EMAIL` channel delivers via real SMTP (`accounts@lynkeduppro.com`,
|
||||
fallback `ceo@lynkeduppro.com`) instead of the sandbox. The template module already renders and
|
||||
queues to the `EMAIL` channel; this is the one piece between "queued (SENT via sandbox)" and "the
|
||||
customer receives it." From the meeting: *"जो पहला जा रहा है, वो SMTP से जा रहा है, क्योंकि हमें तुरंत चाहिए."*
|
||||
|
||||
## What already exists (do NOT rebuild)
|
||||
|
||||
- `CapabilityProvider { name, channelTypes, capabilities, send(req) }` — the seam.
|
||||
- `CapabilityProviderRegistry` binds a provider per channel: **sandbox by default**; `EmailProvider`
|
||||
(HTTP) when `IIOS_PROVIDER_URL_EMAIL` is set. Unknown channels fail closed.
|
||||
- `OutboundService.send` → `CapabilityBroker` (policy + obligations) → the bound provider. Idempotency,
|
||||
rate limits, ledger, provenance all upstream — untouched.
|
||||
- `req.payload` for EMAIL is `{ subject, text, html, inReplyTo }` (from `TemplatedSender`).
|
||||
|
||||
## Design decisions (locked)
|
||||
|
||||
| # | Decision | Why |
|
||||
|---|---|---|
|
||||
| 1 | New `SmtpProvider implements CapabilityProvider`, `channelTypes=['EMAIL']`, via **nodemailer** | The established provider pattern; nodemailer is the standard SMTP client |
|
||||
| 2 | **Env-driven activation**, like `IIOS_PROVIDER_URL_EMAIL` | Off by default (sandbox); flip on by setting SMTP env — no code change to enable |
|
||||
| 3 | **Registry precedence for EMAIL: SMTP > HTTP > sandbox** | SMTP is the intended prod path; HTTP relay stays available; sandbox is the safe default |
|
||||
| 4 | **Transporter is injected** (constructor takes a `Transporter` or a factory) | SMTP is untestable against a live server in CI; inject a stub/`jsonTransport` to assert the envelope |
|
||||
| 5 | **Optional fallback sender** (`accounts@` primary → `ceo@` on failure) | The meeting's fallback: if the primary mailbox send fails, retry once via the fallback identity |
|
||||
| 6 | **Never throw** — a transport error returns `{ outcome: 'FAILED', errorCode }` | Adapter doctrine; the command is marked FAILED, the caller isn't broken |
|
||||
|
||||
## Config (env)
|
||||
|
||||
```
|
||||
IIOS_SMTP_HOST=smtp.<mail-host> # e.g. smtp.gmail.com (Google Workspace)
|
||||
IIOS_SMTP_PORT=587
|
||||
IIOS_SMTP_SECURE=false # true for 465, false for 587/STARTTLS
|
||||
IIOS_SMTP_USER=accounts@lynkeduppro.com
|
||||
IIOS_SMTP_PASS=<app password> # Workspace App Password, NOT the account password
|
||||
IIOS_SMTP_FROM="LynkedUp Pro <accounts@lynkeduppro.com>" # defaults to USER
|
||||
# optional fallback identity used only if the primary send FAILS
|
||||
IIOS_SMTP_FALLBACK_USER=ceo@lynkeduppro.com
|
||||
IIOS_SMTP_FALLBACK_PASS=<app password>
|
||||
IIOS_SMTP_FALLBACK_FROM="Justin Johnson <ceo@lynkeduppro.com>"
|
||||
```
|
||||
|
||||
Activation rule: `SmtpProvider` is bound for `EMAIL` iff `IIOS_SMTP_HOST` + `IIOS_SMTP_USER` +
|
||||
`IIOS_SMTP_PASS` are all set. Fallback transporter built only if the `_FALLBACK_*` trio is set.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/capability/smtp.provider.ts # new — the provider
|
||||
src/capability/smtp.provider.spec.ts # new — injected-transport tests
|
||||
src/capability/capability.registry.ts # modify — bind SMTP for EMAIL when configured (precedence)
|
||||
package.json # add nodemailer + @types/nodemailer
|
||||
```
|
||||
|
||||
## Contract
|
||||
|
||||
```ts
|
||||
interface SmtpIdentity { host: string; port: number; secure: boolean; user: string; pass: string; from: string }
|
||||
|
||||
class SmtpProvider implements CapabilityProvider {
|
||||
readonly name = 'smtp';
|
||||
readonly channelTypes = ['EMAIL'];
|
||||
readonly capabilities = { canSend: true };
|
||||
// `makeTransport` is injectable so tests pass a stub / nodemailer jsonTransport.
|
||||
constructor(primary: SmtpIdentity, fallback?: SmtpIdentity, makeTransport?: (id: SmtpIdentity) => Transporter) {}
|
||||
async send(req: CapabilityRequest): Promise<ProviderResult>;
|
||||
}
|
||||
```
|
||||
|
||||
`send()` builds the mail from `req.target` (recipient) + `req.payload`:
|
||||
```
|
||||
{ from, to: req.target, subject, text, html,
|
||||
inReplyTo?, references?, // threading, from payload.inReplyTo
|
||||
messageId } // generated; returned as providerRef so replies can thread
|
||||
```
|
||||
Primary transporter sends; on throw, if a fallback identity exists, retry once via it; still failing
|
||||
→ `FAILED`. Success → `{ outcome: 'SENT', providerRef: messageId, latencyMs }`.
|
||||
|
||||
**Review findings folded in:**
|
||||
- **`providerRef` = nodemailer's returned `info.messageId`**, not a hand-generated id — nodemailer
|
||||
stamps the real `Message-ID` it sent, which is what a reply's `In-Reply-To` will actually match.
|
||||
- **Fallback only on PRE-acceptance failures** (connection refused, auth failure, timeout) — NOT on
|
||||
an error raised after the SMTP server already accepted the message. Retrying a post-acceptance
|
||||
failure via the fallback identity would **double-deliver**. `send()` inspects the error (nodemailer
|
||||
`err.responseCode` / code) and falls back only when the server never accepted.
|
||||
- **Registry precedence is registration ORDER:** `register()` does `byChannel.set(ch, provider)`, so
|
||||
the LAST registration for `EMAIL` wins. Bind sandbox first (all channels), then HTTP `EmailProvider`
|
||||
if its URL is set, then `SmtpProvider` **last** if SMTP env is set → SMTP > HTTP > sandbox falls out.
|
||||
|
||||
## Task-by-task (TDD)
|
||||
|
||||
Each: failing test → red → implement → green → commit. Tests inject a stub transporter (no network).
|
||||
|
||||
**T1 — provider skeleton + config parse**
|
||||
- `smtpIdentityFromEnv()` reads the env trio; returns null if incomplete.
|
||||
- Test: full env → identity; missing pass → null; fallback trio → fallback identity.
|
||||
|
||||
**T2 — `send()` builds the correct envelope**
|
||||
- Inject a recording stub transporter. Test: `from`/`to`/`subject`/`html`/`text` map from target+payload;
|
||||
`inReplyTo` → header set when present; `messageId` generated and returned as `providerRef`; outcome `SENT`.
|
||||
|
||||
**T3 — failure handling + fallback**
|
||||
- Stub throws on primary. Test: with a fallback identity → retries via fallback, `SENT` via fallback
|
||||
transporter; without fallback → `FAILED` with `errorCode`, **never throws**.
|
||||
|
||||
**T4 — registry precedence**
|
||||
- `capability.registry.spec` (or extend): with SMTP env set, `forChannel('EMAIL')` returns the SMTP
|
||||
provider (not sandbox/HTTP); with only `IIOS_PROVIDER_URL_EMAIL` → HTTP; with neither → sandbox.
|
||||
- The registry reads env in its constructor, so each case sets env, constructs a fresh
|
||||
`CapabilityProviderRegistry`, asserts, then restores env (mirror the env save/restore other specs use).
|
||||
|
||||
**T5 — gate + manual real send**
|
||||
- `vitest run` (all), `boundary`, `build` green.
|
||||
- **Manual (ops):** point env at a real mailbox (or nodemailer **Ethereal** test SMTP for a no-mailbox
|
||||
end-to-end), boot, `POST /v1/templates/send` the `welcome` seed to your own address, confirm receipt
|
||||
and that From = `accounts@lynkeduppro.com`.
|
||||
|
||||
## Error handling
|
||||
- Incomplete SMTP env → provider not bound → EMAIL falls back to sandbox (no accidental silent prod send).
|
||||
- Transport failure → `FAILED` on the command (+ delivery attempt), never thrown.
|
||||
- Fallback used → `providerRef` notes the fallback identity for audit.
|
||||
|
||||
## Risks / prerequisites
|
||||
- 🔴 **Rotate `Qwerty@a2` BEFORE enabling.** This is the switch that turns queued sends into real
|
||||
emails to real addresses — a forgeable IIOS token now reaches customer inboxes under your brand.
|
||||
- **Workspace App Password, not the account password** (2FA accounts reject the raw password over SMTP).
|
||||
- **Deliverability:** SPF + DKIM + DMARC on `lynkeduppro.com` or mail lands in spam. Ops task, before real customers.
|
||||
- **Sending limits:** Google Workspace SMTP ≈ 2000/day. Fine — instant welcome/receipt is low volume; the
|
||||
drip goes via Mailchimp, not SMTP.
|
||||
- **Test safety:** never point CI/test env at a real mailbox; tests use an injected stub, the manual step
|
||||
uses Ethereal or a throwaway inbox.
|
||||
|
||||
## Out of scope (separate plans)
|
||||
Attachments over SMTP (extends `EmailPayload` + this provider); the inbox mirror / INTERNAL delivery
|
||||
(no SMTP dependency).
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+3
-1
@@ -7,7 +7,9 @@
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "vitest run",
|
||||
"boundary": "node scripts/check-import-boundary.mjs"
|
||||
"boundary": "node scripts/check-import-boundary.mjs",
|
||||
"release:dry": "pnpm -r build && pnpm -r publish --dry-run --no-git-checks",
|
||||
"release": "pnpm -r build && pnpm -r publish --no-git-checks"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.0.1",
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "@insignia/iios-adapter-sdk",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@insignia/iios-contracts": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"name": "@insignia/iios-ai-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -23,5 +29,8 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"name": "@insignia/iios-community-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -23,5 +29,8 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"name": "@insignia/iios-contracts",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
|
||||
bodyText?: string;
|
||||
contentRef?: string;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
}>;
|
||||
occurredAt: string;
|
||||
providerEventId?: string;
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"name": "@insignia/iios-inbox-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -23,5 +29,8 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"name": "@insignia/iios-kernel-client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -19,5 +25,8 @@
|
||||
"devDependencies": {
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ export interface MessageSocketConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the
|
||||
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck.
|
||||
* On reconnect it re-opens the current thread so subscriptions resume with no
|
||||
* lost messages (the docs' disconnect/reconnect requirement).
|
||||
* Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
|
||||
* types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
|
||||
* all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
|
||||
* that watches multiple conversations keeps receiving live messages after a drop.
|
||||
*/
|
||||
export class MessageSocket {
|
||||
private readonly socket: SocketLike;
|
||||
private currentThreadId: string | null = null;
|
||||
private readonly joined = new Set<string>();
|
||||
|
||||
constructor(config: MessageSocketConfig, socket?: SocketLike) {
|
||||
this.socket =
|
||||
@@ -26,9 +26,9 @@ export class MessageSocket {
|
||||
autoConnect: config.autoConnect ?? true,
|
||||
}) as unknown as SocketLike);
|
||||
|
||||
// Re-open the active thread after a reconnect.
|
||||
// Re-subscribe to every joined thread after a reconnect.
|
||||
this.socket.on('connect', () => {
|
||||
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId });
|
||||
for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,27 +40,70 @@ export class MessageSocket {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
/** Run `handler` on every (re)connect, and immediately if already connected. */
|
||||
onConnected(handler: () => void): () => void {
|
||||
this.socket.on('connect', handler);
|
||||
if (this.socket.connected) handler();
|
||||
return () => this.socket.off('connect', handler);
|
||||
}
|
||||
|
||||
/** Subscribe to a server event; returns an unsubscribe fn. */
|
||||
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
|
||||
const fn = handler as (...args: unknown[]) => void;
|
||||
this.socket.on(event, fn);
|
||||
return () => this.socket.off(event, fn);
|
||||
this.socket.on(event as string, fn);
|
||||
return () => this.socket.off(event as string, fn);
|
||||
}
|
||||
|
||||
async openThread(threadId?: string): Promise<OpenThreadResult> {
|
||||
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult;
|
||||
this.currentThreadId = result.threadId;
|
||||
async openThread(
|
||||
threadId?: string,
|
||||
opts?: { membership?: string; creatorRole?: string; subject?: string },
|
||||
): Promise<OpenThreadResult> {
|
||||
// Timeout (when the transport supports it) so a server error that never acks
|
||||
// can't hang the caller forever.
|
||||
const ack = this.socket.timeout ? this.socket.timeout(8000) : this.socket;
|
||||
const result = (await ack.emitWithAck('open_thread', { threadId, ...opts })) as OpenThreadResult & { error?: string };
|
||||
if (result?.error) throw new Error(result.error);
|
||||
this.joined.add(result.threadId);
|
||||
return result;
|
||||
}
|
||||
|
||||
async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> {
|
||||
async sendMessage(
|
||||
threadId: string,
|
||||
content: string,
|
||||
opts?: {
|
||||
contentRef?: string;
|
||||
parentInteractionId?: string;
|
||||
mentions?: string[];
|
||||
attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string };
|
||||
},
|
||||
): Promise<Message> {
|
||||
return (await this.socket.emitWithAck('send_message', {
|
||||
threadId,
|
||||
content,
|
||||
contentRef: opts?.contentRef,
|
||||
contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
|
||||
mimeType: opts?.attachment?.mimeType,
|
||||
sizeBytes: opts?.attachment?.sizeBytes,
|
||||
checksumSha256: opts?.attachment?.checksumSha256,
|
||||
parentInteractionId: opts?.parentInteractionId,
|
||||
mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel
|
||||
})) as Message;
|
||||
}
|
||||
|
||||
/** Pin a message in the thread (shared, generic annotation type "pin"). */
|
||||
async pin(threadId: string, interactionId: string): Promise<void> {
|
||||
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'pin', value: '' });
|
||||
}
|
||||
|
||||
/** Save a message for myself (personal, generic annotation type "save"). */
|
||||
async save(threadId: string, interactionId: string): Promise<void> {
|
||||
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'save', value: '' });
|
||||
}
|
||||
|
||||
/** Toggle an emoji reaction on a message (a generic annotation of type "reaction"). */
|
||||
async react(threadId: string, interactionId: string, value: string): Promise<void> {
|
||||
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'reaction', value });
|
||||
}
|
||||
|
||||
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
|
||||
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
|
||||
}
|
||||
@@ -68,4 +111,9 @@ export class MessageSocket {
|
||||
typing(threadId: string): void {
|
||||
this.socket.emit('typing', { threadId });
|
||||
}
|
||||
|
||||
/** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */
|
||||
focus(threadId: string | null): void {
|
||||
this.socket.emit('focus_thread', { threadId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types';
|
||||
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, DiscoveredThread, SavedItem, LoginResult } from './types';
|
||||
|
||||
export interface RestConfig {
|
||||
serviceUrl: string;
|
||||
token?: string;
|
||||
/**
|
||||
* Extra headers for every request — e.g. `x-context-attestation` (the July 12 trust proof).
|
||||
* Pass a FUNCTION to mint fresh headers per request: a context attestation carries a single-use
|
||||
* nonce, so a static header would be replay-rejected on the 2nd call. The function is invoked
|
||||
* once per request.
|
||||
*/
|
||||
headers?: Record<string, string> | (() => Record<string, string>);
|
||||
}
|
||||
|
||||
/** REST/polling client for kernel reads and the native-send fallback. */
|
||||
@@ -15,7 +22,8 @@ export class RestClient {
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const h: Record<string, string> = { 'content-type': 'application/json', ...extra };
|
||||
const custom = typeof this.config.headers === 'function' ? this.config.headers() : this.config.headers;
|
||||
const h: Record<string, string> = { 'content-type': 'application/json', ...custom, ...extra };
|
||||
if (this.config.token) h.authorization = `Bearer ${this.config.token}`;
|
||||
return h;
|
||||
}
|
||||
@@ -57,12 +65,94 @@ export class RestClient {
|
||||
return (await r.json()) as InboxItem;
|
||||
}
|
||||
|
||||
// ─── auth + threads (app surface) ─────────────────────────────
|
||||
/** Dev IdP login (POST /v1/dev/login). A real IdP issues the same JWT — this is the swap point. */
|
||||
async login(username: string, password: string): Promise<LoginResult> {
|
||||
const r = await fetch(this.url('/v1/dev/login'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (r.status === 401) throw new Error('invalid username or password');
|
||||
if (!r.ok) throw new Error(`login failed (${r.status}) — is the service running with IIOS_DEV_TOKENS=1?`);
|
||||
return (await r.json()) as LoginResult;
|
||||
}
|
||||
|
||||
/** Dev directory: known usernames. A real app validates against its user store / MDM. */
|
||||
async listUsers(): Promise<string[]> {
|
||||
const r = await fetch(this.url('/v1/dev/users'), { headers: this.headers() });
|
||||
if (!r.ok) return [];
|
||||
return ((await r.json()) as { users: string[] }).users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-authoritative conversation list (works cross-device, shows unread + members).
|
||||
* `filter.metadata` narrows to threads whose opaque attribute bag matches every key/value.
|
||||
*/
|
||||
async listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
|
||||
const qs = filter?.metadata
|
||||
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
|
||||
: '';
|
||||
const r = await fetch(this.url(`/v1/threads${qs}`), { headers: this.headers() });
|
||||
if (!r.ok) throw new Error(`listThreads ${r.status}`);
|
||||
return (await r.json()) as ThreadSummary[];
|
||||
}
|
||||
|
||||
/** Create a thread with generic app attributes (membership/creator role/subject + an opaque metadata bag). */
|
||||
async createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }> {
|
||||
return this.post<{ threadId: string }>('/v1/threads', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover threads across your scope matching an opaque metadata filter (e.g. browse public
|
||||
* channels: `{ membership: 'channel', visibility: 'public' }`). Returns ones you have NOT joined
|
||||
* too, each flagged `joined`.
|
||||
*/
|
||||
async discoverThreads(filter?: { metadata?: Record<string, string> }): Promise<DiscoveredThread[]> {
|
||||
const qs = filter?.metadata
|
||||
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
|
||||
: '';
|
||||
const r = await fetch(this.url(`/v1/threads/discover${qs}`), { headers: this.headers() });
|
||||
if (!r.ok) throw new Error(`discoverThreads ${r.status}`);
|
||||
return (await r.json()) as DiscoveredThread[];
|
||||
}
|
||||
|
||||
/** Self-leave a thread (e.g. leave a channel). */
|
||||
async leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }> {
|
||||
const r = await fetch(this.url(`/v1/threads/${threadId}/me`), { method: 'DELETE', headers: this.headers() });
|
||||
if (!r.ok) throw new Error(`leaveThread ${r.status}`);
|
||||
return (await r.json()) as { threadId: string; participantCount: number };
|
||||
}
|
||||
|
||||
/** My saved messages (personal bookmarks), newest first, with thread context. */
|
||||
async listSaved(): Promise<SavedItem[]> {
|
||||
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
|
||||
if (!r.ok) throw new Error(`listSaved ${r.status}`);
|
||||
return (await r.json()) as SavedItem[];
|
||||
}
|
||||
|
||||
/** Governed add-participant. A policy 403 (e.g. DM cap) surfaces its reason. */
|
||||
async addParticipant(threadId: string, userId: string): Promise<void> {
|
||||
const r = await fetch(this.url(`/v1/threads/${threadId}/participants`), {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (r.ok) return;
|
||||
const body = (await r.json().catch(() => ({}))) as { message?: string };
|
||||
throw new Error(body.message ?? `could not add member (${r.status})`);
|
||||
}
|
||||
|
||||
// ─── support ──────────────────────────────────────────────────
|
||||
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> {
|
||||
async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): Promise<Ticket> {
|
||||
return this.post<Ticket>('/v1/support/tickets', body);
|
||||
}
|
||||
async escalate(threadId: string, subject?: string): Promise<Ticket> {
|
||||
return this.post<Ticket>('/v1/support/escalate', { threadId, subject });
|
||||
async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
|
||||
return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
|
||||
}
|
||||
/** Manually assign a ticket to a specific user (generic assignment override). */
|
||||
async assignTicket(id: string, userId: string): Promise<Ticket> {
|
||||
return this.post<Ticket>(`/v1/support/tickets/${id}/assignee`, { userId });
|
||||
}
|
||||
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
|
||||
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
/** A media/file part attached to a message (contentRef points at object storage). */
|
||||
export interface Attachment {
|
||||
contentRef: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
kind: 'image' | 'video' | 'audio' | 'file';
|
||||
}
|
||||
|
||||
/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */
|
||||
export interface AnnotationGroup {
|
||||
type: string;
|
||||
value: string;
|
||||
users: string[]; // usernames who applied it
|
||||
}
|
||||
|
||||
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
|
||||
export interface Message {
|
||||
id: string;
|
||||
threadId: string;
|
||||
senderActorId: string;
|
||||
senderId: string; // sender's email/username — reliable "is this mine?" check
|
||||
senderName: string;
|
||||
content: string;
|
||||
contentRef?: string;
|
||||
attachment?: Attachment;
|
||||
parentInteractionId?: string;
|
||||
annotations?: AnnotationGroup[];
|
||||
traceId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -26,10 +46,59 @@ export interface TypingEvent {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** Broadcast when someone toggles an annotation — carries the refreshed user list. */
|
||||
export interface AnnotationEvent {
|
||||
threadId: string;
|
||||
interactionId: string;
|
||||
type: string;
|
||||
value: string;
|
||||
op: 'add' | 'remove';
|
||||
users: string[];
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface MessageEvents {
|
||||
message: (m: Message) => void;
|
||||
receipt: (e: ReceiptEvent) => void;
|
||||
typing: (e: TypingEvent) => void;
|
||||
annotation: (e: AnnotationEvent) => void;
|
||||
}
|
||||
|
||||
/** A discoverable thread from GET /v1/threads/discover — includes ones you have NOT joined. */
|
||||
export interface DiscoveredThread {
|
||||
threadId: string;
|
||||
subject: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
participantCount: number;
|
||||
joined: boolean;
|
||||
}
|
||||
|
||||
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
|
||||
export interface ThreadSummary {
|
||||
threadId: string;
|
||||
subject: string | null;
|
||||
membership?: string; // 'dm' | 'group' (opaque app attribute)
|
||||
/** The thread's opaque, app-supplied attribute bag — echoed verbatim; the kernel never interprets it. */
|
||||
metadata?: Record<string, unknown> | null;
|
||||
participants: string[]; // member usernames
|
||||
participantCount: number;
|
||||
unread: number;
|
||||
muted?: boolean;
|
||||
lastMessage?: string;
|
||||
lastAt?: string;
|
||||
}
|
||||
|
||||
/** A personally-saved message with its thread context (GET /v1/threads/my-annotations?type=save). */
|
||||
export interface SavedItem {
|
||||
message: Message;
|
||||
threadId: string;
|
||||
threadSubject: string | null;
|
||||
}
|
||||
|
||||
/** Result of the dev IdP login (POST /v1/dev/login). A real IdP issues the same claims. */
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
|
||||
@@ -68,6 +137,8 @@ export interface Ticket {
|
||||
assignedActorId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */
|
||||
metadata?: Record<string, unknown> | null;
|
||||
threadLinks?: Array<{ threadId: string; relationKind: string }>;
|
||||
}
|
||||
|
||||
@@ -188,4 +259,8 @@ export interface SocketLike {
|
||||
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
|
||||
connect(): unknown;
|
||||
disconnect(): unknown;
|
||||
/** True while the underlying transport is connected (socket.io exposes this). */
|
||||
connected?: boolean;
|
||||
/** Per-call ack timeout (socket.io). Optional so fakes can omit it. */
|
||||
timeout?(ms: number): { emitWithAck(event: string, ...args: unknown[]): Promise<unknown> };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"name": "@insignia/iios-meeting-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -23,5 +29,8 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"name": "@insignia/iios-message-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -23,5 +29,8 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@insignia/iios-messaging-ui",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./adapters/mock": {
|
||||
"types": "./dist/adapters/mock.d.ts",
|
||||
"import": "./dist/adapters/mock.js"
|
||||
},
|
||||
"./adapters/kernel-client": {
|
||||
"types": "./dist/adapters/kernel-client.d.ts",
|
||||
"import": "./dist/adapters/kernel-client.js"
|
||||
},
|
||||
"./adapters/mock-inbox": {
|
||||
"types": "./dist/adapters/mock-inbox.d.ts",
|
||||
"import": "./dist/adapters/mock-inbox.js"
|
||||
},
|
||||
"./conformance": {
|
||||
"types": "./dist/conformance.d.ts",
|
||||
"import": "./dist/conformance.js"
|
||||
},
|
||||
"./styles.css": "./dist/styles.css"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@insignia/iios-kernel-client": "*",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@insignia/iios-kernel-client": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@insignia/iios-kernel-client": "workspace:*",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
Attachment,
|
||||
ChannelSummary,
|
||||
Conversation,
|
||||
CreateChannelInput,
|
||||
Membership,
|
||||
Message,
|
||||
MessageEvent,
|
||||
Person,
|
||||
SendOpts,
|
||||
Unsubscribe,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* The one seam of this SDK. Hosts implement this; the SDK renders it.
|
||||
*
|
||||
* Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived
|
||||
* two implementations (live data-door + mock) — the minimum real evidence that a
|
||||
* seam is genuine rather than imagined.
|
||||
*
|
||||
* Optional methods degrade gracefully: the UI hides the reaction picker when
|
||||
* `react` is absent, and the attach button when `upload` is absent. That is how one
|
||||
* component set serves both a full CRM messenger and a stripped-down widget with no
|
||||
* `mode` prop.
|
||||
*/
|
||||
export interface MessagingAdapter {
|
||||
listConversations(): Promise<Conversation[]>;
|
||||
|
||||
openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>;
|
||||
|
||||
history(threadId: string): Promise<Message[]>;
|
||||
|
||||
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
|
||||
|
||||
/** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */
|
||||
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
|
||||
|
||||
sendTyping(threadId: string): void;
|
||||
|
||||
markRead(threadId: string, messageId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* The current user's actor id, or null if not yet known.
|
||||
*
|
||||
* MUST NOT be inferred from message history. The CRM's bug was exactly that:
|
||||
* scanning for a sent message meant every message read as not-yours until you
|
||||
* had spoken. Adapters derive this from auth/session.
|
||||
*/
|
||||
currentActorId(): string | null;
|
||||
|
||||
/** Absent => the UI hides reactions entirely. */
|
||||
react?(threadId: string, messageId: string, emoji: string): Promise<void>;
|
||||
|
||||
/** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */
|
||||
upload?(file: File): Promise<Attachment>;
|
||||
|
||||
/** Absent => treated as always connected (e.g. a pure-REST adapter). */
|
||||
isConnected?(): boolean;
|
||||
|
||||
/** A thread's members (id + display name), for @mention autocomplete + highlighting.
|
||||
* Absent => the composer offers no autocomplete (you can still type @text). */
|
||||
listMembers?(threadId: string): Promise<Person[]>;
|
||||
|
||||
/** The people you can start a conversation with (org directory). Drives the "New message"
|
||||
* people picker. Absent => the UI hides DM/group creation (you can still open channels). */
|
||||
directory?(): Promise<Person[]>;
|
||||
|
||||
// ── Channels (optional capability) ──────────────────────────────
|
||||
// A channel is just a third membership beyond dm/group: a discoverable, joinable room.
|
||||
// Implement all four to enable the channels UI; absent => the UI hides channels entirely.
|
||||
|
||||
/** Discoverable channels in the caller's scope, each flagged `joined`. */
|
||||
browseChannels?(): Promise<ChannelSummary[]>;
|
||||
|
||||
/** Create a channel; the creator joins as admin. Returns the new thread id. */
|
||||
createChannel?(input: CreateChannelInput): Promise<{ threadId: string }>;
|
||||
|
||||
/** Join a (public) channel by id. */
|
||||
joinChannel?(threadId: string): Promise<void>;
|
||||
|
||||
/** Leave a channel by id. */
|
||||
leaveChannel?(threadId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Runs the shared adapter conformance suite against KernelClientAdapter — proving it satisfies
|
||||
// the same contract as the mock, over the REAL MessageSocket facade. Only the lowest socket.io
|
||||
// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is
|
||||
// exercised for real. No live IIOS required.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MessageSocket } from '@insignia/iios-kernel-client';
|
||||
import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client';
|
||||
import { runAdapterConformance } from '../conformance';
|
||||
import { KernelClientAdapter, type RestPort } from './kernel-client';
|
||||
|
||||
const ME = 'me';
|
||||
const SEEDED = 'th_seed';
|
||||
|
||||
/** An in-memory stand-in for the /message socket.io namespace: stores messages, echoes 'message'. */
|
||||
function makeFakeSocket(): SocketLike {
|
||||
const threads = new Map<string, KernelMessage[]>([
|
||||
[
|
||||
SEEDED,
|
||||
[
|
||||
{
|
||||
id: 'seed_1',
|
||||
threadId: SEEDED,
|
||||
senderActorId: 'actor_other',
|
||||
senderId: 'pp_other',
|
||||
senderName: 'Other',
|
||||
content: 'seeded message',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const handlers = new Map<string, Set<(...a: unknown[]) => void>>();
|
||||
let seq = 1;
|
||||
|
||||
const fire = (event: string, payload: unknown): void => handlers.get(event)?.forEach((h) => h(payload));
|
||||
|
||||
return {
|
||||
on(event, handler) {
|
||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||
handlers.get(event)!.add(handler);
|
||||
return undefined;
|
||||
},
|
||||
off(event, handler) {
|
||||
handlers.get(event)?.delete(handler);
|
||||
return undefined;
|
||||
},
|
||||
emit() {
|
||||
return undefined; // typing/focus — no echo needed for conformance
|
||||
},
|
||||
async emitWithAck(event, payload) {
|
||||
const p = (payload ?? {}) as {
|
||||
threadId: string;
|
||||
content?: string;
|
||||
parentInteractionId?: string;
|
||||
interactionId?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
};
|
||||
if (event === 'open_thread') {
|
||||
if (!threads.has(p.threadId)) threads.set(p.threadId, []);
|
||||
return { threadId: p.threadId, status: 'OPEN', history: [...threads.get(p.threadId)!] };
|
||||
}
|
||||
if (event === 'send_message') {
|
||||
const msg: KernelMessage = {
|
||||
id: `m_${seq++}`,
|
||||
threadId: p.threadId,
|
||||
senderActorId: `actor_${ME}`,
|
||||
senderId: ME,
|
||||
senderName: ME,
|
||||
content: p.content ?? '',
|
||||
createdAt: new Date().toISOString(),
|
||||
...(p.parentInteractionId ? { parentInteractionId: p.parentInteractionId } : {}),
|
||||
};
|
||||
if (!threads.has(p.threadId)) threads.set(p.threadId, []);
|
||||
threads.get(p.threadId)!.push(msg);
|
||||
fire('message', msg);
|
||||
return msg;
|
||||
}
|
||||
if (event === 'read') return { ok: true };
|
||||
if (event === 'annotate') {
|
||||
fire('annotation', {
|
||||
threadId: p.threadId,
|
||||
interactionId: p.interactionId,
|
||||
type: p.type,
|
||||
value: p.value,
|
||||
op: 'add',
|
||||
users: [ME],
|
||||
userId: ME,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
connect() {
|
||||
return undefined;
|
||||
},
|
||||
disconnect() {
|
||||
return undefined;
|
||||
},
|
||||
connected: true,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeRest(): RestPort {
|
||||
let seq = 1;
|
||||
return {
|
||||
async listThreads() {
|
||||
return [];
|
||||
},
|
||||
async createThread() {
|
||||
return { threadId: `th_new_${seq++}` };
|
||||
},
|
||||
async addParticipant() {
|
||||
/* governed server-side; a fake always allows */
|
||||
},
|
||||
async discoverThreads() {
|
||||
return [
|
||||
{
|
||||
threadId: 'th_pub',
|
||||
subject: 'general',
|
||||
metadata: { membership: 'channel', visibility: 'public', topic: 'Company-wide' },
|
||||
participantCount: 3,
|
||||
joined: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
async leaveThread(threadId) {
|
||||
return { threadId, participantCount: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeAdapter(): KernelClientAdapter {
|
||||
const socket = new MessageSocket({ serviceUrl: 'http://iios.test', token: 'tok', autoConnect: false }, makeFakeSocket());
|
||||
return new KernelClientAdapter({ currentUserId: ME, socket, rest: makeFakeRest() });
|
||||
}
|
||||
|
||||
runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] });
|
||||
|
||||
describe('KernelClientAdapter channels', () => {
|
||||
it('browse maps discovered public channels; create/join/leave delegate to the transport', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const list = await adapter.browseChannels!();
|
||||
expect(list[0]).toMatchObject({ threadId: 'th_pub', name: 'general', visibility: 'public', joined: false, memberCount: 3, topic: 'Company-wide' });
|
||||
|
||||
const { threadId } = await adapter.createChannel!({ name: 'design', topic: 'UI', visibility: 'public' });
|
||||
expect(typeof threadId).toBe('string');
|
||||
|
||||
await expect(adapter.joinChannel!('th_pub')).resolves.toBeUndefined();
|
||||
await expect(adapter.leaveChannel!('th_pub')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
// Transport adapter: implements MessagingAdapter over @insignia/iios-kernel-client
|
||||
// (browser → IIOS directly, token-in). This is the "plug into any app with a token"
|
||||
// path — the same transport chat-web and support-sdk use.
|
||||
//
|
||||
// It lives in adapters/ (the ONLY layer allowed to import a transport) and ships from
|
||||
// its own subpath, so core UI never pulls socket code it can't use.
|
||||
|
||||
import { MessageSocket, RestClient } from '@insignia/iios-kernel-client';
|
||||
import type {
|
||||
AnnotationEvent,
|
||||
DiscoveredThread,
|
||||
Message as KernelMessage,
|
||||
MessageEvents,
|
||||
OpenThreadResult,
|
||||
ReceiptEvent,
|
||||
ThreadSummary,
|
||||
TypingEvent,
|
||||
} from '@insignia/iios-kernel-client';
|
||||
import type { MessagingAdapter } from '../adapter';
|
||||
import type {
|
||||
ChannelSummary,
|
||||
ChannelVisibility,
|
||||
Conversation,
|
||||
CreateChannelInput,
|
||||
Membership,
|
||||
Message,
|
||||
MessageEvent,
|
||||
Reaction,
|
||||
SendOpts,
|
||||
Unsubscribe,
|
||||
} from '../types';
|
||||
|
||||
/** The slice of MessageSocket the adapter needs — the real facade satisfies it; tests inject a fake. */
|
||||
export interface SocketPort {
|
||||
openThread(threadId?: string, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult>;
|
||||
sendMessage(threadId: string, content: string, opts?: { parentInteractionId?: string; mentions?: string[] }): Promise<KernelMessage>;
|
||||
react(threadId: string, interactionId: string, value: string): Promise<void>;
|
||||
markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }>;
|
||||
typing(threadId: string): void;
|
||||
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void;
|
||||
}
|
||||
|
||||
/** The slice of RestClient the adapter needs. */
|
||||
export interface RestPort {
|
||||
listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]>;
|
||||
createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }>;
|
||||
addParticipant(threadId: string, userId: string): Promise<void>;
|
||||
discoverThreads(filter?: { metadata?: Record<string, string> }): Promise<DiscoveredThread[]>;
|
||||
leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }>;
|
||||
}
|
||||
|
||||
export interface KernelClientAdapterConfig {
|
||||
/**
|
||||
* The current user's id in IIOS's `senderId` space (email/username), from the host's auth —
|
||||
* NEVER inferred from message history. This is exactly `currentActorId()`, and it's the bug the
|
||||
* conformance suite kills: identity comes from the session, not from a message you happened to send.
|
||||
*/
|
||||
currentUserId: string;
|
||||
socket: SocketPort;
|
||||
rest: RestPort;
|
||||
/** Optional opaque metadata filter for the conversation list (e.g. { source: 'crm-messenger' }). */
|
||||
threadFilter?: Record<string, string>;
|
||||
}
|
||||
|
||||
const REACTION = 'reaction';
|
||||
|
||||
export class KernelClientAdapter implements MessagingAdapter {
|
||||
private readonly me: string;
|
||||
private readonly socket: SocketPort;
|
||||
private readonly rest: RestPort;
|
||||
private readonly threadFilter?: Record<string, string>;
|
||||
|
||||
/** Per-thread UI subscribers. The socket fans server events in; these fan them out. */
|
||||
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
|
||||
/** Reaction users per message (messageId → emoji → userSet), so an annotation delta becomes a full set. */
|
||||
private readonly reactions = new Map<string, Map<string, Set<string>>>();
|
||||
private readonly joined = new Set<string>();
|
||||
private readonly offs: Array<() => void> = [];
|
||||
|
||||
constructor(cfg: KernelClientAdapterConfig) {
|
||||
this.me = cfg.currentUserId;
|
||||
this.socket = cfg.socket;
|
||||
this.rest = cfg.rest;
|
||||
this.threadFilter = cfg.threadFilter;
|
||||
|
||||
this.offs.push(
|
||||
this.socket.on('message', (m: KernelMessage) => {
|
||||
this.ingestReactions(m);
|
||||
this.emit(m.threadId, { kind: 'message', message: this.toMessage(m) });
|
||||
}),
|
||||
);
|
||||
this.offs.push(
|
||||
this.socket.on('typing', (e: TypingEvent) => this.emit(e.threadId, { kind: 'typing', userId: e.userId })),
|
||||
);
|
||||
this.offs.push(
|
||||
// Receipts carry no threadId, so fan to every open thread; the UI filters by messageId.
|
||||
this.socket.on('receipt', (e: ReceiptEvent) => this.broadcast({ kind: 'receipt', messageId: e.interactionId, actorId: e.actorId })),
|
||||
);
|
||||
this.offs.push(
|
||||
this.socket.on('annotation', (e: AnnotationEvent) => {
|
||||
if (e.type !== REACTION) return;
|
||||
this.setReactionUsers(e.interactionId, e.value, e.users);
|
||||
this.emit(e.threadId, { kind: 'reaction', messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
currentActorId(): string {
|
||||
return this.me;
|
||||
}
|
||||
|
||||
async listConversations(): Promise<Conversation[]> {
|
||||
const threads = await this.rest.listThreads(this.threadFilter ? { metadata: this.threadFilter } : undefined);
|
||||
return threads.map((t) => this.toConversation(t));
|
||||
}
|
||||
|
||||
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
||||
const membership: Membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
|
||||
const { threadId } = await this.rest.createThread({
|
||||
membership,
|
||||
creatorRole: membership === 'group' ? 'ADMIN' : 'MEMBER',
|
||||
...(p.subject ? { subject: p.subject } : {}),
|
||||
});
|
||||
// Governance (DM cap, roles) is enforced server-side by IIOS/OPA; a rejected add surfaces up there.
|
||||
for (const id of p.participantIds) await this.rest.addParticipant(threadId, id).catch(() => undefined);
|
||||
return { threadId };
|
||||
}
|
||||
|
||||
async history(threadId: string): Promise<Message[]> {
|
||||
const res = await this.socket.openThread(threadId); // joins the thread, so live events start flowing
|
||||
this.joined.add(threadId);
|
||||
return res.history.map((m) => {
|
||||
this.ingestReactions(m);
|
||||
return this.toMessage(m);
|
||||
});
|
||||
}
|
||||
|
||||
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||
// Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet,
|
||||
// and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up
|
||||
// (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today.
|
||||
const sendOpts = {
|
||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
|
||||
};
|
||||
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
||||
return this.toMessage(m);
|
||||
}
|
||||
|
||||
async react(threadId: string, messageId: string, emoji: string): Promise<void> {
|
||||
await this.socket.react(threadId, messageId, emoji);
|
||||
}
|
||||
|
||||
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
||||
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
|
||||
this.listeners.get(threadId)!.add(cb);
|
||||
if (!this.joined.has(threadId)) {
|
||||
this.joined.add(threadId);
|
||||
void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId));
|
||||
}
|
||||
return () => {
|
||||
this.listeners.get(threadId)?.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
sendTyping(threadId: string): void {
|
||||
this.socket.typing(threadId);
|
||||
}
|
||||
|
||||
async markRead(threadId: string, messageId: string): Promise<void> {
|
||||
await this.socket.markRead(threadId, messageId);
|
||||
}
|
||||
|
||||
// ── Channels ────────────────────────────────────────────────────
|
||||
async browseChannels(): Promise<ChannelSummary[]> {
|
||||
const found = await this.rest.discoverThreads({ metadata: { membership: 'channel', visibility: 'public' } });
|
||||
return found.map((d) => {
|
||||
const bag = (d.metadata as { topic?: string; visibility?: string } | null) ?? {};
|
||||
const visibility: ChannelVisibility = bag.visibility === 'private' ? 'private' : 'public';
|
||||
return {
|
||||
threadId: d.threadId,
|
||||
name: d.subject ?? 'channel',
|
||||
topic: bag.topic ?? null,
|
||||
visibility,
|
||||
memberCount: d.participantCount,
|
||||
joined: d.joined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
|
||||
return this.rest.createThread({
|
||||
membership: 'channel',
|
||||
creatorRole: 'ADMIN',
|
||||
subject: input.name,
|
||||
metadata: { visibility: input.visibility, ...(input.topic ? { topic: input.topic } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
async joinChannel(threadId: string): Promise<void> {
|
||||
// Self-join is a governed open_thread; OPA allows it for a public channel.
|
||||
await this.socket.openThread(threadId);
|
||||
this.joined.add(threadId);
|
||||
}
|
||||
|
||||
async leaveChannel(threadId: string): Promise<void> {
|
||||
await this.rest.leaveThread(threadId);
|
||||
this.joined.delete(threadId);
|
||||
}
|
||||
|
||||
/** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */
|
||||
close(): void {
|
||||
for (const off of this.offs) off();
|
||||
this.offs.length = 0;
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
// ── mapping ────────────────────────────────────────────────────
|
||||
private toMessage(m: KernelMessage): Message {
|
||||
return {
|
||||
id: m.id,
|
||||
// `senderId` (email/username), NOT the actor id — it matches currentActorId() and is the
|
||||
// reliable "is this mine?" field. Never inferred from history.
|
||||
actorId: m.senderId ?? null,
|
||||
text: m.content ?? '',
|
||||
at: m.createdAt,
|
||||
parentInteractionId: m.parentInteractionId ?? null,
|
||||
reactions: this.reactionsOf(m.id),
|
||||
};
|
||||
}
|
||||
|
||||
private toConversation(t: ThreadSummary): Conversation {
|
||||
const others = t.participants.filter((p) => p !== this.me);
|
||||
const membership: Membership | null =
|
||||
t.membership === 'dm' || t.membership === 'group' || t.membership === 'channel' ? t.membership : null;
|
||||
const topic = (t.metadata as { topic?: string } | null)?.topic;
|
||||
return {
|
||||
threadId: t.threadId,
|
||||
title: t.subject?.trim() || others.join(', ') || 'Conversation',
|
||||
subject: t.subject,
|
||||
membership,
|
||||
participants: [...t.participants],
|
||||
unread: t.unread,
|
||||
...(topic != null ? { topic } : {}),
|
||||
...(t.lastMessage ? { lastMessage: t.lastMessage } : {}),
|
||||
...(t.lastAt ? { lastAt: t.lastAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── reaction state ─────────────────────────────────────────────
|
||||
private ingestReactions(m: KernelMessage): void {
|
||||
for (const a of m.annotations ?? []) {
|
||||
if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users);
|
||||
}
|
||||
}
|
||||
|
||||
private setReactionUsers(messageId: string, emoji: string, users: string[]): void {
|
||||
let byEmoji = this.reactions.get(messageId);
|
||||
if (!byEmoji) {
|
||||
byEmoji = new Map();
|
||||
this.reactions.set(messageId, byEmoji);
|
||||
}
|
||||
if (users.length === 0) byEmoji.delete(emoji);
|
||||
else byEmoji.set(emoji, new Set(users));
|
||||
}
|
||||
|
||||
private reactionsOf(messageId: string): Reaction[] {
|
||||
const byEmoji = this.reactions.get(messageId);
|
||||
if (!byEmoji) return [];
|
||||
const out: Reaction[] = [];
|
||||
for (const [emoji, users] of byEmoji) {
|
||||
if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── event fan-out ──────────────────────────────────────────────
|
||||
private emit(threadId: string, e: MessageEvent): void {
|
||||
this.listeners.get(threadId)?.forEach((cb) => cb(e));
|
||||
}
|
||||
|
||||
private broadcast(e: MessageEvent): void {
|
||||
for (const set of this.listeners.values()) set.forEach((cb) => cb(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: build an adapter that talks straight to IIOS with a token. */
|
||||
export function connectKernelAdapter(opts: {
|
||||
serviceUrl: string;
|
||||
token: string;
|
||||
currentUserId: string;
|
||||
threadFilter?: Record<string, string>;
|
||||
autoConnect?: boolean;
|
||||
}): KernelClientAdapter {
|
||||
const rest = new RestClient({ serviceUrl: opts.serviceUrl, token: opts.token });
|
||||
const socket = new MessageSocket({ serviceUrl: opts.serviceUrl, token: opts.token, autoConnect: opts.autoConnect ?? true });
|
||||
return new KernelClientAdapter({
|
||||
currentUserId: opts.currentUserId,
|
||||
socket,
|
||||
rest,
|
||||
...(opts.threadFilter ? { threadFilter: opts.threadFilter } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { InboxAdapter } from '../inbox/adapter';
|
||||
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from '../inbox/types';
|
||||
|
||||
const PEOPLE: MailPerson[] = [
|
||||
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
|
||||
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
|
||||
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
|
||||
];
|
||||
|
||||
interface MockMail {
|
||||
subject: string;
|
||||
messages: MailMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory inbox for demos/tests. State is PER INSTANCE. Seeds a few work items (mention,
|
||||
* needs-reply, alert) plus mail threads that fold into the Open view as MAIL rows.
|
||||
*/
|
||||
export class MockInboxAdapter implements InboxAdapter {
|
||||
private seq = 100;
|
||||
private readonly items: InboxItem[];
|
||||
private readonly threads = new Map<string, MockMail>();
|
||||
/** threadIds that surface as standalone MAIL rows (in addition to work items). */
|
||||
private readonly mailFold = ['mt_welcome', 'mt_invoice'];
|
||||
|
||||
constructor(private readonly now: () => string = () => new Date().toISOString()) {
|
||||
const at = this.now();
|
||||
this.items = [
|
||||
{ id: 'in_1', kind: 'MENTION', state: 'OPEN', title: 'Sofia mentioned you', summary: '@you — can you confirm the Henderson scope?', priority: 'HIGH', threadId: 'mt_mention', createdAt: at },
|
||||
{ id: 'in_2', kind: 'NEEDS_REPLY', state: 'OPEN', title: 'Reply needed — Storm response', summary: 'Dan: crew rolling out at 7', priority: 'MEDIUM', threadId: 'mt_storm', createdAt: at },
|
||||
{ id: 'in_3', kind: 'SYSTEM_ALERT', state: 'OPEN', title: 'Ticket TK-204 updated', summary: 'Customer replied on the roof-leak case.', priority: 'LOW', createdAt: at },
|
||||
];
|
||||
this.threads.set('mt_mention', {
|
||||
subject: 'Henderson scope',
|
||||
messages: [{ id: 'm1', actorId: 'pp_sofia', kind: 'EMAIL', at, html: '<p>Can you confirm the <b>Henderson</b> scope by EOD?</p>', text: 'Can you confirm the Henderson scope by EOD?', attachment: null }],
|
||||
});
|
||||
this.threads.set('mt_storm', {
|
||||
subject: 'Storm response — East side',
|
||||
messages: [{ id: 'm2', actorId: 'pp_dan', kind: 'EMAIL', at, html: '<p>Crew is rolling out at 7. Confirm the Henderson job?</p>', text: 'Crew rolling out at 7. Confirm the Henderson job?', attachment: null }],
|
||||
});
|
||||
this.threads.set('mt_welcome', {
|
||||
subject: 'Welcome to the Founders Club',
|
||||
messages: [{ id: 'm3', actorId: 'system', kind: 'EMAIL', at, html: '<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>', text: 'Thanks for joining the Founders Club.', attachment: null }],
|
||||
});
|
||||
this.threads.set('mt_invoice', {
|
||||
subject: 'Invoice #1042 — Acme Roofing',
|
||||
messages: [{ id: 'm4', actorId: 'cust_acme', kind: 'EMAIL', at, html: '<p>Attached is invoice <b>#1042</b> for the East-side job.</p>', text: 'Attached is invoice #1042 for the East-side job.', attachment: null }],
|
||||
});
|
||||
}
|
||||
|
||||
async listInbox(state?: InboxState): Promise<InboxItem[]> {
|
||||
const showMail = !state || state === 'OPEN';
|
||||
const work = this.items.filter((i) => (state ? i.state === state : true));
|
||||
const mail: InboxItem[] = showMail
|
||||
? this.mailFold.map((tid) => {
|
||||
const t = this.threads.get(tid)!;
|
||||
const last = t.messages[t.messages.length - 1];
|
||||
return {
|
||||
id: `mail:${tid}`,
|
||||
kind: 'MAIL',
|
||||
state: 'OPEN' as InboxState,
|
||||
title: t.subject,
|
||||
...(last?.text ? { summary: last.text } : {}),
|
||||
priority: 'LOW',
|
||||
threadId: tid,
|
||||
createdAt: last?.at ?? this.now(),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return [...mail, ...work];
|
||||
}
|
||||
|
||||
async transition(id: string, state: InboxState): Promise<void> {
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (item) item.state = state;
|
||||
}
|
||||
|
||||
async mailHistory(threadId: string): Promise<MailMessage[]> {
|
||||
return [...(this.threads.get(threadId)?.messages ?? [])];
|
||||
}
|
||||
|
||||
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (t) t.messages = [...t.messages, { id: `r_${this.seq++}`, actorId: 'me', kind: 'MESSAGE', at: this.now(), html: null, text: content || null, attachment: attachment ?? null }];
|
||||
}
|
||||
|
||||
async uploadAttachment(file: File): Promise<MailAttachment> {
|
||||
return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name };
|
||||
}
|
||||
|
||||
async downloadAttachment(attachment: MailAttachment): Promise<string> {
|
||||
// Demo: no real bytes — hand back a data URL so the click resolves without a network call.
|
||||
return `data:${attachment.mimeType};base64,`;
|
||||
}
|
||||
|
||||
async directory(): Promise<MailPerson[]> {
|
||||
return [...PEOPLE];
|
||||
}
|
||||
|
||||
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
|
||||
const threadId = `mt_${this.seq++}`;
|
||||
this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `<p>${text}</p>`, text, attachment: attachments?.[0] ?? null }] });
|
||||
this.mailFold.unshift(threadId);
|
||||
void recipientUserId;
|
||||
}
|
||||
|
||||
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
|
||||
// A mock external send has no in-app thread — no-op beyond acknowledging.
|
||||
void target;
|
||||
void subject;
|
||||
void text;
|
||||
void attachments;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { MessagingAdapter } from '../adapter';
|
||||
import type {
|
||||
Attachment,
|
||||
ChannelSummary,
|
||||
ChannelVisibility,
|
||||
Conversation,
|
||||
CreateChannelInput,
|
||||
Membership,
|
||||
Message,
|
||||
MessageEvent,
|
||||
Person,
|
||||
SendOpts,
|
||||
Unsubscribe,
|
||||
} from '../types';
|
||||
|
||||
const ME = 'me';
|
||||
|
||||
export const MOCK_PEOPLE: Person[] = [
|
||||
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
|
||||
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
|
||||
{ id: 'pp_priya', name: 'Priya Nair', kind: 'staff' },
|
||||
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
|
||||
{ id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' },
|
||||
];
|
||||
|
||||
interface MockThread {
|
||||
threadId: string;
|
||||
membership: Membership;
|
||||
subject: string | null;
|
||||
participants: string[];
|
||||
messages: Message[];
|
||||
topic?: string | null;
|
||||
visibility?: ChannelVisibility;
|
||||
}
|
||||
|
||||
const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name]));
|
||||
|
||||
/**
|
||||
* In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version
|
||||
* used a module-level Map, which leaks between tests. Each `new MockAdapter()` is
|
||||
* fully isolated.
|
||||
*/
|
||||
export class MockAdapter implements MessagingAdapter {
|
||||
private seq = 100;
|
||||
private threads = new Map<string, MockThread>();
|
||||
private listeners = new Map<string, Set<(e: MessageEvent) => void>>();
|
||||
|
||||
constructor(private readonly now: () => string = () => new Date().toISOString()) {
|
||||
const seededAt = this.now();
|
||||
this.threads.set('th_mock_1', {
|
||||
threadId: 'th_mock_1',
|
||||
membership: 'dm',
|
||||
subject: null,
|
||||
participants: [ME, 'pp_sofia'],
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
actorId: 'pp_sofia',
|
||||
text: 'Can you review the Henderson estimate?',
|
||||
at: seededAt,
|
||||
reactions: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
this.threads.set('th_mock_2', {
|
||||
threadId: 'th_mock_2',
|
||||
membership: 'group',
|
||||
subject: 'Storm response — East side',
|
||||
participants: [ME, 'pp_dan', 'pp_priya'],
|
||||
messages: [
|
||||
{ id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: seededAt, reactions: [] },
|
||||
],
|
||||
});
|
||||
// Channels: a joined public one, a joinable public one (I'm NOT in it → shows in browse only),
|
||||
// and a private one I'm a member of.
|
||||
this.threads.set('th_ch_general', {
|
||||
threadId: 'th_ch_general', membership: 'channel', subject: 'general', topic: 'Company-wide chatter',
|
||||
visibility: 'public', participants: [ME, 'pp_dan', 'pp_priya', 'pp_sofia'],
|
||||
messages: [{ id: 'c1', actorId: 'pp_priya', text: 'Welcome to #general 👋', at: seededAt, reactions: [] }],
|
||||
});
|
||||
this.threads.set('th_ch_random', {
|
||||
threadId: 'th_ch_random', membership: 'channel', subject: 'random', topic: 'Non-work banter',
|
||||
visibility: 'public', participants: ['pp_dan', 'pp_sofia'], messages: [],
|
||||
});
|
||||
this.threads.set('th_ch_deals', {
|
||||
threadId: 'th_ch_deals', membership: 'channel', subject: 'deals', topic: 'Big pipeline moves',
|
||||
visibility: 'private', participants: [ME, 'pp_sofia'], messages: [],
|
||||
});
|
||||
}
|
||||
|
||||
currentActorId(): string {
|
||||
return ME;
|
||||
}
|
||||
|
||||
async listConversations(): Promise<Conversation[]> {
|
||||
// Only threads I'm a member of — an un-joined public channel appears in browse, not here.
|
||||
return [...this.threads.values()]
|
||||
.filter((t) => t.participants.includes(ME))
|
||||
.map((t) => {
|
||||
const last = t.messages[t.messages.length - 1];
|
||||
const others = t.participants.filter((p) => p !== ME);
|
||||
return {
|
||||
threadId: t.threadId,
|
||||
title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation',
|
||||
subject: t.subject,
|
||||
membership: t.membership,
|
||||
participants: [...t.participants],
|
||||
unread: 0,
|
||||
...(t.topic != null ? { topic: t.topic } : {}),
|
||||
...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async browseChannels(): Promise<ChannelSummary[]> {
|
||||
// Public channels are discoverable; private ones only if I'm already a member.
|
||||
return [...this.threads.values()]
|
||||
.filter((t) => t.membership === 'channel' && (t.visibility === 'public' || t.participants.includes(ME)))
|
||||
.map((t) => ({
|
||||
threadId: t.threadId,
|
||||
name: t.subject ?? 'channel',
|
||||
topic: t.topic ?? null,
|
||||
visibility: t.visibility ?? 'public',
|
||||
memberCount: t.participants.length,
|
||||
joined: t.participants.includes(ME),
|
||||
}));
|
||||
}
|
||||
|
||||
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
|
||||
const threadId = `th_ch_${this.seq++}`;
|
||||
this.threads.set(threadId, {
|
||||
threadId,
|
||||
membership: 'channel',
|
||||
subject: input.name,
|
||||
topic: input.topic ?? null,
|
||||
visibility: input.visibility,
|
||||
participants: [ME],
|
||||
messages: [],
|
||||
});
|
||||
return { threadId };
|
||||
}
|
||||
|
||||
async joinChannel(threadId: string): Promise<void> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (t && !t.participants.includes(ME)) t.participants = [...t.participants, ME];
|
||||
}
|
||||
|
||||
async leaveChannel(threadId: string): Promise<void> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (t) t.participants = t.participants.filter((p) => p !== ME);
|
||||
}
|
||||
|
||||
async listMembers(threadId: string): Promise<Person[]> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (!t) return [];
|
||||
return t.participants.map((id) => {
|
||||
if (id === ME) return { id: ME, name: 'You', kind: 'staff' };
|
||||
return MOCK_PEOPLE.find((p) => p.id === id) ?? { id, name: id, kind: 'staff' };
|
||||
});
|
||||
}
|
||||
|
||||
async directory(): Promise<Person[]> {
|
||||
return MOCK_PEOPLE.map((p) => ({ ...p }));
|
||||
}
|
||||
|
||||
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
||||
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
|
||||
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
|
||||
const target = p.participantIds[0];
|
||||
for (const [id, t] of this.threads) {
|
||||
if (t.membership === 'dm' && t.participants.length === 2 && t.participants.includes(ME) && t.participants.includes(target)) {
|
||||
return { threadId: id };
|
||||
}
|
||||
}
|
||||
}
|
||||
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
|
||||
const threadId = `th_mock_${this.seq++}`;
|
||||
this.threads.set(threadId, {
|
||||
threadId,
|
||||
membership,
|
||||
subject: p.subject ?? null,
|
||||
participants: [ME, ...p.participantIds],
|
||||
messages: [],
|
||||
});
|
||||
return { threadId };
|
||||
}
|
||||
|
||||
async history(threadId: string): Promise<Message[]> {
|
||||
return [...(this.threads.get(threadId)?.messages ?? [])];
|
||||
}
|
||||
|
||||
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (!t) throw new Error(`Unknown thread: ${threadId}`);
|
||||
const message: Message = {
|
||||
id: `m_${this.seq++}`,
|
||||
actorId: ME,
|
||||
text: content,
|
||||
at: this.now(),
|
||||
reactions: [],
|
||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||
...(opts?.attachment ? { attachment: opts.attachment } : {}),
|
||||
};
|
||||
t.messages = [...t.messages, message];
|
||||
this.emit(threadId, { kind: 'message', message });
|
||||
return message;
|
||||
}
|
||||
|
||||
async react(threadId: string, messageId: string, emoji: string): Promise<void> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (!t) return;
|
||||
let next: Message | undefined;
|
||||
t.messages = t.messages.map((m) => {
|
||||
if (m.id !== messageId) return m;
|
||||
const existing = (m.reactions ?? []).find((r) => r.emoji === emoji);
|
||||
const reactions = existing
|
||||
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
|
||||
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
|
||||
next = { ...m, reactions };
|
||||
return next;
|
||||
});
|
||||
if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] });
|
||||
}
|
||||
|
||||
async upload(file: File): Promise<Attachment> {
|
||||
return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name };
|
||||
}
|
||||
|
||||
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
||||
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
|
||||
this.listeners.get(threadId)!.add(cb);
|
||||
return () => {
|
||||
this.listeners.get(threadId)?.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
sendTyping(): void {
|
||||
// No-op: nobody is typing back in a mock.
|
||||
}
|
||||
|
||||
async markRead(): Promise<void> {
|
||||
// No-op: the mock has no second party to report a read.
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
private emit(threadId: string, e: MessageEvent): void {
|
||||
this.listeners.get(threadId)?.forEach((cb) => cb(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// This package is ESM ("type": "module") — __dirname does not exist here.
|
||||
const SRC = fileURLToPath(new URL('.', import.meta.url));
|
||||
const ADAPTERS = join(SRC, 'adapters');
|
||||
|
||||
function tsFilesIn(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...tsFilesIn(full));
|
||||
else if (/\.tsx?$/.test(full)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The whole premise of this package: core renders, adapters transport. If core ever
|
||||
// imports a transport, an app on a different backend pays for socket code it cannot
|
||||
// use — which is exactly how iios-message-web welded itself to MessageSocket.
|
||||
describe('transport boundary', () => {
|
||||
const coreFiles = tsFilesIn(SRC).filter((f) => !f.startsWith(ADAPTERS) && !/\.test\.tsx?$/.test(f));
|
||||
|
||||
it('has core files to check', () => {
|
||||
expect(coreFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('core never imports a transport package', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of coreFiles) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
if (/@insignia\/iios-kernel-client|socket\.io|@abe-kap\/appshell-sdk/.test(content)) {
|
||||
offenders.push(file.replace(SRC, 'src'));
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useChannels } from '../hooks/use-channels';
|
||||
import type { ChannelVisibility } from '../types';
|
||||
|
||||
/** Browse + join discoverable channels, and create a new one. Shown in the main pane. */
|
||||
export function ChannelBrowser({ onJoined }: { onJoined?: (threadId: string) => void }) {
|
||||
const { browsable, loading, error, join, create } = useChannels();
|
||||
const [name, setName] = useState('');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [visibility, setVisibility] = useState<ChannelVisibility>('public');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submitCreate(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const n = name.trim();
|
||||
if (!n || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const threadId = await create({ name: n, ...(topic.trim() ? { topic: topic.trim() } : {}), visibility });
|
||||
setName('');
|
||||
setTopic('');
|
||||
onJoined?.(threadId);
|
||||
} catch {
|
||||
// surfaced via the hook's error
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doJoin(threadId: string): Promise<void> {
|
||||
await join(threadId);
|
||||
onJoined?.(threadId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-browser">
|
||||
<div className="miu-browser-head">Channels</div>
|
||||
|
||||
<form className="miu-channel-create" onSubmit={submitCreate}>
|
||||
<input
|
||||
className="miu-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New channel name"
|
||||
aria-label="Channel name"
|
||||
/>
|
||||
<input
|
||||
className="miu-input"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="Topic (optional)"
|
||||
aria-label="Channel topic"
|
||||
/>
|
||||
<div className="miu-channel-vis">
|
||||
<label>
|
||||
<input type="radio" name="miu-vis" checked={visibility === 'public'} onChange={() => setVisibility('public')} /> Public
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="miu-vis" checked={visibility === 'private'} onChange={() => setVisibility('private')} /> Private
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="miu-send" disabled={!name.trim() || busy}>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="miu-browser-list">
|
||||
{loading && browsable.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{!loading && browsable.length === 0 ? <div className="miu-empty">No channels yet — create one above.</div> : null}
|
||||
{browsable.map((c) => (
|
||||
<div key={c.threadId} className="miu-browser-row">
|
||||
<span className="miu-channel-glyph" aria-hidden="true">{c.visibility === 'private' ? '🔒' : '#'}</span>
|
||||
<span className="miu-browser-main">
|
||||
<span className="miu-browser-name">{c.name}</span>
|
||||
{c.topic ? <span className="miu-browser-topic">{c.topic}</span> : null}
|
||||
<span className="miu-browser-meta">
|
||||
{c.memberCount} member{c.memberCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
{c.joined ? (
|
||||
<span className="miu-browser-joined">Joined</span>
|
||||
) : (
|
||||
<button type="button" className="miu-join" onClick={() => void doJoin(c.threadId)}>
|
||||
Join
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { Messenger } from './messenger';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<MessagingProvider adapter={new MockAdapter()}>
|
||||
<Messenger />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('channels (mock adapter)', () => {
|
||||
it('browse returns public channels + private ones I am in, with a joined flag', async () => {
|
||||
const a = new MockAdapter();
|
||||
const list = await a.browseChannels();
|
||||
const byId = new Map(list.map((c) => [c.threadId, c]));
|
||||
expect(byId.get('th_ch_general')?.joined).toBe(true); // public, I'm in
|
||||
expect(byId.get('th_ch_random')?.joined).toBe(false); // public, I'm NOT in
|
||||
expect(byId.get('th_ch_deals')?.joined).toBe(true); // private, I'm in
|
||||
// A private channel I'm not in must never surface in browse — none seeded, so all private here are mine.
|
||||
expect(list.every((c) => c.visibility === 'public' || c.joined)).toBe(true);
|
||||
});
|
||||
|
||||
it('join adds me and the channel then appears in my conversation list', async () => {
|
||||
const a = new MockAdapter();
|
||||
expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(false);
|
||||
await a.joinChannel('th_ch_random');
|
||||
expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(true);
|
||||
expect((await a.browseChannels()).find((c) => c.threadId === 'th_ch_random')?.joined).toBe(true);
|
||||
});
|
||||
|
||||
it('create makes a channel I am a member of', async () => {
|
||||
const a = new MockAdapter();
|
||||
const { threadId } = await a.createChannel({ name: 'design', topic: 'UI stuff', visibility: 'public' });
|
||||
const conv = (await a.listConversations()).find((c) => c.threadId === threadId);
|
||||
expect(conv?.membership).toBe('channel');
|
||||
expect(conv?.topic).toBe('UI stuff');
|
||||
});
|
||||
|
||||
it('UI: browse, then join a channel — it moves into the Channels section', async () => {
|
||||
mount();
|
||||
// Open the browser via the Channels section "+".
|
||||
fireEvent.click(await screen.findByTitle('Browse channels'));
|
||||
// #random is browse-only (not joined) → has a Join button.
|
||||
expect(await screen.findByText('random')).toBeTruthy();
|
||||
const joinButtons = screen.getAllByText('Join');
|
||||
fireEvent.click(joinButtons[0]!);
|
||||
// After joining, we jump to the thread and the browser closes → composer is shown.
|
||||
await waitFor(() => expect(screen.getByLabelText('Message')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import {
|
||||
SPECIAL_MENTIONS,
|
||||
insertMention,
|
||||
resolveMentions,
|
||||
trailingMentionQuery,
|
||||
} from '../mentions';
|
||||
import type { Attachment, Person, SendOpts } from '../types';
|
||||
|
||||
interface Suggestion {
|
||||
key: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
|
||||
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
|
||||
*/
|
||||
export function Composer({
|
||||
members,
|
||||
canUpload,
|
||||
upload,
|
||||
onSend,
|
||||
onTyping,
|
||||
parentInteractionId,
|
||||
placeholder = 'Type a message… @ to mention',
|
||||
}: {
|
||||
members: Person[];
|
||||
canUpload: boolean;
|
||||
upload: (file: File) => Promise<Attachment>;
|
||||
onSend: (text: string, opts?: SendOpts) => Promise<void>;
|
||||
onTyping?: () => void;
|
||||
parentInteractionId?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [staged, setStaged] = useState<Attachment | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const query = trailingMentionQuery(draft);
|
||||
const suggestions = useMemo<Suggestion[]>(() => {
|
||||
if (query === null) return [];
|
||||
const q = query.toLowerCase();
|
||||
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
|
||||
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
|
||||
return [...specials, ...people].slice(0, 6);
|
||||
}, [query, members]);
|
||||
const showSuggest = query !== null && suggestions.length > 0;
|
||||
|
||||
function pick(insert: string): void {
|
||||
setDraft((d) => insertMention(d, insert));
|
||||
}
|
||||
|
||||
async function onPickFile(e: ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadingName(file.name);
|
||||
try {
|
||||
setStaged(await upload(file));
|
||||
} catch {
|
||||
/* host surfaces upload errors */
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadingName(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e?: FormEvent): Promise<void> {
|
||||
e?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if ((!text && !staged) || sending) return;
|
||||
const mentions = resolveMentions(text, members);
|
||||
const att = staged;
|
||||
setDraft('');
|
||||
setStaged(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await onSend(text, {
|
||||
...(mentions.length ? { mentions } : {}),
|
||||
...(att ? { attachment: att } : {}),
|
||||
...(parentInteractionId ? { parentInteractionId } : {}),
|
||||
});
|
||||
} catch {
|
||||
setStaged(att);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
||||
if (showSuggest && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pick(suggestions[0]!.insert);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
{showSuggest ? (
|
||||
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.key}>
|
||||
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{uploading && uploadingName ? (
|
||||
<div className="miu-staged is-uploading">
|
||||
<span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…
|
||||
</div>
|
||||
) : staged ? (
|
||||
<div className="miu-staged">
|
||||
📎 {staged.name}
|
||||
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="miu-composer-row">
|
||||
{canUpload ? (
|
||||
<>
|
||||
<input ref={fileRef} type="file" className="miu-file-input" onChange={onPickFile} aria-label="Attach a file" />
|
||||
<button type="button" className="miu-attach-btn" title="Attach a file" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? '…' : '📎'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<input
|
||||
className="miu-input"
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
aria-label="Message"
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
onTyping?.();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Conversation } from '../types';
|
||||
|
||||
/** Initials for the avatar chip — first letters of the first two words. */
|
||||
function initials(title: string): string {
|
||||
const parts = title.trim().split(/\s+/).filter(Boolean);
|
||||
const chars = (parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? '');
|
||||
return (chars || '?').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational conversation list. Owns no data fetching — the parent passes the
|
||||
* conversations (from useConversations) so a host can also drive it from its own store.
|
||||
*/
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
conversations: Conversation[];
|
||||
selectedId?: string | null;
|
||||
onSelect: (threadId: string) => void;
|
||||
}) {
|
||||
if (conversations.length === 0) {
|
||||
return <div className="miu-empty">No conversations yet.</div>;
|
||||
}
|
||||
return (
|
||||
<ul className="miu-convlist" role="list">
|
||||
{conversations.map((c) => (
|
||||
<li key={c.threadId}>
|
||||
<button
|
||||
type="button"
|
||||
className={`miu-convrow${c.threadId === selectedId ? ' is-active' : ''}`}
|
||||
onClick={() => onSelect(c.threadId)}
|
||||
>
|
||||
<span className="miu-avatar" aria-hidden="true">
|
||||
{c.membership === 'channel' ? '#' : initials(c.title)}
|
||||
</span>
|
||||
<span className="miu-convrow-main">
|
||||
<span className="miu-convrow-title">{c.title}</span>
|
||||
{c.lastMessage ? <span className="miu-convrow-preview">{c.lastMessage}</span> : null}
|
||||
</span>
|
||||
{c.unread > 0 ? (
|
||||
<span className="miu-badge" aria-label={`${c.unread} unread`}>
|
||||
{c.unread}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { highlightMentions } from '../mentions';
|
||||
import { PopoverPortal } from './popover-portal';
|
||||
import type { Attachment } from '../types';
|
||||
import type { UiMessage } from '../hooks/use-messages';
|
||||
|
||||
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
||||
|
||||
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
||||
|
||||
function AttachmentView({ att }: { att: Attachment }) {
|
||||
if (isImage(att.mime)) {
|
||||
return (
|
||||
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-img-link">
|
||||
<img src={att.url} alt={att.name} className="miu-att-img" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-file">
|
||||
📎 {att.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick,
|
||||
* and — in the main thread only — a thread/reply affordance. */
|
||||
export function MessageItem({
|
||||
message,
|
||||
memberNames,
|
||||
canReact,
|
||||
onReact,
|
||||
seen,
|
||||
replyCount,
|
||||
onOpenThread,
|
||||
}: {
|
||||
message: UiMessage;
|
||||
memberNames: string[];
|
||||
canReact: boolean;
|
||||
onReact: (messageId: string, emoji: string) => void;
|
||||
seen: boolean;
|
||||
/** Present only in the main thread (not inside the pane). undefined => no thread affordance. */
|
||||
replyCount?: number;
|
||||
onOpenThread?: (messageId: string) => void;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const reactBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const m = message;
|
||||
|
||||
return (
|
||||
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
|
||||
<div className="miu-bubble-row">
|
||||
<div className="miu-bubble">
|
||||
{m.text ? highlightMentions(m.text, memberNames) : null}
|
||||
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
||||
</div>
|
||||
<div className="miu-msg-actions">
|
||||
{canReact ? (
|
||||
<div className="miu-react-wrap">
|
||||
<button ref={reactBtnRef} type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
|
||||
🙂
|
||||
</button>
|
||||
{pickerOpen ? (
|
||||
<PopoverPortal anchorRef={reactBtnRef} onClose={() => setPickerOpen(false)}>
|
||||
<div className="miu-react-picker">
|
||||
{REACTION_EMOJIS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
className="miu-react-emoji"
|
||||
onClick={() => {
|
||||
onReact(m.id, e);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPortal>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{onOpenThread ? (
|
||||
<button type="button" className="miu-react-btn" title="Reply in thread" onClick={() => onOpenThread(m.id)}>
|
||||
💬
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{m.reactions && m.reactions.length > 0 ? (
|
||||
<div className="miu-reactions">
|
||||
{m.reactions.map((r) => (
|
||||
<button key={r.emoji} type="button" className={`miu-reaction${r.mine ? ' is-mine' : ''}`} onClick={() => canReact && onReact(m.id, r.emoji)}>
|
||||
{r.emoji} {r.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{replyCount !== undefined && replyCount > 0 && onOpenThread ? (
|
||||
<button type="button" className="miu-thread-link" onClick={() => onOpenThread(m.id)}>
|
||||
💬 {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{message.mine && seen ? <span className="miu-seen">Seen</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { Messenger } from './messenger';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<MessagingProvider adapter={new MockAdapter()}>
|
||||
<Messenger />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<Messenger /> (rendered UI over an adapter)', () => {
|
||||
it('renders the conversation list and auto-selects the first thread', async () => {
|
||||
mount();
|
||||
// Seeded group conversation title from the mock.
|
||||
expect(await screen.findByText('Storm response — East side')).toBeTruthy();
|
||||
// Auto-selected thread shows its seeded message.
|
||||
expect(await screen.findByText('Crew is rolling out at 7.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sends a message through the adapter and shows it in the thread', async () => {
|
||||
mount();
|
||||
// Wait for the auto-selected thread's composer (avoids a race with the auto-select effect).
|
||||
const input = (await screen.findByLabelText('Message')) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'on our way' } });
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('on our way')).toBeTruthy());
|
||||
// Composer cleared after send.
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('switches threads when another conversation is clicked', async () => {
|
||||
mount();
|
||||
// Click the DM (its title is the other participant's name from the mock directory).
|
||||
fireEvent.click(await screen.findByText('Sofia Ramirez'));
|
||||
expect(await screen.findByText('Can you review the Henderson estimate?')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useConversations } from '../hooks/use-conversations';
|
||||
import { useAdapter } from '../provider';
|
||||
import { ConversationList } from './conversation-list';
|
||||
import { ChannelBrowser } from './channel-browser';
|
||||
import { NewConversation } from './new-conversation';
|
||||
import { Thread } from './thread';
|
||||
import { ThreadPane } from './thread-pane';
|
||||
|
||||
/**
|
||||
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
|
||||
* with a channel browser when the adapter supports channels. Owns only selection + browse state;
|
||||
* all data flows through the injected adapter via the hooks.
|
||||
*/
|
||||
export function Messenger() {
|
||||
const { conversations, loading, error, refetch } = useConversations();
|
||||
const adapter = useAdapter();
|
||||
const channelsSupported = typeof adapter.browseChannels === 'function';
|
||||
const directorySupported = typeof adapter.directory === 'function';
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const [composing, setComposing] = useState(false); // "New message" people picker open
|
||||
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
|
||||
|
||||
useEffect(() => {
|
||||
if (browsing || composing) return;
|
||||
if (selected && conversations.some((c) => c.threadId === selected)) return;
|
||||
setSelected(conversations[0]?.threadId ?? null);
|
||||
}, [conversations, selected, browsing, composing]);
|
||||
|
||||
// Switching conversations (or into browse/compose) closes any open thread pane.
|
||||
useEffect(() => setActiveRoot(null), [selected, browsing, composing]);
|
||||
|
||||
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
||||
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
||||
|
||||
function pick(threadId: string): void {
|
||||
setBrowsing(false);
|
||||
setComposing(false);
|
||||
setSelected(threadId);
|
||||
}
|
||||
|
||||
function openBrowse(): void {
|
||||
setComposing(false);
|
||||
setBrowsing(true);
|
||||
}
|
||||
|
||||
function openCompose(): void {
|
||||
setBrowsing(false);
|
||||
setComposing(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-messenger">
|
||||
<aside className="miu-sidebar">
|
||||
{loading && conversations.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
|
||||
{channelsSupported ? (
|
||||
<div className="miu-section">
|
||||
<div className="miu-section-head">
|
||||
<span>Channels</span>
|
||||
<button type="button" className="miu-section-add" title="Browse channels" onClick={openBrowse}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{channels.length > 0 ? (
|
||||
<ConversationList conversations={channels} selectedId={browsing || composing ? null : selected} onSelect={pick} />
|
||||
) : (
|
||||
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="miu-section">
|
||||
{channelsSupported || directorySupported ? (
|
||||
<div className="miu-section-head">
|
||||
<span>Direct messages</span>
|
||||
{directorySupported ? (
|
||||
<button type="button" className="miu-section-add" title="New message" onClick={openCompose}>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ConversationList conversations={dms} selectedId={browsing || composing ? null : selected} onSelect={pick} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="miu-main">
|
||||
{browsing ? (
|
||||
<ChannelBrowser
|
||||
onJoined={(threadId) => {
|
||||
refetch();
|
||||
pick(threadId);
|
||||
}}
|
||||
/>
|
||||
) : composing ? (
|
||||
<NewConversation
|
||||
onCreated={(threadId) => {
|
||||
refetch();
|
||||
pick(threadId);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!browsing && !composing && selected && activeRoot ? (
|
||||
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, type ReactNode, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// The SDK theme tokens. A body-portaled node is outside the `.miu-messenger`/`.miu-inbox`
|
||||
// subtree that defines these, so we copy their resolved values onto the portal root.
|
||||
const THEME_VARS = [
|
||||
'--miu-bg', '--miu-panel', '--miu-panel-2', '--miu-border',
|
||||
'--miu-text', '--miu-muted', '--miu-accent', '--miu-accent-text', '--miu-radius',
|
||||
] as const;
|
||||
|
||||
export function copyThemeVars(): Record<string, string> {
|
||||
if (typeof document === 'undefined') return {};
|
||||
const src = document.querySelector('.miu-messenger, .miu-inbox');
|
||||
if (!src) return {};
|
||||
const cs = getComputedStyle(src);
|
||||
const out: Record<string, string> = {};
|
||||
for (const v of THEME_VARS) {
|
||||
const val = cs.getPropertyValue(v).trim();
|
||||
if (val) out[v] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an overlay into document.body so it escapes the host's stacking context and overflow.
|
||||
* A host wrapper like `.view { position: relative; z-index: 1 }` traps a `position: fixed` overlay
|
||||
* BELOW a sibling sticky header no matter how high its z-index — the only robust fix is to leave
|
||||
* that stacking context entirely. Theme tokens are copied from the live surface (captured once on
|
||||
* mount, synchronously, so there is no unstyled first paint) and re-applied on the portal root.
|
||||
*/
|
||||
export function ModalPortal({ children }: { children: ReactNode }) {
|
||||
const [vars] = useState<Record<string, string>>(copyThemeVars);
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(<div className="miu-portal" style={vars as CSSProperties}>{children}</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
import { Messenger } from './messenger';
|
||||
|
||||
function mount(adapter = new MockAdapter()) {
|
||||
render(
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<Messenger />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
describe('MockAdapter directory + openThread', () => {
|
||||
it('directory lists people you can message', async () => {
|
||||
const a = new MockAdapter();
|
||||
const people = await a.directory();
|
||||
expect(people.some((p) => p.name === 'Dan Whitaker')).toBe(true);
|
||||
});
|
||||
|
||||
it('one participant opens a dm; two+ open a group with a subject', async () => {
|
||||
const a = new MockAdapter();
|
||||
const dm = await a.openThread({ participantIds: ['pp_dan'], membership: 'dm' });
|
||||
const list = await a.listConversations();
|
||||
expect(list.find((c) => c.threadId === dm.threadId)?.membership).toBe('dm');
|
||||
|
||||
const group = await a.openThread({ participantIds: ['pp_dan', 'pp_priya'], membership: 'group', subject: 'Roof crew' });
|
||||
const g = (await a.listConversations()).find((c) => c.threadId === group.threadId);
|
||||
expect(g?.membership).toBe('group');
|
||||
expect(g?.title).toBe('Roof crew');
|
||||
});
|
||||
|
||||
it('opening a dm with the same person reuses the existing thread', async () => {
|
||||
const a = new MockAdapter();
|
||||
const first = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
|
||||
const second = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
|
||||
expect(second.threadId).toBe(first.threadId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<Messenger /> new conversation flow', () => {
|
||||
it('opens the picker from the Direct messages +, and starting a chat leaves the picker', async () => {
|
||||
mount();
|
||||
// Open the "New message" picker.
|
||||
fireEvent.click(await screen.findByTitle('New message'));
|
||||
expect(await screen.findByText('New message')).toBeTruthy();
|
||||
|
||||
// Pick a person and start a DM.
|
||||
fireEvent.click(await screen.findByText('Dan Whitaker'));
|
||||
fireEvent.click(screen.getByText('Start chat'));
|
||||
|
||||
// The picker closes (we're back in a thread view — the picker heading is gone).
|
||||
await waitFor(() => expect(screen.queryByText('Start chat')).toBeNull());
|
||||
});
|
||||
|
||||
it('selecting two people switches the action to group create', async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByTitle('New message'));
|
||||
fireEvent.click(await screen.findByText('Dan Whitaker'));
|
||||
fireEvent.click(await screen.findByText('Priya Nair'));
|
||||
expect(screen.getByText(/Create group \(2\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText('Group name')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import type { Person } from '../types';
|
||||
|
||||
/**
|
||||
* Start a direct message or a group — Slack-style. Pick people from the org directory: one selected
|
||||
* opens a DM (deduped by the adapter), two or more create a group with an optional name. Shown in
|
||||
* the main pane like the channel browser.
|
||||
*/
|
||||
export function NewConversation({ onCreated }: { onCreated?: (threadId: string) => void }) {
|
||||
const adapter = useAdapter();
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adapter.directory) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
adapter
|
||||
.directory()
|
||||
.then((p) => {
|
||||
if (alive) {
|
||||
setPeople(p);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter]);
|
||||
|
||||
const filtered = people.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
const isGroup = selected.length > 1;
|
||||
const canStart = selected.length >= 1 && !busy;
|
||||
|
||||
function toggle(id: string): void {
|
||||
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
if (!canStart) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await adapter.openThread({
|
||||
participantIds: selected,
|
||||
membership: isGroup ? 'group' : 'dm',
|
||||
...(isGroup && name.trim() ? { subject: name.trim() } : {}),
|
||||
});
|
||||
onCreated?.(res.threadId);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-browser">
|
||||
<div className="miu-browser-head">New message</div>
|
||||
<div className="miu-newconv">
|
||||
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
|
||||
{isGroup ? (
|
||||
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Group name (optional)" aria-label="Group name" />
|
||||
) : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
<div className="miu-browser-list">
|
||||
{loading && people.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{!loading && filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
|
||||
{filtered.map((p) => (
|
||||
<label key={p.id} className={`miu-person-row${selected.includes(p.id) ? ' is-active' : ''}`}>
|
||||
<input type="checkbox" checked={selected.includes(p.id)} onChange={() => toggle(p.id)} />
|
||||
<span className="miu-browser-name">{p.name}</span>
|
||||
<span className="miu-pill">{p.kind}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="miu-send" onClick={() => void start()} disabled={!canStart}>
|
||||
{busy ? 'Starting…' : isGroup ? `Create group (${selected.length})` : 'Start chat'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useLayoutEffect, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { copyThemeVars } from './modal-portal';
|
||||
|
||||
/**
|
||||
* A small popover (e.g. the reaction picker) rendered into document.body so it is never clipped by
|
||||
* a scroll container's `overflow: hidden` — the reported "emoji picker goes beneath the container"
|
||||
* bug. Positioned fixed just below the anchor, right-aligned to it, and re-placed on scroll/resize.
|
||||
* Closes on outside pointer-down, scroll of a different element, or Escape. Carries the SDK theme
|
||||
* tokens (copied from the live surface) since a body-portaled node is outside the themed subtree.
|
||||
*/
|
||||
export function PopoverPortal({
|
||||
anchorRef,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [vars] = useState(copyThemeVars);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
const place = (): void => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setPos({ top: r.bottom + 4, left: r.right });
|
||||
};
|
||||
place();
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: PointerEvent): void => {
|
||||
const target = e.target as Node;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
if ((target as Element).closest?.('.miu-popover')) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
if (typeof document === 'undefined' || !pos) return null;
|
||||
return createPortal(
|
||||
<div className="miu-portal">
|
||||
<div className="miu-popover" style={{ top: pos.top, left: pos.left, ...vars } as CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { Messenger } from './messenger';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<MessagingProvider adapter={new MockAdapter()}>
|
||||
<Messenger />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Slack-style thread pane', () => {
|
||||
it('opens a thread on 💬, posts a reply into it, and shows the reply count on the parent', async () => {
|
||||
mount();
|
||||
// Wait for the auto-selected thread's message to render WITH its actions (not just the sidebar
|
||||
// preview), then open the thread pane via the message's reply affordance (💬).
|
||||
const replyButtons = await screen.findAllByTitle('Reply in thread');
|
||||
fireEvent.click(replyButtons[0]!);
|
||||
expect(await screen.findByText('Thread')).toBeTruthy(); // pane header
|
||||
|
||||
// The pane's composer (placeholder "Reply…") — send a threaded reply.
|
||||
const replyInput = screen.getByPlaceholderText('Reply…') as HTMLInputElement;
|
||||
fireEvent.change(replyInput, { target: { value: 'on it' } });
|
||||
// The pane has its own Send; grab the last one (pane is rendered after the main composer).
|
||||
const sends = screen.getAllByText('Send');
|
||||
fireEvent.click(sends[sends.length - 1]!);
|
||||
|
||||
// The reply shows in the pane (replies are hidden from the main thread, so this is unique)...
|
||||
await waitFor(() => expect(screen.getByText('on it')).toBeTruthy());
|
||||
// ...and the parent now advertises the reply count as a thread-link button in the main thread.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /1 reply/ })).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMessages } from '../hooks/use-messages';
|
||||
import { useMembers } from '../hooks/use-members';
|
||||
import { Composer } from './composer';
|
||||
import { MessageItem } from './message-item';
|
||||
|
||||
/**
|
||||
* The Slack-style thread side panel: the root message + its replies + a composer that posts back
|
||||
* into the thread (parentInteractionId = root). Uses its own useMessages on the same thread; the
|
||||
* shared adapter subscription keeps it and the main view in sync.
|
||||
*/
|
||||
export function ThreadPane({
|
||||
threadId,
|
||||
rootId,
|
||||
onClose,
|
||||
}: {
|
||||
threadId: string;
|
||||
rootId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { messages, send, react, upload, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
||||
const members = useMembers(threadId);
|
||||
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||
|
||||
const root = useMemo(() => messages.find((m) => m.id === rootId), [messages, rootId]);
|
||||
const replies = useMemo(() => messages.filter((m) => m.parentInteractionId === rootId), [messages, rootId]);
|
||||
|
||||
return (
|
||||
<aside className="miu-pane">
|
||||
<header className="miu-pane-head">
|
||||
<span>Thread</span>
|
||||
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close thread">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="miu-messages miu-pane-messages">
|
||||
{root ? (
|
||||
<MessageItem message={root} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(root.id)} />
|
||||
) : (
|
||||
<div className="miu-empty">Message not found.</div>
|
||||
)}
|
||||
<div className="miu-pane-divider">{replies.length} {replies.length === 1 ? 'reply' : 'replies'}</div>
|
||||
{replies.map((m) => (
|
||||
<MessageItem key={m.id} message={m} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(m.id)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Composer
|
||||
members={members}
|
||||
canUpload={canUpload}
|
||||
upload={upload}
|
||||
onSend={send}
|
||||
onTyping={sendTyping}
|
||||
parentInteractionId={rootId}
|
||||
placeholder="Reply…"
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMessages } from '../hooks/use-messages';
|
||||
import { useMembers } from '../hooks/use-members';
|
||||
import { Composer } from './composer';
|
||||
import { MessageItem } from './message-item';
|
||||
|
||||
/**
|
||||
* The main conversation view: top-level messages + composer. Replies (messages with a
|
||||
* parentInteractionId) are hidden here and live in the ThreadPane — a message with replies shows a
|
||||
* "N replies" link that opens it. @mentions, reactions, and attachments all work.
|
||||
*/
|
||||
export function Thread({
|
||||
threadId,
|
||||
activeRootId,
|
||||
onOpenThread,
|
||||
}: {
|
||||
threadId: string | null;
|
||||
activeRootId?: string | null;
|
||||
onOpenThread?: (rootId: string) => void;
|
||||
}) {
|
||||
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
||||
const members = useMembers(threadId);
|
||||
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||
|
||||
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
|
||||
const replyCount = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const m of messages) if (m.parentInteractionId) counts.set(m.parentInteractionId, (counts.get(m.parentInteractionId) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [messages]);
|
||||
|
||||
if (!threadId) {
|
||||
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
|
||||
<div className="miu-messages">
|
||||
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{topLevel.map((m) => (
|
||||
<MessageItem
|
||||
key={m.id}
|
||||
message={m}
|
||||
memberNames={memberNames}
|
||||
canReact={canReact}
|
||||
onReact={(id, emoji) => void react(id, emoji)}
|
||||
seen={seenIds.has(m.id)}
|
||||
replyCount={replyCount.get(m.id) ?? 0}
|
||||
{...(onOpenThread ? { onOpenThread } : {})}
|
||||
/>
|
||||
))}
|
||||
{typingUserIds.length > 0 ? (
|
||||
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { runAdapterConformance } from './conformance';
|
||||
import { MockAdapter } from './adapters/mock';
|
||||
|
||||
runAdapterConformance({
|
||||
makeAdapter: () => new MockAdapter(),
|
||||
seededThreadId: 'th_mock_1',
|
||||
openWith: ['pp_sofia'],
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { MessagingAdapter } from './adapter';
|
||||
import type { MessageEvent } from './types';
|
||||
|
||||
export interface ConformanceOptions {
|
||||
/** Build a fresh, isolated adapter per test. */
|
||||
makeAdapter: () => Promise<MessagingAdapter> | MessagingAdapter;
|
||||
/** A thread id that exists in the fixture. */
|
||||
seededThreadId: string;
|
||||
/** Participant ids openThread can legally be called with. */
|
||||
openWith: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition of a correct MessagingAdapter. Every adapter runs this.
|
||||
*
|
||||
* Imports vitest, so it ships from the './conformance' subpath ONLY and is never
|
||||
* reachable from the main barrel. Consumers supply their own vitest.
|
||||
*
|
||||
* Usage from another package:
|
||||
* import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance';
|
||||
* runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] });
|
||||
*/
|
||||
export function runAdapterConformance(opts: ConformanceOptions): void {
|
||||
const make = async () => await opts.makeAdapter();
|
||||
|
||||
describe('MessagingAdapter conformance', () => {
|
||||
it('lists conversations', async () => {
|
||||
const a = await make();
|
||||
const list = await a.listConversations();
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns history for a seeded thread', async () => {
|
||||
const a = await make();
|
||||
const msgs = await a.history(opts.seededThreadId);
|
||||
expect(Array.isArray(msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('send resolves with a message carrying the sent text and a stable id', async () => {
|
||||
const a = await make();
|
||||
const m = await a.send(opts.seededThreadId, 'conformance hello');
|
||||
expect(m.text).toBe('conformance hello');
|
||||
expect(typeof m.id).toBe('string');
|
||||
expect(m.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a sent message is attributed to the current actor', async () => {
|
||||
const a = await make();
|
||||
const m = await a.send(opts.seededThreadId, 'whose is this');
|
||||
expect(m.actorId).toBe(a.currentActorId());
|
||||
});
|
||||
|
||||
it('currentActorId is known BEFORE any message is sent', async () => {
|
||||
// The bug this SDK exists to kill: identity must come from auth, never be
|
||||
// inferred from history. A fresh adapter already knows who you are.
|
||||
const a = await make();
|
||||
expect(a.currentActorId()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('a sent message appears in history', async () => {
|
||||
const a = await make();
|
||||
await a.send(opts.seededThreadId, 'persist me');
|
||||
const msgs = await a.history(opts.seededThreadId);
|
||||
expect(msgs.some((m) => m.text === 'persist me')).toBe(true);
|
||||
});
|
||||
|
||||
it('subscribe delivers a message event on send', async () => {
|
||||
const a = await make();
|
||||
const seen: MessageEvent[] = [];
|
||||
const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
|
||||
await a.send(opts.seededThreadId, 'live one');
|
||||
off();
|
||||
const msgs = seen.filter((e) => e.kind === 'message');
|
||||
expect(msgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('unsubscribe stops delivery', async () => {
|
||||
const a = await make();
|
||||
const seen: MessageEvent[] = [];
|
||||
const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
|
||||
off();
|
||||
await a.send(opts.seededThreadId, 'should not be heard');
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unsubscribe is idempotent', async () => {
|
||||
const a = await make();
|
||||
const off = a.subscribe(opts.seededThreadId, () => {});
|
||||
off();
|
||||
expect(() => off()).not.toThrow();
|
||||
});
|
||||
|
||||
it('openThread returns a thread id', async () => {
|
||||
const a = await make();
|
||||
const { threadId } = await a.openThread({ participantIds: opts.openWith });
|
||||
expect(typeof threadId).toBe('string');
|
||||
expect(threadId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('markRead resolves', async () => {
|
||||
const a = await make();
|
||||
const m = await a.send(opts.seededThreadId, 'read me');
|
||||
await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('sendTyping does not throw', async () => {
|
||||
const a = await make();
|
||||
expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import type { ChannelSummary, CreateChannelInput } from '../types';
|
||||
|
||||
export interface ChannelsState {
|
||||
/** Discoverable channels (public + private-I'm-in), each flagged `joined`. */
|
||||
browsable: ChannelSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Whether the adapter implements channels at all — drives showing/hiding the channels UI. */
|
||||
supported: boolean;
|
||||
refetch: () => void;
|
||||
create: (input: CreateChannelInput) => Promise<string>;
|
||||
join: (threadId: string) => Promise<void>;
|
||||
leave: (threadId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChannels(): ChannelsState {
|
||||
const adapter = useAdapter();
|
||||
const supported = typeof adapter.browseChannels === 'function';
|
||||
const [browsable, setBrowsable] = useState<ChannelSummary[]>([]);
|
||||
const [loading, setLoading] = useState(supported);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adapter.browseChannels) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
adapter
|
||||
.browseChannels()
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setBrowsable(list);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setBrowsable([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, nonce]);
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
||||
|
||||
const create = useCallback(
|
||||
async (input: CreateChannelInput) => {
|
||||
if (!adapter.createChannel) throw new Error('channels not supported by this adapter');
|
||||
const { threadId } = await adapter.createChannel(input);
|
||||
setNonce((n) => n + 1);
|
||||
return threadId;
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
const join = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!adapter.joinChannel) throw new Error('channels not supported by this adapter');
|
||||
await adapter.joinChannel(threadId);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
const leave = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!adapter.leaveChannel) throw new Error('channels not supported by this adapter');
|
||||
await adapter.leaveChannel(threadId);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return { browsable, loading, error, supported, refetch, create, join, leave };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
import { useConversations } from './use-conversations';
|
||||
import type { MessagingAdapter } from '../adapter';
|
||||
|
||||
const wrap = (adapter: MessagingAdapter) =>
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
|
||||
};
|
||||
|
||||
describe('useConversations', () => {
|
||||
it('starts loading, then resolves the adapter list', async () => {
|
||||
const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) });
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
// 2 DMs/groups + 2 channels I'm a member of (the un-joined public channel is browse-only).
|
||||
expect(result.current.conversations).toHaveLength(4);
|
||||
expect(result.current.conversations[0]!.threadId).toBe('th_mock_1');
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces adapter failure as error state and never throws', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down'));
|
||||
|
||||
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe('data door down');
|
||||
expect(result.current.conversations).toEqual([]);
|
||||
});
|
||||
|
||||
it('refetch picks up newly opened threads', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
|
||||
await waitFor(() => expect(result.current.conversations).toHaveLength(4));
|
||||
|
||||
await act(async () => {
|
||||
await adapter.openThread({ participantIds: ['pp_dan'] });
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.refetch();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.conversations).toHaveLength(5));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import type { Conversation } from '../types';
|
||||
|
||||
export interface ConversationsState {
|
||||
conversations: Conversation[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useConversations(): ConversationsState {
|
||||
const adapter = useAdapter();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
adapter
|
||||
.listConversations()
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setConversations(list);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setConversations([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, nonce]);
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
||||
|
||||
return { conversations, loading, error, refetch };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import type { Person } from '../types';
|
||||
|
||||
/** A thread's members (for @mention autocomplete + highlighting). Empty if the adapter
|
||||
* doesn't implement listMembers, or while loading. */
|
||||
export function useMembers(threadId: string | null): Person[] {
|
||||
const adapter = useAdapter();
|
||||
const [members, setMembers] = useState<Person[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadId || !adapter.listMembers) {
|
||||
setMembers([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
adapter
|
||||
.listMembers(threadId)
|
||||
.then((m) => {
|
||||
if (alive) setMembers(m);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setMembers([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, threadId]);
|
||||
|
||||
return members;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
import { useMessages } from './use-messages';
|
||||
import type { MessagingAdapter } from '../adapter';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const wrap = (adapter: MessagingAdapter) =>
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
|
||||
};
|
||||
|
||||
describe('useMessages', () => {
|
||||
it('loads history for the thread', async () => {
|
||||
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?');
|
||||
});
|
||||
|
||||
// REGRESSION: the CRM inferred actor identity by scanning for a sent message, so
|
||||
// before you had spoken in a thread EVERY message rendered as not-yours.
|
||||
it('marks ownership correctly before the user has sent anything', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
await adapter.send('th_mock_1', 'an earlier message of mine');
|
||||
|
||||
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
||||
|
||||
// Never sent anything via the hook — ownership still resolves from currentActorId().
|
||||
expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia
|
||||
expect(result.current.messages[1]!.mine).toBe(true); // from me
|
||||
});
|
||||
|
||||
it('appends an optimistic message immediately on send', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
let release!: () => void;
|
||||
vi.spyOn(adapter, 'send').mockImplementation(
|
||||
() => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => { void result.current.send('hi'); });
|
||||
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
||||
expect(result.current.messages[1]!.pending).toBe(true);
|
||||
expect(result.current.messages[1]!.mine).toBe(true);
|
||||
|
||||
await act(async () => { release(); });
|
||||
await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy());
|
||||
});
|
||||
|
||||
it('rolls back the optimistic message and reports error when send fails', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline'));
|
||||
|
||||
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.send('doomed')).rejects.toThrow('offline');
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false);
|
||||
expect(result.current.error).toBe('offline');
|
||||
});
|
||||
|
||||
it('does not duplicate a message when the transport echoes it back', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// MockAdapter.send emits a 'message' event AND resolves with the same message.
|
||||
await act(async () => { await result.current.send('echo once'); });
|
||||
|
||||
expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collects typing user ids from subscribe events', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
let emit!: (userId: string) => void;
|
||||
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
|
||||
emit = (userId) => cb({ kind: 'typing', userId });
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => emit('pp_sofia'));
|
||||
expect(result.current.typingUserIds).toEqual(['pp_sofia']);
|
||||
});
|
||||
|
||||
it('unsubscribes on unmount', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
const off = vi.fn();
|
||||
vi.spyOn(adapter, 'subscribe').mockReturnValue(off);
|
||||
|
||||
const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
unmount();
|
||||
|
||||
expect(off).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a live message that arrives before history resolves', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
let resolveHistory!: (msgs: Message[]) => void;
|
||||
vi.spyOn(adapter, 'history').mockImplementation(
|
||||
() => new Promise<Message[]>((res) => { resolveHistory = res; }),
|
||||
);
|
||||
let emit!: (m: Message) => void;
|
||||
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
|
||||
emit = (m) => cb({ kind: 'message', message: m });
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||
|
||||
// A live message arrives while history() is still pending.
|
||||
act(() => emit({ id: 'live_1', actorId: 'pp_sofia', text: 'ping before history', at: '2026-07-17T10:00:00.000Z' }));
|
||||
|
||||
// History resolves afterwards with an older message.
|
||||
await act(async () => {
|
||||
resolveHistory([{ id: 'hist_1', actorId: 'pp_sofia', text: 'older', at: '2026-07-17T09:00:00.000Z' }]);
|
||||
});
|
||||
|
||||
const texts = result.current.messages.map((m) => m.text);
|
||||
expect(texts).toContain('older');
|
||||
expect(texts).toContain('ping before history'); // must NOT be clobbered by history load
|
||||
});
|
||||
|
||||
it('clears a typing indicator after its TTL elapses', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const adapter = new MockAdapter();
|
||||
let emit!: (userId: string) => void;
|
||||
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
|
||||
emit = (userId) => cb({ kind: 'typing', userId });
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0); }); // flush history microtask
|
||||
|
||||
act(() => emit('pp_sofia'));
|
||||
expect(result.current.typingUserIds).toEqual(['pp_sofia']);
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3600); });
|
||||
expect(result.current.typingUserIds).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import { isOwnMessage } from '../types';
|
||||
import type { Attachment, Message, SendOpts } from '../types';
|
||||
|
||||
const TYPING_TTL_MS = 3500;
|
||||
|
||||
export interface UiMessage extends Message {
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
export interface MessagesState {
|
||||
messages: UiMessage[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
send: (content: string, opts?: SendOpts) => Promise<void>;
|
||||
react: (messageId: string, emoji: string) => Promise<void>;
|
||||
upload: (file: File) => Promise<Attachment>;
|
||||
typingUserIds: string[];
|
||||
seenIds: Set<string>;
|
||||
sendTyping: () => void;
|
||||
canReact: boolean;
|
||||
canUpload: boolean;
|
||||
}
|
||||
|
||||
let optimisticSeq = 0;
|
||||
|
||||
export function useMessages(threadId: string | null): MessagesState {
|
||||
const adapter = useAdapter();
|
||||
const [raw, setRaw] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [typing, setTyping] = useState<Record<string, number>>({});
|
||||
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const currentActorId = adapter.currentActorId();
|
||||
const actorRef = useRef(currentActorId);
|
||||
actorRef.current = currentActorId;
|
||||
|
||||
// Load history, then subscribe. Reconciliation is by message id, so an echoed
|
||||
// send never duplicates the optimistic row.
|
||||
useEffect(() => {
|
||||
if (!threadId) {
|
||||
setRaw([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setRaw([]);
|
||||
setError(null);
|
||||
setSeenIds(new Set());
|
||||
setTyping({});
|
||||
|
||||
adapter
|
||||
.history(threadId)
|
||||
.then((h) => {
|
||||
if (!alive) return;
|
||||
// Merge, don't clobber: a live message can arrive via subscribe while this
|
||||
// history fetch is still in flight. Blindly setting raw = h would drop it.
|
||||
setRaw((live) => {
|
||||
const histIds = new Set(h.map((m) => m.id));
|
||||
const extras = live.filter((m) => !histIds.has(m.id));
|
||||
return extras.length ? [...h, ...extras] : h;
|
||||
});
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
|
||||
const off = adapter.subscribe(threadId, (e) => {
|
||||
if (!alive) return;
|
||||
switch (e.kind) {
|
||||
case 'message':
|
||||
setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message]));
|
||||
break;
|
||||
case 'typing':
|
||||
if (e.userId !== actorRef.current) {
|
||||
setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS }));
|
||||
}
|
||||
break;
|
||||
case 'receipt':
|
||||
// Only the OTHER side reading my message counts as "seen".
|
||||
if (e.actorId !== actorRef.current) {
|
||||
setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId)));
|
||||
}
|
||||
break;
|
||||
case 'reaction':
|
||||
setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m)));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
off();
|
||||
};
|
||||
}, [adapter, threadId]);
|
||||
|
||||
const messages: UiMessage[] = useMemo(
|
||||
() => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })),
|
||||
[raw, currentActorId],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (content: string, opts?: SendOpts) => {
|
||||
if (!threadId) return;
|
||||
const tempId = `optimistic_${optimisticSeq++}`;
|
||||
const optimistic: Message = {
|
||||
id: tempId,
|
||||
actorId: actorRef.current,
|
||||
text: content,
|
||||
at: new Date().toISOString(),
|
||||
pending: true,
|
||||
reactions: [],
|
||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||
...(opts?.attachment ? { attachment: opts.attachment } : {}),
|
||||
};
|
||||
setRaw((l) => [...l, optimistic]);
|
||||
|
||||
try {
|
||||
const saved = await adapter.send(threadId, content, opts);
|
||||
setError(null);
|
||||
// Replace the optimistic row with the server's. If the subscribe echo already
|
||||
// added the real message, just drop the optimistic one.
|
||||
setRaw((l) => {
|
||||
const withoutTemp = l.filter((m) => m.id !== tempId);
|
||||
return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved];
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
setRaw((l) => l.filter((m) => m.id !== tempId));
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[adapter, threadId],
|
||||
);
|
||||
|
||||
const react = useCallback(
|
||||
async (messageId: string, emoji: string) => {
|
||||
if (!threadId || !adapter.react) return;
|
||||
await adapter.react(threadId, messageId, emoji);
|
||||
},
|
||||
[adapter, threadId],
|
||||
);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File): Promise<Attachment> => {
|
||||
if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
|
||||
return adapter.upload(file);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
const sendTyping = useCallback(() => {
|
||||
if (threadId) adapter.sendTyping(threadId);
|
||||
}, [adapter, threadId]);
|
||||
|
||||
// The newest acknowledged (non-pending) message id — what we report as read.
|
||||
const lastReadableId = useMemo(() => {
|
||||
for (let i = raw.length - 1; i >= 0; i--) {
|
||||
if (!raw[i]!.pending) return raw[i]!.id;
|
||||
}
|
||||
return null;
|
||||
}, [raw]);
|
||||
|
||||
// Report my read of the newest message (drives the other side's "seen" tick).
|
||||
// Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.
|
||||
useEffect(() => {
|
||||
if (!threadId || !lastReadableId) return;
|
||||
void adapter.markRead(threadId, lastReadableId).catch(() => {});
|
||||
}, [adapter, threadId, lastReadableId]);
|
||||
|
||||
const typingUserIds = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return Object.entries(typing)
|
||||
.filter(([, exp]) => exp > now)
|
||||
.map(([u]) => u);
|
||||
}, [typing]);
|
||||
|
||||
// Expire stale typing entries. Bumping `typing` to a new reference forces the
|
||||
// memo above to recompute with a fresh `now`, dropping entries past their TTL.
|
||||
// (A bump of unrelated state can't do this — the memo is keyed on `typing`, so it
|
||||
// would return its cached array and the indicator would stick forever.)
|
||||
useEffect(() => {
|
||||
if (typingUserIds.length === 0) return;
|
||||
const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [typingUserIds.length, typing]);
|
||||
|
||||
// Only my messages that the other side has read.
|
||||
const seenMine = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
|
||||
return out;
|
||||
}, [seenIds, messages]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
send,
|
||||
react,
|
||||
upload,
|
||||
typingUserIds,
|
||||
seenIds: seenMine,
|
||||
sendTyping,
|
||||
canReact: typeof adapter.react === 'function',
|
||||
canUpload: typeof adapter.upload === 'function',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MailAttachment, InboxItem, InboxState, MailMessage, MailPerson } from './types';
|
||||
|
||||
/**
|
||||
* The inbox seam. A host implements this; the SDK renders it. Mirrors MessagingAdapter's philosophy:
|
||||
* `listInbox` returns the UNIFIED feed (work items + mail folded in) so the folding logic lives in
|
||||
* one place (the adapter, which knows both sources). Compose methods are optional and degrade.
|
||||
*/
|
||||
export interface InboxAdapter {
|
||||
/** The unified inbox: work items + mail threads as rows, filtered by state (mail shows in OPEN). */
|
||||
listInbox(state?: InboxState): Promise<InboxItem[]>;
|
||||
|
||||
/** Transition a work item's state (Done/Snooze/Archive…). Mail rows are not transitioned. */
|
||||
transition(id: string, state: InboxState): Promise<void>;
|
||||
|
||||
/** Messages of one mail thread (HTML + text parts) for the reader. */
|
||||
mailHistory(threadId: string): Promise<MailMessage[]>;
|
||||
|
||||
/** Reply into an existing mail thread, optionally with one attachment. */
|
||||
mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void>;
|
||||
|
||||
// ── Attachments (optional) — absent hides the attach affordance ──
|
||||
/** Upload a file to storage, returning a reference to send with a reply/compose. */
|
||||
uploadAttachment?(file: File): Promise<MailAttachment>;
|
||||
|
||||
/** Resolve a short-lived URL to view/download an attachment. Absent => attachment chips are
|
||||
* shown but not clickable. */
|
||||
downloadAttachment?(attachment: MailAttachment): Promise<string>;
|
||||
|
||||
// ── Compose (optional) — absent hides the "New message" affordance ──
|
||||
/** People you can compose an in-app message to. */
|
||||
directory?(): Promise<MailPerson[]>;
|
||||
/** App-to-app mail (no SMTP) to a registered user's in-app inbox. */
|
||||
composeInternal?(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
|
||||
/** External email (SMTP) to an address. */
|
||||
composeExternal?(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useInboxAdapter } from './provider';
|
||||
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './types';
|
||||
|
||||
export interface InboxData {
|
||||
items: InboxItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
transition: (id: string, state: InboxState) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/** The unified inbox for a given filter state. Refetches when the filter changes. */
|
||||
export function useInbox(state?: InboxState): InboxData {
|
||||
const adapter = useInboxAdapter();
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
adapter
|
||||
.listInbox(state)
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setItems(list);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, state, nonce]);
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
||||
const transition = useCallback(
|
||||
async (id: string, next: InboxState) => {
|
||||
await adapter.transition(id, next);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return { items, loading, error, transition, refetch };
|
||||
}
|
||||
|
||||
export interface MailThreadState {
|
||||
messages: MailMessage[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
|
||||
canAttach: boolean;
|
||||
upload: (file: File) => Promise<MailAttachment>;
|
||||
canDownload: boolean;
|
||||
download: (attachment: MailAttachment) => Promise<string>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/** One mail thread: history + reply. */
|
||||
export function useMailThread(threadId: string | null): MailThreadState {
|
||||
const adapter = useInboxAdapter();
|
||||
const [messages, setMessages] = useState<MailMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
adapter
|
||||
.mailHistory(threadId)
|
||||
.then((m) => {
|
||||
if (!alive) return;
|
||||
setMessages(m);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, threadId, nonce]);
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
||||
const reply = useCallback(
|
||||
async (content: string, attachment?: MailAttachment) => {
|
||||
if (!threadId) return;
|
||||
await adapter.mailReply(threadId, content, attachment);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter, threadId],
|
||||
);
|
||||
const upload = useCallback(
|
||||
async (file: File) => {
|
||||
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
|
||||
return adapter.uploadAttachment(file);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
const download = useCallback(
|
||||
async (attachment: MailAttachment) => {
|
||||
if (!adapter.downloadAttachment) throw new Error('attachment download is not supported by this adapter');
|
||||
return adapter.downloadAttachment(attachment);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
reply,
|
||||
canAttach: typeof adapter.uploadAttachment === 'function',
|
||||
upload,
|
||||
canDownload: typeof adapter.downloadAttachment === 'function',
|
||||
download,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposeState {
|
||||
supported: boolean;
|
||||
directory: MailPerson[];
|
||||
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
|
||||
sendExternal: (target: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
|
||||
canAttach: boolean;
|
||||
upload: (file: File) => Promise<MailAttachment>;
|
||||
}
|
||||
|
||||
/** Compose a new message — in-app (to a person) or external (to an email). */
|
||||
export function useCompose(): ComposeState {
|
||||
const adapter = useInboxAdapter();
|
||||
const supported = typeof adapter.composeInternal === 'function';
|
||||
const [directory, setDirectory] = useState<MailPerson[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adapter.directory) return;
|
||||
let alive = true;
|
||||
adapter
|
||||
.directory()
|
||||
.then((d) => {
|
||||
if (alive) setDirectory(d);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setDirectory([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter]);
|
||||
|
||||
const sendInternal = useCallback(
|
||||
async (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => {
|
||||
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
|
||||
await adapter.composeInternal(recipientUserId, subject, text, attachments);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
const sendExternal = useCallback(
|
||||
async (target: string, subject: string, text: string, attachments?: MailAttachment[]) => {
|
||||
if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter');
|
||||
await adapter.composeExternal(target, subject, text, attachments);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
const upload = useCallback(
|
||||
async (file: File) => {
|
||||
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
|
||||
return adapter.uploadAttachment(file);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return { supported, directory, sendInternal, sendExternal, canAttach: typeof adapter.uploadAttachment === 'function', upload };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { InboxProvider } from './provider';
|
||||
import { Inbox } from './inbox';
|
||||
import { MockInboxAdapter } from '../adapters/mock-inbox';
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<InboxProvider adapter={new MockInboxAdapter()}>
|
||||
<Inbox />
|
||||
</InboxProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('mock inbox adapter', () => {
|
||||
it('unifies work items + mail in the Open view; other states drop mail', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
const open = await a.listInbox('OPEN');
|
||||
expect(open.some((i) => i.kind === 'MAIL')).toBe(true);
|
||||
expect(open.some((i) => i.kind === 'MENTION')).toBe(true);
|
||||
const done = await a.listInbox('DONE');
|
||||
expect(done.some((i) => i.kind === 'MAIL')).toBe(false);
|
||||
});
|
||||
|
||||
it('transition moves a work item out of Open', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
await a.transition('in_3', 'DONE');
|
||||
expect((await a.listInbox('OPEN')).some((i) => i.id === 'in_3')).toBe(false);
|
||||
expect((await a.listInbox('DONE')).some((i) => i.id === 'in_3')).toBe(true);
|
||||
});
|
||||
|
||||
it('mail reply appends to the thread', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
await a.mailReply('mt_welcome', 'thanks!');
|
||||
expect((await a.mailHistory('mt_welcome')).some((m) => m.text === 'thanks!')).toBe(true);
|
||||
});
|
||||
|
||||
it('reply carries an uploaded attachment', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
const ref = await a.uploadAttachment(new File(['x'], 'plan.pdf', { type: 'application/pdf' }));
|
||||
expect(ref).toMatchObject({ filename: 'plan.pdf', mimeType: 'application/pdf' });
|
||||
await a.mailReply('mt_welcome', '', ref);
|
||||
const last = (await a.mailHistory('mt_welcome')).at(-1)!;
|
||||
expect(last.attachment?.filename).toBe('plan.pdf');
|
||||
});
|
||||
|
||||
it('composeInternal carries a first attachment onto the new thread', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
const ref = await a.uploadAttachment(new File(['x'], 'quote.png', { type: 'image/png' }));
|
||||
await a.composeInternal('pp_sofia', 'Quote', 'see attached', [ref]);
|
||||
const open = await a.listInbox('OPEN');
|
||||
const row = open.find((i) => i.title === 'Quote')!;
|
||||
expect((await a.mailHistory(row.threadId!)).at(-1)?.attachment?.filename).toBe('quote.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<Inbox /> (rendered)', () => {
|
||||
it('lists items and opens a mail thread on click', async () => {
|
||||
mount();
|
||||
// A folded mail row is present.
|
||||
const welcome = await screen.findByText('Welcome to the Founders Club');
|
||||
fireEvent.click(welcome);
|
||||
// The reader opens with a reply box.
|
||||
expect(await screen.findByLabelText('Reply')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('replies into a mail thread', async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
|
||||
const input = (await screen.findByLabelText('Reply')) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'got it' } });
|
||||
fireEvent.click(screen.getByText('Reply'));
|
||||
await waitFor(() => expect(screen.getByText('got it')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('attaches a file into a mail reply', async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
|
||||
const file = new File(['data'], 'roof.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(await screen.findByLabelText('Attach file'), { target: { files: [file] } });
|
||||
// The pending chip shows the file, then Reply sends it.
|
||||
await screen.findByText(/roof\.pdf/);
|
||||
fireEvent.click(screen.getByText('Reply'));
|
||||
await waitFor(() => expect(screen.getAllByText(/roof\.pdf/).length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('composes an in-app message and it shows in the inbox', async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText('New message'));
|
||||
// pick a recipient
|
||||
fireEvent.click(await screen.findByText('Sofia Ramirez'));
|
||||
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Quick q' } });
|
||||
fireEvent.change(screen.getByLabelText('Message body'), { target: { value: 'ping' } });
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(screen.getByText('Quick q')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { ModalPortal } from '../components/modal-portal';
|
||||
import { useCompose, useInbox, useMailThread } from './hooks';
|
||||
import type { InboxItem, InboxState, MailAttachment, MailPerson } from './types';
|
||||
|
||||
const fmtBytes = (n: number): string => {
|
||||
if (!n) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const FILTERS: { value: InboxState; label: string }[] = [
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'SNOOZED', label: 'Snoozed' },
|
||||
{ value: 'DONE', label: 'Done' },
|
||||
{ value: 'ARCHIVED', label: 'Archived' },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
MAIL: 'Mail',
|
||||
MENTION: 'Mention',
|
||||
NEEDS_REPLY: 'Needs reply',
|
||||
SYSTEM_ALERT: 'Alert',
|
||||
SUPPORT_UPDATE: 'Support',
|
||||
};
|
||||
|
||||
const timeOf = (iso?: string): string => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(+d) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */
|
||||
export function Inbox() {
|
||||
const [filter, setFilter] = useState<InboxState>('OPEN');
|
||||
const { items, loading, error, transition, refetch } = useInbox(filter);
|
||||
const compose = useCompose();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [composing, setComposing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId && items.some((i) => i.id === selectedId)) return;
|
||||
setSelectedId(items[0]?.id ?? null);
|
||||
}, [items, selectedId]);
|
||||
|
||||
const selected = items.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="miu-inbox">
|
||||
<div className="miu-inbox-bar">
|
||||
<div className="miu-inbox-filters">
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.value} type="button" className={`miu-tab${filter === f.value ? ' is-active' : ''}`} onClick={() => setFilter(f.value)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{compose.supported ? (
|
||||
<button type="button" className="miu-send" onClick={() => setComposing(true)}>
|
||||
New message
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="miu-inbox-body">
|
||||
<aside className="miu-inbox-list">
|
||||
{loading && items.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{!loading && items.length === 0 ? <div className="miu-empty">Nothing here — you're all caught up 🎉</div> : null}
|
||||
{items.map((it) => (
|
||||
<button key={it.id} type="button" className={`miu-inbox-row${it.id === selectedId ? ' is-active' : ''}`} onClick={() => setSelectedId(it.id)}>
|
||||
<span className="miu-inbox-glyph" aria-hidden="true">{it.kind === 'MAIL' ? '✉' : it.kind === 'MENTION' ? '@' : '•'}</span>
|
||||
<span className="miu-inbox-main">
|
||||
<span className="miu-inbox-row-top">
|
||||
<span className="miu-pill">{KIND_LABEL[it.kind] ?? it.kind}</span>
|
||||
<span className="miu-inbox-title">{it.title}</span>
|
||||
</span>
|
||||
{it.summary ? <span className="miu-inbox-summary">{it.summary}</span> : null}
|
||||
</span>
|
||||
{it.state !== 'OPEN' ? <span className="miu-pill">{it.state.toLowerCase()}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<section className="miu-inbox-detail">
|
||||
{selected ? <Detail item={selected} onTransition={(s) => void transition(selected.id, s)} /> : <div className="miu-empty miu-thread-empty">Select an item to read.</div>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{composing ? <ComposeModal onClose={() => setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) {
|
||||
return (
|
||||
<div className="miu-detail">
|
||||
{item.state === 'OPEN' && item.kind !== 'MAIL' ? (
|
||||
<div className="miu-detail-actions">
|
||||
<button type="button" className="miu-tab" onClick={() => onTransition('SNOOZED')}>Snooze</button>
|
||||
<button type="button" className="miu-tab" onClick={() => onTransition('DONE')}>Done</button>
|
||||
<button type="button" className="miu-tab" onClick={() => onTransition('ARCHIVED')}>Archive</button>
|
||||
</div>
|
||||
) : null}
|
||||
{item.threadId ? (
|
||||
<MailReader threadId={item.threadId} subject={item.title} />
|
||||
) : (
|
||||
<div className="miu-detail-body">
|
||||
<div className="miu-detail-title">{item.title}</div>
|
||||
{item.summary ? <div className="miu-detail-summary">{item.summary}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
|
||||
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
|
||||
const { messages, loading, error, reply, canAttach, upload, canDownload, download } = useMailThread(threadId);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [pending, setPending] = useState<MailAttachment | null>(null);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||
const [attachErr, setAttachErr] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function openAttachment(att: MailAttachment): Promise<void> {
|
||||
try {
|
||||
const url = await download(att);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch (err) {
|
||||
setAttachErr(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
setUploadingName(file.name);
|
||||
setAttachErr(null);
|
||||
try {
|
||||
setPending(await upload(file));
|
||||
} catch (err) {
|
||||
setAttachErr(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
setUploadingName(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const text = draft.trim();
|
||||
if ((!text && !pending) || sending) return;
|
||||
const att = pending;
|
||||
setDraft('');
|
||||
setPending(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await reply(text, att ?? undefined);
|
||||
} catch {
|
||||
setDraft(text);
|
||||
setPending(att);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-mail">
|
||||
<header className="miu-mail-head">{subject || '(no subject)'}</header>
|
||||
<div className="miu-mail-body">
|
||||
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{messages.map((m) => (
|
||||
<article key={m.id} className="miu-mail-msg">
|
||||
<div className="miu-mail-meta">
|
||||
<span>{m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''}</span>
|
||||
<span>{timeOf(m.at)}</span>
|
||||
</div>
|
||||
{m.html ? (
|
||||
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
|
||||
) : m.text ? (
|
||||
<div className="miu-mail-text">{m.text}</div>
|
||||
) : null}
|
||||
{m.attachment ? (
|
||||
canDownload ? (
|
||||
<button type="button" className="miu-attach-chip miu-attach-dl" onClick={() => void openAttachment(m.attachment!)} title="Download">
|
||||
📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''} ↓
|
||||
</button>
|
||||
) : (
|
||||
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
|
||||
)
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
|
||||
{attaching && uploadingName ? (
|
||||
<div className="miu-attach-pending">
|
||||
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…</span>
|
||||
</div>
|
||||
) : pending ? (
|
||||
<div className="miu-attach-pending">
|
||||
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
|
||||
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment">✕</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="miu-composer-row">
|
||||
{canAttach ? (
|
||||
<>
|
||||
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
|
||||
<button type="button" className="miu-attach-btn" onClick={() => fileRef.current?.click()} disabled={attaching || !!pending} title="Attach a file" aria-label="Attach a file">
|
||||
{attaching ? '…' : '📎'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<input className="miu-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply…" aria-label="Reply" />
|
||||
<button type="submit" className="miu-send" disabled={(!draft.trim() && !pending) || sending}>Reply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
|
||||
const compose = useCompose();
|
||||
const [mode, setMode] = useState<'internal' | 'external'>('internal');
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
const canSend = !!recipient && !!subject.trim() && (!!body.trim() || attachments.length > 0) && !busy && !attaching;
|
||||
|
||||
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || attachments.length >= 10) return;
|
||||
setAttaching(true);
|
||||
setUploadingName(file.name);
|
||||
setErr(null);
|
||||
try {
|
||||
const ref = await compose.upload(file);
|
||||
setAttachments((a) => [...a, ref]);
|
||||
} catch (e2) {
|
||||
setErr(e2 instanceof Error ? e2.message : String(e2));
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
setUploadingName(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function send(): Promise<void> {
|
||||
if (!canSend) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
const atts = attachments.length > 0 ? attachments : undefined;
|
||||
try {
|
||||
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim(), atts);
|
||||
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), atts);
|
||||
onSent();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div className="miu-modal-overlay" onMouseDown={onClose}>
|
||||
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="miu-modal-head">
|
||||
<span>New message</span>
|
||||
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div className="miu-modal-body">
|
||||
<div className="miu-compose-modes">
|
||||
<button type="button" className={`miu-tab${mode === 'internal' ? ' is-active' : ''}`} onClick={() => { setMode('internal'); setRecipient(''); }}>In-app</button>
|
||||
<button type="button" className={`miu-tab${mode === 'external' ? ' is-active' : ''}`} onClick={() => { setMode('external'); setRecipient(''); }}>Email</button>
|
||||
</div>
|
||||
|
||||
{mode === 'internal' ? (
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">To (person)</span>
|
||||
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
|
||||
<div className="miu-people">
|
||||
{filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
|
||||
{filtered.map((p: MailPerson) => (
|
||||
<label key={p.id} className={`miu-person${recipient === p.id ? ' is-active' : ''}`}>
|
||||
<input type="radio" name="miu-recipient" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
|
||||
<span>{p.name}</span>
|
||||
<span className="miu-pill">{p.kind}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">To (email)</span>
|
||||
<input className="miu-input" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" aria-label="To email" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Subject</span>
|
||||
<input className="miu-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" aria-label="Subject" />
|
||||
</div>
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Message</span>
|
||||
<textarea className="miu-input miu-textarea" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={5} aria-label="Message body" />
|
||||
</div>
|
||||
{compose.canAttach ? (
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Attachments</span>
|
||||
<div className="miu-attach-list">
|
||||
{attachments.map((a, i) => (
|
||||
<span key={`${a.contentRef}-${i}`} className="miu-attach-pending">
|
||||
<span className="miu-attach-chip">📎 {a.filename ?? 'attachment'}{a.sizeBytes ? ` · ${fmtBytes(a.sizeBytes)}` : ''}</span>
|
||||
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment">✕</button>
|
||||
</span>
|
||||
))}
|
||||
{attaching && uploadingName ? (
|
||||
<span className="miu-attach-pending">
|
||||
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…</span>
|
||||
</span>
|
||||
) : null}
|
||||
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
|
||||
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
|
||||
📎 Attach
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{err ? <div className="miu-empty miu-error">{err}</div> : null}
|
||||
</div>
|
||||
<div className="miu-modal-foot">
|
||||
<button type="button" className="miu-tab" onClick={onClose}>Cancel</button>
|
||||
<button type="button" className="miu-send" onClick={() => void send()} disabled={!canSend}>{busy ? 'Sending…' : 'Send'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { InboxAdapter } from './adapter';
|
||||
|
||||
const InboxContext = createContext<InboxAdapter | null>(null);
|
||||
|
||||
export function InboxProvider({ adapter, children }: { adapter: InboxAdapter; children: ReactNode }) {
|
||||
return <InboxContext.Provider value={adapter}>{children}</InboxContext.Provider>;
|
||||
}
|
||||
|
||||
/** Access the host-injected inbox adapter. Throws outside a provider — a missing provider is a
|
||||
* wiring bug, and failing loudly beats a confusing null-deref three layers down. */
|
||||
export function useInboxAdapter(): InboxAdapter {
|
||||
const adapter = useContext(InboxContext);
|
||||
if (!adapter) throw new Error('useInboxAdapter must be used within an <InboxProvider>');
|
||||
return adapter;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Domain types for the inbox surface (work items + mail). Zero transport imports.
|
||||
|
||||
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
|
||||
|
||||
/** A unified inbox row — a work item (mention/needs-reply/alert) OR a mail thread. */
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
kind: string; // MAIL | MENTION | NEEDS_REPLY | SYSTEM_ALERT | …
|
||||
state: InboxState;
|
||||
title: string;
|
||||
summary?: string;
|
||||
priority: string;
|
||||
/** Present when the row opens a conversation/mail thread. */
|
||||
threadId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A mail attachment reference (bytes live in storage; the reader resolves a display URL). */
|
||||
export interface MailAttachment {
|
||||
contentRef: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
filename: string | null;
|
||||
}
|
||||
|
||||
/** One message in a mail thread — HTML and/or plain text, optionally an attachment. */
|
||||
export interface MailMessage {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
kind: string;
|
||||
at: string;
|
||||
html: string | null;
|
||||
text: string | null;
|
||||
attachment: MailAttachment | null;
|
||||
}
|
||||
|
||||
/** A person you can compose an in-app message to. */
|
||||
export interface MailPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'staff' | 'customer';
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Public API. Components land in Plan 2; adapters/kernel in Plan 3.
|
||||
//
|
||||
// `runAdapterConformance` is deliberately NOT exported here — it imports vitest and
|
||||
// ships from the './conformance' subpath so consumers never pull a test runner into
|
||||
// their production bundle.
|
||||
export { MessagingProvider, useAdapter } from './provider';
|
||||
export { useConversations } from './hooks/use-conversations';
|
||||
export { useMessages } from './hooks/use-messages';
|
||||
export { useChannels } from './hooks/use-channels';
|
||||
export { useMembers } from './hooks/use-members';
|
||||
export { isOwnMessage } from './types';
|
||||
|
||||
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
|
||||
export { Messenger } from './components/messenger';
|
||||
export { ConversationList } from './components/conversation-list';
|
||||
export { ChannelBrowser } from './components/channel-browser';
|
||||
export { Thread } from './components/thread';
|
||||
export { ThreadPane } from './components/thread-pane';
|
||||
|
||||
// ── Inbox domain (work items + mail) ──
|
||||
export { InboxProvider, useInboxAdapter } from './inbox/provider';
|
||||
export { useInbox, useMailThread, useCompose } from './inbox/hooks';
|
||||
export { Inbox, MailReader } from './inbox/inbox';
|
||||
export type { InboxAdapter } from './inbox/adapter';
|
||||
export type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './inbox/types';
|
||||
|
||||
export type { MessagingAdapter } from './adapter';
|
||||
export type { ConversationsState } from './hooks/use-conversations';
|
||||
export type { MessagesState, UiMessage } from './hooks/use-messages';
|
||||
export type { ChannelsState } from './hooks/use-channels';
|
||||
export type {
|
||||
Attachment,
|
||||
ChannelSummary,
|
||||
ChannelVisibility,
|
||||
Conversation,
|
||||
CreateChannelInput,
|
||||
Membership,
|
||||
Message,
|
||||
MessageEvent,
|
||||
Person,
|
||||
Reaction,
|
||||
SendOpts,
|
||||
Unsubscribe,
|
||||
} from './types';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessagingProvider } from './provider';
|
||||
import { Messenger } from './components/messenger';
|
||||
import { MockAdapter } from './adapters/mock';
|
||||
import { insertMention, resolveMentions, trailingMentionQuery } from './mentions';
|
||||
import type { Person } from './types';
|
||||
|
||||
const people: Person[] = [
|
||||
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
|
||||
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
|
||||
];
|
||||
|
||||
describe('mention helpers', () => {
|
||||
it('trailingMentionQuery finds the @token being typed', () => {
|
||||
expect(trailingMentionQuery('hey @Sof')).toBe('Sof');
|
||||
expect(trailingMentionQuery('@')).toBe('');
|
||||
expect(trailingMentionQuery('no mention here')).toBeNull();
|
||||
expect(trailingMentionQuery('done @Sofia Ramirez ')).toBeNull(); // completed, trailing space
|
||||
});
|
||||
|
||||
it('insertMention replaces the trailing query, keeping the boundary', () => {
|
||||
expect(insertMention('hey @Sof', 'Sofia Ramirez')).toBe('hey @Sofia Ramirez ');
|
||||
expect(insertMention('@ch', 'channel')).toBe('@channel ');
|
||||
});
|
||||
|
||||
it('resolveMentions maps names to ids; @channel expands to everyone', () => {
|
||||
expect(resolveMentions('ping @Sofia Ramirez', people)).toEqual(['pp_sofia']);
|
||||
expect(resolveMentions('nobody here', people)).toEqual([]);
|
||||
expect(resolveMentions('@channel ship it', people).sort()).toEqual(['pp_dan', 'pp_sofia']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<Thread /> @mention autocomplete', () => {
|
||||
it('picking a suggestion inserts the name and send carries the mention id', async () => {
|
||||
const adapter = new MockAdapter();
|
||||
const sendSpy = vi.spyOn(adapter, 'send');
|
||||
render(
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<Messenger />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
const input = (await screen.findByLabelText('Message')) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'hey @Sof' } });
|
||||
|
||||
// The suggestion (role=option) is distinct from the sidebar row of the same name.
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Sofia Ramirez' }));
|
||||
expect(input.value).toBe('hey @Sofia Ramirez ');
|
||||
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(sendSpy).toHaveBeenCalled());
|
||||
const opts = sendSpy.mock.calls[0]![2];
|
||||
expect(opts?.mentions).toContain('pp_sofia');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Person } from './types';
|
||||
|
||||
/** Room-wide mention tokens, always offered alongside members. */
|
||||
export const SPECIAL_MENTIONS = ['channel', 'here'];
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** The @token currently being typed at the end of the draft (caret-at-end), or null. */
|
||||
export function trailingMentionQuery(draft: string): string | null {
|
||||
const m = draft.match(/(?:^|\s)@([\w]*)$/);
|
||||
return m ? (m[1] ?? '') : null;
|
||||
}
|
||||
|
||||
/** Replace the trailing @query with `@insert ` (keeping the leading boundary). */
|
||||
export function insertMention(draft: string, insert: string): string {
|
||||
return draft.replace(/(^|\s)@([\w]*)$/, (_full, lead: string) => `${lead}@${insert} `);
|
||||
}
|
||||
|
||||
/** Resolve the opaque mention userId list from the final text + known members. */
|
||||
export function resolveMentions(text: string, members: Person[]): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const p of members) if (text.includes(`@${p.name}`)) ids.add(p.id);
|
||||
if (/@channel\b/.test(text) || /@here\b/.test(text)) for (const p of members) ids.add(p.id);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Render text with @mentions (member names + @channel/@here) wrapped for highlighting. */
|
||||
export function highlightMentions(text: string, memberNames: string[]): ReactNode[] {
|
||||
// Longest-first so "@Sofia Ramirez" wins over a bare "@Sofia".
|
||||
const names = [...new Set([...memberNames, ...SPECIAL_MENTIONS])].filter(Boolean).sort((a, b) => b.length - a.length);
|
||||
if (names.length === 0) return [text];
|
||||
const re = new RegExp(`@(${names.map(esc).join('|')})`, 'g');
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let key = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
out.push(
|
||||
<span key={`m${key++}`} className="miu-mention">
|
||||
{m[0]}
|
||||
</span>,
|
||||
);
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out.length > 0 ? out : [text];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MessagingProvider, useAdapter } from './provider';
|
||||
import { MockAdapter } from './adapters/mock';
|
||||
|
||||
function ShowActor() {
|
||||
const adapter = useAdapter();
|
||||
return <span>{adapter.currentActorId()}</span>;
|
||||
}
|
||||
|
||||
describe('MessagingProvider', () => {
|
||||
it('supplies the injected adapter to descendants', () => {
|
||||
render(
|
||||
<MessagingProvider adapter={new MockAdapter()}>
|
||||
<ShowActor />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
expect(screen.getByText('me')).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws a helpful error when a hook is used outside the provider', () => {
|
||||
// React logs the error boundary trace; that noise is expected.
|
||||
expect(() => render(<ShowActor />)).toThrow(/useAdapter must be used within a <MessagingProvider>/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { MessagingAdapter } from './adapter';
|
||||
|
||||
const AdapterContext = createContext<MessagingAdapter | null>(null);
|
||||
|
||||
export function MessagingProvider({
|
||||
adapter,
|
||||
children,
|
||||
}: {
|
||||
adapter: MessagingAdapter;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <AdapterContext.Provider value={adapter}>{children}</AdapterContext.Provider>;
|
||||
}
|
||||
|
||||
/** Access the host-injected adapter. Throws outside a provider — a missing provider is
|
||||
* a wiring bug, and failing loudly beats a confusing null-deref three layers down. */
|
||||
export function useAdapter(): MessagingAdapter {
|
||||
const adapter = useContext(AdapterContext);
|
||||
if (!adapter) throw new Error('useAdapter must be used within a <MessagingProvider>');
|
||||
return adapter;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
describe('test infrastructure', () => {
|
||||
it('renders React components in jsdom', () => {
|
||||
render(<div>messaging-ui</div>);
|
||||
expect(screen.getByText('messaging-ui')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,967 @@
|
||||
/* Default theme for @insignia/iios-messaging-ui. Everything is driven by CSS variables
|
||||
scoped to .miu-messenger, so a host restyles by overriding the tokens — no component edits. */
|
||||
.miu-messenger {
|
||||
--miu-bg: #0e0e13;
|
||||
--miu-panel: #16161d;
|
||||
--miu-panel-2: #1d1d26;
|
||||
--miu-border: #262631;
|
||||
--miu-text: #e9e9ef;
|
||||
--miu-muted: #9a9aa7;
|
||||
--miu-accent: #fda913;
|
||||
--miu-accent-text: #1a1206;
|
||||
--miu-radius: 12px;
|
||||
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
color: var(--miu-text);
|
||||
background: var(--miu-bg);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.miu-sidebar {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--miu-border);
|
||||
overflow-y: auto;
|
||||
background: var(--miu-panel);
|
||||
}
|
||||
|
||||
.miu-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* conversation list */
|
||||
.miu-convlist {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.miu-convrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.miu-convrow:hover {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-convrow.is-active {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--miu-accent-text);
|
||||
background: var(--miu-accent);
|
||||
}
|
||||
.miu-convrow-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.miu-convrow-title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.miu-convrow-preview {
|
||||
color: var(--miu-muted);
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.miu-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--miu-accent-text);
|
||||
background: var(--miu-accent);
|
||||
}
|
||||
|
||||
/* thread */
|
||||
.miu-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.miu-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.miu-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: 78%;
|
||||
}
|
||||
.miu-msg.is-mine {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.miu-bubble {
|
||||
padding: 8px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.miu-msg.is-mine .miu-bubble {
|
||||
border: none;
|
||||
color: var(--miu-accent-text);
|
||||
background: var(--miu-accent);
|
||||
}
|
||||
.miu-msg.is-pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.miu-bubble-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.miu-msg.is-mine .miu-bubble-row {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.miu-msg-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.miu-thread-link {
|
||||
align-self: flex-start;
|
||||
margin-top: 3px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-msg.is-mine .miu-thread-link {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.miu-react-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.miu-react-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
padding: 2px;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.miu-msg:hover .miu-react-btn {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.miu-react-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Portaled to <body> via .miu-popover so it's never clipped by a scroll container. */
|
||||
.miu-popover {
|
||||
position: fixed;
|
||||
z-index: 2147483000;
|
||||
transform: translateX(-100%); /* right-align the picker's right edge to the anchor */
|
||||
}
|
||||
.miu-react-picker {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
width: max-content;
|
||||
}
|
||||
/* On my own (right-aligned) messages, anchor the picker to the left instead so it stays in view. */
|
||||
.miu-react-emoji {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.miu-react-emoji:hover {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-reactions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.miu-reaction {
|
||||
font-size: 12px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel-2);
|
||||
color: var(--miu-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-reaction.is-mine {
|
||||
border-color: var(--miu-accent);
|
||||
}
|
||||
|
||||
/* attachments */
|
||||
.miu-att-img-link {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.miu-att-img {
|
||||
max-width: 260px;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-att-file {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel-2);
|
||||
color: var(--miu-text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.miu-seen {
|
||||
font-size: 11px;
|
||||
color: var(--miu-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.miu-typing {
|
||||
font-size: 12.5px;
|
||||
color: var(--miu-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* mentions */
|
||||
.miu-mention {
|
||||
color: var(--miu-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.miu-msg.is-mine .miu-bubble .miu-mention {
|
||||
color: var(--miu-accent-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* composer */
|
||||
.miu-composer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-composer-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.miu-file-input {
|
||||
display: none;
|
||||
}
|
||||
.miu-attach-btn {
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-text);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.miu-staged {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.miu-staged-x {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--miu-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* inbox mail attachments */
|
||||
.miu-attach-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel-2);
|
||||
color: var(--miu-text);
|
||||
font-size: 12.5px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.miu-mail-msg .miu-attach-chip {
|
||||
margin: 8px 12px 12px;
|
||||
}
|
||||
button.miu-attach-dl {
|
||||
cursor: pointer;
|
||||
color: var(--miu-text);
|
||||
}
|
||||
button.miu-attach-dl:hover {
|
||||
border-color: var(--miu-accent);
|
||||
color: var(--miu-accent);
|
||||
}
|
||||
.miu-attach-pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-attach-chip.is-uploading,
|
||||
.miu-staged.is-uploading {
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--miu-border);
|
||||
border-top-color: var(--miu-accent);
|
||||
border-radius: 50%;
|
||||
animation: miu-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes miu-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.miu-spinner { animation-duration: 2s; }
|
||||
}
|
||||
.miu-attach-x {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--miu-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.miu-attach-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.miu-suggest {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(100% - 4px);
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: var(--miu-radius);
|
||||
background: var(--miu-panel);
|
||||
box-shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.6);
|
||||
z-index: 5;
|
||||
}
|
||||
.miu-suggest-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--miu-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-suggest-item:hover {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-input {
|
||||
flex: 1;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.miu-input:focus {
|
||||
border-color: var(--miu-accent);
|
||||
}
|
||||
.miu-send {
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: var(--miu-accent-text);
|
||||
background: var(--miu-accent);
|
||||
}
|
||||
.miu-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* sidebar sections + channel browser */
|
||||
.miu-section {
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-section-add {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--miu-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.miu-section-add:hover {
|
||||
color: var(--miu-text);
|
||||
}
|
||||
.miu-empty-sm {
|
||||
padding: 8px 14px 12px;
|
||||
text-align: left;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.miu-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.miu-browser-head {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-channel-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: var(--miu-radius);
|
||||
background: var(--miu-panel);
|
||||
}
|
||||
.miu-channel-vis {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-channel-vis label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* new conversation (DM / group) picker */
|
||||
.miu-newconv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
/* Submit buttons in the stacked create/compose forms size to content, not full width. */
|
||||
.miu-newconv .miu-send,
|
||||
.miu-channel-create .miu-send {
|
||||
align-self: flex-start;
|
||||
height: 38px;
|
||||
}
|
||||
.miu-person-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: 10px;
|
||||
background: var(--miu-panel);
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-person-row:hover {
|
||||
border-color: var(--miu-accent);
|
||||
}
|
||||
.miu-person-row.is-active {
|
||||
border-color: var(--miu-accent);
|
||||
background: color-mix(in srgb, var(--miu-accent) 10%, var(--miu-panel));
|
||||
}
|
||||
.miu-person-row .miu-browser-name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.miu-browser-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-browser-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: var(--miu-radius);
|
||||
background: var(--miu-panel);
|
||||
}
|
||||
.miu-channel-glyph {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: var(--miu-muted);
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-browser-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.miu-browser-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.miu-browser-topic {
|
||||
font-size: 12.5px;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-browser-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-join {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 14px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: var(--miu-accent-text);
|
||||
background: var(--miu-accent);
|
||||
}
|
||||
.miu-browser-joined {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
|
||||
/* thread pane (Slack-style) */
|
||||
.miu-pane {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
}
|
||||
.miu-pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-pane-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--miu-muted);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
}
|
||||
.miu-pane-messages {
|
||||
background: var(--miu-bg);
|
||||
}
|
||||
.miu-pane-divider {
|
||||
font-size: 11.5px;
|
||||
color: var(--miu-muted);
|
||||
text-align: center;
|
||||
margin: 4px 0;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
}
|
||||
|
||||
/* ── Inbox ── */
|
||||
.miu-inbox {
|
||||
--miu-bg: #0e0e13;
|
||||
--miu-panel: #16161d;
|
||||
--miu-panel-2: #1d1d26;
|
||||
--miu-border: #262631;
|
||||
--miu-text: #e9e9ef;
|
||||
--miu-muted: #9a9aa7;
|
||||
--miu-accent: #fda913;
|
||||
--miu-accent-text: #1a1206;
|
||||
--miu-radius: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
color: var(--miu-text);
|
||||
background: var(--miu-bg);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
.miu-inbox-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-inbox-filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.miu-tab {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-tab.is-active {
|
||||
background: var(--miu-accent);
|
||||
color: var(--miu-accent-text);
|
||||
border-color: var(--miu-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-inbox-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.miu-inbox-list {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
}
|
||||
.miu-inbox-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.miu-inbox-row:hover,
|
||||
.miu-inbox-row.is-active {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-inbox-glyph {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--miu-muted);
|
||||
background: var(--miu-panel-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-inbox-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.miu-inbox-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
color: var(--miu-muted);
|
||||
background: var(--miu-panel-2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.miu-inbox-title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.miu-inbox-summary {
|
||||
color: var(--miu-muted);
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.miu-inbox-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--miu-bg);
|
||||
}
|
||||
.miu-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.miu-detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-detail-body {
|
||||
padding: 22px;
|
||||
}
|
||||
.miu-detail-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.miu-detail-summary {
|
||||
color: var(--miu-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* mail reader */
|
||||
.miu-mail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.miu-mail-head {
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.miu-mail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.miu-mail-msg {
|
||||
flex: 0 0 auto; /* don't let the flex column shrink cards — they'd clip their own content */
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: var(--miu-radius);
|
||||
background: var(--miu-panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.miu-mail-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
font-size: 12px;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-mail-frame {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
.miu-mail-text {
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* compose modal — portaled to <body>, so carry SDK theme-token defaults here (the host's
|
||||
own token mapping + a synchronous copy on the portal root override these). */
|
||||
.miu-portal {
|
||||
--miu-bg: #0e0e13;
|
||||
--miu-panel: #16161d;
|
||||
--miu-panel-2: #1d1d26;
|
||||
--miu-border: #262631;
|
||||
--miu-text: #e9e9ef;
|
||||
--miu-muted: #9a9aa7;
|
||||
--miu-accent: #fda913;
|
||||
--miu-accent-text: #1a1206;
|
||||
--miu-radius: 12px;
|
||||
}
|
||||
.miu-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483000;
|
||||
background: rgba(2, 2, 6, 0.6);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.miu-modal {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: 16px;
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-text);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.miu-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-modal-body {
|
||||
padding: 16px 18px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.miu-modal-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-compose-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-field-lbl {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-textarea {
|
||||
resize: vertical;
|
||||
min-height: 90px;
|
||||
}
|
||||
.miu-people {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.miu-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-person.is-active {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-person span:nth-of-type(1) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* misc */
|
||||
.miu-empty {
|
||||
padding: 24px;
|
||||
color: var(--miu-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.miu-thread-empty {
|
||||
margin: auto;
|
||||
}
|
||||
.miu-error {
|
||||
color: #f0563f;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// @testing-library/react auto-registers cleanup only when afterEach is a global.
|
||||
// We run with globals: false, so register it explicitly.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isOwnMessage } from './types';
|
||||
import type { Message } from './types';
|
||||
|
||||
const base: Message = {
|
||||
id: 'm1',
|
||||
actorId: 'actor_a',
|
||||
text: 'hello',
|
||||
at: '2026-07-17T10:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('isOwnMessage', () => {
|
||||
it('is true when the message actor matches the current actor', () => {
|
||||
expect(isOwnMessage(base, 'actor_a')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the actors differ', () => {
|
||||
expect(isOwnMessage(base, 'actor_b')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the current actor is unknown', () => {
|
||||
expect(isOwnMessage(base, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the message has no actor', () => {
|
||||
expect(isOwnMessage({ ...base, actorId: null }, 'actor_a')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Domain types for the messaging UI. Zero imports on purpose: this file must never
|
||||
// reach for a transport package. Adapters map their own DTOs onto these.
|
||||
|
||||
export type Membership = 'dm' | 'group' | 'channel';
|
||||
|
||||
export type ChannelVisibility = 'public' | 'private';
|
||||
|
||||
export interface Person {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'staff' | 'customer';
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
threadId: string;
|
||||
title: string;
|
||||
subject: string | null;
|
||||
membership: Membership | null;
|
||||
participants: string[];
|
||||
unread: number;
|
||||
lastMessage?: string;
|
||||
lastAt?: string;
|
||||
/** Channel description (channels only). */
|
||||
topic?: string | null;
|
||||
}
|
||||
|
||||
/** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */
|
||||
export interface ChannelSummary {
|
||||
threadId: string;
|
||||
name: string;
|
||||
topic: string | null;
|
||||
visibility: ChannelVisibility;
|
||||
memberCount: number;
|
||||
/** True if the current user is already a member. */
|
||||
joined: boolean;
|
||||
}
|
||||
|
||||
export interface CreateChannelInput {
|
||||
name: string;
|
||||
topic?: string;
|
||||
visibility: ChannelVisibility;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
/** A resolved URL for display/download. May be empty until resolved by the host. */
|
||||
url: string;
|
||||
mime: string;
|
||||
name: string;
|
||||
/** Storage reference — carried so send() can persist the message part (upload returns it). */
|
||||
contentRef?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
text: string;
|
||||
at: string;
|
||||
parentInteractionId?: string | null;
|
||||
reactions?: Reaction[];
|
||||
attachment?: Attachment;
|
||||
/** True only when the transport is optimistic-local and not yet acknowledged. */
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export interface SendOpts {
|
||||
parentInteractionId?: string;
|
||||
attachment?: Attachment;
|
||||
/** Opaque userId notify-list (from @mentions). The app parses "@"; the kernel just forwards it. */
|
||||
mentions?: string[];
|
||||
}
|
||||
|
||||
export type MessageEvent =
|
||||
| { kind: 'message'; message: Message }
|
||||
| { kind: 'typing'; userId: string }
|
||||
| { kind: 'receipt'; messageId: string; actorId: string }
|
||||
| { kind: 'reaction'; messageId: string; reactions: Reaction[] };
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/** Ownership is a pure function of explicit identity — never inferred from history.
|
||||
* See the spec: inferring it is the bug this SDK exists partly to kill. */
|
||||
export function isOwnMessage(message: Message, currentActorId: string | null): boolean {
|
||||
return currentActorId !== null && message.actorId !== null && message.actorId === currentActorId;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
// `src/adapters/mock.ts` and `src/conformance.ts` are added as separate entries
|
||||
// in later tasks (they don't exist yet). `conformance` in particular is its own
|
||||
// entry, never reachable from `index`, because it imports vitest, and bundling
|
||||
// that into the main barrel would drag a test runner into every consumer's
|
||||
// production build.
|
||||
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
|
||||
format: ['esm'],
|
||||
// Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file).
|
||||
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
|
||||
clean: true,
|
||||
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// This package owns its vitest config on purpose. The ROOT config includes only
|
||||
// `.ts` (never `.tsx`) and provisions a Postgres DB via globalSetup for every run —
|
||||
// a pure-UI package must not drag a database along, and its tests are .tsx.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
@@ -49,3 +49,22 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
|
||||
# ── Capability providers (governed egress targets) ───────────────────────────
|
||||
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
|
||||
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
|
||||
|
||||
# ── Context attestation (July 12 trust layer) ──
|
||||
# Audience IIOS requires on attestations addressed to it.
|
||||
IIOS_ATTESTATION_AUDIENCE=iios-core
|
||||
# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the
|
||||
# appshell-crm client into the registry when IIOS_DEV_TOKENS=1).
|
||||
# IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key
|
||||
# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403).
|
||||
# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless.
|
||||
IIOS_REQUIRE_ATTESTATION=0
|
||||
|
||||
# ─── Object storage (media + email attachments) ───────────────────
|
||||
# Unset → local disk (MEDIA_DIR). Set these → S3-compatible (AWS S3 / MinIO / R2 / Supabase).
|
||||
# IIOS_S3_ENDPOINT=https://minio.your-server:9000 # omit for AWS S3
|
||||
# IIOS_S3_BUCKET=iios-media
|
||||
# IIOS_S3_ACCESS_KEY=...
|
||||
# IIOS_S3_SECRET_KEY=...
|
||||
# IIOS_S3_REGION=us-east-1 # any value for MinIO
|
||||
# IIOS_S3_FORCE_PATH_STYLE=true # true for MinIO/self-hosted
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1090.0",
|
||||
"@insignia/iios-adapter-sdk": "workspace:*",
|
||||
"@insignia/iios-contracts": "workspace:*",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
@@ -24,12 +25,16 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"handlebars": "^4.7.9",
|
||||
"ioredis": "^5.11.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^4.1.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"prisma": "^6.2.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io": "^4.8.3"
|
||||
"socket.io": "^4.8.3",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@insignia/iios-testkit": "workspace:*",
|
||||
@@ -38,6 +43,8 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "IiosThreadParticipant" ADD COLUMN "muted" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IiosNotificationSubscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scopeId" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL DEFAULT 'webpush',
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "IiosNotificationSubscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "IiosNotificationSubscription_endpoint_key" ON "IiosNotificationSubscription"("endpoint");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IiosNotificationSubscription_actorId_idx" ON "IiosNotificationSubscription"("actorId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
-- Trust plane (July 12): context attestation client registry + nonce replay ledger.
|
||||
CREATE TABLE "IiosClientRegistry" (
|
||||
"clientId" TEXT NOT NULL,
|
||||
"clientType" TEXT NOT NULL,
|
||||
"ownerService" TEXT,
|
||||
"allowedAppIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"attestSecret" TEXT,
|
||||
"jwksUri" TEXT,
|
||||
"spiffeId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "IiosClientRegistry_pkey" PRIMARY KEY ("clientId")
|
||||
);
|
||||
|
||||
CREATE TABLE "IiosAttestationNonce" (
|
||||
"nonce" TEXT NOT NULL,
|
||||
"clientId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "IiosAttestationNonce_pkey" PRIMARY KEY ("nonce")
|
||||
);
|
||||
|
||||
CREATE INDEX "IiosAttestationNonce_expiresAt_idx" ON "IiosAttestationNonce"("expiresAt");
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
-- Reusable message templates (email/SMS/in-app). DB is runtime truth; platform defaults seeded
|
||||
-- from repo files on boot. Versions are immutable (a change = a new higher version).
|
||||
CREATE TYPE "IiosTemplateChannel" AS ENUM ('EMAIL', 'SMS', 'INTERNAL');
|
||||
|
||||
CREATE TABLE "IiosMessageTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scopeId" TEXT,
|
||||
"key" TEXT NOT NULL,
|
||||
"channel" "IiosTemplateChannel" NOT NULL,
|
||||
"locale" TEXT NOT NULL DEFAULT 'en',
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"subject" TEXT,
|
||||
"bodyHtml" TEXT,
|
||||
"bodyText" TEXT,
|
||||
"variables" JSONB,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "IiosMessageTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "IiosMessageTemplate"
|
||||
ADD CONSTRAINT "IiosMessageTemplate_scopeId_fkey"
|
||||
FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Uniqueness for SCOPED rows (scopeId NOT NULL).
|
||||
CREATE UNIQUE INDEX "IiosMessageTemplate_scopeId_key_channel_locale_version_key"
|
||||
ON "IiosMessageTemplate" ("scopeId", "key", "channel", "locale", "version");
|
||||
|
||||
-- Uniqueness for GLOBAL rows (scopeId IS NULL). Postgres treats NULL as distinct under a normal
|
||||
-- UNIQUE, so the constraint above does NOT stop two platform defaults for the same key — this
|
||||
-- partial index does. (Same footgun handled in the inbox idempotency migration.)
|
||||
CREATE UNIQUE INDEX "IiosMessageTemplate_global_key"
|
||||
ON "IiosMessageTemplate" ("key", "channel", "locale", "version")
|
||||
WHERE "scopeId" IS NULL;
|
||||
|
||||
-- Resolution lookup: by key/channel/locale among active rows.
|
||||
CREATE INDEX "IiosMessageTemplate_key_channel_locale_active_idx"
|
||||
ON "IiosMessageTemplate" ("key", "channel", "locale", "active");
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- Template provenance on the outbound command: which template (key/version/locale) produced this
|
||||
-- send, plus a hash of the rendered content. The rendered content itself already lives in `payload`;
|
||||
-- these columns answer "which template version produced this send?" for replay/audit. All nullable —
|
||||
-- non-templated sends leave them null.
|
||||
ALTER TABLE "IiosOutboundCommand"
|
||||
ADD COLUMN "templateKey" TEXT,
|
||||
ADD COLUMN "templateVersion" INTEGER,
|
||||
ADD COLUMN "templateLocale" TEXT,
|
||||
ADD COLUMN "renderedHash" TEXT;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "IiosProviderCredential" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scopeId" TEXT NOT NULL,
|
||||
"providerType" TEXT NOT NULL,
|
||||
"cipherText" TEXT NOT NULL,
|
||||
"iv" TEXT NOT NULL,
|
||||
"authTag" TEXT NOT NULL,
|
||||
"keyVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
"displayHints" JSONB,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "IiosProviderCredential_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IiosProviderCredential_scopeId_idx" ON "IiosProviderCredential"("scopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "IiosProviderCredential_scopeId_providerType_key" ON "IiosProviderCredential"("scopeId", "providerType");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IiosProviderCredential" ADD CONSTRAINT "IiosProviderCredential_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "IiosClientRegistry" ALTER COLUMN "allowedAppIds" DROP DEFAULT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IiosMediaObject" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scopeId" TEXT NOT NULL,
|
||||
"objectKey" TEXT NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"sizeBytes" BIGINT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "IiosMediaObject_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "IiosMediaObject_objectKey_key" ON "IiosMediaObject"("objectKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IiosMediaObject_createdAt_idx" ON "IiosMediaObject"("createdAt");
|
||||
@@ -96,6 +96,12 @@ enum IiosInboxState {
|
||||
STALE
|
||||
}
|
||||
|
||||
enum IiosTemplateChannel {
|
||||
EMAIL
|
||||
SMS
|
||||
INTERNAL
|
||||
}
|
||||
|
||||
enum IiosTicketState {
|
||||
NEW
|
||||
OPEN
|
||||
@@ -309,10 +315,35 @@ model IiosScope {
|
||||
supportQueues IiosSupportQueue[]
|
||||
tickets IiosTicket[]
|
||||
callbacks IiosCallbackRequest[]
|
||||
notificationSubscriptions IiosNotificationSubscription[]
|
||||
messageTemplates IiosMessageTemplate[]
|
||||
providerCredentials IiosProviderCredential[]
|
||||
|
||||
@@index([orgId, appId, tenantId])
|
||||
}
|
||||
|
||||
/// A tenant's own credentials for an egress provider (BYO: Twilio SMS, own SMTP, …).
|
||||
/// The secret is sealed with AES-256-GCM under the platform key (IIOS_CRED_KEY); only
|
||||
/// `displayHints` (non-secret, e.g. from-number / SID last-4) is ever read back out.
|
||||
/// One row per (scope, providerType). Resolved at send time by the channel's provider.
|
||||
model IiosProviderCredential {
|
||||
id String @id @default(cuid())
|
||||
scopeId String
|
||||
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||
providerType String // TWILIO_SMS | SMTP | WHATSAPP_CLOUD | …
|
||||
cipherText String // base64(AES-256-GCM ciphertext of the JSON config)
|
||||
iv String // base64 nonce
|
||||
authTag String // base64 GCM auth tag
|
||||
keyVersion Int @default(1)
|
||||
displayHints Json? // non-secret display fields (fromNumber, sidLast4, …)
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scopeId, providerType])
|
||||
@@index([scopeId])
|
||||
}
|
||||
|
||||
/// A channel-specific handle (phone/email/portal user). NOT identity — stays
|
||||
/// UNVERIFIED until MDM resolves it; no silent merge.
|
||||
model IiosSourceHandle {
|
||||
@@ -358,6 +389,7 @@ model IiosActorRef {
|
||||
ticketsRequested IiosTicket[] @relation("TicketRequester")
|
||||
ticketsAssigned IiosTicket[] @relation("TicketAssignee")
|
||||
callbacksRequested IiosCallbackRequest[]
|
||||
notificationSubscriptions IiosNotificationSubscription[]
|
||||
}
|
||||
|
||||
/// A configured channel surface (PORTAL in P1).
|
||||
@@ -412,12 +444,32 @@ model IiosThreadParticipant {
|
||||
actorId String
|
||||
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
||||
participantRole String @default("MEMBER")
|
||||
muted Boolean @default(false)
|
||||
joinedAt DateTime @default(now())
|
||||
leftAt DateTime?
|
||||
|
||||
@@id([threadId, actorId])
|
||||
}
|
||||
|
||||
/// A device/browser push subscription for an actor (Web Push endpoint + keys).
|
||||
/// The notification engine delivers to these when the actor is absent.
|
||||
model IiosNotificationSubscription {
|
||||
id String @id @default(cuid())
|
||||
scopeId String
|
||||
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||
actorId String
|
||||
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
||||
kind String @default("webpush")
|
||||
endpoint String @unique
|
||||
p256dh String
|
||||
auth String
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now())
|
||||
|
||||
@@index([actorId])
|
||||
}
|
||||
|
||||
/// The semantic envelope. Idempotent per (scope, idempotencyKey).
|
||||
model IiosInteraction {
|
||||
id String @id @default(cuid())
|
||||
@@ -491,6 +543,22 @@ model IiosMessagePart {
|
||||
@@unique([interactionId, partIndex])
|
||||
}
|
||||
|
||||
/// A stored media object (bytes behind the StoragePort). A row is recorded when bytes actually
|
||||
/// land (MediaService.put), so an orphan sweep can reap objects that no message part references —
|
||||
/// e.g. a file attached in a composer but never sent. "Referenced" is derived at sweep time from
|
||||
/// IiosMessagePart.contentRef (no stored flag to drift), after a grace period so in-progress
|
||||
/// composes are never reaped.
|
||||
model IiosMediaObject {
|
||||
id String @id @default(cuid())
|
||||
scopeId String
|
||||
objectKey String @unique
|
||||
mime String
|
||||
sizeBytes BigInt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// Transactional outbox — written in the same tx as the business row.
|
||||
model IiosOutboxEvent {
|
||||
eventId String @id @default(cuid())
|
||||
@@ -655,6 +723,33 @@ model IiosInboxItem {
|
||||
@@index([ownerActorId, state, priority])
|
||||
}
|
||||
|
||||
/// A reusable message template (email/SMS/in-app). DB is runtime truth; platform defaults are
|
||||
/// seeded from repo files on boot. Versions are immutable — a change writes a new (higher) version,
|
||||
/// never edits in place, so a rendered send can always be traced to the exact source it used.
|
||||
/// scopeId NULL = a platform default; set = a tenant scope's override of the same key. Resolution
|
||||
/// prefers the scoped row, else the global default. (The global-uniqueness of NULL-scope rows is
|
||||
/// enforced by a partial unique index added in the migration — Postgres treats NULL as distinct.)
|
||||
model IiosMessageTemplate {
|
||||
id String @id @default(cuid())
|
||||
scopeId String?
|
||||
scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||
key String
|
||||
channel IiosTemplateChannel
|
||||
locale String @default("en")
|
||||
version Int @default(1)
|
||||
subject String?
|
||||
bodyHtml String?
|
||||
bodyText String?
|
||||
/// Declared variable names — render throws if a declared var is missing (fail loud).
|
||||
variables Json?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scopeId, key, channel, locale, version])
|
||||
@@index([key, channel, locale, active])
|
||||
}
|
||||
|
||||
/// Audit trail of inbox-item state transitions.
|
||||
model IiosInboxItemStateHistory {
|
||||
id String @id @default(cuid())
|
||||
@@ -812,6 +907,11 @@ model IiosOutboundCommand {
|
||||
idempotencyKey String @unique
|
||||
providerRef String?
|
||||
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
||||
// Template provenance (which source produced this send) — null for non-templated sends.
|
||||
templateKey String?
|
||||
templateVersion Int?
|
||||
templateLocale String?
|
||||
renderedHash String? // sha256 of the rendered content, for replay/audit
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
attempts IiosDeliveryAttempt[]
|
||||
@@ -1299,3 +1399,31 @@ model IiosDlqItem {
|
||||
@@unique([consumerName, sourceId])
|
||||
@@index([scopeId, status])
|
||||
}
|
||||
|
||||
// ─── Trust plane (July 12) — context attestation ──────────────────
|
||||
// Registered clients/BFFs/adapters allowed to call IIOS and attest context on
|
||||
// behalf of an app. A valid actor token is NOT enough; the caller must present a
|
||||
// context attestation signed by one of these registered clients.
|
||||
model IiosClientRegistry {
|
||||
clientId String @id
|
||||
clientType String // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
|
||||
ownerService String?
|
||||
allowedAppIds String[] // which app_ids this client may attest for
|
||||
attestSecret String? // dev: shared HS256 signing key (AppShell's key stand-in)
|
||||
jwksUri String? // prod: verify the attestation signature via JWKS
|
||||
spiffeId String? // prod: workload identity for mTLS proof
|
||||
status String @default("ACTIVE") // ACTIVE | DISABLED
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Single-use nonce ledger: once a context attestation's nonce is seen it can
|
||||
// never be replayed. Reserve-if-absent (unique PK) makes the check atomic.
|
||||
model IiosAttestationNonce {
|
||||
nonce String @id
|
||||
clientId String
|
||||
expiresAt DateTime
|
||||
firstSeenAt DateTime @default(now())
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
@@ -34,8 +34,18 @@ export class OutboundService {
|
||||
idempotencyKey?: string,
|
||||
scopeId?: string,
|
||||
purpose?: string,
|
||||
/** Opaque template provenance recorded on the command. OutboundService neither renders nor
|
||||
* resolves templates — it only persists the four strings it is handed (guaranteed by the
|
||||
* templated sender, never by caller convention). */
|
||||
provenance?: { templateKey?: string | null; templateVersion?: number | null; templateLocale?: string | null; renderedHash?: string | null },
|
||||
) {
|
||||
const key = idempotencyKey ?? randomUUID();
|
||||
const prov = {
|
||||
templateKey: provenance?.templateKey ?? null,
|
||||
templateVersion: provenance?.templateVersion ?? null,
|
||||
templateLocale: provenance?.templateLocale ?? null,
|
||||
renderedHash: provenance?.renderedHash ?? null,
|
||||
};
|
||||
|
||||
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
|
||||
// command returns the existing row instead of colliding on idempotencyKey @unique.
|
||||
@@ -46,14 +56,14 @@ export class OutboundService {
|
||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
||||
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key },
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key, ...prov },
|
||||
});
|
||||
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||
return cmd;
|
||||
}
|
||||
|
||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key },
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key, ...prov },
|
||||
});
|
||||
|
||||
let result;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { AttestationModule } from './platform/attestation.module';
|
||||
import { IdentityModule } from './identity/identity.module';
|
||||
import { InteractionsModule } from './interactions/interactions.module';
|
||||
import { IdempotencyModule } from './idempotency/idempotency.module';
|
||||
@@ -9,6 +10,10 @@ import { OutboxModule } from './outbox/outbox.module';
|
||||
import { ThreadsModule } from './threads/threads.module';
|
||||
import { MessageModule } from './messaging/message.module';
|
||||
import { InboxModule } from './inbox/inbox.module';
|
||||
import { TemplateModule } from './templates/template.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { MediaModule } from './media/media.module';
|
||||
import { NotificationModule } from './notifications/notification.module';
|
||||
import { SupportModule } from './support/support.module';
|
||||
import { AdaptersModule } from './adapters/adapters.module';
|
||||
import { RoutingModule } from './routing/routing.module';
|
||||
@@ -25,6 +30,7 @@ import { DevController } from './dev/dev.controller';
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PlatformModule,
|
||||
AttestationModule,
|
||||
IdentityModule,
|
||||
InteractionsModule,
|
||||
IdempotencyModule,
|
||||
@@ -33,6 +39,10 @@ import { DevController } from './dev/dev.controller';
|
||||
ThreadsModule,
|
||||
MessageModule,
|
||||
InboxModule,
|
||||
TemplateModule,
|
||||
MailModule,
|
||||
MediaModule,
|
||||
NotificationModule,
|
||||
SupportModule,
|
||||
AdaptersModule,
|
||||
RoutingModule,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { CapabilityProviderRegistry } from './capability.registry';
|
||||
import { CapabilityBroker } from './capability.broker';
|
||||
import { ProviderCredentialService } from './provider-credential.service';
|
||||
import { ProviderCredentialController } from './provider-credential.controller';
|
||||
|
||||
/**
|
||||
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the
|
||||
* outbound layer routes all sends through policy + obligations + a pluggable
|
||||
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
||||
* Also owns per-scope BYO provider credentials (Twilio SMS, …) — sealed at rest and
|
||||
* resolved by the channel providers at send time.
|
||||
*/
|
||||
@Module({
|
||||
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
||||
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
||||
imports: [MediaModule],
|
||||
controllers: [ProviderCredentialController],
|
||||
providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService],
|
||||
exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService],
|
||||
})
|
||||
export class CapabilityModule {}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { CapabilityProviderRegistry } from './capability.registry';
|
||||
|
||||
// The registry reads env in its constructor, so each case sets env → constructs a fresh registry →
|
||||
// asserts → restores env.
|
||||
const SMTP_KEYS = ['IIOS_SMTP_HOST', 'IIOS_SMTP_USER', 'IIOS_SMTP_PASS', 'IIOS_PROVIDER_URL_EMAIL'];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
function set(env: Record<string, string | undefined>) {
|
||||
for (const k of SMTP_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||
for (const [k, v] of Object.entries(env)) if (v != null) process.env[k] = v;
|
||||
}
|
||||
afterEach(() => { for (const k of SMTP_KEYS) { if (saved[k] == null) delete process.env[k]; else process.env[k] = saved[k]; } });
|
||||
|
||||
describe('CapabilityProviderRegistry — EMAIL precedence (SMTP > HTTP > sandbox)', () => {
|
||||
it('binds SMTP for EMAIL when the SMTP env trio is set', () => {
|
||||
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' });
|
||||
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
|
||||
});
|
||||
|
||||
it('binds the HTTP EmailProvider when only IIOS_PROVIDER_URL_EMAIL is set', () => {
|
||||
set({ IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
|
||||
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('email-http');
|
||||
});
|
||||
|
||||
it('SMTP wins over the HTTP relay when both are set', () => {
|
||||
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p', IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
|
||||
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
|
||||
});
|
||||
|
||||
it('falls back to the sandbox when neither is configured', () => {
|
||||
set({});
|
||||
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('sandbox');
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,33 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
|
||||
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
||||
import { SandboxProvider } from './sandbox.provider';
|
||||
import { HttpProvider } from './http.provider';
|
||||
import { EmailProvider } from './email.provider';
|
||||
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
|
||||
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
|
||||
import { credKeyFromEnv } from './secret-crypto';
|
||||
import { ProviderCredentialService } from './provider-credential.service';
|
||||
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
|
||||
|
||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
|
||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||
|
||||
/**
|
||||
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
||||
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
|
||||
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
|
||||
* channels fail closed — no silent egress path.
|
||||
*
|
||||
* Precedence is registration ORDER (register() does Map.set → last wins). For EMAIL:
|
||||
* sandbox → HTTP EmailProvider (if URL set) → SMTP (if SMTP env set), so SMTP > HTTP > sandbox.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CapabilityProviderRegistry {
|
||||
private readonly byChannel = new Map<string, CapabilityProvider>();
|
||||
|
||||
constructor() {
|
||||
constructor(
|
||||
@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort,
|
||||
@Optional() private readonly credentials?: ProviderCredentialService,
|
||||
) {
|
||||
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
||||
for (const ch of DEFAULT_CHANNELS) {
|
||||
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
||||
@@ -24,6 +35,19 @@ export class CapabilityProviderRegistry {
|
||||
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
|
||||
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
||||
}
|
||||
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments
|
||||
// resolve through the media StoragePort when one is bound (else attachments FAIL closed).
|
||||
const smtp = smtpIdentityFromEnv();
|
||||
if (smtp) {
|
||||
const resolver = this.storage ? storageResolver(this.storage) : undefined;
|
||||
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
|
||||
}
|
||||
// BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered
|
||||
// only when the platform key + credential store are present — else SMS stays on the sandbox.
|
||||
if (credKeyFromEnv() && this.credentials) {
|
||||
const creds = this.credentials;
|
||||
this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise<TwilioCreds | null>));
|
||||
}
|
||||
}
|
||||
|
||||
register(provider: CapabilityProvider): void {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from '@nestjs/common';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||
import { ProviderCredentialService } from './provider-credential.service';
|
||||
import { TwilioCredentialsDto } from './provider-credential.dto';
|
||||
|
||||
const SUPPORTED = new Set(['TWILIO_SMS']);
|
||||
|
||||
/**
|
||||
* Per-scope BYO integration credentials. The caller's attested session decides the scope, so a
|
||||
* tenant can only read/write ITS OWN provider credentials. The secret is sealed by the service;
|
||||
* GET returns a masked status (never the token). be-crm gates this behind tenant-admin policy.
|
||||
*/
|
||||
@Controller('v1/providers')
|
||||
export class ProviderCredentialController {
|
||||
constructor(
|
||||
private readonly session: SessionVerifier,
|
||||
private readonly actors: ActorResolver,
|
||||
private readonly credentials: ProviderCredentialService,
|
||||
) {}
|
||||
|
||||
@Put(':providerType/credentials')
|
||||
async put(
|
||||
@Param('providerType') providerType: string,
|
||||
@Body() body: TwilioCredentialsDto,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
this.assertSupported(providerType);
|
||||
const scope = await this.actors.resolveScope(this.principal(authorization));
|
||||
await this.credentials.upsert(scope.id, providerType, {
|
||||
accountSid: body.accountSid,
|
||||
authToken: body.authToken,
|
||||
fromNumber: body.fromNumber,
|
||||
});
|
||||
return this.credentials.status(scope.id, providerType);
|
||||
}
|
||||
|
||||
@Get(':providerType/credentials')
|
||||
async get(@Param('providerType') providerType: string, @Headers('authorization') authorization?: string) {
|
||||
this.assertSupported(providerType);
|
||||
const scope = await this.actors.resolveScope(this.principal(authorization));
|
||||
return this.credentials.status(scope.id, providerType);
|
||||
}
|
||||
|
||||
private assertSupported(providerType: string): void {
|
||||
if (!SUPPORTED.has(providerType)) throw new BadRequestException(`unsupported providerType: ${providerType}`);
|
||||
}
|
||||
|
||||
private principal(authorization?: string): MessagePrincipal {
|
||||
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
||||
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||
return this.session.verify(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* PUT /v1/providers/TWILIO_SMS/credentials — a tenant's own Twilio credentials. Step 1 supports
|
||||
* TWILIO_SMS only; when a second provider (SMTP, …) is added, switch to a per-type validated body.
|
||||
*/
|
||||
export class TwilioCredentialsDto {
|
||||
@IsString() @IsNotEmpty() accountSid!: string;
|
||||
@IsString() @IsNotEmpty() authToken!: string;
|
||||
@IsString() @IsNotEmpty() fromNumber!: string;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ProviderCredentialService } from './provider-credential.service';
|
||||
|
||||
const KEY_B64 = Buffer.alloc(32, 7).toString('base64');
|
||||
|
||||
/** Minimal in-memory stand-in for prisma.iiosProviderCredential (upsert/findUnique). */
|
||||
function fakePrisma() {
|
||||
const rows = new Map<string, Record<string, unknown>>();
|
||||
const k = (scopeId: string, providerType: string) => `${scopeId}::${providerType}`;
|
||||
return {
|
||||
_rows: rows,
|
||||
iiosProviderCredential: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async upsert({ where, create, update }: any) {
|
||||
const key = k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType);
|
||||
const existing = rows.get(key);
|
||||
const row = existing ? { ...existing, ...update } : { ...create };
|
||||
rows.set(key, row);
|
||||
return row;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async findUnique({ where }: any) {
|
||||
return rows.get(k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType)) ?? null;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function make(prisma: ReturnType<typeof fakePrisma>, env: NodeJS.ProcessEnv): ProviderCredentialService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = new ProviderCredentialService(prisma as any);
|
||||
s.env = env;
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('ProviderCredentialService', () => {
|
||||
let prisma: ReturnType<typeof fakePrisma>;
|
||||
let svc: ProviderCredentialService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = fakePrisma();
|
||||
svc = make(prisma, { IIOS_CRED_KEY: KEY_B64 } as NodeJS.ProcessEnv);
|
||||
});
|
||||
|
||||
it('seals the secret at rest — plaintext token never stored', async () => {
|
||||
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' });
|
||||
const stored = JSON.stringify([...prisma._rows.values()]);
|
||||
expect(stored).not.toContain('tok_secret');
|
||||
expect(stored).toContain('cipherText');
|
||||
});
|
||||
|
||||
it('resolves back the exact config it sealed', async () => {
|
||||
const cfg = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
|
||||
await svc.upsert('scope_1', 'TWILIO_SMS', cfg);
|
||||
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toEqual(cfg);
|
||||
});
|
||||
|
||||
it('status is masked — reveals hints, never the token', async () => {
|
||||
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC1234567', authToken: 'tok_secret', fromNumber: '+15550001111' });
|
||||
const status = await svc.status('scope_1', 'TWILIO_SMS');
|
||||
expect(status).toMatchObject({ configured: true, enabled: true, hints: { fromNumber: '+15550001111', sidLast4: '4567' } });
|
||||
expect(JSON.stringify(status)).not.toContain('tok_secret');
|
||||
});
|
||||
|
||||
it('reports not-configured for an unknown scope', async () => {
|
||||
expect(await svc.status('nope', 'TWILIO_SMS')).toEqual({ configured: false });
|
||||
expect(await svc.resolve('nope', 'TWILIO_SMS')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resolve a disabled credential', async () => {
|
||||
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 't', fromNumber: '+1' }, { enabled: false });
|
||||
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toBeNull();
|
||||
});
|
||||
|
||||
it('throws when the platform key is absent (fail closed, never store plaintext)', async () => {
|
||||
const noKey = make(prisma, {} as NodeJS.ProcessEnv);
|
||||
await expect(noKey.upsert('s', 'TWILIO_SMS', { accountSid: 'A', authToken: 't', fromNumber: '+1' })).rejects.toThrow(/IIOS_CRED_KEY/);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user