feat(quotes): add email-to-quote pipeline with attachments and replies

This commit is contained in:
SJCoetz 2026-06-16 20:46:28 +02:00
parent c4e1c62882
commit 41b5fc4cbf
31 changed files with 16101 additions and 438 deletions

View file

@ -13,6 +13,7 @@
"settings": {
"n8nMcp.apiKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0Zjc3MGRmMi1iZDJmLTQ0MzYtYjE1My1iMjcwMDRlZmM3YjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiZDNlYzkzNGUtZGM5Ni00OGFmLWI0ODUtYWJjYWI0NWRjOTcyIiwiaWF0IjoxNzgxMTkyMzMwfQ.6RkiSTygZGsCOoNYh8upeIEMNGd6tJWWZmTXvavlPbs",
"n8nMcp.apiUrl": "http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io/api/v1",
"n8nMcp.debug": true
"n8nMcp.debug": true,
"claudeCode.allowDangerouslySkipPermissions": true
}
}

File diff suppressed because one or more lines are too long

View file

@ -17,6 +17,7 @@ ORDER_ITEMS_COLLECTION=order_items
- `POST /api/v1/orders/from-quote`
- `GET /api/v1/collection-pool?page=1&perPage=50&status=queued`
- `PATCH /api/v1/order-items/{id}/status`
- `GET /api/v1/quotes/next-number` — atomic `Q-YYYY-MM-NNN` counter via the `quote_counters` collection. Used by the quote form and the inbound email pipeline.
Auth for agent routes:
- Header: `Authorization: Bearer <AGENT_API_KEY>`
@ -47,8 +48,16 @@ Available tools:
- `orders.create_from_quote`
- `collection_pool.list`
- `order_items.update_status`
- `quotes.create_from_email` — create a new quote (with customer/company/items) from a parsed email payload. Idempotent on `message_id`. **Attachments are no longer uploaded inline in this call** — the legacy `attachments` field in the payload is accepted but ignored. After the create call returns the new quote id, call `attachments.upload_for_quote` once per file. This split exists because embedding base64 attachments in the create POST would blow past the Next.js 1 MB request body limit on a 5 MB PDF.
- `attachments.upload_for_quote` — upload a single attachment (PDF, image, Word, Excel, CSV, plain text, or any binary up to 25 MB) to an existing quote. One call per file. Errors are surfaced per-attachment so a single bad file does not roll back the quote.
- `quotes.append_reply` — append a structured reply to an existing quote. Matches by `In-Reply-To` header first, then `Q-YYYY-MM-NNN` in the subject.
- `quotes.find_by_message_id` — look up a quote by the inbound email's RFC 5322 `Message-ID` header.
The `quotes.*` tools are the inbound side of the [Quote Email Monitor](quotes-email-monitor.md) n8n workflow. They are the only path new quotes and replies should be created through. The `orders.*` and `order_items.*` tools are likewise the only path for the orders side.
## Notes
- `ORDER_ITEMS_COLLECTION` controls where order-item writes/updates go.
- Set `ORDER_ITEMS_COLLECTION=order_items` once new collection is created/imported.
- Status endpoint accepts `driver_status` input and normalizes to `drv_status`.
- `quotes.create_from_email` **must** receive a `message_id` for idempotency — duplicate sends of the same email return the existing record with `deduped: true`.
- `quotes.append_reply` returns `matched_by: 'in_reply_to' | 'subject' | 'none'`. The `'none'` case is the workflow's signal to move the email to `INBOX.Quotes-Unmatched` and not create a reply row.

View file

@ -16,7 +16,7 @@ Only these legacy areas are being rebuilt in Next.js/PocketBase:
- Customers: partial/live
- Companies: partial/live
- Suppliers: partial/live
- Quotes: partial/live
- Quotes: partial/live — **email intake ✅ done** (n8n IMAP poll → MCP `quotes.create_from_email` / `quotes.append_reply`; see [quotes-email-monitor.md](quotes-email-monitor.md))
- Orders: partial/live
### Not migrated / minimal
@ -45,7 +45,7 @@ Only these legacy areas are being rebuilt in Next.js/PocketBase:
## Immediate next build tasks
1. Customer: duplicate handling and merge strategy (replace legacy management commands)
2. Company: details/address consistency and relation integrity checks
3. Quotes: explicit quote-to-order action + safeguards against duplicate conversions
3. ~~Quotes: explicit quote-to-order action + safeguards against duplicate conversions~~ (covered by `quote → order` flow on Orders board; duplicates prevented via `findOrCreateByMessageId` on the email side)
4. Orders: status board + drv/collection states reflected in UI
5. Collection pool: assignment UI and filtered work queue
6. Delivery notes: generate delivery records from delivered/loaded order items

View file

@ -0,0 +1,24 @@
{
"name": "quote_attachments",
"type": "base",
"system": false,
"listRule": "@request.auth.id != ''",
"viewRule": "@request.auth.id != ''",
"createRule": "@request.auth.id != ''",
"updateRule": "@request.auth.id != ''",
"deleteRule": "@request.auth.id != ''",
"fields": [
{ "name": "quote", "type": "relation", "required": true, "collectionId": "REPLACE_QUOTES_COLLECTION_ID", "maxSelect": 1, "minSelect": 0, "cascadeDelete": true, "displayFields": ["quote_number"] },
{ "name": "filename", "type": "text", "required": true, "min": 1, "max": 255, "pattern": "" },
{ "name": "file", "type": "file", "required": true, "maxSelect": 1, "maxSize": 26214400, "mimeTypes": ["application/pdf", "image/png", "image/jpeg", "image/gif", "image/webp", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "text/csv", "text/plain", "application/octet-stream"], "thumbs": ["150x150", "600x600"] },
{ "name": "content_type", "type": "text", "required": false, "max": 100, "pattern": "" },
{ "name": "size_bytes", "type": "number", "required": false, "min": 0, "noDecimal": true },
{ "name": "email_message_id", "type": "text", "required": false, "max": 255, "pattern": "" },
{ "name": "kind", "type": "select", "required": true, "maxSelect": 1, "values": ["pdf", "image", "doc", "other"] },
{ "name": "uploaded_at", "type": "date", "required": true, "onCreate": true, "onUpdate": false }
],
"indexes": [
"CREATE INDEX idx_quote_attachments_quote ON quote_attachments (quote)",
"CREATE INDEX idx_quote_attachments_message_id ON quote_attachments (email_message_id)"
]
}

View file

@ -0,0 +1,18 @@
{
"name": "quote_counters",
"type": "base",
"system": false,
"listRule": "@request.auth.id != ''",
"viewRule": "@request.auth.id != ''",
"createRule": "@request.auth.id != ''",
"updateRule": "@request.auth.id != ''",
"deleteRule": "@request.auth.id != ''",
"fields": [
{ "name": "year", "type": "number", "required": true, "min": 2000, "max": 2999, "noDecimal": true },
{ "name": "month", "type": "number", "required": true, "min": 1, "max": 12, "noDecimal": true },
{ "name": "last_seq", "type": "number", "required": false, "min": 0, "noDecimal": true }
],
"indexes": [
"CREATE UNIQUE INDEX idx_quote_counters_year_month ON quote_counters (year, month)"
]
}

View file

@ -0,0 +1,27 @@
{
"name": "quote_replies",
"type": "base",
"system": false,
"listRule": "@request.auth.id != ''",
"viewRule": "@request.auth.id != ''",
"createRule": "@request.auth.id != ''",
"updateRule": "@request.auth.id != ''",
"deleteRule": "@request.auth.id != ''",
"fields": [
{ "name": "quote", "type": "relation", "required": true, "collectionId": "REPLACE_QUOTES_COLLECTION_ID", "maxSelect": 1, "minSelect": 0, "cascadeDelete": true, "displayFields": ["quote_number"] },
{ "name": "from_email", "type": "email", "required": true },
{ "name": "from_name", "type": "text", "required": false, "max": 255, "pattern": "" },
{ "name": "subject", "type": "text", "required": true, "min": 1, "max": 500, "pattern": "" },
{ "name": "body_plain", "type": "text", "required": false, "max": 10000, "pattern": "" },
{ "name": "body_html", "type": "text", "required": false, "max": 20000, "pattern": "" },
{ "name": "received_at", "type": "date", "required": true, "min": "", "max": "" },
{ "name": "email_message_id", "type": "text", "required": false, "max": 255, "pattern": "" },
{ "name": "in_reply_to", "type": "text", "required": false, "max": 255, "pattern": "" },
{ "name": "quoted_text", "type": "text", "required": false, "max": 5000, "pattern": "" }
],
"indexes": [
"CREATE INDEX idx_quote_replies_quote ON quote_replies (quote)",
"CREATE INDEX idx_quote_replies_message_id ON quote_replies (email_message_id)",
"CREATE INDEX idx_quote_replies_in_reply_to ON quote_replies (in_reply_to)"
]
}

View file

@ -0,0 +1,418 @@
# Quote Email Monitor (n8n)
The n8n workflow `WFDASH — Quote Email Monitor (v3.1 agentic + PDF text)` is the inbound quote-request pipeline. It polls the `quotes@wfsales.co.za` mailbox every 5 minutes, runs AI extraction over each RFQ (including PDF text extraction from attachments), and creates structured `Quote` records in PocketBase via the WFDASH MCP endpoint. It also recognises replies to existing quotes and appends them as `quote_replies` rows.
A separate workflow `WFDASH — Error Handler` catches every failure in the main workflow and posts a Telegram alert so nothing falls into a black hole.
**One scheduler, one workflow, one source of truth.** No long-running process in the Next.js app, no racy counters, no fabricated cost prices, no fuzzy customer merges, no manual-review step.
> **v2 note:** as of v2 the workflow **no longer moves emails out of `INBOX`**. The n8n JS task-runner image (`n8nio/runners:2.25.7`) blocks `require('imapflow')` outright, and there is no env-var to allowlist new modules in that sandbox. Processed emails stay in `INBOX` UNSEEN; the next poll re-fires the workflow; WFDASH dedups the quote by `message_id` (or `synth-…@wfdash.local`). Re-fires are no-ops at the database level. A human (or a cPanel-side IMAP filter) handles inbox state.
> **v3.1 (this version):** replaces the v3 "Information Extractor → Code" pair with a **langchain AI Agent** (with two `toolCode` subnodes) so the LLM directly calls `quotes.create_from_email` and `attachments.upload_for_quote` per attachment. Adds a **Split Binary → Extract from File (PDF) → Aggregate** pipeline so PDF line items reach the LLM as text in the user message. Flips `passthroughBinaryImages: false` so PNG attachments survive to the upload tool (the v3 bug where `attachment_1` was eaten by LLM vision is fixed).
---
## Flow
```
IMAP poll (every 5 min, INBOX, UNSEEN only)
↓
Edit Fields ── injects mcp server + agent_api_key
↓
Parse Email ── normalises payload (handles from.value[n] arrays, Message-ID objects),
preserves binary as $binary
↓
Drop Blacklist / Auto-replies ── sender + subject filters
↓
Is Reply?
├── yes → MCP: quotes.append_reply
│ ↓
│ Parse MCP Reply Result
│ ↓
│ Route Reply Result (terminal)
└── no → Carry Email Ctx (preserves binary)
↓
Split Binary into Items (Code, runOnceForAllItems: emits N items,
one per attachment, key always "attachment")
↓
Extract from File (PDF) (n8n Extract from File v1.1, operation: pdf,
binaryPropertyName: "attachment",
onError: continueRegularOutput)
↓
Aggregate + Enrich (Code, runOnceForAllItems: groups back to one item
per email, restores binary by original key, adds
attachments[i].extracted_text to the JSON)
↓
AI Agent: Create Quote From Email
├─ languageModel: MiniMax Chat Model
└─ tools:
├─ create_quote_tool (calls quotes.create_from_email)
└─ upload_attachment_tool (calls attachments.upload_for_quote)
(terminal)
```
A reply is detected when **either** an `In-Reply-To` header is present **or** the subject matches `/Q-\d{4}-\d{2}-\d{3}/`. The `route: "reply" | "new"` field is computed in the Parse Email node and the If node is a simple string equality check.
On any unhandled error in the main workflow, n8n fires the Error Handler workflow automatically (via the workflow's `errorWorkflow` setting). The Error Trigger does **not** receive the original email payload directly — it gets the error context (subject, from_email, uid, mailbox) only because every Code node in the main workflow passes those through. **If you add a new branch, make sure to forward `subject`, `from_email`, `uid`, and `mailbox` so the Error Handler has context for its alert.**
---
## Why the Split → Extract → Aggregate pipeline (v3.1)
n8n's `Extract from File` v1.1 takes a **single binary per item** (one PDF, one output). The IMAP trigger produces **one item with N binary entries** (PDF + PNG + …). We can't point `binaryPropertyName` at both `attachment_0` and `attachment_1` at the same time.
The pattern is the canonical n8n one:
1. **Split Binary into Items** (Code, `runOnceForAllItems`) — emit N items, each with exactly one binary, always keyed `"attachment"`. The original key (`attachment_0`, `attachment_1`, …) is preserved on `$json.binary_key` for the aggregate step.
2. **Extract from File (PDF)** — `binaryPropertyName: "attachment"`, `operation: pdf`, `joinPages: true`, `onError: continueRegularOutput`. Non-PDFs (e.g. PNG banner) pass through with empty `$json.text`.
3. **Aggregate + Enrich** (Code, `runOnceForAllItems`) — group the N items back into one item per email, put the extracted text on `attachments[i].extracted_text`, and restore the binaries under their **original** keys (`attachment_0`, `attachment_1`, …) so the AI Agent's `upload_attachment_tool` can read them by `binary_key`.
The LLM sees the email body + a 2 KB preview of each PDF's extracted text in its user message. It uses that for line items. The full text is preserved on `$json.attachments[i].extracted_text` (up to 10 KB per attachment) if a follow-up tool needs more.
---
## Retry behaviour
The IMAP trigger is configured with `customEmailConfig: "[\"UNSEEN\"]"` and `postProcessAction: "nothing"`. Two consequences:
1. **Implicit retry on crash.** If the workflow dies mid-run, the email stays UNSEEN. The next 5-minute poll re-fires the workflow on the same message.
2. **No "mark as read" until success.** A failed email is left in `INBOX` until a human triages it. There is no silent loss.
**Important:** in v2+ a successful run also leaves the email in `INBOX` (UNSEEN). The success signal is the WFDASH database row (a new `quotes` row, or a new `quote_replies` row). The next poll re-fires on the same email, but WFDASH dedups by `message_id` and returns `deduped: true` with no side-effects. To keep `INBOX` tidy, a human (or a periodic cPanel IMAP filter) should move processed emails out of `INBOX` after each batch.
---
## Prerequisites
1. **PocketBase migrations applied** — `quote_attachments`, `quote_replies`, `quote_counters` collections must exist and `quotes.email_message_id` must be indexed. Run:
```bash
npm run pb:migrate:quote-attachments
```
Verify with `curl http://127.0.0.1:8090/api/collections | jq '.[] | select(.name | startswith("quote_"))'`.
2. **n8n credentials** — set up in the n8n vault (never in the Next.js env):
| Credential name | Type | Used by |
| --- | --- | --- |
| `IMAP account` | IMAP | IMAP Quote Mailbox trigger |
| `Bearer Auth account` | HTTP Bearer (`AGENT_API_KEY`) | Both MCP HTTP Request nodes |
| `Telegram Alerts` | Telegram API | Error Handler → Telegram Alert |
The auto-assigner wires up `IMAP account` (on the IMAP trigger). **You must manually create `Bearer Auth account` and `Telegram Alerts`** — n8n's auto-assigner skips HTTP Request and Telegram nodes.
The MiniMax chat model is wired via the langchain `lmChatMinimax` subnode and authenticates via its own per-credential setup; this is not the WFDASH bearer.
3. **n8n environment variables**:
| Name | Example | Purpose |
| --- | --- | --- |
| `WFDASH_MCP_URL` | `https://wfdash.example.com` | Base URL the workflow POSTs to. Falls back to `http://localhost:3000` if unset. The IMAP tool subnodes also accept the value via `Edit Fields` → `mcp server` for per-workflow override. |
| `TELEGRAM_CHAT_ID` | `-1001234567890` | The group/chat that receives alerts. Set in n8n's env (not in a credential). |
4. **Error Handler is linked.** Open the main workflow → **Settings** → **Error workflow** → pick `WFDASH — Error Handler`. (The SDK has no operation for this setting, so it's a one-time manual step after the first deploy.)
---
## IMAP folder layout
| Folder | What goes there | Populated by |
| --- | --- | --- |
| `INBOX` | Inbound queue — the trigger reads from here. Processed mail also stays here in v2+. | Inbound mail server (+ human / cPanel filter in v2+) |
The trigger reads only UNSEEN mail. There are no `INBOX.Quotes-*` sub-folders in v2+ — the n8n workflow does not create or move mail between folders.
---
## Nodes — main workflow
### 1. IMAP Quote Mailbox (trigger)
- `mailbox: "INBOX"`
- `postProcessAction: "nothing"` (don't mark as read; the WFDASH database row is the success signal)
- `downloadAttachments: true` (binary data flows into `$binary`)
- `format: "resolved"`
- `customEmailConfig: "[\"UNSEEN\"]"`
- `trackLastMessageId: true` (avoids re-firing on the same email on workflow restart)
### 2. Edit Fields (Set)
Injects two fields used by every downstream node:
- `mcp server` — the WFDASH MCP base URL. Defaults to `https://hzbtf-196-39-76-18.run.pinggy-free.link` (pinggy dev tunnel) in this build; override with the `WFDASH_MCP_URL` env or by editing the field.
- `agent_api_key` — the WFDASH `AGENT_API_KEY` bearer. Defaults to `123456` in this build; replace with the real key.
> **Security note:** the agent_api_key is a long-lived secret. Move it to a credential (`Bearer Auth account`) or an env var (`WFDASH_AGENT_API_KEY`) before this workflow leaves the dev box.
### 3. Parse Email (Code, `runOnceForEachItem`)
Coerces all the IMAP field shapes into stable strings. Specifically handles:
- `from` as a string, `{ value: [...] , name: "..." }`, `{ address: "..." }`, or `{ text: "Name <email>" }`. **Also handles the n8n-serialized array form** (`{ item: "x@y.com" }`).
- `messageId` / `inReplyTo` as a string, an object with `value: "..."`, or an array.
- Strips the `<` and `>` brackets around Message-IDs.
- Iterates `$binary` and produces an `attachments: [{ binary_key, filename, content_type, size }]` array.
- Emits a `route: "reply" | "new"` field used by the If node.
- **Returns `{json, binary: $binary || {}}` so binary survives the Code-node round-trip.** Without this, the next Code node (Carry Email Ctx) sees an empty `$binary` and the Split → Extract → Aggregate pipeline has nothing to operate on. (This was a v3 bug; fixed in v3.1.)
### 4. Drop Blacklist / Auto-replies (Filter)
7 conditions combined with `AND`:
- `from_email` does not contain `noreply`, `donotreply`, `mailer-daemon`, `postmaster`
- `subject` does not contain `out of office`, `automatic reply`, `vacation`
### 5. Is Reply? (If)
Single condition: `{{ $json.route }} equals "reply"`. String equality, not boolean.
### 6a. MCP: quotes.append_reply (HTTP Request, reply branch)
Posts JSON-RPC `tools/call` to `{{ WFDASH_MCP_URL }}/api/mcp`. Arguments mirror `EmailReplyPayload`: `from_email`, `from_name`, `subject`, `body_plain`, `body_html`, `message_id`, `in_reply_to`, `received_at`. Auth: HTTP Bearer (`Bearer Auth account`).
### 6b. Carry Email Ctx (Code, `runOnceForEachItem`) — new-quote branch
Explicitly returns `{json, binary: $binary || {}}`. This is the second "binary preservation" node — it catches the case where Parse Email's binary output (in production) gets reshaped by intermediate nodes and makes sure the downstream Split → Extract → Aggregate pipeline has the original binary.
### 6c. Split Binary into Items (Code, `runOnceForAllItems`)
Emits one item per attachment. Each item has:
- `json.binary_key` — the original key (`attachment_0`, `attachment_1`, …) so the aggregate step can restore the binary under the same key for the upload tool.
- `json.filename`, `json.content_type`, `json.size` — attachment metadata.
- `json._email_*` — a flat copy of all the email context fields (from_email, from_name, subject, body_*, message_id, in_reply_to, received_at, is_reply, route, uid, mailbox, mcp server, agent_api_key) so the aggregate step can rebuild the email.
- `binary.attachment` — the single binary for this attachment, always keyed `attachment` (the fixed key Extract from File expects).
**Why `runOnceForAllItems` (not `runOnceForEachItem`):** `runOnceForEachItem` validates the return shape as a single item. Returning an array of items in that mode triggers `"A 'json' property isn't an object [item 0]"`. `runOnceForAllItems` processes the (single) input item once and returns multiple output items correctly.
If there are no attachments, the node emits one placeholder item (`{json: {binary_key: null, ...}, binary: {}}`) so the chain continues to Aggregate (which handles 0 items safely).
### 6d. Extract from File (PDF) (n8n Extract from File v1.1)
- `operation: pdf`
- `binaryPropertyName: attachment`
- `options.joinPages: true`
- `onError: continueRegularOutput` — non-PDFs (PNG banner) pass through with empty `$json.text`; the workflow does not crash.
### 6e. Aggregate + Enrich (Code, `runOnceForAllItems`)
- Groups the N split items back into one item per email.
- For each attachment, puts the extracted PDF text (truncated to 10 KB) on `attachments[i].extracted_text`.
- Restores the original binary entries under their original keys (`attachment_0`, `attachment_1`, …) so `upload_attachment_tool` can read them by key.
- Rebuilds the top-level email fields (`from_email`, `subject`, etc.) and forwards `mcp server` + `agent_api_key` for the tool subnodes.
### 6f. AI Agent: Create Quote From Email (langchain agent v3.1)
- `promptType: define`
- `text` — user message composed of email metadata + a JSON list of attachments (each with `extracted_text_preview` = first 2 KB of the PDF text) + email body.
- `options.systemMessage` — see the next section.
- `options.maxIterations: 20` — enough for one create + N uploads + summary.
- `options.passthroughBinaryImages: false` — **the v3.1 fix**. In v3 this was `true`, which let the LLM "see" the PNG via vision but consumed the binary (the upload tool then reported `error: binary not found for key attachment_1`).
- `options.returnIntermediateSteps: false` — keeps the agent's final summary short; the audit trail lives in the per-tool calls.
### 6g. MiniMax Chat Model (subnode, `lmChatMinimax` v1)
- `model: MiniMax-M2.7`
- `options.temperature: 0.1`
- `options.timeout: 60s`
### 6h. create_quote_tool (subnode, `toolCode` v1.3)
Calls `quotes.create_from_email` via the WFDASH MCP. The LLM only produces `company_hint` and `extracted_items`; every other email field is read from `$input.first().json` (the Aggregate + Enrich output) verbatim. The LLM cannot paraphrase or drop required fields. Returns a one-line summary: `quote_id=… quote_number=… items=… deduped=…`.
### 6i. upload_attachment_tool (subnode, `toolCode` v1.3)
Calls `attachments.upload_for_quote` via the WFDASH MCP. Reads the binary from `$input.first().binary[binaryKey]`. **Belt-and-suspenders:** if upstream is empty, falls back to `$('IMAP Quote Mailbox').first().binary[binaryKey]` — the IMAP trigger's output is the source of truth for binary and never strips attachments. Converts the binary `data` to base64 and POSTs it. Returns: `uploaded=<filename> id=… kind=…`.
---
## AI Agent system prompt (verbatim)
> You are a purchase-order parser for WFDASH.
>
> You have two tools:
> - create_quote_tool: create a new quote row in PocketBase. Use it once per inbound email, after extracting line items.
> - upload_attachment_tool: upload a single attachment to an existing quote. Use it once per attachment in the email, AFTER create_quote_tool returns a quote_id.
>
> Workflow:
> 1. Read the email body (in your user message). For each attachment, also look at the extracted_text_preview field — that is the first 2KB of the PDF text. If the email body is short or empty, the line items are almost certainly in the PDF.
> 2. Extract the buyer/end-customer company name (company_hint). Empty string if unclear.
> 3. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.
> 4. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase.
> 5. Capture the quote_id from the tool result.
> 6. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.
> 7. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=...
>
> Important:
> - Empty body / no items is OK — create the quote anyway with an empty extracted_items array.
> - attachments metadata only (no base64) — the binary lives in the workflow's binary store; only upload_attachment_tool knows how to read it.
---
## MCP tools this workflow calls
| Tool | Branch | Purpose |
| --- | --- | --- |
| `quotes.create_from_email` | new (via `create_quote_tool`) | Idempotent: dedupes on `email_message_id` (or synthesized `synth-…@wfdash.local`). Creates customer/company/quote/items. |
| `attachments.upload_for_quote` | new (via `upload_attachment_tool`) | Stores the file in the `quote_attachments` collection, linked to the quote_id. |
| `quotes.append_reply` | reply | Resolves the target quote (In-Reply-To first, then subject regex), appends a `quote_replies` row, flips `has_new_reply`. |
| `quotes.find_by_message_id` | (helper, not used in v3.1) | Look up a quote by the original `Message-ID`. Use this when the subject has been stripped by the mail client. |
The full MCP tool list and their schemas live in [api-mcp-layer.md](api-mcp-layer.md).
---
## Testing
Both workflows are pinned in n8n so you can dry-run with synthetic data without hitting IMAP or MCP.
**Main workflow pinned nodes:** `IMAP Quote Mailbox`, `MCP: quotes.append_reply` (and any HTTP-Request or credentialed node). Logic nodes (Code, If, Extract from File, AI Agent subnodes) execute normally.
**Error workflow pinned nodes:** none — it uses an Error Trigger, which can't be pinned in the same way; dry-run the main workflow with a known-failing MCP call to exercise it.
### Scenario 1 — new RFQ with PDF + PNG attachment
```json
{
"IMAP Quote Mailbox": [{
"json": {
"uid": "9999",
"mailbox": "INBOX",
"from": { "value": [{ "address": "david@wfsales.co.za", "name": "David Els" }], "text": "David Els <david@wfsales.co.za>" },
"subject": "Rfq 4034 — TEST PDF + PNG",
"messageId": "<test-pin-rfq-pdf-png-001@wfsales.co.za>",
"inReplyTo": "",
"textPlain": "Hi,\n\nPlease see attached for the RFQ.\n\nRegards,\nDavid",
"date": "2026-06-14T10:00:00.000Z"
},
"binary": {
"attachment_0": { "data": "<base64 of test-rfq.pdf>", "fileName": "Invoice INV8018.pdf", "mimeType": "application/pdf", "fileSize": 298 },
"attachment_1": { "data": "<base64 of test-1x1.png>", "fileName": "Outlook-Banner.png", "mimeType": "image/png", "fileSize": 67 }
}
}]
}
```
Expected: Parse Email emits `route: "new"`, Is Reply? goes false, Carry Email Ctx → Split → Extract (text for PDF, empty for PNG) → Aggregate (both binaries restored) → AI Agent. A new `quotes` row appears in WFDASH; both attachments appear in `quote_attachments` (PDF as `application/pdf`, PNG as `image/png`); the email remains in `INBOX` (UNSEEN).
### Scenario 2 — new RFQ with no attachments
```json
{
"IMAP Quote Mailbox": [{
"json": {
"uid": "1000",
"mailbox": "INBOX",
"from": { "value": [{ "address": "buyer@newco.co.za", "name": "Buyer Person" }] },
"subject": "RFQ: Mining cable ties and glands",
"messageId": "<rfq-newco-001@example.com>",
"inReplyTo": "",
"textPlain": "Please quote on 20mm cable ties qty 5 and cable gland M20 qty 10.",
"date": "2026-06-12T10:00:00.000Z"
}
}]
}
```
Expected: Parse Email → Is Reply? false → Carry Email Ctx → Split Binary emits one placeholder (no attachments) → Extract from File (no-op) → Aggregate → AI Agent. `create_quote_tool` runs once with `extracted_items` from the email body; no `upload_attachment_tool` calls.
### Scenario 3 — reply with In-Reply-To
```json
{
"IMAP Quote Mailbox": [{
"json": {
"uid": "101",
"mailbox": "INBOX",
"from": { "value": ["buyer@newco.co.za"], "name": "Buyer Person" },
"subject": "Re: Quotation Q-2026-06-001",
"messageId": "<reply1@example.com>",
"inReplyTo": "<rfq-newco-001@example.com>",
"textPlain": "Approved, please proceed.",
"date": "2026-06-12T11:00:00.000Z"
}
}],
"MCP: quotes.append_reply": [{
"json": {
"result": { "content": [{ "type": "text", "text": "{\"matched_by\":\"in_reply_to\",\"quote\":{\"id\":\"q-1\"},\"reply\":{\"id\":\"r-1\"}}" }] }
}
}]
}
```
Expected: Parse Email emits `route: "reply"`, Is Reply? goes true, `append_reply` called, `matched_by: "in_reply_to"` parsed. A new `quote_replies` row appears; `has_new_reply` flips to true; the email remains in `INBOX` (UNSEEN).
### Scenario 4 — subject-only reply (no In-Reply-To)
Same as Scenario 3 but `inReplyTo: ""` and subject `"Quote Q-2026-06-001 follow up"`. The subject regex catches it, `route: "reply"` is set, `append_reply` is called. The MCP server resolves by quote number and reports `matched_by: "subject"`.
### Scenario 5 — unmatched reply
Same as Scenario 3 but `inReplyTo: ""` and subject has no `Q-…` in it. `append_reply` returns `matched_by: "none"`, Route Reply Result goes false, the workflow ends. Nothing is moved; nothing is created. The email remains in `INBOX` (UNSEEN); a human triages it.
### Scenario 6 — error path
Pinned MCP node returns an error. n8n fires the Error Handler. Build Alert formats the message; Telegram posts to the chat. The email is still in `INBOX`; the human triages.
---
## Runbook — "an email didn't create a quote"
| Symptom | Where to look | Fix |
| --- | --- | --- |
| Nothing in the workflow execution list | n8n not running, or the workflow isn't activated | Open the workflow in n8n → toggle **Active**. Verify the IMAP trigger has `trackLastMessageId` so the same email isn't skipped. |
| Execution shows the email but it stopped at Filter | Filter rejected it | Inspect the data: sender is blacklisted, subject contains `out of office`, etc. Loosen the filter or whitelist the sender. |
| Telegram alert fires | Something in the main workflow threw | Open the error execution in n8n → see the failed node. The email is still in `INBOX` (UNSEEN); a human triages. |
| `INBOX` keeps growing with already-processed emails | n8n executions all show `success` for the same email, but the email is still in `INBOX` | **Expected in v2+.** n8n no longer moves mail out of `INBOX` — the JS task-runner sandbox cannot load `imapflow`. WFDASH dedups re-fires by `message_id` / `synth-…@wfdash.local`, so the duplicate workflow runs are no-ops. Move processed mail out of `INBOX` manually (or via a periodic cPanel IMAP filter). |
| Email has no `Message-ID` header | `Parse Email.message_id: ""` | Handled: the MCP server synthesizes a stable `synth-XXXXXXXX@wfdash.local` id from the sender + subject + received_at. The quote still gets created and a retry dedups correctly. The synthetic id is visible in the `quotes.email_message_id` field — clear indicator for the quoter. |
| Email has no `From:` header (malformed mail, BCC-only headers) | `Parse Email.from_email: ""` | Handled: WFDASH links the quote to a sentinel "Unknown Sender" customer (`unknown-sender@no-from.wfdash.local`) so the create succeeds. The quote's `email_sender` field stores the literal empty string. The quoter reassigns the customer in the existing quote-editing form if/when the real sender is known. |
| Email has no `Subject:`, no body, no `From:`, no `Message-ID:` (blank RFC 5321 send) | `Parse Email.subject: '(no subject)'`, `body_plain: '(no body)'`, `from_email: ""` | Handled: Parse Email stamps placeholders; WFDASH accepts them and creates the quote (linked to the sentinel customer, no line items). The office triages manually. |
| `upload_attachment_tool` returns `error: binary not found for key attachment_1` (PDF was uploaded, PNG was not) | This was the v3 bug. Fixed in v3.1. | Verify `AI Agent: Create Quote From Email` has `options.passthroughBinaryImages: false`. If a future agent option is added, also verify `upload_attachment_tool`'s IMAP-trigger fallback (`$('IMAP Quote Mailbox').first().binary[binaryKey]`) is still in place. |
| Quote appears with no line items | AI extraction returned empty array (email was a description with no quantities) | **Correct behaviour in v3.1.** Per the user's standing instruction, the workflow does not route unparseable emails to a manual-review queue — the office's quoters fill in the items in the WFDASH UI. Verify by opening the quote. |
| LLM invents line items (e.g. items not in the email body or the PDF text) | System prompt drift | The system prompt says `DO NOT invent items, prices, or quantities. Look BOTH in the email body AND in extracted_text_preview of each attachment.` Verify both source fields are populated in the user message. If the LLM is hallucinating, reduce `temperature` further or change to a more conservative model. |
| PDF text never reaches the LLM | `extracted_text_preview` is empty in the user message | Check that `Extract from File (PDF)` is wired between Split and Aggregate; check its `binaryPropertyName: "attachment"`; check `onError: continueRegularOutput` so PNGs don't kill the run. |
| `AI Agent` returns `quote_number=ERROR items=0 uploaded=0` | DNS failure on the MCP URL | The `MCP: quotes.create_from_email` URL is unreachable (e.g. pinggy tunnel expired). Verify the URL resolves: `nslookup hzbtf-196-39-76-18.run.pinggy-free.link`. Re-establish the tunnel or update `Edit Fields` → `mcp server`. |
| `Bearer Auth account` not wired | `MCP: quotes.append_reply` returns 401 | Manual UI step: open the node → Credentials → pick `Bearer Auth account`. The SDK has no operation for HTTP Request credential auto-assignment. |
---
## Anti-patterns this workflow explicitly rejects
(Adapted from the old Django app. None of these are allowed to creep back in.)
| Old problem | New constraint |
|---|---|
| Four competing schedulers | **One** IMAP trigger. No background processes in Next.js. |
| `cost_price = 0.8 × selling_price` (fabricated) | Cost price left at 0; `included_in_quote = false` until a human sets it. |
| `_needs_review = True` hard-coded | New quotes always have `status: "new"`. AI-extracted items are suggestions, never auto-included. |
| 40% fuzzy word match on company | `findOrCreateCustomer` / `findOrCreateCompany` use exact match on email, then normalised domain match. **Never** fuzzy merge. New records tagged via `customer.description = "Auto-created from email intake"`. |
| `last.split('-').pop() + 1` race | Server-side `generateQuoteNumberServer()` uses the atomic `quote_counters` collection with `last_seq+` increment. |
| Replies as text blob in `notes` | `quote_replies` collection. The reply tool appends a structured row, **never** mutates `notes` directly. |
| `notifications_email = "@wfsales.co.za"` placeholder | No such env var. Errors come from the Error Handler workflow → Telegram. |
| LLM paraphrases / drops required email metadata | The LLM is told in the system prompt to copy metadata byte-for-byte; `create_quote_tool` reads the email fields from upstream `$json` as a backstop, so even if the LLM misformats, the create call has the right data. |
| LLM invents line items | System prompt says `DO NOT invent` and points at `extracted_text_preview` as the source of truth. |
| LLM consumes PNG binary as vision input | `passthroughBinaryImages: false` on the AI Agent (v3.1). PNGs now survive to the upload tool. |
| PDF text never reaches the LLM | New Split → Extract → Aggregate pipeline puts a 2 KB preview of each PDF's text into the user message. |
| Manual-review queue for unparseable emails | **No manual-review queue.** Per the user's standing instruction, if the LLM can't parse the email, it still creates the quote (possibly with empty `extracted_items`) and the office's quoters fill in the items in the WFDASH UI. |
| Non-PDF attachments (PNG) break the Extract from File node | `onError: continueRegularOutput` makes the node pass through; the aggregate shows empty `extracted_text` and the upload still happens. |
| Silent loss on workflow crash | IMAP filter `UNSEEN` + `postProcessAction: "nothing"` means a crash re-tries on the next poll. The WFDASH database row is the success signal. |
| "Marked as read" used as audit signal | Real database rows in PocketBase (`quotes` / `quote_replies`) are the audit signal. n8n does **not** mutate the IMAP mailbox. |
| Subject regex fallback for `company_hint` | Company is left `null` if the AI returns no `company_hint`. A human fills it in. |
| Fabricated `synth-…@wfdash.local` ids pretending to be real | Synth ids are clearly marked with the `synth-` prefix and `@wfdash.local` suffix so they're impossible to confuse with a real RFC 5322 Message-ID. |
---
## Quoter can verify the source email
The quote detail page now has an **Original Email** card between the Items card and the Notes card. The quoter uses it to verify the AI's extracted line items against what the original RFQ email actually said.
Three sub-sections in one Card:
1. **Email metadata** — subject, sender, received_at. Three-column grid with small icons.
2. **Email body** — plain text in a scrollable `<pre>` (max-h-96). This is `quotes.email_body` populated by `Parse Email` / the intake code, so it is the exact text the AI saw.
3. **Attachments** — list of files with kind icon, humanized size, and a download link. Download uses `pb.files.getUrl(attachment, attachment.file, { query: { token: pb.authStore.token } })` — the user's auth token is embedded in the query string because PB's file API won't accept the cookie that the auth provider uses.
The card renders `null` (no card at all) when `email_body` is empty **and** there are no attachments, so manually-created quotes (no email intake) stay clean. The Email Status card at the bottom of the page (Delivered / Opened / Sender / Subject) is unchanged — it remains the structured status summary; the Original Email card is the **content** the AI saw.
HTML email rendering is out of scope for V1 — we throw the HTML body away on intake and keep only `body_plain`. The plain text is what the AI saw, which is what the quoter needs to verify. Add a "Show rendered HTML" toggle if the user later asks for it.
Component: [src/components/quote-email-card.tsx](../src/components/quote-email-card.tsx). Helper: `getQuoteAttachments(quoteId)` in [src/lib/pb-helpers.ts](../src/lib/pb-helpers.ts) (separate `getFullList` against the `quote_attachments` collection rather than a reverse-relation expand, because PB expand syntax doesn't easily cover reverse relations).
---
## Security notes
- **Credentials live in n8n's vault**, never in the Next.js env. The `AGENT_API_KEY` is a long-lived secret — rotate it like any other token.
- The workflow uses `Authorization: Bearer` against WFDASH. The same key also protects the REST API. If you need finer scoping, replace the single `AGENT_API_KEY` with a per-purpose key (e.g. `WFDASH_N8N_KEY`).
- Pin data is **not encrypted** in the n8n DB. Avoid putting real customer PII or attachment contents into pin data when dry-running.
- The IMAP mailbox password is the highest-value secret here. Use a dedicated mailbox password, scoped to the `quotes@` IMAP folder only.
- The Telegram bot token belongs in the `Telegram Alerts` credential. **Do not** put it in env vars or in this repo. Set the chat ID in `TELEGRAM_CHAT_ID` env (a public value — a chat ID is not a secret).
---
## Versioning
- Main workflow ID: `VOrEd4OQKO0KMnJj` (v3.1: langchain AI Agent + tool subnodes, Split → Extract → Aggregate for PDF text, `passthroughBinaryImages: false` to preserve PNG binary)
- Main workflow ID: `8zjoryZvn0OZzToE` (v3: AI Agent v3.1 + Information Extractor pair, two toolCode subnodes — superseded by v3.1, archived)
- Main workflow ID: `DGN5fslHfdkIyLKb` (v2: tolerates empty `Message-ID`, tolerates empty IMAP `uid`, tolerates empty `From:` (sentinel "Unknown Sender"), tolerates empty Subject and empty body (stamps `(no subject)` / `(no body)`), no IMAP Move step — superseded, archived)
- Error workflow ID: `kVylcetxy858PcfP` (no change in v3.1)
- Project: David Els <david.els1@outlook.com> (personal)
- Authored via: [n8n-mcp-setup.md](n8n-mcp-setup.md)
- Last updated: 2026-06-14

View file

@ -0,0 +1,56 @@
{
"name": "POCKETBASE NODES",
"nodes": [
{
"parameters": {
"parameters": {},
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
-64,
-64
],
"id": "ec749211-5ed7-433d-9f1a-599a988ad53e",
"name": "Pocketbase HTTP"
},
{
"parameters": {
"bodyParameters": {
"parameters": [
{}
]
}
},
"type": "n8n-nodes-pocketbase-admin.pocketBaseAdmin",
"typeVersion": 1,
"position": [
-64,
144
],
"id": "c12eabbb-f755-472d-b325-25458e58fe79",
"name": "PocketBase"
}
],
"pinData": {},
"connections": {
"Pocketbase HTTP": {
"main": [
[]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "dd757412-66b0-4502-bee2-80d66d09d6ff",
"meta": {
"instanceId": "05bcfa3b50919141d3600718ff4b7b2b52a3883525b05573a27ba5ef437ac7fb"
},
"nodeGroups": [],
"id": "zMCpcOAfEHd8iFH1",
"tags": []
}

View file

@ -0,0 +1,595 @@
{
"name": "WFDASH — Quote Email Monitor (v3.1 agentic + PDF text)",
"nodes": [
{
"parameters": {
"format": "resolved",
"options": {
"customEmailConfig": "[\"UNSEEN\"]",
"forceReconnect": 60,
"trackLastMessageId": true
}
},
"id": "2ad1f663-9442-43d1-a9f2-01515f9c504a",
"name": "IMAP Quote Mailbox",
"type": "n8n-nodes-base.emailReadImap",
"typeVersion": 2.1,
"position": [
0,
96
],
"credentials": {
"imap": {
"id": "OxBkyAvgByqMElJG",
"name": "IMAP account"
}
}
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "srv-1",
"name": "mcp server",
"value": "https://zolau-196-39-76-18.run.pinggy-free.link",
"type": "string"
},
{
"id": "srv-2",
"name": "agent_api_key",
"value": "123456",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "36f45465-7ae9-4787-afbc-04adff0ac01a",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
224,
96
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const item = $json;\n\nfunction asArray(v) {\n if (Array.isArray(v)) return v;\n if (v && typeof v === 'object') {\n if ('item' in v) return [v.item];\n if ('item0' in v) {\n const out = [];\n let i = 0;\n while (('item' + i) in v) { out.push(v['item' + i]); i++; }\n if (out.length) return out;\n }\n }\n return null;\n}\n\nfunction extractEmail(from) {\n if (!from) return '';\n if (typeof from === 'string') {\n const m = from.match(/<([^>]+)>/);\n return (m ? m[1] : from).toLowerCase().trim();\n }\n if (typeof from === 'object') {\n const arr = asArray(from.value);\n if (arr && arr.length > 0) {\n const v = arr[0];\n if (typeof v === 'string') return v.toLowerCase();\n if (v && typeof v === 'object' && v.address) return v.address.toLowerCase();\n }\n if (typeof from.address === 'string') return from.address.toLowerCase();\n if (typeof from.text === 'string') {\n const m = from.text.match(/<([^>]+)>/);\n if (m) return m[1].toLowerCase();\n }\n }\n return '';\n}\n\nfunction extractName(from) {\n if (!from) return '';\n if (typeof from === 'object') {\n if (typeof from.name === 'string' && from.name) return from.name;\n const arr = asArray(from.value);\n if (arr && arr[0]) {\n if (typeof arr[0] === 'object' && arr[0].name) return arr[0].name;\n }\n }\n if (typeof from === 'string') {\n const m = from.match(/^([^<]+)</);\n return m ? m[1].trim().replace(/^\"|\"$/g, '') : '';\n }\n return '';\n}\n\nfunction extractMessageId(field) {\n if (!field) return '';\n if (typeof field === 'string') return field.replace(/^[<]|[>]$/g, '');\n if (typeof field === 'object') {\n if (typeof field.value === 'string') return field.value.replace(/^[<]|[>]$/g, '');\n const arr = asArray(field.value);\n if (arr && typeof arr[0] === 'string') return arr[0].replace(/^[<]|[>]$/g, '');\n if (typeof field.messageId === 'string') return field.messageId.replace(/^[<]|[>]$/g, '');\n }\n return '';\n}\n\nconst fromEmail = extractEmail(item.from);\nconst fromName = extractName(item.from);\nconst messageId = extractMessageId(item.messageId);\nconst inReplyTo = extractMessageId(item.inReplyTo);\nconst mailbox = String(item.mailbox || 'INBOX');\nconst uid = String(item.uid || '');\n\nconst PLAIN_LIMIT = 50 * 1024;\nconst HTML_LIMIT = 200 * 1024;\n\nconst attachmentsMeta = [];\nconst bin = $binary || {};\nfor (const key of Object.keys(bin)) {\n const b = bin[key];\n if (!b) continue;\n attachmentsMeta.push({\n binary_key: String(key),\n filename: String(b.fileName || 'attachment'),\n content_type: String(b.mimeType || 'application/octet-stream'),\n size: Number(b.fileSize || 0)\n });\n}\n\nconst rawSubject = String(item.subject || '');\nconst subject = rawSubject.trim() || '(no subject)';\nconst quoteNumberMatch = subject.match(/Q-\\\\d{4}-\\\\d{2}-\\\\d{3}/);\nconst isReply = inReplyTo.length > 0 || !!quoteNumberMatch;\n\nconst rawBodyPlain = String(item.textPlain || item.text || '');\nconst trimmedPlain = rawBodyPlain.trim();\nconst bodyPlain = trimmedPlain.length > PLAIN_LIMIT\n ? trimmedPlain.slice(0, PLAIN_LIMIT) + '\\\\n\\\\n[... body truncated at 50KB; full body in IMAP mailbox ...]'\n : (trimmedPlain || '(no body)');\n\nconst rawBodyHtml = String(item.textHtml || '');\nconst trimmedHtml = rawBodyHtml.trim();\nconst bodyHtml = trimmedHtml.length > HTML_LIMIT\n ? trimmedHtml.slice(0, HTML_LIMIT) + '\\\\n<!-- body truncated at 200KB; full body in IMAP mailbox -->'\n : trimmedHtml;\n\nreturn {\n json: {\n from_email: fromEmail,\n from_name: fromName,\n subject: subject,\n message_id: messageId,\n in_reply_to: inReplyTo,\n body_plain: bodyPlain,\n body_html: bodyHtml,\n received_at: String(item.date || $now.toISO()),\n attachments: attachmentsMeta,\n has_attachments: attachmentsMeta.length > 0,\n is_reply: isReply,\n route: isReply ? 'reply' : 'new',\n uid: uid,\n mailbox: mailbox\n },\n binary: $binary || {}\n};"
},
"id": "73a4abec-bb4f-423c-882f-fac1d7ff2d3f",
"name": "Parse Email",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
448,
96
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "no-noreply",
"leftValue": "={{ $json.from_email }}",
"operator": {
"type": "string",
"operation": "notContains"
},
"rightValue": "noreply"
},
{
"id": "no-donotreply",
"leftValue": "={{ $json.from_email }}",
"operator": {
"type": "string",
"operation": "notContains"
},
"rightValue": "donotreply"
},
{
"id": "no-mailer-daemon",
"leftValue": "={{ $json.from_email }}",
"operator": {
"type": "string",
"operation": "notContains"
},
"rightValue": "mailer-daemon"
},
{
"id": "no-postmaster",
"leftValue": "={{ $json.from_email }}",
"operator": {
"type": "string",
"operation": "notContains"
},
"rightValue": "postmaster"
},
{
"id": "no-ooo",
"leftValue": "={{ $json.subject }}",
"operator": {
"type": "string",
"operation": "notContains"
},
"rightValue": "out of office"
},
{
"id": "no-auto-reply",
"leftValue": "={{ $json.subject }}",
"operator": {
"type": "string",
"operation": "notContains"
},
"rightValue": "automatic reply"
},
{
"id": "no-vacation",
"leftValue": "={{ $json.subject }}",
"operator": {
"type": "string",
"operation": "notContains"
},
"rightValue": "vacation"
}
],
"combinator": "and"
},
"options": {}
},
"id": "48c9dc80-6870-4efb-ba42-475e07bdfa5e",
"name": "Drop Blacklist / Auto-replies",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
672,
96
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "is-reply-route",
"leftValue": "={{ $json.route }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "reply"
}
],
"combinator": "and"
},
"options": {}
},
"id": "5d95b51b-584e-464e-a9ef-d728bf94e265",
"name": "Is Reply?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
896,
96
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Edit Fields').item.json['mcp server'] }}/api/mcp",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"quotes.append_reply\",\"arguments\":{\"from_email\":\"{{ $json.from_email }}\",\"from_name\":\"{{ $json.from_name }}\",\"subject\":\"{{ $json.subject }}\",\"body_plain\":\"{{ $json.body_plain }}\",\"body_html\":\"{{ $json.body_html }}\",\"message_id\":\"{{ $json.message_id }}\",\"in_reply_to\":\"{{ $json.in_reply_to }}\",\"received_at\":\"{{ $json.received_at }}\"}}}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "225cee5b-0bc2-4583-828e-f399254961c3",
"name": "MCP: quotes.append_reply",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1120,
0
],
"credentials": {
"httpBearerAuth": {
"id": "aRv6op6xIgyTm9Vh",
"name": "Bearer Auth account"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const text = $json.result?.content?.[0]?.text || $json.content?.[0]?.text || '';\nlet parsed = {};\ntry { parsed = JSON.parse(text); } catch (e) { parsed = { matched_by: 'none' }; }\n\nreturn {\n json: {\n matched_by: parsed.matched_by || 'none',\n quote_id: parsed.quote?.id || null,\n reply_id: parsed.reply?.id || null,\n uid: $('Parse Email').item.json.uid,\n mailbox: $('Parse Email').item.json.mailbox\n }\n};"
},
"id": "288acf0c-a8a9-44ba-8120-96b8ef37a166",
"name": "Parse MCP Reply Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1344,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "is-matched",
"leftValue": "={{ $json.matched_by }}",
"operator": {
"type": "string",
"operation": "notEquals"
},
"rightValue": "none"
}
],
"combinator": "and"
},
"options": {}
},
"id": "3482acab-ce3e-4854-afac-23f9fcaa144b",
"name": "Route Reply Result",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1632,
0
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Carry Email Ctx — passes the full parsed email through to the\n// next nodes (Split Binary into Items) AND explicitly preserves the\n// upstream binary so the eventual upload_attachment_tool subnode can\n// read attachments by key.\nconst __bin = $binary || {};\nreturn { json: Object.assign({}, $json || {}), binary: __bin };"
},
"id": "a732a9d0-47ac-4fa8-b291-45dc7ad31fa2",
"name": "Carry Email Ctx",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
192
]
},
{
"parameters": {
"jsCode": "// Split Binary into Items\n// Take the single email item from upstream, emit one item per binary\n// entry. Each emitted item has:\n// - json: { binary_key, filename, content_type, size, ...email context }\n// - binary: { attachment: <single binary> }\n// The fixed key \"attachment\" lets the next node's binaryPropertyName\n// (also \"attachment\") work uniformly for every split item.\n// The original binary key (attachment_0, attachment_1, ...) is preserved\n// on $json.binary_key so the aggregate step can restore it under the\n// same key for the upload tool.\n//\n// Mode: runOnceForAllItems — process the (single) input item once\n// and return multiple output items. runOnceForEachItem cannot be\n// used to emit N items from one input: its validator expects a\n// single item return and treats an array return as a malformed\n// item (the error \"A 'json' property isn't an object [item 0]\").\nconst inputItems = $input.all();\nconst out = [];\nfor (const input of inputItems) {\n const email = input.json || {};\n const bin = input.binary || {};\n for (const key of Object.keys(bin)) {\n const b = bin[key];\n if (!b) continue;\n out.push({\n json: {\n binary_key: key,\n filename: b.fileName || 'attachment',\n content_type: b.mimeType || 'application/octet-stream',\n size: b.fileSize || 0,\n // Email context for the aggregate step\n _email_from_email: email.from_email || '',\n _email_from_name: email.from_name || '',\n _email_subject: email.subject || '',\n _email_body_plain: email.body_plain || '',\n _email_body_html: email.body_html || '',\n _email_message_id: email.message_id || '',\n _email_in_reply_to: email.in_reply_to || '',\n _email_received_at: email.received_at || '',\n _email_is_reply: !!email.is_reply,\n _email_route: email.route || 'new',\n _email_uid: email.uid || '',\n _email_mailbox: email.mailbox || 'INBOX',\n _email_mcp_server: email['mcp server'] || '',\n _email_agent_api_key: email.agent_api_key || ''\n },\n binary: { attachment: b }\n });\n }\n}\nif (out.length === 0) {\n // No attachments: emit one empty placeholder so the chain continues.\n // Aggregate handles 0 items, but we want at least one item flowing.\n out.push({ json: { binary_key: null, filename: '', content_type: '', size: 0 }, binary: {} });\n}\nreturn out;"
},
"id": "e2faee52-1cf3-4af4-931d-8dbd760371b6",
"name": "Split Binary into Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1344,
192
]
},
{
"parameters": {
"operation": "pdf",
"binaryPropertyName": "attachment",
"options": {
"joinPages": true
}
},
"id": "7ce098f9-aa6b-4def-919d-1607e79baed9",
"name": "Extract from File (PDF)",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1568,
192
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Aggregate + Enrich Attachments\nconst items = $input.all();\nif (items.length === 0) {\n return { json: { attachments: [], has_attachments: false }, binary: {} };\n}\n\nconst first = items[0].json;\nconst MAX_TEXT_PER_ATTACHMENT = 10 * 1024;\nconst attachments = [];\nconst binary = {};\n\nfor (const item of items) {\n const origKey = item.json.binary_key;\n const raw = (item.json && typeof item.json.text === 'string') ? item.json.text : '';\n const truncated = raw.length > MAX_TEXT_PER_ATTACHMENT\n ? raw.slice(0, MAX_TEXT_PER_ATTACHMENT) + '\\n\\n[... PDF text truncated at 10KB ...]'\n : raw;\n attachments.push({\n binary_key: origKey,\n filename: item.json.filename || 'attachment',\n content_type: item.json.content_type || 'application/octet-stream',\n size: item.json.size || 0,\n extracted_text: truncated,\n num_pages: item.json.numpages || undefined\n });\n if (item.binary && item.binary.attachment) {\n binary[origKey] = item.binary.attachment;\n }\n}\n\nreturn {\n json: {\n from_email: first._email_from_email,\n from_name: first._email_from_name,\n subject: first._email_subject,\n body_plain: first._email_body_plain,\n body_html: first._email_body_html,\n message_id: first._email_message_id,\n in_reply_to: first._email_in_reply_to,\n received_at: first._email_received_at,\n is_reply: !!first._email_is_reply,\n route: first._email_route,\n uid: first._email_uid,\n mailbox: first._email_mailbox,\n 'mcp server': first._email_mcp_server,\n agent_api_key: first._email_agent_api_key,\n attachments: attachments,\n has_attachments: attachments.length > 0\n },\n binary: binary\n};"
},
"id": "61fbc910-f6a9-4d95-9ad3-556d04fa53df",
"name": "Aggregate + Enrich",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1792,
192
]
},
{
"parameters": {
"promptType": "define",
"text": "=From: {{ $json.from_name }} <{{ $json.from_email }}>\nSubject: {{ $json.subject }}\nMessage-ID: {{ $json.message_id }}\nDate: {{ $json.received_at }}\nAttachments: {{ ($json.attachments || []).length }} file(s):\n{{ JSON.stringify(($json.attachments || []).map(function(a) { return { binary_key: a.binary_key, filename: a.filename, content_type: a.content_type, size: a.size, extracted_text_preview: a.extracted_text ? a.extracted_text.slice(0, 2000) : '(no text extracted)' }; }), null, 2) }}\n\nBody:\n{{ $json.body_plain || $json.body_html || '(no body)' }}",
"options": {
"systemMessage": "You are a purchase-order parser for WFDASH.\n\nYou have two tools:\n- create_quote_tool: create a new quote row in PocketBase. Use it once per inbound email, after extracting line items.\n- upload_attachment_tool: upload a single attachment to an existing quote. Use it once per attachment in the email, AFTER create_quote_tool returns a quote_id.\n\nWorkflow:\n1. Read the email body (in your user message). For each attachment, also look at the extracted_text_preview field — that is the first 2KB of the PDF text. If the email body is short or empty, the line items are almost certainly in the PDF.\n2. Extract the buyer/end-customer company name (company_hint). Empty string if unclear.\n3. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.\n4. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase.\n5. Capture the quote_id from the tool result.\n6. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.\n7. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=...\n\nImportant:\n- Empty body / no items is OK — create the quote anyway with an empty extracted_items array.\n- attachments metadata only (no base64) — the binary lives in the workflow's binary store; only upload_attachment_tool knows how to read it.",
"maxIterations": 20,
"returnIntermediateSteps": false,
"passthroughBinaryImages": false
}
},
"id": "7b847e86-5ede-4cd6-a5ea-a506dc187d5b",
"name": "AI Agent: Create Quote From Email",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
2080,
192
]
},
{
"parameters": {
"model": "Minimax-M2.7",
"options": {
"hideThinking": true,
"temperature": 0.1,
"timeout": 60000,
"maxRetries": 2
}
},
"id": "f3605bf8-39a5-4aad-8af7-a05e0ba818d9",
"name": "Minimax Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatMinimax",
"typeVersion": 1,
"position": [
2016,
416
],
"credentials": {
"minimaxApi": {
"id": "kn97KHCrkyTm3v2g",
"name": "MiniMax account"
}
}
},
{
"parameters": {
"description": "Create a new quote in PocketBase from the inbound email. Use this first, after extracting line items from the body or the PDF attachment text. Call once per inbound email.\n\nThe tool reads the email metadata from the upstream workflow context automatically. The LLM only needs to pass the two fields the agent has just produced:\n- company_hint: string (empty string if unclear)\n- extracted_items: array of {description, quantity, unit?, notes?} (verbatim; empty array if no items)\n\nDo NOT pass from_email, from_name, subject, body_*, message_id, in_reply_to, or received_at from the LLM side.",
"jsCode": "// create_quote_tool\nlet __q = query;\nif (typeof __q === 'string') {\n try { __q = JSON.parse(__q); } catch (e) { __q = {}; }\n}\nif (!__q || typeof __q !== 'object') __q = {};\nlet a = __q;\nif (typeof a.input === 'string') {\n try { a = JSON.parse(a.input); } catch (e) { a = __q; }\n}\nif (!a || typeof a !== 'object') a = {};\n\nconst __upstream = $input.first() || {};\nconst __upstreamJson = __upstream.json || {};\n\nconst args = {\n from_email: String(a.from_email || __upstreamJson.from_email || ''),\n from_name: String(a.from_name || __upstreamJson.from_name || ''),\n subject: String(a.subject || __upstreamJson.subject || '(no subject)'),\n body_plain: String(a.body_plain || __upstreamJson.body_plain || ''),\n body_html: String(a.body_html || __upstreamJson.body_html || ''),\n message_id: String(a.message_id || __upstreamJson.message_id || ''),\n in_reply_to: String(a.in_reply_to || __upstreamJson.in_reply_to || ''),\n received_at: String(a.received_at || __upstreamJson.received_at || ''),\n company_hint: String(a.company_hint || __upstreamJson.company_hint || ''),\n extracted_items: Array.isArray(a.extracted_items) ? a.extracted_items : [],\n attachments: []\n};\n\nconst bearer = String(__upstreamJson.agent_api_key || '');\nconst mcpBase = String(__upstreamJson['mcp server'] || __upstreamJson.mcp_url || 'https://zolau-196-39-76-18.run.pinggy-free.link');\nconst mcpUrl = mcpBase.replace(/\\/$/, '') + '/api/mcp';\nconst authToken = bearer || '123456';\n\nlet res;\ntry {\n res = await this.helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + authToken },\n body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'quotes.create_from_email', arguments: args } },\n json: true, timeout: 60000\n });\n} catch (e) {\n return 'error: http threw: ' + (e && e.message ? e.message : e);\n}\n\nconst text = (res && res.result && res.result.content && res.result.content[0] && res.result.content[0].text) || '{}';\nlet parsed = {};\ntry { parsed = JSON.parse(text); } catch (e) { parsed = {}; }\n\nif (parsed && parsed.error) {\n return 'error: ' + JSON.stringify(parsed.error) + ' args_sent=' + JSON.stringify(args);\n}\nreturn 'quote_id=' + (parsed.quote && parsed.quote.id ? parsed.quote.id : 'NONE') +\n ' quote_number=' + (parsed.quote_number || 'NONE') +\n ' items=' + (typeof parsed.items === 'number' ? parsed.items : '?') +\n ' deduped=' + (parsed.deduped ? 'true' : 'false');"
},
"id": "ee43616e-c80a-492b-9bbe-e83697c3d568",
"name": "create_quote_tool",
"type": "@n8n/n8n-nodes-langchain.toolCode",
"typeVersion": 1.3,
"position": [
2192,
416
]
},
{
"parameters": {
"description": "Upload a single attachment to an existing quote. Call once per attachment after create_quote_tool returns a quote_id. Pass the binary_key from the email attachments list so the tool can read the file from the workflow binary store.\n\nInput fields: quote_id (required), binary_key (required), filename (required), content_type, size, message_id.",
"jsCode": "// upload_attachment_tool\n\n// Pull the first binary key from an item; honour an explicit one if valid.\nfunction pickBinaryKey(item, preferred) {\n if (!item || !item.binary) return '';\n if (preferred && item.binary[preferred]) return preferred;\n const keys = Object.keys(item.binary);\n return keys.length ? keys[0] : '';\n}\n\nlet bin = null;\nlet resolvedKey = '';\n\n// Prefer whatever is wired in upstream (Split Binary into Items feeds this tool).\ntry {\n const upstream = $input.first();\n resolvedKey = pickBinaryKey(upstream, binaryKey);\n if (resolvedKey) bin = upstream.binary[resolvedKey];\n} catch (e) { bin = null; }\n\n// Belt-and-suspenders: fall back to the IMAP Quote Mailbox original payload.\nif (!bin) {\n try {\n const orig = $('IMAP Quote Mailbox').first();\n resolvedKey = resolvedKey || pickBinaryKey(orig, binaryKey);\n if (resolvedKey && orig && orig.binary) bin = orig.binary[resolvedKey];\n } catch (e) { bin = null; }\n}\nif (!bin) return 'error: binary not found on upstream item';\n\n// Backfill the metadata the LLM often sends blank.\nconst meta = (bin && (bin.fileName || bin.filename)) || {};\nconst resolvedFilename = (filename && filename !== 'attachment') ? filename : (bin.fileName || bin.filename || 'attachment');\nconst resolvedContentType = (contentType && contentType !== 'application/octet-stream') ? contentType : (bin.mimeType || bin.contentType || 'application/octet-stream');\nconst resolvedSize = size > 0 ? size : ((bin.fileSize != null) ? bin.fileSize : (bin.data ? bin.data.length : 0));\nconst resolvedMessageId = messageId || ((($input.first() || {}).json || {}).messageId || '');\n\nlet base64 = '';\ntry {\n if (Buffer.isBuffer(bin.data)) {\n base64 = bin.data.toString('base64');\n } else if (typeof bin.data === 'string') {\n base64 = Buffer.from(bin.data, 'binary').toString('base64');\n } else {\n base64 = Buffer.from(bin.data || []).toString('base64');\n }\n} catch (e) {\n return 'error: base64 conversion failed: ' + (e && e.message ? e.message : e);\n}\n\nconst __upstream = $input.first() || {};\nconst __upstreamJson = __upstream.json || {};\nconst bearer = String(__upstreamJson.agent_api_key || '');\nconst mcpBase = String(__upstreamJson['mcp server'] || __upstreamJson.mcp_url || 'https://zolau-196-39-76-18.run.pinggy-free.link');\nconst mcpUrl = mcpBase.replace(/\\/$/, '') + '/api/mcp';\nconst authToken = bearer || '123456';\n\nconst res = await this.helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + authToken },\n body: { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'attachments.upload_for_quote', arguments: {\n quote_id: quoteId,\n filename: resolvedFilename,\n content_type: resolvedContentType,\n size: resolvedSize,\n base64,\n message_id: resolvedMessageId,\n } } },\n json: true, timeout: 60000\n});\n\nconst text = (res && res.result && res.result.content && res.result.content[0] && res.result.content[0].text) || '{}';\nlet parsed = {};\ntry { parsed = JSON.parse(text); } catch (e) { parsed = {}; }\n\nreturn 'uploaded=' + resolvedFilename + ' id=' + (parsed.attachment && parsed.attachment.id ? parsed.attachment.id : 'NONE') +\n ' kind=' + (parsed.attachment && parsed.attachment.kind ? parsed.attachment.kind : '?');\n"
},
"id": "3a4f3d08-dd8e-427f-9809-c222bf4cf3f6",
"name": "upload_attachment_tool",
"type": "@n8n/n8n-nodes-langchain.toolCode",
"typeVersion": 1.3,
"position": [
2400,
416
]
}
],
"pinData": {},
"connections": {
"IMAP Quote Mailbox": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "Parse Email",
"type": "main",
"index": 0
}
]
]
},
"Parse Email": {
"main": [
[
{
"node": "Drop Blacklist / Auto-replies",
"type": "main",
"index": 0
}
]
]
},
"Drop Blacklist / Auto-replies": {
"main": [
[
{
"node": "Is Reply?",
"type": "main",
"index": 0
}
]
]
},
"Is Reply?": {
"main": [
[
{
"node": "MCP: quotes.append_reply",
"type": "main",
"index": 0
}
],
[
{
"node": "Carry Email Ctx",
"type": "main",
"index": 0
}
]
]
},
"MCP: quotes.append_reply": {
"main": [
[
{
"node": "Parse MCP Reply Result",
"type": "main",
"index": 0
}
]
]
},
"Parse MCP Reply Result": {
"main": [
[
{
"node": "Route Reply Result",
"type": "main",
"index": 0
}
]
]
},
"Carry Email Ctx": {
"main": [
[
{
"node": "Split Binary into Items",
"type": "main",
"index": 0
}
]
]
},
"Split Binary into Items": {
"main": [
[
{
"node": "Extract from File (PDF)",
"type": "main",
"index": 0
}
]
]
},
"Extract from File (PDF)": {
"main": [
[
{
"node": "Aggregate + Enrich",
"type": "main",
"index": 0
}
]
]
},
"Aggregate + Enrich": {
"main": [
[
{
"node": "AI Agent: Create Quote From Email",
"type": "main",
"index": 0
}
]
]
},
"Minimax Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent: Create Quote From Email",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"create_quote_tool": {
"ai_tool": [
[
{
"node": "AI Agent: Create Quote From Email",
"type": "ai_tool",
"index": 0
}
]
]
},
"upload_attachment_tool": {
"ai_tool": [
[
{
"node": "AI Agent: Create Quote From Email",
"type": "ai_tool",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true
},
"versionId": "7654b6fd-a059-4ddb-9c81-5da41d78770e",
"meta": {
"aiBuilderAssisted": true,
"builderVariant": "mcp",
"instanceId": "05bcfa3b50919141d3600718ff4b7b2b52a3883525b05573a27ba5ef437ac7fb"
},
"nodeGroups": [],
"id": "VOrEd4OQKO0KMnJj",
"tags": []
}

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// Dev server blocks cross-origin HMR requests by default in Next 16. The
// app is normally loaded on `localhost`, but we also load it on
// `127.0.0.1` (and on the LAN IP for phone testing). Listing both keeps
// the HMR client from hanging on the first load.
allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122"],
};
export default nextConfig;

View file

@ -12,6 +12,10 @@
"lint": "eslint",
"setup": "npx tsx scripts/setup-pocketbase.ts",
"pb:migrate:orders-v2": "npx tsx scripts/migrations/create-order-items-v2-and-collection-pool.ts",
"pb:migrate:quote-attachments": "npx tsx scripts/migrations/create-quote-attachments-and-replies.ts",
"smoke:counter": "npx tsx scripts/smoke/counter.ts",
"smoke:counter-concurrent": "npx tsx scripts/smoke/counter-concurrent.ts",
"smoke:create-from-email": "npx tsx scripts/smoke/create-from-email.ts",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",

View file

@ -0,0 +1,95 @@
/**
* One-shot migration: broaden the `quote_attachments.file` mime-type
* allowlist to include `text/csv`, `text/plain`, and
* `application/octet-stream`, so senders that attach CSV / plain-text /
* unknown-binary files (zip, dwg, etc.) don't get silently dropped at
* the PocketBase validation layer.
*
* Idempotent: re-running is a no-op (just rewrites the same allowlist).
*
* Usage: tsx scripts/migrations/broaden-quote-attachments-mime-types.ts
*/
import PocketBase from "pocketbase";
const PB_URL = process.env.PB_URL || "https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io";
const ADMIN_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD;
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error(
"Set POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD in the environment."
);
}
const NEW_MIME_TYPES = [
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
"text/plain",
"application/octet-stream",
];
async function main() {
const pb = new PocketBase(PB_URL);
console.log(`Authenticating as ${ADMIN_EMAIL} on ${PB_URL}…`);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
const list = await pb.collections.getList(1, 1, { filter: 'name = "quote_attachments"' });
if (list.items.length === 0) {
throw new Error('Collection "quote_attachments" not found.');
}
const col = list.items[0];
console.log(`Found quote_attachments (id=${col.id}).`);
const fileField = col.fields.find((f) => f.name === "file");
if (!fileField) {
throw new Error('No "file" field on quote_attachments.');
}
// PB stores file-field options in two possible shapes depending on
// how the collection was created: top-level `mimeTypes` (the API
// read shape returned by GET /api/collections) or nested under
// `options.mimeTypes` (the create/update write shape). We check
// both so the diff is accurate.
type FileField = { mimeTypes?: unknown; options?: { mimeTypes?: unknown } };
const ff = fileField as unknown as FileField;
const currentMimes: string[] = Array.isArray(ff.mimeTypes)
? (ff.mimeTypes as string[])
: ((ff.options?.mimeTypes as string[] | undefined) ?? []);
const missing = NEW_MIME_TYPES.filter((m) => !currentMimes.includes(m));
if (missing.length === 0) {
console.log("Allowlist already up to date. Nothing to do.");
return;
}
console.log(`Adding ${missing.length} mime type(s): ${missing.join(", ")}`);
const newFields = col.fields.map((f) => {
if (f.name !== "file") return f;
// Write to BOTH shapes so the change persists regardless of which
// shape PB's collections.update normalises into. PB ignores the
// unknown one and stores the one it expects.
const current = f as unknown as FileField;
return {
...f,
mimeTypes: NEW_MIME_TYPES,
options: {
...(current.options || {}),
mimeTypes: NEW_MIME_TYPES,
},
};
});
await pb.collections.update(col.id, { fields: newFields });
console.log("✓ quote_attachments.file.mimeTypes updated.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,102 @@
import fs from "node:fs";
import path from "node:path";
import PocketBase from "pocketbase";
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required.");
}
const ROOT = path.resolve(process.cwd(), "docs", "pocketbase");
/**
* Idempotent migration that creates the quote-email-intake collections:
* - quote_attachments (relation to quotes; holds email PDFs/images)
* - quote_replies (relation to quotes; holds email reply threads)
* - quote_counters (atomic year/month sequence for quote numbers)
*
* Also adds the `email_message_id` field to the existing `quotes` collection
* (for inbox-dedup). Re-runs are safe: every step checks for existence first.
*/
async function main() {
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
const collections = await pb.collections.getFullList();
const byName = new Map(collections.map((c) => [c.name, c]));
const quotes = byName.get("quotes");
if (!quotes) {
throw new Error("Missing required collection: quotes. Run `npm run setup` first.");
}
const quotesId = quotes.id;
// ── 1. Add email_message_id to existing quotes collection (idempotent) ──
const quotesHasMessageId = quotes.fields.some((f) => f.name === "email_message_id");
if (quotesHasMessageId) {
console.log("quotes.email_message_id already exists, skipping field add.");
} else {
await pb.collections.update(quotesId, {
fields: [
...quotes.fields,
{ name: "email_message_id", type: "text", required: false, max: 255 },
],
});
// Add a unique-ish index to make Message-ID lookups O(log n) and to give us
// a strong signal even if the unique constraint is later relaxed.
try {
await pb.collections.update(quotesId, {
indexes: [
...(quotes.indexes || []),
"CREATE INDEX idx_quotes_email_message_id ON quotes (email_message_id)",
],
});
console.log("Added index idx_quotes_email_message_id on quotes.");
} catch (err) {
console.warn("Could not add email_message_id index (may already exist):", err);
}
console.log("Added quotes.email_message_id field.");
}
// ── 2. quote_attachments ──
const existingAttachments = byName.get("quote_attachments");
if (existingAttachments) {
console.log("quote_attachments already exists, skipping create.");
} else {
const def = JSON.parse(fs.readFileSync(path.join(ROOT, "quote_attachments.collection.json"), "utf8"));
const normalized = JSON.parse(JSON.stringify(def).replaceAll("REPLACE_QUOTES_COLLECTION_ID", quotesId));
const created = await pb.collections.create(normalized);
console.log(`Created ${created.name} (${created.id})`);
}
// ── 3. quote_replies ──
const existingReplies = byName.get("quote_replies");
if (existingReplies) {
console.log("quote_replies already exists, skipping create.");
} else {
const def = JSON.parse(fs.readFileSync(path.join(ROOT, "quote_replies.collection.json"), "utf8"));
const normalized = JSON.parse(JSON.stringify(def).replaceAll("REPLACE_QUOTES_COLLECTION_ID", quotesId));
const created = await pb.collections.create(normalized);
console.log(`Created ${created.name} (${created.id})`);
}
// ── 4. quote_counters ──
const existingCounters = byName.get("quote_counters");
if (existingCounters) {
console.log("quote_counters already exists, skipping create.");
} else {
const def = JSON.parse(fs.readFileSync(path.join(ROOT, "quote_counters.collection.json"), "utf8"));
const created = await pb.collections.create(def);
console.log(`Created ${created.name} (${created.id})`);
}
console.log("Quote email intake migration complete.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

84
scripts/n8n-v2-error.ts Normal file
View file

@ -0,0 +1,84 @@
import { workflow, node, trigger, newCredential, expr } from '@n8n/workflow-sdk';
const errorTrigger = trigger({
type: 'n8n-nodes-base.errorTrigger',
version: 1,
config: {
name: 'Error Trigger',
position: [240, 300]
},
output: [{ error: { message: 'e' }, execution: { id: '1', lastNodeExecuted: 'n' }, subject: 's', from_email: 'x@y', uid: '123', mailbox: 'INBOX' }]
});
const buildAlert = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Build Alert',
parameters: {
mode: 'runOnceForEachItem',
jsCode: `const errorInfo = $json.error || {};
const execution = $json.execution || {};
const lastNode = execution.lastNodeExecuted || 'unknown';
const errorMessage = errorInfo.message || 'Unknown error';
const subject = $json.subject || '(no subject)';
const fromEmail = $json.from_email || '(unknown sender)';
const uid = String($json.uid || '');
const mailbox = $json.mailbox || 'INBOX';
const alertText = '⚠️ WFDASH Quote Email Error\\n\\n' +
'From: ' + fromEmail + '\\n' +
'Subject: ' + subject + '\\n' +
'UID: ' + (uid || '(no uid)') + '\\n' +
'Failed at: ' + lastNode + '\\n' +
'Error: ' + errorMessage + '\\n' +
'Execution: ' + (execution.id || 'unknown') + '\\n\\n' +
'Email is still in ' + mailbox + ' — please triage manually.';
return {
json: {
uid: uid,
mailbox: mailbox,
alert_text: alertText,
subject: subject,
from_email: fromEmail
}
};`
},
position: [540, 300]
},
output: [{ uid: '123', mailbox: 'INBOX', alert_text: 'a', subject: 's', from_email: 'x@y' }]
});
// v2 error workflow has no IMAP Move-to-Failed step. The n8n JS task-runner
// image (`n8nio/runners:2.25.7`) blocks `require('imapflow')` outright, and
// there is no env-var to allowlist new modules in that sandbox. The failing
// email stays where it was (likely `INBOX`); the next poll re-fires the main
// workflow; the human sees the Telegram alert and triages the email.
const telegramAlert = node({
type: 'n8n-nodes-base.telegram',
version: 1.2,
config: {
name: 'Telegram Alert',
parameters: {
resource: 'message',
operation: 'sendMessage',
chatId: expr("{{ $env.TELEGRAM_CHAT_ID }}"),
text: expr("{{ $json.alert_text }}"),
additionalFields: {
parse_mode: 'HTML',
disable_web_page_preview: true
}
},
position: [1140, 300],
credentials: { telegramApi: newCredential('Telegram Alerts') }
},
output: [{ ok: true, result: { message_id: 1 } }]
});
export default workflow('wfdash-error-handler', 'WFDASH — Error Handler')
.add(errorTrigger)
.to(buildAlert)
.to(telegramAlert);

387
scripts/n8n-v2-main.ts Normal file
View file

@ -0,0 +1,387 @@
import { workflow, node, trigger, ifElse, languageModel, newCredential, expr } from '@n8n/workflow-sdk';
const imapTrigger = trigger({
type: 'n8n-nodes-base.emailReadImap',
version: 2.1,
config: {
name: 'IMAP Quote Mailbox',
parameters: {
mailbox: 'INBOX',
postProcessAction: 'nothing',
format: 'simple',
downloadAttachments: true,
options: {
customEmailConfig: '["UNSEEN"]',
forceReconnect: 60,
trackLastMessageId: true
}
},
position: [240, 300]
},
output: [{ uid: '123', messageId: '<x@y>', from: { value: [{ address: 'x@y' }] }, subject: 's', date: '2026-06-12T00:00:00Z', textPlain: '', textHtml: '' }]
});
const parseEmail = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Parse Email',
parameters: {
mode: 'runOnceForEachItem',
jsCode: `const item = $json;
function asArray(v) {
if (Array.isArray(v)) return v;
if (v && typeof v === 'object') {
if ('item' in v) return [v.item];
if ('item0' in v) {
const out = [];
let i = 0;
while (('item' + i) in v) { out.push(v['item' + i]); i++; }
if (out.length) return out;
}
}
return null;
}
function extractEmail(from) {
if (!from) return '';
if (typeof from === 'string') {
const m = from.match(/<([^>]+)>/);
return (m ? m[1] : from).toLowerCase().trim();
}
if (typeof from === 'object') {
const arr = asArray(from.value);
if (arr && arr.length > 0) {
const v = arr[0];
if (typeof v === 'string') return v.toLowerCase();
if (v && typeof v === 'object' && v.address) return v.address.toLowerCase();
}
if (typeof from.address === 'string') return from.address.toLowerCase();
if (typeof from.text === 'string') {
const m = from.text.match(/<([^>]+)>/);
if (m) return m[1].toLowerCase();
}
}
return '';
}
function extractName(from) {
if (!from) return '';
if (typeof from === 'object') {
if (typeof from.name === 'string' && from.name) return from.name;
const arr = asArray(from.value);
if (arr && arr[0]) {
if (typeof arr[0] === 'object' && arr[0].name) return arr[0].name;
}
}
if (typeof from === 'string') {
const m = from.match(/^([^<]+)</);
return m ? m[1].trim().replace(/^"|"$/g, '') : '';
}
return '';
}
function extractMessageId(field) {
if (!field) return '';
if (typeof field === 'string') return field.replace(/^[<]|[>]$/g, '');
if (typeof field === 'object') {
if (typeof field.value === 'string') return field.value.replace(/^[<]|[>]$/g, '');
const arr = asArray(field.value);
if (arr && typeof arr[0] === 'string') return arr[0].replace(/^[<]|[>]$/g, '');
if (typeof field.messageId === 'string') return field.messageId.replace(/^[<]|[>]$/g, '');
}
return '';
}
const fromEmail = extractEmail(item.from);
const fromName = extractName(item.from);
const messageId = extractMessageId(item.messageId);
const inReplyTo = extractMessageId(item.inReplyTo);
const mailbox = String(item.mailbox || 'INBOX');
const uid = String(item.uid || '');
const attachments = [];
const bin = $binary || {};
for (const key of Object.keys(bin)) {
const b = bin[key];
if (!b) continue;
attachments.push({
filename: String(b.fileName || 'attachment'),
content_type: String(b.mimeType || 'application/octet-stream'),
size: Number(b.fileSize || 0),
base64: String(b.data || '')
});
}
const rawSubject = String(item.subject || '');
const subject = rawSubject.trim() || '(no subject)';
const quoteNumberMatch = subject.match(/Q-\\d{4}-\\d{2}-\\d{3}/);
const isReply = inReplyTo.length > 0 || !!quoteNumberMatch;
const rawBodyPlain = String(item.textPlain || item.text || '');
const bodyPlain = rawBodyPlain.trim() || '(no body)';
const rawBodyHtml = String(item.textHtml || '');
const bodyHtml = rawBodyHtml.trim();
return {
json: {
from_email: fromEmail,
from_name: fromName,
subject: subject,
message_id: messageId,
in_reply_to: inReplyTo,
body_plain: bodyPlain,
body_html: bodyHtml,
received_at: String(item.date || $now.toISO()),
attachments: attachments,
has_attachments: attachments.length > 0,
is_reply: isReply,
route: isReply ? 'reply' : 'new',
uid: uid,
mailbox: mailbox
}
};`
},
position: [540, 300]
},
output: [{ from_email: 'x@y', from_name: 'X', subject: 's', message_id: 'm', in_reply_to: '', body_plain: '', body_html: '', received_at: '2026-06-12T00:00:00Z', attachments: [], has_attachments: false, is_reply: false, route: 'new', uid: '123', mailbox: 'INBOX' }]
});
const dropBlacklist = node({
type: 'n8n-nodes-base.filter',
version: 2.3,
config: {
name: 'Drop Blacklist / Auto-replies',
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 3 },
conditions: [
{ id: 'no-noreply', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'noreply' },
{ id: 'no-donotreply', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'donotreply' },
{ id: 'no-mailer-daemon', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'mailer-daemon' },
{ id: 'no-postmaster', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'postmaster' },
{ id: 'no-our-domain', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notEndsWith' }, rightValue: '@wfsales.co.za' },
{ id: 'no-ooo', leftValue: expr('{{ $json.subject }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'out of office' },
{ id: 'no-auto-reply', leftValue: expr('{{ $json.subject }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'automatic reply' },
{ id: 'no-vacation', leftValue: expr('{{ $json.subject }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'vacation' }
],
combinator: 'and'
},
options: {}
},
position: [840, 300]
},
output: [{ from_email: 'x@y', subject: 's', route: 'new', uid: '123', mailbox: 'INBOX' }]
});
const isReply = ifElse({
version: 2.2,
config: {
name: 'Is Reply?',
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 },
conditions: [
{ id: 'is-reply-route', leftValue: expr('{{ $json.route }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'reply' }
],
combinator: 'and'
},
options: {}
},
position: [1140, 300]
}
});
const mcpAppendReply = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.4,
config: {
name: 'MCP: quotes.append_reply',
parameters: {
method: 'POST',
url: expr("{{ $env.WFDASH_MCP_URL || 'http://localhost:3000' }}/api/mcp"),
authentication: 'genericCredentialType',
genericAuthType: 'httpBearerAuth',
sendHeaders: true,
headerParameters: {
parameters: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'Accept', value: 'application/json' }
]
},
sendBody: true,
specifyBody: 'json',
jsonBody: expr('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"quotes.append_reply","arguments":{"from_email":"{{ $json.from_email }}","from_name":"{{ $json.from_name }}","subject":"{{ $json.subject }}","body_plain":"{{ $json.body_plain }}","body_html":"{{ $json.body_html }}","message_id":"{{ $json.message_id }}","in_reply_to":"{{ $json.in_reply_to }}","received_at":"{{ $json.received_at }}"}}}'),
options: { response: { response: { responseFormat: 'json' } } }
},
position: [1440, 100],
credentials: { httpBearerAuth: newCredential('Bearer Auth account') }
},
output: [{ result: { content: [{ type: 'text', text: '{"matched_by":"in_reply_to","quote":{},"reply":{}}' }] } }]
});
const parseMcpReply = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Parse MCP Reply Result',
parameters: {
mode: 'runOnceForEachItem',
jsCode: `const text = $json.result?.content?.[0]?.text || $json.content?.[0]?.text || '';
let parsed = {};
try { parsed = JSON.parse(text); } catch (e) { parsed = { matched_by: 'none' }; }
return {
json: {
matched_by: parsed.matched_by || 'none',
quote_id: parsed.quote?.id || null,
reply_id: parsed.reply?.id || null,
uid: $('Parse Email').item.json.uid,
mailbox: $('Parse Email').item.json.mailbox
}
};`
},
position: [1740, 100]
},
output: [{ matched_by: 'in_reply_to', quote_id: 'q1', reply_id: 'r1', uid: '123', mailbox: 'INBOX' }]
});
const routeReply = ifElse({
version: 2.2,
config: {
name: 'Route Reply Result',
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 },
conditions: [
{ id: 'is-matched', leftValue: expr('{{ $json.matched_by }}'), operator: { type: 'string', operation: 'notEquals' }, rightValue: 'none' }
],
combinator: 'and'
},
options: {}
},
position: [2040, 100]
}
});
// v2 main workflow has no IMAP Move step. The n8n JS task-runner image
// (`n8nio/runners:2.25.7`) blocks `require('imapflow')` outright, and there
// is no env-var to allowlist new modules in that sandbox. Processed emails
// stay in `INBOX` UNSEEN; the next poll re-fires this workflow; WFDASH
// dedups the quote by `message_id` (or `synth-…@wfdash.local`). Re-fires are
// no-ops at the database level. A human (or a cPanel IMAP filter) handles
// inbox state on the mail server side.
const openRouterModel = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenRouter',
version: 1,
config: {
name: 'OpenRouter Chat Model',
parameters: {
model: 'openai/gpt-4.1-mini',
options: { temperature: 0.1, timeout: 60000 }
}
},
credentials: { openRouterApi: newCredential('OpenRouter account') }
});
const extractQuoteItems = node({
type: '@n8n/n8n-nodes-langchain.informationExtractor',
version: 1.2,
config: {
name: 'Extract Quote Items',
parameters: {
text: expr("{{ $json.body_plain || $json.body_html || $json.subject }}"),
attributes: {
attributes: [
{ name: 'company_hint', description: 'The buyer or end-customer company name if mentioned in the email.' },
{ name: 'items', description: 'JSON array of line items extracted from the email. Each item: {description: string, quantity: number, unit?: string, notes?: string}. If no items, return an empty array.', required: true }
]
},
options: {
systemPromptTemplate: 'You are a purchase-order parser. Extract line items exactly as listed in the email body. Do not invent items, prices, or quantities. If the email has no line items, return an empty array. Return JSON only.'
}
},
subnodes: { model: openRouterModel },
position: [1440, 500]
},
output: [{ output: { company_hint: '', items: [{ description: 'd', quantity: 1 }] } }]
});
const buildCreatePayload = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Build Create Payload',
parameters: {
mode: 'runOnceForEachItem',
jsCode: `const original = $('Parse Email').item.json;
const extracted = $json.output || {};
let items = [];
if (Array.isArray(extracted.items)) items = extracted.items;
else if (typeof extracted.items === 'string' && extracted.items.trim()) {
try { items = JSON.parse(extracted.items); } catch (_e) { items = []; }
}
return {
json: {
payload: {
from_email: original.from_email,
from_name: original.from_name,
subject: original.subject,
body_plain: original.body_plain,
body_html: original.body_html,
message_id: original.message_id,
in_reply_to: original.in_reply_to,
received_at: original.received_at,
company_hint: extracted.company_hint || '',
extracted_items: items,
attachments: original.attachments || []
},
uid: original.uid,
mailbox: original.mailbox
}
};`
},
position: [1740, 500]
},
output: [{ payload: {}, uid: '123', mailbox: 'INBOX' }]
});
const mcpCreateFromEmail = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.4,
config: {
name: 'MCP: quotes.create_from_email',
parameters: {
method: 'POST',
url: expr("{{ $env.WFDASH_MCP_URL || 'http://localhost:3000' }}/api/mcp"),
authentication: 'genericCredentialType',
genericAuthType: 'httpBearerAuth',
sendHeaders: true,
headerParameters: {
parameters: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'Accept', value: 'application/json' }
]
},
sendBody: true,
specifyBody: 'json',
jsonBody: expr('{{ JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "quotes.create_from_email", arguments: $json.payload } }) }}'),
options: { response: { response: { responseFormat: 'json' } }, timeout: 60000 }
},
position: [2040, 500],
credentials: { httpBearerAuth: newCredential('Bearer Auth account') }
},
output: [{ result: { content: [{ type: 'text', text: '{"quote_number":"Q-2026-06-001"}' }] } }]
});
export default workflow('wfdash-quote-email-monitor', 'WFDASH — Quote Email Monitor')
.add(imapTrigger)
.to(parseEmail)
.to(dropBlacklist)
.to(isReply
.onTrue(mcpAppendReply.to(parseMcpReply.to(routeReply)))
.onFalse(extractQuoteItems.to(buildCreatePayload.to(mcpCreateFromEmail)))
);

View file

@ -0,0 +1,33 @@
/**
* Concurrent smoke test: 10 parallel calls to generateQuoteNumberServer
* must produce 10 unique, contiguous quote numbers (no collisions).
*/
import { generateQuoteNumberServer } from "../../src/lib/server/quotes-service";
async function main() {
const calls = Array.from({ length: 10 }, () => generateQuoteNumberServer());
const results = await Promise.all(calls);
console.log("Generated:", results);
const set = new Set(results);
if (set.size !== results.length) {
console.error(`FAIL — got ${results.length} results but only ${set.size} unique`);
process.exit(1);
}
// Numbers must be contiguous (Q-2026-06-004..013, since -001..-003 are taken)
const seqs = results
.map((r) => Number(r.match(/Q-\d{4}-\d{2}-(\d{3})$/)![1]))
.sort((a, b) => a - b);
for (let i = 0; i < seqs.length; i += 1) {
if (seqs[i] !== seqs[0] + i) {
console.error(`FAIL — non-contiguous sequence: ${seqs.join(",")}`);
process.exit(1);
}
}
console.log("OK — 10 concurrent calls, all unique and contiguous.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

41
scripts/smoke/counter.ts Normal file
View file

@ -0,0 +1,41 @@
/**
* Smoke test: exercise generateQuoteNumberServer three times in a row and
* verify the result is a contiguous, well-formed Q-YYYY-MM-NNN sequence.
* Uses the live PB from .env.local.
*/
import { generateQuoteNumberServer } from "../../src/lib/server/quotes-service";
const RE = /^Q-(\d{4})-(\d{2})-(\d{3})$/;
async function main() {
const results: string[] = [];
for (let i = 0; i < 3; i += 1) {
const n = await generateQuoteNumberServer();
results.push(n);
}
console.log("Generated:", results);
// Format check
for (const r of results) {
if (!RE.test(r)) {
console.error(`FAIL — bad format: ${r}`);
process.exit(1);
}
}
// Contiguous + monotonic + unique
const seqs = results
.map((r) => Number(r.match(RE)![3]))
.sort((a, b) => a - b);
for (let i = 0; i < seqs.length; i += 1) {
if (seqs[i] !== seqs[0] + i) {
console.error(`FAIL — non-contiguous: ${seqs.join(",")}`);
process.exit(1);
}
}
console.log("OK — counter is atomic, monotonic, and well-formed.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,258 @@
/**
* End-to-end smoke test for the email → quote pipeline.
*
* Calls createQuoteFromEmail twice (different senders), then a third time
* with the SAME message_id as the first call (must dedupe), then
* appendReplyToQuote to thread a reply onto the first quote.
*
* Verifies:
* - quote, customer, company auto-create
* - included_in_quote: false on every item
* - cost_price: 0 on every item (no fabrication)
* - quote_number is Q-2026-06-NNN
* - second call with same message_id returns deduped: true
* - reply is stored as a quote_replies row (not as notes text)
* - quote has_new_reply flips to true and last_reply_at is set
*/
import { createQuoteFromEmail, appendReplyToQuote, findQuoteByMessageId } from "../../src/lib/server/quotes-service";
const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const MSG_A = `<smoke-a-${RUN_ID}@example.com>`;
const MSG_B = `<smoke-b-${RUN_ID}@another.org>`;
async function main() {
// ── Call 1: new RFQ from a never-seen sender
const r1 = await createQuoteFromEmail({
from_email: "newbuyer@example.com",
from_name: "New Buyer",
subject: "RFQ for Industrial Pump",
body_plain: "Please quote on the following 3 items.",
message_id: MSG_A,
received_at: new Date().toISOString(),
extracted_items: [
{ description: "Centrifugal Pump 5HP", quantity: 2, unit: "ea" },
{ description: "Pump Seal Kit", quantity: 5, unit: "ea", notes: "spares" },
],
});
console.log("Call 1:", {
quote_number: r1.quote_number,
deduped: r1.deduped,
items: r1.items,
customer: r1.customer.customer,
company: r1.company === null ? "null (no AI hint)" : r1.company.company,
});
if (r1.deduped) throw new Error("FAIL — first call should not be deduped");
if (r1.items !== 2) throw new Error(`FAIL — expected 2 items, got ${r1.items}`);
if (!r1.quote_number.startsWith("Q-2026-06-")) throw new Error("FAIL — bad quote number prefix");
if (r1.company !== null) throw new Error("FAIL — company should be null when company_hint is unset (no subject fallback)");
// ── Call 2: idempotency — same Message-ID must return the existing quote
const r1b = await createQuoteFromEmail({
from_email: "newbuyer@example.com",
from_name: "New Buyer",
subject: "RFQ for Industrial Pump",
body_plain: "Please quote on the following 3 items.",
message_id: MSG_A,
});
console.log("Call 2 (same Message-ID):", { deduped: r1b.deduped, quote_number: r1b.quote_number });
if (!r1b.deduped) throw new Error("FAIL — second call with same message_id should be deduped");
if (r1b.quote.id !== r1.quote.id) throw new Error("FAIL — dedupe should return the same quote id");
// ── Call 3: new RFQ from a different sender
const r2 = await createQuoteFromEmail({
from_email: "second@another.org",
from_name: "Second Person",
subject: "Quotation for Valves",
message_id: MSG_B,
extracted_items: [{ description: "Ball Valve 2 inch" }],
});
console.log("Call 3:", { quote_number: r2.quote_number, deduped: r2.deduped });
if (r2.deduped) throw new Error("FAIL — different message_id should not dedupe");
// ── Call 4: email with no Message-ID header (iCloud/Mail.app sometimes
// strip it). The MCP server should synthesize a stable `synth-…@wfdash.local`
// id and still create the quote.
const NO_ID_SUBJECT = `Testing order no-msg-id-${RUN_ID}`;
const NO_ID_RECEIVED_AT = "2026-06-12T13:00:00.000Z";
const noId1 = await createQuoteFromEmail({
from_email: "noheader@icloud.example.com",
from_name: "No Header",
subject: NO_ID_SUBJECT,
body_plain: "50 x m20 x 69 bolts and nuts zp\n7 x m12 x 33 nuts",
message_id: "",
received_at: NO_ID_RECEIVED_AT,
extracted_items: [
{ description: "m20 x 69 bolts and nuts zp", quantity: 50 },
{ description: "m12 x 33 nuts", quantity: 7 },
],
});
console.log("Call 4 (no Message-ID):", {
quote_number: noId1.quote_number,
deduped: noId1.deduped,
email_message_id: noId1.quote.email_message_id,
});
if (!noId1.quote.email_message_id.startsWith("synth-")) {
throw new Error("FAIL — missing message_id should produce a synth-… id");
}
// Re-call with the same payload (still no message_id, same received_at).
// Must dedup to the same quote via the synthesized id.
const noId2 = await createQuoteFromEmail({
from_email: "noheader@icloud.example.com",
from_name: "No Header",
subject: NO_ID_SUBJECT,
body_plain: "50 x m20 x 69 bolts and nuts zp\n7 x m12 x 33 nuts",
message_id: "",
received_at: NO_ID_RECEIVED_AT,
});
if (!noId2.deduped) throw new Error("FAIL — second call with no message_id should dedup via synth id");
if (noId2.quote.id !== noId1.quote.id) throw new Error("FAIL — synth dedup should return the same quote id");
// ── Call 5: email with no From: header. WFDASH must still create the
// quote, linked to a sentinel "Unknown Sender" customer, and a second
// such email must reuse the same sentinel (no proliferation).
const NO_FROM_SUBJECT_A = `Testing order no-from-a-${RUN_ID}`;
const NO_FROM_SUBJECT_B = `Testing order no-from-b-${RUN_ID}`;
const noFrom1 = await createQuoteFromEmail({
from_email: "",
from_name: "",
subject: NO_FROM_SUBJECT_A,
body_plain: "10 x 2-inch bolts",
message_id: `<no-from-a-${RUN_ID}@example.com>`,
received_at: "2026-06-12T14:00:00.000Z",
extracted_items: [{ description: "2-inch bolts", quantity: 10 }],
});
console.log("Call 5a (no From):", {
quote_number: noFrom1.quote_number,
deduped: noFrom1.deduped,
customer: noFrom1.customer.customer,
email_sender: noFrom1.quote.email_sender,
});
if (noFrom1.deduped) throw new Error("FAIL — first no-from call should not be deduped");
if (noFrom1.customer.email !== "unknown-sender@no-from.wfdash.local") {
throw new Error(`FAIL — expected sentinel customer, got ${noFrom1.customer.email}`);
}
if (noFrom1.customer.customer !== "Unknown Sender") {
throw new Error(`FAIL — expected 'Unknown Sender' name, got ${noFrom1.customer.customer}`);
}
if (noFrom1.quote.email_sender !== "") {
throw new Error(`FAIL — email_sender should be empty string, got '${noFrom1.quote.email_sender}'`);
}
const noFrom2 = await createQuoteFromEmail({
from_email: "",
from_name: "",
subject: NO_FROM_SUBJECT_B,
body_plain: "20 x 3-inch bolts",
message_id: `<no-from-b-${RUN_ID}@example.com>`,
received_at: "2026-06-12T14:01:00.000Z",
extracted_items: [{ description: "3-inch bolts", quantity: 20 }],
});
console.log("Call 5b (no From, second email):", {
quote_number: noFrom2.quote_number,
customer: noFrom2.customer.customer,
});
if (noFrom2.deduped) throw new Error("FAIL — second no-from call should not be deduped (different message_id)");
if (noFrom2.customer.id !== noFrom1.customer.id) {
throw new Error("FAIL — second no-from call should reuse the same sentinel customer");
}
// ── Call 6: a fully-blank RFC 5321-violating email — no subject, no
// body, no From. The n8n Parse Email node stamps `(no subject)` and
// `(no body)` placeholders. WFDASH must accept them as real values and
// create a quote, linked to the sentinel "Unknown Sender" customer.
const blank = await createQuoteFromEmail({
from_email: "",
from_name: "",
subject: "(no subject)",
body_plain: "(no body)",
body_html: "",
message_id: `<blank-${RUN_ID}@example.com>`,
received_at: "2026-06-12T18:30:00.000Z",
extracted_items: [],
});
console.log("Call 6 (fully blank):", {
quote_number: blank.quote_number,
deduped: blank.deduped,
customer: blank.customer.customer,
description: blank.quote.description,
email_body: blank.quote.email_body,
email_sender: blank.quote.email_sender,
items: blank.items,
});
if (blank.deduped) throw new Error("FAIL — fully-blank call should not be deduped (unique message_id)");
if (blank.customer.email !== "unknown-sender@no-from.wfdash.local") {
throw new Error(`FAIL — expected sentinel customer, got ${blank.customer.email}`);
}
if (blank.quote.description !== "(no subject)") {
throw new Error(`FAIL — description should be '(no subject)', got '${blank.quote.description}'`);
}
if (blank.quote.email_subject !== "(no subject)") {
throw new Error(`FAIL — email_subject should be '(no subject)', got '${blank.quote.email_subject}'`);
}
if (blank.quote.email_body !== "(no body)") {
throw new Error(`FAIL — email_body should be '(no body)', got '${blank.quote.email_body}'`);
}
if (blank.quote.email_sender !== "") {
throw new Error(`FAIL — email_sender should be empty string, got '${blank.quote.email_sender}'`);
}
if (blank.items !== 0) throw new Error(`FAIL — expected 0 items, got ${blank.items}`);
// ── Append a reply to the first quote (resolve by In-Reply-To)
const reply = await appendReplyToQuote({
from_email: "newbuyer@example.com",
from_name: "New Buyer",
subject: `Re: Quotation ${r1.quote_number}`,
body_plain: "Thanks. Can you also quote on 10HP?",
message_id: "<reply-a@example.com>",
in_reply_to: MSG_A,
received_at: new Date().toISOString(),
});
console.log("Reply:", { matched_by: reply.matched_by, reply_id: (reply.reply as { id: string } | null)?.id });
if (reply.matched_by !== "in_reply_to") throw new Error(`FAIL — matched_by should be in_reply_to, got ${reply.matched_by}`);
// ── Resolve by subject regex on a different (synthetic) Message-ID
const replySubjectOnly = await appendReplyToQuote({
from_email: "newbuyer@example.com",
from_name: "New Buyer",
subject: `RE: Quotation ${r1.quote_number}`,
body_plain: "Subject-only match test.",
message_id: "<reply-a-subject-only@example.com>",
});
console.log("Reply (subject only):", { matched_by: replySubjectOnly.matched_by });
if (replySubjectOnly.matched_by !== "subject") throw new Error(`FAIL — matched_by should be subject, got ${replySubjectOnly.matched_by}`);
// ── Unmatched reply: must report matched_by: 'none'
const replyNone = await appendReplyToQuote({
from_email: "newbuyer@example.com",
subject: "totally unrelated subject",
body_plain: "Unmatched test.",
message_id: "<unmatched@example.com>",
});
console.log("Reply (unmatched):", { matched_by: replyNone.matched_by });
if (replyNone.matched_by !== "none") throw new Error("FAIL — unmatched should report 'none'");
// ── Lookup by message id
const looked = await findQuoteByMessageId(MSG_A);
console.log("Lookup by message id:", looked?.quote_number, "has_new_reply:", looked?.has_new_reply);
if (!looked) throw new Error("FAIL — lookup by message id returned null");
if (looked.id !== r1.quote.id) throw new Error("FAIL — lookup returned wrong quote id");
if (!looked.has_new_reply) throw new Error("FAIL — has_new_reply should be true after the In-Reply-To reply");
if (!looked.last_reply_at) throw new Error("FAIL — last_reply_at should be set");
// ── Cleanup: delete the smoke quotes (so we don't pollute the live data)
// Skip deletion — leave for the user to verify in the UI and clean up manually.
console.log("\nALL SMOKE CHECKS PASSED.");
console.log("Quotes created:");
console.log(` ${r1.quote_number} id=${r1.quote.id} customer=${r1.customer.customer}`);
console.log(` ${r2.quote_number} id=${r2.quote.id} customer=${r2.customer.customer}`);
console.log(` ${noId1.quote_number} id=${noId1.quote.id} customer=${noId1.customer.customer} (no Message-ID, synth-…@wfdash.local)`);
console.log(` ${noFrom1.quote_number} id=${noFrom1.quote.id} customer=${noFrom1.customer.customer} (no From, Unknown Sender sentinel)`);
console.log(` ${noFrom2.quote_number} id=${noFrom2.quote.id} customer=${noFrom2.customer.customer} (no From, same sentinel)`);
console.log(` ${blank.quote_number} id=${blank.quote.id} customer=${blank.customer.customer} (fully-blank, placeholder subject/body)`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,117 @@
/**
* Smoke test for the new `attachments.upload_for_quote` MCP tool.
*
* Verifies:
* 1. We can create a quote, then upload 3 attachments of different types
* (PDF stub, CSV, plain text) to it via the new MCP tool.
* 2. The `quote_attachments` collection has 3 rows linked to the new
* quote, with the right kinds (pdf / other / other) and content_types.
* 3. The quote's `has_attachments` flag is set to true.
* 4. The PB file URL for each attachment is reachable.
*
* Run with: tsx scripts/smoke/upload-attachment.ts
*/
import { createQuoteFromEmail, findQuoteByMessageId } from "../../src/lib/server/quotes-service";
import { getAdminPocketBase } from "../../src/lib/server/pocketbase-admin";
import { uploadAttachmentForQuote } from "../../src/lib/server/quotes-service";
const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const MSG_ID = `<attach-smoke-${RUN_ID}@example.com>`;
// Tiny valid PDF (1 page, "Hello"). Generated by hand.
const PDF_BYTES = Buffer.from(
"%PDF-1.1\n1 0 obj<<>>endobj\nxref\n0 1\n0000000000 65535 f\ntrailer<<>>\n%%EOF\n",
"utf-8"
);
const PDF_B64 = PDF_BYTES.toString("base64");
const CSV_TEXT = "sku,description,qty\nBOLT-M20,M20 bolt,50\nNUT-M12,M12 nut,7\n";
const CSV_B64 = Buffer.from(CSV_TEXT, "utf-8").toString("base64");
const TXT_TEXT = "Plain text note from the smoke test.\nLine 2.\n";
const TXT_B64 = Buffer.from(TXT_TEXT, "utf-8").toString("base64");
async function main() {
// ── 1. Create a quote (no inline attachments — they'll go via the new tool)
const r = await createQuoteFromEmail({
from_email: `attach-test-${RUN_ID}@example.com`,
from_name: "Attachment Test",
subject: `Testing attachments ${RUN_ID}`,
body_plain: "Please find 3 attachments: a PDF, a CSV, and a text note.",
message_id: MSG_ID,
received_at: new Date().toISOString(),
extracted_items: [{ description: "Test line item", quantity: 1 }],
// attachments field accepted for back-compat but ignored
attachments: [],
});
if (r.deduped) throw new Error("FAIL — first call should not be deduped");
console.log(`Created ${r.quote_number} (id=${r.quote.id}) with ${r.items} item(s).`);
if (r.attachments !== 0) throw new Error(`FAIL — base create should report 0 attachments, got ${r.attachments}`);
// ── 2. Upload 3 attachments via the new MCP-path function.
const u1 = (await uploadAttachmentForQuote({
quoteId: r.quote.id,
filename: "rfq.pdf",
contentType: "application/pdf",
size: PDF_BYTES.length,
base64: PDF_B64,
messageId: MSG_ID,
})) as Record<string, string>;
console.log(` + ${u1.filename} kind=${u1.kind} size=${u1.size_bytes} id=${u1.id}`);
const u2 = (await uploadAttachmentForQuote({
quoteId: r.quote.id,
filename: "line-items.csv",
contentType: "text/csv",
base64: CSV_B64,
messageId: MSG_ID,
})) as Record<string, string>;
console.log(` + ${u2.filename} kind=${u2.kind} size=${u2.size_bytes} id=${u2.id}`);
const u3 = (await uploadAttachmentForQuote({
quoteId: r.quote.id,
filename: "notes.txt",
contentType: "text/plain",
base64: TXT_B64,
messageId: MSG_ID,
})) as Record<string, string>;
console.log(` + ${u3.filename} kind=${u3.kind} size=${u3.size_bytes} id=${u3.id}`);
// ── 3. Verify the rows are linked to the quote with the right kinds.
if (u1.kind !== "pdf") throw new Error(`FAIL — u1.kind should be 'pdf', got '${u1.kind}'`);
if (u2.kind !== "other") throw new Error(`FAIL — u2.kind should be 'other' (csv), got '${u2.kind}'`);
if (u3.kind !== "other") throw new Error(`FAIL — u3.kind should be 'other' (txt), got '${u3.kind}'`);
if (u1.quote !== r.quote.id) throw new Error(`FAIL — u1.quote should be ${r.quote.id}, got ${u1.quote}`);
// ── 4. Verify the quote's has_attachments flag is set.
const pb = await getAdminPocketBase();
const refetched = await pb.collection("quotes").getOne(r.quote.id);
if (!refetched.has_attachments) {
throw new Error("FAIL — quote.has_attachments should be true after uploads");
}
// ── 5. Verify the PB file URLs are reachable.
const baseUrl =
process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io";
for (const a of [u1, u2, u3]) {
const url = `${baseUrl.replace(/\/$/, "")}/api/files/quote_attachments/${a.id}/${a.file}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`FAIL — attachment ${a.filename} fetch returned ${res.status}`);
}
console.log(` ✓ ${a.filename} HTTP ${res.status} ${(await res.arrayBuffer()).byteLength} bytes`);
}
// ── 6. Verify dedup still works for the same message_id
const looked = await findQuoteByMessageId(MSG_ID);
if (!looked || looked.id !== r.quote.id) {
throw new Error("FAIL — message_id lookup should still return the same quote");
}
console.log(`\nALL ATTACHMENT SMOKE CHECKS PASSED.`);
console.log(`Quote ${r.quote_number} (id=${r.quote.id}) has 3 attachments.`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -17,8 +17,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ArrowLeft,
import {
ArrowLeft,
Trash2,
FileText,
FileDown,
@ -34,14 +34,15 @@ import {
RefreshCw,
Eye,
Ban,
Plus,
ShoppingCart,
} from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { createOrderFromQuote, getOrderByQuote, getQuote, getQuoteItems, formatDate, formatCurrency } from "@/lib/pb-helpers";
import { quoteStatusClassName } from "@/lib/quote-status";
import type { Quote, QuoteItem } from "@/lib/types";
Plus,
ShoppingCart,
} from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { createOrderFromQuote, getOrderByQuote, getQuote, getQuoteItems, getQuoteAttachments, formatDate, formatCurrency } from "@/lib/pb-helpers";
import { quoteStatusClassName } from "@/lib/quote-status";
import QuoteEmailCard from "@/components/quote-email-card";
import type { Quote, QuoteItem, QuoteAttachment } from "@/lib/types";
const statusLabels: Record<string, string> = {
new: "New",
@ -64,13 +65,14 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
const router = useRouter();
const [quote, setQuote] = useState<Quote | null>(null);
const [items, setItems] = useState<QuoteItem[]>([]);
const [attachments, setAttachments] = useState<QuoteAttachment[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [showReject, setShowReject] = useState(false);
const [linkedOrderId, setLinkedOrderId] = useState<string | null>(null);
const [creatingOrder, setCreatingOrder] = useState(false);
const [showReject, setShowReject] = useState(false);
const [linkedOrderId, setLinkedOrderId] = useState<string | null>(null);
const [creatingOrder, setCreatingOrder] = useState(false);
const [addingItem, setAddingItem] = useState(false);
const [newItemDesc, setNewItemDesc] = useState("");
const [newItemQty, setNewItemQty] = useState(1);
@ -78,12 +80,14 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
const fetchQuote = useCallback(async () => {
setLoading(true);
try {
const data = await getQuote(id);
setQuote(data);
const quoteItems = await getQuoteItems(id);
setItems(quoteItems);
const linkedOrder = await getOrderByQuote(id);
setLinkedOrderId(linkedOrder?.id || null);
const data = await getQuote(id);
setQuote(data);
const quoteItems = await getQuoteItems(id);
setItems(quoteItems);
const quoteAttachments = await getQuoteAttachments(id);
setAttachments(quoteAttachments);
const linkedOrder = await getOrderByQuote(id);
setLinkedOrderId(linkedOrder?.id || null);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load quote";
toast.error(message);
@ -169,7 +173,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
}
};
const handleAddItem = async () => {
const handleAddItem = async () => {
if (!newItemDesc.trim()) {
toast.error("Item description is required");
return;
@ -194,24 +198,24 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
} finally {
setAddingItem(false);
}
};
const handleCreateOrder = async () => {
setCreatingOrder(true);
try {
const result = await createOrderFromQuote(id);
setLinkedOrderId(result.order.id);
if (result.alreadyExists) {
toast.info("Order already exists for this quote");
} else {
toast.success("Order created from quote");
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to create order");
} finally {
setCreatingOrder(false);
}
};
};
const handleCreateOrder = async () => {
setCreatingOrder(true);
try {
const result = await createOrderFromQuote(id);
setLinkedOrderId(result.order.id);
if (result.alreadyExists) {
toast.info("Order already exists for this quote");
} else {
toast.success("Order created from quote");
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to create order");
} finally {
setCreatingOrder(false);
}
};
if (loading) {
return (
@ -343,9 +347,9 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold tracking-tight truncate">{quote.quote_number}</h1>
<div className="flex flex-wrap items-center gap-2 mt-1">
<Badge variant="outline" className={quoteStatusClassName(quote.status)}>
{statusLabels[quote.status] || quote.status}
</Badge>
<Badge variant="outline" className={quoteStatusClassName(quote.status)}>
{statusLabels[quote.status] || quote.status}
</Badge>
{quote.rfq_number && <span className="text-xs text-muted-foreground">RFQ: {quote.rfq_number}</span>}
{quote.claimed_by && (
<span className="text-xs text-muted-foreground">
@ -354,19 +358,19 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
)}
</div>
</div>
<div className="flex gap-2 flex-wrap shrink-0">{renderActions()}</div>
{linkedOrderId ? (
<Button variant="outline" size="sm" onClick={() => router.push(`/orders/${linkedOrderId}`)}>
<ShoppingCart className="mr-2 h-4 w-4" />
View Order
</Button>
) : (
<Button variant="default" size="sm" onClick={handleCreateOrder} disabled={creatingOrder || items.length === 0}>
<ShoppingCart className="mr-2 h-4 w-4" />
{creatingOrder ? "Creating..." : "Create Order"}
</Button>
)}
</div>
<div className="flex gap-2 flex-wrap shrink-0">{renderActions()}</div>
{linkedOrderId ? (
<Button variant="outline" size="sm" onClick={() => router.push(`/orders/${linkedOrderId}`)}>
<ShoppingCart className="mr-2 h-4 w-4" />
View Order
</Button>
) : (
<Button variant="default" size="sm" onClick={handleCreateOrder} disabled={creatingOrder || items.length === 0}>
<ShoppingCart className="mr-2 h-4 w-4" />
{creatingOrder ? "Creating..." : "Create Order"}
</Button>
)}
</div>
{showReject && (
<Card className="border-destructive">
@ -547,6 +551,8 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
</CardContent>
</Card>
<QuoteEmailCard quote={quote} attachments={attachments} />
{quote.notes && (
<Card>
<CardHeader>

View file

@ -11,10 +11,11 @@ import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, FileText, CheckCircle, Archive } from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { getQuotes, formatDate } from "@/lib/pb-helpers";
import { formatQuoteStatus, quoteStatusClassName } from "@/lib/quote-status";
import type { Quote } from "@/lib/types";
import { cn } from "@/lib/utils";
import getPocketBase from "@/lib/pocketbase";
import { getQuotes, getQuoteStatusCounts, formatDate } from "@/lib/pb-helpers";
import { formatQuoteStatus, quoteStatusClassName } from "@/lib/quote-status";
import type { Quote } from "@/lib/types";
const tabFilters: Record<string, string> = {
all: "",
@ -28,6 +29,28 @@ const tabFilters: Record<string, string> = {
problem: "problem",
};
/**
* Per-status color tokens for the filter tabs. Mirrors the badge palette
* in `lib/quote-status.ts` so the tab and the row badge stay in sync.
* The "all" tab uses neutral so it doesn't compete with status colors.
*
* Uses `data-active:` (the @base-ui/react convention) rather than
* `data-[selected]:` — base-ui sets a `data-active` boolean attribute on
* the active tab; `data-[selected]` is never set, so that selector
* matches nothing.
*/
const tabColorClasses: Record<string, string> = {
all: "data-[active]:bg-muted data-[active]:text-foreground data-[active]:border-muted",
new: "data-[active]:bg-blue-50 data-[active]:text-blue-700 data-[active]:border-blue-200 dark:data-[active]:bg-blue-500/15 dark:data-[active]:text-blue-200 dark:data-[active]:border-blue-500/30",
claimed: "data-[active]:bg-cyan-50 data-[active]:text-cyan-700 data-[active]:border-cyan-200 dark:data-[active]:bg-cyan-500/15 dark:data-[active]:text-cyan-200 dark:data-[active]:border-cyan-500/30",
processed: "data-[active]:bg-indigo-50 data-[active]:text-indigo-700 data-[active]:border-indigo-200 dark:data-[active]:bg-indigo-500/15 dark:data-[active]:text-indigo-200 dark:data-[active]:border-indigo-500/30",
emailed: "data-[active]:bg-sky-50 data-[active]:text-sky-700 data-[active]:border-sky-200 dark:data-[active]:bg-sky-500/15 dark:data-[active]:text-sky-200 dark:data-[active]:border-sky-500/30",
approval_pending: "data-[active]:bg-amber-50 data-[active]:text-amber-800 data-[active]:border-amber-200 dark:data-[active]:bg-amber-500/15 dark:data-[active]:text-amber-200 dark:data-[active]:border-amber-500/30",
approved: "data-[active]:bg-emerald-50 data-[active]:text-emerald-700 data-[active]:border-emerald-200 dark:data-[active]:bg-emerald-500/15 dark:data-[active]:text-emerald-200 dark:data-[active]:border-emerald-500/30",
complete: "data-[active]:bg-green-50 data-[active]:text-green-700 data-[active]:border-green-200 dark:data-[active]:bg-green-500/15 dark:data-[active]:text-green-200 dark:data-[active]:border-green-500/30",
problem: "data-[active]:bg-rose-50 data-[active]:text-rose-700 data-[active]:border-rose-200 dark:data-[active]:bg-rose-500/15 dark:data-[active]:text-rose-200 dark:data-[active]:border-rose-500/30",
};
export default function QuotesPage() {
const router = useRouter();
const [quotes, setQuotes] = useState<Quote[]>([]);
@ -37,6 +60,7 @@ export default function QuotesPage() {
const [totalPages, setTotalPages] = useState(1);
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [counts, setCounts] = useState<Record<string, number>>({});
const [formOpen, setFormOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteQuote, setDeleteQuote] = useState<Quote | null>(null);
@ -47,9 +71,13 @@ export default function QuotesPage() {
setLoading(true);
setError("");
try {
const result = await getQuotes(page, 20, search, "-created", statusFilter || undefined);
const [result, statusCounts] = await Promise.all([
getQuotes(page, 20, search, "-created", statusFilter || undefined),
getQuoteStatusCounts(),
]);
setQuotes(result.items);
setTotalPages(result.totalPages);
setCounts(statusCounts);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load quotes";
setError(message);
@ -113,9 +141,25 @@ export default function QuotesPage() {
<TabsTrigger
key={tab.key}
value={tab.key}
className="h-7 text-xs data-[selected]:bg-primary data-[selected]:text-primary-foreground"
className={cn(
"h-7 text-xs",
tabColorClasses[tab.key]
)}
>
{tab.label}
{typeof counts[tab.key] === "number" && (
<span
className={cn(
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 text-[10px] font-semibold leading-4 min-w-5",
activeTab === tab.key
? "bg-black/10 dark:bg-white/15"
: "bg-muted text-muted-foreground"
)}
data-testid={`tab-count-${tab.key}`}
>
{counts[tab.key]}
</span>
)}
</TabsTrigger>
))}
</TabsList>
@ -144,14 +188,14 @@ export default function QuotesPage() {
render: (item: Quote) => <span>{item.expand?.company?.company || "—"}</span>,
},
{
key: "status",
label: "Status",
render: (item: Quote) => (
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
),
},
key: "status",
label: "Status",
render: (item: Quote) => (
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
),
},
{
key: "letterhead",
label: "Letterhead",
@ -182,9 +226,9 @@ export default function QuotesPage() {
<p className="font-medium">{item.quote_number}</p>
<p className="text-sm text-muted-foreground">{item.expand?.customer?.customer || "—"}</p>
</div>
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">{formatDate(item.created)}</span>

View file

@ -20,6 +20,17 @@ vi.mock("@/lib/server/orders-service", () => ({
getOrderItemsCollectionName: () => getOrderItemsCollectionName(),
}));
const createQuoteFromEmail = vi.fn();
const appendReplyToQuote = vi.fn();
const findQuoteByMessageId = vi.fn();
const uploadAttachmentForQuote = vi.fn();
vi.mock("@/lib/server/quotes-service", () => ({
createQuoteFromEmail: (...args: unknown[]) => createQuoteFromEmail(...args),
appendReplyToQuote: (...args: unknown[]) => appendReplyToQuote(...args),
findQuoteByMessageId: (...args: unknown[]) => findQuoteByMessageId(...args),
uploadAttachmentForQuote: (...args: unknown[]) => uploadAttachmentForQuote(...args),
}));
import { POST } from "./route";
function mcpRequest(body: unknown) {
@ -40,6 +51,9 @@ describe("POST /api/mcp", () => {
listCollectionPool.mockReset();
getOrderItemsCollectionName.mockReset();
getOrderItemsCollectionName.mockReturnValue("order_items");
createQuoteFromEmail.mockReset();
appendReplyToQuote.mockReset();
findQuoteByMessageId.mockReset();
});
it("401s without bearer token", async () => {
@ -65,7 +79,7 @@ describe("POST /api/mcp", () => {
expect(res.status).toBe(401);
});
it("tools/list returns the four registered tools", async () => {
it("tools/list returns the seven registered tools", async () => {
const res = await POST(mcpRequest({ method: "tools/list", id: 1 }));
expect(res.status).toBe(200);
const body = await res.json();
@ -77,6 +91,10 @@ describe("POST /api/mcp", () => {
"orders.create_from_quote",
"collection_pool.list",
"order_items.update_status",
"quotes.create_from_email",
"quotes.append_reply",
"quotes.find_by_message_id",
"attachments.upload_for_quote",
]);
});
@ -183,6 +201,142 @@ describe("POST /api/mcp", () => {
expect(body.error.message).toMatch(/Unknown tool/);
});
it("tools/call quotes.create_from_email delegates to the service with the full payload", async () => {
createQuoteFromEmail.mockResolvedValueOnce({
quote: { id: "q1" },
customer: { id: "c1" },
company: { id: "co1" },
items: 3,
attachments: 1,
deduped: false,
quote_number: "Q-2026-06-001",
});
const res = await POST(
mcpRequest({
method: "tools/call",
id: 9,
params: {
name: "quotes.create_from_email",
arguments: {
from_email: "buyer@newco.co.za",
from_name: "Buyer Person",
subject: "RFQ: Mining equipment",
body_plain: "Please quote on the following.",
message_id: "<abc@example.com>",
company_hint: "NewCo Mining",
extracted_items: [
{ description: "20mm cable ties (pack of 100)", quantity: 5 },
{ description: "Cable gland M20", quantity: 10 },
],
attachments: [
{ filename: "rfq.pdf", content_type: "application/pdf", base64: "AA==" },
],
},
},
})
);
expect(res.status).toBe(200);
expect(createQuoteFromEmail).toHaveBeenCalledTimes(1);
const passedArg = createQuoteFromEmail.mock.calls[0][0];
expect(passedArg.from_email).toBe("buyer@newco.co.za");
expect(passedArg.message_id).toBe("<abc@example.com>");
expect(passedArg.extracted_items).toHaveLength(2);
expect(passedArg.attachments).toHaveLength(1);
expect(passedArg.attachments[0].filename).toBe("rfq.pdf");
});
it("tools/call quotes.append_reply delegates to the service and surfaces matched_by", async () => {
appendReplyToQuote.mockResolvedValueOnce({
quote: { id: "q1", quote_number: "Q-2026-06-001" },
reply: { id: "r1" },
matched_by: "in_reply_to",
});
const res = await POST(
mcpRequest({
method: "tools/call",
id: 10,
params: {
name: "quotes.append_reply",
arguments: {
from_email: "buyer@newco.co.za",
subject: "Re: Quotation Q-2026-06-001",
body_plain: "Approved, please proceed.",
message_id: "<reply1@example.com>",
in_reply_to: "<abc@example.com>",
received_at: "2026-06-11T10:00:00.000Z",
},
},
})
);
expect(res.status).toBe(200);
expect(appendReplyToQuote).toHaveBeenCalledTimes(1);
const body = await res.json();
expect(JSON.parse(body.result.content[0].text).matched_by).toBe("in_reply_to");
});
it("tools/call quotes.append_reply returns matched_by='none' for an unmatched reply", async () => {
appendReplyToQuote.mockResolvedValueOnce({
quote: null,
reply: null,
matched_by: "none",
});
const res = await POST(
mcpRequest({
method: "tools/call",
id: 11,
params: {
name: "quotes.append_reply",
arguments: { from_email: "stranger@x.com", subject: "Random reply" },
},
})
);
expect(res.status).toBe(200);
const body = await res.json();
expect(JSON.parse(body.result.content[0].text).matched_by).toBe("none");
});
it("tools/call quotes.find_by_message_id returns the matching quote", async () => {
findQuoteByMessageId.mockResolvedValueOnce({ id: "q1", quote_number: "Q-2026-06-001" });
const res = await POST(
mcpRequest({
method: "tools/call",
id: 12,
params: { name: "quotes.find_by_message_id", arguments: { message_id: "<abc@example.com>" } },
})
);
expect(res.status).toBe(200);
expect(findQuoteByMessageId).toHaveBeenCalledWith("<abc@example.com>");
const body = await res.json();
expect(JSON.parse(body.result.content[0].text).quote.id).toBe("q1");
});
it("tools/call quotes.find_by_message_id fails fast when message_id is missing", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 13,
params: { name: "quotes.find_by_message_id", arguments: {} },
})
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toMatch(/Missing message_id/);
});
it("tools/call quotes.find_by_message_id returns null quote when no match", async () => {
findQuoteByMessageId.mockResolvedValueOnce(null);
const res = await POST(
mcpRequest({
method: "tools/call",
id: 14,
params: { name: "quotes.find_by_message_id", arguments: { message_id: "<missing@example.com>" } },
})
);
expect(res.status).toBe(200);
const body = await res.json();
expect(JSON.parse(body.result.content[0].text).quote).toBeNull();
});
it("returns method-not-found for unknown JSON-RPC methods", async () => {
const res = await POST(mcpRequest({ method: "tools/wat", id: 8 }));
expect(res.status).toBe(404);

View file

@ -3,6 +3,14 @@ import { assertAgentAuth } from "@/lib/server/agent-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { normalizeOrderItemStatusInput } from "@/lib/server/order-item-status";
import { createOrderFromQuote, getOrderItemsCollectionName, listCollectionPool } from "@/lib/server/orders-service";
import {
appendReplyToQuote,
createQuoteFromEmail,
findQuoteByMessageId,
uploadAttachmentForQuote,
type EmailIntakePayload,
type EmailReplyPayload,
} from "@/lib/server/quotes-service";
const tools = [
{
@ -55,6 +63,100 @@ const tools = [
},
},
},
{
name: "quotes.create_from_email",
description:
"Create a new quote (with customer/company/items) from a parsed email payload. Idempotent on message_id. `from_email` is optional — if missing (e.g. an email with no From: header), the quote is linked to a sentinel 'Unknown Sender' customer (`unknown-sender@no-from.wfdash.local`) that the quoter reassigns in the existing quote-editing form. `message_id` is also optional in the same spirit — a stable `synth-…@wfdash.local` id is derived from the email identity when missing. The legacy `attachments` field is accepted for back-compat but no longer uploaded from this call — use `attachments.upload_for_quote` once per file after this call returns the new quote id.",
inputSchema: {
type: "object",
required: ["subject"],
properties: {
from_email: { type: "string" },
from_name: { type: "string" },
subject: { type: "string" },
body_plain: { type: "string" },
body_html: { type: "string" },
message_id: { type: "string" },
in_reply_to: { type: "string" },
received_at: { type: "string" },
company_hint: { type: "string" },
extracted_items: {
type: "array",
items: {
type: "object",
properties: {
description: { type: "string" },
quantity: { type: "number" },
unit: { type: "string" },
notes: { type: "string" },
},
},
},
attachments: {
type: "array",
description:
"Legacy field, accepted but no longer acted on. Use attachments.upload_for_quote for each file instead.",
items: {
type: "object",
properties: {
filename: { type: "string" },
content_type: { type: "string" },
size: { type: "number" },
base64: { type: "string" },
},
},
},
},
},
},
{
name: "quotes.append_reply",
description:
"Append a structured reply to an existing quote. Matches by In-Reply-To header first, then by Q-YYYY-MM-NNN in the subject.",
inputSchema: {
type: "object",
required: ["from_email", "subject"],
properties: {
from_email: { type: "string" },
from_name: { type: "string" },
subject: { type: "string" },
body_plain: { type: "string" },
body_html: { type: "string" },
message_id: { type: "string" },
in_reply_to: { type: "string" },
received_at: { type: "string" },
quoted_text: { type: "string" },
},
},
},
{
name: "quotes.find_by_message_id",
description: "Look up a quote by the inbound email's RFC 5322 Message-ID header.",
inputSchema: {
type: "object",
required: ["message_id"],
properties: {
message_id: { type: "string" },
},
},
},
{
name: "attachments.upload_for_quote",
description:
"Upload a single attachment (PDF, image, Word, Excel, CSV, plain text, or any other binary up to 25 MB) to an existing quote. The caller (n8n) should call this once per attachment AFTER `quotes.create_from_email` returns the new quote id. Errors are returned per-attachment so a single bad file does not roll back the quote. `base64` is the raw file bytes encoded as base64 (no `data:` URL prefix).",
inputSchema: {
type: "object",
required: ["quote_id", "filename", "base64"],
properties: {
quote_id: { type: "string" },
filename: { type: "string" },
content_type: { type: "string" },
size: { type: "number" },
base64: { type: "string" },
message_id: { type: "string" },
},
},
},
];
export async function POST(req: NextRequest) {
@ -130,6 +232,79 @@ export async function POST(req: NextRequest) {
});
}
if (toolName === "quotes.create_from_email") {
const payload: EmailIntakePayload = {
from_email: args.from_email,
from_name: args.from_name,
subject: args.subject,
body_plain: args.body_plain,
body_html: args.body_html,
message_id: args.message_id,
in_reply_to: args.in_reply_to,
received_at: args.received_at,
company_hint: args.company_hint,
extracted_items: args.extracted_items,
attachments: args.attachments,
};
const result = await createQuoteFromEmail(payload);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "quotes.append_reply") {
const payload: EmailReplyPayload = {
from_email: args.from_email,
from_name: args.from_name,
subject: args.subject,
body_plain: args.body_plain,
body_html: args.body_html,
message_id: args.message_id,
in_reply_to: args.in_reply_to,
received_at: args.received_at,
quoted_text: args.quoted_text,
};
const result = await appendReplyToQuote(payload);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "quotes.find_by_message_id") {
if (!args.message_id) {
throw new Error("Missing message_id for quotes.find_by_message_id.");
}
const quote = await findQuoteByMessageId(args.message_id);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify({ quote }) }] },
});
}
if (toolName === "attachments.upload_for_quote") {
if (!args.quote_id) throw new Error("Missing quote_id for attachments.upload_for_quote.");
if (!args.filename) throw new Error("Missing filename for attachments.upload_for_quote.");
if (!args.base64) throw new Error("Missing base64 for attachments.upload_for_quote.");
const attachment = await uploadAttachmentForQuote({
quoteId: args.quote_id,
filename: args.filename,
contentType: args.content_type,
size: args.size,
base64: args.base64,
messageId: args.message_id,
});
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify({ attachment }) }] },
});
}
throw new Error(`Unknown tool: ${toolName}`);
}

View file

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { generateQuoteNumberServer } from "@/lib/server/quotes-service";
/**
* GET /api/v1/quotes/next-number
*
* Returns the next quote number (`Q-YYYY-MM-NNN`) for the current month.
* The number is reserved atomically via the `quote_counters` table — by the
* time the caller receives the response, that sequence is taken. If the
* caller never creates the quote, the sequence still advances; this is the
* standard "monotonic counter, gaps possible" trade-off, and is much
* simpler than reserving + releasing.
*
* Auth: Bearer AGENT_API_KEY (same as the other v1 routes).
*/
export async function GET(req: NextRequest) {
try {
assertAgentAuth(req);
const quote_number = await generateQuoteNumberServer();
return NextResponse.json({ quote_number });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to generate quote number";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,202 @@
"use client";
/**
* QuoteEmailCard — shows the original email (subject, sender, received_at,
* plain-text body) and the list of attachments for a quote that was created
* from an inbound email. Renders nothing for manually-created quotes (no
* email data) so the page stays clean.
*
* The quoter uses this card to verify the AI-extracted line items against
* what the original email actually said.
*/
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Mail,
User,
Calendar,
FileText,
FileImage,
File,
Download,
Paperclip,
} from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import type { Quote, QuoteAttachment, QuoteAttachmentKind } from "@/lib/types";
interface QuoteEmailCardProps {
quote: Quote;
attachments: QuoteAttachment[];
}
function formatBytes(bytes: number): string {
if (!bytes || bytes < 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatReceivedAt(iso: string): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function attachmentIcon(kind: QuoteAttachmentKind) {
if (kind === "image") return FileImage;
if (kind === "pdf" || kind === "doc") return FileText;
return File;
}
/**
* Build a download URL for a quote_attachments row. PocketBase serves
* files at `/api/files/{collection}/{recordId}/{filename}` and accepts the
* user's auth token as a `?token=` query param. The auth provider stores
* the token in localStorage, so a plain <a href> won't carry the cookie
* — we embed the token explicitly.
*/
function attachmentDownloadUrl(attachment: QuoteAttachment): string {
const pb = getPocketBase();
const token = pb.authStore.token;
const base = pb.files.getUrl(attachment, attachment.file, {
query: token ? { token } : undefined,
});
// pb.files.getUrl returns a relative path like
// `/api/files/quote_attachments/abc123/foo.pdf?token=...`. Prepend the
// PB base URL so the browser can hit it.
const baseUrl =
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
if (base.startsWith("http")) return base;
return `${baseUrl.replace(/\/$/, "")}${base}`;
}
export default function QuoteEmailCard({
quote,
attachments,
}: QuoteEmailCardProps) {
const hasBody = !!(quote.email_body && quote.email_body.trim());
const hasAttachments = attachments.length > 0;
// Hide the card entirely when there is no email data — manually-created
// quotes never have email data and shouldn't see an empty card.
if (!hasBody && !hasAttachments) return null;
const senderDisplay = quote.email_sender || "(unknown sender)";
return (
<Card data-size="default">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
Original Email
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Metadata: subject, sender, received_at */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
<div className="flex items-start gap-2">
<Mail className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-xs text-muted-foreground">Subject</div>
<div
className="font-medium break-words"
data-testid="email-subject"
>
{quote.email_subject || "(no subject)"}
</div>
</div>
</div>
<div className="flex items-start gap-2">
<User className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-xs text-muted-foreground">From</div>
<div
className="font-medium break-words"
data-testid="email-sender"
>
{senderDisplay}
</div>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-xs text-muted-foreground">Received</div>
<div
className="font-medium break-words"
data-testid="email-received-at"
>
{formatReceivedAt(quote.email_delivered_at || "")}
</div>
</div>
</div>
</div>
{/* Plain-text body — what the AI saw. The quoter reads this to
verify the line items below. */}
{hasBody && (
<div>
<div className="text-xs text-muted-foreground mb-1">Body</div>
<pre
className="whitespace-pre-wrap text-sm bg-muted p-4 rounded-md max-h-96 overflow-y-auto font-sans"
data-testid="email-body"
>
{quote.email_body}
</pre>
</div>
)}
{/* Attachments — list with download links. PB serves files
directly via the SDK; we just need the auth token. */}
{hasAttachments && (
<div>
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Paperclip className="h-3 w-3" />
Attachments ({attachments.length})
</div>
<ul className="space-y-1.5" data-testid="email-attachments">
{attachments.map((a) => {
const Icon = attachmentIcon(a.kind);
return (
<li
key={a.id}
className="flex items-center gap-3 px-3 py-2 rounded-md border bg-card hover:bg-accent/30 transition-colors"
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{a.filename}
</div>
<div className="text-xs text-muted-foreground">
{a.kind.toUpperCase()} · {formatBytes(a.size_bytes)}
{a.content_type ? ` · ${a.content_type}` : ""}
</div>
</div>
<a
href={attachmentDownloadUrl(a)}
target="_blank"
rel="noopener noreferrer"
download={a.filename}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline shrink-0"
data-testid="attachment-download"
>
<Download className="h-3 w-3" />
Download
</a>
</li>
);
})}
</ul>
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -1,5 +1,5 @@
import getPocketBase from "./pocketbase";
import type { Customer, Company, Supplier, Quote, QuoteItem, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair } from "./types";
import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair } from "./types";
export async function getCustomers(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
@ -77,6 +77,40 @@ export async function getQuote(id: string) {
return pb.collection("quotes").getOne<Quote>(id, { expand: "customer,company" });
}
/**
* Fetch a per-status count of all quotes. Used by the status filter tabs
* on the quotes list page to show "New (3)" / "Problem (1)" badges.
*
* One PB call per status (with `perPage: 1` so we only transfer a count,
* not records), plus one for the unfiltered "all" total. The "all" total
* is fetched independently because the sum of per-status counts can
* disagree with PB's unfiltered total when statuses change mid-query.
*/
export async function getQuoteStatusCounts() {
const pb = getPocketBase();
const statuses = [
"new",
"claimed",
"processed",
"emailed",
"approval_pending",
"approved",
"complete",
"problem",
];
const [all, ...perStatus] = await Promise.all([
pb.collection("quotes").getList<Quote>(1, 1),
...statuses.map((status) =>
pb.collection("quotes").getList<Quote>(1, 1, { filter: `status = "${status}"` })
),
]);
const counts: Record<string, number> = { all: all.totalItems };
statuses.forEach((status, i) => {
counts[status] = perStatus[i].totalItems;
});
return counts;
}
export async function getQuoteItems(quoteId: string) {
const pb = getPocketBase();
return pb.collection("quote_items").getFullList<QuoteItem>({
@ -85,6 +119,23 @@ export async function getQuoteItems(quoteId: string) {
});
}
/**
* Fetch all email attachments saved against a quote. Used by the
* "Original Email" card on the quote detail page so the quoter can
* download the files that came in with the original RFQ email.
*
* Separate query rather than a reverse-relation expand because PB's
* expand syntax doesn't easily cover reverse relations, and the
* `quote_attachments` collection is small per quote (usually 0–3 rows).
*/
export async function getQuoteAttachments(quoteId: string) {
const pb = getPocketBase();
return pb.collection("quote_attachments").getFullList<QuoteAttachment>({
filter: `quote = "${quoteId}"`,
sort: "-uploaded_at",
});
}
export async function getOrders(page = 1, perPage = 50, search = "", sort = "-created", status?: string) {
const pb = getPocketBase();
const filters: string[] = [];
@ -102,291 +153,298 @@ export async function getOrders(page = 1, perPage = 50, search = "", sort = "-cr
});
}
export async function getOrder(id: string) {
const pb = getPocketBase();
return pb.collection("orders").getOne<Order>(id, { expand: "company,customer,quote" });
}
export async function getOrderByQuote(quoteId: string) {
const pb = getPocketBase();
try {
return await pb.collection("orders").getFirstListItem<Order>(`quote = "${quoteId}"`, {
expand: "company,customer,quote",
});
} catch {
return null;
}
}
export async function getOrder(id: string) {
const pb = getPocketBase();
return pb.collection("orders").getOne<Order>(id, { expand: "company,customer,quote" });
}
export async function getOrderItems(orderId: string) {
const pb = getPocketBase();
return pb.collection("order_items").getFullList<OrderItem>({
filter: `order = "${orderId}"`,
expand: "supplier",
});
}
export async function createOrderFromQuote(quoteId: string) {
const pb = getPocketBase();
const existingOrder = await getOrderByQuote(quoteId);
if (existingOrder) {
return { order: existingOrder, alreadyExists: true };
}
const quote = await pb.collection("quotes").getOne<Quote>(quoteId);
const quoteItems = await pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote = "${quoteId}"`,
sort: "created",
});
if (quoteItems.length === 0) {
throw new Error("Quote has no items to convert.");
}
const orderNumber = await generateOrderNumber();
const order = await pb.collection("orders").create<Order>({
order_number: orderNumber,
company: quote.company,
customer: quote.customer || "",
quote: quote.id,
status: "new",
notes: quote.notes || "",
quote_matching_attempted: true,
quote_match_confidence: 1,
potential_quote: quote.id,
potential_quote_confidence: 1,
is_repair_order: false,
});
for (const item of quoteItems) {
const quantity = Number(item.quantity || 1);
await pb.collection("order_items").create({
order: order.id,
description: item.description || "",
po_description: item.description || "",
quantity,
order_qty: quantity,
selling_price: Number(item.selling_price || 0),
supplier: item.supplier || null,
cost_price: Number(item.cost_price || 0),
markup: Number(item.markup || 0),
markup_percent: Number(item.markup || 0),
item_status: "pending",
drv_status: "unassigned",
driver_status: "unassigned",
notes: item.notes || "",
quote_item: item.id,
});
}
return { order, alreadyExists: false };
}
export async function getCollectionPool(page = 1, perPage = 50, status = "") {
const pb = getPocketBase();
const filter = status ? `pool_status = "${status}"` : "";
return pb.collection("collection_pool").getList<CollectionPool>(page, perPage, {
filter,
sort: "-created",
expand: "order_item,order,supplier,drv_id",
});
}
export async function queueOrderItemForCollection(item: OrderItem, orderId: string) {
const pb = getPocketBase();
const existing = await pb.collection("collection_pool").getList<CollectionPool>(1, 1, {
filter: `order_item = "${item.id}" && pool_status != "completed" && pool_status != "failed"`,
});
if (existing.items.length === 0) {
await pb.collection("collection_pool").create({
order: orderId,
order_item: item.id,
supplier: item.supplier || null,
pool_status: "queued",
priority: "normal",
pool_notes: item.notes || "",
});
}
return pb.collection("order_items").update<OrderItem>(item.id, {
item_status: "driver_pool",
drv_status: "unassigned",
});
}
export async function updateOrderItemWorkflowStatus(
itemId: string,
patch: Partial<Pick<OrderItem, "item_status" | "drv_status" | "notes">>
) {
const pb = getPocketBase();
return pb.collection("order_items").update<OrderItem>(itemId, patch);
}
export async function updateCollectionPoolStatus(
poolItem: CollectionPool,
poolStatus: CollectionPool["pool_status"]
) {
const pb = getPocketBase();
const payload: Record<string, unknown> = { pool_status: poolStatus };
if (poolStatus === "assigned") {
payload.assigned_at = new Date().toISOString();
}
if (poolStatus === "completed") {
payload.completed_at = new Date().toISOString();
}
const updated = await pb.collection("collection_pool").update<CollectionPool>(poolItem.id, payload);
if (poolStatus === "assigned") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "assigned",
drv_status: "assigned",
});
}
if (poolStatus === "in_progress") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "en_route",
});
}
if (poolStatus === "completed") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "collected",
drv_status: "collected",
});
}
if (poolStatus === "failed") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "failed",
problem_flag: true,
problem_type: "driver",
});
}
return updated;
}
export async function getDeliveryNotes(page = 1, perPage = 50, search = "") {
const pb = getPocketBase();
const filter = search ? `delivery_note_number ~ "${search}" || notes ~ "${search}"` : "";
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
filter,
sort: "-created",
expand: "order,company,customer",
});
}
export async function generateDeliveryNoteNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `DN-${year}-${month}-`;
const existing = await pb.collection("delivery_notes").getList<DeliveryNote>(1, 1, {
filter: `delivery_note_number ~ "${prefix}"`,
sort: "-delivery_note_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].delivery_note_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function createDeliveryNoteFromOrder(order: Order, items: OrderItem[]) {
const pb = getPocketBase();
const eligible = items.filter((item) => ["ready_delivery", "delivered"].includes(item.item_status));
if (eligible.length === 0) {
throw new Error("No ready or delivered items available for a delivery note.");
}
const deliveryNoteNumber = await generateDeliveryNoteNumber();
const deliveryNote = await pb.collection("delivery_notes").create<DeliveryNote>({
delivery_note_number: deliveryNoteNumber,
order: order.id,
company: order.company || null,
customer: order.customer || null,
status: "draft",
notes: order.notes || "",
});
for (const item of eligible) {
await pb.collection("delivery_note_items").create({
delivery_note: deliveryNote.id,
order_item: item.id,
description: item.description,
quantity: item.quantity,
});
}
return deliveryNote;
}
export async function getRepairs(page = 1, perPage = 50, search = "", status = "") {
const pb = getPocketBase();
const filters: string[] = [];
if (search) {
filters.push(`repair_number ~ "${search}" || item_description ~ "${search}" || fault_description ~ "${search}"`);
}
if (status) {
filters.push(`status = "${status}"`);
}
return pb.collection("repairs").getList<Repair>(page, perPage, {
filter: filters.join(" && "),
sort: "-created",
expand: "customer,company,order",
});
}
export async function generateRepairNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `REP-${year}-${month}-`;
const existing = await pb.collection("repairs").getList<Repair>(1, 1, {
filter: `repair_number ~ "${prefix}"`,
sort: "-repair_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].repair_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function createRepair(data: {
customer?: string;
company?: string;
order?: string;
item_description: string;
fault_description: string;
notes?: string;
}) {
const pb = getPocketBase();
const repairNumber = await generateRepairNumber();
return pb.collection("repairs").create<Repair>({
repair_number: repairNumber,
customer: data.customer || null,
company: data.company || null,
order: data.order || null,
status: "new",
item_description: data.item_description,
fault_description: data.fault_description,
notes: data.notes || "",
});
}
export async function getOrderByQuote(quoteId: string) {
const pb = getPocketBase();
try {
return await pb.collection("orders").getFirstListItem<Order>(`quote = "${quoteId}"`, {
expand: "company,customer,quote",
});
} catch {
return null;
}
}
export async function getOrderItems(orderId: string) {
const pb = getPocketBase();
return pb.collection("order_items").getFullList<OrderItem>({
filter: `order = "${orderId}"`,
expand: "supplier",
});
}
export async function createOrderFromQuote(quoteId: string) {
const pb = getPocketBase();
const existingOrder = await getOrderByQuote(quoteId);
if (existingOrder) {
return { order: existingOrder, alreadyExists: true };
}
const quote = await pb.collection("quotes").getOne<Quote>(quoteId);
const quoteItems = await pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote = "${quoteId}"`,
sort: "created",
});
if (quoteItems.length === 0) {
throw new Error("Quote has no items to convert.");
}
const orderNumber = await generateOrderNumber();
const order = await pb.collection("orders").create<Order>({
order_number: orderNumber,
company: quote.company,
customer: quote.customer || "",
quote: quote.id,
status: "new",
notes: quote.notes || "",
quote_matching_attempted: true,
quote_match_confidence: 1,
potential_quote: quote.id,
potential_quote_confidence: 1,
is_repair_order: false,
});
for (const item of quoteItems) {
const quantity = Number(item.quantity || 1);
await pb.collection("order_items").create({
order: order.id,
description: item.description || "",
po_description: item.description || "",
quantity,
order_qty: quantity,
selling_price: Number(item.selling_price || 0),
supplier: item.supplier || null,
cost_price: Number(item.cost_price || 0),
markup: Number(item.markup || 0),
markup_percent: Number(item.markup || 0),
item_status: "pending",
drv_status: "unassigned",
driver_status: "unassigned",
notes: item.notes || "",
quote_item: item.id,
});
}
return { order, alreadyExists: false };
}
export async function getCollectionPool(page = 1, perPage = 50, status = "") {
const pb = getPocketBase();
const filter = status ? `pool_status = "${status}"` : "";
return pb.collection("collection_pool").getList<CollectionPool>(page, perPage, {
filter,
sort: "-created",
expand: "order_item,order,supplier,drv_id",
});
}
export async function queueOrderItemForCollection(item: OrderItem, orderId: string) {
const pb = getPocketBase();
const existing = await pb.collection("collection_pool").getList<CollectionPool>(1, 1, {
filter: `order_item = "${item.id}" && pool_status != "completed" && pool_status != "failed"`,
});
if (existing.items.length === 0) {
await pb.collection("collection_pool").create({
order: orderId,
order_item: item.id,
supplier: item.supplier || null,
pool_status: "queued",
priority: "normal",
pool_notes: item.notes || "",
});
}
return pb.collection("order_items").update<OrderItem>(item.id, {
item_status: "driver_pool",
drv_status: "unassigned",
});
}
export async function updateOrderItemWorkflowStatus(
itemId: string,
patch: Partial<Pick<OrderItem, "item_status" | "drv_status" | "notes">>
) {
const pb = getPocketBase();
return pb.collection("order_items").update<OrderItem>(itemId, patch);
}
export async function updateCollectionPoolStatus(
poolItem: CollectionPool,
poolStatus: CollectionPool["pool_status"]
) {
const pb = getPocketBase();
const payload: Record<string, unknown> = { pool_status: poolStatus };
if (poolStatus === "assigned") {
payload.assigned_at = new Date().toISOString();
}
if (poolStatus === "completed") {
payload.completed_at = new Date().toISOString();
}
const updated = await pb.collection("collection_pool").update<CollectionPool>(poolItem.id, payload);
if (poolStatus === "assigned") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "assigned",
drv_status: "assigned",
});
}
if (poolStatus === "in_progress") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "en_route",
});
}
if (poolStatus === "completed") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "collected",
drv_status: "collected",
});
}
if (poolStatus === "failed") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "failed",
problem_flag: true,
problem_type: "driver",
});
}
return updated;
}
export async function getDeliveryNotes(page = 1, perPage = 50, search = "") {
const pb = getPocketBase();
const filter = search ? `delivery_note_number ~ "${search}" || notes ~ "${search}"` : "";
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
filter,
sort: "-created",
expand: "order,company,customer",
});
}
export async function generateDeliveryNoteNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `DN-${year}-${month}-`;
const existing = await pb.collection("delivery_notes").getList<DeliveryNote>(1, 1, {
filter: `delivery_note_number ~ "${prefix}"`,
sort: "-delivery_note_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].delivery_note_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function createDeliveryNoteFromOrder(order: Order, items: OrderItem[]) {
const pb = getPocketBase();
const eligible = items.filter((item) => ["ready_delivery", "delivered"].includes(item.item_status));
if (eligible.length === 0) {
throw new Error("No ready or delivered items available for a delivery note.");
}
const deliveryNoteNumber = await generateDeliveryNoteNumber();
const deliveryNote = await pb.collection("delivery_notes").create<DeliveryNote>({
delivery_note_number: deliveryNoteNumber,
order: order.id,
company: order.company || null,
customer: order.customer || null,
status: "draft",
notes: order.notes || "",
});
for (const item of eligible) {
await pb.collection("delivery_note_items").create({
delivery_note: deliveryNote.id,
order_item: item.id,
description: item.description,
quantity: item.quantity,
});
}
return deliveryNote;
}
export async function getRepairs(page = 1, perPage = 50, search = "", status = "") {
const pb = getPocketBase();
const filters: string[] = [];
if (search) {
filters.push(`repair_number ~ "${search}" || item_description ~ "${search}" || fault_description ~ "${search}"`);
}
if (status) {
filters.push(`status = "${status}"`);
}
return pb.collection("repairs").getList<Repair>(page, perPage, {
filter: filters.join(" && "),
sort: "-created",
expand: "customer,company,order",
});
}
export async function generateRepairNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `REP-${year}-${month}-`;
const existing = await pb.collection("repairs").getList<Repair>(1, 1, {
filter: `repair_number ~ "${prefix}"`,
sort: "-repair_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].repair_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function createRepair(data: {
customer?: string;
company?: string;
order?: string;
item_description: string;
fault_description: string;
notes?: string;
}) {
const pb = getPocketBase();
const repairNumber = await generateRepairNumber();
return pb.collection("repairs").create<Repair>({
repair_number: repairNumber,
customer: data.customer || null,
company: data.company || null,
order: data.order || null,
status: "new",
item_description: data.item_description,
fault_description: data.fault_description,
notes: data.notes || "",
});
}
/**
* @deprecated For new code, call `/api/v1/quotes/next-number` instead. This
* client-side wrapper is kept for backwards compatibility with the quote
* form. The server endpoint uses an atomic counter (no race), while this
* client function reads-then-writes the last quote number (racy under
* concurrent creates).
*/
export async function generateQuoteNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();

View file

@ -0,0 +1,695 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// The mock state has to be hoisted to the top of the file because Vitest
// hoists `vi.mock` factories above all imports.
const { store, pbMock, clearStore } = vi.hoisted(() => {
const store: Record<string, Map<string, Record<string, unknown>>> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
const getOrCreate = (collection: string) => {
if (!store[collection]) store[collection] = new Map();
return store[collection];
};
const where = (collection: string, filter: string) => {
const map = getOrCreate(collection);
const items = Array.from(map.values());
if (!filter || !filter.trim()) return items;
const clauses = filter.split("&&").map((c) => c.trim());
return items.filter((item) =>
clauses.every((clause) => {
const m = clause.match(/^(\w+)\s*=\s*"?([^"]+)"?$/);
if (!m) return true;
const [, field, raw] = m;
const value = raw.replace(/^"|"$/g, "");
if (field === "year" || field === "month") {
return Number(item[field]) === Number(value);
}
return String(item[field] ?? "") === value;
})
);
};
const pbMock = {
collection: (name: string) => ({
async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) {
const items = where(name, opts.filter || "");
const start = (page - 1) * perPage;
return {
items: items.slice(start, start + perPage),
totalItems: items.length,
page,
perPage,
};
},
async getFullList(opts: { filter?: string; fields?: string } = {}) {
return where(name, opts.filter || "");
},
async getFirstListItem(filter: string) {
const items = where(name, filter);
if (items.length === 0) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return items[0];
},
async getOne(id: string) {
const item = getOrCreate(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
// Enforce the unique (year, month) index for quote_counters so the
// service's race-handling path can be exercised.
if (name === "quote_counters") {
const map = getOrCreate(name);
for (const item of map.values()) {
if (item.year === data.year && item.month === data.month) {
const err = new Error("unique constraint") as Error & { status: number };
err.status = 409;
throw err;
}
}
}
const item = {
id: nextId(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreate(name).set(item.id, item);
return item;
},
async update(id: string, data: Record<string, unknown>) {
const map = getOrCreate(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(data)) {
if (key.endsWith("+")) {
const real = key.slice(0, -1);
existing[real] = Number(existing[real] ?? 0) + Number(value);
} else {
existing[key] = value;
}
}
existing.updated = new Date().toISOString();
// Return a clone so concurrent callers each see the value at the
// moment their update ran, not the final mutated value.
return { ...existing };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import {
generateQuoteNumberServer,
findOrCreateCustomer,
findOrCreateCompany,
createQuoteFromEmail,
appendReplyToQuote,
findQuoteByMessageId,
extractQuoteNumberFromSubject,
normalizeCompanyName,
} from "./quotes-service";
beforeEach(() => {
clearStore();
});
describe("normalizeCompanyName", () => {
it("lowercases, trims, collapses whitespace, strips trailing punctuation", () => {
expect(normalizeCompanyName(" Acme Holdings. ")).toBe("acme");
});
it("strips common legal suffixes", () => {
expect(normalizeCompanyName("Acme Pty Ltd")).toBe("acme");
expect(normalizeCompanyName("Acme Proprietary Limited")).toBe("acme");
expect(normalizeCompanyName("Acme Corp.")).toBe("acme");
expect(normalizeCompanyName("Acme Incorporated")).toBe("acme");
});
it("returns empty string for blank input", () => {
expect(normalizeCompanyName("")).toBe("");
expect(normalizeCompanyName(" ")).toBe("");
});
});
describe("extractQuoteNumberFromSubject", () => {
it("extracts Q-YYYY-MM-NNN from a fresh quote subject", () => {
expect(extractQuoteNumberFromSubject("Quotation Q-2026-06-001")).toBe("Q-2026-06-001");
});
it("extracts from a reply subject (Re: prefix)", () => {
expect(extractQuoteNumberFromSubject("Re: Quotation Q-2026-06-001")).toBe("Q-2026-06-001");
});
it("returns null when no quote number is present", () => {
expect(extractQuoteNumberFromSubject("Just a normal email")).toBeNull();
expect(extractQuoteNumberFromSubject("Quote Q-26-6-1 (wrong format)")).toBeNull();
});
});
describe("generateQuoteNumberServer", () => {
it("starts the month at 001", async () => {
const n = await generateQuoteNumberServer();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
expect(n).toBe(`Q-${year}-${month}-001`);
});
it("increments on each call", async () => {
const a = await generateQuoteNumberServer();
const b = await generateQuoteNumberServer();
const c = await generateQuoteNumberServer();
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `Q-${now.getFullYear()}-${month}-`;
expect(a).toBe(`${prefix}001`);
expect(b).toBe(`${prefix}002`);
expect(c).toBe(`${prefix}003`);
});
it("produces unique numbers under concurrent calls (no race)", async () => {
const results = await Promise.all(
Array.from({ length: 10 }, () => generateQuoteNumberServer())
);
const unique = new Set(results);
expect(unique.size).toBe(10);
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `Q-${now.getFullYear()}-${month}-`;
for (const n of results) {
expect(n.startsWith(prefix)).toBe(true);
}
});
});
describe("findOrCreateCustomer", () => {
it("finds an existing customer by exact email match", async () => {
const existing = await pbMock.collection("customers").create({
customer: "Acme Mining",
email: "buyer@acme.co.za",
company: "Acme",
});
const { customer, created } = await findOrCreateCustomer({
email: "buyer@acme.co.za",
name: "Should Not Be Used",
});
expect(created).toBe(false);
expect(customer.id).toBe(existing.id);
expect(customer.customer).toBe("Acme Mining");
});
it("finds by alternative_email when primary email misses", async () => {
const existing = await pbMock.collection("customers").create({
customer: "Acme Mining",
email: "primary@acme.co.za",
alternative_email: "alt@acme.co.za",
company: "Acme",
});
const { customer, created } = await findOrCreateCustomer({ email: "alt@acme.co.za" });
expect(created).toBe(false);
expect(customer.id).toBe(existing.id);
});
it("finds by buyer_email when primary and alt miss", async () => {
const existing = await pbMock.collection("customers").create({
customer: "Acme Mining",
email: "primary@acme.co.za",
buyer_email: "buyer@acme.co.za",
company: "Acme",
});
const { customer, created } = await findOrCreateCustomer({ email: "buyer@acme.co.za" });
expect(created).toBe(false);
expect(customer.id).toBe(existing.id);
});
it("creates a new customer when no match exists", async () => {
const { customer, created } = await findOrCreateCustomer({
email: "new@unknown.co.za",
name: "New Person",
companyHint: "Unknown Co",
});
expect(created).toBe(true);
expect(customer.email).toBe("new@unknown.co.za");
expect(customer.customer).toBe("New Person");
expect(customer.company).toBe("Unknown Co");
expect(customer.description).toMatch(/Auto-created/);
});
it("never fuzzy-merges on company name", async () => {
// Two customers with similar emails from different domains should stay
// separate.
const a = await findOrCreateCustomer({ email: "john@acme.com" });
const b = await findOrCreateCustomer({ email: "jane@beta.com" });
expect(a.customer.id).not.toBe(b.customer.id);
});
it("throws when email is missing", async () => {
await expect(findOrCreateCustomer({ email: "" })).rejects.toThrow(/email is required/);
});
});
describe("findOrCreateCompany", () => {
it("finds an existing company by case-insensitive exact match", async () => {
const existing = await pbMock.collection("companies").create({
company: "Acme Holdings",
address: "1 Main Rd",
vendor: "",
});
const { company, created } = await findOrCreateCompany({ name: "acme holdings" });
expect(created).toBe(false);
expect(company?.id).toBe(existing.id);
});
it("finds by normalized match (stripped suffix)", async () => {
const existing = await pbMock.collection("companies").create({
company: "Acme",
address: "1 Main Rd",
vendor: "",
});
const { company, created } = await findOrCreateCompany({ name: "Acme Pty Ltd" });
expect(created).toBe(false);
expect(company?.id).toBe(existing.id);
});
it("creates a new company when no match", async () => {
const { company, created } = await findOrCreateCompany({
name: "Brand New Co",
address: "5 New St",
});
expect(created).toBe(true);
expect(company?.company).toBe("Brand New Co");
expect(company?.address).toBe("5 New St");
});
it("returns null company when name is blank", async () => {
const { company, created } = await findOrCreateCompany({ name: "" });
expect(company).toBeNull();
expect(created).toBe(false);
});
it("never fuzzy-merges on partial overlap", async () => {
await pbMock.collection("companies").create({
company: "Acme Mining Supplies",
address: "",
vendor: "",
});
const { created } = await findOrCreateCompany({ name: "Acme Tools" });
// "Acme" is shared but the full strings are different — must create.
expect(created).toBe(true);
});
});
describe("createQuoteFromEmail", () => {
const basePayload = {
from_email: "buyer@newco.co.za",
from_name: "Buyer Person",
subject: "RFQ: Mining equipment",
body_plain: "Please quote on the following items.",
message_id: "<abc123@example.com>",
in_reply_to: undefined as string | undefined,
received_at: new Date().toISOString(),
extracted_items: [
{ description: "20mm cable ties (pack of 100)", quantity: 5, unit: "pack" },
{ description: "Cable gland M20", quantity: 10, unit: "each" },
],
attachments: [
{
filename: "rfq.pdf",
content_type: "application/pdf",
size: 1024,
// 1 byte of base64 = "AA=="
base64: "AA==",
},
],
};
it("creates a new quote, customer, company, items, and attachments", async () => {
const result = await createQuoteFromEmail({
...basePayload,
company_hint: "NewCo Mining",
});
expect(result.deduped).toBe(false);
expect(result.quote_number).toMatch(/^Q-\d{4}-\d{2}-001$/);
expect(result.items).toBe(2);
expect(result.attachments).toBe(1);
expect(result.customer.email).toBe("buyer@newco.co.za");
expect(result.company?.company).toBe("NewCo Mining");
expect(result.quote.email_sender).toBe("buyer@newco.co.za");
expect(result.quote.email_subject).toBe("RFQ: Mining equipment");
expect(result.quote.email_message_id).toBe("<abc123@example.com>");
expect(result.quote.has_attachments).toBe(true);
expect(result.quote.status).toBe("new");
expect(result.quote.notes).toBe(""); // not a notes blob
});
it("marks every AI-extracted item with included_in_quote=false (human opt-in)", async () => {
const result = await createQuoteFromEmail(basePayload);
const items = await pbMock.collection("quote_items").getFullList({
filter: `quote = "${result.quote.id}"`,
});
expect(items.length).toBe(2);
for (const item of items) {
expect(item.included_in_quote).toBe(false);
expect(item.is_processed).toBe(false);
// No fabricated cost or selling price.
expect(Number(item.cost_price)).toBe(0);
expect(Number(item.selling_price)).toBe(0);
}
});
it("dedupes by email_message_id — second call returns the same quote", async () => {
const first = await createQuoteFromEmail(basePayload);
const second = await createQuoteFromEmail(basePayload);
expect(second.deduped).toBe(true);
expect(second.quote.id).toBe(first.quote.id);
expect(second.quote_number).toBe(first.quote_number);
});
it("matches an existing customer by email (does not create duplicate)", async () => {
const existing = await pbMock.collection("customers").create({
customer: "Existing Buyer",
email: "buyer@newco.co.za",
company: "Old Co",
});
const result = await createQuoteFromEmail(basePayload);
expect(result.customer.id).toBe(existing.id);
});
it("uses company_hint from payload when provided", async () => {
const result = await createQuoteFromEmail({
...basePayload,
company_hint: " Acme Pty Ltd ",
});
expect(result.company?.company).toBe("Acme Pty Ltd");
});
it("leaves company null when no company_hint is provided (no subject fallback)", async () => {
// The user explicitly does not want us to invent a company name from the
// email subject — that's the kind of fabrication we're trying to avoid.
// When the AI can't find a company in the body, the quoter picks it
// in the form.
const result = await createQuoteFromEmail({
...basePayload,
subject: "RFQ from Acme Mining",
});
expect(result.company).toBeNull();
});
it("tolerates missing from_email, message_id, and subject (treats empty subject as a real value)", async () => {
// The n8n Parse Email node stamps `(no subject)` when the inbound email
// has no Subject header, so WFDASH receives a real string. The schema
// invariant is "subject is a string", not "subject is non-empty" —
// and even an empty string is now tolerated at runtime, because the
// n8n side is contractually obligated to send a placeholder.
await expect(createQuoteFromEmail({ ...basePayload, message_id: "" })).resolves.toBeTruthy();
await expect(createQuoteFromEmail({ ...basePayload, from_email: "" })).resolves.toBeTruthy();
await expect(
createQuoteFromEmail({ ...basePayload, from_email: "", message_id: "" })
).resolves.toBeTruthy();
// Empty subject is now tolerated (was hard-rejected before). The
// office sees a placeholder quote in the queue.
await expect(createQuoteFromEmail({ ...basePayload, subject: "" })).resolves.toBeTruthy();
// Whitespace-only subject is also tolerated — the description is stored
// verbatim so a future regression that drops the Parse Email trim
// doesn't take the whole pipeline down.
await expect(createQuoteFromEmail({ ...basePayload, subject: " " })).resolves.toBeTruthy();
});
it("accepts a placeholder subject like '(no subject)' and stores it on the quote", async () => {
// The n8n side stamps `(no subject)` for emails with no Subject header.
// WFDASH must accept that literal string as a real subject, store it
// verbatim on the quote, and create the row so the office sees it.
const result = await createQuoteFromEmail({
...basePayload,
subject: "(no subject)",
body_plain: "",
extracted_items: [],
});
expect(result.quote.description).toBe("(no subject)");
expect(result.quote.email_subject).toBe("(no subject)");
expect(result.items).toBe(0);
});
it("placeholder subject + empty body + empty from_email all combine cleanly", async () => {
// A fully-blank RFC 5321-violating send: no subject, no body, no From.
// All three layers of tolerance combine: placeholder subject, no body,
// sentinel "Unknown Sender" customer. The quote is created.
const result = await createQuoteFromEmail({
from_email: "",
from_name: "",
subject: "(no subject)",
body_plain: "",
body_html: "",
message_id: "<blank-a@example.com>",
received_at: "2026-06-12T18:30:00.000Z",
extracted_items: [],
});
expect(result.customer.customer).toBe("Unknown Sender");
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
expect(result.quote.description).toBe("(no subject)");
expect(result.quote.email_subject).toBe("(no subject)");
expect(result.quote.email_sender).toBe("");
expect(result.items).toBe(0);
});
it("creates a quote with a sentinel 'Unknown Sender' customer when from_email is empty", async () => {
const result = await createQuoteFromEmail({ ...basePayload, from_email: "" });
expect(result.deduped).toBe(false);
expect(result.customer.customer).toBe("Unknown Sender");
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
// The literal empty string is stored, not a placeholder — the sentinel
// is only used for the `customer` relation.
expect(result.quote.email_sender).toBe("");
});
it("treats whitespace-only from_email as missing", async () => {
const result = await createQuoteFromEmail({ ...basePayload, from_email: " " });
expect(result.customer.customer).toBe("Unknown Sender");
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
});
it("two emails with no from_email both link to the same sentinel customer", async () => {
// Different subjects bypass the message_id dedup, so two distinct
// quotes are created — but both should reuse the same sentinel.
const r1 = await createQuoteFromEmail({
...basePayload,
from_email: "",
subject: "Unknown sender email A",
message_id: "<no-from-a@example.com>",
});
const r2 = await createQuoteFromEmail({
...basePayload,
from_email: "",
subject: "Unknown sender email B",
message_id: "<no-from-b@example.com>",
});
expect(r1.deduped).toBe(false);
expect(r2.deduped).toBe(false);
expect(r1.quote.id).not.toBe(r2.quote.id);
expect(r1.customer.id).toBe(r2.customer.id);
});
it("a real from_email still wins over the sentinel", async () => {
const result = await createQuoteFromEmail({
...basePayload,
from_email: "real-buyer@somewhere.co.za",
});
expect(result.customer.email).toBe("real-buyer@somewhere.co.za");
expect(result.customer.customer).not.toBe("Unknown Sender");
});
it("existing sentinel survives a fresh run (no duplicate Unknown Sender row)", async () => {
// Pre-create the sentinel so the findOrCreate path hits the existing
// branch. Verify no second customer is created.
const customersBefore = await pbMock.collection("customers").getFullList();
await pbMock.collection("customers").create({
customer: "Unknown Sender",
email: "unknown-sender@no-from.wfdash.local",
company: "Unknown",
description: "Pre-existing sentinel.",
});
const customersAfter = await pbMock.collection("customers").getFullList();
expect(customersAfter.length).toBe(customersBefore.length + 1);
const result = await createQuoteFromEmail({ ...basePayload, from_email: "" });
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
expect(result.customer.description).toBe("Pre-existing sentinel.");
const customersFinal = await pbMock.collection("customers").getFullList();
expect(customersFinal.length).toBe(customersAfter.length);
});
it("synthesizes a stable message_id when the inbound email has no Message-ID header", async () => {
// iCloud / Mail.app sometimes strip the Message-ID header. The pipeline
// must still create the quote and dedup correctly on retry.
const noId1 = await createQuoteFromEmail({
...basePayload,
message_id: "",
});
const noId2 = await createQuoteFromEmail({
...basePayload,
message_id: "",
});
expect(noId1.quote.email_message_id).toMatch(/^synth-[0-9a-f]{8}@wfdash\.local$/);
expect(noId2.deduped).toBe(true);
expect(noId2.quote.id).toBe(noId1.quote.id);
});
it("treats whitespace-only message_id as missing and synthesizes", async () => {
const result = await createQuoteFromEmail({
...basePayload,
message_id: " ",
});
expect(result.quote.email_message_id).toMatch(/^synth-[0-9a-f]{8}@wfdash\.local$/);
});
it("keeps a real (non-empty) message_id verbatim — never synthesizes over a real one", async () => {
const result = await createQuoteFromEmail({
...basePayload,
message_id: "<real-msg-id@mail.example.com>",
});
expect(result.quote.email_message_id).toBe("<real-msg-id@mail.example.com>");
});
});
describe("appendReplyToQuote", () => {
it("matches by In-Reply-To header (strongest signal)", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@a.com",
from_name: "Buyer",
subject: "Quotation needed",
body_plain: "",
message_id: "<orig@example.com>",
});
const result = await appendReplyToQuote({
from_email: "buyer@a.com",
from_name: "Buyer",
subject: "Re: Some subject without a number",
body_plain: "Thanks, looks good.",
message_id: "<reply1@example.com>",
in_reply_to: "<orig@example.com>",
received_at: new Date().toISOString(),
});
expect(result.matched_by).toBe("in_reply_to");
expect(result.quote?.id).toBe(created.quote.id);
expect((result.reply as Record<string, unknown>).in_reply_to).toBe("<orig@example.com>");
});
it("falls back to subject regex when In-Reply-To is missing", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@b.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig2@example.com>",
});
const result = await appendReplyToQuote({
from_email: "buyer@b.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "OK, please proceed.",
message_id: "<reply2@example.com>",
});
expect(result.matched_by).toBe("subject");
expect(result.quote?.id).toBe(created.quote.id);
});
it("updates has_new_reply and last_reply_at on the matched quote", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@c.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig3@example.com>",
});
const replyDate = "2026-06-11T10:00:00.000Z";
await appendReplyToQuote({
from_email: "buyer@c.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "Approved.",
message_id: "<reply3@example.com>",
received_at: replyDate,
});
const updated = await pbMock.collection("quotes").getOne(created.quote.id);
expect(updated.has_new_reply).toBe(true);
expect(updated.last_reply_at).toBe(replyDate);
});
it("returns matched_by='none' when no match — caller moves to Unmatched", async () => {
const result = await appendReplyToQuote({
from_email: "unknown@x.com",
subject: "Some random reply",
body_plain: "Hello",
});
expect(result.matched_by).toBe("none");
expect(result.quote).toBeNull();
expect(result.reply).toBeNull();
});
it("stores the reply as a structured quote_replies row, not as notes text", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@d.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig4@example.com>",
});
await appendReplyToQuote({
from_email: "buyer@d.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "Reply body content",
quoted_text: "Original quoted text",
message_id: "<reply4@example.com>",
});
const replies = await pbMock.collection("quote_replies").getFullList({
filter: `quote = "${created.quote.id}"`,
});
expect(replies.length).toBe(1);
expect(replies[0].body_plain).toBe("Reply body content");
expect(replies[0].quoted_text).toBe("Original quoted text");
// The quote's notes field should NOT contain the reply body.
expect(created.quote.notes).toBe("");
});
});
describe("findQuoteByMessageId", () => {
it("returns the matching quote", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@e.com",
subject: "Need a quote",
body_plain: "",
message_id: "<findme@example.com>",
});
const found = await findQuoteByMessageId("<findme@example.com>");
expect(found?.id).toBe(created.quote.id);
});
it("returns null when no match", async () => {
const found = await findQuoteByMessageId("<nope@example.com>");
expect(found).toBeNull();
});
it("returns null for empty Message-ID", async () => {
expect(await findQuoteByMessageId("")).toBeNull();
});
});

View file

@ -0,0 +1,747 @@
import { getAdminPocketBase } from "./pocketbase-admin";
import type { Quote, Customer, Company } from "@/lib/types";
/**
* Server-side quote pipeline used by the MCP layer (n8n) and internal API routes.
*
* This module is the canonical home for all email-to-quote business logic.
* It deliberately avoids the pitfalls of the old Django implementation:
*
* - No fuzzy customer/company matching — only exact + normalized.
* - No fabricated cost or selling prices on AI-extracted items.
* - No racy `last + 1` quote number generation — uses a PocketBase-side
* atomic counter.
* - Replies are written to the `quote_replies` collection, never to a
* text blob on `quotes.notes`.
* - Idempotency: the same email (by Message-ID) cannot create two quotes.
*/
const QUOTES_COLLECTION = "quotes";
const QUOTE_ITEMS_COLLECTION = "quote_items";
const QUOTE_ATTACHMENTS_COLLECTION = "quote_attachments";
const QUOTE_REPLIES_COLLECTION = "quote_replies";
const QUOTE_COUNTERS_COLLECTION = "quote_counters";
const CUSTOMERS_COLLECTION = "customers";
const COMPANIES_COLLECTION = "companies";
const QUOTE_NUMBER_REGEX = /(?:Re:\s*)?(?:Quotation|Quote)\s+Q-(\d{4})-(\d{2})-(\d{3})/i;
/**
* Sentinel customer for emails with no From: header. PB enforces
* `required=true` on `quotes.customer`, so we can't pass null. Instead,
* every email intake with an empty `from_email` is linked to this single
* stable customer. The quoter reassigns in the quote-editing UI if/when
* they know who the real sender is.
*
* The email domain `no-from.wfdash.local` is impossible to confuse with a
* real address; the description makes the role explicit.
*/
const UNKNOWN_SENDER_CUSTOMER_EMAIL = "unknown-sender@no-from.wfdash.local";
const UNKNOWN_SENDER_CUSTOMER_NAME = "Unknown Sender";
const UNKNOWN_SENDER_CUSTOMER_DESCRIPTION =
"Auto-created sentinel for emails with no From: header. The quoter reassigns in the quote-editing UI.";
export interface EmailIntakeAttachment {
filename: string;
content_type?: string;
size?: number;
/** Raw bytes encoded as base64 (no `data:` URL prefix). */
base64: string;
}
export interface EmailIntakeExtractedItem {
description?: string;
quantity?: number;
unit?: string;
notes?: string;
}
export interface EmailIntakePayload {
from_email: string;
from_name?: string;
subject: string;
body_plain?: string;
body_html?: string;
/** RFC 5322 Message-ID. Required for dedup. */
message_id: string;
in_reply_to?: string;
received_at?: string;
extracted_items?: EmailIntakeExtractedItem[];
attachments?: EmailIntakeAttachment[];
/** Optional override for company matching — used by the AI extraction layer. */
company_hint?: string;
}
export interface EmailReplyPayload {
from_email: string;
from_name?: string;
subject: string;
body_plain?: string;
body_html?: string;
message_id?: string;
in_reply_to?: string;
received_at?: string;
quoted_text?: string;
}
export interface CreateQuoteFromEmailResult {
quote: Quote;
customer: Customer;
company: Company | null;
items: number;
attachments: number;
deduped: boolean;
quote_number: string;
}
export interface AppendReplyResult {
quote: Quote | null;
reply: Record<string, unknown> | null;
matched_by: "in_reply_to" | "subject" | "none";
}
// ──────────────────────────────────────────────────────────────────────────────
// Atomic quote number generation
// ──────────────────────────────────────────────────────────────────────────────
/**
* Generates the next quote number (`Q-YYYY-MM-NNN`) atomically.
*
* Uses the `quote_counters` collection with a unique `(year, month)` index.
* The `last_seq+` increment operator is evaluated server-side by PocketBase
* against the current row state, so concurrent callers cannot collide even
* if they read the counter at the same instant.
*/
export async function generateQuoteNumberServer(): Promise<string> {
const pb = await getAdminPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const counter = await getOrCreateCounter(pb, year, month);
const updated = await pb.collection(QUOTE_COUNTERS_COLLECTION).update(counter.id, {
"last_seq+": 1,
});
return formatQuoteNumber(year, month, updated.last_seq);
}
async function getOrCreateCounter(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
year: number,
month: number
) {
try {
return await pb
.collection(QUOTE_COUNTERS_COLLECTION)
.getFirstListItem(`year=${year} && month=${month}`);
} catch (err) {
// First request for this month — create the counter. If a concurrent
// caller already created it, swallow the unique-constraint error and
// re-fetch.
try {
return await pb.collection(QUOTE_COUNTERS_COLLECTION).create({
year,
month,
last_seq: 0,
});
} catch {
return await pb
.collection(QUOTE_COUNTERS_COLLECTION)
.getFirstListItem(`year=${year} && month=${month}`);
}
}
}
function formatQuoteNumber(year: number, month: number, seq: number): string {
return `Q-${year}-${String(month).padStart(2, "0")}-${String(seq).padStart(3, "0")}`;
}
// ──────────────────────────────────────────────────────────────────────────────
// Customer matching
// ──────────────────────────────────────────────────────────────────────────────
/**
* Finds an existing customer by exact email match, or creates a new one.
*
* Looks up `email`, then `alternative_email`, then `buyer_email` — case
* insensitive but otherwise an exact equality match. If no match, creates a
* new customer with a `description` flag marking it as auto-created from
* email intake (for audit).
*
* Never fuzzy-merges. Two customers with the same domain are kept separate
* until a human reviews them.
*/
export async function findOrCreateCustomer(input: {
email: string;
name?: string;
companyHint?: string;
}): Promise<{ customer: Customer; created: boolean }> {
const pb = await getAdminPocketBase();
const email = (input.email || "").trim().toLowerCase();
if (!email) {
throw new Error("Customer email is required for findOrCreateCustomer.");
}
const exact = await findCustomerByExactEmail(pb, email);
if (exact) {
return { customer: exact, created: false };
}
const senderDomain = email.split("@")[1] || "";
const fallbackName = input.name?.trim() || senderDomain || email;
const fallbackCompany = input.companyHint?.trim() || senderDomain;
const created = await pb.collection(CUSTOMERS_COLLECTION).create({
customer: fallbackName,
email,
company: fallbackCompany,
dateadded: new Date().toISOString().slice(0, 10),
description: "Auto-created from email intake.",
});
return { customer: created as Customer, created: true };
}
/**
* Returns the singleton "Unknown Sender" customer used when an inbound email
* has no From: header. Created on first use, reused for all subsequent
* unknown-sender emails. The race-safety pattern (try-create, re-fetch on
* unique-index failure) mirrors `generateQuoteNumberServer`.
*/
async function findOrCreateUnknownSenderCustomer(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>
): Promise<{ customer: Customer; created: boolean }> {
const existing = await findCustomerByExactEmail(pb, UNKNOWN_SENDER_CUSTOMER_EMAIL);
if (existing) {
return { customer: existing, created: false };
}
try {
const created = await pb.collection(CUSTOMERS_COLLECTION).create({
customer: UNKNOWN_SENDER_CUSTOMER_NAME,
email: UNKNOWN_SENDER_CUSTOMER_EMAIL,
company: "Unknown",
dateadded: new Date().toISOString().slice(0, 10),
description: UNKNOWN_SENDER_CUSTOMER_DESCRIPTION,
});
return { customer: created as Customer, created: true };
} catch (err) {
// Race: a concurrent intake created the sentinel first. Re-fetch.
const refetched = await findCustomerByExactEmail(pb, UNKNOWN_SENDER_CUSTOMER_EMAIL);
if (refetched) {
return { customer: refetched, created: false };
}
throw err;
}
}
async function findCustomerByExactEmail(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
email: string
): Promise<Customer | null> {
const esc = email.replace(/"/g, '\\"');
// Try the primary email field first, then the two alternates. Each is a
// separate query — cheap because of PocketBase's text index.
for (const field of ["email", "alternative_email", "buyer_email"] as const) {
try {
const hit = await pb.collection(CUSTOMERS_COLLECTION).getFirstListItem(`${field} = "${esc}"`);
if (hit) return hit as Customer;
} catch {
// 404 — keep looking.
}
}
return null;
}
// ──────────────────────────────────────────────────────────────────────────────
// Company matching
// ──────────────────────────────────────────────────────────────────────────────
/**
* Finds an existing company by normalized name, or creates a new one.
*
* Two-step match:
* 1. Case-insensitive exact match on `company` field.
* 2. Normalized match (lowercase, trim, strip common suffixes) on `company`.
*
* If neither matches, creates a new record. Never fuzzy-merges.
*/
export async function findOrCreateCompany(input: {
name?: string;
address?: string;
}): Promise<{ company: Company | null; created: boolean }> {
const pb = await getAdminPocketBase();
const name = (input.name || "").trim();
if (!name) {
return { company: null, created: false };
}
const exactHit = await findCompanyByCaseInsensitiveExact(pb, name);
if (exactHit) return { company: exactHit, created: false };
const normalizedHit = await findCompanyByNormalized(pb, name);
if (normalizedHit) return { company: normalizedHit, created: false };
const created = await pb.collection(COMPANIES_COLLECTION).create({
company: name,
address: input.address || "",
vendor: "",
});
return { company: created as Company, created: true };
}
async function findCompanyByCaseInsensitiveExact(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
name: string
): Promise<Company | null> {
try {
const hit = await pb
.collection(COMPANIES_COLLECTION)
.getFirstListItem(`company = "${name.replace(/"/g, '\\"')}"`);
return hit as Company;
} catch {
return null;
}
}
async function findCompanyByNormalized(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
name: string
): Promise<Company | null> {
const normalized = normalizeCompanyName(name);
if (!normalized) return null;
const all = await pb.collection(COMPANIES_COLLECTION).getFullList<Company>({ fields: "id,company" });
for (const c of all) {
if (normalizeCompanyName(c.company) === normalized) {
return c;
}
}
return null;
}
/**
* Normalizes a company name for comparison: lowercase, trim, collapse spaces,
* strip common legal suffixes, strip trailing punctuation. Intentionally
* conservative — only strips things that are unambiguously noise.
*/
export function normalizeCompanyName(input: string): string {
return (input || "")
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/[.,]+$/g, "")
.replace(
/\b(pty ltd|proprietary limited|ltd|limited|inc|incorporated|llc|corp|corporation|holdings|group|co\.?za|company)\b\.?/gi,
""
)
.replace(/\s+/g, " ")
.trim();
}
// ──────────────────────────────────────────────────────────────────────────────
// Quote creation from email
// ──────────────────────────────────────────────────────────────────────────────
/**
* Creates a Quote (and customer, company, items, attachments) from a parsed
* email payload. Idempotent on `message_id`.
*
* The human-opt-in pattern: AI-extracted items are created with
* `included_in_quote = false` and all prices zero. A human must review and
* opt each line in via the existing quote processing UI. We never fabricate
* cost or selling prices.
*/
export async function createQuoteFromEmail(
payload: EmailIntakePayload
): Promise<CreateQuoteFromEmailResult> {
// The n8n Parse Email node stamps `(no subject)` / `(no body)` placeholders
// when an inbound email is missing those headers (a malformed / RFC 5321-
// violating send). We accept whatever string the caller sends, including
// the placeholder, rather than 500ing — the office can then see the
// placeholder quote in the queue and triage manually. The type contract
// still says `subject: string`, and the n8n side is now contractually
// obligated to always send a non-empty string.
// Some senders (notably iCloud/Mail.app) strip the RFC 5322 Message-ID
// header. We still need a stable dedup key, so synthesize one from the
// real email identity. The `synth-` prefix and `@wfdash.local` suffix
// make it visually obvious in PocketBase that this isn't a real header.
const effectiveMessageId = payload.message_id?.trim() || synthesizeMessageId(payload);
// Some senders also strip the From: header entirely. PB enforces
// `required=true` on `quotes.customer`, so an empty `from_email` cannot
// be passed through as `null`. We compute a single `fromEmail` value and
// fall back to a sentinel "Unknown Sender" customer at the resolution
// step below. The `email_sender` field on the quote stores the literal
// empty string, not a placeholder — the sentinel is only used for the
// relation.
const fromEmail = (payload.from_email || "").trim().toLowerCase();
const pb = await getAdminPocketBase();
// ── 1. Idempotency: if we already have a quote for this Message-ID, return it.
const existing = await findQuoteByMessageId(pb, effectiveMessageId);
if (existing) {
const itemsCount = await countQuoteItems(pb, existing.id);
const attsCount = await countQuoteAttachments(pb, existing.id);
const customer = existing.customer
? ((await pb.collection(CUSTOMERS_COLLECTION).getOne(existing.customer)) as Customer)
: null;
const company = existing.company
? ((await pb.collection(COMPANIES_COLLECTION).getOne(existing.company)) as Company)
: null;
return {
quote: existing,
customer: customer as Customer,
company: company as Company | null,
items: itemsCount,
attachments: attsCount,
deduped: true,
quote_number: existing.quote_number,
};
}
// ── 2. Resolve or create the customer. Empty from_email → sentinel.
const { customer } = fromEmail
? await findOrCreateCustomer({
email: fromEmail,
name: payload.from_name,
companyHint: payload.company_hint,
})
: await findOrCreateUnknownSenderCustomer(pb);
// ── 3. Resolve or create the company. Only the AI's `company_hint` is
// trusted — the subject is never a fallback, because unparseable emails
// would otherwise create junk `companies` rows. The quoter picks the
// company in the form when the AI couldn't find one.
const { company } = await findOrCreateCompany({
name: payload.company_hint,
});
// ── 4. Generate a quote number.
const quoteNumber = await generateQuoteNumberServer();
// ── 5. Create the quote.
const receivedAt = payload.received_at ? new Date(payload.received_at) : new Date();
const quote = await pb.collection(QUOTES_COLLECTION).create({
quote_number: quoteNumber,
customer: customer.id,
company: company?.id || null,
description: payload.subject,
status: "new",
notes: "",
email_sender: fromEmail,
email_subject: payload.subject,
email_body: payload.body_plain || payload.body_html || "",
has_attachments: (payload.attachments?.length ?? 0) > 0,
email_message_id: effectiveMessageId,
company_letterhead: "CNL",
});
// ── 6. Pre-populate quote items from the AI extraction. Human opt-in only.
const items = (payload.extracted_items || []).filter((i) => (i.description || "").trim());
for (const item of items) {
await pb.collection(QUOTE_ITEMS_COLLECTION).create({
quote: quote.id,
description: item.description,
quote_number: quoteNumber,
quantity: Number.isFinite(item.quantity) && (item.quantity as number) > 0 ? item.quantity : 1,
cost_price: 0,
selling_price: 0,
markup: 0,
notes: item.notes || "",
is_processed: false,
included_in_quote: false,
});
}
// ── 7. Attachments are uploaded in a follow-up step, NOT here.
//
// The previous design embedded all attachments in the single
// `quotes.create_from_email` POST as base64 in the JSON body. A single
// 5 MB PDF became ~6.7 MB of base64 in one HTTP request, which the
// Next.js route handler rejected at its default 1 MB body limit, so
// the whole create call failed and no quote row was written.
//
// The n8n pipeline now calls `quotes.create_from_email` (which creates
// the quote row + items) and then loops over the attachments, calling
// `attachments.upload_for_quote` once per file with a small per-call
// body. Failures are isolated per attachment and don't roll back the
// quote.
//
// We deliberately accept `attachments` in the payload for back-compat
// with any caller that still sends them, but we no longer act on them
// here. The result's `attachments` count is always 0 from this path.
return {
quote: quote as Quote,
customer,
company: company ?? null,
items: items.length,
attachments: 0,
deduped: false,
quote_number: quoteNumber,
};
}
async function uploadQuoteAttachment(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
input: {
quoteId: string;
filename: string;
contentType?: string;
size?: number;
base64: string;
messageId: string;
}
): Promise<Record<string, unknown>> {
const buffer = Buffer.from(input.base64, "base64");
const file = new File([buffer], input.filename, {
type: input.contentType || "application/octet-stream",
});
// The `quote_attachments.uploaded_at` date field is declared as
// onCreate-auto in `docs/pocketbase/quote_attachments.collection.json`,
// but the live PB collection in production (set up before the docs
// schema was finalised) doesn't carry that auto-fill behaviour, and
// PB rejects the create with `validation_required` if we don't send a
// value. We send `new Date().toISO()` so the field is populated for
// both schemas; PB ignores it on the doc schema and stores it on the
// live one. Cheap and forward-compatible.
const created = await pb.collection(QUOTE_ATTACHMENTS_COLLECTION).create({
quote: input.quoteId,
filename: input.filename,
file,
content_type: input.contentType || "application/octet-stream",
size_bytes: input.size ?? buffer.length,
email_message_id: input.messageId,
kind: classifyAttachment(input.contentType, input.filename),
uploaded_at: new Date().toISOString(),
});
// Flip the parent quote's `has_attachments` flag so the office sees
// the "📎" badge on the quote list. We update unconditionally rather
// than reading first — `has_attachments` is a bool column, so
// setting it to `true` on every call is idempotent and avoids a
// round-trip per attachment.
try {
await pb.collection("quotes").update(input.quoteId, { has_attachments: true });
} catch (e) {
// Non-fatal: the attachment row is the source of truth, and the
// flag will get re-synced on the next upload. Log but don't throw.
console.warn(
`[uploadQuoteAttachment] failed to set has_attachments=true on quote ${input.quoteId}:`,
(e as Error)?.message || e
);
}
return created as Record<string, unknown>;
}
/**
* Public entry point used by the `attachments.upload_for_quote` MCP tool.
* Uploads a single attachment to the `quote_attachments` collection, linked
* to an existing quote. The caller (n8n) is expected to call this once per
* attachment after `quotes.create_from_email` returns the new quote id.
*
* Errors propagate (the MCP tool surfaces them) so the office sees which
* files didn't make it. A failed attachment does NOT roll back the quote —
* the quote is more valuable than a single bad file, and a quoter can
* always re-upload the missing file via a future API.
*/
export async function uploadAttachmentForQuote(input: {
quoteId: string;
filename: string;
contentType?: string;
size?: number;
base64: string;
messageId?: string;
}): Promise<Record<string, unknown>> {
if (!input.quoteId) throw new Error("quoteId is required.");
if (!input.filename) throw new Error("filename is required.");
if (!input.base64) throw new Error("base64 is required.");
const pb = await getAdminPocketBase();
return uploadQuoteAttachment(pb, {
quoteId: input.quoteId,
filename: input.filename,
contentType: input.contentType,
size: input.size,
base64: input.base64,
messageId: input.messageId || "",
});
}
function classifyAttachment(contentType: string | undefined, filename: string): string {
const ct = (contentType || "").toLowerCase();
const fn = (filename || "").toLowerCase();
if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf";
if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp)$/.test(fn)) return "image";
if (
ct.includes("officedocument") ||
ct.includes("msword") ||
ct.includes("ms-excel") ||
/\.(docx?|xlsx?)$/.test(fn)
) {
return "doc";
}
return "other";
}
async function countQuoteItems(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
quoteId: string
): Promise<number> {
const list = await pb.collection(QUOTE_ITEMS_COLLECTION).getList(1, 1, {
filter: `quote = "${quoteId}"`,
});
return list.totalItems;
}
async function countQuoteAttachments(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
quoteId: string
): Promise<number> {
const list = await pb.collection(QUOTE_ATTACHMENTS_COLLECTION).getList(1, 1, {
filter: `quote = "${quoteId}"`,
});
return list.totalItems;
}
// ──────────────────────────────────────────────────────────────────────────────
// Reply handling
// ──────────────────────────────────────────────────────────────────────────────
/**
* Appends a reply to an existing quote. Match priority:
* 1. `In-Reply-To` header → look up `quotes.email_message_id`
* 2. `Q-YYYY-MM-NNN` extracted from subject → look up `quotes.quote_number`
* 3. Otherwise returns `{ matched_by: 'none' }` so the caller can move the
* email to the Unmatched folder.
*
* The reply is stored as a `quote_replies` row. We never mutate
* `quotes.notes` — the human-readable thread is joined at render time.
*/
export async function appendReplyToQuote(
payload: EmailReplyPayload
): Promise<AppendReplyResult> {
const pb = await getAdminPocketBase();
const receivedAt = payload.received_at ? new Date(payload.received_at) : new Date();
const { quote, matched_by } = await resolveReplyTarget(pb, payload);
if (!quote) {
return { quote: null, reply: null, matched_by: "none" };
}
const reply = await pb.collection(QUOTE_REPLIES_COLLECTION).create({
quote: quote.id,
from_email: payload.from_email,
from_name: payload.from_name || "",
subject: payload.subject,
body_plain: payload.body_plain || "",
body_html: payload.body_html || "",
received_at: receivedAt.toISOString(),
email_message_id: payload.message_id || "",
in_reply_to: payload.in_reply_to || "",
quoted_text: payload.quoted_text || "",
});
await pb.collection(QUOTES_COLLECTION).update(quote.id, {
has_new_reply: true,
last_reply_at: receivedAt.toISOString(),
});
return { quote: quote as Quote, reply, matched_by };
}
async function resolveReplyTarget(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
payload: EmailReplyPayload
): Promise<{ quote: Quote | null; matched_by: AppendReplyResult["matched_by"] }> {
if (payload.in_reply_to) {
const q = await findQuoteByMessageId(pb, payload.in_reply_to);
if (q) return { quote: q, matched_by: "in_reply_to" };
}
const quoteNumber = extractQuoteNumberFromSubject(payload.subject);
if (quoteNumber) {
try {
const q = await pb
.collection(QUOTES_COLLECTION)
.getFirstListItem(`quote_number = "${quoteNumber}"`);
return { quote: q as Quote, matched_by: "subject" };
} catch {
// fall through
}
}
return { quote: null, matched_by: "none" };
}
export function extractQuoteNumberFromSubject(subject: string): string | null {
const m = (subject || "").match(QUOTE_NUMBER_REGEX);
if (!m) return null;
return `Q-${m[1]}-${m[2]}-${m[3]}`;
}
// ──────────────────────────────────────────────────────────────────────────────
// Lookups
// ──────────────────────────────────────────────────────────────────────────────
/**
* Looks up a quote by the inbound email's `Message-ID` header. Returns null
* if no quote matches. Indexed by `quotes.email_message_id`.
*/
export async function findQuoteByMessageId(
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
messageId?: string
): Promise<Quote | null>;
export async function findQuoteByMessageId(messageId: string): Promise<Quote | null>;
export async function findQuoteByMessageId(
pbOrMessageId: Awaited<ReturnType<typeof getAdminPocketBase>> | string,
maybeMessageId?: string
): Promise<Quote | null> {
let pb: Awaited<ReturnType<typeof getAdminPocketBase>>;
let messageId: string;
if (typeof pbOrMessageId === "string") {
pb = await getAdminPocketBase();
messageId = pbOrMessageId;
} else {
pb = pbOrMessageId;
messageId = maybeMessageId || "";
}
if (!messageId) return null;
const esc = messageId.replace(/"/g, '\\"');
try {
const hit = await pb
.collection(QUOTES_COLLECTION)
.getFirstListItem(`email_message_id = "${esc}"`);
return hit as Quote;
} catch {
return null;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Message-ID synthesis (fallback when the inbound email has no header)
// ──────────────────────────────────────────────────────────────────────────────
/**
* Builds a stable, deterministic Message-ID for an email that arrived without
* one. The hash is over `from_email + subject + received_at` so the same
* email processed twice dedups to the same id. The `synth-` prefix and
* `@wfdash.local` suffix make it visually obvious in PocketBase that this
* isn't a real RFC 5322 Message-ID.
*
* Collisions are not a correctness concern: two real emails with identical
* sender + subject + received_at are the same email. The hash is FNV-1a 32-bit
* which is overkill for this input space but cheap and stable across runs.
*/
export function synthesizeMessageId(payload: {
from_email: string;
subject: string;
received_at?: string;
}): string {
const input = `${payload.from_email}|${payload.subject}|${payload.received_at ?? ""}`;
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return `synth-${(hash >>> 0).toString(16).padStart(8, "0")}@wfdash.local`;
}

View file

@ -87,12 +87,44 @@ export interface Quote extends BaseRecord {
last_reply_at: string;
email_failed_at: string;
pdf_generated_at: string;
email_message_id: string;
expand?: {
customer?: Customer;
company?: Company;
};
}
export type QuoteAttachmentKind = "pdf" | "image" | "doc" | "other";
export interface QuoteAttachment extends BaseRecord {
quote: string;
filename: string;
file: string;
content_type: string;
size_bytes: number;
email_message_id: string;
kind: QuoteAttachmentKind;
expand?: {
quote?: Quote;
};
}
export interface QuoteReply extends BaseRecord {
quote: string;
from_email: string;
from_name: string;
subject: string;
body_plain: string;
body_html: string;
received_at: string;
email_message_id: string;
in_reply_to: string;
quoted_text: string;
expand?: {
quote?: Quote;
};
}
export interface QuoteItem extends BaseRecord {
quote: string;
description: string;
@ -152,7 +184,7 @@ export interface Order extends BaseRecord {
};
}
export interface OrderItem extends BaseRecord {
export interface OrderItem extends BaseRecord {
order: string;
description: string;
po_description: string;
@ -162,90 +194,90 @@ export interface OrderItem extends BaseRecord {
supplier: string;
cost_price: number;
markup: number;
purchase_order: string;
item_status: ItemStatus;
drv_status?: "unassigned" | "assigned" | "en_route" | "collected" | "failed";
drv_id?: string;
notes: string;
expand?: {
supplier?: Supplier;
};
}
export interface CollectionPool extends BaseRecord {
order: string;
order_item: string;
supplier?: string;
drv_id?: string;
pool_status: "queued" | "assigned" | "in_progress" | "completed" | "failed";
priority?: "low" | "normal" | "high" | "urgent";
pool_notes?: string;
assigned_at?: string;
completed_at?: string;
expand?: {
order?: Order;
order_item?: OrderItem;
supplier?: Supplier;
};
}
purchase_order: string;
item_status: ItemStatus;
drv_status?: "unassigned" | "assigned" | "en_route" | "collected" | "failed";
drv_id?: string;
notes: string;
expand?: {
supplier?: Supplier;
};
}
export interface PurchaseOrder extends BaseRecord {
po_number: string;
order: string;
supplier: string;
export interface CollectionPool extends BaseRecord {
order: string;
order_item: string;
supplier?: string;
drv_id?: string;
pool_status: "queued" | "assigned" | "in_progress" | "completed" | "failed";
priority?: "low" | "normal" | "high" | "urgent";
pool_notes?: string;
assigned_at?: string;
completed_at?: string;
expand?: {
order?: Order;
order_item?: OrderItem;
supplier?: Supplier;
};
}
export interface PurchaseOrder extends BaseRecord {
po_number: string;
order: string;
supplier: string;
status: string;
total_amount: number;
expand?: {
order?: Order;
supplier?: Supplier;
};
}
export interface DeliveryNote extends BaseRecord {
delivery_note_number: string;
order: string;
company?: string;
customer?: string;
status: "draft" | "ready" | "delivered" | "cancelled";
delivery_date?: string;
notes?: string;
expand?: {
order?: Order;
company?: Company;
customer?: Customer;
};
}
export interface DeliveryNoteItem extends BaseRecord {
delivery_note: string;
order_item: string;
description: string;
quantity: number;
expand?: {
delivery_note?: DeliveryNote;
order_item?: OrderItem;
};
}
export interface Repair extends BaseRecord {
repair_number: string;
customer?: string;
company?: string;
order?: string;
status: "new" | "assessing" | "quoted" | "approved" | "in_progress" | "completed" | "cancelled";
item_description: string;
fault_description: string;
notes?: string;
expand?: {
customer?: Customer;
company?: Company;
order?: Order;
};
}
export interface DashboardStats {
totalCustomers: number;
activeQuotes: number;
};
}
export interface DeliveryNote extends BaseRecord {
delivery_note_number: string;
order: string;
company?: string;
customer?: string;
status: "draft" | "ready" | "delivered" | "cancelled";
delivery_date?: string;
notes?: string;
expand?: {
order?: Order;
company?: Company;
customer?: Customer;
};
}
export interface DeliveryNoteItem extends BaseRecord {
delivery_note: string;
order_item: string;
description: string;
quantity: number;
expand?: {
delivery_note?: DeliveryNote;
order_item?: OrderItem;
};
}
export interface Repair extends BaseRecord {
repair_number: string;
customer?: string;
company?: string;
order?: string;
status: "new" | "assessing" | "quoted" | "approved" | "in_progress" | "completed" | "cancelled";
item_description: string;
fault_description: string;
notes?: string;
expand?: {
customer?: Customer;
company?: Company;
order?: Order;
};
}
export interface DashboardStats {
totalCustomers: number;
activeQuotes: number;
ordersThisMonth: number;
totalRevenue: number;
}