Compare commits
46 Commits
8ce647552a
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,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,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.
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
|
|||||||
bodyText?: string;
|
bodyText?: string;
|
||||||
contentRef?: string;
|
contentRef?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
}>;
|
}>;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
providerEventId?: string;
|
providerEventId?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||||
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } 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 {
|
export interface RestConfig {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
@@ -103,6 +103,27 @@ export class RestClient {
|
|||||||
return this.post<{ threadId: string }>('/v1/threads', opts);
|
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. */
|
/** My saved messages (personal bookmarks), newest first, with thread context. */
|
||||||
async listSaved(): Promise<SavedItem[]> {
|
async listSaved(): Promise<SavedItem[]> {
|
||||||
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
|
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ export interface MessageEvents {
|
|||||||
annotation: (e: AnnotationEvent) => 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). */
|
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
|
||||||
export interface ThreadSummary {
|
export interface ThreadSummary {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "@insignia/iios-messaging-ui",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"./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,109 @@
|
|||||||
|
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 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,147 @@
|
|||||||
|
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 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);
|
||||||
|
try {
|
||||||
|
setStaged(await upload(file));
|
||||||
|
} catch {
|
||||||
|
/* host surfaces upload errors */
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
{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,107 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { highlightMentions } from '../mentions';
|
||||||
|
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 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 type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
|
||||||
|
🙂
|
||||||
|
</button>
|
||||||
|
{pickerOpen ? (
|
||||||
|
<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>
|
||||||
|
) : 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;
|
||||||
|
|
||||||
|
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,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,32 @@
|
|||||||
|
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>;
|
||||||
|
|
||||||
|
// ── 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,174 @@
|
|||||||
|
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>;
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages, loading, error, reply, canAttach: typeof adapter.uploadAttachment === 'function', upload, 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,328 @@
|
|||||||
|
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 } = useMailThread(threadId);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [pending, setPending] = useState<MailAttachment | null>(null);
|
||||||
|
const [attaching, setAttaching] = useState(false);
|
||||||
|
const [attachErr, setAttachErr] = useState<string | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
setAttaching(true);
|
||||||
|
setAttachErr(null);
|
||||||
|
try {
|
||||||
|
setPending(await upload(file));
|
||||||
|
} catch (err) {
|
||||||
|
setAttachErr(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setAttaching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<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}
|
||||||
|
{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 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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
<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}>
|
||||||
|
{attaching ? 'Uploading…' : '📎 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,944 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
.miu-react-picker {
|
||||||
|
position: absolute;
|
||||||
|
/* Open downward + right-aligned so it never clips against the top of the scroll area
|
||||||
|
(the reported bug) or spills past the right edge. */
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
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);
|
||||||
|
z-index: 5;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
/* On my own (right-aligned) messages, anchor the picker to the left instead so it stays in view. */
|
||||||
|
.miu-msg.is-mine .miu-react-picker {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.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-top: 6px;
|
||||||
|
}
|
||||||
|
.miu-attach-pending {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
height: 100%;
|
||||||
|
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;
|
||||||
|
height: 100%;
|
||||||
|
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 {
|
||||||
|
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,87 @@
|
|||||||
|
// 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 {
|
||||||
|
url: string;
|
||||||
|
mime: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -59,3 +59,12 @@ IIOS_ATTESTATION_AUDIENCE=iios-core
|
|||||||
# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403).
|
# 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.
|
# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless.
|
||||||
IIOS_REQUIRE_ATTESTATION=0
|
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"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1090.0",
|
||||||
"@insignia/iios-adapter-sdk": "workspace:*",
|
"@insignia/iios-adapter-sdk": "workspace:*",
|
||||||
"@insignia/iios-contracts": "workspace:*",
|
"@insignia/iios-contracts": "workspace:*",
|
||||||
"@nestjs/common": "^11.1.27",
|
"@nestjs/common": "^11.1.27",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jwks-rsa": "^4.1.0",
|
"jwks-rsa": "^4.1.0",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"prisma": "^6.2.1",
|
"prisma": "^6.2.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
|
|||||||
+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;
|
||||||
|
|
||||||
@@ -317,10 +317,33 @@ model IiosScope {
|
|||||||
callbacks IiosCallbackRequest[]
|
callbacks IiosCallbackRequest[]
|
||||||
notificationSubscriptions IiosNotificationSubscription[]
|
notificationSubscriptions IiosNotificationSubscription[]
|
||||||
messageTemplates IiosMessageTemplate[]
|
messageTemplates IiosMessageTemplate[]
|
||||||
|
providerCredentials IiosProviderCredential[]
|
||||||
|
|
||||||
@@index([orgId, appId, tenantId])
|
@@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
|
/// A channel-specific handle (phone/email/portal user). NOT identity — stays
|
||||||
/// UNVERIFIED until MDM resolves it; no silent merge.
|
/// UNVERIFIED until MDM resolves it; no silent merge.
|
||||||
model IiosSourceHandle {
|
model IiosSourceHandle {
|
||||||
@@ -868,6 +891,11 @@ model IiosOutboundCommand {
|
|||||||
idempotencyKey String @unique
|
idempotencyKey String @unique
|
||||||
providerRef String?
|
providerRef String?
|
||||||
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
attempts IiosDeliveryAttempt[]
|
attempts IiosDeliveryAttempt[]
|
||||||
|
|||||||
@@ -34,8 +34,18 @@ export class OutboundService {
|
|||||||
idempotencyKey?: string,
|
idempotencyKey?: string,
|
||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
purpose?: 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 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
|
// 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.
|
// 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).
|
// 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))) {
|
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
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' } });
|
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
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;
|
let result;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { OutboxModule } from './outbox/outbox.module';
|
|||||||
import { ThreadsModule } from './threads/threads.module';
|
import { ThreadsModule } from './threads/threads.module';
|
||||||
import { MessageModule } from './messaging/message.module';
|
import { MessageModule } from './messaging/message.module';
|
||||||
import { InboxModule } from './inbox/inbox.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 { MediaModule } from './media/media.module';
|
||||||
import { NotificationModule } from './notifications/notification.module';
|
import { NotificationModule } from './notifications/notification.module';
|
||||||
import { SupportModule } from './support/support.module';
|
import { SupportModule } from './support/support.module';
|
||||||
@@ -37,6 +39,8 @@ import { DevController } from './dev/dev.controller';
|
|||||||
ThreadsModule,
|
ThreadsModule,
|
||||||
MessageModule,
|
MessageModule,
|
||||||
InboxModule,
|
InboxModule,
|
||||||
|
TemplateModule,
|
||||||
|
MailModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
SupportModule,
|
SupportModule,
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MediaModule } from '../media/media.module';
|
||||||
import { CapabilityProviderRegistry } from './capability.registry';
|
import { CapabilityProviderRegistry } from './capability.registry';
|
||||||
import { CapabilityBroker } from './capability.broker';
|
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
|
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the
|
||||||
* outbound layer routes all sends through policy + obligations + a pluggable
|
* outbound layer routes all sends through policy + obligations + a pluggable
|
||||||
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
* 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({
|
@Module({
|
||||||
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
imports: [MediaModule],
|
||||||
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
controllers: [ProviderCredentialController],
|
||||||
|
providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService],
|
||||||
|
exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService],
|
||||||
})
|
})
|
||||||
export class CapabilityModule {}
|
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 type { CapabilityProvider } from '@insignia/iios-contracts';
|
||||||
import { SandboxProvider } from './sandbox.provider';
|
import { SandboxProvider } from './sandbox.provider';
|
||||||
import { HttpProvider } from './http.provider';
|
import { HttpProvider } from './http.provider';
|
||||||
import { EmailProvider } from './email.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
|
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
||||||
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
|
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
|
||||||
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
|
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
|
||||||
* channels fail closed — no silent egress path.
|
* 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()
|
@Injectable()
|
||||||
export class CapabilityProviderRegistry {
|
export class CapabilityProviderRegistry {
|
||||||
private readonly byChannel = new Map<string, CapabilityProvider>();
|
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) this.register(new SandboxProvider([ch]));
|
||||||
for (const ch of DEFAULT_CHANNELS) {
|
for (const ch of DEFAULT_CHANNELS) {
|
||||||
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
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.
|
// 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));
|
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 {
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { credKeyFromEnv, sealSecret, openSecret, type SealedSecret } from './secret-crypto';
|
||||||
|
|
||||||
|
/** A provider config is an opaque JSON bag; each provider knows its own shape. */
|
||||||
|
export type ProviderConfig = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface CredentialStatus {
|
||||||
|
configured: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
hints?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-secret display fields surfaced by `status` — NEVER the secret itself. */
|
||||||
|
function hintsFor(providerType: string, config: ProviderConfig): Record<string, unknown> {
|
||||||
|
if (providerType === 'TWILIO_SMS') {
|
||||||
|
const sid = String(config.accountSid ?? '');
|
||||||
|
return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tenant's BYO integration credentials, one row per (scope, providerType). The secret is
|
||||||
|
* sealed with AES-256-GCM under the platform key before it touches the DB; only non-secret
|
||||||
|
* `displayHints` are ever read back. `resolve` decrypts on demand at send time; `status` never
|
||||||
|
* returns the secret. Fail-closed: no platform key ⇒ upsert throws (we refuse to store plaintext).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ProviderCredentialService {
|
||||||
|
/** Overridable in tests; defaults to the process env (holds IIOS_CRED_KEY). */
|
||||||
|
env: NodeJS.ProcessEnv = process.env;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async upsert(scopeId: string, providerType: string, config: ProviderConfig, opts?: { enabled?: boolean }): Promise<void> {
|
||||||
|
const key = credKeyFromEnv(this.env);
|
||||||
|
if (!key) throw new BadRequestException('IIOS_CRED_KEY is not configured — cannot store integration credentials');
|
||||||
|
const sealed = sealSecret(JSON.stringify(config), key);
|
||||||
|
const enabled = opts?.enabled ?? true;
|
||||||
|
const displayHints = hintsFor(providerType, config) as Prisma.InputJsonValue;
|
||||||
|
await this.prisma.iiosProviderCredential.upsert({
|
||||||
|
where: { scopeId_providerType: { scopeId, providerType } },
|
||||||
|
create: { scopeId, providerType, ...sealed, displayHints, enabled },
|
||||||
|
update: { ...sealed, displayHints, enabled },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt the config for send-time use. Null when unconfigured, disabled, or no key. */
|
||||||
|
async resolve(scopeId: string, providerType: string): Promise<ProviderConfig | null> {
|
||||||
|
const row = await this.prisma.iiosProviderCredential.findUnique({
|
||||||
|
where: { scopeId_providerType: { scopeId, providerType } },
|
||||||
|
});
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const key = credKeyFromEnv(this.env);
|
||||||
|
if (!key) return null;
|
||||||
|
const sealed: SealedSecret = { cipherText: row.cipherText, iv: row.iv, authTag: row.authTag, keyVersion: row.keyVersion };
|
||||||
|
return JSON.parse(openSecret(sealed, key)) as ProviderConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Masked view for the admin UI — configured + hints, never the secret. */
|
||||||
|
async status(scopeId: string, providerType: string): Promise<CredentialStatus> {
|
||||||
|
const row = await this.prisma.iiosProviderCredential.findUnique({
|
||||||
|
where: { scopeId_providerType: { scopeId, providerType } },
|
||||||
|
});
|
||||||
|
if (!row) return { configured: false };
|
||||||
|
return { configured: true, enabled: row.enabled, hints: (row.displayHints ?? {}) as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { credKeyFromEnv, sealSecret, openSecret, SECRET_KEY_VERSION } from './secret-crypto';
|
||||||
|
|
||||||
|
const KEY = Buffer.alloc(32, 7); // deterministic 32-byte key for tests
|
||||||
|
|
||||||
|
describe('secret-crypto', () => {
|
||||||
|
it('round-trips a secret through seal → open', () => {
|
||||||
|
const sealed = sealSecret('sk_live_abc123', KEY);
|
||||||
|
expect(sealed.keyVersion).toBe(SECRET_KEY_VERSION);
|
||||||
|
expect(sealed.cipherText).not.toContain('sk_live_abc123');
|
||||||
|
expect(openSecret(sealed, KEY)).toBe('sk_live_abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a distinct ciphertext each time (random IV)', () => {
|
||||||
|
const a = sealSecret('same', KEY);
|
||||||
|
const b = sealSecret('same', KEY);
|
||||||
|
expect(a.cipherText).not.toBe(b.cipherText);
|
||||||
|
expect(a.iv).not.toBe(b.iv);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails to open with the wrong key', () => {
|
||||||
|
const sealed = sealSecret('top-secret', KEY);
|
||||||
|
expect(() => openSecret(sealed, Buffer.alloc(32, 9))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails to open if the ciphertext is tampered (GCM auth)', () => {
|
||||||
|
const sealed = sealSecret('top-secret', KEY);
|
||||||
|
const bad = { ...sealed, cipherText: Buffer.from('deadbeef', 'hex').toString('base64') };
|
||||||
|
expect(() => openSecret(bad, KEY)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('credKeyFromEnv returns null when unset and rejects a wrong-length key', () => {
|
||||||
|
expect(credKeyFromEnv({})).toBeNull();
|
||||||
|
expect(() => credKeyFromEnv({ IIOS_CRED_KEY: Buffer.alloc(16).toString('base64') })).toThrow(/32 bytes/);
|
||||||
|
const key = credKeyFromEnv({ IIOS_CRED_KEY: KEY.toString('base64') });
|
||||||
|
expect(key?.length).toBe(32);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envelope for a tenant secret sealed with AES-256-GCM. Stored as base64 columns on
|
||||||
|
* IiosProviderCredential; the plaintext (a provider config JSON string) never touches disk.
|
||||||
|
*/
|
||||||
|
export interface SealedSecret {
|
||||||
|
cipherText: string;
|
||||||
|
iv: string;
|
||||||
|
authTag: string;
|
||||||
|
keyVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bumped only when the platform master key rotates; lets old rows decrypt under an old key. */
|
||||||
|
export const SECRET_KEY_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the 32-byte platform master key from `IIOS_CRED_KEY` (base64). Returns null when
|
||||||
|
* unset (so egress providers that need it stay unregistered / fail closed) but THROWS on a
|
||||||
|
* present-but-malformed key — a wrong-length key is an operator error, not a "disabled" state.
|
||||||
|
*/
|
||||||
|
export function credKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Buffer | null {
|
||||||
|
const raw = env.IIOS_CRED_KEY;
|
||||||
|
if (!raw) return null;
|
||||||
|
const key = Buffer.from(raw, 'base64');
|
||||||
|
if (key.length !== 32) throw new Error('IIOS_CRED_KEY must decode to 32 bytes (base64-encoded 256-bit key)');
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealSecret(plaintext: string, key: Buffer): SealedSecret {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
return {
|
||||||
|
cipherText: enc.toString('base64'),
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
authTag: cipher.getAuthTag().toString('base64'),
|
||||||
|
keyVersion: SECRET_KEY_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSecret(sealed: SealedSecret, key: Buffer): string {
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'));
|
||||||
|
return Buffer.concat([decipher.update(Buffer.from(sealed.cipherText, 'base64')), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider';
|
||||||
|
import type { CapabilityRequest } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
|
const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
|
||||||
|
const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin <ceo@lynkeduppro.com>' };
|
||||||
|
|
||||||
|
const req = (payload: Record<string, unknown>): CapabilityRequest => ({
|
||||||
|
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A recording transport; optionally throws a given error on send. */
|
||||||
|
function stub(opts: { throwErr?: unknown; messageId?: string } = {}) {
|
||||||
|
const calls: Array<{ id: SmtpIdentity; mail: Parameters<MailTransport['sendMail']>[0] }> = [];
|
||||||
|
const make = (id: SmtpIdentity): MailTransport => ({
|
||||||
|
async sendMail(mail) {
|
||||||
|
calls.push({ id, mail });
|
||||||
|
if (opts.throwErr) throw opts.throwErr;
|
||||||
|
return { messageId: opts.messageId ?? '<generated@smtp.test>' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { make, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('smtpIdentityFromEnv', () => {
|
||||||
|
const base = { IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' };
|
||||||
|
|
||||||
|
it('builds the identity from a complete env trio (defaults port 587, secure false)', () => {
|
||||||
|
const id = smtpIdentityFromEnv({ ...base } as NodeJS.ProcessEnv);
|
||||||
|
expect(id).toMatchObject({ host: 'smtp.test', port: 587, secure: false, user: 'accounts@x', from: 'accounts@x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the trio is incomplete', () => {
|
||||||
|
expect(smtpIdentityFromEnv({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'a@x' } as NodeJS.ProcessEnv)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the fallback identity, reusing the primary host', () => {
|
||||||
|
const fb = smtpFallbackFromEnv({ ...base, IIOS_SMTP_FALLBACK_USER: 'ceo@x', IIOS_SMTP_FALLBACK_PASS: 'q', IIOS_SMTP_FALLBACK_FROM: 'CEO <ceo@x>' } as NodeJS.ProcessEnv);
|
||||||
|
expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO <ceo@x>' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null fallback when not configured', () => {
|
||||||
|
expect(smtpFallbackFromEnv({ ...base } as NodeJS.ProcessEnv)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SmtpProvider.send — envelope', () => {
|
||||||
|
it('maps target + payload into the mail and returns the messageId as providerRef', async () => {
|
||||||
|
const t = stub({ messageId: '<abc@smtp.test>' });
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '<p>x</p>', text: 'x' }));
|
||||||
|
expect(res).toMatchObject({ outcome: 'SENT', providerRef: '<abc@smtp.test>' });
|
||||||
|
expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '<p>x</p>', text: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets In-Reply-To + References headers for a reply', async () => {
|
||||||
|
const t = stub();
|
||||||
|
await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 're', inReplyTo: '<parent@smtp.test>' }));
|
||||||
|
expect(t.calls[0].mail).toMatchObject({ inReplyTo: '<parent@smtp.test>', references: '<parent@smtp.test>' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SmtpProvider.send — failure + fallback', () => {
|
||||||
|
it('retries via the fallback identity on a pre-acceptance failure (SENT via fallback)', async () => {
|
||||||
|
// primary throws ECONNREFUSED (server never accepted) → fallback used.
|
||||||
|
const primaryThrows = { make: (id: SmtpIdentity): MailTransport => ({
|
||||||
|
async sendMail(mail) {
|
||||||
|
if (id.user === ID.user) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
|
||||||
|
return { messageId: '<viaFallback@smtp.test>' };
|
||||||
|
},
|
||||||
|
}) };
|
||||||
|
const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' }));
|
||||||
|
expect(res.outcome).toBe('SENT');
|
||||||
|
expect(res.providerRef).toBe('fallback:<viaFallback@smtp.test>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT retry a post-acceptance failure (avoids double delivery) → FAILED', async () => {
|
||||||
|
// responseCode present = the server already spoke; retrying could double-send.
|
||||||
|
const t = stub({ throwErr: Object.assign(new Error('rejected after data'), { responseCode: 550 }) });
|
||||||
|
const res = await new SmtpProvider(ID, FB, t.make).send(req({ subject: 'x' }));
|
||||||
|
expect(res.outcome).toBe('FAILED');
|
||||||
|
expect(t.calls).toHaveLength(1); // primary only — no fallback attempt
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with no fallback, a failure is FAILED and never throws', async () => {
|
||||||
|
const t = stub({ throwErr: Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) });
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'x' }));
|
||||||
|
expect(res.outcome).toBe('FAILED');
|
||||||
|
expect(res.errorCode).toBe('ETIMEDOUT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SmtpProvider.send — attachments', () => {
|
||||||
|
const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
|
||||||
|
async (ref: string) => map[ref] ?? null;
|
||||||
|
|
||||||
|
it('resolves attachment refs to bytes and attaches them', async () => {
|
||||||
|
const t = stub();
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } }))
|
||||||
|
.send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] }));
|
||||||
|
expect(res.outcome).toBe('SENT');
|
||||||
|
expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => {
|
||||||
|
const t = stub();
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({}))
|
||||||
|
.send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] }));
|
||||||
|
expect(res.outcome).toBe('FAILED');
|
||||||
|
expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED');
|
||||||
|
expect(t.calls).toHaveLength(0); // nothing sent
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAILS when attachments are requested but no resolver is wired', async () => {
|
||||||
|
const t = stub();
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make) // no resolver
|
||||||
|
.send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] }));
|
||||||
|
expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plain send with no attachments is unaffected', async () => {
|
||||||
|
const t = stub();
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' }));
|
||||||
|
expect(res.outcome).toBe('SENT');
|
||||||
|
expect(t.calls[0].mail.attachments).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
|
||||||
|
it('maps storage bytes into an attachment; missing ref → null', async () => {
|
||||||
|
const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) };
|
||||||
|
const r = storageResolver(storage);
|
||||||
|
expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' });
|
||||||
|
expect(await r('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createTransport } from 'nodemailer';
|
||||||
|
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
|
/** One SMTP sending identity (a mailbox + how to reach its server). */
|
||||||
|
export interface SmtpIdentity {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
from: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailAttachment {
|
||||||
|
filename: string;
|
||||||
|
content: Buffer;
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch an attachment's bytes by its opaque contentRef (backed by the media StoragePort). */
|
||||||
|
export type AttachmentResolver = (contentRef: string) => Promise<{ filename?: string; content: Buffer; contentType?: string } | null>;
|
||||||
|
|
||||||
|
/** Build an AttachmentResolver over the media StoragePort (contentRef = the storage object key). */
|
||||||
|
export function storageResolver(storage: { get(key: string): Promise<{ data: Buffer; mime: string } | null> }): AttachmentResolver {
|
||||||
|
return async (contentRef) => {
|
||||||
|
const o = await storage.get(contentRef);
|
||||||
|
return o ? { content: o.data, contentType: o.mime } : null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */
|
||||||
|
export interface MailTransport {
|
||||||
|
sendMail(mail: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
inReplyTo?: string;
|
||||||
|
references?: string;
|
||||||
|
attachments?: MailAttachment[];
|
||||||
|
}): Promise<{ messageId: string; accepted?: unknown[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool = (v: string | undefined): boolean => v === 'true' || v === '1';
|
||||||
|
|
||||||
|
/** Build the primary SMTP identity from env, or null if the required trio is incomplete. */
|
||||||
|
export function smtpIdentityFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
|
||||||
|
return identityFrom(env, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the optional fallback identity (accounts@ → ceo@), or null if not configured. */
|
||||||
|
export function smtpFallbackFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
|
||||||
|
const fb = identityFrom(env, 'FALLBACK_');
|
||||||
|
if (fb) return fb;
|
||||||
|
// Fallback may reuse the primary host/port and only override the mailbox identity.
|
||||||
|
const host = env.IIOS_SMTP_HOST;
|
||||||
|
const user = env.IIOS_SMTP_FALLBACK_USER;
|
||||||
|
const pass = env.IIOS_SMTP_FALLBACK_PASS;
|
||||||
|
if (!host || !user || !pass) return null;
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: Number(env.IIOS_SMTP_PORT ?? 587),
|
||||||
|
secure: bool(env.IIOS_SMTP_SECURE),
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
from: env.IIOS_SMTP_FALLBACK_FROM ?? user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | null {
|
||||||
|
const host = env[`IIOS_SMTP_${prefix}HOST`] ?? (prefix ? undefined : env.IIOS_SMTP_HOST);
|
||||||
|
const user = env[`IIOS_SMTP_${prefix}USER`];
|
||||||
|
const pass = env[`IIOS_SMTP_${prefix}PASS`];
|
||||||
|
if (!host || !user || !pass) return null;
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: Number(env[`IIOS_SMTP_${prefix}PORT`] ?? env.IIOS_SMTP_PORT ?? 587),
|
||||||
|
secure: bool(env[`IIOS_SMTP_${prefix}SECURE`] ?? env.IIOS_SMTP_SECURE),
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
from: env[`IIOS_SMTP_${prefix}FROM`] ?? user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailPayload {
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
inReplyTo?: string;
|
||||||
|
/** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */
|
||||||
|
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP egress provider (nodemailer). Bound for EMAIL when the SMTP env trio is set (else the sandbox
|
||||||
|
* stays). A transport failure surfaces as FAILED, never thrown. On a PRE-acceptance failure it retries
|
||||||
|
* once via the fallback identity (accounts@ → ceo@); a post-acceptance failure is NOT retried, so a
|
||||||
|
* message the server already accepted can't be double-delivered.
|
||||||
|
*/
|
||||||
|
export class SmtpProvider implements CapabilityProvider {
|
||||||
|
readonly name = 'smtp';
|
||||||
|
readonly channelTypes = ['EMAIL'];
|
||||||
|
readonly capabilities = { canSend: true };
|
||||||
|
|
||||||
|
private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly primary: SmtpIdentity,
|
||||||
|
private readonly fallback?: SmtpIdentity,
|
||||||
|
makeTransport?: (id: SmtpIdentity) => MailTransport,
|
||||||
|
private readonly resolveAttachment?: AttachmentResolver,
|
||||||
|
) {
|
||||||
|
this.makeTransport = makeTransport ?? defaultTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const p = (req.payload ?? {}) as EmailPayload;
|
||||||
|
|
||||||
|
// Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing
|
||||||
|
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
|
||||||
|
let attachments: MailAttachment[] | undefined;
|
||||||
|
if (p.attachments && p.attachments.length > 0) {
|
||||||
|
if (!this.resolveAttachment) return this.failed('NO_ATTACHMENT_RESOLVER', started);
|
||||||
|
const out: MailAttachment[] = [];
|
||||||
|
for (const a of p.attachments) {
|
||||||
|
const r = await this.resolveAttachment(a.contentRef).catch(() => null);
|
||||||
|
if (!r) return this.failed('ATTACHMENT_UNRESOLVED', started);
|
||||||
|
out.push({ filename: a.filename ?? r.filename ?? 'attachment', content: r.content, ...(a.mimeType ?? r.contentType ? { contentType: a.mimeType ?? r.contentType } : {}) });
|
||||||
|
}
|
||||||
|
attachments = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> =>
|
||||||
|
this.makeTransport(id).sendMail({
|
||||||
|
from: id.from,
|
||||||
|
to: req.target,
|
||||||
|
subject: p.subject ?? '(no subject)',
|
||||||
|
text: p.text,
|
||||||
|
html: p.html,
|
||||||
|
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
|
||||||
|
...(attachments ? { attachments } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await attempt(this.primary);
|
||||||
|
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
|
||||||
|
} catch (err) {
|
||||||
|
// Retry via the fallback identity ONLY if the primary never got the message accepted.
|
||||||
|
if (this.fallback && isPreAcceptanceFailure(err)) {
|
||||||
|
try {
|
||||||
|
const info = await attempt(this.fallback);
|
||||||
|
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
|
||||||
|
} catch (err2) {
|
||||||
|
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private failed(errorCode: string, started: number): ProviderResult {
|
||||||
|
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode, latencyMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */
|
||||||
|
function isPreAcceptanceFailure(err: unknown): boolean {
|
||||||
|
const e = err as { code?: string; responseCode?: number };
|
||||||
|
const preCodes = ['ECONNECTION', 'ETIMEDOUT', 'ECONNREFUSED', 'EDNS', 'EAUTH', 'ESOCKET', 'EENVELOPE'];
|
||||||
|
if (e.code && preCodes.includes(e.code)) return true;
|
||||||
|
// A responseCode present means the server spoke — treat 5xx after acceptance as terminal (no retry).
|
||||||
|
return e.responseCode == null && e.code == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeOf(err: unknown): string {
|
||||||
|
const e = err as { code?: string; message?: string };
|
||||||
|
return (e.code ?? e.message ?? 'SMTP_ERROR').slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTransport(id: SmtpIdentity): MailTransport {
|
||||||
|
return createTransport({ host: id.host, port: id.port, secure: id.secure, auth: { user: id.user, pass: id.pass } }) as unknown as MailTransport;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { CapabilityRequest } from '@insignia/iios-contracts';
|
||||||
|
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
|
||||||
|
|
||||||
|
const CREDS: TwilioCreds = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
|
||||||
|
|
||||||
|
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
|
||||||
|
capability: 'channel.send',
|
||||||
|
channelType: 'SMS',
|
||||||
|
scopeId: 'scope_1',
|
||||||
|
target: '+15557654321',
|
||||||
|
payload: { text: 'hello world' },
|
||||||
|
idempotencyKey: 'idem-1',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwilioSmsProvider', () => {
|
||||||
|
it('resolves the scope creds and POSTs to Twilio, returning SENT with the message SID', async () => {
|
||||||
|
const http = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ sid: 'SM999' }), text: async () => '' });
|
||||||
|
const resolve = vi.fn().mockResolvedValue(CREDS);
|
||||||
|
const p = new TwilioSmsProvider(resolve, http);
|
||||||
|
|
||||||
|
const res = await p.send(req());
|
||||||
|
|
||||||
|
expect(resolve).toHaveBeenCalledWith('scope_1');
|
||||||
|
const [url, init] = http.mock.calls[0];
|
||||||
|
expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(init.headers.authorization).toBe(`Basic ${Buffer.from('AC123:tok_secret').toString('base64')}`);
|
||||||
|
const body = new URLSearchParams(init.body as string);
|
||||||
|
expect(body.get('To')).toBe('+15557654321');
|
||||||
|
expect(body.get('From')).toBe('+15550001111');
|
||||||
|
expect(body.get('Body')).toBe('hello world');
|
||||||
|
expect(res).toMatchObject({ outcome: 'SENT', providerRef: 'SM999' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed as NOT_CONFIGURED when the scope has no creds', async () => {
|
||||||
|
const http = vi.fn();
|
||||||
|
const p = new TwilioSmsProvider(async () => null, http);
|
||||||
|
const res = await p.send(req());
|
||||||
|
expect(res.outcome).toBe('FAILED');
|
||||||
|
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
||||||
|
expect(http).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed as NOT_CONFIGURED when the request has no scope', async () => {
|
||||||
|
const p = new TwilioSmsProvider(async () => CREDS, vi.fn());
|
||||||
|
const res = await p.send(req({ scopeId: undefined }));
|
||||||
|
expect(res.outcome).toBe('FAILED');
|
||||||
|
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a Twilio API error as FAILED (never throws)', async () => {
|
||||||
|
const http = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ code: 20003, message: 'Authenticate' }), text: async () => 'Authenticate' });
|
||||||
|
const p = new TwilioSmsProvider(async () => CREDS, http);
|
||||||
|
const res = await p.send(req());
|
||||||
|
expect(res.outcome).toBe('FAILED');
|
||||||
|
expect(res.errorCode).toBe('TWILIO_20003');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a transport throw as FAILED', async () => {
|
||||||
|
const http = vi.fn().mockRejectedValue(new Error('network down'));
|
||||||
|
const p = new TwilioSmsProvider(async () => CREDS, http);
|
||||||
|
const res = await p.send(req());
|
||||||
|
expect(res.outcome).toBe('FAILED');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
|
/** The decrypted Twilio config a tenant configures (BYO credentials). */
|
||||||
|
export interface TwilioCreds {
|
||||||
|
accountSid: string;
|
||||||
|
authToken: string;
|
||||||
|
fromNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a scope's Twilio creds (decrypting on demand); null when unconfigured/disabled. */
|
||||||
|
export type TwilioCredResolver = (scopeId: string) => Promise<TwilioCreds | null>;
|
||||||
|
|
||||||
|
/** Injectable HTTP sender so tests don't hit the network; prod uses fetch. */
|
||||||
|
export type HttpSender = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json: () => Promise<unknown>;
|
||||||
|
text: () => Promise<string>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const defaultSender: HttpSender = (url, init) => fetch(url, init) as unknown as ReturnType<HttpSender>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMS egress via Twilio, using the CALLER'S OWN credentials (resolved per scope at send time).
|
||||||
|
* Fail-closed: no scope or no configured creds ⇒ FAILED/NOT_CONFIGURED, nothing sent. A Twilio
|
||||||
|
* API error is surfaced as FAILED (never thrown), per the provider doctrine.
|
||||||
|
*/
|
||||||
|
export class TwilioSmsProvider implements CapabilityProvider {
|
||||||
|
readonly name = 'twilio-sms';
|
||||||
|
readonly channelTypes = ['SMS'];
|
||||||
|
readonly capabilities = { canSend: true, maxPayloadBytes: 1600 };
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly resolve: TwilioCredResolver,
|
||||||
|
private readonly http: HttpSender = defaultSender,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
if (!req.scopeId) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
|
||||||
|
|
||||||
|
const creds = await this.resolve(req.scopeId).catch(() => null);
|
||||||
|
if (!creds) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
To: req.target,
|
||||||
|
From: creds.fromNumber,
|
||||||
|
Body: String(req.payload.text ?? req.payload.body ?? ''),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.http(`https://api.twilio.com/2010-04-01/Accounts/${creds.accountSid}/Messages.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Basic ${Buffer.from(`${creds.accountSid}:${creds.authToken}`).toString('base64')}`,
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const latencyMs = Date.now() - started;
|
||||||
|
const payload = (await res.json().catch(() => ({}))) as { sid?: string; code?: number; message?: string };
|
||||||
|
if (!res.ok) {
|
||||||
|
return { providerRef: `twilio-${res.status}`, outcome: 'FAILED', errorCode: payload.code ? `TWILIO_${payload.code}` : `HTTP_${res.status}`, latencyMs };
|
||||||
|
}
|
||||||
|
return { providerRef: payload.sid ?? 'twilio-sent', outcome: 'SENT', latencyMs };
|
||||||
|
} catch (err) {
|
||||||
|
return { providerRef: 'twilio-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsInt,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -40,6 +41,7 @@ class PartDto {
|
|||||||
@IsOptional() @IsString() bodyText?: string;
|
@IsOptional() @IsString() bodyText?: string;
|
||||||
@IsOptional() @IsString() contentRef?: string;
|
@IsOptional() @IsString() contentRef?: string;
|
||||||
@IsOptional() @IsString() mimeType?: string;
|
@IsOptional() @IsString() mimeType?: string;
|
||||||
|
@IsOptional() @IsInt() sizeBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
|
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export class IngestService {
|
|||||||
bodyText: p.bodyText,
|
bodyText: p.bodyText,
|
||||||
contentRef: p.contentRef,
|
contentRef: p.contentRef,
|
||||||
mimeType: p.mimeType,
|
mimeType: p.mimeType,
|
||||||
|
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
|
||||||
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
import { MailInternalDto, MailSendDto } from './mail.dto';
|
||||||
|
import type { TemplateSource } from '../templates/template.model';
|
||||||
|
|
||||||
|
@Controller('v1/mail')
|
||||||
|
export class MailController {
|
||||||
|
constructor(
|
||||||
|
private readonly mail: MailService,
|
||||||
|
private readonly session: SessionVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** App-to-app mail — renders a template and posts it into the recipient's in-app inbox (no SMTP). */
|
||||||
|
@Post('internal')
|
||||||
|
async internal(@Body() body: MailInternalDto, @Headers('authorization') authorization?: string) {
|
||||||
|
const principal = this.principal(authorization);
|
||||||
|
return this.mail.postInternal(principal, {
|
||||||
|
source: this.source(body),
|
||||||
|
recipientUserId: body.recipientUserId,
|
||||||
|
vars: body.vars ?? {},
|
||||||
|
...(body.locale ? { locale: body.locale } : {}),
|
||||||
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** External email (SMTP) + an in-app mirror when a registered recipient is named. */
|
||||||
|
@Post('send')
|
||||||
|
async send(@Body() body: MailSendDto, @Headers('authorization') authorization?: string) {
|
||||||
|
const principal = this.principal(authorization);
|
||||||
|
return this.mail.sendExternalWithMirror(principal, {
|
||||||
|
source: this.source(body),
|
||||||
|
target: body.target,
|
||||||
|
vars: body.vars ?? {},
|
||||||
|
...(body.locale ? { locale: body.locale } : {}),
|
||||||
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
...(body.purpose ? { purpose: body.purpose } : {}),
|
||||||
|
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
|
||||||
|
...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource {
|
||||||
|
if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both');
|
||||||
|
if (body.inline) return { inline: body.inline };
|
||||||
|
if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key };
|
||||||
|
throw new BadRequestException('one of "key" or "inline" is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,41 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
import { InlineTemplateDto } from '../templates/template.dto';
|
||||||
|
|
||||||
|
/** An email attachment reference — bytes live in the media store under `contentRef`. */
|
||||||
|
export class AttachmentDto {
|
||||||
|
@IsOptional() @IsString() filename?: string;
|
||||||
|
@IsString() @IsNotEmpty() contentRef!: string;
|
||||||
|
@IsOptional() @IsString() mimeType?: string;
|
||||||
|
@IsOptional() @IsInt() sizeBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
|
||||||
|
export class MailInternalDto {
|
||||||
|
@IsOptional() @IsString() @IsNotEmpty() key?: string;
|
||||||
|
@IsOptional() @IsInt() version?: number;
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
|
||||||
|
|
||||||
|
@IsString() @IsNotEmpty() recipientUserId!: string;
|
||||||
|
@IsOptional() @IsObject() vars?: Record<string, unknown>;
|
||||||
|
@IsOptional() @IsString() locale?: string;
|
||||||
|
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
||||||
|
/** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */
|
||||||
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
|
||||||
|
export class MailSendDto {
|
||||||
|
@IsOptional() @IsString() @IsNotEmpty() key?: string;
|
||||||
|
@IsOptional() @IsInt() version?: number;
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
|
||||||
|
|
||||||
|
@IsString() @IsNotEmpty() target!: string;
|
||||||
|
@IsOptional() @IsObject() vars?: Record<string, unknown>;
|
||||||
|
@IsOptional() @IsString() locale?: string;
|
||||||
|
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
||||||
|
@IsOptional() @IsString() purpose?: string;
|
||||||
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
|
||||||
|
/** The registered recipient to mirror to; omit for a pre-registration send (email only). */
|
||||||
|
@IsOptional() @IsString() mirrorToUserId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TemplateModule } from '../templates/template.module';
|
||||||
|
import { InteractionsModule } from '../interactions/interactions.module';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
import { MailController } from './mail.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail orchestration: render a template (TemplateModule) → deliver it either app-to-app (an EMAIL
|
||||||
|
* interaction via IngestService) or externally (TemplatedSender/SMTP) with an in-app mirror.
|
||||||
|
* SessionVerifier + ActorResolver are global.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [TemplateModule, InteractionsModule],
|
||||||
|
controllers: [MailController],
|
||||||
|
providers: [MailService],
|
||||||
|
exports: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { IngestService } from '../interactions/ingest.service';
|
||||||
|
import { MessageService } from '../messaging/message.service';
|
||||||
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
|
import { TemplateRepository } from '../templates/template.repository';
|
||||||
|
import { TemplateService } from '../templates/template.service';
|
||||||
|
import { TemplatedSender } from '../templates/templated-sender';
|
||||||
|
import { MailService, contentToParts } from './mail.service';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios_test?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const asService = prisma as unknown as PrismaService;
|
||||||
|
const actors = new ActorResolver(asService);
|
||||||
|
|
||||||
|
function mail(): MailService {
|
||||||
|
const templates = new TemplateService(new TemplateRepository(asService));
|
||||||
|
const ingest = new IngestService(asService, makeFakePorts(), actors);
|
||||||
|
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
|
const sender = new TemplatedSender(templates, outbound, makeFakePorts());
|
||||||
|
return new MailService(templates, ingest, sender, actors, asService);
|
||||||
|
}
|
||||||
|
const messages = () => new MessageService(asService, makeFakePorts(), actors);
|
||||||
|
|
||||||
|
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||||
|
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
|
||||||
|
const inline = { inline: { subject: 'Welcome Dana', html: '<p>Hi <b>Dana</b></p>', text: 'Hi Dana', variables: [] } };
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('contentToParts (pure)', () => {
|
||||||
|
it('produces HTML + TEXT parts and the subject', () => {
|
||||||
|
expect(contentToParts({ subject: 'S', html: '<p>h</p>', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: '<p>h</p>' }, { kind: 'TEXT', bodyText: 't' }] });
|
||||||
|
});
|
||||||
|
it('never yields zero parts (empty text fallback)', () => {
|
||||||
|
expect(contentToParts({ subject: 'S' }).parts).toEqual([{ kind: 'TEXT', bodyText: '' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MailService.postInternal', () => {
|
||||||
|
it('creates an EMAIL interaction and makes the thread visible to BOTH sender and recipient', async () => {
|
||||||
|
const res = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:1' });
|
||||||
|
|
||||||
|
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
|
||||||
|
expect(interaction.kind).toBe('EMAIL');
|
||||||
|
expect(interaction.parts.map((p) => p.kind).sort()).toEqual(['HTML', 'TEXT']);
|
||||||
|
|
||||||
|
const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } });
|
||||||
|
expect(thread.subject).toBe('Welcome Dana');
|
||||||
|
expect((thread.metadata as { source?: string } | null)?.source).toBe('crm-mail'); // lists separately from chat
|
||||||
|
|
||||||
|
// The load-bearing assertion: the RECIPIENT can see the thread in their inbox.
|
||||||
|
const bobThreads = await messages().listThreads(bob);
|
||||||
|
expect(bobThreads.map((t) => t.threadId)).toContain(res.threadId);
|
||||||
|
// ...and so can the sender.
|
||||||
|
const aliceThreads = await messages().listThreads(alice);
|
||||||
|
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores an in-app attachment as a media part alongside the body', async () => {
|
||||||
|
const res = await mail().postInternal(alice, {
|
||||||
|
source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att',
|
||||||
|
attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }],
|
||||||
|
});
|
||||||
|
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
|
||||||
|
const file = interaction.parts.find((p) => p.contentRef);
|
||||||
|
expect(file).toBeDefined();
|
||||||
|
expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF
|
||||||
|
expect(file!.contentRef).toBe('scope/roof.png');
|
||||||
|
expect(file!.mimeType).toBe('image/png');
|
||||||
|
expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => {
|
||||||
|
const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
|
||||||
|
const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
|
||||||
|
expect(b.threadId).toBe(a.threadId);
|
||||||
|
expect(await prisma.iiosInteraction.count({ where: { threadId: a.threadId } })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MailService.sendExternalWithMirror', () => {
|
||||||
|
const ext = { source: inline, target: 'dana@acme.com', idempotencyKey: 'recv:1' } as const;
|
||||||
|
|
||||||
|
it('sends via the outbound pipeline AND mirrors into a registered recipient inbox', async () => {
|
||||||
|
const res = await mail().sendExternalWithMirror(alice, { ...ext, mirrorToUserId: 'bob' });
|
||||||
|
// outbound command exists (SENT via sandbox in tests)
|
||||||
|
const cmd = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id: res.commandId } });
|
||||||
|
expect(cmd.channelType).toBe('EMAIL');
|
||||||
|
expect(cmd.target).toBe('dana@acme.com');
|
||||||
|
// mirror interaction visible to the recipient
|
||||||
|
expect(res.mirror).toBeDefined();
|
||||||
|
const bobThreads = await messages().listThreads(bob);
|
||||||
|
expect(bobThreads.map((t) => t.threadId)).toContain(res.mirror!.threadId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT mirror when there is no registered recipient (pre-registration send)', async () => {
|
||||||
|
const res = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:2' }); // no mirrorToUserId
|
||||||
|
expect(res.mirror).toBeUndefined();
|
||||||
|
// an outbound command was created, but no mirror interaction
|
||||||
|
expect(await prisma.iiosOutboundCommand.count({ where: { id: res.commandId } })).toBe(1);
|
||||||
|
expect(await prisma.iiosInteraction.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — a replay yields one command and one mirror', async () => {
|
||||||
|
const a = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' });
|
||||||
|
const b = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' });
|
||||||
|
expect(b.commandId).toBe(a.commandId);
|
||||||
|
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'recv:dup' } })).toBe(1);
|
||||||
|
expect(await prisma.iiosInteraction.count({ where: { threadId: a.mirror!.threadId } })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { IngestService } from '../interactions/ingest.service';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { TemplateService } from '../templates/template.service';
|
||||||
|
import { TemplatedSender } from '../templates/templated-sender';
|
||||||
|
import type { RenderedContent } from '../templates/template.renderer';
|
||||||
|
import type { TemplateSource } from '../templates/template.model';
|
||||||
|
|
||||||
|
export interface MailPart { kind: 'HTML' | 'TEXT'; bodyText: string }
|
||||||
|
|
||||||
|
/** Turn rendered content into ingest parts (HTML + TEXT) + the thread subject. Pure. */
|
||||||
|
export function contentToParts(content: RenderedContent): { subject?: string; parts: MailPart[] } {
|
||||||
|
const parts: MailPart[] = [];
|
||||||
|
if (content.html != null) parts.push({ kind: 'HTML', bodyText: content.html });
|
||||||
|
if (content.text != null) parts.push({ kind: 'TEXT', bodyText: content.text });
|
||||||
|
// A message must carry at least one part; fall back to an empty text part rather than fail ingest.
|
||||||
|
if (parts.length === 0) parts.push({ kind: 'TEXT', bodyText: '' });
|
||||||
|
return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
|
||||||
|
|
||||||
|
export interface PostInternalInput {
|
||||||
|
source: TemplateSource;
|
||||||
|
recipientUserId: string;
|
||||||
|
vars?: Record<string, unknown>;
|
||||||
|
locale?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
/** In-app attachment refs — stored as FILE message parts alongside the body. */
|
||||||
|
attachments?: MailAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendExternalInput {
|
||||||
|
source: TemplateSource;
|
||||||
|
target: string; // email address (external)
|
||||||
|
vars?: Record<string, unknown>;
|
||||||
|
locale?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
purpose?: string;
|
||||||
|
/** Attachment refs (bytes resolved at send time). */
|
||||||
|
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
|
||||||
|
/** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */
|
||||||
|
mirrorToUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail orchestration: render a template, then deliver it.
|
||||||
|
* - INTERNAL (app-to-app): create an EMAIL interaction on a per-email thread — no SMTP.
|
||||||
|
* - EXTERNAL: send via SMTP (TemplatedSender) AND mirror a copy into the recipient's in-app inbox,
|
||||||
|
* but only when they are a registered user (pre-registration sends have no inbox yet).
|
||||||
|
*
|
||||||
|
* ingest() writes the interaction + thread but adds no participants, and a thread is only visible to
|
||||||
|
* its participants — so after each ingest we add BOTH the sender and the recipient as participants.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
constructor(
|
||||||
|
private readonly templates: TemplateService,
|
||||||
|
private readonly ingest: IngestService,
|
||||||
|
private readonly sender: TemplatedSender,
|
||||||
|
private readonly actors: ActorResolver,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Marks a thread as CRM mail so it lists separately from chat (Messenger uses source=crm-messenger). */
|
||||||
|
static readonly SOURCE = 'crm-mail';
|
||||||
|
|
||||||
|
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> {
|
||||||
|
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) });
|
||||||
|
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL', input.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
|
||||||
|
const command = await this.sender.sendTemplated({
|
||||||
|
source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {},
|
||||||
|
...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}),
|
||||||
|
...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror only for a registered recipient (timing rule: no inbox exists pre-registration).
|
||||||
|
if (!input.mirrorToUserId) return { commandId: command.id };
|
||||||
|
|
||||||
|
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) });
|
||||||
|
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL', input.attachments);
|
||||||
|
return { commandId: command.id, mirror };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */
|
||||||
|
private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } {
|
||||||
|
const mime = a.mimeType ?? '';
|
||||||
|
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
|
||||||
|
return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */
|
||||||
|
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> {
|
||||||
|
const { subject, parts } = contentToParts(content);
|
||||||
|
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
|
||||||
|
const res = await this.ingest.ingest(
|
||||||
|
{
|
||||||
|
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
|
||||||
|
channel: { type: channelType, externalChannelId: channelType.toLowerCase() },
|
||||||
|
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
|
||||||
|
kind: 'EMAIL',
|
||||||
|
thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
|
||||||
|
parts: allParts,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
providerEventId: key,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make the thread visible to both the sender and the recipient (ingest adds no participants).
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
const senderActor = await this.actors.resolveActor(scope.id, principal);
|
||||||
|
const recipientActor = await this.actors.resolveActor(scope.id, {
|
||||||
|
userId: recipientUserId, appId: principal.appId, orgId: principal.orgId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}),
|
||||||
|
});
|
||||||
|
await this.actors.ensureParticipant(res.threadId, senderActor.id);
|
||||||
|
await this.actors.ensureParticipant(res.threadId, recipientActor.id);
|
||||||
|
|
||||||
|
// Tag the thread as CRM mail (thread metadata) so it lists separately from Messenger chat.
|
||||||
|
// ingest() puts req.metadata on the interaction, not the thread, so we set it here directly.
|
||||||
|
await this.prisma.iiosThread.update({
|
||||||
|
where: { id: res.threadId },
|
||||||
|
data: { metadata: { source: MailService.SOURCE } as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { threadId: res.threadId, interactionId: res.interactionId };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import { SessionVerifier } from '../platform/session.verifier';
|
|||||||
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
||||||
import type { MessagePrincipal } from '../identity/actor.resolver';
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
|
||||||
|
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
|
||||||
|
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
|
||||||
|
|
||||||
@Controller('v1/media')
|
@Controller('v1/media')
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -37,6 +40,12 @@ export class MediaController {
|
|||||||
async blob(@Param('token') token: string, @Res() res: Response) {
|
async blob(@Param('token') token: string, @Res() res: Response) {
|
||||||
const { data, mime } = await this.media.get(token);
|
const { data, mime } = await this.media.get(token);
|
||||||
res.setHeader('Content-Type', mime);
|
res.setHeader('Content-Type', mime);
|
||||||
|
// Never let the browser MIME-sniff an upload into something executable.
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
|
||||||
|
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
|
||||||
|
// fine even with attachment disposition; only top-level navigation to the blob is affected.
|
||||||
|
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
|
||||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
res.send(data);
|
res.send(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, Logger } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { IdentityModule } from '../identity/identity.module';
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
import { MediaController } from './media.controller';
|
import { MediaController } from './media.controller';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { LocalDiskStorage } from './local-disk.storage';
|
import { LocalDiskStorage } from './local-disk.storage';
|
||||||
import { STORAGE_PORT } from './storage.port';
|
import { S3Storage, s3ConfigFromEnv } from './s3.storage';
|
||||||
|
import { STORAGE_PORT, type StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
/** S3/MinIO when its env is set (IIOS_S3_BUCKET + keys), else local disk. Env-driven swap. */
|
||||||
|
function makeStorage(): StoragePort {
|
||||||
|
const cfg = s3ConfigFromEnv();
|
||||||
|
if (cfg) {
|
||||||
|
new Logger('MediaStorage').log(`using S3 storage (bucket "${cfg.bucket}"${cfg.endpoint ? ` @ ${cfg.endpoint}` : ''})`);
|
||||||
|
return new S3Storage(cfg);
|
||||||
|
}
|
||||||
|
return new LocalDiskStorage();
|
||||||
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, IdentityModule],
|
imports: [PlatformModule, IdentityModule],
|
||||||
controllers: [MediaController],
|
controllers: [MediaController],
|
||||||
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
|
providers: [MediaService, { provide: STORAGE_PORT, useFactory: makeStorage }],
|
||||||
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
|
// Exported so the capability layer can resolve email attachment bytes at send time.
|
||||||
|
exports: [STORAGE_PORT],
|
||||||
})
|
})
|
||||||
export class MediaModule {}
|
export class MediaModule {}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { S3Storage, s3ConfigFromEnv, type S3Config, type S3Like } from './s3.storage';
|
||||||
|
|
||||||
|
const CFG: S3Config = { endpoint: 'https://minio.test', region: 'us-east-1', bucket: 'iios', accessKeyId: 'k', secretAccessKey: 's', forcePathStyle: true };
|
||||||
|
|
||||||
|
/** A recording stub S3 client; GetObject returns whatever `store[key]` holds (or a NoSuchKey error). */
|
||||||
|
function stub(store: Record<string, { body: Buffer; mime: string }> = {}) {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const client: S3Like = {
|
||||||
|
async send(command: unknown) {
|
||||||
|
calls.push(command);
|
||||||
|
if (command instanceof PutObjectCommand) { store[command.input.Key!] = { body: command.input.Body as Buffer, mime: command.input.ContentType ?? '' }; return {}; }
|
||||||
|
if (command instanceof DeleteObjectCommand) { delete store[command.input.Key!]; return {}; }
|
||||||
|
if (command instanceof GetObjectCommand) {
|
||||||
|
const hit = store[command.input.Key!];
|
||||||
|
if (!hit) throw Object.assign(new Error('missing'), { name: 'NoSuchKey' });
|
||||||
|
return { Body: { transformToByteArray: async () => new Uint8Array(hit.body) }, ContentType: hit.mime };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { client, calls, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('s3ConfigFromEnv', () => {
|
||||||
|
it('builds config from env (path-style default true for MinIO)', () => {
|
||||||
|
const cfg = s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x', IIOS_S3_BUCKET: 'b', IIOS_S3_ACCESS_KEY: 'k', IIOS_S3_SECRET_KEY: 's' } as NodeJS.ProcessEnv);
|
||||||
|
expect(cfg).toMatchObject({ endpoint: 'https://minio.x', bucket: 'b', region: 'us-east-1', forcePathStyle: true });
|
||||||
|
});
|
||||||
|
it('returns null when bucket/keys are missing', () => {
|
||||||
|
expect(s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x' } as NodeJS.ProcessEnv)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S3Storage (StoragePort over S3/MinIO)', () => {
|
||||||
|
it('put stores the object and returns size + sha256', async () => {
|
||||||
|
const s = stub();
|
||||||
|
const store = new S3Storage(CFG, s.client);
|
||||||
|
const res = await store.put('scope/obj1', Buffer.from('hello'), 'text/plain');
|
||||||
|
expect(res.sizeBytes).toBe(5);
|
||||||
|
expect(res.checksumSha256).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
const put = s.calls[0] as PutObjectCommand;
|
||||||
|
expect(put.input).toMatchObject({ Bucket: 'iios', Key: 'scope/obj1', ContentType: 'text/plain' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get round-trips the bytes + mime', async () => {
|
||||||
|
const s = stub();
|
||||||
|
const store = new S3Storage(CFG, s.client);
|
||||||
|
await store.put('scope/obj2', Buffer.from('PDFDATA'), 'application/pdf');
|
||||||
|
const got = await store.get('scope/obj2');
|
||||||
|
expect(got).toEqual({ data: Buffer.from('PDFDATA'), mime: 'application/pdf', sizeBytes: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get returns null for a missing key (NoSuchKey → null, not throw)', async () => {
|
||||||
|
const store = new S3Storage(CFG, stub().client);
|
||||||
|
expect(await store.get('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove issues a DeleteObject', async () => {
|
||||||
|
const s = stub({ 'k': { body: Buffer.from('x'), mime: 't' } });
|
||||||
|
await new S3Storage(CFG, s.client).remove('k');
|
||||||
|
expect(s.calls.some((c) => c instanceof DeleteObjectCommand)).toBe(true);
|
||||||
|
expect(s.store['k']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import type { StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
export interface S3Config {
|
||||||
|
endpoint?: string; // MinIO/self-hosted URL; omit for AWS
|
||||||
|
region: string;
|
||||||
|
bucket: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
forcePathStyle: boolean; // true for MinIO
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one method this adapter uses — lets tests inject a stub client (no live S3/MinIO). */
|
||||||
|
export interface S3Like {
|
||||||
|
send(command: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an S3Config from env, or null if the required bits are missing (→ fall back to disk). */
|
||||||
|
export function s3ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S3Config | null {
|
||||||
|
const bucket = env.IIOS_S3_BUCKET;
|
||||||
|
const accessKeyId = env.IIOS_S3_ACCESS_KEY;
|
||||||
|
const secretAccessKey = env.IIOS_S3_SECRET_KEY;
|
||||||
|
if (!bucket || !accessKeyId || !secretAccessKey) return null;
|
||||||
|
return {
|
||||||
|
...(env.IIOS_S3_ENDPOINT ? { endpoint: env.IIOS_S3_ENDPOINT } : {}),
|
||||||
|
region: env.IIOS_S3_REGION ?? 'us-east-1',
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
// MinIO/self-hosted needs path-style; default true unless explicitly disabled for AWS.
|
||||||
|
forcePathStyle: env.IIOS_S3_FORCE_PATH_STYLE !== 'false',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3-compatible object storage (AWS S3, MinIO, R2, Supabase Storage). A drop-in for LocalDiskStorage:
|
||||||
|
* same StoragePort contract. sha256 is computed locally on put (S3 doesn't return it), matching the
|
||||||
|
* disk adapter. A missing object reads back as null (not an error).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class S3Storage implements StoragePort {
|
||||||
|
private readonly client: S3Like;
|
||||||
|
private readonly bucket: string;
|
||||||
|
|
||||||
|
constructor(config: S3Config, client?: S3Like) {
|
||||||
|
this.bucket = config.bucket;
|
||||||
|
this.client = client ?? new S3Client({
|
||||||
|
region: config.region,
|
||||||
|
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
||||||
|
forcePathStyle: config.forcePathStyle,
|
||||||
|
credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
|
||||||
|
const checksumSha256 = createHash('sha256').update(data).digest('hex');
|
||||||
|
await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, Body: data, ContentType: mime }));
|
||||||
|
return { sizeBytes: data.length, checksumSha256 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
|
||||||
|
try {
|
||||||
|
const res = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }))) as {
|
||||||
|
Body?: { transformToByteArray(): Promise<Uint8Array> };
|
||||||
|
ContentType?: string;
|
||||||
|
};
|
||||||
|
if (!res.Body) return null;
|
||||||
|
const bytes = await res.Body.transformToByteArray();
|
||||||
|
const data = Buffer.from(bytes);
|
||||||
|
return { data, mime: res.ContentType ?? 'application/octet-stream', sizeBytes: data.length };
|
||||||
|
} catch (err) {
|
||||||
|
if (isNotFound(err)) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(objectKey: string): Promise<void> {
|
||||||
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFound(err: unknown): boolean {
|
||||||
|
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
|
||||||
|
return e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404;
|
||||||
|
}
|
||||||
@@ -114,10 +114,18 @@ export class MessageService {
|
|||||||
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
const alreadyMember =
|
const alreadyMember =
|
||||||
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null;
|
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null;
|
||||||
// Join is governed ONLY for threads that opted into a membership model (chat dm/group);
|
// Join is governed ONLY for threads that opted into a membership model (chat dm/group/channel);
|
||||||
// support/inbox/generic threads (no membership attr) keep open-join, unchanged.
|
// support/inbox/generic threads (no membership attr) keep open-join, unchanged. A PUBLIC channel
|
||||||
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
// is the one membership type that also allows open self-join (policy reads `visibility`).
|
||||||
await decideOrThrow(this.ports, { action: 'iios.thread.join', threadId, scopeId: thread.scopeId, membership, alreadyMember });
|
const bag = (thread.metadata as { membership?: string; visibility?: string } | null) ?? {};
|
||||||
|
await decideOrThrow(this.ports, {
|
||||||
|
action: 'iios.thread.join',
|
||||||
|
threadId,
|
||||||
|
scopeId: thread.scopeId,
|
||||||
|
membership: bag.membership,
|
||||||
|
visibility: bag.visibility,
|
||||||
|
alreadyMember,
|
||||||
|
});
|
||||||
await this.actors.ensureParticipant(threadId, actor.id);
|
await this.actors.ensureParticipant(threadId, actor.id);
|
||||||
return { threadId, status: thread.status, history: await this.history(threadId) };
|
return { threadId, status: thread.status, history: await this.history(threadId) };
|
||||||
}
|
}
|
||||||
@@ -159,6 +167,129 @@ export class MessageService {
|
|||||||
return { threadId, participantCount: participantCount + 1 };
|
return { threadId, participantCount: participantCount + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Governed thread rename (a generic subject update). Policy decides who may rename — for a
|
||||||
|
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
|
||||||
|
* the subject; "group settings" meaning lives in the app + policy, not here.
|
||||||
|
*/
|
||||||
|
async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> {
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
|
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
|
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
||||||
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
|
||||||
|
await decideOrThrow(this.ports, {
|
||||||
|
action: 'iios.thread.update',
|
||||||
|
threadId,
|
||||||
|
scopeId: thread.scopeId,
|
||||||
|
membership,
|
||||||
|
callerRole: callerP?.participantRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } });
|
||||||
|
return { threadId, subject };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Governed participant removal. Policy decides who may remove — for a membership thread the dev
|
||||||
|
* OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success.
|
||||||
|
*/
|
||||||
|
async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): Promise<{ threadId: string; participantCount: number }> {
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
|
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
|
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
||||||
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
|
||||||
|
await decideOrThrow(this.ports, {
|
||||||
|
action: 'iios.thread.participant.remove',
|
||||||
|
threadId,
|
||||||
|
scopeId: thread.scopeId,
|
||||||
|
membership,
|
||||||
|
callerRole: callerP?.participantRole,
|
||||||
|
targetUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
const target = await this.actors.resolveActor(scope.id, {
|
||||||
|
userId: targetUserId, appId: principal.appId, orgId: principal.orgId, tenantId: principal.tenantId, displayName: targetUserId,
|
||||||
|
});
|
||||||
|
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } });
|
||||||
|
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
|
||||||
|
return { threadId, participantCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */
|
||||||
|
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
|
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
|
||||||
|
const parts = await this.prisma.iiosThreadParticipant.findMany({
|
||||||
|
where: { threadId },
|
||||||
|
include: { actor: { include: { sourceHandle: true } } },
|
||||||
|
});
|
||||||
|
return parts.map((p) => ({
|
||||||
|
userId: p.actor?.sourceHandle?.externalId ?? '',
|
||||||
|
displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown',
|
||||||
|
role: p.participantRole ?? 'MEMBER',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope-wide thread discovery — the ONE generic primitive channels need beyond dm/group.
|
||||||
|
* Unlike listThreads (membership-scoped), this returns threads in the caller's scope matching an
|
||||||
|
* opaque metadata filter (e.g. {membership:'channel', visibility:'public'}) REGARDLESS of whether
|
||||||
|
* the caller is a participant, each flagged `joined`. Governed by policy (fail-closed) + fenced to
|
||||||
|
* the caller's own scope. The kernel never interprets the filter keys.
|
||||||
|
*/
|
||||||
|
async discoverThreads(
|
||||||
|
principal: MessagePrincipal,
|
||||||
|
filter?: { metadata?: Record<string, string> },
|
||||||
|
): Promise<Array<{ threadId: string; subject: string | null; metadata: Record<string, unknown> | null; participantCount: number; joined: boolean }>> {
|
||||||
|
const scope = await this.actors.findScope(principal);
|
||||||
|
if (!scope) return [];
|
||||||
|
await decideOrThrow(this.ports, { action: 'iios.thread.discover', scopeId: scope.id });
|
||||||
|
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||||
|
|
||||||
|
const inScope = await this.prisma.iiosThread.findMany({ where: { scopeId: scope.id } });
|
||||||
|
const metaFilter = filter?.metadata;
|
||||||
|
const matched = metaFilter
|
||||||
|
? inScope.filter((t) => {
|
||||||
|
const bag = (t.metadata as Record<string, unknown> | null) ?? {};
|
||||||
|
return Object.entries(metaFilter).every(([k, v]) => bag[k] === v);
|
||||||
|
})
|
||||||
|
: inScope;
|
||||||
|
if (matched.length === 0) return [];
|
||||||
|
|
||||||
|
const ids = matched.map((t) => t.id);
|
||||||
|
const parts = await this.prisma.iiosThreadParticipant.groupBy({ by: ['threadId'], where: { threadId: { in: ids } }, _count: { actorId: true } });
|
||||||
|
const countBy = new Map(parts.map((p) => [p.threadId, p._count.actorId]));
|
||||||
|
const mine = await this.prisma.iiosThreadParticipant.findMany({ where: { threadId: { in: ids }, actorId: actor.id }, select: { threadId: true } });
|
||||||
|
const joinedSet = new Set(mine.map((m) => m.threadId));
|
||||||
|
|
||||||
|
return matched.map((t) => ({
|
||||||
|
threadId: t.id,
|
||||||
|
subject: t.subject,
|
||||||
|
metadata: (t.metadata as Record<string, unknown> | null) ?? null,
|
||||||
|
participantCount: countBy.get(t.id) ?? 0,
|
||||||
|
joined: joinedSet.has(t.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Self-leave: remove the caller's own participant row. Governed (a member may always leave). */
|
||||||
|
async leaveThread(threadId: string, principal: MessagePrincipal): Promise<{ threadId: string; participantCount: number }> {
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
|
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
|
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } });
|
||||||
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
await decideOrThrow(this.ports, { action: 'iios.thread.leave', threadId, scopeId: thread.scopeId, membership, callerRole: callerP?.participantRole });
|
||||||
|
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: actor.id } });
|
||||||
|
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
|
||||||
|
return { threadId, participantCount };
|
||||||
|
}
|
||||||
|
|
||||||
/** Toggle the caller's per-thread notification mute flag. */
|
/** Toggle the caller's per-thread notification mute flag. */
|
||||||
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
|||||||
@@ -124,6 +124,28 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
|||||||
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('group settings: admin renames + lists members + removes; a plain member cannot rename/remove', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
|
||||||
|
await s.addParticipant(threadId, alice, 'bob');
|
||||||
|
|
||||||
|
// admin renames
|
||||||
|
expect((await s.renameThread(threadId, alice, 'Design Team')).subject).toBe('Design Team');
|
||||||
|
// a plain member cannot rename
|
||||||
|
await expect(s.renameThread(threadId, bob, 'Hacked')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
|
||||||
|
// member list carries roles
|
||||||
|
const members = await s.listParticipants(threadId, alice);
|
||||||
|
expect(members.map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
|
||||||
|
expect(members.find((m) => m.userId === 'alice')?.role).toBe('ADMIN');
|
||||||
|
|
||||||
|
// a plain member cannot remove
|
||||||
|
await expect(s.removeParticipant(threadId, bob, 'alice')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
// admin removes bob
|
||||||
|
expect((await s.removeParticipant(threadId, alice, 'bob')).participantCount).toBe(1);
|
||||||
|
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
||||||
const s = gov();
|
const s = gov();
|
||||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
@@ -132,6 +154,34 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
|||||||
expect((await s.openThread(threadId, bob)).threadId).toBe(threadId);
|
expect((await s.openThread(threadId, bob)).threadId).toBe(threadId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('channels: a PUBLIC channel allows open self-join; a PRIVATE one does not', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
|
||||||
|
expect((await s.openThread(pub, bob)).threadId).toBe(pub); // bob self-joins a public channel
|
||||||
|
|
||||||
|
const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' });
|
||||||
|
await expect(s.openThread(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError); // private = invite-only
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discoverThreads browses same-scope channels with a joined flag; leave removes me', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId: gen } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
|
||||||
|
await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); // a group must NOT appear in a channel browse
|
||||||
|
|
||||||
|
// bob (non-member, same scope) discovers the public channel as not-joined.
|
||||||
|
const seen = await s.discoverThreads(bob, { metadata: { membership: 'channel', visibility: 'public' } });
|
||||||
|
expect(seen.map((t) => t.threadId)).toEqual([gen]);
|
||||||
|
expect(seen[0]!.joined).toBe(false);
|
||||||
|
// alice (member) sees it joined.
|
||||||
|
expect((await s.discoverThreads(alice, { metadata: { membership: 'channel', visibility: 'public' } }))[0]!.joined).toBe(true);
|
||||||
|
|
||||||
|
// bob joins, appears in his list, then leaves.
|
||||||
|
await s.openThread(gen, bob);
|
||||||
|
expect((await s.listThreads(bob)).map((t) => t.threadId)).toContain(gen);
|
||||||
|
await s.leaveThread(gen, bob);
|
||||||
|
expect((await s.listThreads(bob)).map((t) => t.threadId)).not.toContain(gen);
|
||||||
|
});
|
||||||
|
|
||||||
it('listThreads returns the caller’s threads with membership, count, last message + unread', async () => {
|
it('listThreads returns the caller’s threads with membership, count, last message + unread', async () => {
|
||||||
const s = gov();
|
const s = gov();
|
||||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
|||||||
@@ -33,6 +33,49 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
|||||||
expect(asAdmin.allow).toBe(true);
|
expect(asAdmin.allow).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
|
||||||
|
const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
|
||||||
|
expect(asMember.allow).toBe(false);
|
||||||
|
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
|
||||||
|
const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
|
||||||
|
expect(asMember.allow).toBe(false);
|
||||||
|
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
|
||||||
|
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
|
||||||
|
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
|
||||||
|
expect((await up(mime)).allow).toBe(true);
|
||||||
|
}
|
||||||
|
const bad = await up('application/x-msdownload');
|
||||||
|
expect(bad.allow).toBe(false);
|
||||||
|
expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
|
||||||
|
const big = await up('image/png', 30 * 1024 * 1024);
|
||||||
|
expect(big.allow).toBe(false);
|
||||||
|
expect(big.obligations[0]?.reason).toMatch(/too large/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a PUBLIC channel allows open self-join; private channel and group do not', async () => {
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'public', alreadyMember: false })).allow).toBe(true);
|
||||||
|
const priv = await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: false });
|
||||||
|
expect(priv.allow).toBe(false);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false })).allow).toBe(false);
|
||||||
|
// an existing member of a private channel can still (re)open it
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: true })).allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discover and leave are allowed (scope fence / member-implied)', async () => {
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.discover' })).allow).toBe(true);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.leave', membership: 'channel' })).allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('self-join is governed only on membership threads', async () => {
|
it('self-join is governed only on membership threads', async () => {
|
||||||
// generic / support thread (no membership attr) → open join, unchanged
|
// generic / support thread (no membership attr) → open join, unchanged
|
||||||
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type { PolicyDecision } from '@insignia/iios-contracts';
|
|||||||
*/
|
*/
|
||||||
export interface OpaInput {
|
export interface OpaInput {
|
||||||
action?: string;
|
action?: string;
|
||||||
membership?: string; // app-set generic thread attribute: 'dm' | 'group'
|
membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel'
|
||||||
|
visibility?: string; // 'public' | 'private' (channels)
|
||||||
participantCount?: number;
|
participantCount?: number;
|
||||||
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
||||||
alreadyMember?: boolean;
|
alreadyMember?: boolean;
|
||||||
@@ -22,7 +23,7 @@ export interface OpaInput {
|
|||||||
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
const MEDIA_ALLOWED = /^(image|video|audio)\//;
|
const MEDIA_ALLOWED = /^(image|video|audio)\//;
|
||||||
const MEDIA_ALLOWED_DOCS = new Set([
|
const MEDIA_ALLOWED_DOCS = new Set([
|
||||||
'application/pdf', 'text/plain', 'application/zip',
|
'application/pdf', 'text/plain', 'text/markdown', 'text/x-markdown', 'text/html', 'text/csv', 'application/zip',
|
||||||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
@@ -42,13 +43,34 @@ export class DevOpaPort {
|
|||||||
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||||
return allow();
|
return allow();
|
||||||
}
|
}
|
||||||
|
case 'iios.thread.participant.remove': {
|
||||||
|
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
|
||||||
|
// (no membership attr) threads allow removal by any member.
|
||||||
|
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||||
|
if (i.membership && i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can remove participants');
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
case 'iios.thread.update': {
|
||||||
|
// Rename / settings change: for a group, only an ADMIN. Ungoverned threads allow it.
|
||||||
|
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can change group settings');
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
case 'iios.thread.join': {
|
case 'iios.thread.join': {
|
||||||
// Only threads that opted into a membership model (chat dm/group) are governed;
|
// Generic/support threads (no membership attr) keep open-join. A PUBLIC channel is the one
|
||||||
// generic/support threads (no membership attr) keep open-join. For a governed
|
// membership type that also allows open self-join. dm/group/private-channel are closed:
|
||||||
// thread, new members must be added first (governed add-participant).
|
// new members must be added first (governed add-participant).
|
||||||
if (!i.membership) return allow();
|
if (!i.membership) return allow();
|
||||||
|
if (i.membership === 'channel' && i.visibility === 'public') return allow();
|
||||||
return i.alreadyMember ? allow() : deny('you are not a member of this thread');
|
return i.alreadyMember ? allow() : deny('you are not a member of this thread');
|
||||||
}
|
}
|
||||||
|
case 'iios.thread.discover': {
|
||||||
|
// Browsing discoverable threads — the query fences to the caller's own scope, so allow.
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
case 'iios.thread.leave': {
|
||||||
|
// Anyone may leave a thread they're in. (A membership thread implies the caller is a member.)
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
case 'iios.interaction.annotate': {
|
case 'iios.interaction.annotate': {
|
||||||
// Annotating (e.g. reacting to) a message requires being a participant of its thread.
|
// Annotating (e.g. reacting to) a message requires being a participant of its thread.
|
||||||
return i.isMember ? allow() : deny('you are not a member of this thread');
|
return i.isMember ? allow() : deny('you are not a member of this thread');
|
||||||
|
|||||||
@@ -117,3 +117,59 @@ describe('RetentionService (P9 — retention sweep)', () => {
|
|||||||
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
|
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// T8: an outbound email/SMS command holds PII (the recipient address, and the rendered body with
|
||||||
|
// their name). Once aged, redact those while KEEPING the template provenance for audit/replay.
|
||||||
|
describe('RetentionService — outbound command PII (T8)', () => {
|
||||||
|
const setOutboundSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) =>
|
||||||
|
prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'outbound_command', targetId } }, data });
|
||||||
|
|
||||||
|
async function command(target: string): Promise<{ id: string; scopeId: string }> {
|
||||||
|
const scope = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
|
||||||
|
const cmd = await prisma.iiosOutboundCommand.create({
|
||||||
|
data: {
|
||||||
|
channelType: 'EMAIL', target, scopeId: scope.id, status: 'SENT', idempotencyKey: `k-${randomUUID()}`,
|
||||||
|
payload: { subject: 'Hi Dana', html: '<p>secret body</p>' },
|
||||||
|
templateKey: 'welcome', templateVersion: 1, templateLocale: 'en', renderedHash: 'abc123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: cmd.id, scopeId: scope.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ensureOutboundSnapshots captures one snapshot per outbound command', async () => {
|
||||||
|
const { id } = await command('dana@acme.com');
|
||||||
|
const n = await svc().ensureOutboundSnapshots();
|
||||||
|
expect(n).toBe(1);
|
||||||
|
const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'outbound_command', targetId: id } } });
|
||||||
|
expect(snap.targetType).toBe('outbound_command');
|
||||||
|
expect(snap.dataClass).toBe('outbound');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts target + payload of an aged command but keeps template provenance', async () => {
|
||||||
|
const { id } = await command('dana@acme.com');
|
||||||
|
await svc().ensureOutboundSnapshots();
|
||||||
|
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
|
||||||
|
|
||||||
|
const res = await svc().applySweep();
|
||||||
|
expect(res.redacted).toBe(1);
|
||||||
|
const after = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } });
|
||||||
|
expect(after.target).toBe('[redacted]');
|
||||||
|
expect(after.payload).toEqual({ redacted: true });
|
||||||
|
// Provenance survives — you can still answer "which template version did we send?"
|
||||||
|
expect(after.templateKey).toBe('welcome');
|
||||||
|
expect(after.templateVersion).toBe(1);
|
||||||
|
expect(after.renderedHash).toBe('abc123');
|
||||||
|
expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted', resourceType: 'outbound_command' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an active compliance hold blocks the command sweep', async () => {
|
||||||
|
const { id, scopeId } = await command('held@acme.com');
|
||||||
|
await svc().ensureOutboundSnapshots();
|
||||||
|
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
|
||||||
|
await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'outbound_command', targetId: id, holdReason: 'legal' } });
|
||||||
|
|
||||||
|
const res = await svc().applySweep();
|
||||||
|
expect(res.skippedHeld).toBe(1);
|
||||||
|
expect((await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } })).target).toBe('held@acme.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -72,6 +72,41 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a retention snapshot for any outbound command that lacks one. An email/SMS command
|
||||||
|
* holds PII (the recipient address, and the rendered body with their name), so it gets a single
|
||||||
|
* window and goes STRAIGHT to redact (archiveAfter == deleteAfter — no archive phase). Unscoped
|
||||||
|
* system sends use an 'unscoped' partition tag so the global sweep still reaches them.
|
||||||
|
*/
|
||||||
|
async ensureOutboundSnapshots(scopeId?: string): Promise<number> {
|
||||||
|
const commands = await this.prisma.iiosOutboundCommand.findMany({
|
||||||
|
where: scopeId ? { scopeId } : {},
|
||||||
|
select: { id: true, scopeId: true, createdAt: true },
|
||||||
|
});
|
||||||
|
let created = 0;
|
||||||
|
for (const c of commands) {
|
||||||
|
const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({
|
||||||
|
where: { targetType_targetId: { targetType: 'outbound_command', targetId: c.id } },
|
||||||
|
});
|
||||||
|
if (exists) continue;
|
||||||
|
const deleteAt = new Date(c.createdAt.getTime() + this.windowDays('outbound', 'DELETE') * DAY_MS);
|
||||||
|
await this.prisma.iiosRetentionPolicySnapshot.create({
|
||||||
|
data: {
|
||||||
|
policyKey: 'outbound:pii:v1',
|
||||||
|
scopeSnapshotId: c.scopeId ?? 'unscoped',
|
||||||
|
targetType: 'outbound_command',
|
||||||
|
targetId: c.id,
|
||||||
|
dataClass: 'outbound',
|
||||||
|
archiveAfter: deleteAt,
|
||||||
|
deleteAfter: deleteAt,
|
||||||
|
sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
|
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
|
||||||
async applySweep(scopeId?: string): Promise<SweepResult> {
|
async applySweep(scopeId?: string): Promise<SweepResult> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -90,13 +125,22 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (s.deleteAfter <= now) {
|
if (s.deleteAfter <= now) {
|
||||||
await this.prisma.$transaction([
|
if (s.targetType === 'outbound_command') {
|
||||||
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
|
// Redact the recipient address + rendered body; KEEP the template provenance columns.
|
||||||
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
|
await this.prisma.$transaction([
|
||||||
this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }),
|
this.prisma.iiosOutboundCommand.update({ where: { id: s.targetId }, data: { target: '[redacted]', payload: { redacted: true } } }),
|
||||||
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
|
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
|
||||||
]);
|
]);
|
||||||
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'outbound_command', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
||||||
|
} else {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
|
||||||
|
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
|
||||||
|
this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }),
|
||||||
|
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
|
||||||
|
]);
|
||||||
|
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
||||||
|
}
|
||||||
result.redacted++;
|
result.redacted++;
|
||||||
} else if (s.status === 'ACTIVE') {
|
} else if (s.status === 'ACTIVE') {
|
||||||
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
||||||
@@ -107,9 +151,10 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full sweep: capture missing snapshots, then act on due ones. */
|
/** Full sweep: capture missing snapshots (interactions + outbound commands), then act on due ones. */
|
||||||
async sweep(scopeId?: string): Promise<SweepResult> {
|
async sweep(scopeId?: string): Promise<SweepResult> {
|
||||||
await this.ensureSnapshots(scopeId);
|
await this.ensureSnapshots(scopeId);
|
||||||
|
await this.ensureOutboundSnapshots(scopeId);
|
||||||
return this.applySweep(scopeId);
|
return this.applySweep(scopeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { IiosTemplateChannel } from '@prisma/client';
|
||||||
|
|
||||||
|
/** A platform-default template shipped in the repo and seeded at boot (scopeId NULL). */
|
||||||
|
export interface TemplateSeed {
|
||||||
|
key: string;
|
||||||
|
channel: IiosTemplateChannel;
|
||||||
|
locale: string;
|
||||||
|
version: number;
|
||||||
|
subject?: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
bodyText?: string;
|
||||||
|
variables: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder copy — marketing (Nikita/Himanshu) owns the words, Gautam the HTML. These are
|
||||||
|
// functional defaults so the pipeline works end-to-end before final copy lands; bump `version`
|
||||||
|
// when the content changes (the old version stays for audit/replay).
|
||||||
|
|
||||||
|
const WELCOME_EMAIL: TemplateSeed = {
|
||||||
|
key: 'welcome',
|
||||||
|
channel: 'EMAIL',
|
||||||
|
locale: 'en',
|
||||||
|
version: 1,
|
||||||
|
subject: 'Welcome to the Founders Club, {{firstName}}',
|
||||||
|
bodyHtml:
|
||||||
|
'<p>Hi {{firstName}},</p>' +
|
||||||
|
'<p>Thanks for joining the Founders Club. Your account is ready to set up.</p>' +
|
||||||
|
'<p><a href="{{signupLink}}">Create your account</a></p>' +
|
||||||
|
'<p>We are launching soon — we will keep you posted.</p>',
|
||||||
|
bodyText:
|
||||||
|
'Hi {{firstName}},\n\nThanks for joining the Founders Club. Create your account: {{signupLink}}\n\nWe are launching soon.',
|
||||||
|
variables: ['firstName', 'signupLink'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_RECEIPT_EMAIL: TemplateSeed = {
|
||||||
|
key: 'payment.receipt',
|
||||||
|
channel: 'EMAIL',
|
||||||
|
locale: 'en',
|
||||||
|
version: 1,
|
||||||
|
subject: 'Thanks for your payment, {{firstName}}',
|
||||||
|
bodyHtml:
|
||||||
|
'<p>Hi {{firstName}},</p>' +
|
||||||
|
'<p>We have received your payment of <strong>{{amount}}</strong> for {{licenseCount}} license(s).</p>' +
|
||||||
|
'<p>A separate tax receipt from our payment processor will follow.</p>',
|
||||||
|
bodyText:
|
||||||
|
'Hi {{firstName}},\n\nWe have received your payment of {{amount}} for {{licenseCount}} license(s).\nA separate tax receipt will follow.',
|
||||||
|
variables: ['firstName', 'amount', 'licenseCount'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_RECEIPT_SMS: TemplateSeed = {
|
||||||
|
key: 'payment.receipt',
|
||||||
|
channel: 'SMS',
|
||||||
|
locale: 'en',
|
||||||
|
version: 1,
|
||||||
|
bodyText: 'Thanks {{firstName}}! We received your payment of {{amount}}. A receipt is on its way to your email.',
|
||||||
|
variables: ['firstName', 'amount'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ONBOARDING_REMINDER_EMAIL: TemplateSeed = {
|
||||||
|
key: 'onboarding.reminder',
|
||||||
|
channel: 'EMAIL',
|
||||||
|
locale: 'en',
|
||||||
|
version: 1,
|
||||||
|
subject: 'Looks like you have not finished setting up',
|
||||||
|
bodyHtml:
|
||||||
|
'<p>Hi {{firstName}},</p>' +
|
||||||
|
'<p>You paid but have not created your account yet. It only takes a minute.</p>' +
|
||||||
|
'<p><a href="{{signupLink}}">Finish creating your account</a></p>',
|
||||||
|
bodyText:
|
||||||
|
'Hi {{firstName}},\n\nYou paid but have not created your account yet. Finish here: {{signupLink}}',
|
||||||
|
variables: ['firstName', 'signupLink'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEMPLATE_SEEDS: TemplateSeed[] = [
|
||||||
|
WELCOME_EMAIL,
|
||||||
|
PAYMENT_RECEIPT_EMAIL,
|
||||||
|
PAYMENT_RECEIPT_SMS,
|
||||||
|
ONBOARDING_REMINDER_EMAIL,
|
||||||
|
];
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
|
||||||
|
import { IiosTemplateChannel } from '@prisma/client';
|
||||||
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { TemplatedSender, type ExternalChannel } from './templated-sender';
|
||||||
|
import { TemplateService } from './template.service';
|
||||||
|
import { PreviewTemplateDto, SendTemplateDto } from './template.dto';
|
||||||
|
import type { TemplateSource } from './template.model';
|
||||||
|
|
||||||
|
@Controller('v1/templates')
|
||||||
|
export class TemplateController {
|
||||||
|
constructor(
|
||||||
|
private readonly sender: TemplatedSender,
|
||||||
|
private readonly templates: TemplateService,
|
||||||
|
private readonly session: SessionVerifier,
|
||||||
|
private readonly actors: ActorResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Render a stored/inline template and queue it for external delivery (EMAIL/SMS). */
|
||||||
|
@Post('send')
|
||||||
|
async send(@Body() body: SendTemplateDto, @Headers('authorization') authorization?: string) {
|
||||||
|
const principal = this.principal(authorization);
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
return this.sender.sendTemplated({
|
||||||
|
source: this.source(body),
|
||||||
|
channel: body.channel as ExternalChannel,
|
||||||
|
target: body.target,
|
||||||
|
vars: body.vars ?? {},
|
||||||
|
scopeId: scope.id,
|
||||||
|
...(body.locale ? { locale: body.locale } : {}),
|
||||||
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
...(body.purpose ? { purpose: body.purpose } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render only — no send. For marketing/QA to eyeball copy before it goes out. */
|
||||||
|
@Post('preview')
|
||||||
|
async preview(@Body() body: PreviewTemplateDto, @Headers('authorization') authorization?: string) {
|
||||||
|
const principal = this.principal(authorization);
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
const { content } = await this.templates.render(this.source(body), body.vars ?? {}, {
|
||||||
|
channel: body.channel as IiosTemplateChannel,
|
||||||
|
...(body.locale ? { locale: body.locale } : {}),
|
||||||
|
scopeId: scope.id,
|
||||||
|
});
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map the DTO's key|inline into a TemplateSource (exactly one must be present). */
|
||||||
|
private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource {
|
||||||
|
if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both');
|
||||||
|
if (body.inline) return { inline: body.inline };
|
||||||
|
if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key };
|
||||||
|
throw new BadRequestException('one of "key" or "inline" is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,38 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
const EXTERNAL_CHANNELS = ['EMAIL', 'SMS'] as const;
|
||||||
|
const ALL_CHANNELS = ['EMAIL', 'SMS', 'INTERNAL'] as const;
|
||||||
|
|
||||||
|
/** Inline template content — an ad-hoc source (e.g. finished HTML from marketing). */
|
||||||
|
export class InlineTemplateDto {
|
||||||
|
@IsOptional() @IsString() subject?: string;
|
||||||
|
@IsOptional() @IsString() html?: string;
|
||||||
|
@IsOptional() @IsString() text?: string;
|
||||||
|
@IsOptional() @IsString({ each: true }) variables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body of POST /v1/templates/send. Provide EITHER `key` (stored) OR `inline` (ad-hoc). */
|
||||||
|
export class SendTemplateDto {
|
||||||
|
@IsOptional() @IsString() @IsNotEmpty() key?: string;
|
||||||
|
@IsOptional() @IsInt() version?: number;
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
|
||||||
|
|
||||||
|
@IsIn(EXTERNAL_CHANNELS) channel!: (typeof EXTERNAL_CHANNELS)[number];
|
||||||
|
@IsString() @IsNotEmpty() target!: string;
|
||||||
|
@IsOptional() @IsObject() vars?: Record<string, unknown>;
|
||||||
|
@IsOptional() @IsString() locale?: string;
|
||||||
|
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
||||||
|
@IsOptional() @IsString() purpose?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body of POST /v1/templates/preview — render only, no send. INTERNAL allowed (preview only). */
|
||||||
|
export class PreviewTemplateDto {
|
||||||
|
@IsOptional() @IsString() @IsNotEmpty() key?: string;
|
||||||
|
@IsOptional() @IsInt() version?: number;
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
|
||||||
|
|
||||||
|
@IsIn(ALL_CHANNELS) channel!: (typeof ALL_CHANNELS)[number];
|
||||||
|
@IsOptional() @IsObject() vars?: Record<string, unknown>;
|
||||||
|
@IsOptional() @IsString() locale?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdaptersModule } from '../adapters/adapters.module';
|
||||||
|
import { TemplateRepository } from './template.repository';
|
||||||
|
import { TemplateService } from './template.service';
|
||||||
|
import { TemplatedSender } from './templated-sender';
|
||||||
|
import { TemplateSeeder } from './template.seeder';
|
||||||
|
import { TemplateController } from './template.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable message-template module: render a stored/inline template → hand it to the existing
|
||||||
|
* outbound pipeline (via AdaptersModule's OutboundService). Content lives here; delivery stays in
|
||||||
|
* the outbound/capability layers. SessionVerifier, ActorResolver (IdentityModule) and PLATFORM_PORTS
|
||||||
|
* (PlatformModule) are global.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [AdaptersModule],
|
||||||
|
controllers: [TemplateController],
|
||||||
|
providers: [TemplateRepository, TemplateService, TemplatedSender, TemplateSeeder],
|
||||||
|
exports: [TemplateService, TemplatedSender],
|
||||||
|
})
|
||||||
|
export class TemplateModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { TemplateSeeder } from './template.seeder';
|
||||||
|
import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const seeder = new TemplateSeeder(prisma as unknown as PrismaService);
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('TemplateSeeder', () => {
|
||||||
|
it('seeds the file defaults as global (scopeId NULL) templates', async () => {
|
||||||
|
const n = await seeder.seed(TEMPLATE_SEEDS);
|
||||||
|
expect(n).toBe(TEMPLATE_SEEDS.length);
|
||||||
|
const rows = await prisma.iiosMessageTemplate.findMany();
|
||||||
|
expect(rows).toHaveLength(TEMPLATE_SEEDS.length);
|
||||||
|
expect(rows.every((r) => r.scopeId === null)).toBe(true);
|
||||||
|
const welcome = rows.find((r) => r.key === 'welcome' && r.channel === 'EMAIL');
|
||||||
|
expect(welcome?.variables).toEqual(['firstName', 'signupLink']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — re-seeding inserts nothing and creates no duplicates', async () => {
|
||||||
|
await seeder.seed(TEMPLATE_SEEDS);
|
||||||
|
const second = await seeder.seed(TEMPLATE_SEEDS);
|
||||||
|
expect(second).toBe(0);
|
||||||
|
expect(await prisma.iiosMessageTemplate.count()).toBe(TEMPLATE_SEEDS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a bumped version inserts a new row and leaves the old one', async () => {
|
||||||
|
await seeder.seed(TEMPLATE_SEEDS);
|
||||||
|
const bumped: TemplateSeed = { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'v2', bodyText: 'v2', variables: [] };
|
||||||
|
const n = await seeder.seed([bumped]);
|
||||||
|
expect(n).toBe(1);
|
||||||
|
const welcomes = await prisma.iiosMessageTemplate.findMany({ where: { key: 'welcome', channel: 'EMAIL' }, orderBy: { version: 'asc' } });
|
||||||
|
expect(welcomes.map((w) => w.version)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds platform-default templates (scopeId NULL) from the repo files on boot — DB is runtime truth,
|
||||||
|
* files are the version-controlled source. Idempotent: a seed is inserted only if its exact
|
||||||
|
* (key, channel, locale, version) default is absent, so re-boots never duplicate and a bumped
|
||||||
|
* version adds a new row while leaving the old one for audit/replay.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TemplateSeeder implements OnModuleInit {
|
||||||
|
private readonly log = new Logger(TemplateSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
const inserted = await this.seed(TEMPLATE_SEEDS);
|
||||||
|
if (inserted > 0) this.log.log(`seeded ${inserted} default template(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns how many rows were newly inserted. */
|
||||||
|
async seed(seeds: TemplateSeed[]): Promise<number> {
|
||||||
|
let inserted = 0;
|
||||||
|
for (const s of seeds) {
|
||||||
|
const exists = await this.prisma.iiosMessageTemplate.findFirst({
|
||||||
|
where: { scopeId: null, key: s.key, channel: s.channel, locale: s.locale, version: s.version },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (exists) continue;
|
||||||
|
await this.prisma.iiosMessageTemplate.create({
|
||||||
|
data: {
|
||||||
|
scopeId: null,
|
||||||
|
key: s.key,
|
||||||
|
channel: s.channel,
|
||||||
|
locale: s.locale,
|
||||||
|
version: s.version,
|
||||||
|
subject: s.subject,
|
||||||
|
bodyHtml: s.bodyHtml,
|
||||||
|
bodyText: s.bodyText,
|
||||||
|
variables: s.variables as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
|
import { TemplateRepository } from './template.repository';
|
||||||
|
import { TemplateService } from './template.service';
|
||||||
|
import { TemplatedSender } from './templated-sender';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const asService = prisma as unknown as PrismaService;
|
||||||
|
|
||||||
|
function sender(): TemplatedSender {
|
||||||
|
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
|
const templates = new TemplateService(new TemplateRepository(asService));
|
||||||
|
return new TemplatedSender(templates, outbound, makeFakePorts());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedReceipt() {
|
||||||
|
await prisma.iiosMessageTemplate.create({
|
||||||
|
data: { key: 'payment.receipt', channel: 'EMAIL', locale: 'en', version: 1, subject: 'Thanks {{firstName}}', bodyHtml: '<p>{{amount}}</p>', bodyText: '{{amount}}', variables: ['firstName', 'amount'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('TemplatedSender.sendTemplated', () => {
|
||||||
|
it('renders a stored template, queues an outbound command, and stamps provenance', async () => {
|
||||||
|
await seedReceipt();
|
||||||
|
const cmd = await sender().sendTemplated({
|
||||||
|
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
|
||||||
|
vars: { firstName: 'Dana', amount: '$2,000' }, idempotencyKey: 'receipt:cs_1',
|
||||||
|
});
|
||||||
|
expect(cmd.channelType).toBe('EMAIL');
|
||||||
|
expect(cmd.target).toBe('dana@acme.com');
|
||||||
|
expect((cmd.payload as { subject: string }).subject).toBe('Thanks Dana');
|
||||||
|
expect(cmd.templateKey).toBe('payment.receipt');
|
||||||
|
expect(cmd.templateVersion).toBe(1);
|
||||||
|
expect(cmd.templateLocale).toBe('en');
|
||||||
|
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent per key: a replay yields ONE command', async () => {
|
||||||
|
await seedReceipt();
|
||||||
|
const s = sender();
|
||||||
|
const input = { source: { key: 'payment.receipt' }, channel: 'EMAIL' as const, target: 'dana@acme.com', vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'receipt:cs_dup' };
|
||||||
|
const a = await s.sendTemplated(input);
|
||||||
|
const b = await s.sendTemplated(input);
|
||||||
|
expect(b.id).toBe(a.id);
|
||||||
|
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'receipt:cs_dup' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends inline content with null template key but a hash', async () => {
|
||||||
|
const cmd = await sender().sendTemplated({
|
||||||
|
source: { inline: { subject: 'Hi {{n}}', html: '<b>{{n}}</b>', variables: ['n'] } },
|
||||||
|
channel: 'EMAIL', target: 'x@y.com', vars: { n: 'Z' }, idempotencyKey: 'inline:1',
|
||||||
|
});
|
||||||
|
expect((cmd.payload as { subject: string }).subject).toBe('Hi Z');
|
||||||
|
expect(cmd.templateKey).toBeNull();
|
||||||
|
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries attachment refs into the EMAIL command payload', async () => {
|
||||||
|
await seedReceipt();
|
||||||
|
const cmd = await sender().sendTemplated({
|
||||||
|
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
|
||||||
|
vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'att:1',
|
||||||
|
attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }],
|
||||||
|
});
|
||||||
|
expect((cmd.payload as { attachments: unknown[] }).attachments).toEqual([{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shapes an SMS send as text only', async () => {
|
||||||
|
await prisma.iiosMessageTemplate.create({
|
||||||
|
data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] },
|
||||||
|
});
|
||||||
|
const cmd = await sender().sendTemplated({
|
||||||
|
source: { key: 'payment.receipt' }, channel: 'SMS', target: '+1415', vars: { amount: '$2,000' }, idempotencyKey: 'sms:1',
|
||||||
|
});
|
||||||
|
expect(cmd.channelType).toBe('SMS');
|
||||||
|
expect(cmd.payload).toEqual({ text: 'Paid $2,000' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user