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/app/router/router.tsx b/js/src/app/router/router.tsx index bb70f14..28d4957 100644 --- a/js/src/app/router/router.tsx +++ b/js/src/app/router/router.tsx @@ -5,12 +5,29 @@ import { RequireAdmin } from "@/app/router/guards/RequireAdmin"; import { RequireAuth } from "@/app/router/guards/RequireAuth"; import AdminPage from "@/features/admin/Admin.page"; import AdminLoginPage from "@/features/admin/AdminLogin.page"; -import EmailAdminPage from "@/features/emails/EmailAdminPage"; +import EmailAdminPage, { + EmailAdminHistoryDetailPage, + EmailAdminHistoryPage, + EmailAdminProgressFallbackPage, + EmailAdminProgressPage, + EmailAdminSendPage, +} from "@/features/emails/EmailAdminPage"; import HomePage from "@/features/home/Home.page"; import SamplePage from "@/features/sample/Sample.page"; import SampleAdminPage from "@/features/sample/SampleAdmin.page"; import { SignUpPage } from "@/features/sign-up/SignUp.page"; -import { createBrowserRouter } from "react-router-dom"; +import { Navigate, createBrowserRouter } from "react-router-dom"; + +function createEmailAdminTabRoutes() { + return [ + { index: true, element: }, + { path: "send", element: }, + { path: "progress", element: }, + { path: "progress/:requestId", element: }, + { path: "history", element: }, + { path: "history/:requestId", element: }, + ]; +} export const router = createBrowserRouter([ // Public admin login: its page owns the full viewport. @@ -24,8 +41,12 @@ export const router = createBrowserRouter([ { path: "sign-up", element: }, ], }, - // Temporary public email admin page for TESTING - { path: "email", element: }, + // Temporary public email routes for TESTING (before auth is wired) + { + path: "email", + element: , + children: createEmailAdminTabRoutes(), + }, // Authenticated: guard -> layout -> page. Admin nests a second guard + layout. { element: , @@ -41,7 +62,11 @@ export const router = createBrowserRouter([ element: , children: [ { path: "admin", element: }, - { path: "admin/email", element: }, + { + path: "admin/email", + element: , + children: createEmailAdminTabRoutes(), + }, { path: "sample/admin", element: }, ], }, diff --git a/js/src/features/emails/EmailAdminPage.tsx b/js/src/features/emails/EmailAdminPage.tsx index 6770adf..4c891d4 100644 --- a/js/src/features/emails/EmailAdminPage.tsx +++ b/js/src/features/emails/EmailAdminPage.tsx @@ -1,24 +1,141 @@ 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"; +import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom"; + +type EmailAdminTabValue = "send" | "progress" | "history"; + +function getSelectedTab(pathname: string): EmailAdminTabValue { + if (pathname.includes("/history")) { + return "history"; + } + if (pathname.includes("/progress")) { + return "progress"; + } + return "send"; +} export default function EmailAdminPage() { - const [request, setRequest] = useState(null); + const location = useLocation(); + const navigate = useNavigate(); + const selectedTab = getSelectedTab(location.pathname); + + const handleTabChange = (value: string | null) => { + if (value === "send") { + navigate("send"); + return; + } + if (value === "progress") { + navigate("progress"); + return; + } + if (value === "history") { + navigate("history"); + } + }; + + return ( + + + Send Emails + Live Progress + History + + + + + + ); +} + +export function EmailAdminSendPage() { + const [request, setRequest] = useState(null); const [previews, setPreviews] = useState(null); + const [selectedTemplateId, setSelectedTemplateId] = useState( + null, + ); + const [isSending, setIsSending] = useState(false); + const navigate = useNavigate(); + + 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`); + + // Kick the runner to start draining + await triggerProcess(); + + navigate(`../progress/${response.requestId}`); + } catch (err) { + showEmailError( + "Send Failed", + err instanceof Error ? err.message : "Unknown error", + ); + } finally { + setIsSending(false); + } + }; + + const handleTemplateChange = (templateId: string | null) => { + setSelectedTemplateId(templateId); + setRequest(null); + setPreviews(null); + }; return ( - - + {/* Template selector - new for async */} + + {/* CSV uploader */} + + {/* Send button */} + + {/* Preview */} ); } + +export function EmailAdminProgressPage() { + const { requestId } = useParams<{ requestId: string }>(); + const navigate = useNavigate(); + + if (!requestId) { + return Missing request ID.; + } + + return ( + + + Batch {requestId} + + + + + ); +} + +export function EmailAdminProgressFallbackPage() { + return ( + + No Email Sending Session in progress. Please select a batch from history + + ); +} + +export function EmailAdminHistoryPage() { + const navigate = useNavigate(); + + return navigate(requestId)} />; +} + +export function EmailAdminHistoryDetailPage() { + const { requestId } = useParams<{ requestId: string }>(); + const navigate = useNavigate(); + + return ( + navigate("..")} + /> + ); +} diff --git a/js/src/features/emails/_components/CsvUploader.tsx b/js/src/features/emails/_components/CsvUploader.tsx index ce7571a..e68d34b 100644 --- a/js/src/features/emails/_components/CsvUploader.tsx +++ b/js/src/features/emails/_components/CsvUploader.tsx @@ -1,10 +1,10 @@ -import type { Pair, SendRequest, User } from "@/features/emails/dto/emailDto"; +import type { + Pair, + SendAsyncRequest, + User, +} from "@/features/emails/dto/emailDto"; import { showEmailError } from "@/features/emails/api/emailError"; -import { - EmailTemplate, - emailTemplateMap, -} from "@/features/emails/api/emailTemplate"; import { dataToSendRequest, parseUserFile, @@ -13,13 +13,11 @@ import { import { FileInput, Button, - NativeSelect, Flex, Box, ScrollArea, Text, Table, - Spoiler, } from "@mantine/core"; import { useEffect, useState } from "react"; @@ -33,31 +31,32 @@ const rowStyle = { /** * Main component for user and pairing CSV file uploaders, an email template dropdown, and email generation button. - * @param setRequest - A function to set the SendRequest object in the parent component. - * @returns A component that allows users to upload CSV files, select an email template, and generate a SendRequest object. + * @param setRequest - A function to set the SendAsyncRequest object in the parent component. + * @returns A component that allows users to upload CSV files, select an email template, and generate a SendAsyncRequest object. */ export function CsvUploader({ + templateId, setRequest, }: { - setRequest: React.Dispatch>; + templateId: string | null; + setRequest: React.Dispatch>; }) { const [userMap, setUserMap] = useState>(new Map()); const [pairList, setPairList] = useState([]); const [userFile, setUserFile] = useState(null); const [pairingFile, setPairingFile] = useState(null); - const [template, setTemplate] = useState(""); const handleCSV = async () => { if (!userMap || userMap.size === 0) { showEmailError("Missing file", "Please upload a User CSV."); return; } - if (template === "") { + if (!templateId) { showEmailError("Missing template", "Please select a template."); return; } try { - const req = await dataToSendRequest(userMap, pairList, template); + const req = await dataToSendRequest(userMap, pairList, templateId); setRequest(req); } catch (err) { showEmailError("Error when processing CSV", `${err}`); @@ -76,10 +75,7 @@ export function CsvUploader({ setPairFile={setPairingFile} setPairs={setPairList} /> - - + ); } @@ -250,51 +246,3 @@ export function PairingCsvUpload({ ); } - -/** - * Component for email template selection. - * @param selection - The currently selected email template. (undefined if no template is selected) - * @param setSelection - A function to handle changes to the selected email template. - * @returns Email template selection dropdown and template text preview. - */ -export function TemplateSelect({ - selection, - setSelection, -}: { - selection: string | undefined; - setSelection: React.Dispatch>; -}) { - const [template, setTemplate] = useState(); - - useEffect(() => { - if (selection) { - // Display the appropriate template - const template = emailTemplateMap[selection]; - setTemplate(template); - } else { - setTemplate(undefined); - } - }, [selection]); - - return ( - - setSelection(event.currentTarget.value)} - label="Email Templates" - description="Select an email template" - data={[{ label: "Select a template", value: "" }, "Pair", "Reminder"]} - /> - - - Subject: {template ? template.subject : ""} -
- - Body:{" "} - {template ? template.body : "Select a template to see its content."} - -
-
-
- ); -} diff --git a/js/src/features/emails/_components/EmailHistory.tsx b/js/src/features/emails/_components/EmailHistory.tsx new file mode 100644 index 0000000..7190512 --- /dev/null +++ b/js/src/features/emails/_components/EmailHistory.tsx @@ -0,0 +1,120 @@ +import type { EmailRequestSummary } from "@/features/emails/dto/emailDto"; + +import { EmailProgress } from "@/features/emails/_components/EmailProgress"; +import { listRequests } from "@/features/emails/api/emailAPI"; +import { Table, Text, Title, Badge, Stack, Group } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +interface EmailHistoryProps { + detailRequestId?: string | null; + onSelectRequest?: (requestId: string) => void; + onBack?: () => void; +} + +export function EmailHistory({ + detailRequestId, + onSelectRequest, + onBack, +}: EmailHistoryProps = {}) { + const [localSelectedRequestId, setLocalSelectedRequestId] = useState< + string | null + >(null); + const selectedRequestId = detailRequestId ?? localSelectedRequestId; + + const handleSelectRequest = + onSelectRequest ?? + ((requestId: string) => setLocalSelectedRequestId(requestId)); + const handleBack = onBack ?? (() => setLocalSelectedRequestId(null)); + + const { + data: requests, + isLoading, + error, + } = useQuery({ + queryKey: ["emailRequests"], + queryFn: () => listRequests(), + }); + + if (isLoading) return Loading history...; + + if (selectedRequestId) { + return ( + + + Batch Details + + ← Back to history + + + + + ); + } + + const rows = (requests || []).map((req) => ( + handleSelectRequest(req.id)} + style={{ cursor: "pointer" }} + className="hover:bg-gray-50" + > + + {new Date(req.createdAt).toLocaleString()} + + + + {req.source} + + + + {req.total} + + + + + Sent: {req.sent} + + + Error: {req.error} + + + + + + {req.terminal ? "Complete" : "In Progress"} + + + + )); + + return ( + + Email History + {error || (requests && requests.length === 0) ? + No email history yet. + : <> + + Click a row to view batch details + + + + + Created + Source + Total + Results + Status + + + {rows} +
+ + } +
+ ); +} diff --git a/js/src/features/emails/_components/EmailPreviewer.tsx b/js/src/features/emails/_components/EmailPreviewer.tsx index 9a41db2..69136d3 100644 --- a/js/src/features/emails/_components/EmailPreviewer.tsx +++ b/js/src/features/emails/_components/EmailPreviewer.tsx @@ -1,6 +1,6 @@ import type { MessagePreview, - SendRequest, + SendAsyncRequest, } from "@/features/emails/dto/emailDto"; import { sendToPreviewApi } from "@/features/emails/api/emailAPI"; @@ -14,10 +14,8 @@ import { useEffect } from "react"; /** * Component that displays the rendered emails returned by the /api/email/preview endpoint in a carousel. * - * Note: this takes the *rendered* previews (MessagePreview[]), not the raw object from dataToSendRequest. - * dataToSendRequest returns a SendEmailRequest whose subject/body still contain unresolved ${...} placeholders; - * passing that here would show literal template syntax. Render it first via previewEmails(request), then - * pass the result in so the user sees exactly what will be sent. + * Note: this takes the *rendered* previews (MessagePreview[]), not the SendAsyncRequest. + * The request is passed for query key dependency only. */ export function EmailPreviewer({ previews, @@ -26,7 +24,7 @@ export function EmailPreviewer({ }: { previews: MessagePreview[] | null; setPreviews: React.Dispatch>; - request: SendRequest | null; + request: SendAsyncRequest | null; }) { const { data, status } = useQuery({ queryKey: ["preview", request], diff --git a/js/src/features/emails/_components/EmailProgress.tsx b/js/src/features/emails/_components/EmailProgress.tsx new file mode 100644 index 0000000..0dc6416 --- /dev/null +++ b/js/src/features/emails/_components/EmailProgress.tsx @@ -0,0 +1,207 @@ +import type { EmailProgress } from "@/features/emails/dto/emailDto"; + +import { StatusBadge } from "@/features/emails/_components/StatusBadge"; +import { + getProgress, + resendEmail, + triggerProcess, +} from "@/features/emails/api/emailAPI"; +import { + showEmailSuccess, + showEmailError, +} from "@/features/emails/api/emailError"; +import { + SimpleGrid, + Stack, + Table, + Text, + Title, + Badge, + ActionIcon, + Tooltip, +} from "@mantine/core"; +import { IconRefresh } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +interface EmailProgressProps { + requestId: string; +} + +export function EmailProgress({ requestId }: EmailProgressProps) { + const [isTerminal, setIsTerminal] = useState(false); + const [isResending, setIsResending] = useState(null); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["emailProgress", requestId], + queryFn: () => getProgress(requestId), + refetchInterval: (query) => { + const data = query.state.data; + // Stop polling when batch is terminal (no pending or processing emails) + if (data && data.pending + data.processing === 0) { + setIsTerminal(true); + return false; // stops polling + } + return 2000; // poll every 2 seconds + }, + enabled: !!requestId, + }); + + const handleResend = async (emailId: string) => { + setIsResending(emailId); + try { + await resendEmail(emailId); + showEmailSuccess("Resend", `Email ${emailId} re-queued successfully`); + // Trigger the runner to send it + await triggerProcess(); + // Refetch to show updated status + await refetch(); + } catch (err) { + showEmailError( + "Resend Failed", + err instanceof Error ? err.message : "Unknown error", + ); + } finally { + setIsResending(null); + } + }; + + if (isLoading) return Loading progress...; + if (error) + return Error loading progress: {error.message}; + if (!data) return No progress data; + + const rows = data.emails.map((email) => ( + + + {email.recipients.join(", ")} + + + + + + + {email.error || "—"} + + + + + {email.sentAt ? new Date(email.sentAt).toLocaleString() : "—"} + + + + {email.status === "ERROR" && ( + + handleResend(email.id)} + loading={isResending === email.id} + color="red" + > + + + + )} + + + )); + + return ( + + + Live Progress {isTerminal && <Badge color="green">Complete</Badge>} + + {/* Summary tiles */} + +
+ + Total + + + {data.total} + +
+
+ + Pending + + + {data.pending} + +
+
+ + Processing + + + {data.processing} + +
+
+ + Sent + + + {data.sent} + +
+
+ + Error + + + {data.error} + +
+
+ {/* Per-email table */} +
+ + Emails ({data.emails.length}) + + + + + Recipients + Status + Error + Sent At + Action + + + {rows} +
+
+
+ ); +} diff --git a/js/src/features/emails/_components/StatusBadge.tsx b/js/src/features/emails/_components/StatusBadge.tsx new file mode 100644 index 0000000..704071f --- /dev/null +++ b/js/src/features/emails/_components/StatusBadge.tsx @@ -0,0 +1,31 @@ +import { Badge } from "@mantine/core"; + +export type StatusType = "PENDING" | "PROCESSING" | "SENT" | "ERROR"; + +function getStatusColor(status: StatusType): string { + switch (status) { + case "PENDING": + return "gray"; + case "PROCESSING": + return "blue"; + case "SENT": + return "green"; + case "ERROR": + return "red"; + default: + return "gray"; + } +} + +interface StatusBadgeProps { + status: StatusType; + size?: "xs" | "sm" | "md" | "lg" | "xl"; +} + +export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) { + return ( + + {status} + + ); +} diff --git a/js/src/features/emails/_components/TemplateSelector.tsx b/js/src/features/emails/_components/TemplateSelector.tsx new file mode 100644 index 0000000..9b82134 --- /dev/null +++ b/js/src/features/emails/_components/TemplateSelector.tsx @@ -0,0 +1,96 @@ +import type { EmailTemplate } from "@/features/emails/dto/emailDto"; + +import { listTemplates } from "@/features/emails/api/emailAPI"; +import { Select, Stack, Text, Badge, Box, Spoiler } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +interface TemplateSelectorProps { + value: string | null; + onChange: (templateId: string | null) => void; + label?: string; + error?: string; +} + +export function TemplateSelector({ + value, + onChange, + label = "Select Template", + error, +}: TemplateSelectorProps) { + const { + data: templates, + isLoading, + error: queryError, + } = useQuery({ + queryKey: ["emailTemplates"], + queryFn: () => listTemplates(), + }); + + const selectedTemplate = templates?.find((t) => t.id === value); + + return ( + +