diff --git a/db/migration/V0005__Create_email_tables.sql b/db/migration/V0005__Create_email_tables.sql new file mode 100644 index 0000000..53145be --- /dev/null +++ b/db/migration/V0005__Create_email_tables.sql @@ -0,0 +1,75 @@ +CREATE TABLE IF NOT EXISTS "email_templates" ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + subject TEXT NOT NULL, -- [] template + body TEXT NOT NULL, -- [] template + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS "email_requests" ( + id UUID PRIMARY KEY, -- requestId + label TEXT, + sender_email TEXT, + source TEXT NOT NULL, -- 'MANUAL' | 'MATCHING' + template_id UUID, + total_count INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_request_template FOREIGN KEY (template_id) REFERENCES email_templates(id) +); + +CREATE TABLE IF NOT EXISTS "emails" ( + id UUID PRIMARY KEY, -- emailId + request_id UUID NOT NULL, + matches_id UUID, -- nullable; future FK to matches + recipient_1 TEXT NOT NULL, -- per1 (always present) + recipient_2 TEXT, -- per2; NULL for a solo email, set for a pair + reply_to TEXT, + template_id UUID NOT NULL, -- load-bearing: runner renders subject/body from this + template_values JSONB NOT NULL, -- variables merged into the template at send-time + status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING | PROCESSING | SENT | ERROR + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + CONSTRAINT fk_email_request FOREIGN KEY (request_id) REFERENCES email_requests(id), + CONSTRAINT fk_email_template FOREIGN KEY (template_id) REFERENCES email_templates(id) +); + +CREATE INDEX IF NOT EXISTS idx_emails_status_created ON emails (status, created_at); -- claim query +CREATE INDEX IF NOT EXISTS idx_emails_request ON emails (request_id); -- progress (Inc 2) +CREATE INDEX IF NOT EXISTS idx_emails_matches ON emails (matches_id); -- dedup (Inc 4) +CREATE INDEX IF NOT EXISTS idx_emails_recipient_1 ON emails (recipient_1); +CREATE INDEX IF NOT EXISTS idx_emails_recipient_2 ON emails (recipient_2) WHERE recipient_2 IS NOT NULL; -- partial + +-- Seed read-only templates (create/list/delete arrives in Increment 5). +INSERT INTO "email_templates" (id, name, subject, body) VALUES + ( + '00000000-0000-0000-0000-000000000001', + 'Pair', + replace( + '[PatChats $${month}] $${per1.firstName} and $${per2.firstName}, you''ve been paired for PatChats!', + '$${', + '$' || '{' + ), + replace( + E'Hey $${per1.firstName} and $${per2.firstName}! \n\nWe''ve paired you two for PatChats this month! Find some time to have a 30 minute coffee chat or video call with your pairing!\nShare a screenshot or selfie of you two in the #pat-chats channel on the Discord server! \n\n$${per1.name} ($${per1.email}): \n$${per1.intro: {Intro missing! Send me an intro to add to the emails.}} \n$${per1.linkedin:}\n\n$${per2.name} ($${per2.email}): \n$${per2.intro: {Intro missing! Send me an intro to add to the emails.}}\n$${per2.linkedin:}\n\nLet me know if you''d like to update your pairing information or want to be taken off the list.\n \nCheers,\nPatina Network', + '$${', + '$' || '{' + ) + ), + ( + '00000000-0000-0000-0000-000000000002', + 'Reminder', + replace( + '[PatChats $${month}] Reminder: Have you had your PatChat yet?', + '$${', + '$' || '{' + ), + replace( + E'Hi $${per1.firstName} and $${per2.firstName},\n\nJust a friendly reminder that you were paired for PatChats this month!\nIf you haven''t had your 30 minute coffee chat or video call yet, now''s a great time to schedule it.\nDon''t forget to share a screenshot or selfie in the #pat-chats channel on the Discord server!\n\n$${per1.name} ($${per1.email})\n$${per2.name} ($${per2.email})\n\nLet me know if you''d like to update your pairing information or be taken off the list.\n\nCheers,\nPatina Network', + '$${', + '$' || '{' + ) + ) +ON CONFLICT (id) DO NOTHING; diff --git a/docs/email-async/00-overview.md b/docs/email-async/00-overview.md new file mode 100644 index 0000000..e7fe2ec --- /dev/null +++ b/docs/email-async/00-overview.md @@ -0,0 +1,107 @@ +# Async Email Service — Overview + +This directory specifies the conversion of the `email` domain from a **synchronous, in-memory** sender +into an **asynchronous transactional-outbox pipeline** with a background runner, a live progress UI, a +history view, and DB-stored templates. + +The work is split into **5 sequential vertical-slice increments**, each in its own file. Every increment +is independently shippable and demoable, and **no increment depends on a later one**. Read this overview +first; each increment file is self-contained for implementation and links back here for rationale. + +| File | Increment | Delivers | Prerequisites | +|------|-----------|----------|---------------| +| [01-async-send-pipeline.md](01-async-send-pipeline.md) | Async send pipeline (backend core) | Async sending works via API | — | +| [02-progress-history-resend-apis.md](02-progress-history-resend-apis.md) | Progress, history & resend APIs | Batch status queryable + manual resend | Inc 1 | +| [03-async-admin-frontend.md](03-async-admin-frontend.md) | Async admin frontend | User-facing manual send + live progress + history | Inc 1, 2 | +| [04-matching-send-flow.md](04-matching-send-flow.md) | Matching send flow | Pairing notifications end-to-end | Inc 1–3 | +| [05-template-management.md](05-template-management.md) | Template management (create/list/delete + UI) | Self-service template create + delete (no code) | Inc 1 | + +--- + +## Context (why this change) + +Today `POST /api/email/send` renders caller-supplied templates and sends each message over SMTP inside a +**request-blocking `for` loop** ([EmailService.java:29](../../src/main/java/org/patinanetwork/patchats/email/EmailService.java)), +returning per-message results. There is **no persistence** for email and **no DAO layer anywhere** in the +codebase. Consequences: the HTTP request blocks for the whole batch, there is no durable record or live +progress, and a crash mid-batch loses everything. + +Target design: +- A request **enqueues** rows into Postgres and returns immediately (`202`). +- A single **on-demand runner** (started by a manual/frontend API kick, plus a startup drain) drains the `emails` table, **renders each row from its template**, sends over SMTP, and updates each row's status. +- A **new admin UI** polls the backend for live per-email progress and a history of past sending sessions. +- Templates live in a DB table (**seeded read-only** first, **create/list/delete** in Increment 5; immutable — no edit). +- Recipient/pair data comes from **CSV uploads for now**, behind a swappable port, with a future migration + to another team's DB. + +**Stack:** Spring Boot, Postgres + Flyway, `spring-boot-starter-jdbc` with the fluent **`JdbcClient`** +(Boot 3.2+; **no JPA**), React + Mantine frontend. + +--- + +## Key decisions & tradeoffs (cross-cutting) + +Each increment repeats only the rows it needs; this is the full reference. + +| # | Decision | Choice | Why / tradeoff accepted | +|---|----------|--------|--------------------------| +| 1 | Queue transport | **DB-as-queue (outbox), no SQS** | Postgres is already the transactional store; single atomic insert, no dual-write. Any future retry logic would be ours (SQS gives it free) — fine since retry is deferred. | +| 2 | `/send` semantics | **New async endpoint, `202` + `{requestId, accepted}`** | Added alongside the existing sync `/send` (kept during migration, retired later). | +| 3 | Row granularity | **One row per message** (1–2 recipients), grouped by `requestId` | Matches current domain; `matchesId` stays 1:1 with a row. | +| 4 | Render timing | **Render at send-time** — the runner renders each row from `template_id` + `template_values` just before sending | Stores only the template ref + variables, not rendered text → smaller rows. Tradeoffs: render errors surface **async** as `ERROR` rows (no `400`); the runner needs the renderer + template lookup; `/preview` must share a render helper with the runner to avoid drift. (Templates are **immutable** — see #15 — so a queued row's template never changes under it.) | +| 5 | Deployment topology | **Strictly single instance** | Simplest claim logic. ⚠️ Deploys must be **stop-then-start** to avoid a transient 2-runner window. | +| 6 | Runner driver | **On-demand executor** (manual/frontend kick → drain loop → idle), **no polling** | Zero steady-state cost for a monthly workload. Coverage from an explicit `POST /api/email/process` kick (issued by the frontend after a send / by ops) plus a startup drain — **no enqueue-time auto-trigger**, no always-on poller. Tradeoff: if the kick is never issued, the batch waits for the next kick or a restart. | +| 7 | Intra-drain processing | **Sequential small batch** (claim ≤50 oldest, send one-at-a-time) | Gentle on SMTP, per-row error handling. Parallel pool is a future upgrade. | +| 8 | Retry policy | **No auto-retry — one attempt → `ERROR`** (deferred) | Avoids re-sending to the same person. Failed rows wait for a deliberate manual resend. | +| 9 | Crash recovery | **On boot: orphaned `PROCESSING` → `ERROR`** (at-most-once) | Guarantees **zero duplicate emails**. Cost: an email that crashed pre-send is stranded as `ERROR`, needs manual resend. | +| 10 | Progress UI | **Per-batch summary + per-email table**, polled live; **history tab** | Poll self-terminates when the batch is terminal. | +| 11 | Session model | **Parent `email_requests` table** | Durable session record; stable count denominator; home for the `source` flag. | +| 12 | Matching | **In scope** (Increment 4) | A **second producer** into the same queue: the manual flow (`source=MANUAL`) is the first writer into the `emails` outbox; matching (`source=MATCHING`) is a second endpoint that fans pairs into messages and calls the **same `EmailEnqueueService.enqueue(...)`**. Reuses the existing queue, runner, tables, and progress UI unchanged — only a new producer endpoint is added; the `source` column distinguishes them. | +| 13 | Match selection | **Explicit selection** (browsable by cycle), interim rows from the **pairings CSV** | DB-read is the future swap. | +| 14 | Pairing email shape | **One email to both partners** (per1/per2, 2 recipients) | Reuses multi-recipient sender; `matchesId` 1:1. | +| 15 | Templates | **DB-stored; seeded read-only (Inc 1) → create / list / delete (Inc 5)**; **immutable — no edit** | Admins add/select/delete without code once Inc 5 lands; to change copy, create a new template. Immutability keeps render-at-send safe — a queued row's template never changes under it. | +| 16 | Template model | **All sends via a selected `templateId`** (`template_id` is load-bearing / `NOT NULL`) | "Add a template" is the escape hatch. Future freeform sends would need rendered-body columns back (a hybrid), since freeform has no template to render at send-time. | +| 17 | Recipient/pair source | **CSV now, behind a swappable `RecipientSource` port**; DB later | Unblocks both flows without the unready DB; future swap is one seam. | +| 18 | Recipient storage | **Two scalar columns** `recipient_1` / `recipient_2` (nullable) | Plain btree indexing + `=`/`LIKE`; maps to per1/per2 (capped at 2). | +| 19 | Persistence API | **`JdbcClient`** (not `JdbcTemplate`) | Fluent, auto-configured; drop to `JdbcTemplate` only for batch inserts. | + +--- + +## Data model (full reference) + +The migration lands in **Increment 1** ([details](01-async-send-pipeline.md#1a-data-model)); all three +tables are created together because of the FKs. Summary: + +- **`email_templates`** — reusable `${}` subject/body templates. Seeded read-only in Inc 1; CRUD in Inc 5. +- **`email_requests`** — one row per "sending session" (the history-tab unit); carries `source` + (`MANUAL`/`MATCHING`), `template_id`, `total_count`, `created_at`. +- **`emails`** — one row per message (the outbox): `recipient_1`/`recipient_2`, `reply_to`, `template_id` + + `template_values` (the runner renders `subject`/`body` from these at send-time — **rendered text is not stored**), + `status` (`PENDING`|`PROCESSING`|`SENT`|`ERROR`), `error_message`, timestamps. + +--- + +## Deferred (documented, not in these increments) + +- **DB-backed recipient/pair source** — swap the CSV `RecipientSource` impl for the other team's DB. +- **Auto-retry with backoff** — re-add `attempt_count` / `next_attempt_at`, a claim eligibility clause, and a + one-shot `TaskScheduler` re-arm. Intentionally omitted now to avoid any risk of re-sending to the same person. +- **SQS transport** — a future scale lever if volume outgrows DB-as-queue. +- **Multi-instance runner** — `SELECT … FOR UPDATE SKIP LOCKED` or ShedLock leader election. +- **Concurrent send pool** — a bounded, rate-limit-capped executor over the claimed batch. +- **Global ops dashboard** — an always-on monitor across all sends (Inc 3 ships per-batch + history only). +- **Freeform (non-template) sends** — would require adding rendered `subject`/`body` columns back (a hybrid with + the render-at-send rows), since a freeform email has no template to render at send-time. +- **Storing sent output for audit** — render-at-send does not keep the exact bytes that went out; if a template is + later edited/deleted, past sends can't be reconstructed. Add rendered columns (or a sent-copy table) if audit needs it. + +--- + +## Cross-cutting notes for implementers + +- **External dependency:** the other team's user/pair DB. Isolated behind `RecipientSource` + nullable + `matches_id`; the CSV→DB swap touches only the source impl. +- **Ops:** production deploys must be **stop-then-start** (single-instance runner assumption). +- **Reuse, don't reinvent:** the SMTP port [EmailSender](../../src/main/java/org/patinanetwork/patchats/email/EmailSender.java), + the [TemplateRenderer](../../src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java), and + `EmailService.mergeVariables` already exist — the pipeline wraps them, it does not replace them. diff --git a/docs/email-async/01-async-send-pipeline.md b/docs/email-async/01-async-send-pipeline.md new file mode 100644 index 0000000..f37a605 --- /dev/null +++ b/docs/email-async/01-async-send-pipeline.md @@ -0,0 +1,207 @@ +# Increment 1 — Async send pipeline (backend core) + +**Prerequisites:** none (greenfield). **Delivers:** enqueue an async send and have a background runner +actually deliver it — fully functional and testable via API + dev-profile logging, **no UI yet**. +See [00-overview.md](00-overview.md) for full context and the decision table. + +## Decisions that apply here +- **DB-as-queue (outbox), no SQS** (#1) — the `emails` table *is* the queue. +- **Render at send-time** (#4) — store `template_id` + `template_values`; the runner renders `subject`/`body` per + row just before sending (rendered text is **not** stored). +- **Single instance** (#5) — no row-locking needed; deploys must be **stop-then-start**. +- **On-demand runner, no polling** (#6) — started only by an explicit kick (`POST /api/email/process`, called by + the frontend after a send / by ops) and by a startup drain. **No auto-trigger on enqueue.** +- **Sequential small batch** (#7) — claim ≤50, send one-at-a-time. +- **No auto-retry** (#8) — a failed send → terminal `ERROR`. +- **At-most-once crash recovery** (#9) — orphaned `PROCESSING` → `ERROR` on boot. +- **All sends via a `templateId`** (#16); templates **seeded read-only** here (#15). +- **`JdbcClient`, not `JdbcTemplate`** (#19). **Two scalar recipient columns** (#18). + +--- + +## 1a. Data model — `db/migration/V0004__Create_email_tables.sql` + +All three tables are created here (the FKs require it). `email_templates` is **seeded and read-only** until +Increment 5. Follow the existing style in [db/migration/](../../db/migration/) (`UUID` PKs, `TIMESTAMPTZ`, +named FK constraints). + +```sql +CREATE TABLE IF NOT EXISTS "email_templates" ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + subject TEXT NOT NULL, -- ${} template + body TEXT NOT NULL, -- ${} template + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS "email_requests" ( + id UUID PRIMARY KEY, -- requestId + label TEXT, + sender_email TEXT, + source TEXT NOT NULL, -- 'MANUAL' | 'MATCHING' + template_id UUID, + total_count INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_request_template FOREIGN KEY (template_id) REFERENCES email_templates(id) +); + +CREATE TABLE IF NOT EXISTS "emails" ( + id UUID PRIMARY KEY, -- emailId + request_id UUID NOT NULL, + matches_id UUID, -- nullable; future FK to matches + recipient_1 TEXT NOT NULL, -- per1 (always present) + recipient_2 TEXT, -- per2; NULL for a solo email, set for a pair + reply_to TEXT, + template_id UUID NOT NULL, -- load-bearing: runner renders subject/body from this + template_values JSONB NOT NULL, -- variables merged into the template at send-time + status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING | PROCESSING | SENT | ERROR + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + CONSTRAINT fk_email_request FOREIGN KEY (request_id) REFERENCES email_requests(id), + CONSTRAINT fk_email_template FOREIGN KEY (template_id) REFERENCES email_templates(id) +); + +CREATE INDEX IF NOT EXISTS idx_emails_status_created ON emails (status, created_at); -- claim query +CREATE INDEX IF NOT EXISTS idx_emails_request ON emails (request_id); -- progress (Inc 2) +CREATE INDEX IF NOT EXISTS idx_emails_matches ON emails (matches_id); -- dedup (Inc 4) +CREATE INDEX IF NOT EXISTS idx_emails_recipient_1 ON emails (recipient_1); +CREATE INDEX IF NOT EXISTS idx_emails_recipient_2 ON emails (recipient_2) WHERE recipient_2 IS NOT NULL; -- partial +``` + +Also **seed ≥1 template** in the migration (include a pairing template for Increment 4). +Recipient search later: `WHERE recipient_1 = :x OR recipient_2 = :x`; solo emails: `recipient_2 IS NULL`. + +## 1b. Persistence (net-new — first DAOs in the repo) + +Use Spring's fluent **`JdbcClient`** (auto-configured; inject directly — do **not** use `JdbcTemplate` +except where noted). Create `@Repository` classes: + +- `EmailRepo` — insert children, claim batch, update status per row, boot reset. +- `EmailRequestRepo` — insert the parent session row. +- `EmailTemplateRepo` — **read/list only** here (`findById`, `findAll`); writes arrive in Increment 5. + +Pattern: +```java +jdbcClient.sql("SELECT * FROM email_templates WHERE id = :id") + .param("id", id) + .query(new EmailTemplateRowMapper()) + .optional(); +``` +Map `JSONB` (`template_values`) ↔ Java via a small Jackson helper. For the **N-child batch insert**, drop to +the underlying `JdbcTemplate.batchUpdate(...)` (JdbcClient has no batch API yet) — inject `JdbcTemplate` +only in `EmailRepo` for that one method. + +## 1c. Enqueue path (producer) + +- **DTO** `EnqueueEmailRequest { UUID templateId, String source, String replyTo?, List messages }`, + where each `Message` carries recipients + variable maps (built by the frontend from CSV; later the DB). + Reuse the shape of the existing + [SendEmailRequest.Message/Recipient](../../src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java). +- **`EmailEnqueueService.enqueue(request)`** — in **one `@Transactional` method**: + 1. Validate `templateId` exists (`EmailTemplateRepo.findById`; `400` if unknown). **Do not render here.** + 2. Per message: build the variable map with + [`EmailService.mergeVariables`](../../src/main/java/org/patinanetwork/patchats/email/EmailService.java:83) + (no rendering). + 3. Insert one `email_requests` row (`total_count = messages.size()`) + N `emails` rows (`status='PENDING'`, + `recipient_1`/`recipient_2` from the message, `template_id`, `template_values` = the merged map). Rendering + happens later, in the runner (1e). + - **The service does not start the runner.** After the `202` returns (transaction committed), the **caller** + kicks the drain via `POST /api/email/process` (see below). This is the "manual/frontend kick only" model (#6). +- *(Optional)* dry-run render at enqueue for **early validation only** — reject a template that can't render up + front. Only the *values* are stored, never the output. Skip it for the minimal path; otherwise render errors + surface asynchronously as `ERROR` rows. +- **Endpoints** (new `EmailAsyncController` or extend the existing controller): + - `POST /api/email/send/async` → `enqueue(...)` with `source=MANUAL` → **`202 Accepted`** `{ requestId, accepted }`. + - `POST /api/email/process` → `EmailDrainer.trigger()`. **This is the primary way sending starts** — the + frontend calls it right after a `202` (the enqueue tx has committed by then, so there is no visibility race), + and ops can call it manually. Returns `202`/`200` immediately (the drain runs on the executor thread). + - `GET /api/email/templates` → read-only list (so seeded templates are usable + verifiable now). +- **Update `/preview`** in + [EmailController](../../src/main/java/org/patinanetwork/patchats/email/EmailController.java) to accept a + `templateId`, load the template + render via a **shared render helper that the runner (1e) also calls** — so + preview matches what the runner will actually send. (This shared helper is the guard against preview/runner + drift; do not duplicate render logic.) Keep the existing sync `/send` untouched for now. + +## 1d. Recipient/pair source port + +Define `interface RecipientSource` and a CSV-backed implementation for v1. In practice the frontend already +parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and posts structured messages, so the +"port" is mostly the request-DTO shape plus a clear seam; the point is that a future `DbRecipientSource` +(reading `members`/`matches`) is a **one-file swap**. Keep `matches_id` optional until then. + +## 1e. Runner (`EmailDrainer`) — on-demand kick, no polling + +- **Bean:** a single-thread `ThreadPoolTaskExecutor` named `emailDrainExecutor` (core=max=1 so drains + serialize and overlapping triggers coalesce), configured with `setWaitForTasksToCompleteOnShutdown(true)` + + an await timeout. `@EnableAsync` is already present on + [PatChatsApplication](../../src/main/java/org/patinanetwork/patchats/PatChatsApplication.java); **no** + `@EnableScheduling` / `TaskScheduler` is needed (there are no timed retries). +- **`EmailDrainer.trigger()`** submits a drain job to `emailDrainExecutor` **only if one isn't already + running** — guard with an `AtomicBoolean` via `compareAndSet`; if `trigger()` fires while a drain is + running, set a `rerun` flag so the current drain loops again instead of exiting. +- **Triggers (the only things that start a runner):** + 1. **Explicit kick** — `POST /api/email/process` calls `trigger()`. The frontend issues it right after a send's + `202` (and after a resend); ops can call it manually. Because it happens after the request's transaction has + committed, the rows are already visible — no `AFTER_COMMIT` event is needed. **There is no automatic + enqueue-time trigger** (the accepted tradeoff of #6: if the kick is never issued, the batch waits for the next + kick or a restart). + 2. **On startup** — `@EventListener(ApplicationReadyEvent.class)` first resets `PROCESSING → ERROR` + (at-most-once recovery), then calls `trigger()` once (covers rows left `PENDING` before shutdown). This is the + only safety net for a missed kick. +- **Drain job** (runs on the executor thread, loops until no rows, then the thread idles): + 1. **Claim** up to 50 `PENDING` rows atomically: + ```sql + UPDATE emails SET status='PROCESSING', updated_at=now() + WHERE id IN (SELECT id FROM emails WHERE status='PENDING' ORDER BY created_at LIMIT 50) + RETURNING *; + ``` + 2. For each claimed row **sequentially**: load its template (`template_id`) and **render** `subject`/`body` + from `template_values` via the shared render helper (the same one `/preview` uses — + [`TemplateRenderer`](../../src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java)); then build + `OutgoingEmail([recipient_1(, recipient_2)], subject, body, replyTo)` (drop a null `recipient_2`) and call + [`EmailSender.send`](../../src/main/java/org/patinanetwork/patchats/email/EmailSender.java). On success → + `status='SENT'`, `sent_at=now()` (**commit per row** — keeps any duplicate window to ≤1 email). On failure — + including a **render failure** (template edited into an invalid state, missing variable) — → `status='ERROR'`, + `error_message=ex.getMessage()` (**no retry**). *(Cache templates per drain to avoid reloading the same one + for every row in a batch.)* + 3. Re-claim; when a claim returns 0 rows, stop (honor the `rerun` flag if set). + +**Runner tradeoffs:** on-demand kick (manual/frontend API request) + single-instance + sequential is chosen +for zero idle cost. Rejected: `@Scheduled` (always-on timer for a monthly job), an **AFTER_COMMIT +enqueue-time auto-trigger** (couples sending to the enqueue transaction and needs an extra event class — the +explicit kick keeps the frontend in control and the rows are already committed by the time it fires), raw +`Thread` (reimplements lifecycle), SQS (extra infra, dual source of truth). Accepted tradeoff: if the kick is +never issued, the batch waits for the next kick or a restart (the startup drain is the safety net). +Future upgrades don't disturb the claim logic: a concurrent rate-limited pool for throughput; `SKIP LOCKED` +or ShedLock for multi-instance. + +--- + +## Files to touch +- **Create:** `db/migration/V0004__Create_email_tables.sql`; `email/` — `EmailRepo`, + `EmailRequestRepo`, `EmailTemplateRepo`, row mappers, a Jackson JSONB helper; + `EmailEnqueueService`, `dto/EnqueueEmailRequest`, `dto/EnqueueEmailResponse`; `RecipientSource` (+ CSV impl); + a shared render helper (wrapping `TemplateRenderer`, used by both `/preview` and the runner); + `EmailDrainer` (depends on `EmailTemplateRepo` + the render helper + `EmailSender`), + an executor config `@Configuration`. +- **Modify:** [EmailController](../../src/main/java/org/patinanetwork/patchats/email/EmailController.java) + (add `/send/async`, `/templates` list, update `/preview`). + +## Verification +- **Unit** (fake `EmailSender`, like + [EmailServiceTest](../../src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java)): + - `EmailEnqueueServiceTest` — enqueue stores `template_id` + `template_values` (no rendered output); an unknown + `templateId` → `400`; one parent + N children inserted in a single transaction. + - `EmailDrainerTest` — claims ≤50; **renders each row from its template** then `SENT` on success; a **send or + render** failure → terminal `ERROR` with **no** re-attempt; boot listener resets `PROCESSING → ERROR`; + overlapping `trigger()` calls coalesce to one drain. +- **Repository/integration** — Testcontainers or local Postgres ([db/README.md](../../db/README.md)) to run + the `V0004` migration and exercise the claim `UPDATE … RETURNING`. +- **End-to-end** — with the dev profile (logs instead of sending — + [LoggingEmailSender](../../src/main/java/org/patinanetwork/patchats/email/LoggingEmailSender.java)): + `just dev`, `POST /api/email/send/async`, confirm `202 {requestId}` and rows move `PENDING→PROCESSING→SENT` + in the logs; force a send failure to confirm straight-to-`ERROR`; restart mid-batch to confirm the boot + reset takes `PROCESSING→ERROR`. diff --git a/docs/email-async/02-progress-history-resend-apis.md b/docs/email-async/02-progress-history-resend-apis.md new file mode 100644 index 0000000..75138e3 --- /dev/null +++ b/docs/email-async/02-progress-history-resend-apis.md @@ -0,0 +1,76 @@ +# Increment 2 — Progress, history & resend APIs + +**Prerequisites:** [Increment 1](01-async-send-pipeline.md) (tables, rows, `EmailDrainer`). +**Delivers:** batch state queryable via API + a manual resend path. This is a read/UX-support layer over +Increment 1 — no schema changes. See [00-overview.md](00-overview.md) for full context. + +## Decisions that apply here +- **Progress = per-batch summary + per-email list** (#10), scoped by `requestId`. +- **Session model = parent `email_requests` table** (#11) — the history unit. +- **At-most-once** (#9) means `ERROR` rows may include never-sent emails, so a **manual resend** is required. + +--- + +## Endpoints + +Add to the email controller; back them with the Increment-1 repositories (add query methods as needed). + +### `GET /api/email/progress?requestId={uuid}` +Returns the live state of one batch. One `GROUP BY status` for the counts plus the row list. +```jsonc +{ + "total": 12, + "pending": 3, + "processing": 1, + "sent": 7, + "error": 1, + "emails": [ + { "id": "…", "recipients": ["a@x.com", "b@x.com"], "status": "SENT", "error": null, "sentAt": "…" }, + { "id": "…", "recipients": ["c@x.com"], "status": "ERROR", "error": "…", "sentAt": null } + ] +} +``` +`recipients` is derived from `recipient_1` (+ `recipient_2` when non-null). Counts query: +```sql +SELECT status, count(*) FROM emails WHERE request_id = :requestId GROUP BY status; +``` + +### `GET /api/email/requests` +History list for the sessions tab — one entry per `email_requests` row with aggregated child counts, +newest first. +```sql +SELECT r.id, r.source, r.template_id, r.created_at, r.total_count, + count(*) FILTER (WHERE e.status = 'SENT') AS sent, + count(*) FILTER (WHERE e.status = 'ERROR') AS error, + count(*) FILTER (WHERE e.status IN ('PENDING','PROCESSING')) AS in_flight + FROM email_requests r + JOIN emails e ON e.request_id = r.id + GROUP BY r.id + ORDER BY r.created_at DESC; +``` +Return `terminal = (in_flight == 0)` so the frontend knows whether a past session needs polling. +(Consider pagination later; not required for v1 volume.) + +### `POST /api/email/{emailId}/resend` +The manual recovery the at-most-once model requires. Flip the row `ERROR → PENDING` (clear `error_message`, +`updated_at = now()`), then call `EmailDrainer.trigger()` so it sends promptly. Reject if the row is not +currently `ERROR` (`409`/`400`). + +### `POST /api/email/process` *(optional)* +A convenience "process now" kick that just calls `EmailDrainer.trigger()`. Handy for ops; not required by +the UI. + +--- + +## Files to touch +- **Modify:** the email controller (add the three/four endpoints); Increment-1 repositories (add + `countByStatus(requestId)`, `findEmailsByRequest(requestId)`, `listRequestsWithCounts()`, + `markPending(emailId)` query methods). +- **Create:** `dto/EmailProgressResponse`, `dto/EmailRequestSummary`. + +## Verification +- **Controller/repository tests:** the aggregate counts match seeded rows; the history list returns sessions + newest-first with correct counts and `terminal`; `resend` on an `ERROR` row → `PENDING`, then (with a fake + sender) drains to `SENT`; `resend` on a non-`ERROR` row is rejected. +- **Manual (Postman):** against a batch created in Increment 1, poll `GET /progress?requestId=` and watch + counts change; call `GET /requests`; force an `ERROR`, `POST /{id}/resend`, and confirm it re-sends. diff --git a/docs/email-async/03-async-admin-frontend.md b/docs/email-async/03-async-admin-frontend.md new file mode 100644 index 0000000..525406e --- /dev/null +++ b/docs/email-async/03-async-admin-frontend.md @@ -0,0 +1,64 @@ +# Increment 3 — Async admin frontend + +**Prerequisites:** [Increment 1](01-async-send-pipeline.md) (`/send/async`, `/templates`) and +[Increment 2](02-progress-history-resend-apis.md) (`/progress`, `/requests`, `/resend`). +**Delivers:** the full user-facing manual async send experience — select a template, send, watch live +progress, review history, resend failures. See [00-overview.md](00-overview.md) for full context. + +All files live under [js/src/features/emails/](../../js/src/features/emails/). + +## Decisions that apply here +- **Progress = summary + per-email table**, **polled live**, **self-terminating** (#10). +- **History tab** of past sessions (#11). +- **All sends via a selected `templateId`** (#16) — the compose UI selects a template, not freeform text. +- **CSV is the interim recipient source** (#17) — keep the uploader. + +--- + +## Send flow (rework [EmailAdminPage](../../js/src/features/emails/EmailAdminPage.tsx)) + +- **Replace** the freeform subject/body inputs with a **template selector** populated from + `GET /api/email/templates` (read-only list from Increment 1). +- **Keep** [CsvUploader](../../js/src/features/emails/_components/CsvUploader.tsx) as the interim recipient + source, and [EmailPreviewer](../../js/src/features/emails/_components/EmailPreviewer.tsx) — but preview now + renders the **selected template** against the CSV rows (the `/preview` call sends a `templateId`). +- On **Send**: `POST /api/email/send/async`, capture the returned `requestId`, and switch the page to the + **progress view** for that batch. + +## API layer (extend [emailAPI.ts](../../js/src/features/emails/api/emailAPI.ts)) +Add: `enqueueEmails(body): Promise<{requestId, accepted}>`, `getProgress(requestId)`, `listRequests()`, +`resendEmail(emailId)`, `listTemplates()`. Follow the existing fetch + `ApiResponder` unwrap pattern already +used by `sendToPreviewApi`. + +## Progress component (new — e.g. `_components/EmailProgress.tsx`) +- **Summary tiles:** total / pending / processing / sent / error (Mantine cards or a `Group` of badges). +- **Per-email table:** recipients, a status badge (color by status), error message, `sent_at`, and a + **Resend** button on `ERROR` rows (calls `resendEmail`, which re-queues + triggers a drain). +- **Polling:** every ~2s call `getProgress(requestId)` (use `@tanstack/react-query` `refetchInterval`, or a + `useEffect` + `setInterval`). **Stop polling when `pending + processing === 0`** (batch terminal). + +## History tab (new — e.g. `_components/EmailHistory.tsx`) +- One-shot `GET /api/email/requests` → a table of past sessions (created time, source, template, sent/error + counts, terminal?). +- Row click drills into that batch's per-email table — **reuse the progress table component**, but do **not** + poll a terminal batch (fetch once). + +**Polling tradeoffs:** short-interval, self-terminating polling is chosen — trivial to build, no server-push +infra, ≤2s staleness, and chatter is bounded because polling stops at terminal state. Rejected SSE (needs a +server event stream) and WebSockets (heaviest infra) as overkill for a monthly admin action. + +--- + +## Files to touch +- **Modify:** [EmailAdminPage.tsx](../../js/src/features/emails/EmailAdminPage.tsx) (template selector + + view switch), [emailAPI.ts](../../js/src/features/emails/api/emailAPI.ts) (new fns), + [emailDto.ts](../../js/src/features/emails/dto/emailDto.ts) (progress/request/template types). +- **Create:** `_components/EmailProgress.tsx`, `_components/EmailHistory.tsx`, a shared status-badge helper, + and a template-selector component. + +## Verification +- Load a users CSV, select a seeded template, preview (confirm rendered subject/body), send. +- Watch the progress table update live and **stop polling** once the batch is terminal. +- Confirm the batch appears in the **History** tab with correct counts; open it and see the per-email rows + without re-polling. +- Force an `ERROR` (dev profile) and confirm the **Resend** button re-queues and the row goes to `SENT`. diff --git a/docs/email-async/04-matching-send-flow.md b/docs/email-async/04-matching-send-flow.md new file mode 100644 index 0000000..b52f8f3 --- /dev/null +++ b/docs/email-async/04-matching-send-flow.md @@ -0,0 +1,54 @@ +# Increment 4 — Matching send flow + +**Prerequisites:** [Increment 1](01-async-send-pipeline.md) (pipeline), +[Increment 2](02-progress-history-resend-apis.md) (progress/history), and +[Increment 3](03-async-admin-frontend.md) (progress UI to reuse). +**Delivers:** pairing notifications end-to-end. This is a **second producer** into the Increment-1 queue — +the runner, tables, and progress UI are reused unchanged. See [00-overview.md](00-overview.md) for full context. + +## Decisions that apply here +- **Matching in scope** (#12); **explicit selection**, interim rows from the **pairings CSV** (#13). +- **One email to both partners** (#14) — per match: one `emails` row, 2 recipients (`per1`/`per2`). +- **Render at send-time from `templateId` + `template_values`** (#4, #16). **CSV source behind the port** (#17). + +--- + +## Backend + +- **Endpoint:** `POST /api/email/matching/send` — accepts selected pairs (interim: rows parsed from the + uploaded [pairings-test.csv](../../js/src/features/emails/examples/pairings-test.csv)) plus a `templateId`. +- **Fan-out:** per pair → build one `Message` with **two recipients** (member A = `per1`, member B = `per2`), + then call the **same `EmailEnqueueService.enqueue(...)`** from Increment 1 with `source=MATCHING`. Each pair + becomes one `emails` row addressed to both; set `matches_id` if the CSV carries a match id, else null. +- **Variable mapping:** auto-expose per-side fields as `per1.*` / `per2.*` — `name`, `email`, `bio`, + `industry`, `role`, `topics`, `linkedUrl` — from the pair's CSV columns (see the `Pair`/`User` shapes in + [emailDto.ts](../../js/src/features/emails/dto/emailDto.ts)); shared vars (e.g. `${period}`) come from the + request. The merged map is stored as `template_values` (via `EmailService.mergeVariables` at enqueue); the + runner renders it at send-time (`TemplateRenderer`). A future DB-backed source swaps only the `RecipientSource` impl. +- **Dedup guard:** before enqueuing a pair that has a `matches_id`, check for an existing non-`ERROR` row with + that `matches_id` and skip/reject it (prevents double-notifying a pair on re-run). Uses `idx_emails_matches`. + +## Frontend + +- **Match-selection UI** (new component under [js/src/features/emails/](../../js/src/features/emails/)): + browse the uploaded pairs (scoped by cycle once DB-backed later), with a checkbox per pair. +- **Show each pair's email status** (from `matches_id` lookups) so already-sent pairs are visibly + disabled/warned — the UI half of the dedup guard. +- **Select → preview → send:** reuse [EmailPreviewer](../../js/src/features/emails/_components/EmailPreviewer.tsx) + to render a `per1`/`per2` pairing email, then `POST /api/email/matching/send`, then reuse the + **Increment-3 progress view** for live status. + +--- + +## Files to touch +- **Create (backend):** a matching controller endpoint + a `MatchingSendService` (or a method on the enqueue + service) that maps pairs → messages; `dto/MatchingSendRequest`. +- **Create (frontend):** a match-selection component; add a `sendMatchingEmails(...)` fn to + [emailAPI.ts](../../js/src/features/emails/api/emailAPI.ts). +- **Reuse:** `EmailEnqueueService`, `EmailDrainer`, the progress endpoints/UI — unchanged. + +## Verification +- Upload the pairings CSV, select a seeded **pairing** template, preview one pair and confirm both partners' + variables render (`per1.*` and `per2.*`). +- Send; confirm **one email per pair addressed to both** recipients, and watch progress in the reused view. +- Re-select an already-sent pair and confirm the dedup guard blocks it (UI disabled + backend rejects). diff --git a/docs/email-async/05-template-management.md b/docs/email-async/05-template-management.md new file mode 100644 index 0000000..1818ad9 --- /dev/null +++ b/docs/email-async/05-template-management.md @@ -0,0 +1,64 @@ +# Increment 5 — Template management (create / list / delete + UI) + +**Prerequisites:** [Increment 1](01-async-send-pipeline.md) only (the `email_templates` table + +read/list DAO). **Delivers:** self-service authoring — admins **create and delete** templates with no code. +Purely **additive**; it removes the "seeded/read-only" limitation that Increments 1–4 lived with. +See [00-overview.md](00-overview.md) for full context. + +> **Templates are immutable — there is no edit/update.** To change copy, create a new template (and delete the +> old one if it's unused). This is deliberate: with render-at-send (#4), immutability means a queued row's +> template never changes under it, so there is no in-flight edit race to reason about. + +## Decisions that apply here +- **Templates DB-stored; create/list/delete lands here** (#15). +- **All sends via a selected `templateId`** (#16) — this increment makes the selectable set self-service. +- **Render at send-time** (#4): safe here because templates are immutable — a `PENDING`/`ERROR` row always renders + from the same template it was created against. The only in-flight concern is **delete** (see below). + +--- + +## Backend — template create / list / delete + +Extend `EmailTemplateRepo` (read/list already exists from Increment 1) with `insert` and `delete`, and add +endpoints: + +- `POST /api/email/templates` — create. +- `DELETE /api/email/templates/{id}` — delete. +- *(list/read already exists: `GET /api/email/templates` from Increment 1.)* + +**Validation** (reject before save, `400`): +- `name` unique and non-blank; `subject`/`body` non-blank. +- **Well-formed `${}` placeholders** — dry-run + [TemplateRenderer](../../src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java) against a set + of sample `per1.*`/`per2.*` + shared vars and reject a template that throws on malformed syntax. +- On `DELETE`: `template_id` is **load-bearing** with a `NOT NULL` FK from `emails`, so a template referenced by + any row **cannot be hard-deleted** — the FK blocks it, and deleting one referenced by `PENDING` rows would make + them unrenderable. Recommended: **block delete** if any row references it (or add a `deleted_at` **soft-delete** + flag — hidden from the selector, kept for existing rows). Never hard-delete a referenced template. + +## Frontend — `TemplateManager` (new route/tab) + +- **(a) List/table** of templates: name, `created_at`, delete action. +- **(b) Create form:** `name` + subject + body textareas, with a **live preview** that reuses `/preview` + + [EmailPreviewer](../../js/src/features/emails/_components/EmailPreviewer.tsx) so the author sees rendered + output as they type. +- **(c) Placeholder helper:** a side panel listing the available variables (`${per1.name}`, `${per1.bio}`, …, + `${per2.*}`, and shared vars like `${period}`) so authors know what they can reference. +- **(d) Delete** with a confirm dialog (disabled/blocked for referenced templates, per the delete policy). +- After this ships, the **template selectors** in Increments 3 (manual send) and 4 (matching) read this fuller, + user-managed list instead of only the seeded rows — no change needed there beyond pointing at the same + `GET /api/email/templates`. + +--- + +## Files to touch +- **Modify (backend):** `EmailTemplateRepo` (add `insert` + `delete`); the email controller (add POST + + DELETE); add validation (reuse `TemplateRenderer` for the dry run). +- **Create (frontend):** `TemplateManager` component/route; extend + [emailAPI.ts](../../js/src/features/emails/api/emailAPI.ts) with `createTemplate` and `deleteTemplate`; + template DTO types in [emailDto.ts](../../js/src/features/emails/dto/emailDto.ts). + +## Verification +- Create a template in the UI, then use it in a **manual** send (Inc 3) and a **matching** send (Inc 4). +- Delete an **unreferenced** template succeeds; deleting a **referenced** template is blocked (or soft-deletes). +- Assert validation **rejects** a template with malformed `${}` syntax and a duplicate `name`. diff --git a/js/src/features/emails/EmailAdminPage.tsx b/js/src/features/emails/EmailAdminPage.tsx index 6770adf..3dde938 100644 --- a/js/src/features/emails/EmailAdminPage.tsx +++ b/js/src/features/emails/EmailAdminPage.tsx @@ -1,30 +1,140 @@ import { CsvUploader } from "@/features/emails/_components/CsvUploader"; +import { EmailHistory } from "@/features/emails/_components/EmailHistory"; import { EmailPreviewer } from "@/features/emails/_components/EmailPreviewer"; -import { EmailSender } from "@/features/emails/_components/EmailSender"; +import { EmailProgress } from "@/features/emails/_components/EmailProgress"; +import { TemplateSelector } from "@/features/emails/_components/TemplateSelector"; +import { enqueueEmails, triggerProcess } from "@/features/emails/api/emailAPI"; +import { + showEmailSuccess, + showEmailError, +} from "@/features/emails/api/emailError"; import { type MessagePreview, - type SendRequest, + type SendAsyncRequest, + type EnqueueEmailRequest, } from "@/features/emails/dto/emailDto"; -import { Box, Flex, Stack } from "@mantine/core"; +import { Box, Flex, Stack, Tabs, Button, Text, Group } from "@mantine/core"; import { useState } from "react"; export default function EmailAdminPage() { - const [request, setRequest] = useState(null); + const [selectedTab, setSelectedTab] = useState("send"); + const [request, setRequest] = useState(null); const [previews, setPreviews] = useState(null); + const [selectedTemplateId, setSelectedTemplateId] = useState( + null, + ); + const [requestId, setRequestId] = useState(null); + const [isSending, setIsSending] = useState(false); + + const handleAsyncSend = async () => { + if (!selectedTemplateId) { + showEmailError("Missing Template", "Please select a template"); + return; + } + if (!request) { + showEmailError("Missing Request", "Please process CSV files first"); + return; + } + + setIsSending(true); + try { + // Transform the SendRequest to EnqueueEmailRequest + const enqueueRequest: EnqueueEmailRequest = { + templateId: selectedTemplateId, + replyTo: request.replyTo || undefined, + messages: request.messages, + }; + + const response = await enqueueEmails(enqueueRequest); + showEmailSuccess("Emails Queued", `Accepted ${response.accepted} emails`); + setRequestId(response.requestId); + + // Kick the runner to start draining + await triggerProcess(); + + // Switch to progress view + setSelectedTab("progress"); + } catch (err) { + showEmailError( + "Send Failed", + err instanceof Error ? err.message : "Unknown error", + ); + } finally { + setIsSending(false); + } + }; + + const handleReset = () => { + setRequest(null); + setPreviews(null); + setSelectedTemplateId(null); + setRequestId(null); + setSelectedTab("send"); + }; + + const handleTemplateChange = (templateId: string | null) => { + setSelectedTemplateId(templateId); + setRequest(null); + setPreviews(null); + }; return ( - - - - - - - - - + + + Send Emails + {requestId && Live Progress} + History + + + + + {/* Template selector - new for async */} + + {/* CSV uploader */} + + {/* Send button */} + + + + {/* Preview */} + + + + + {requestId && ( + + + + Batch {requestId} + + + + + + )} + + + + ); } diff --git a/js/src/features/emails/MatchingSendPage.tsx b/js/src/features/emails/MatchingSendPage.tsx new file mode 100644 index 0000000..0ac7011 --- /dev/null +++ b/js/src/features/emails/MatchingSendPage.tsx @@ -0,0 +1,225 @@ +import type { + MatchingSendRequest, + Pair, + SelectedPair, +} from "@/features/emails/dto/emailDto"; + +import { PairingCsvUpload } from "@/features/emails/_components/CsvUploader"; +import { EmailProgress } from "@/features/emails/_components/EmailProgress"; +import { TemplateSelector } from "@/features/emails/_components/TemplateSelector"; +import { + sendMatchingEmails, + triggerProcess, +} from "@/features/emails/api/emailAPI"; +import { + showEmailSuccess, + showEmailError, +} from "@/features/emails/api/emailError"; +import { + Stack, + Button, + Table, + Checkbox, + Box, + Group, + Text, + Tabs, + Badge, +} from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; + +export function MatchingSendPage() { + const [selectedTab, setSelectedTab] = useState("select"); + const [pairingFile, setPairingFile] = useState(null); + const [pairs, setPairs] = useState([]); + const [selectedTemplateId, setSelectedTemplateId] = useState( + null, + ); + const [selectedPairs, setSelectedPairs] = useState>(new Set()); + const [requestId, setRequestId] = useState(null); + const [sharedVars] = useState>({}); + + const sendMutation = useMutation({ + mutationFn: async () => { + if (!selectedTemplateId) throw new Error("Template not selected"); + if (selectedPairs.size === 0) throw new Error("No pairs selected"); + + const selectedPairsList: SelectedPair[] = Array.from(selectedPairs).map( + (idx) => { + const pair = pairs[idx]; + return { + matchesId: undefined, // TODO: get from CSV if available (future feature that will prevent same pairing being emailed twice) + per1: { + name: pair.fullNameA, + email: pair.emailA, + }, + per2: { + name: pair.fullNameB, + email: pair.emailB, + }, + }; + }, + ); + + const request: MatchingSendRequest = { + templateId: selectedTemplateId, // TODO: validate UUID format (future feature will validate UUID, but for now we just check that the user has selected a template) + replyTo: null, + pairs: selectedPairsList, + sharedVariables: sharedVars, + }; + + const response = await sendMatchingEmails(request); + await triggerProcess(); + return response; + }, + onSuccess: (response) => { + if (!response.requestId) { + // No session was created — every selected pair had already been sent. Don't open a phantom progress view. + showEmailSuccess( + "Nothing to Send", + "All selected pairs were already sent", + ); + return; + } + showEmailSuccess( + "Pairing Emails Queued", + `Accepted ${response.accepted} pairing emails`, + ); + setRequestId(response.requestId); + setSelectedTab("progress"); + }, + onError: (error) => { + showEmailError( + "Send Failed", + error instanceof Error ? error.message : "Unknown error", + ); + }, + }); + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedPairs(new Set(pairs.map((_, i) => i))); + } else { + setSelectedPairs(new Set()); + } + }; + + const handleSelectPair = (index: number) => { + const newSelected = new Set(selectedPairs); + if (newSelected.has(index)) { + newSelected.delete(index); + } else { + newSelected.add(index); + } + setSelectedPairs(newSelected); + }; + + const rows = pairs.map((pair, index) => { + const isSelected = selectedPairs.has(index); + return ( + + + handleSelectPair(index)} + /> + + + {pair.fullNameA} + + + {pair.emailA} + + + {pair.fullNameB} + + + {pair.emailB} + + + ); + }); + + return ( + + + Select Pairs + {requestId && Live Progress} + + + + {/* Template & CSV upload */} + + + + + + + {/* Pair selection table */} + {pairs.length > 0 && ( + + + + Pairs ({selectedPairs.size} of {pairs.length} selected) + + + + + + + 0 && + selectedPairs.size < pairs.length + } + onChange={(e) => + handleSelectAll(e.currentTarget.checked) + } + /> + + Name (A) + Email (A) + Name (B) + Email (B) + + + {rows} +
+
+ )} + {/* Send button */} + +
+
+ {requestId && ( + + + + Batch {requestId} + Matching + + + + + )} +
+ ); +} diff --git a/js/src/features/emails/TemplateManager.tsx b/js/src/features/emails/TemplateManager.tsx new file mode 100644 index 0000000..58b41b9 --- /dev/null +++ b/js/src/features/emails/TemplateManager.tsx @@ -0,0 +1,347 @@ +import type { + EmailTemplate, + SendRequest, + MessagePreview, +} from "@/features/emails/dto/emailDto"; + +import { EmailPreviewer } from "@/features/emails/_components/EmailPreviewer"; +import { + listTemplates, + createTemplate, + deleteTemplate, + sendToLegacyPreviewApi, +} from "@/features/emails/api/emailAPI"; +import { + showEmailSuccess, + showEmailError, +} from "@/features/emails/api/emailError"; +import { + Tabs, + Stack, + Table, + Button, + Modal, + Text, + TextInput, + Textarea, + Group, + ActionIcon, + Tooltip, + Box, + Flex, +} from "@mantine/core"; +import { IconTrash, IconPlus } from "@tabler/icons-react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +/** + * UNREACHABLE: This component is currently not reachable in the app's UI. It is intended for future implementation in managing email templates. + * TemplateManager component allows users to view, create, and delete email templates. + * It also provides a preview feature to visualize how the email will look with sample data. + */ +export function TemplateManager() { + const queryClient = useQueryClient(); + const [showCreateModal, setShowCreateModal] = useState(false); + const [formData, setFormData] = useState({ name: "", subject: "", body: "" }); + const [previewData, setPreviewData] = useState(null); + + const { data: templates, isLoading } = useQuery({ + queryKey: ["emailTemplates"], + queryFn: () => listTemplates(), + }); + + const createMutation = useMutation({ + mutationFn: async () => { + return await createTemplate({ + name: formData.name, + subject: formData.subject, + body: formData.body, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["emailTemplates"] }); + showEmailSuccess( + "Template Created", + `"${formData.name}" created successfully`, + ); + setFormData({ name: "", subject: "", body: "" }); + setShowCreateModal(false); + }, + onError: (error) => { + showEmailError( + "Creation Failed", + error instanceof Error ? error.message : "Unknown error", + ); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (templateId: string) => { + return await deleteTemplate(templateId); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["emailTemplates"] }); + showEmailSuccess("Template Deleted", "Template removed successfully"); + }, + onError: (error) => { + showEmailError( + "Delete Failed", + error instanceof Error ? error.message : "Unknown error", + ); + }, + }); + + const handlePreviewTemplate = async () => { + if (!formData.subject || !formData.body) { + showEmailError("Missing Fields", "Subject and body are required"); + return; + } + + try { + // Create fake messages with sample variables to preview + const request: SendRequest = { + subject: formData.subject, + body: formData.body, + replyTo: null, + messages: [ + { + recipients: [ + { + email: "sample@example.com", + variableToValue: { + "per1.name": "Alice", + "per1.email": "alice@example.com", + "per2.name": "Bob", + "per2.email": "bob@example.com", + }, + }, + ], + }, + ], + }; + + const previews = await sendToLegacyPreviewApi(request); + setPreviewData(previews || []); + } catch (error) { + showEmailError( + "Preview Failed", + error instanceof Error ? error.message : "Unknown error", + ); + } + }; + + const rows = (templates || []).map((template) => ( + + + + {template.name} + + + + + {new Date(template.createdAt).toLocaleDateString()} + + + + + { + if (confirm(`Delete template "${template.name}"?`)) { + deleteMutation.mutate(template.id); + } + }} + loading={deleteMutation.isPending} + > + + + + + + )); + + return ( + + + Templates ({templates?.length || 0}) + Create New + + + + + Available Templates + + + {isLoading ? + Loading templates... + : (templates || []).length === 0 ? + No templates yet. Create one to get started. + : + + + Name + Created + Action + + + {rows} +
+ } +
+
+ + + + Create New Template + + setFormData({ + ...formData, + name: e.currentTarget.value, + }) + } + /> +