diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e0911ad..4706c15ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to this project will be documented in this file. - **Feature**: The analytics API gives when each transactional notification last sent successfully, with the new `last_sent_at` measure of message history, and `messages.list` filters by `transactional_notification_id`. -- **Feature**: The API counts the recipients of a broadcast audience before it is sent, with the new `broadcasts.estimateAudience`. `broadcasts.list` gives the number of broadcasts in each status (`with_status_counts`) and keeps the ones started from a date (`started_after`), and the message history analytics take `opened_at` and `clicked_at` as time dimensions. +- **Feature**: The API counts the recipients of a broadcast audience before it is sent, with the new `broadcasts.estimateAudience`. `broadcasts.list` gives the number of broadcasts in each status (`with_status_counts`) and keeps the ones started from a date (`started_after`), and the message history analytics take `opened_at` and `clicked_at` as time dimensions. In the console, broadcasts get a new list with status tabs, a search and their 30-day results, a detail page with the funnel, opens and clicks over time, the A/B test and the clicked links, and a one-screen editor that saves as you type, previews the email and checks everything before sending. - **Feature**: The API gives the statistics of each step of an automation, over a period or all time, with the new `automations.nodeStats`: visits, results, the path each split took, the journeys waiting or retrying there, and for email steps the messages sent, delivered, opened and clicked. `automations.list` gives each automation's journeys by status (`with_journey_counts`), `messages.list` filters by `automation_id`, and the message history analytics split by `automation_id`. diff --git a/console/e2e/features/broadcasts.spec.ts b/console/e2e/features/broadcasts.spec.ts index 4c731a7ac..590e8ea15 100644 --- a/console/e2e/features/broadcasts.spec.ts +++ b/console/e2e/features/broadcasts.spec.ts @@ -1,322 +1,135 @@ import { test, expect, requestCapture } from '../fixtures/auth' import { waitForLoading } from '../fixtures/test-utils' import { API_PATTERNS } from '../fixtures/request-capture' -import { testBroadcastData } from '../fixtures/form-data' import { logCapturedRequests } from '../fixtures/payload-assertions' import type { Locator, Page } from '@playwright/test' const WORKSPACE_ID = 'test-workspace' +const BROADCASTS = `/console/workspace/${WORKSPACE_ID}/broadcasts` -// antd 6 puts role="dialog" on the drawer section itself and names it after the -// drawer title, so the title is what the dialog is looked up by. -const CREATE_DRAWER = 'Create a broadcast' -const EDIT_DRAWER = 'Edit broadcast' - -// The card header of a given broadcast: it carries the status badge and the -// icon-only action buttons. -const cardHead = (page: Page, broadcastName: string): Locator => - page.locator('.ant-card-head').filter({ hasText: broadcastName }) - -// The whole card of a given broadcast, header plus body. -const card = (page: Page, broadcastName: string): Locator => - page.locator('.ant-card').filter({ hasText: broadcastName }) - -// Opens the creation drawer and returns it. -const openCreateDrawer = async (page: Page): Promise => { - await page.getByRole('button', { name: 'Create Broadcast', exact: true }).click() - const drawer = page.getByRole('dialog', { name: CREATE_DRAWER }) - await expect(drawer).toBeVisible() - return drawer -} - -// Opens the edit drawer of an existing broadcast and returns it. The edit action is -// an icon-only button, and FontAwesome stamps the icon name onto the svg, which is -// the only stable handle it exposes. -const openEditDrawer = async (page: Page, broadcastName: string): Promise => { - await cardHead(page, broadcastName).locator('button:has(svg[data-icon="pen-to-square"])').click() - const drawer = page.getByRole('dialog', { name: EDIT_DRAWER }) - await expect(drawer).toBeVisible() - return drawer -} - -// Picks an option in an antd Select rendered inside `drawer`, identified by the -// label of its form item. -const selectOption = async ( - page: Page, - drawer: Locator, - fieldLabel: string, - optionText: string -): Promise => { - await drawer.getByLabel(fieldLabel, { exact: true }).click() - await page.locator('.ant-select-dropdown').waitFor({ state: 'visible' }) - await page.locator('.ant-select-item-option').filter({ hasText: optionText }).click() -} - -// The text antd 6 shows for the current value of a Select (v5 named it -// .ant-select-selection-item). -const selectValue = (drawer: Locator, fieldLabel: string): Locator => - drawer.locator('.ant-form-item').filter({ hasText: fieldLabel }).locator('.ant-select-content') +// The table row of a given broadcast. +const row = (page: Page, broadcastName: string): Locator => + page.locator('.ant-table-tbody tr').filter({ hasText: broadcastName }) -test.describe('Broadcasts Feature', () => { - test.describe('Page Load & Empty State', () => { - test('loads broadcasts page and shows empty state', async ({ authenticatedPage }) => { - const page = authenticatedPage - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - await expect(page.getByText('No broadcasts found')).toBeVisible() - await expect(page.getByRole('button', { name: 'Create Broadcast', exact: true })).toBeVisible() - }) - - test('loads broadcasts page with data', async ({ authenticatedPageWithData }) => { - const page = authenticatedPageWithData - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - await expect(page).toHaveURL(/broadcasts/) - - // One card per broadcast returned by the API - await expect(cardHead(page, 'January Newsletter')).toBeVisible() - await expect(cardHead(page, 'Product Launch')).toBeVisible() - await expect(cardHead(page, 'A/B Test Campaign')).toBeVisible() - }) - }) +// The composer and the detail are full drawers: antd puts role="dialog" on them. +const drawer = (page: Page): Locator => page.locator('.ant-drawer-open [role="dialog"]').last() - test.describe('CRUD Operations', () => { - test('opens create broadcast form', async ({ authenticatedPage }) => { +test.describe('Broadcasts Feature', () => { + test.describe('List', () => { + test('shows the first-broadcast empty state', async ({ authenticatedPage }) => { const page = authenticatedPage - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) + await page.goto(BROADCASTS) await waitForLoading(page) - const drawer = await openCreateDrawer(page) - - await expect(drawer.getByLabel('Broadcast name')).toBeVisible() - await expect(drawer.getByLabel('List', { exact: true })).toBeVisible() - await expect(drawer.getByRole('button', { name: 'Next', exact: true })).toBeVisible() + await expect(page.getByText('No broadcasts yet')).toBeVisible() + await expect(page.getByRole('button', { name: 'New broadcast' })).toBeVisible() }) - test('fills broadcast form', async ({ authenticatedPageWithData }) => { + test('lists each broadcast with its status', async ({ authenticatedPageWithData }) => { const page = authenticatedPageWithData - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) + await page.goto(BROADCASTS) await waitForLoading(page) - const drawer = await openCreateDrawer(page) - - // Tab 1: Audience - the two required fields - const nameInput = drawer.getByLabel('Broadcast name') - await nameInput.fill('Test Marketing Broadcast') - await expect(nameInput).toHaveValue('Test Marketing Broadcast') - - await selectOption(page, drawer, 'List', 'Newsletter') - await expect(selectValue(drawer, 'List')).toHaveText('Newsletter') - - await expect(drawer.getByRole('button', { name: 'Next', exact: true })).toBeVisible() + await expect(row(page, 'January Newsletter').getByText('Draft', { exact: true })).toBeVisible() + await expect(row(page, 'Product Launch').getByText('Sent', { exact: true })).toBeVisible() + await expect(row(page, 'A/B Test Campaign').getByText('Scheduled', { exact: true })).toBeVisible() + // The A/B broadcast says how many variants it tests. + await expect(row(page, 'A/B Test Campaign').getByText('A/B · 2 variants')).toBeVisible() }) - test('views broadcast details', async ({ authenticatedPageWithData }) => { + test('writes the status tab and the search to the URL', async ({ authenticatedPageWithData }) => { const page = authenticatedPageWithData - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) + await page.goto(BROADCASTS) await waitForLoading(page) - // Only the first card is expanded by default - const productLaunch = card(page, 'Product Launch') - await productLaunch.getByRole('button', { name: 'Show Details' }).click() + await page.locator('.ant-segmented-item').filter({ hasText: 'Drafts' }).click() + await expect(page).toHaveURL(/status=draft/) - // The audience the broadcast targets - await expect(productLaunch.getByText('Marketing Updates')).toBeVisible() - await expect(productLaunch.getByText('Active Users')).toBeVisible() + await page.getByRole('textbox', { name: 'Search by name' }).fill('launch') + await expect(page).toHaveURL(/q=launch/) }) }) - test.describe('Audience Selection', () => { - test('shows audience selection options', async ({ authenticatedPageWithData }) => { + test.describe('Detail', () => { + test('opens a sent broadcast in the detail drawer', async ({ authenticatedPageWithData }) => { const page = authenticatedPageWithData - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) + await page.goto(BROADCASTS) await waitForLoading(page) - const drawer = await openCreateDrawer(page) - - await expect(drawer.getByLabel('List', { exact: true })).toBeVisible() - await expect(drawer.getByText('Belonging to at least one of the following segments')).toBeVisible() - await expect( - drawer.getByText('Exclude unsubscribed, bounced & complained recipients') - ).toBeVisible() - - // The list options are the workspace lists - await drawer.getByLabel('List', { exact: true }).click() - await page.locator('.ant-select-dropdown').waitFor({ state: 'visible' }) - await expect(page.locator('.ant-select-item-option').filter({ hasText: 'Newsletter' })).toBeVisible() - await expect( - page.locator('.ant-select-item-option').filter({ hasText: 'Marketing Updates' }) - ).toBeVisible() - await expect( - page.locator('.ant-select-item-option').filter({ hasText: 'Beta Testers' }) - ).toBeVisible() + await row(page, 'Product Launch').click() + await expect(page).toHaveURL(/broadcast=bc-2/) + const detail = drawer(page) + await expect(detail.getByRole('heading', { name: 'Product Launch' })).toBeVisible() + await expect(detail.getByText('Audience', { exact: true })).toBeVisible() }) }) - test.describe('Scheduling', () => { - test('shows the scheduled delivery of a scheduled broadcast', async ({ - authenticatedPageWithData - }) => { + test.describe('Composer', () => { + test('opens a draft in the composer with its values', async ({ authenticatedPageWithData }) => { const page = authenticatedPageWithData - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) + await page.goto(BROADCASTS) await waitForLoading(page) - const abTest = card(page, 'A/B Test Campaign') - await abTest.getByRole('button', { name: 'Show Details' }).click() - - // schedule.scheduled_date 2024-02-01 at 09:00 in UTC - await expect(abTest.getByText('Feb 1, 2024 9:00 AM in UTC')).toBeVisible() + await row(page, 'January Newsletter').click() + await expect(page).toHaveURL(/compose=bc-1/) + const composer = drawer(page) + await expect(composer.getByRole('textbox', { name: 'Broadcast name' })).toHaveValue('January Newsletter') + await expect(composer.getByText('Newsletter', { exact: true }).first()).toBeVisible() }) - }) - test.describe('Status Display', () => { - test('displays broadcast status', async ({ authenticatedPageWithData }) => { + test('creates the broadcast once a list is chosen', async ({ authenticatedPageWithData }) => { const page = authenticatedPageWithData - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) + await page.goto(BROADCASTS) await waitForLoading(page) - await expect(cardHead(page, 'January Newsletter').getByText('Draft')).toBeVisible() - await expect(cardHead(page, 'Product Launch').getByText('Complete')).toBeVisible() - await expect(cardHead(page, 'A/B Test Campaign').getByText('Scheduled')).toBeVisible() - }) + await page.getByRole('button', { name: 'New broadcast' }).click() + await expect(page).toHaveURL(/compose=new/) + const composer = drawer(page) - test('shows draft broadcasts', async ({ authenticatedPageWithData }) => { - const page = authenticatedPageWithData - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - // A draft is the only status that offers both editing and sending - const draft = cardHead(page, 'January Newsletter') - await expect(draft.locator('button:has(svg[data-icon="pen-to-square"])')).toBeVisible() - await expect(draft.getByRole('button', { name: 'Send or Schedule' })).toBeVisible() - - // A completed broadcast offers neither - const sent = cardHead(page, 'Product Launch') - await expect(sent.locator('button:has(svg[data-icon="pen-to-square"])')).toHaveCount(0) - await expect(sent.getByRole('button', { name: 'Send or Schedule' })).toHaveCount(0) - }) - }) + await composer.getByRole('textbox', { name: 'Broadcast name' }).fill('Spring sale') + await composer.getByRole('combobox', { name: 'List' }).click() + await page.locator('.ant-select-item-option').filter({ hasText: 'Newsletter' }).first().click() - test.describe('Statistics', () => { - test('displays broadcast statistics', async ({ authenticatedPageWithData }) => { - const page = authenticatedPageWithData - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - const stats = card(page, 'January Newsletter') - for (const label of [ - 'Sent', - 'Delivered', - 'Opens', - 'Clicks', - 'Failed', - 'Bounced', - 'Complaints', - 'Unsub.' - ]) { - await expect(stats.getByText(label, { exact: true })).toBeVisible() + // The autosave creates the broadcast, then the URL carries its id. + await expect + .poll(() => requestCapture.getRequestCount(API_PATTERNS.BROADCAST_CREATE)) + .toBeGreaterThan(0) + logCapturedRequests(requestCapture) + const body = requestCapture.getLastRequest(API_PATTERNS.BROADCAST_CREATE)?.body as { + workspace_id?: string + name?: string + audience?: { list?: string; exclude_unsubscribed?: boolean } + utm_parameters?: { campaign?: string } + test_settings?: { enabled?: boolean } } - }) - }) - - test.describe('Edit Form Prefill', () => { - test('edit broadcast drawer shows existing broadcast name', async ({ - authenticatedPageWithData - }) => { - const page = authenticatedPageWithData - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - const drawer = await openEditDrawer(page, 'January Newsletter') - - await expect(drawer.getByLabel('Broadcast name')).toHaveValue('January Newsletter') - }) - - test('edit broadcast preserves list selection', async ({ authenticatedPageWithData }) => { - const page = authenticatedPageWithData - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - const drawer = await openEditDrawer(page, 'January Newsletter') - - // audience.list is list-1, whose name the select displays - await expect(selectValue(drawer, 'List')).toHaveText('Newsletter') - }) - - test('edit broadcast preserves template selection', async ({ authenticatedPageWithData }) => { - const page = authenticatedPageWithData - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - const drawer = await openEditDrawer(page, 'January Newsletter') - - // The template lives on the last tab - await drawer.getByRole('tab', { name: '4. Content' }).click() - - // test_settings.variations[0].template_id is tpl-2 - await expect(drawer.getByPlaceholder('Select template')).toHaveValue('Monthly Newsletter') - }) - - test('edit draft broadcast shows correct status', async ({ authenticatedPageWithData }) => { - const page = authenticatedPageWithData - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - const drawer = await openEditDrawer(page, 'January Newsletter') - - // A draft stays fully editable - await expect(drawer.getByLabel('Broadcast name')).toBeEnabled() - await expect(drawer.getByLabel('List', { exact: true })).toBeEnabled() - await expect(drawer.getByRole('button', { name: 'Next', exact: true })).toBeVisible() - }) - }) - - test.describe('Form Validation', () => { - test('requires broadcast name', async ({ authenticatedPage }) => { - const page = authenticatedPage - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - const drawer = await openCreateDrawer(page) - - await drawer.getByRole('button', { name: 'Next', exact: true }).click() - - await expect(drawer.getByText('Please enter a broadcast name')).toBeVisible() - // The step must not advance while the audience tab is invalid - await expect(drawer.getByLabel('Broadcast name')).toBeVisible() + expect(body.workspace_id).toBe(WORKSPACE_ID) + expect(body.name).toBe('Spring sale') + expect(body.audience?.list).toBe('list-1') + expect(body.audience?.exclude_unsubscribed).toBe(true) + expect(body.utm_parameters?.campaign).toBe('spring_sale') + expect(body.test_settings?.enabled).toBe(false) + await expect(page).toHaveURL(/compose=bc-1/) }) - test('requires list selection', async ({ authenticatedPage }) => { + test('closes an untouched new broadcast without a confirmation', async ({ authenticatedPage }) => { const page = authenticatedPage - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) + await page.goto(BROADCASTS) await waitForLoading(page) - const drawer = await openCreateDrawer(page) + await page.getByRole('button', { name: 'New broadcast' }).click() + const composer = drawer(page) + await expect(composer.getByRole('textbox', { name: 'Broadcast name' })).toBeVisible() + await composer.getByRole('button', { name: 'Close' }).click() - await drawer.getByLabel('Broadcast name').fill('Test Broadcast') - - await drawer.getByRole('button', { name: 'Next', exact: true }).click() - - await expect(drawer.getByText('Please select a list')).toBeVisible() - await expect(drawer.getByText('Please enter a broadcast name')).toHaveCount(0) + await expect(page).not.toHaveURL(/compose=/) + expect(requestCapture.getRequestCount(API_PATTERNS.BROADCAST_CREATE)).toBe(0) }) }) @@ -324,95 +137,12 @@ test.describe('Broadcasts Feature', () => { test('navigates to broadcasts from sidebar', async ({ authenticatedPage }) => { const page = authenticatedPage - // Start at dashboard await page.goto(`/console/workspace/${WORKSPACE_ID}/`) await waitForLoading(page) - // Click broadcasts link in sidebar - const broadcastsLink = page.locator('a[href*="broadcasts"], [data-menu-id*="broadcasts"]').first() - await broadcastsLink.click() + await page.locator('a[href*="broadcasts"], [data-menu-id*="broadcasts"]').first().click() - // Should be on broadcasts page await expect(page).toHaveURL(/broadcasts/) }) - - test('can close create form', async ({ authenticatedPage }) => { - const page = authenticatedPage - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - const drawer = await openCreateDrawer(page) - - // An untouched form closes without the unsaved-changes confirmation - await drawer.getByRole('button', { name: 'Close' }).click() - - await expect(drawer).toBeHidden() - }) - }) - - test.describe('Full Form Submission with Payload Verification', () => { - test('creates broadcast with all fields and verifies payload', async ({ - authenticatedPageWithData - }) => { - const page = authenticatedPageWithData - - await page.goto(`/console/workspace/${WORKSPACE_ID}/broadcasts`) - await waitForLoading(page) - - const drawer = await openCreateDrawer(page) - - // Tab 1: Audience - await drawer.getByLabel('Broadcast name').fill(testBroadcastData.name) - await selectOption(page, drawer, 'List', 'Newsletter') - await drawer.getByRole('button', { name: 'Next', exact: true }).click() - - // Tab 2: Web Analytics - await drawer.getByLabel('utm_source').fill(testBroadcastData.utm_source!) - await drawer.getByLabel('utm_medium').fill(testBroadcastData.utm_medium!) - await drawer.getByLabel('utm_campaign').fill(testBroadcastData.utm_campaign!) - await drawer.getByRole('button', { name: 'Next', exact: true }).click() - - // Tab 3: Data Feeds - left at its defaults - await drawer.getByRole('button', { name: 'Next', exact: true }).click() - - // Tab 4: Content - await drawer.getByPlaceholder('Select template').click() - const picker = page.getByRole('dialog', { name: 'Select Template' }) - await picker - .getByRole('listitem') - .filter({ hasText: 'Monthly Newsletter' }) - .getByRole('button', { name: 'Select', exact: true }) - .click() - await expect(drawer.getByPlaceholder('Select template')).toHaveValue('Monthly Newsletter') - - await drawer.getByRole('button', { name: 'Save', exact: true }).click() - - await expect - .poll(() => requestCapture.getRequestCount(API_PATTERNS.BROADCAST_CREATE)) - .toBeGreaterThan(0) - logCapturedRequests(requestCapture) - - const request = requestCapture.getLastRequest(API_PATTERNS.BROADCAST_CREATE) - expect(request?.body, 'Broadcast create body should not be empty').toBeTruthy() - - const body = request!.body as { - workspace_id?: string - name?: string - audience?: { list?: string; exclude_unsubscribed?: boolean } - utm_parameters?: Record - test_settings?: { enabled?: boolean; variations?: Array<{ template_id?: string }> } - } - - expect(body.workspace_id).toBe(WORKSPACE_ID) - expect(body.name).toBe(testBroadcastData.name) - expect(body.audience?.list).toBe('list-1') - expect(body.audience?.exclude_unsubscribed).toBe(testBroadcastData.exclude_unsubscribed) - expect(body.utm_parameters?.source).toBe(testBroadcastData.utm_source) - expect(body.utm_parameters?.medium).toBe(testBroadcastData.utm_medium) - expect(body.utm_parameters?.campaign).toBe(testBroadcastData.utm_campaign) - expect(body.test_settings?.enabled).toBe(testBroadcastData.test_enabled) - expect(body.test_settings?.variations?.[0]?.template_id).toBe('tpl-2') - }) }) }) diff --git a/console/e2e/fixtures/auth.ts b/console/e2e/fixtures/auth.ts index 33c6630f8..5ecc92c3b 100644 --- a/console/e2e/fixtures/auth.ts +++ b/console/e2e/fixtures/auth.ts @@ -283,7 +283,11 @@ async function setupApiMocks(page: Page, config: MockConfig = {}) { return route.fulfill(jsonResponse(withData ? mockBroadcastsResponse : mockEmptyBroadcasts)) } if (url.includes('/api/broadcast.get') || url.includes('/api/broadcasts.get')) { - return route.fulfill(jsonResponse({ broadcast: mockBroadcastsResponse.broadcasts[0] })) + const id = new URL(url).searchParams.get('id') + const broadcast = mockBroadcastsResponse.broadcasts.find((b) => b.id === id) + return broadcast + ? route.fulfill(jsonResponse({ broadcast })) + : route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'Broadcast not found' }) }) } if (url.includes('/api/broadcast.create') || url.includes('/api/broadcasts.create')) { return route.fulfill(jsonResponse(mockBroadcastCreateResponse)) diff --git a/console/e2e/protected-pages.spec.ts b/console/e2e/protected-pages.spec.ts index 5498140ef..f91090fa9 100644 --- a/console/e2e/protected-pages.spec.ts +++ b/console/e2e/protected-pages.spec.ts @@ -78,8 +78,8 @@ test.describe('Protected Pages Load', () => { await waitForPageLoad(page) // The fixture serves no broadcasts, so the list shows its empty state - await expect(page.getByRole('heading', { name: 'No broadcasts found' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Create Broadcast' })).toBeVisible() + await expect(page.getByText('No broadcasts yet')).toBeVisible() + await expect(page.getByRole('button', { name: 'New broadcast' })).toBeVisible() // Check for critical console errors expect(errors).toHaveLength(0) diff --git a/console/src/__tests__/broadcastsRoute.test.ts b/console/src/__tests__/broadcastsRoute.test.ts new file mode 100644 index 000000000..c1adcacf2 --- /dev/null +++ b/console/src/__tests__/broadcastsRoute.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { validateBroadcastsSearch } from '../router' + +describe('validateBroadcastsSearch', () => { + it('normalises broadcast and compose like status and q', () => { + expect( + validateBroadcastsSearch({ status: ' sent ', q: ['news', 'other'], broadcast: ' b1 ', compose: ['new', 'x'] }) + ).toEqual({ status: 'sent', q: 'news', broadcast: 'b1', compose: 'new' }) + }) + + it('drops empty values', () => { + expect(validateBroadcastsSearch({ status: '', q: ' ', broadcast: '', compose: [] })).toEqual({ + status: undefined, + q: undefined, + broadcast: undefined, + compose: undefined + }) + }) + + it('reads an identifier the router parsed as a number back as text', () => { + expect(validateBroadcastsSearch({ broadcast: 1234, compose: 99 })).toMatchObject({ broadcast: '1234', compose: '99' }) + }) + + it('drops unknown keys', () => { + expect(validateBroadcastsSearch({ status: 'draft', page: '2', foo: 'bar' })).toEqual({ + status: 'draft', + q: undefined, + broadcast: undefined, + compose: undefined + }) + }) +}) diff --git a/console/src/components/broadcasts/BroadcastAnalyticsLinks.tsx b/console/src/components/broadcasts/BroadcastAnalyticsLinks.tsx index c8ec8e1ac..f3c94cf4b 100644 --- a/console/src/components/broadcasts/BroadcastAnalyticsLinks.tsx +++ b/console/src/components/broadcasts/BroadcastAnalyticsLinks.tsx @@ -4,7 +4,7 @@ import { faBullseye, faChartLine } from '@fortawesome/free-solid-svg-icons' import type { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { useLingui } from '@lingui/react/macro' import { useRouter } from '@tanstack/react-router' -import { getBrowserTimezone } from '../../lib/timezoneNormalizer' +import { canReadWebAnalytics, workspaceTimezone } from './access' import { Broadcast } from '../../services/api/broadcast' import { UserPermissions, Workspace } from '../../services/api/workspace' import { @@ -47,8 +47,7 @@ export function BroadcastAnalyticsLinks({ const { t } = useLingui() const router = useRouter() - if (!workspace?.settings?.web_analytics?.enabled) return null - if (!permissions?.web_analytics?.read) return null + if (!canReadWebAnalytics(workspace, permissions)) return null if (!broadcast.started_at) return null const isVariation = variationTemplateId !== undefined @@ -72,7 +71,7 @@ export function BroadcastAnalyticsLinks({ content, startedAt: broadcast.started_at, completedAt: broadcast.completed_at, - timezone: workspace.settings?.timezone || getBrowserTimezone() || 'UTC' + timezone: workspaceTimezone(workspace) }) : null diff --git a/console/src/components/broadcasts/BroadcastKpis.test.tsx b/console/src/components/broadcasts/BroadcastKpis.test.tsx new file mode 100644 index 000000000..849455f5b --- /dev/null +++ b/console/src/components/broadcasts/BroadcastKpis.test.tsx @@ -0,0 +1,111 @@ +import '../../__tests__/resizeObserverMock' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { App } from 'antd' +import { BroadcastKpis } from './BroadcastKpis' +import { runBroadcastQuery } from './broadcastQueryClient' +import { broadcastApi } from '../../services/api/broadcast' +import type { Workspace } from '../../services/api/types' +import type { UserPermissions } from '../../services/api/workspace' +import type { AnalyticsQuery } from '../../services/api/analytics' + +vi.mock('./broadcastQueryClient', () => ({ runBroadcastQuery: vi.fn() })) +vi.mock('../../services/api/broadcast', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, broadcastApi: { ...actual.broadcastApi, list: vi.fn() } } +}) + +const workspace = (web = true) => + ({ id: 'ws', settings: { timezone: 'UTC', web_analytics: { enabled: web } } }) as unknown as Workspace +const permissions = (over: Partial = {}) => + ({ + message_history: { read: true, write: false }, + web_analytics: { read: true, write: false }, + ...over + }) as UserPermissions + +let current: Record = {} +let previous: Record = {} + +const renderKpis = (ws = workspace(), perms: UserPermissions | null = permissions()) => + render( + + + + + + ) + +describe('BroadcastKpis', () => { + beforeEach(() => { + current = { + count_sent: 1000, + count_opened: 300, + count_opens_tracked: 1000, + count_clicked: 50, + count_clicks_tracked: 1000, + count_unsubscribed: 5, + count_bounced: 20 + } + previous = { ...current, count_opened: 280, count_clicked: 60 } + let call = 0 + vi.mocked(runBroadcastQuery).mockReset().mockImplementation(async (query: AnalyticsQuery) => { + if (query.schema === 'web_sessions') { + return { data: [{ utm_campaign: 'news', year: 2000, month: 1, day: 1, sessions: 9 }], meta: { total: 1, query: '', params: [] } } + } + // L4 is asked before L5. + return { data: [call++ === 0 ? current : previous], meta: { total: 1, query: '', params: [] } } + }) + vi.mocked(broadcastApi.list).mockReset().mockResolvedValue({ broadcasts: [], total_count: 0 }) + }) + + it('shows rates and point deltas with their tone', async () => { + renderKpis() + expect(await screen.findByText('30.0%')).toBeInTheDocument() + expect(screen.getByText('5.0%')).toBeInTheDocument() + expect(screen.getByText('+2.0 pts')).toBeInTheDocument() + expect(screen.getByText('+2.0 pts').className).toContain('text-success-fg') + expect(screen.getByText('−1.0 pt').className).toContain('text-danger-fg') + expect(screen.getByText('5 contacts')).toBeInTheDocument() + expect(screen.getByText('20 addresses')).toBeInTheDocument() + }) + + it('shows "—" for a rate over nothing', async () => { + current = { count_sent: 0 } + renderKpis() + await waitFor(() => expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(4)) + }) + + it('has no traffic cell without web analytics, nor without the right to read it', async () => { + const { unmount } = renderKpis(workspace(false)) + await screen.findByText('30.0%') + expect(screen.queryByText('Website traffic')).not.toBeInTheDocument() + unmount() + renderKpis(workspace(true), permissions({ web_analytics: { read: false, write: false } })) + await screen.findByText('Open rate') + await waitFor(() => expect(screen.getAllByText(/%$/).length).toBeGreaterThan(0)) + expect(screen.queryByText('Website traffic')).not.toBeInTheDocument() + }) + + it('counts traffic as 0 without asking web sessions when no broadcast has a campaign', async () => { + renderKpis() + expect(await screen.findByText('Website traffic')).toBeInTheDocument() + await waitFor(() => expect(broadcastApi.list).toHaveBeenCalled()) + expect(vi.mocked(broadcastApi.list).mock.calls[0][0]).toMatchObject({ limit: 100, offset: 0 }) + expect(vi.mocked(broadcastApi.list).mock.calls[0][0].started_after).toMatch(/Z$/) + expect(vi.mocked(runBroadcastQuery).mock.calls.some(([q]) => q.schema === 'web_sessions')).toBe(false) + }) + + it('is hidden without message_history read', () => { + const { container } = renderKpis(workspace(), permissions({ message_history: { read: false, write: false } })) + expect(container.querySelector('[data-kpi-strip]')).toBeNull() + expect(runBroadcastQuery).not.toHaveBeenCalled() + }) + + it('says it could not load when the totals fail', async () => { + vi.mocked(runBroadcastQuery).mockRejectedValue(new Error('boom')) + renderKpis(workspace(false)) + await waitFor(() => expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(5)) + }) +}) diff --git a/console/src/components/broadcasts/BroadcastKpis.tsx b/console/src/components/broadcasts/BroadcastKpis.tsx new file mode 100644 index 000000000..dd9a1a2fe --- /dev/null +++ b/console/src/components/broadcasts/BroadcastKpis.tsx @@ -0,0 +1,181 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useLingui } from '@lingui/react/macro' +import { plural } from '@lingui/core/macro' +import { KpiStrip, useNumberFormat, type KpiItem, type KpiTone } from '../layout' +import dayjs from '../../lib/dayjs' +import { broadcastApi, type Broadcast } from '../../services/api/broadcast' +import type { Workspace } from '../../services/api/types' +import type { UserPermissions } from '../../services/api/workspace' +import { computeComparisonRange, computeDateRange, resolveRange } from '../web_analytics/lib/dates' +import { formatValue } from '../web_analytics/lib/format' +import { BROADCAST_CONVERSION_WINDOW_DAYS } from '../web_analytics/lib/broadcastLinks' +import { TrackedTooltipTitle } from '../common/TrackedTooltipTitle' +import { campaignDayRows, campaignVisitsQuery, periodTotalsQuery, readPeriodTotals } from './broadcastAnalytics' +import { runBroadcastQuery } from './broadcastQueryClient' +import { campaignsOf, kpiTraffic } from './attribution' +import { canReadWebAnalytics, workspaceTimezone } from './access' +import { CouldntLoad } from './CouldntLoad' + +const FIVE_MINUTES = 5 * 60_000 +const TEN_MINUTES = 10 * 60_000 +const ONE_HOUR = 60 * 60_000 +const CAMPAIGNS_PAGE = 100 + +/** The rate of a numerator over a denominator, or null when the denominator is 0. */ +export const rate = (numerator: number, denominator: number) => (denominator > 0 ? numerator / denominator : null) + +/** Every broadcast started since `startedAfter` (L6), 100 at a time. */ +async function listStartedSince(workspaceId: string, startedAfter: string): Promise { + const all: Broadcast[] = [] + for (let offset = 0; ; offset += CAMPAIGNS_PAGE) { + const page = await broadcastApi.list({ + workspace_id: workspaceId, + started_after: startedAfter, + limit: CAMPAIGNS_PAGE, + offset + }) + all.push(...(page.broadcasts ?? [])) + if (offset + CAMPAIGNS_PAGE >= page.total_count) return all + } +} + +export interface BroadcastKpisProps { + workspace: Workspace + permissions: UserPermissions | null +} + +/** L4 to L7: the broadcast emails of the last 30 days against the 30 before, and their traffic. */ +export function BroadcastKpis({ workspace, permissions }: BroadcastKpisProps) { + const { t } = useLingui() + const { int, percent, points } = useNumberFormat() + const timezone = workspaceTimezone(workspace) + const canReadHistory = Boolean(permissions?.message_history?.read) + const canReadWeb = canReadWebAnalytics(workspace, permissions) + + const { current, previous, campaignsSince } = useMemo(() => { + const range = computeDateRange('previous_30_days', timezone) + const comparison = computeComparisonRange(range, 'previous_period')! + return { + current: resolveRange(range, timezone), + previous: resolveRange(comparison, timezone), + campaignsSince: range.start.subtract(BROADCAST_CONVERSION_WINDOW_DAYS, 'day').toISOString() + } + }, [timezone]) + + const currentQuery = useQuery({ + queryKey: ['broadcast-kpis', workspace.id, current.startUtc, current.endUtc], + queryFn: () => runBroadcastQuery(periodTotalsQuery(current, timezone), workspace.id), + enabled: canReadHistory, + staleTime: FIVE_MINUTES + }) + const previousQuery = useQuery({ + queryKey: ['broadcast-kpis', workspace.id, previous.startUtc, previous.endUtc], + queryFn: () => runBroadcastQuery(periodTotalsQuery(previous, timezone), workspace.id), + enabled: canReadHistory, + staleTime: ONE_HOUR + }) + const campaignsQuery = useQuery({ + queryKey: ['broadcast-campaigns', workspace.id, campaignsSince], + queryFn: () => listStartedSince(workspace.id, campaignsSince), + enabled: canReadHistory && canReadWeb, + staleTime: TEN_MINUTES + }) + const campaigns = campaignsOf(campaignsQuery.data ?? []) + const trafficQuery = useQuery({ + queryKey: ['broadcast-traffic', workspace.id, current.startUtc, [...campaigns].sort()], + queryFn: () => + runBroadcastQuery( + campaignVisitsQuery({ + campaigns, + start: dayjs(current.startUtc), + end: dayjs(current.endUtc), + timezone, + withGoals: true + }), + workspace.id + ), + enabled: canReadHistory && canReadWeb && campaigns.length > 0, + staleTime: TEN_MINUTES + }) + + if (!canReadHistory) return null + + const failed = + const now = readPeriodTotals(currentQuery.data?.data ?? []) + const before = previousQuery.data ? readPeriodTotals(previousQuery.data.data ?? []) : null + + const rateCell = ( + key: string, + label: string, + numerator: number, + tracked: number, + beforeNumerator?: number, + beforeTracked?: number + ): KpiItem => { + const value = rate(numerator, tracked) + const prior = beforeNumerator !== undefined && beforeTracked !== undefined ? rate(beforeNumerator, beforeTracked) : null + const delta = value !== null && prior !== null ? (value - prior) * 100 : null + const tone: KpiTone = delta === null || Math.abs(delta) < 0.05 ? 'neutral' : delta > 0 ? 'positive' : 'negative' + return { + key, + label, + value: currentQuery.isError ? failed : value === null ? '—' : percent(value), + sub: delta === null ? undefined : points(delta), + tone, + hint: + tracked < now.sent && tracked > 0 ? : undefined + } + } + + const unsubscribed = now.unsubscribed + const bounced = now.bounced + const items: KpiItem[] = [ + { + key: 'sent', + label: t`Emails sent`, + value: currentQuery.isError ? failed : int(now.sent), + sub: t`Last 30 days` + }, + rateCell('opens', t`Open rate`, now.opened, now.opensTracked, before?.opened, before?.opensTracked), + rateCell('clicks', t`Click rate`, now.clicked, now.clicksTracked, before?.clicked, before?.clicksTracked), + { + key: 'unsubscribes', + label: t`Unsubscribes`, + value: currentQuery.isError ? failed : rate(unsubscribed, now.sent) === null ? '—' : percent(rate(unsubscribed, now.sent)!), + sub: t`${plural(unsubscribed, { one: '# contact', other: '# contacts' })}` + }, + { + key: 'bounces', + label: t`Bounces`, + value: currentQuery.isError ? failed : rate(bounced, now.sent) === null ? '—' : percent(rate(bounced, now.sent)!), + sub: t`${plural(bounced, { one: '# address', other: '# addresses' })}` + } + ] + + if (canReadWeb) { + const traffic = kpiTraffic( + campaignsQuery.data ?? [], + campaignDayRows(trafficQuery.data?.data ?? []), + { startDay: current.startDay, endDay: current.endDay }, + timezone + ) + const conversions = traffic.conversions + const valueText = formatValue(traffic.value, 'currency') + const trafficFailed = campaignsQuery.isError || trafficQuery.isError + items.push({ + key: 'traffic', + label: t`Website traffic`, + value: trafficFailed ? failed : int(traffic.sessions), + sub: t`${plural(conversions, { one: '# conversion', other: '# conversions' })} · ${valueText}`, + hint: t`Sessions from the UTM campaign of a broadcast, during the 7 days after it was sent. Broadcasts sharing a campaign count a session once here, and once each in the list.` + }) + } + + return ( + 0)} + /> + ) +} diff --git a/console/src/components/broadcasts/BroadcastLinkStats.test.tsx b/console/src/components/broadcasts/BroadcastLinkStats.test.tsx deleted file mode 100644 index 55d05fc82..000000000 --- a/console/src/components/broadcasts/BroadcastLinkStats.test.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { App as AntApp } from 'antd' -import { BroadcastLinkStats } from './BroadcastLinkStats' -import { getBroadcastLinkStats } from '../../services/api/messages_history' - -// antd's Table scroll wrapper mounts a ResizeObserver; jsdom doesn't provide one. -class ResizeObserverStub { - observe() {} - unobserve() {} - disconnect() {} -} -vi.stubGlobal('ResizeObserver', ResizeObserverStub) - -vi.mock('../../services/api/messages_history', () => ({ - getBroadcastLinkStats: vi.fn() -})) - -const mockedGetBroadcastLinkStats = getBroadcastLinkStats as Mock - -const navigate = vi.fn() -vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigate })) - -const clipboardWriteText = vi.fn() -Object.defineProperty(navigator, 'clipboard', { - value: { writeText: clipboardWriteText }, - configurable: true -}) - -// Rendered inside an expanded variation row: scoped to a templateId, with the variation's -// recipient count as the click-rate denominator. -const renderComponent = (props?: { templateId?: string | null; recipients?: number }) => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return render( - - - - - - ) -} - -describe('BroadcastLinkStats', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('renders a row per link with unique clicks, total clicks and click rate', async () => { - mockedGetBroadcastLinkStats.mockResolvedValue({ - link_stats: [ - { url: 'https://example.com/pricing', total_clicks: 40, unique_clicks: 30 }, - { url: 'https://example.com/blog', total_clicks: 12, unique_clicks: 10 } - ] - }) - - renderComponent() - - expect(await screen.findByText('https://example.com/pricing')).toBeInTheDocument() - expect(screen.getByText('https://example.com/blog')).toBeInTheDocument() - // Unique clicks - expect(screen.getByText('30')).toBeInTheDocument() - expect(screen.getByText('10')).toBeInTheDocument() - // Total clicks - expect(screen.getByText('40')).toBeInTheDocument() - expect(screen.getByText('12')).toBeInTheDocument() - // Click rate = unique / recipients (30/100, 10/100). - expect(screen.getByText('30%')).toBeInTheDocument() - expect(screen.getByText('10%')).toBeInTheDocument() - // The query is scoped to the variation's template. - expect(mockedGetBroadcastLinkStats).toHaveBeenCalledWith('ws1', 'bc1', 'tpl-1') - }) - - it('keeps the caveats behind an info icon and no longer claims cross-variation aggregation', async () => { - mockedGetBroadcastLinkStats.mockResolvedValue({ - link_stats: [{ url: 'https://example.com/pricing', total_clicks: 40, unique_clicks: 30 }] - }) - - renderComponent() - - await screen.findByText('https://example.com/pricing') - // Caveats live behind the info icon (antd tooltip), not rendered inline. - expect( - screen.queryByText(/won't add up to this variation's total number of clickers/) - ).not.toBeInTheDocument() - // The per-variation view must not carry the old broadcast-wide caveat. - expect(screen.queryByText(/Aggregated across all A\/B variations/)).not.toBeInTheDocument() - // The info affordance sits in the header. - expect(document.querySelector('[data-icon="circle-question"]')).not.toBeNull() - }) - - it('renders a magnitude bar per link, widest for the most-clicked link', async () => { - mockedGetBroadcastLinkStats.mockResolvedValue({ - link_stats: [ - { url: 'https://example.com/pricing', total_clicks: 40, unique_clicks: 30 }, - { url: 'https://example.com/blog', total_clicks: 12, unique_clicks: 10 } - ] - }) - - renderComponent() - - await screen.findByText('https://example.com/pricing') - const bars = screen.getAllByTestId('unique-bar-fill') - expect(bars).toHaveLength(2) - // Widths are relative to the top link (30): the max-unique bar fills the track and - // the 10-click bar is a third of it. Order-independent. - const widths = bars.map((b) => parseFloat((b as HTMLElement).style.width)) - expect(Math.max(...widths)).toBe(100) - expect(Math.min(...widths)).toBeCloseTo(33.33, 1) - }) - - it('shows a dash for the click rate when the variation has no recipients', async () => { - mockedGetBroadcastLinkStats.mockResolvedValue({ - link_stats: [{ url: 'https://example.com/pricing', total_clicks: 40, unique_clicks: 30 }] - }) - - renderComponent({ recipients: 0 }) - - await screen.findByText('https://example.com/pricing') - expect(screen.getByText('-')).toBeInTheDocument() - }) - - it('never renders recorded URLs as clickable links', async () => { - // Recorded URLs are attacker-influenceable (recipients can mint tracking tokens with - // arbitrary URLs), so the table must render them as plain text. - mockedGetBroadcastLinkStats.mockResolvedValue({ - link_stats: [{ url: 'https://evil.example.com/phish', total_clicks: 1, unique_clicks: 1 }] - }) - - const { container } = renderComponent() - - await screen.findByText('https://evil.example.com/phish') - expect(container.querySelector('a')).toBeNull() - }) - - it('offers a copy-to-clipboard action for each URL', async () => { - mockedGetBroadcastLinkStats.mockResolvedValue({ - link_stats: [{ url: 'https://example.com/pricing', total_clicks: 10, unique_clicks: 6 }] - }) - - renderComponent() - - await screen.findByText('https://example.com/pricing') - const copyButtons = screen.getAllByRole('button') - expect(copyButtons.length).toBeGreaterThan(0) - await userEvent.click(copyButtons[0]) - expect(clipboardWriteText).toHaveBeenCalledWith('https://example.com/pricing') - }) - - it('shows an empty-state message when the variation has no recorded link clicks', async () => { - mockedGetBroadcastLinkStats.mockResolvedValue({ link_stats: [] }) - - renderComponent() - - expect( - await screen.findByText('No link clicks recorded for this variation yet') - ).toBeInTheDocument() - }) - - it('degrades to a notice when the query fails (older server)', async () => { - // Old servers answer the route with an empty 200 body → a SyntaxError on parse. - mockedGetBroadcastLinkStats.mockRejectedValue(new SyntaxError('Unexpected end of JSON input')) - - renderComponent() - - expect(await screen.findByText('Per-link click data is not available.')).toBeInTheDocument() - expect(screen.queryByRole('table')).not.toBeInTheDocument() - }) - - it('opens the message log on the recipients who clicked that link in this variation', async () => { - const url = 'https://example.com/pricing?utm_source=news&x=1' - mockedGetBroadcastLinkStats.mockResolvedValue({ - link_stats: [{ url, total_clicks: 10, unique_clicks: 6 }] - }) - - renderComponent() - - await screen.findByText(url) - await userEvent.click(screen.getByRole('button', { name: 'See who clicked' })) - const to = navigate.mock.calls[0][0].to as string - const [path, query] = to.split('?') - expect(path).toBe('/console/workspace/ws1/logs') - const params = new URLSearchParams(query) - expect(params.get('broadcast_id')).toBe('bc1') - expect(params.get('template_id')).toBe('tpl-1') - expect(params.get('clicked_url')).toBe(url) - }) - - it('leaves the variation out of the log link for a whole-broadcast breakdown', async () => { - mockedGetBroadcastLinkStats.mockResolvedValue({ - link_stats: [{ url: 'https://example.com/pricing', total_clicks: 10, unique_clicks: 6 }] - }) - - renderComponent({ templateId: null }) - - await screen.findByText('https://example.com/pricing') - await userEvent.click(screen.getByRole('button', { name: 'See who clicked' })) - const params = new URLSearchParams((navigate.mock.calls[0][0].to as string).split('?')[1]) - expect(params.get('broadcast_id')).toBe('bc1') - expect(params.has('template_id')).toBe(false) - expect(params.get('clicked_url')).toBe('https://example.com/pricing') - }) -}) diff --git a/console/src/components/broadcasts/BroadcastLinkStats.tsx b/console/src/components/broadcasts/BroadcastLinkStats.tsx deleted file mode 100644 index 0d73e80f3..000000000 --- a/console/src/components/broadcasts/BroadcastLinkStats.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { App, Button, Table, Tooltip, Typography } from 'antd' -import { useLingui } from '@lingui/react/macro' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faCopy, faCircleQuestion, faUser } from '@fortawesome/free-regular-svg-icons' -import { getBroadcastLinkStats, LinkClickStats } from '../../services/api/messages_history' -import { alpha, colors } from '../../theme/tokens' - -const { Text } = Typography - -// Single sequential hue for the magnitude bars — the brand primary at reduced opacity -// (a solid primary reads too bright as a full bar), over a lighter tint of it as the track. -const BAR_FILL = alpha(colors.primary[500], 0.5) -const BAR_TRACK = alpha(colors.primary[500], 0.12) - -interface BroadcastLinkStatsProps { - workspaceId: string - broadcastId: string - // Scopes the breakdown to a single A/B variation (the template its messages were sent - // with). Omit for the whole broadcast. - templateId?: string - // Denominator for the per-link click rate — the emails of this variation that - // carried tracked links (the same figure the variation's Clicks % is computed from). - recipients?: number - enabled?: boolean -} - -// Percentage formatter mirroring BroadcastStats.getRate so per-link and aggregate rates -// round the same way. -const formatRate = (numerator: number, denominator: number) => { - if (!denominator) return '-' - const pct = (numerator / denominator) * 100 - return pct === 0 || pct >= 10 ? `${Math.round(pct)}%` : `${pct.toFixed(1)}%` -} - -export function BroadcastLinkStats({ - workspaceId, - broadcastId, - templateId, - recipients = 0, - enabled = true -}: BroadcastLinkStatsProps) { - const { t } = useLingui() - const { message } = App.useApp() - const navigate = useNavigate() - - // Per-link breakdown for this variation. Mounted only when the variation row is - // expanded (antd renders expandedRowRender lazily), so the JSONB aggregation runs on - // expand rather than for every broadcast on the list. No polling; kept fresh for a - // minute so re-renders don't refire it. - const { data, isLoading, isError } = useQuery({ - queryKey: ['broadcast-link-stats', workspaceId, broadcastId, templateId ?? ''], - queryFn: () => getBroadcastLinkStats(workspaceId, broadcastId, templateId), - enabled, - staleTime: 60_000, - retry: false - }) - - const linkStats = data?.link_stats ?? [] - const maxUnique = linkStats.reduce((max, row) => Math.max(max, row.unique_clicks), 0) - - const copyUrl = (url: string) => { - navigator.clipboard.writeText(url) - message.success(t`URL copied to clipboard.`) - } - - // Opens the message log on the emails of this broadcast (and variation) on which this - // exact link was clicked: the recipients behind the row. - const showClickers = (url: string) => { - const searchParams = new URLSearchParams() - searchParams.set('broadcast_id', broadcastId) - if (templateId) searchParams.set('template_id', templateId) - searchParams.set('clicked_url', url) - navigate({ to: `/console/workspace/${workspaceId}/logs?${searchParams.toString()}` as string & {} }) - } - - // Older servers answer this route with an empty 200 body, which makes the API client - // throw while parsing. Degrade to a quiet notice instead of a broken expanded row. - if (isError) { - return ( -
- {t`Per-link click data is not available.`} -
- ) - } - - return ( -
- ({ ...row, key: row.url }))} - columns={[ - { - title: ( - - {t`Link`} - -
- {t`Unique clicks counts each recipient once per link — someone who clicks several links appears in every row, so these won't add up to this variation's total number of clickers.`} -
-
- {t`Only clicks recorded since per-link tracking was enabled appear here; earlier clicks still count toward the variation's Clicks total.`} -
-
- {t`Counts may include automated security-scanner and Apple Mail prefetch clicks; Unique clicks is the most reliable human signal.`} -
- - } - > - -
-
- ), - dataIndex: 'url', - key: 'url', - render: (url: string) => ( -
- {/* Plain text on purpose: recorded URLs can be forged by any email - recipient (tracking tokens are mintable), so rendering them as - clickable links would hand admins a phishing vector. */} - - - {url} - - - -
- ) - }, - { - title: t`Unique clicks`, - dataIndex: 'unique_clicks', - key: 'unique_clicks', - width: 220, - defaultSortOrder: 'descend' as const, - sorter: (a: LinkClickStats, b: LinkClickStats) => a.unique_clicks - b.unique_clicks, - render: (value: number) => ( -
- {/* Inline magnitude bar: width relative to the top link so "which link - won" is scannable at a glance. Left-baselined, 4px rounded right end. */} -
-
0 ? (value / maxUnique) * 100 : 0}%` - }} - /> -
- - {value.toLocaleString()} - -
- ) - }, - { - title: ( - - {t`Click rate`} - - ), - key: 'click_rate', - width: 110, - align: 'right' as const, - render: (_: unknown, record: LinkClickStats) => ( - {formatRate(record.unique_clicks, recipients)} - ) - }, - { - title: ( - - {t`Total clicks`} - - ), - dataIndex: 'total_clicks', - key: 'total_clicks', - width: 120, - align: 'right' as const, - sorter: (a: LinkClickStats, b: LinkClickStats) => a.total_clicks - b.total_clicks, - render: (value: number) => ( - {value.toLocaleString()} - ) - } - ]} - size="small" - loading={isLoading} - pagination={{ pageSize: 10, hideOnSinglePage: true, showSizeChanger: false }} - scroll={{ x: 'max-content' }} - locale={{ emptyText: t`No link clicks recorded for this variation yet` }} - /> - {linkStats.length >= 200 && ( -
- {t`Showing the top 200 links by unique clicks.`} -
- )} -
- ) -} diff --git a/console/src/components/broadcasts/BroadcastStats.test.tsx b/console/src/components/broadcasts/BroadcastStats.test.tsx deleted file mode 100644 index d76a63d96..000000000 --- a/console/src/components/broadcasts/BroadcastStats.test.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import '../../__tests__/resizeObserverMock' -import { describe, it, expect, vi } from 'vitest' -import userEvent from '@testing-library/user-event' -import { render, screen, waitFor, within } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ConfigProvider } from 'antd' -import { BroadcastStats, type ProgressStats } from './BroadcastStats' - -vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn() })) - -const state = vi.hoisted(() => ({ - result: {} as Record -})) - -vi.mock('../../services/api/messages_history', () => ({ - getBroadcastStats: vi.fn(() => Promise.resolve(state.result)) -})) - -const sum = (over: Record = {}) => ({ - total_sent: 45, - total_delivered: 0, - total_opened: 0, - total_clicked: 0, - total_failed: 23, - total_bounced: 0, - total_complained: 0, - total_unsubscribed: 0, - ...over -}) - -const renderStats = (onStatsUpdate?: (s: ProgressStats) => void) => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return render( - - - - - - ) -} - -// The tile showed a percentage, so 23 abandoned recipients read as "34%" beside a green -// Complete badge. A count is what the operator has to act on. -describe('BroadcastStats failed tile', () => { - it('shows how many recipients failed, not just a rate', async () => { - state.result = { broadcast_id: 'bc-1', stats: sum() } - renderStats() - - await waitFor(() => expect(screen.getByText('23')).toBeInTheDocument()) - // The rate is a share of what was attempted. Dividing by total_sent, which now - // means "accepted by the provider", gave 51% for a campaign that lost a third of - // its recipients — and would pass 100% in a worse outage. - expect(screen.getByText('34%')).toBeInTheDocument() - }) -}) - -describe('BroadcastStats queue counts', () => { - it('passes the queue counts up so the badge can tell finished from abandoned', async () => { - state.result = { - broadcast_id: 'bc-1', - stats: sum(), - queue: { pending: 2, processing: 1, paused: 0, failed_retrying: 3, failed_terminal: 23 } - } - const updates: ProgressStats[] = [] - renderStats((s) => updates.push(s)) - - await waitFor(() => expect(updates[updates.length - 1]?.inFlight).toBe(6)) - expect(updates[updates.length - 1].failedTerminal).toBe(23) - }) - - it('leaves the counts undefined when the queue could not be read', async () => { - // Undefined has to mean "unknown", never "nothing left" — the badge falls back - // to the message-history arithmetic rather than declaring the campaign finished. - state.result = { broadcast_id: 'bc-1', stats: sum() } - const updates: ProgressStats[] = [] - renderStats((s) => updates.push(s)) - - // Wait for the response to land before asserting on what it did not carry. - await waitFor(() => expect(updates[updates.length - 1]?.sentCount).toBe(45)) - expect(updates[updates.length - 1].inFlight).toBeUndefined() - expect(updates[updates.length - 1].failedTerminal).toBeUndefined() - }) -}) - -// Open and click rates are shares of the emails that could record them: a recipient -// who refused tracking got no pixel and no tracked links. -describe('BroadcastStats tracked rates', () => { - // The tooltip of a rate tile, read after hovering it. - const tooltipOf = async (tile: string) => { - await userEvent.hover(screen.getByText(tile).closest('.ant-statistic') as HTMLElement) - return (await screen.findByText(new RegExp(`total ${tile.toLowerCase()}`))).closest('.ant-tooltip') as HTMLElement - } - - it('computes open and click rates over the tracked emails and says how many in the tooltip', async () => { - state.result = { - broadcast_id: 'bc-1', - stats: sum({ - total_sent: 100, - total_failed: 0, - total_opened: 10, - total_clicked: 5, - total_opens_tracked: 40, - total_clicks_tracked: 20 - }) - } - renderStats() - - // 10 of 40 opened, 5 of 20 clicked: both 25%, where the sent total gave 10% and 5%. - await waitFor(() => expect(screen.getAllByText('25%')).toHaveLength(2)) - expect(screen.queryByText(/tracked$/)).not.toBeInTheDocument() - expect(within(await tooltipOf('Opens')).getByText('of 40 tracked')).toBeInTheDocument() - expect(within(await tooltipOf('Clicks')).getByText('of 20 tracked')).toBeInTheDocument() - }) - - it('adds no note when every email was tracked', async () => { - state.result = { - broadcast_id: 'bc-1', - stats: sum({ - total_sent: 100, - total_failed: 0, - total_opened: 10, - total_clicked: 5, - total_opens_tracked: 100, - total_clicks_tracked: 100 - }) - } - renderStats() - - await waitFor(() => expect(screen.getByText('10%')).toBeInTheDocument()) - expect(within(await tooltipOf('Opens')).queryByText(/tracked$/)).not.toBeInTheDocument() - }) - - it('shows no rate when nothing was tracked', async () => { - state.result = { - broadcast_id: 'bc-1', - stats: sum({ - total_sent: 100, - total_failed: 0, - total_opens_tracked: 0, - total_clicks_tracked: 0 - }) - } - renderStats() - - await waitFor(() => expect(screen.getAllByText('-').length).toBeGreaterThan(0)) - expect(within(await tooltipOf('Opens')).getByText('of 0 tracked')).toBeInTheDocument() - for (const tile of ['Opens', 'Clicks']) { - const statistic = screen.getByText(tile).closest('.ant-statistic') as HTMLElement - expect(within(statistic).getByText('-')).toBeInTheDocument() - } - }) -}) diff --git a/console/src/components/broadcasts/BroadcastStats.tsx b/console/src/components/broadcasts/BroadcastStats.tsx deleted file mode 100644 index cb1b42bfb..000000000 --- a/console/src/components/broadcasts/BroadcastStats.tsx +++ /dev/null @@ -1,408 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import React, { useEffect } from 'react' -import { Row, Col, Statistic, Space, Tooltip, Spin } from 'antd' -import { useLingui } from '@lingui/react/macro' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { - faPaperPlane, - faCircleCheck, - faEye, - faCircleXmark, - faFaceFrown -} from '@fortawesome/free-regular-svg-icons' -import { faArrowPointer, faTriangleExclamation, faBan } from '@fortawesome/free-solid-svg-icons' -import { getBroadcastStats } from '../../services/api/messages_history' -import { useNavigate } from '@tanstack/react-router' -import type { Workspace } from '../../services/api/types' -import { isPartiallyTracked, trackedBase } from '../../lib/emailTracking' -import { TrackedTooltipTitle } from '../common/TrackedTooltipTitle' - -export interface ProgressStats { - remaining: number - processed: number - enqueuedCount: number - sentCount: number - failedCount: number - /** - * Rows still on their way out: pending, processing, paused, or failed with - * attempts left. Undefined when the queue could not be read — callers fall back - * to the message-history arithmetic rather than claiming the campaign is done. - */ - inFlight?: number - /** Rows that spent every attempt. These recipients were given up on. */ - failedTerminal?: number -} - -interface BroadcastStatsProps { - workspaceId: string - broadcastId: string - workspace?: Workspace - enqueuedCount?: number - broadcastStatus?: string - onStatsUpdate?: (stats: ProgressStats) => void -} - -export function BroadcastStats({ - workspaceId, - broadcastId, - workspace, - enqueuedCount, - broadcastStatus, - onStatsUpdate -}: BroadcastStatsProps) { - const { t } = useLingui() - const navigate = useNavigate() - - const { data, isLoading } = useQuery({ - queryKey: ['broadcast-stats', workspaceId, broadcastId], - queryFn: async () => { - return getBroadcastStats(workspaceId, broadcastId) - }, - // Stop polling for terminal states where stats won't change. - refetchInterval: - broadcastStatus === 'cancelled' || broadcastStatus === 'failed' ? false : 5000 - }) - - const stats = data?.stats || { - total_sent: 0, - total_delivered: 0, - total_opened: 0, - total_clicked: 0, - total_failed: 0, - total_bounced: 0, - total_complained: 0, - total_unsubscribed: 0 - } - - // The queue is the authority on whether the campaign has finished; the stats - // below only describe the attempts message history recorded. - const queue = data?.queue - - // Calculate remaining - const processed = stats.total_sent + stats.total_failed - const remaining = enqueuedCount ? Math.max(0, enqueuedCount - processed) : 0 - - // Notify parent of stats changes using a ref to avoid re-renders - const onStatsUpdateRef = React.useRef(onStatsUpdate) - useEffect(() => { - onStatsUpdateRef.current = onStatsUpdate - }) - - useEffect(() => { - if (enqueuedCount !== undefined) { - onStatsUpdateRef.current?.({ - remaining, - processed, - enqueuedCount, - sentCount: stats.total_sent, - failedCount: stats.total_failed, - inFlight: queue && queue.pending + queue.processing + queue.paused + queue.failed_retrying, - failedTerminal: queue?.failed_terminal - }) - } - // Every dependency is a number on purpose: `queue` is a freshly parsed object on - // each 5s poll, so depending on it re-ran this effect — and re-rendered every - // broadcast card — even when nothing had changed. - }, [ - remaining, - processed, - enqueuedCount, - stats.total_sent, - stats.total_failed, - queue?.pending, - queue?.processing, - queue?.paused, - queue?.failed_retrying, - queue?.failed_terminal - ]) - - // Check if marketing provider is SMTP - const isSmtpProvider = (() => { - if (!workspace?.settings?.marketing_email_provider_id) return false - const marketingProviderId = workspace.settings.marketing_email_provider_id - const integration = workspace.integrations?.find((i) => i.id === marketingProviderId) - return integration?.email_provider?.kind === 'smtp' - })() - - // Opens and clicks are shares of the emails that carried the pixel or tracked links. - const opensBase = trackedBase(stats.total_sent, stats.total_opens_tracked) - const clicksBase = trackedBase(stats.total_sent, stats.total_clicks_tracked) - - const getRate = (numerator: number, denominator: number) => { - if (denominator === 0) return '-' - const percentage = (numerator / denominator) * 100 - if (percentage === 0 || percentage >= 10) { - return `${Math.round(percentage)}%` - } - return `${percentage.toFixed(1)}%` - } - - // Function to navigate to logs page with specific filter - const navigateToLogs = (filterType: string) => { - const searchParams = new URLSearchParams() - searchParams.set('broadcast_id', broadcastId) - - if (filterType !== 'sent') { - searchParams.set(filterType, 'true') - } - - const url = `/console/workspace/${workspaceId}/logs?${searchParams.toString()}` - navigate({ to: url as string & {} }) - } - - // Formatter function for statistics that handles loading state - const formatStat = (value: number | string) => { - if (isLoading) { - return - } - return value - } - - return ( - - - -
navigateToLogs('sent')} - > - - {' '} - {t`Sent`} - - } - value={stats.total_sent} - styles={{ content: { fontSize: '16px' } }} - formatter={formatStat} - /> -
-
- - - -
navigateToLogs('is_delivered')} - > - - {' '} - {t`Delivered`} - - } - value={isSmtpProvider ? '-' : getRate(stats.total_delivered, stats.total_sent)} - styles={{ content: { fontSize: '16px' } }} - formatter={formatStat} - /> -
-
- - - - } - > -
navigateToLogs('is_opened')} - > - - {' '} - {t`Opens`} - - } - value={getRate(stats.total_opened, opensBase)} - styles={{ content: { fontSize: '16px' } }} - formatter={formatStat} - /> -
-
- - - - } - > -
navigateToLogs('is_clicked')} - > - - {' '} - {t`Clicks`} - - } - value={getRate(stats.total_clicked, clicksBase)} - styles={{ content: { fontSize: '16px' } }} - formatter={formatStat} - /> -
-
- - - -
navigateToLogs('is_failed')} - > - - {' '} - {t`Failed`} - - } - value={stats.total_failed} - suffix={ - stats.total_sent > 0 ? ( - - {getRate(stats.total_failed, stats.total_sent + stats.total_failed)} - - ) : undefined - } - styles={{ content: { fontSize: '16px' } }} - formatter={formatStat} - /> -
-
- - - -
navigateToLogs('is_bounced')} - > - - {' '} - {t`Bounced`} - - } - value={isSmtpProvider ? '-' : getRate(stats.total_bounced, stats.total_sent)} - styles={{ content: { fontSize: '16px' } }} - formatter={formatStat} - /> -
-
- - - -
navigateToLogs('is_complained')} - > - - {' '} - {t`Complaints`} - - } - value={isSmtpProvider ? '-' : getRate(stats.total_complained, stats.total_sent)} - styles={{ content: { fontSize: '16px' } }} - formatter={formatStat} - /> -
-
- - - -
navigateToLogs('is_unsubscribed')} - > - - {' '} - {t`Unsub.`} - - } - value={getRate(stats.total_unsubscribed, stats.total_sent)} - styles={{ content: { fontSize: '16px' } }} - formatter={formatStat} - /> -
-
- - - ) -} diff --git a/console/src/components/broadcasts/BroadcastStatusPill.test.tsx b/console/src/components/broadcasts/BroadcastStatusPill.test.tsx new file mode 100644 index 000000000..7aa049936 --- /dev/null +++ b/console/src/components/broadcasts/BroadcastStatusPill.test.tsx @@ -0,0 +1,69 @@ +import '../../__tests__/resizeObserverMock' +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { BroadcastStatusPill } from './BroadcastStatusPill' +import type { Broadcast } from '../../services/api/broadcast' +import type { DeliveryVerdict } from './broadcastStatus' + +const broadcast = (status: string, over: Partial = {}): Broadcast => + ({ id: 'bc-1', name: 'Newsletter', status, ...over }) as unknown as Broadcast + +const hoverTooltip = async (label: string) => { + fireEvent.mouseEnter(screen.getByText(label).closest('span.inline-flex')!) + return waitFor(() => expect(document.querySelector('.ant-tooltip')).not.toBeNull()) +} + +describe('BroadcastStatusPill', () => { + it.each([ + ['draft', 'Draft'], + ['scheduled', 'Scheduled'], + ['testing', 'Testing'], + ['test_completed', 'Pick a winner'], + ['processing', 'Sending'], + ['winner_selected', 'Sending'], + ['paused', 'Paused'], + ['cancelled', 'Cancelled'], + ['failed', 'Failed'] + ])('labels %s "%s"', (status, label) => { + render() + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it('says how many are still to go while a processed broadcast is sending', async () => { + const verdict: DeliveryVerdict = { kind: 'sending', remaining: 12 } + render() + expect(screen.getByText('Sending')).toBeInTheDocument() + await hoverTooltip('Sending') + expect(await screen.findByText('Emails are being delivered. 12 still to go.')).toBeInTheDocument() + }) + + it('names the failures, never "Sent", once the queue gave up on recipients', () => { + render() + expect(screen.getByText('23 failed')).toBeInTheDocument() + expect(screen.queryByText('Sent')).not.toBeInTheDocument() + }) + + it('is "Sent" when the queue is done and nothing failed', () => { + render() + expect(screen.getByText('Sent')).toBeInTheDocument() + }) + + it('reads the failures of another row from the 30-day stats', () => { + render() + expect(screen.getByText('4 failed')).toBeInTheDocument() + }) + + it('keeps naming the winner phase in the tooltip while the winner goes out', async () => { + render() + await hoverTooltip('Sending') + expect( + await screen.findByText('A winner has been selected. Emails are being sent to the remaining recipients.') + ).toBeInTheDocument() + }) + + it('explains a pause with its reason when there is one', async () => { + render() + await hoverTooltip('Paused') + expect(await screen.findByText('Circuit breaker: bounces')).toBeInTheDocument() + }) +}) diff --git a/console/src/components/broadcasts/BroadcastStatusPill.tsx b/console/src/components/broadcasts/BroadcastStatusPill.tsx new file mode 100644 index 000000000..2cc1932f1 --- /dev/null +++ b/console/src/components/broadcasts/BroadcastStatusPill.tsx @@ -0,0 +1,87 @@ +import { Tooltip } from 'antd' +import { useLingui } from '@lingui/react/macro' +import { plural } from '@lingui/core/macro' +import { Pill, useNumberFormat } from '../layout' +import type { Broadcast } from '../../services/api/broadcast' +import { statusPill, type StatusPillContext } from './broadcastStatus' + +export interface BroadcastStatusPillProps extends StatusPillContext { + broadcast: Broadcast +} + +/** The status of a broadcast, as a pill with its explanatory tooltip (list and detail). */ +export function BroadcastStatusPill({ broadcast, verdict, l2Failed }: BroadcastStatusPillProps) { + const { t } = useLingui() + const { int } = useNumberFormat() + const spec = statusPill(broadcast, { verdict, l2Failed }) + const count = int(spec.count ?? 0) + + let label: string + let tooltip: string + switch (spec.kind) { + case 'draft': + label = t`Draft` + tooltip = t`This broadcast is a draft and has not been scheduled or sent yet.` + break + case 'scheduled': + label = t`Scheduled` + tooltip = t`This broadcast is scheduled and will start sending at the specified time.` + break + case 'testing': + label = t`Testing` + tooltip = t`A/B test is in progress. Emails are being sent to the test group.` + break + case 'pickWinner': + label = t`Pick a winner` + tooltip = t`A/B test has completed. Select a winner to send to the remaining recipients.` + break + case 'preparing': + label = t`Sending` + tooltip = t`Preparing emails for delivery. Contacts are being added to the sending queue.` + break + case 'winnerSending': + label = t`Sending` + tooltip = t`A winner has been selected. Emails are being sent to the remaining recipients.` + break + case 'paused': + label = t`Paused` + tooltip = broadcast.pause_reason || t`Sending has been paused. You can resume at any time.` + break + case 'sending': + label = t`Sending` + tooltip = t`Emails are being delivered. ${count} still to go.` + break + case 'sent': + label = t`Sent` + tooltip = t`All emails have been sent.` + break + case 'someFailed': + label = t`${plural(spec.count ?? 0, { one: '# failed', other: '# failed' })}` + tooltip = t`${plural(spec.count ?? 0, { + one: 'The provider refused # recipient and every retry was used up. Open the logs to see why, then retry it.', + other: 'The provider refused # recipients and every retry was used up. Open the logs to see why, then retry them.' + })}` + break + case 'cancelled': + label = t`Cancelled` + tooltip = t`This broadcast was cancelled before completion.` + break + case 'failed': + label = t`Failed` + tooltip = t`This broadcast failed due to an error. Check the logs for details.` + break + default: + label = broadcast.status + tooltip = broadcast.status + } + + return ( + + + + {label} + + + + ) +} diff --git a/console/src/components/broadcasts/BroadcastsTable.tsx b/console/src/components/broadcasts/BroadcastsTable.tsx new file mode 100644 index 000000000..2b54ca236 --- /dev/null +++ b/console/src/components/broadcasts/BroadcastsTable.tsx @@ -0,0 +1,416 @@ +import type { ReactNode } from 'react' +import { Button, Dropdown, Table, Tooltip, type MenuProps } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useLingui } from '@lingui/react/macro' +import { plural } from '@lingui/core/macro' +import { useRouter } from '@tanstack/react-router' +import { CalendarClock, Ellipsis, Pause, Send, Trophy } from 'lucide-react' +import { Pill, RateBar, useNumberFormat } from '../layout' +import type { Broadcast } from '../../services/api/broadcast' +import type { List } from '../../services/api/list' +import type { Segment } from '../../services/api/segment' +import type { Workspace } from '../../services/api/types' +import type { UserPermissions } from '../../services/api/workspace' +import { buildBroadcastAnalyticsLinks } from '../web_analytics/lib/broadcastLinks' +import { BroadcastStatusPill } from './BroadcastStatusPill' +import { availableActions, getRemainingTestTime } from './broadcastStatus' +import { SendingProgress } from './SendingProgress' +import { CouldntLoad } from './CouldntLoad' +import { workspaceTimezone } from './access' +import { broadcastCampaign } from './attribution' +import { scheduledInstant, useBroadcastDates } from './useBroadcastDates' +import type { PageStats } from './useBroadcastPageStats' +import type { ConfirmedAction } from './useBroadcastActions' + +export const OPEN_RATE_SCALE = 0.7 +export const CLICK_RATE_SCALE = 0.25 +export const UNSUBSCRIBE_WARNING = 0.005 +export const BOUNCE_WARNING = 0.02 + +export interface BroadcastsTableProps { + workspace: Workspace + rows: Broadcast[] + loading: boolean + permissions: UserPermissions | null + canReadHistory: boolean + canReadWeb: boolean + pageStats: PageStats + estimates: Map + lists: List[] + segments: Pick[] + activeId?: string + /** A click on a row: the detail, or the composer for a draft. */ + onOpen: (broadcast: Broadcast) => void + onCompose: (broadcast: Broadcast) => void + onAction: (action: ConfirmedAction, broadcast: Broadcast) => void + /** "Retry failed…" of a row without a queue: the detail loads it and offers the action. */ + onOpenDetail: (broadcast: Broadcast) => void + onDelete: (broadcast: Broadcast) => void + footer?: ReactNode +} + +const isStarted = (b: Broadcast) => b.status !== 'draft' && b.status !== 'scheduled' + +/** The template shown for a row: the winner's once chosen, else the first variant's. */ +export function shownVariation(broadcast: Broadcast) { + const variations = broadcast.test_settings?.variations ?? [] + return ( + (broadcast.winning_template && variations.find((v) => v.template_id === broadcast.winning_template)) || + variations[0] + ) +} + +export function BroadcastsTable(props: BroadcastsTableProps) { + const { + workspace, + rows, + loading, + permissions, + canReadHistory, + canReadWeb, + pageStats, + estimates, + lists, + segments, + activeId, + onOpen, + onCompose, + onAction, + onOpenDetail, + onDelete, + footer + } = props + const { t } = useLingui() + const { int, percent } = useNumberFormat() + const dates = useBroadcastDates() + const router = useRouter() + const timezone = workspaceTimezone(workspace) + const canWrite = Boolean(permissions?.broadcasts?.write) + const noWrite = t`You don't have write permission for broadcasts` + + const statCell = (b: Broadcast, render: (s: NonNullable>) => ReactNode) => { + if (!isStarted(b) || !canReadHistory) return — + if (pageStats.statsError) return + const s = pageStats.stats.get(b.id) + if (!s) return — + return render(s) + } + + const audience = (b: Broadcast) => { + const listName = lists.find((l) => l.id === b.audience?.list)?.name ?? b.audience?.list + const chosen = b.audience?.segments ?? [] + const names = chosen.map((id) => segments.find((s) => s.id === id)?.name ?? id) + return ( + + {listName || '—'} + {chosen.length > 0 ? ( + + +{int(chosen.length)} + + ) : null} + + ) + } + + const sending = (b: Broadcast) => { + if (b.status === 'draft') { + const ago = dates.relative(b.updated_at) + return {t`Edited ${ago}`} + } + if (b.status === 'scheduled') { + const scheduled = scheduledInstant(b.schedule, timezone) + if (!scheduled) return — + const iso = scheduled.instant.toISOString() + return ( + + + ) + } + if (b.status === 'testing') { + const duration = getRemainingTestTime(b) + if (duration) return {t`Test ends in ${duration}`} + } + const verdict = pageStats.verdicts.get(b.id) + const progress = pageStats.progress.get(b.id) + const enqueued = pageStats.tasks.get(b.id)?.state?.send_broadcast?.enqueued_count + if (b.status === 'processed' && verdict?.kind === 'sending' && progress && enqueued) { + return ( +
+ +
+ ) + } + return b.started_at ? {dates.dateTime(b.started_at, timezone)} : — + } + + const recipients = (b: Broadcast) => { + if (isStarted(b)) return statCell(b, (s) => {int(s.sent)}) + const estimate = estimates.get(b.id) + if (estimate?.error) return + return estimate?.count === undefined ? ( + — + ) : ( + ≈ {int(estimate.count)} + ) + } + + const rowMenu = (b: Broadcast): MenuProps['items'] => { + const verdict = pageStats.verdicts.get(b.id) + const actions = availableActions(b, verdict, pageStats.progress.get(b.id)) + const l2Failed = pageStats.stats.get(b.id)?.failed ?? 0 + const campaign = broadcastCampaign(b) + const traffic = + canReadWeb && b.started_at && campaign + ? buildBroadcastAnalyticsLinks({ + workspaceId: workspace.id, + campaign, + startedAt: b.started_at, + completedAt: b.completed_at, + timezone + }).traffic + : null + + const items: NonNullable = [] + const guarded = (key: string, label: string, onClick: () => void, danger = false) => + items.push({ key, label: canWrite ? label : {label}, disabled: !canWrite, danger, onClick }) + if (actions.edit) guarded('edit', t`Edit`, () => onCompose(b)) + if (actions.pause) guarded('pause', t`Pause`, () => onAction('pause', b)) + if (actions.resume) guarded('resume', t`Resume`, () => onAction('resume', b)) + if (actions.cancel) guarded('cancel', t`Cancel`, () => onAction('cancel', b)) + if (actions.retryFailed) guarded('retry', t`Retry failed`, () => onAction('retryFailed', b)) + else if ((b.status === 'processed' || b.status === 'failed') && !verdict && l2Failed > 0) + items.push({ key: 'retry-detail', label: t`Retry failed…`, onClick: () => onOpenDetail(b) }) + if (actions.delete) guarded('delete', t`Delete`, () => onDelete(b), true) + if (traffic) { + items.push({ type: 'divider' }) + items.push({ + key: 'traffic', + label: ( + + {t`Website traffic`} + + ) + }) + } + return items + } + + const columns: ColumnsType = [ + { + key: 'status', + title: t`Status`, + width: 120, + render: (_, b) => ( + + ) + }, + { + key: 'broadcast', + title: t`Broadcast`, + render: (_, b) => { + const variation = shownVariation(b) + const subject = variation?.template?.email?.subject + const ab = b.test_settings?.enabled + const winner = b.winning_template + ? b.test_settings.variations.find((v) => v.template_id === b.winning_template) + : undefined + const count = b.test_settings?.variations?.length ?? 0 + // The A/B pill opens the second line, so the name keeps the column's width. + const winnerName = winner ? winner.template?.name || winner.variation_name : '' + const pill = ab ? ( + winner ? ( + + ) : ( + + {t`${plural(count, { one: 'A/B · # variant', other: 'A/B · # variants' })}`} + + ) + ) : null + const second = subject ? ( + {subject} + ) : b.status === 'draft' && !variation?.template_id ? ( + {t`No template yet`} + ) : variation?.template_id && !variation.template ? ( + // Without Templates read the list comes without templates: the ID says which. + {variation.template_id} + ) : null + return ( +
+ + {b.name} + + {pill || second ? ( + + {pill ? {pill} : null} + {second} + + ) : null} +
+ ) + } + }, + { key: 'audience', title: t`Audience`, width: 150, render: (_, b) => audience(b) }, + { key: 'sending', title: t`Sending`, width: 190, render: (_, b) => sending(b) }, + { key: 'recipients', title: t`Recipients`, width: 100, align: 'right', render: (_, b) => recipients(b) }, + { + key: 'opens', + title: t`Opens`, + width: 110, + render: (_, b) => + statCell(b, (s) => ( + 0 ? s.opened / s.opensTracked : null} + scale={OPEN_RATE_SCALE} + tone={s.opensTracked < s.sent ? 'partial' : 'default'} + /> + )) + }, + { + key: 'clicks', + title: t`Clicks`, + width: 110, + render: (_, b) => + statCell(b, (s) => ( + 0 ? s.clicked / s.clicksTracked : null} + scale={CLICK_RATE_SCALE} + tone={s.clicksTracked < s.sent ? 'partial' : 'default'} + /> + )) + }, + { + key: 'unsub', + title: t`Unsub.`, + width: 80, + align: 'right', + render: (_, b) => + statCell(b, (s) => { + if (s.sent === 0) return — + const share = s.unsubscribed / s.sent + return ( + = UNSUBSCRIBE_WARNING ? 'text-warning-fg' : 'text-ink'}`}> + {percent(share)} + + ) + }) + }, + { + key: 'bounces', + title: t`Bounces`, + width: 80, + align: 'right', + render: (_, b) => + statCell(b, (s) => { + if (s.sent === 0) return — + const share = s.bounced / s.sent + return ( + = BOUNCE_WARNING ? 'text-warning-fg' : 'text-ink'}`}> + {percent(share)} + + ) + }) + } + ] + + if (canReadWeb) { + columns.push({ + key: 'visits', + title: ( + + {t`Website visits`} + + ), + width: 110, + align: 'right', + render: (_, b) => { + if (!b.started_at) return — + if (!broadcastCampaign(b)) { + return ( + + — + + ) + } + if (pageStats.visitsError) return + const visits = pageStats.visits.get(b.id) + return visits === null || visits === undefined ? ( + — + ) : ( + {int(visits)} + ) + } + }) + } + + columns.push({ + key: 'actions', + width: 76, + align: 'right', + render: (_, b) => { + const verdict = pageStats.verdicts.get(b.id) + const sendingNow = b.status === 'processing' || (b.status === 'processed' && verdict?.kind === 'sending') + return ( + // The row opens on click: the actions keep their clicks. +
e.stopPropagation()}> + {b.status === 'draft' ? ( + +
+ ) + } + }) + + return ( + + rowKey="id" + columns={columns} + dataSource={rows} + loading={loading && rows.length === 0 ? false : undefined} + pagination={false} + size="middle" + tableLayout="fixed" + // The Broadcast column keeps about 200px: below that the table scrolls. + scroll={{ x: 1150 }} + rowClassName={(b) => `cursor-pointer ${b.id === activeId ? 'is-hover' : ''}`} + onRow={(b) => ({ onClick: () => onOpen(b) })} + footer={footer ? () => footer : undefined} + /> + ) +} diff --git a/console/src/components/broadcasts/ChangeVariationSenderModal.test.tsx b/console/src/components/broadcasts/ChangeVariationSenderModal.test.tsx index a5822ac70..ac46afa6c 100644 --- a/console/src/components/broadcasts/ChangeVariationSenderModal.test.tsx +++ b/console/src/components/broadcasts/ChangeVariationSenderModal.test.tsx @@ -60,8 +60,9 @@ const makeBroadcast = (overrides: Partial = {}): Broadcast => ...overrides }) as unknown as Broadcast +let queryClient: QueryClient const renderModal = (broadcast: Broadcast, disabled = false, canReadTemplates = true) => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render( @@ -144,6 +145,18 @@ describe('ChangeVariationSenderModal', () => { expect(templatesApi.list).not.toHaveBeenCalled() }) + it('refreshes the list and the detail page of the broadcast', async () => { + renderModal(makeBroadcast()) + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + await userEvent.click(screen.getByRole('button', { name: 'Change sender' })) + await screen.findByText('Sender of variation A') + await userEvent.click(screen.getByRole('combobox')) + await userEvent.click(await screen.findByText('Promos ')) + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['broadcast', 'ws1', 'bc1'] })) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['broadcasts', 'ws1'] }) + }) + it('numbers unnamed variations, as the console saves them', async () => { const broadcast = makeBroadcast() broadcast.test_settings.variations[0].variation_name = '' diff --git a/console/src/components/broadcasts/ChangeVariationSenderModal.tsx b/console/src/components/broadcasts/ChangeVariationSenderModal.tsx index e79dae27a..7630e08bb 100644 --- a/console/src/components/broadcasts/ChangeVariationSenderModal.tsx +++ b/console/src/components/broadcasts/ChangeVariationSenderModal.tsx @@ -111,6 +111,8 @@ export function ChangeVariationSenderModal({ message.success(t`Sender changed. You can now resume the broadcast.`) setOpen(false) queryClient.invalidateQueries({ queryKey: ['broadcasts', workspace.id] }) + // The detail page reads the broadcast by its own key. + queryClient.invalidateQueries({ queryKey: ['broadcast', workspace.id, broadcast.id] }) } catch (error) { message.error( error instanceof Error diff --git a/console/src/components/broadcasts/CouldntLoad.tsx b/console/src/components/broadcasts/CouldntLoad.tsx new file mode 100644 index 000000000..011574160 --- /dev/null +++ b/console/src/components/broadcasts/CouldntLoad.tsx @@ -0,0 +1,12 @@ +import { Tooltip } from 'antd' +import { useLingui } from '@lingui/react/macro' + +/** "—" for a number that failed to load, with the reason in a tooltip; the rest of the page lives. */ +export function CouldntLoad() { + const { t } = useLingui() + return ( + + — + + ) +} diff --git a/console/src/components/broadcasts/DataFeedSettings.tsx b/console/src/components/broadcasts/DataFeedSettings.tsx deleted file mode 100644 index ca676db48..000000000 --- a/console/src/components/broadcasts/DataFeedSettings.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Divider } from 'antd' -import { GlobalFeedSettings } from './GlobalFeedSettings' -import { RecipientFeedSettings } from './RecipientFeedSettings' -import type { - GlobalFeedSettings as GlobalFeedSettingsType, - RecipientFeedSettings as RecipientFeedSettingsType -} from '../../services/api/broadcast' - -interface DataFeedSettingsProps { - workspaceId: string - broadcastId?: string - globalFeed?: GlobalFeedSettingsType - onGlobalFeedChange?: (settings: GlobalFeedSettingsType) => void - globalFeedData?: Record - globalFeedFetchedAt?: string - recipientFeed?: RecipientFeedSettingsType - onRecipientFeedChange?: (settings: RecipientFeedSettingsType) => void - disabled?: boolean -} - -export function DataFeedSettings({ - workspaceId, - broadcastId, - globalFeed, - onGlobalFeedChange, - globalFeedData, - globalFeedFetchedAt, - recipientFeed, - onRecipientFeedChange, - disabled = false -}: DataFeedSettingsProps) { - return ( - <> - - - - - ) -} diff --git a/console/src/components/broadcasts/DeleteBroadcastModal.tsx b/console/src/components/broadcasts/DeleteBroadcastModal.tsx new file mode 100644 index 000000000..45e840176 --- /dev/null +++ b/console/src/components/broadcasts/DeleteBroadcastModal.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react' +import { App, Button, Input, Modal } from 'antd' +import { useLingui } from '@lingui/react/macro' +import { Mono } from '../layout' +import type { Broadcast } from '../../services/api/broadcast' + +export interface DeleteBroadcastModalProps { + broadcast: Broadcast | null + onClose: () => void + /** Deletes the broadcast; rejects on failure. */ + onDelete: (broadcast: Broadcast) => Promise +} + +/** Deleting a draft is confirmed by typing its identifier. */ +export function DeleteBroadcastModal({ broadcast, onClose, onDelete }: DeleteBroadcastModalProps) { + const { t } = useLingui() + const { message } = App.useApp() + const [input, setInput] = useState('') + const [deleting, setDeleting] = useState(false) + + const close = () => { + setInput('') + onClose() + } + + const handleDelete = async () => { + if (!broadcast) return + setDeleting(true) + try { + await onDelete(broadcast) + close() + } catch { + message.error(t`Failed to delete broadcast`) + } finally { + setDeleting(false) + } + } + + const mismatch = Boolean(broadcast && input && input !== broadcast.id) + + return ( + + {t`Cancel`} + , + + ]} + > + {broadcast ? ( +
+

{t`Are you sure you want to delete the broadcast "${broadcast.name}"?`}

+

+ {t`This action cannot be undone. To confirm, please enter the broadcast ID:`}{' '} + {broadcast.id} +

+ setInput(e.target.value)} + status={mismatch ? 'error' : ''} + /> + {mismatch ?

{t`ID doesn't match`}

: null} +
+ ) : null} +
+ ) +} diff --git a/console/src/components/broadcasts/FeedSettings.test.tsx b/console/src/components/broadcasts/FeedSettings.test.tsx new file mode 100644 index 000000000..ca7c883fb --- /dev/null +++ b/console/src/components/broadcasts/FeedSettings.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { App as AntApp } from 'antd' +import type { ReactNode } from 'react' +import { GlobalFeedSettings } from './GlobalFeedSettings' +import { RecipientFeedSettings } from './RecipientFeedSettings' + +const wrap = (node: ReactNode) => + render( + + {node} + + ) + +const feed = { enabled: true, url: 'https://api.example.com/feed', headers: [] } + +describe.each([ + ['GlobalFeedSettings', (id?: string) => ], + ['RecipientFeedSettings', (id?: string) => ] +])('%s test button', (_name, element) => { + it('is disabled until the broadcast is saved, and says so', async () => { + wrap(element()) + const button = screen.getByRole('button', { name: 'Test' }) + expect(button).toBeDisabled() + fireEvent.mouseEnter(button.parentElement as HTMLElement) + fireEvent.mouseOver(button) + expect(await screen.findByText('Save the broadcast first')).toBeInTheDocument() + }) + + it('is enabled once the broadcast exists', () => { + wrap(element('bc1')) + expect(screen.getByRole('button', { name: 'Test' })).toBeEnabled() + }) +}) diff --git a/console/src/components/broadcasts/GlobalFeedSettings.tsx b/console/src/components/broadcasts/GlobalFeedSettings.tsx index dad0fa716..8559309b5 100644 --- a/console/src/components/broadcasts/GlobalFeedSettings.tsx +++ b/console/src/components/broadcasts/GlobalFeedSettings.tsx @@ -112,15 +112,20 @@ export function GlobalFeedSettings({ onChange={(e) => handleChange('url', e.target.value)} disabled={disabled} /> - + {/* The test calls the feed for a stored broadcast; a disabled button fires no mouse event, hence the span. */} + + + + + diff --git a/console/src/components/broadcasts/RecipientFeedSettings.tsx b/console/src/components/broadcasts/RecipientFeedSettings.tsx index c50d2513d..d440c8ec7 100644 --- a/console/src/components/broadcasts/RecipientFeedSettings.tsx +++ b/console/src/components/broadcasts/RecipientFeedSettings.tsx @@ -121,14 +121,19 @@ export function RecipientFeedSettings({ onChange={(e) => handleChange('url', e.target.value)} disabled={disabled} /> - + {/* The test calls the feed for a stored broadcast; a disabled button fires no mouse event, hence the span. */} + + + + + diff --git a/console/src/components/broadcasts/SendOrScheduleModal.tsx b/console/src/components/broadcasts/SendOrScheduleModal.tsx deleted file mode 100644 index db4d648d4..000000000 --- a/console/src/components/broadcasts/SendOrScheduleModal.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { useState } from 'react' -import { - Modal, - Button, - Form, - Switch, - Select, - DatePicker, - Row, - Col, - message, - Space, - Alert -} from 'antd' -import { useLingui } from '@lingui/react/macro' -import { Broadcast } from '../../services/api/broadcast' -import { broadcastApi } from '../../services/api/broadcast' -import type { Workspace } from '../../services/api/types' -import dayjs from '../../lib/dayjs' -import { TIMEZONE_OPTIONS } from '../../lib/timezones' - -// Feature flag for recipient timezone functionality -const ENABLE_RECIPIENT_TIMEZONE = false - -interface SendOrScheduleModalProps { - broadcast: Broadcast | null - visible: boolean - onClose: () => void - workspaceId: string - workspace?: Workspace - onSuccess: () => void -} - -export function SendOrScheduleModal({ - broadcast, - visible, - onClose, - workspaceId, - workspace, - onSuccess -}: SendOrScheduleModalProps) { - const { t } = useLingui() - const [form] = Form.useForm() - const [isScheduled, setIsScheduled] = useState(false) - const [loading, setLoading] = useState(false) - - const hasMarketingEmailProvider = workspace?.settings?.marketing_email_provider_id - - // Helper function to extract error message from API response - const getErrorMessage = (error: unknown, defaultMessage: string): string => { - // Try to extract message from various possible response structures - // Check for 'error' field first (used by WriteJSONError in backend) - if (error && typeof error === 'object' && 'response' in error) { - const response = (error as { response?: { data?: { error?: string; message?: string } } }).response - if (response?.data?.error) { - return response.data.error - } - // Check for 'message' field (used by some other handlers) - if (response?.data?.message) { - return response.data.message - } - } - // Fallback to general error message - if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') { - return (error as { message: string }).message - } - return defaultMessage - } - - // Reset form when modal opens - const handleOpen = () => { - // Get the default timezone from broadcast or workspace or fall back to UTC - const defaultTimezone = broadcast?.schedule?.timezone || 'UTC' - - form.setFieldsValue({ - is_scheduled: false, - scheduled_date: null, - scheduled_time: '11:00', - timezone: defaultTimezone, - use_recipient_timezone: false - }) - setIsScheduled(false) - } - - // Send broadcast immediately - const handleSendNow = async () => { - if (!broadcast) return - - setLoading(true) - try { - await broadcastApi.schedule({ - workspace_id: workspaceId, - id: broadcast.id, - send_now: true - }) - message.success(t`Broadcast "${broadcast.name}" sending started`) - onSuccess() - onClose() - } catch (error) { - console.error(error) - const errorMessage = getErrorMessage(error, t`Failed to send broadcast`) - message.error(errorMessage) - } finally { - setLoading(false) - } - } - - // Schedule broadcast or send immediately based on form state - const handleSubmit = async () => { - if (!broadcast) return - - try { - // Only validate fields if scheduling is enabled - if (isScheduled) { - await form.validateFields() - } - - const values = form.getFieldsValue() - - if (!values.is_scheduled) { - // If not scheduled, send immediately - return handleSendNow() - } - - setLoading(true) - - // For scheduled broadcasts, we need to send the schedule details - try { - // Format date and time for API - const scheduledDate = dayjs(values.scheduled_date).format('YYYY-MM-DD') - const scheduledTime = values.scheduled_time - - // Now schedule the broadcast - await broadcastApi.schedule({ - workspace_id: workspaceId, - id: broadcast.id, - send_now: false, - scheduled_date: scheduledDate, - scheduled_time: scheduledTime, - timezone: values.timezone, - use_recipient_timezone: values.use_recipient_timezone - }) - - message.success(t`Broadcast "${broadcast.name}" scheduled successfully`) - onSuccess() - onClose() - } catch (error) { - console.error(error) - const errorMessage = getErrorMessage(error, t`Failed to schedule broadcast`) - message.error(errorMessage) - } - } catch (error) { - console.error(error) - message.error(t`Please check the form for errors`) - } finally { - setLoading(false) - } - } - - if (!broadcast) return null - - return ( - { - if (visible) handleOpen() - }} - > -
- {!hasMarketingEmailProvider && ( - - {t`Configure Provider`} - - } - /> - )} - -
-

{t`Do you want to send "${broadcast.name}" immediately or schedule it for later?`}

-
- - - setIsScheduled(checked)} /> - - - {isScheduled && ( - <> - -
- - { - // Can't select days before today - return current && current < dayjs().startOf('day') - }} - style={{ width: '100%' }} - /> - - - - - - - - - - - - - onChange?.(e.target.value)} - /> - ) -})) - -// The sender field reads the chosen template's sender from the template list. -vi.mock('../../services/api/template', () => ({ - templatesApi: { - list: vi.fn().mockResolvedValue({ templates: [{ id: 'tmpl1', email: { sender_id: 's2' } }] }) - } -})) - -vi.mock('../../services/api/broadcast', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - broadcastApi: { - list: vi.fn(), - get: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - schedule: vi.fn(), - refreshGlobalFeed: vi.fn(), - testRecipientFeed: vi.fn() - } - } -}) - -const workspace = { - id: 'ws1', - settings: { - website_url: 'https://example.com', - email_tracking_enabled: true - } -} as unknown as Workspace - -const lists = [{ id: 'list1', name: 'Newsletter' }] as unknown as List[] - -const makeBroadcast = (overrides: Partial = {}): Broadcast => - ({ - id: 'bc1', - workspace_id: 'ws1', - name: 'Weekly Newsletter', - channel_type: 'email', - status: 'draft', - audience: { list: 'list1', segments: [], exclude_unsubscribed: true }, - schedule: { is_scheduled: false, use_recipient_timezone: false }, - test_settings: { - enabled: false, - sample_percentage: 50, - auto_send_winner: false, - variations: [{ variation_name: 'default', template_id: 'tmpl1' }] - }, - data_feed: { - global_feed: { enabled: true, url: 'https://api.example.com/global', headers: [] }, - recipient_feed: { enabled: true, url: 'https://api.example.com/recipient', headers: [] } - }, - test_phase_recipient_count: 0, - winner_phase_recipient_count: 0, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', - ...overrides - }) as unknown as Broadcast - -function renderDrawer(ui: ReactElement) { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return render( - - {ui} - - ) -} - -const openEditDrawer = async (broadcast: Broadcast) => { - renderDrawer() - await userEvent.click(screen.getByRole('button', { name: /Edit Broadcast/i })) -} - -// The two feed switches carry no accessible name of their own; each sits in the -// heading row next to its title. -const feedSwitch = (title: string) => - within(screen.getByText(title).closest('div') as HTMLElement).getByRole('switch') - -const goToTab = async (label: string) => { - await userEvent.click(screen.getByRole('tab', { name: label })) -} - -const save = async () => { - await goToTab('4. Content') - await userEvent.click(screen.getByRole('button', { name: 'Save' })) -} - -const updatePayload = () => - (broadcastApi.update as Mock).mock.calls[0][0] - -describe('UpsertBroadcastDrawer data feeds', () => { - beforeEach(() => { - vi.clearAllMocks() - ;(broadcastApi.update as Mock).mockResolvedValue({ broadcast: {} }) - ;(broadcastApi.create as Mock).mockResolvedValue({ broadcast: {} }) - }) - - it('turns a stored feed off when the user unchecks both toggles', async () => { - // An absent data_feed - and an absent sub-feed inside it - means "keep what is - // stored" on the server, so the payload has to spell out that both feeds are off - // or the broadcast keeps calling the feed URLs on every send. - await openEditDrawer(makeBroadcast()) - - await goToTab('3. Data Feeds') - await userEvent.click(feedSwitch('Global Data Feed')) - await userEvent.click(feedSwitch('Per-Recipient Data Feed')) - - await save() - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - const payload = updatePayload() - expect(payload.data_feed).toBeDefined() - expect(payload.data_feed.global_feed).toBeDefined() - expect(payload.data_feed.global_feed.enabled).toBe(false) - expect(payload.data_feed.recipient_feed).toBeDefined() - expect(payload.data_feed.recipient_feed.enabled).toBe(false) - }) - - it('records both feeds as off on a broadcast created without any feed', async () => { - renderDrawer() - await userEvent.click(screen.getByRole('button', { name: /Create Broadcast/i })) - - await userEvent.type( - screen.getByPlaceholderText('E.g. Weekly Newsletter - May 2023'), - 'Weekly Newsletter' - ) - await userEvent.click(screen.getAllByRole('combobox')[0]) - await userEvent.click(await screen.findByText('Newsletter')) - - await goToTab('4. Content') - await userEvent.type(screen.getByTestId('template-selector'), 'tmpl1') - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(broadcastApi.create).toHaveBeenCalled()) - const payload = (broadcastApi.create as Mock).mock.calls[0][0] - expect(payload.data_feed.global_feed.enabled).toBe(false) - expect(payload.data_feed.recipient_feed.enabled).toBe(false) - }) - - it('keeps the feed the user left on while turning the other one off', async () => { - await openEditDrawer(makeBroadcast()) - - await goToTab('3. Data Feeds') - await userEvent.click(feedSwitch('Per-Recipient Data Feed')) - - await save() - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - const payload = updatePayload() - expect(payload.data_feed.global_feed).toEqual({ - enabled: true, - url: 'https://api.example.com/global', - headers: [] - }) - expect(payload.data_feed.recipient_feed.enabled).toBe(false) - }) -}) - -// The drawer edits the audience, the templates and the UTM tags; the send date is owned by -// the send-or-schedule modal and broadcasts.schedule. The pencil is offered on a scheduled -// broadcast too, so a typo fix must not be able to move the date it is already booked for. -describe('UpsertBroadcastDrawer schedule', () => { - beforeEach(() => { - vi.clearAllMocks() - ;(broadcastApi.update as Mock).mockResolvedValue({ broadcast: {} }) - ;(broadcastApi.create as Mock).mockResolvedValue({ broadcast: {} }) - }) - - it('sends no schedule when a scheduled broadcast is edited', async () => { - await openEditDrawer( - makeBroadcast({ - status: 'scheduled', - schedule: { - is_scheduled: true, - scheduled_date: '2026-09-01', - scheduled_time: '09:00', - timezone: 'Europe/Paris', - use_recipient_timezone: false - } - }) - ) - - await save() - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - // An absent schedule is the server's "leave the stored one alone". Sending the drawer's - // empty default instead clears is_scheduled, and a later resume reads that as "send now". - expect('schedule' in updatePayload()).toBe(false) - }) - - it('sends no schedule when a draft broadcast is edited', async () => { - await openEditDrawer(makeBroadcast()) - - await save() - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect('schedule' in updatePayload()).toBe(false) - }) - - it('creates a new broadcast unscheduled', async () => { - renderDrawer() - await userEvent.click(screen.getByRole('button', { name: /Create Broadcast/i })) - - await userEvent.type( - screen.getByPlaceholderText('E.g. Weekly Newsletter - May 2023'), - 'Weekly Newsletter' - ) - await userEvent.click(screen.getAllByRole('combobox')[0]) - await userEvent.click(await screen.findByText('Newsletter')) - - await goToTab('4. Content') - await userEvent.type(screen.getByTestId('template-selector'), 'tmpl1') - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(broadcastApi.create).toHaveBeenCalled()) - const payload = (broadcastApi.create as Mock).mock.calls[0][0] - expect(payload.schedule).toEqual({ is_scheduled: false, use_recipient_timezone: false }) - }) - - it('creates a new broadcast tracked, with the "Don\'t track" box unchecked', async () => { - renderDrawer() - await userEvent.click(screen.getByRole('button', { name: /Create Broadcast/i })) - - await userEvent.type( - screen.getByPlaceholderText('E.g. Weekly Newsletter - May 2023'), - 'Weekly Newsletter' - ) - await userEvent.click(screen.getAllByRole('combobox')[0]) - await userEvent.click(await screen.findByText('Newsletter')) - - await goToTab('2. Web Analytics') - expect(screen.getByRole('checkbox', { name: /Don't track opens and clicks/i })).not.toBeChecked() - - await goToTab('4. Content') - await userEvent.type(screen.getByTestId('template-selector'), 'tmpl1') - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(broadcastApi.create).toHaveBeenCalled()) - expect((broadcastApi.create as Mock).mock.calls[0][0].tracking_mode).toBe('inherit') - }) -}) - -describe('UpsertBroadcastDrawer tracking', () => { - beforeEach(() => { - vi.clearAllMocks() - ;(broadcastApi.update as Mock).mockResolvedValue({ broadcast: {} }) - ;(broadcastApi.create as Mock).mockResolvedValue({ broadcast: {} }) - }) - - const noTrackingCheckbox = () => - screen.getByRole('checkbox', { name: /Don't track opens and clicks/i }) - - it('follows the workspace and each contact by default', async () => { - await openEditDrawer(makeBroadcast()) - - await save() - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect(updatePayload().tracking_mode).toBe('inherit') - }) - - it('sends no_tracking when the user turns tracking off for the broadcast', async () => { - await openEditDrawer(makeBroadcast()) - - await goToTab('2. Web Analytics') - await userEvent.click(noTrackingCheckbox()) - await save() - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect(updatePayload().tracking_mode).toBe('no_tracking') - }) - - it('shows the stored no_tracking mode checked and turns it back on', async () => { - await openEditDrawer(makeBroadcast({ tracking_mode: 'no_tracking' })) - - await goToTab('2. Web Analytics') - expect(noTrackingCheckbox()).toBeChecked() - await userEvent.click(noTrackingCheckbox()) - await save() - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect(updatePayload().tracking_mode).toBe('inherit') - }) - - it('turns off and locks the automatic winner when the broadcast is not tracked', async () => { - await openEditDrawer( - makeBroadcast({ - test_settings: { - enabled: true, - sample_percentage: 50, - auto_send_winner: true, - auto_send_winner_metric: 'open_rate', - test_duration_hours: 2, - variations: [ - { variation_name: 'A', template_id: 'tmpl1' }, - { variation_name: 'B', template_id: 'tmpl2' } - ] - } - } as Partial) - ) - - await goToTab('2. Web Analytics') - await userEvent.click(noTrackingCheckbox()) - await goToTab('4. Content') - - const autoWinner = within( - screen.getByText('Automatically send winner').closest('.ant-form-item') as HTMLElement - ).getByRole('switch') - expect(autoWinner).not.toBeChecked() - expect(autoWinner).toBeDisabled() - - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect(updatePayload().test_settings.auto_send_winner).toBe(false) - expect(updatePayload().tracking_mode).toBe('no_tracking') - }) - - it('warns that only consenting contacts count when unknown contacts are not tracked', async () => { - const strictWorkspace = { - ...workspace, - settings: { - ...workspace.settings, - email_tracking: { opens_enabled: true, clicks_enabled: true, unknown_consent: 'no_track' } - } - } as unknown as Workspace - renderDrawer( - )} - lists={lists} - /> - ) - await userEvent.click(screen.getByRole('button', { name: /Edit Broadcast/i })) - await goToTab('4. Content') - - expect( - screen.getByText(/only recipients who agreed to tracking count in the test results/i) - ).toBeInTheDocument() - }) - - it('says nothing about consent when unknown contacts are tracked', async () => { - await openEditDrawer( - makeBroadcast({ - test_settings: { - enabled: true, - sample_percentage: 50, - auto_send_winner: false, - variations: [ - { variation_name: 'A', template_id: 'tmpl1' }, - { variation_name: 'B', template_id: 'tmpl2' } - ] - } - } as Partial) - ) - await goToTab('4. Content') - - expect( - screen.queryByText(/only recipients who agreed to tracking count in the test results/i) - ).not.toBeInTheDocument() - }) -}) - -// Switching the exclusion off is how a notice every member must receive is sent (issue #484); -// the switch says what else it changes: the emails carry no List-Unsubscribe header. -describe('UpsertBroadcastDrawer unsubscribed recipients', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('explains that including unsubscribed contacts drops the List-Unsubscribe header', async () => { - await openEditDrawer(makeBroadcast()) - - expect(screen.getByText('Exclude unsubscribed recipients')).toBeInTheDocument() - expect( - screen.getByText(/carry no List-Unsubscribe header, so mail clients show no Unsubscribe button/) - ).toBeInTheDocument() - }) -}) - -describe('UpsertBroadcastDrawer sender', () => { - const withSenders = { - ...workspace, - settings: { ...workspace.settings, marketing_email_provider_id: 'mkt' }, - integrations: [ - { - id: 'mkt', - email_provider: { - senders: [ - { id: 's1', name: 'Default', email: 'hello@example.com', is_default: true }, - { id: 's2', name: 'Newsletter', email: 'news@example.com', is_default: false }, - { id: 's3', name: 'Promos', email: 'promos@example.com', is_default: false } - ] - } - } - ] - } as unknown as Workspace - - const openWithSenders = async (broadcast: Broadcast) => { - renderDrawer( - - ) - await userEvent.click(screen.getByRole('button', { name: /Edit Broadcast/i })) - await goToTab('4. Content') - } - - beforeEach(() => { - vi.clearAllMocks() - ;(broadcastApi.update as Mock).mockResolvedValue({ broadcast: {} }) - }) - - it('sends the sender chosen for the variation', async () => { - await openWithSenders(makeBroadcast()) - - expect(await screen.findByText("Template's sender (Newsletter)")).toBeInTheDocument() - await userEvent.click(screen.getByLabelText('Sender')) - await userEvent.click(await screen.findByText('Promos ')) - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect(updatePayload().test_settings.variations[0]).toMatchObject({ - template_id: 'tmpl1', - sender_id: 's3' - }) - }) - - it("sends no sender when the variation keeps the template's", async () => { - await openWithSenders(makeBroadcast()) - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect(updatePayload().test_settings.variations[0].sender_id).toBeUndefined() - }) - - it('sends no sender once the chosen one is cleared', async () => { - await openWithSenders( - makeBroadcast({ - test_settings: { - enabled: false, - sample_percentage: 50, - auto_send_winner: false, - variations: [{ variation_name: 'default', template_id: 'tmpl1', sender_id: 's3' }] - } - }) - ) - - const select = screen.getByLabelText('Sender').closest('.ant-select') as HTMLElement - const clear = select.querySelector('.ant-select-clear') as HTMLElement - fireEvent.mouseDown(clear) - fireEvent.click(clear) - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect(updatePayload().test_settings.variations[0].sender_id).toBeUndefined() - }) - - it('refuses to save a sender that has left the integration', async () => { - await openWithSenders( - makeBroadcast({ - test_settings: { - enabled: false, - sample_percentage: 50, - auto_send_winner: false, - variations: [{ variation_name: 'default', template_id: 'tmpl1', sender_id: 'gone' }] - } - }) - ) - - expect(screen.getByText('gone (deleted)')).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - - expect( - await screen.findByText( - "This sender is no longer one of the marketing email integration's. Choose another one or clear the field to use the template's." - ) - ).toBeInTheDocument() - expect(broadcastApi.update).not.toHaveBeenCalled() - }) - - it('gives each A/B variation its own sender', async () => { - await openWithSenders( - makeBroadcast({ - test_settings: { - enabled: true, - sample_percentage: 20, - auto_send_winner: false, - variations: [ - { variation_name: 'a', template_id: 'tmpl1' }, - { variation_name: 'b', template_id: 'tmpl2', sender_id: 's3' } - ] - } - }) - ) - - await userEvent.click(screen.getByLabelText('Sender 1')) - await userEvent.click(await screen.findByText('Default ')) - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - const variations = updatePayload().test_settings.variations - expect(variations[0].sender_id).toBe('s1') - expect(variations[1].sender_id).toBe('s3') - }) -}) - -// Without Templates read, templates.list and templates.get answer 403: each variation's template -// field gets the permission, so it shows the stored ID locked, and the sender field beside it -// requests nothing. The broadcast still saves with its templates. -describe('UpsertBroadcastDrawer without Templates read', () => { - beforeEach(() => { - vi.clearAllMocks() - ;(broadcastApi.update as Mock).mockResolvedValue({ broadcast: {} }) - }) - - const openLocked = async (broadcast: Broadcast) => { - renderDrawer( - - ) - await userEvent.click(screen.getByRole('button', { name: /Edit Broadcast/i })) - await goToTab('4. Content') - } - - it('locks the template of a single variation and keeps it on save', async () => { - await openLocked(makeBroadcast()) - - const selectors = screen.getAllByTestId('template-selector') - expect(selectors.map((el) => el.getAttribute('data-can-read-templates'))).toEqual(['false']) - await userEvent.click(screen.getByRole('button', { name: 'Save' })) - await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) - expect(updatePayload().test_settings.variations[0].template_id).toBe('tmpl1') - expect(templatesApi.list).not.toHaveBeenCalled() - }) - - it('locks the template of every A/B variation', async () => { - await openLocked( - makeBroadcast({ - test_settings: { - enabled: true, - sample_percentage: 20, - auto_send_winner: false, - variations: [ - { variation_name: 'a', template_id: 'tmpl1' }, - { variation_name: 'b', template_id: 'tmpl2' } - ] - } - }) - ) - - const selectors = screen.getAllByTestId('template-selector') - expect(selectors.map((el) => el.getAttribute('data-can-read-templates'))).toEqual([ - 'false', - 'false' - ]) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(templatesApi.list).not.toHaveBeenCalled() - }) - - it('gives the template fields the permission when the member has it', async () => { - renderDrawer( - - ) - await userEvent.click(screen.getByRole('button', { name: /Edit Broadcast/i })) - await goToTab('4. Content') - - expect(screen.getByTestId('template-selector').getAttribute('data-can-read-templates')).toBe('true') - await waitFor(() => expect(templatesApi.list).toHaveBeenCalled()) - }) -}) diff --git a/console/src/components/broadcasts/UpsertBroadcastDrawer.tsx b/console/src/components/broadcasts/UpsertBroadcastDrawer.tsx deleted file mode 100644 index 9db5a1004..000000000 --- a/console/src/components/broadcasts/UpsertBroadcastDrawer.tsx +++ /dev/null @@ -1,950 +0,0 @@ -import { useState, useEffect } from 'react' -import { - Button, - Drawer, - Form, - Input, - Select, - Space, - App, - Row, - Col, - Switch, - InputNumber, - Popconfirm, - Alert, - Tag, - Tabs, - Tooltip, - Checkbox -} from 'antd' -import { useLingui } from '@lingui/react/macro' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { - broadcastApi, - Broadcast, - CreateBroadcastRequest, - UpdateBroadcastRequest -} from '../../services/api/broadcast' -import type { Workspace } from '../../services/api/types' -import TemplateSelectorInput from '../templates/TemplateSelectorInput' -import { DeleteOutlined, InfoCircleOutlined } from '@ant-design/icons' -import React from 'react' -import extractTLD from '../../lib/tld' -import type { List } from '../../services/api/list' -import { DataFeedSettings } from './DataFeedSettings' -import type { GlobalFeedSettings, RecipientFeedSettings } from '../../services/api/broadcast' -import { getEmailTrackingPolicy, TRACKING_DOCS_URL } from '../../lib/emailTracking' -import { VariationSenderSelect } from './VariationSenderSelect' -import { getMarketingSenders } from './variationSender' - -// Custom component to handle A/B testing configuration -const ABTestingConfig = ({ form }: { form: ReturnType[0] }) => { - const { t } = useLingui() - const autoSendWinner = Form.useWatch(['test_settings', 'auto_send_winner'], form) - - if (!autoSendWinner) return null - - return ( - - - - - - - - ({ - value: segment.id, - label: segment.name - }))} - optionRender={(option) => { - const segment = segments.find((s) => s.id === option.value) - if (!segment) return option.label - - return ( - - {segment.name} - {segment.users_count !== undefined && ( - - ({segment.users_count.toLocaleString()}) - - )} - - ) - }} - tagRender={(props) => { - const segment = segments.find((s) => s.id === props.value) - if (!segment) return {props.label} - - return ( - - {segment.name} - {segment.users_count !== undefined && ( - - ({segment.users_count.toLocaleString()}) - - )} - - ) - }} - /> - - - - - - - - -
-
- - - - - - - - - - - - ({ checked: mode === 'no_tracking' })} - getValueFromEvent={(e: { target: { checked: boolean } }) => - e.target.checked ? 'no_tracking' : 'inherit' - } - extra={ - <> - {t`No recipient gets the open pixel or tracked links; UTM parameters are kept. Otherwise each contact's tracking consent applies.`}{' '} - - {t`Learn more`} - - - } - > - { - // The server refuses an untracked broadcast with an automatic - // winner: the winner is chosen on opens or clicks. - if (e.target.checked) { - form.setFieldValue(['test_settings', 'auto_send_winner'], false) - } - }} - > - {t`Don't track opens and clicks for this broadcast`} - - -
-
- -
-
- { - setGlobalFeed(settings) - setFormTouched(true) - }} - globalFeedData={broadcast?.data_feed?.global_feed_data} - globalFeedFetchedAt={broadcast?.data_feed?.global_feed_fetched_at} - recipientFeed={recipientFeed} - onRecipientFeedChange={(settings) => { - setRecipientFeed(settings) - setFormTouched(true) - }} - /> -
-
- -
-
- {!workspaceTracks && ( - - )} - - - - - - { - return ( - prevValues.test_settings?.enabled !== currentValues.test_settings?.enabled - ) - }} - > - {({ getFieldValue }) => { - const testEnabled = getFieldValue(['test_settings', 'enabled']) - - if (testEnabled) { - return ( - <> - -
- - - - - - - - - } - > - - - - - - {broadcastUntracked && ( - - )} - {!broadcastUntracked && - workspaceTracks && - trackingPolicy.unknown_consent === 'no_track' && ( - - )} - - - - {/* Variations management will be added here */} -
- {t`Variations`} -
- - - {(fields, { add, remove }) => ( - <> - {fields.map((field) => ( -
- -
- ({ - validator(_, value) { - if (!value) return Promise.resolve() - - // Get all variations - const allVariations = - getFieldsValue()?.test_settings?.variations || - [] - - // Check if this template is used in any other variation - const duplicates = allVariations.filter( - (v: { template_id?: string }, i: number) => - v?.template_id === value && i !== field.name - ) - - if (duplicates.length > 0) { - return Promise.reject( - new Error( - t`This template is already used in another variation` - ) - ) - } - - return Promise.resolve() - } - }) - ]} - > - - - - - - - {fields.length > 1 && ( - - - remove(field.name)} - okText={t`Yes`} - cancelText={t`No`} - > - - )} -
- {t`Each variation keeps its template's sender unless you choose another one, which then becomes part of what is tested.`} -
- - )} - - - ) - } - - // If A/B testing is disabled, show single template config - return ( -
- - - - - - -
- ) - }} -
- - - - - - - )} - - ) -} diff --git a/console/src/components/broadcasts/access.ts b/console/src/components/broadcasts/access.ts new file mode 100644 index 000000000..8a25a6f66 --- /dev/null +++ b/console/src/components/broadcasts/access.ts @@ -0,0 +1,17 @@ +import { getBrowserTimezone } from '../../lib/timezoneNormalizer' +import type { UserPermissions, Workspace } from '../../services/api/workspace' + +/** Web analytics is used by the workspace and readable by the member (BroadcastAnalyticsLinks). */ +export const canReadWebAnalytics = (workspace?: Workspace | null, permissions?: UserPermissions | null) => + Boolean(workspace?.settings?.web_analytics?.enabled && permissions?.web_analytics?.read) + +/** The timezone analytics queries and dates of the broadcasts screens use. */ +export const workspaceTimezone = (workspace?: Workspace | null) => + workspace?.settings?.timezone || getBrowserTimezone() || 'UTC' + +/** The marketing provider sends through SMTP, which reports no delivery, bounce nor complaint. */ +export function isSmtpMarketingProvider(workspace?: Workspace | null): boolean { + const id = workspace?.settings?.marketing_email_provider_id + if (!id) return false + return workspace?.integrations?.find((i) => i.id === id)?.email_provider?.kind === 'smtp' +} diff --git a/console/src/components/broadcasts/attribution.test.ts b/console/src/components/broadcasts/attribution.test.ts new file mode 100644 index 000000000..5297763dd --- /dev/null +++ b/console/src/components/broadcasts/attribution.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import dayjs from '../../lib/dayjs' +import { broadcastVisits, kpiTraffic } from './attribution' +import type { CampaignDayRow } from './broadcastAnalytics' +import type { Broadcast } from '../../services/api/broadcast' + +const now = dayjs('2026-11-12T12:00:00Z') + +const bc = (over: Partial): Broadcast => + ({ id: 'b', status: 'processed', utm_parameters: { campaign: 'news' }, ...over }) as unknown as Broadcast + +const row = (campaign: string, day: string, sessions: number, conversions = 0, value = 0): CampaignDayRow => ({ + campaign, + day, + sessions, + conversions, + value +}) + +describe('broadcastVisits (list column)', () => { + const rows = [ + row('news', '2026-10-31', 50), + row('news', '2026-11-01', 10), + row('news', '2026-11-05', 5), + row('news', '2026-11-09', 7), + row('other', '2026-11-01', 99) + ] + + it('sums the sessions of its campaign inside its attribution window only', () => { + // Started 1 Nov, completed 1 Nov: the window runs 1 Nov to 8 Nov. + const b = bc({ started_at: '2026-11-01T09:00:00Z', completed_at: '2026-11-01T10:00:00Z' }) + expect(broadcastVisits(b, rows, 'UTC', now)).toBe(15) + }) + + it('is null without a campaign', () => { + const b = bc({ started_at: '2026-11-01T09:00:00Z', utm_parameters: { campaign: '' } }) + expect(broadcastVisits(b, rows, 'UTC', now)).toBeNull() + expect(broadcastVisits(bc({ started_at: '2026-11-01T09:00:00Z', utm_parameters: undefined }), rows, 'UTC', now)).toBeNull() + }) + + it('is null for a send older than 90 days, or one that has not started', () => { + expect(broadcastVisits(bc({ started_at: '2026-07-01T09:00:00Z' }), rows, 'UTC', now)).toBeNull() + expect(broadcastVisits(bc({ started_at: undefined }), rows, 'UTC', now)).toBeNull() + }) + + it('is 0 when its campaign had no visit', () => { + expect(broadcastVisits(bc({ started_at: '2026-11-01T09:00:00Z' }), [], 'UTC', now)).toBe(0) + }) +}) + +describe('kpiTraffic (Website traffic KPI)', () => { + const period = { startDay: '2026-10-13', endDay: '2026-11-11' } + + it('counts a day once for two broadcasts of one campaign', () => { + const broadcasts = [ + bc({ id: 'a', started_at: '2026-11-01T09:00:00Z', completed_at: '2026-11-01T10:00:00Z' }), + bc({ id: 'b', started_at: '2026-11-03T09:00:00Z', completed_at: '2026-11-03T10:00:00Z' }) + ] + const rows = [row('news', '2026-11-04', 10, 2, 30)] + expect(kpiTraffic(broadcasts, rows, period, 'UTC', now)).toEqual({ sessions: 10, conversions: 2, value: 30 }) + }) + + it('drops the days outside every window and outside the period', () => { + const broadcasts = [bc({ started_at: '2026-11-01T09:00:00Z', completed_at: '2026-11-01T10:00:00Z' })] + const rows = [ + row('news', '2026-10-30', 100), // before the window + row('news', '2026-11-02', 4), + row('news', '2026-11-12', 100), // after the period (today) + row('other', '2026-11-02', 100) // another campaign + ] + expect(kpiTraffic(broadcasts, rows, period, 'UTC', now).sessions).toBe(4) + }) + + it('counts nothing in the period for a broadcast started today', () => { + const broadcasts = [bc({ started_at: '2026-11-12T08:00:00Z' })] + expect(kpiTraffic(broadcasts, [row('news', '2026-11-12', 9)], period, 'UTC', now).sessions).toBe(0) + }) + + it('ignores broadcasts without a campaign', () => { + const broadcasts = [bc({ started_at: '2026-11-01T09:00:00Z', utm_parameters: { campaign: '' } })] + expect(kpiTraffic(broadcasts, [row('', '2026-11-02', 9)], period, 'UTC', now).sessions).toBe(0) + }) +}) diff --git a/console/src/components/broadcasts/attribution.ts b/console/src/components/broadcasts/attribution.ts new file mode 100644 index 000000000..0cd0b6bfc --- /dev/null +++ b/console/src/components/broadcasts/attribution.ts @@ -0,0 +1,74 @@ +import type { Dayjs } from 'dayjs' +import dayjs from '../../lib/dayjs' +import type { Broadcast } from '../../services/api/broadcast' +import { broadcastAttributionWindow, type AttributionWindow } from '../web_analytics/lib/broadcastLinks' +import { VISITS_LOOKBACK_DAYS, type CampaignDayRow } from './broadcastAnalytics' + +const DAY = 'YYYY-MM-DD' + +/** The campaign a broadcast's visits are matched on, or null. */ +export const broadcastCampaign = (broadcast: Broadcast): string | null => + broadcast.utm_parameters?.campaign?.trim() || null + +const inWindow = (day: string, window: AttributionWindow) => + day >= window.start.format(DAY) && day <= window.end.format(DAY) + +/** + * The sessions of a broadcast's campaign inside its attribution window (list column, L3). Null + * without a campaign, before it started, or for a send older than VISITS_LOOKBACK_DAYS. + */ +export function broadcastVisits( + broadcast: Broadcast, + rows: CampaignDayRow[], + timezone: string, + now: Dayjs = dayjs() +): number | null { + const campaign = broadcastCampaign(broadcast) + const window = broadcastAttributionWindow(broadcast, timezone, now) + if (!campaign || !window) return null + const floor = now.tz(timezone).startOf('day').subtract(VISITS_LOOKBACK_DAYS, 'day') + if (window.start.isBefore(floor)) return null + return rows + .filter((r) => r.campaign === campaign && inWindow(r.day, window)) + .reduce((sum, r) => sum + r.sessions, 0) +} + +export interface TrafficTotals { + sessions: number + conversions: number + value: number +} + +/** + * The Website traffic KPI (L6 and L7): for each campaign, a day of the period counts once when + * it falls in the window of at least one broadcast of that campaign. + */ +export function kpiTraffic( + broadcasts: Broadcast[], + rows: CampaignDayRow[], + period: { startDay: string; endDay: string }, + timezone: string, + now: Dayjs = dayjs() +): TrafficTotals { + const windows = new Map() + for (const broadcast of broadcasts) { + const campaign = broadcastCampaign(broadcast) + const window = broadcastAttributionWindow(broadcast, timezone, now) + if (!campaign || !window) continue + windows.set(campaign, [...(windows.get(campaign) ?? []), window]) + } + const totals: TrafficTotals = { sessions: 0, conversions: 0, value: 0 } + for (const r of rows) { + if (r.day < period.startDay || r.day > period.endDay) continue + if (!(windows.get(r.campaign) ?? []).some((w) => inWindow(r.day, w))) continue + totals.sessions += r.sessions + totals.conversions += r.conversions + totals.value += r.value + } + return totals +} + +/** The campaigns of a set of broadcasts, without empty ones. */ +export const campaignsOf = (broadcasts: Broadcast[]): string[] => [ + ...new Set(broadcasts.map(broadcastCampaign).filter((c): c is string => c !== null)) +] diff --git a/console/src/components/broadcasts/broadcastAnalytics.test.ts b/console/src/components/broadcasts/broadcastAnalytics.test.ts new file mode 100644 index 000000000..91fc4c19b --- /dev/null +++ b/console/src/components/broadcasts/broadcastAnalytics.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest' +import dayjs from '../../lib/dayjs' +import { + pageStatsQuery, + statsByBroadcast, + campaignVisitsQuery, + campaignDayRows, + periodTotalsQuery, + readPeriodTotals, + broadcastVisitsQuery, + readBroadcastVisits, + engagementQuery, + pageVisitsStart +} from './broadcastAnalytics' +import type { Broadcast } from '../../services/api/broadcast' + +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + +describe('pageStatsQuery (L2)', () => { + it('aggregates the rows of a page by broadcast, with no date range', () => { + expect(pageStatsQuery(['b2', 'b1'], 'Europe/Paris')).toEqual({ + schema: 'message_history', + measures: [ + 'count_sent', + 'count_failed', + 'count_opened', + 'count_opens_tracked', + 'count_clicked', + 'count_clicks_tracked', + 'count_unsubscribed', + 'count_bounced' + ], + dimensions: ['broadcast_id'], + timezone: 'Europe/Paris', + filters: [{ member: 'broadcast_id', operator: 'equals', values: ['b1', 'b2'] }] + }) + }) + + it('reads rows into numbers by broadcast', () => { + const map = statsByBroadcast([ + { broadcast_id: 'b1', count_sent: '100', count_failed: 2, count_opened: 40, count_opens_tracked: 80 }, + { broadcast_id: null, count_sent: 5 } + ]) + expect(map.get('b1')).toEqual({ + sent: 100, + failed: 2, + opened: 40, + opensTracked: 80, + clicked: 0, + clicksTracked: 0, + unsubscribed: 0, + bounced: 0 + }) + expect(map.size).toBe(1) + }) +}) + +describe('campaignVisitsQuery (L3, L7)', () => { + it('counts sessions by campaign and local day, between two instants', () => { + const query = campaignVisitsQuery({ + campaigns: ['b', 'a', 'a'], + start: dayjs.tz('2026-09-01', 'Europe/Paris'), + end: dayjs('2026-11-12T12:00:00Z'), + timezone: 'Europe/Paris' + }) + expect(query).toEqual({ + schema: 'web_sessions', + measures: ['sessions'], + dimensions: ['utm_campaign', 'year', 'month', 'day'], + timezone: 'Europe/Paris', + filters: [ + { member: 'utm_campaign', operator: 'equals', values: ['a', 'b'] }, + { member: 'created_at', operator: 'inDateRange', values: ['2026-08-31T22:00:00.000Z', '2026-11-12T12:00:00.000Z'] } + ] + }) + query.filters![1].values.forEach((bound) => expect(bound).toMatch(ISO_Z)) + }) + + it('adds conversions and value for the KPI', () => { + const query = campaignVisitsQuery({ + campaigns: ['a'], + start: dayjs('2026-10-01T00:00:00Z'), + end: dayjs('2026-10-30T23:59:59.999Z'), + timezone: 'UTC', + withGoals: true + }) + expect(query.measures).toEqual(['sessions', 'goal_conversions', 'goal_value']) + }) + + it('reads rows into campaign days', () => { + expect( + campaignDayRows([ + { utm_campaign: 'a', year: 2026, month: 3, day: 7, sessions: '4', goal_conversions: 1, goal_value: '9.5' }, + { utm_campaign: '', year: 2026, month: 3, day: 7, sessions: 1 } + ]) + ).toEqual([{ campaign: 'a', day: '2026-03-07', sessions: 4, conversions: 1, value: 9.5 }]) + }) +}) + +describe('periodTotalsQuery (L4, L5)', () => { + it('totals the broadcast emails of a period between UTC instants', () => { + const query = periodTotalsQuery( + { startDay: '2026-10-13', endDay: '2026-11-11', startUtc: '2026-10-12T22:00:00.000Z', endUtc: '2026-11-11T22:59:59.999Z' }, + 'Europe/Paris' + ) + expect(query).toEqual({ + schema: 'message_history', + measures: [ + 'count_sent', + 'count_opened', + 'count_opens_tracked', + 'count_clicked', + 'count_clicks_tracked', + 'count_unsubscribed', + 'count_bounced' + ], + dimensions: [], + timezone: 'Europe/Paris', + filters: [ + { member: 'origin', operator: 'equals', values: ['broadcast'] }, + { member: 'channel', operator: 'equals', values: ['email'] }, + { member: 'created_at', operator: 'inDateRange', values: ['2026-10-12T22:00:00.000Z', '2026-11-11T22:59:59.999Z'] } + ] + }) + }) + + it('reads the totals, zero without a row', () => { + expect(readPeriodTotals([{ count_sent: '10', count_bounced: 1 }])).toMatchObject({ sent: 10, bounced: 1, opened: 0 }) + expect(readPeriodTotals([]).sent).toBe(0) + }) +}) + +describe('broadcastVisitsQuery (F6)', () => { + it('reads the visits of one campaign over its attribution window, whole local days', () => { + const query = broadcastVisitsQuery({ + campaign: 'news_1', + window: { start: dayjs.tz('2026-11-01', 'Europe/Paris'), end: dayjs('2026-11-08T10:00:00Z') }, + timezone: 'Europe/Paris' + }) + expect(query).toEqual({ + schema: 'web_sessions', + measures: ['sessions', 'median_duration', 'goal_conversions', 'goal_value'], + dimensions: [], + timezone: 'Europe/Paris', + filters: [ + { member: 'utm_campaign', operator: 'equals', values: ['news_1'] }, + { member: 'created_at', operator: 'inDateRange', values: ['2026-10-31T23:00:00.000Z', '2026-11-08T22:59:59.999Z'] } + ] + }) + }) + + it('reads its single row', () => { + expect(readBroadcastVisits([{ sessions: 12, median_duration: '40', goal_conversions: 2, goal_value: 30 }])).toEqual({ + sessions: 12, + medianDuration: 40, + conversions: 2, + value: 30 + }) + }) +}) + +describe('engagementQuery (F7, F8)', () => { + it('buckets opens by hour without a date range, bounded by a filter', () => { + const query = engagementQuery({ + broadcastId: 'b1', + kind: 'opens', + granularity: 'hour', + start: dayjs('2026-11-10T08:00:00Z'), + end: dayjs('2026-11-12T08:00:00Z'), + timezone: 'America/New_York' + }) + expect(query).toEqual({ + schema: 'message_history', + measures: ['count_opened'], + dimensions: [], + timezone: 'America/New_York', + timeDimensions: [{ dimension: 'opened_at', granularity: 'hour' }], + filters: [ + { member: 'broadcast_id', operator: 'equals', values: ['b1'] }, + { member: 'opened_at', operator: 'inDateRange', values: ['2026-11-10T08:00:00.000Z', '2026-11-12T08:00:00.000Z'] } + ] + }) + expect(query.timeDimensions![0]).not.toHaveProperty('dateRange') + }) + + it('reads clicks on clicked_at', () => { + const query = engagementQuery({ + broadcastId: 'b1', + kind: 'clicks', + granularity: 'day', + start: dayjs('2026-11-01T00:00:00Z'), + end: dayjs('2026-11-12T00:00:00Z'), + timezone: 'UTC' + }) + expect(query.measures).toEqual(['count_clicked']) + expect(query.timeDimensions).toEqual([{ dimension: 'clicked_at', granularity: 'day' }]) + expect(query.filters![1].member).toBe('clicked_at') + }) +}) + +describe('pageVisitsStart', () => { + const now = dayjs('2026-11-12T12:00:00Z') + const b = (started_at?: string) => ({ id: 'x', started_at }) as unknown as Broadcast + + it('is the oldest start day of the page', () => { + expect(pageVisitsStart([b('2026-11-01T10:00:00Z'), b('2026-10-20T10:00:00Z'), b()], 'UTC', now)!.toISOString()).toBe( + '2026-10-20T00:00:00.000Z' + ) + }) + + it('is bounded at 90 days before today', () => { + expect(pageVisitsStart([b('2026-01-01T10:00:00Z')], 'UTC', now)!.toISOString()).toBe('2026-08-14T00:00:00.000Z') + }) + + it('is null without a started row', () => { + expect(pageVisitsStart([b()], 'UTC', now)).toBeNull() + }) +}) diff --git a/console/src/components/broadcasts/broadcastAnalytics.ts b/console/src/components/broadcasts/broadcastAnalytics.ts new file mode 100644 index 000000000..0e35e3ffa --- /dev/null +++ b/console/src/components/broadcasts/broadcastAnalytics.ts @@ -0,0 +1,252 @@ +import type { Dayjs } from 'dayjs' +import dayjs from '../../lib/dayjs' +import type { AnalyticsQuery } from '../../services/api/analytics' +import type { Broadcast } from '../../services/api/broadcast' +import type { ResolvedRange } from '../web_analytics/lib/types' +import { toNumber } from '../web_analytics/lib/format' +import type { AttributionWindow } from '../web_analytics/lib/broadcastLinks' + +type Row = Record + +/** Days a broadcast's visits are read back: older sends show "—". */ +export const VISITS_LOOKBACK_DAYS = 90 + +const iso = (d: Dayjs) => d.toISOString() + +// --------------------------------------------------------------------------------------------- +// L2: the stats of the rows of a page, by broadcast. No date bound: the broadcast_id filter uses +// the partial index on broadcast_id. + +const PAGE_STATS_MEASURES = [ + 'count_sent', + 'count_failed', + 'count_opened', + 'count_opens_tracked', + 'count_clicked', + 'count_clicks_tracked', + 'count_unsubscribed', + 'count_bounced' +] + +export function pageStatsQuery(broadcastIds: string[], timezone: string): AnalyticsQuery { + return { + schema: 'message_history', + measures: PAGE_STATS_MEASURES, + dimensions: ['broadcast_id'], + timezone, + filters: [{ member: 'broadcast_id', operator: 'equals', values: [...broadcastIds].sort() }] + } +} + +export interface EmailCounts { + sent: number + opened: number + opensTracked: number + clicked: number + clicksTracked: number + unsubscribed: number + bounced: number +} + +export interface BroadcastRowStats extends EmailCounts { + failed: number +} + +const readCounts = (row: Row | undefined): EmailCounts => ({ + sent: toNumber(row?.count_sent), + opened: toNumber(row?.count_opened), + opensTracked: toNumber(row?.count_opens_tracked), + clicked: toNumber(row?.count_clicked), + clicksTracked: toNumber(row?.count_clicks_tracked), + unsubscribed: toNumber(row?.count_unsubscribed), + bounced: toNumber(row?.count_bounced) +}) + +export function statsByBroadcast(rows: Row[]): Map { + const map = new Map() + for (const row of rows) { + if (typeof row.broadcast_id !== 'string' || !row.broadcast_id) continue + const counts = readCounts(row) + map.set(row.broadcast_id, { + sent: counts.sent, + failed: toNumber(row.count_failed), + opened: counts.opened, + opensTracked: counts.opensTracked, + clicked: counts.clicked, + clicksTracked: counts.clicksTracked, + unsubscribed: counts.unsubscribed, + bounced: counts.bounced + }) + } + return map +} + +// --------------------------------------------------------------------------------------------- +// L3 and L7: sessions by campaign and local day. No time dimension, hence no gap filler. + +export interface CampaignVisitsInput { + campaigns: string[] + start: Dayjs + end: Dayjs + timezone: string + /** Conversions and their value too (L7). */ + withGoals?: boolean +} + +export function campaignVisitsQuery({ campaigns, start, end, timezone, withGoals }: CampaignVisitsInput): AnalyticsQuery { + return { + schema: 'web_sessions', + measures: withGoals ? ['sessions', 'goal_conversions', 'goal_value'] : ['sessions'], + dimensions: ['utm_campaign', 'year', 'month', 'day'], + timezone, + filters: [ + { member: 'utm_campaign', operator: 'equals', values: [...new Set(campaigns)].sort() }, + { member: 'created_at', operator: 'inDateRange', values: [iso(start), iso(end)] } + ] + } +} + +export interface CampaignDayRow { + campaign: string + /** The local day, YYYY-MM-DD. */ + day: string + sessions: number + conversions: number + value: number +} + +const pad = (n: number) => String(n).padStart(2, '0') + +export function campaignDayRows(rows: Row[]): CampaignDayRow[] { + const result: CampaignDayRow[] = [] + for (const row of rows) { + if (typeof row.utm_campaign !== 'string' || !row.utm_campaign) continue + result.push({ + campaign: row.utm_campaign, + day: `${toNumber(row.year)}-${pad(toNumber(row.month))}-${pad(toNumber(row.day))}`, + sessions: toNumber(row.sessions), + conversions: toNumber(row.goal_conversions), + value: toNumber(row.goal_value) + }) + } + return result +} + +/** + * Where the visits of a page start: the oldest start day of its rows, bounded at + * VISITS_LOOKBACK_DAYS before today. Null when no row has started. + */ +export function pageVisitsStart(broadcasts: Broadcast[], timezone: string, now: Dayjs = dayjs()): Dayjs | null { + const starts = broadcasts.filter((b) => b.started_at).map((b) => dayjs(b.started_at).tz(timezone).startOf('day')) + if (starts.length === 0) return null + const oldest = starts.reduce((a, b) => (b.isBefore(a) ? b : a)) + const floor = now.tz(timezone).startOf('day').subtract(VISITS_LOOKBACK_DAYS, 'day') + return oldest.isBefore(floor) ? floor : oldest +} + +// --------------------------------------------------------------------------------------------- +// L4 and L5: the broadcast emails of a period, as the dashboard totals filtered on one origin. + +export function periodTotalsQuery(range: ResolvedRange, timezone: string): AnalyticsQuery { + return { + schema: 'message_history', + measures: PAGE_STATS_MEASURES.filter((m) => m !== 'count_failed'), + dimensions: [], + timezone, + filters: [ + { member: 'origin', operator: 'equals', values: ['broadcast'] }, + { member: 'channel', operator: 'equals', values: ['email'] }, + { member: 'created_at', operator: 'inDateRange', values: [range.startUtc, range.endUtc] } + ] + } +} + +export function readPeriodTotals(rows: Row[]): EmailCounts { + return readCounts(rows[0]) +} + +// --------------------------------------------------------------------------------------------- +// F6: the visits of one broadcast over its attribution window, in whole local days. + +export function broadcastVisitsQuery({ + campaign, + window, + timezone +}: { + campaign: string + window: AttributionWindow + timezone: string +}): AnalyticsQuery { + return { + schema: 'web_sessions', + measures: ['sessions', 'median_duration', 'goal_conversions', 'goal_value'], + dimensions: [], + timezone, + filters: [ + { member: 'utm_campaign', operator: 'equals', values: [campaign] }, + { + member: 'created_at', + operator: 'inDateRange', + values: [iso(window.start.tz(timezone).startOf('day')), iso(window.end.tz(timezone).endOf('day'))] + } + ] + } +} + +export interface BroadcastVisits { + sessions: number + medianDuration: number + conversions: number + value: number +} + +export function readBroadcastVisits(rows: Row[]): BroadcastVisits { + const row = rows[0] + return { + sessions: toNumber(row?.sessions), + medianDuration: toNumber(row?.median_duration), + conversions: toNumber(row?.goal_conversions), + value: toNumber(row?.goal_value) + } +} + +// --------------------------------------------------------------------------------------------- +// F7 and F8: opens or clicks over time. No dateRange, so no gap filler: fillBuckets completes +// the empty buckets. + +export type EngagementKind = 'opens' | 'clicks' +export type EngagementGranularity = 'hour' | 'day' + +export const ENGAGEMENT_COLUMNS: Record = { + opens: { measure: 'count_opened', dimension: 'opened_at' }, + clicks: { measure: 'count_clicked', dimension: 'clicked_at' } +} + +export function engagementQuery({ + broadcastId, + kind, + granularity, + start, + end, + timezone +}: { + broadcastId: string + kind: EngagementKind + granularity: EngagementGranularity + start: Dayjs + end: Dayjs + timezone: string +}): AnalyticsQuery { + const { measure, dimension } = ENGAGEMENT_COLUMNS[kind] + return { + schema: 'message_history', + measures: [measure], + dimensions: [], + timezone, + timeDimensions: [{ dimension, granularity }], + filters: [ + { member: 'broadcast_id', operator: 'equals', values: [broadcastId] }, + { member: dimension, operator: 'inDateRange', values: [iso(start), iso(end)] } + ] + } +} diff --git a/console/src/components/broadcasts/broadcastQueryClient.ts b/console/src/components/broadcasts/broadcastQueryClient.ts new file mode 100644 index 000000000..803cf8bee --- /dev/null +++ b/console/src/components/broadcasts/broadcastQueryClient.ts @@ -0,0 +1,14 @@ +import { AnalyticsService, type AnalyticsQuery } from '../../services/api/analytics' + +let client: AnalyticsService | undefined + +/** + * Runs an analytics query of the broadcasts screens. The shared singleton runs one query at a + * time and caches each answer 30 s by its JSON, so the 30 s refetch of a live row would read its + * own cached answer: this client runs four at once and caches nothing, TanStack Query being the + * only cache. Created on first use. + */ +export function runBroadcastQuery(query: AnalyticsQuery, workspaceId: string) { + client ??= AnalyticsService.create({ maxConcurrency: 4, cacheTTL: 0 }) + return client.query(query, workspaceId) +} diff --git a/console/src/components/broadcasts/broadcastStatus.test.ts b/console/src/components/broadcasts/broadcastStatus.test.ts new file mode 100644 index 000000000..a0c8e0b6a --- /dev/null +++ b/console/src/components/broadcasts/broadcastStatus.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from 'vitest' +import dayjs from '../../lib/dayjs' +import { + availableActions, + deliveryVerdict, + progressStatsFrom, + statusPill, + isFollowedRow, + isLiveRow, + getRemainingTestTime, + type ProgressStats +} from './broadcastStatus' +import type { Broadcast } from '../../services/api/broadcast' +import type { BroadcastStatsResult } from '../../services/api/messages_history' + +const broadcast = (over: Partial): Broadcast => + ({ + id: 'bc-1', + name: 'Newsletter', + status: 'draft', + test_settings: { enabled: false, sample_percentage: 50, auto_send_winner: false, variations: [] }, + ...over + }) as unknown as Broadcast + +const stats = (over: Partial): ProgressStats => ({ + remaining: 0, + processed: 68, + enqueuedCount: 68, + sentCount: 45, + failedCount: 23, + ...over +}) + +const statsResult = (over: Partial = {}): BroadcastStatsResult => + ({ + broadcast_id: 'bc-1', + stats: { + total_sent: 45, + total_delivered: 40, + total_opened: 10, + total_clicked: 2, + total_failed: 23, + total_bounced: 0, + total_complained: 0, + total_unsubscribed: 0 + }, + ...over + }) as BroadcastStatsResult + +// A broadcast is finished when its queue is empty, not when the arithmetic on message +// history happens to reach zero: sent_at is stamped on the first attempt whatever its +// outcome, so a refused recipient counted as both sent and failed. +describe('deliveryVerdict', () => { + it('is still sending while anything is in flight', () => { + expect(deliveryVerdict(stats({ inFlight: 12, failedTerminal: 0 }))).toEqual({ kind: 'sending', remaining: 12 }) + }) + + it('is complete only when the queue is empty and nothing was given up on', () => { + expect(deliveryVerdict(stats({ inFlight: 0, failedTerminal: 0, failedCount: 0 }))).toEqual({ kind: 'complete' }) + }) + + it('names the recipients it gave up on', () => { + expect(deliveryVerdict(stats({ inFlight: 0, failedTerminal: 23 }))).toEqual({ + kind: 'completedWithFailures', + failed: 23 + }) + }) + + it('counts a row still retrying as in flight, not as given up on', () => { + expect(deliveryVerdict(stats({ inFlight: 2, failedTerminal: 0, failedCount: 2 }))).toEqual({ + kind: 'sending', + remaining: 2 + }) + }) + + it('falls back to the message-history arithmetic when the queue could not be read', () => { + expect(deliveryVerdict(stats({ inFlight: undefined, failedTerminal: undefined, remaining: 5 }))).toEqual({ + kind: 'sending', + remaining: 5 + }) + expect(deliveryVerdict(undefined)).toEqual({ kind: 'complete' }) + }) + + it('still names the failures once the abandoned rows have been swept', () => { + // Terminal queue rows are deleted after their retention window; the count comes from + // message history so the campaign is not repainted green a week later. + expect(deliveryVerdict(stats({ inFlight: 0, failedTerminal: 0, failedCount: 23 }))).toEqual({ + kind: 'completedWithFailures', + failed: 23 + }) + }) +}) + +describe('progressStatsFrom', () => { + it('maps the queue to in-flight and given-up rows', () => { + const result = progressStatsFrom( + statsResult({ queue: { pending: 1, processing: 2, paused: 1, failed_retrying: 2, failed_terminal: 23 } }), + 100 + ) + expect(result).toEqual({ + remaining: 32, + processed: 68, + enqueuedCount: 100, + sentCount: 45, + failedCount: 23, + inFlight: 6, + failedTerminal: 23 + }) + }) + + it('leaves both undefined when the queue is unreadable', () => { + const result = progressStatsFrom(statsResult(), 68) + expect(result.inFlight).toBeUndefined() + expect(result.failedTerminal).toBeUndefined() + expect(result.failedCount).toBe(23) + }) + + it('reports no remainder without an enqueued count', () => { + const result = progressStatsFrom( + statsResult({ queue: { pending: 3, processing: 0, paused: 0, failed_retrying: 0, failed_terminal: 0 } }) + ) + expect(result.remaining).toBe(0) + expect(result.enqueuedCount).toBe(0) + expect(result.inFlight).toBe(3) + }) +}) + +describe('statusPill', () => { + it.each([ + ['draft', 'neutral', false, 'draft'], + ['scheduled', 'info', false, 'scheduled'], + ['testing', 'primary', true, 'testing'], + ['test_completed', 'info', false, 'pickWinner'], + ['processing', 'primary', true, 'preparing'], + ['winner_selected', 'primary', true, 'winnerSending'], + ['paused', 'warning', false, 'paused'], + ['cancelled', 'neutral', false, 'cancelled'], + ['failed', 'danger', false, 'failed'] + ] as const)('%s is a %s pill', (status, tone, pulse, kind) => { + expect(statusPill(broadcast({ status }))).toMatchObject({ tone, pulse, kind }) + }) + + it('follows the verdict for a followed processed row', () => { + const b = broadcast({ status: 'processed' }) + expect(statusPill(b, { verdict: { kind: 'sending', remaining: 12 } })).toEqual({ + tone: 'primary', + pulse: true, + kind: 'sending', + count: 12 + }) + expect(statusPill(b, { verdict: { kind: 'complete' } })).toMatchObject({ tone: 'success', kind: 'sent' }) + expect(statusPill(b, { verdict: { kind: 'completedWithFailures', failed: 4 } })).toEqual({ + tone: 'warning', + pulse: false, + kind: 'someFailed', + count: 4 + }) + }) + + it('reads the failures of another processed row from the 30-day stats', () => { + const b = broadcast({ status: 'processed' }) + expect(statusPill(b, { l2Failed: 7 })).toEqual({ tone: 'warning', pulse: false, kind: 'someFailed', count: 7 }) + expect(statusPill(b, { l2Failed: 0 })).toMatchObject({ tone: 'success', kind: 'sent' }) + expect(statusPill(b)).toMatchObject({ tone: 'success', kind: 'sent' }) + }) + + it('uses the fallback rule for a followed row whose queue is not loaded yet', () => { + // No verdict yet: the rule of the other rows applies. + expect(statusPill(broadcast({ status: 'processed' }), { l2Failed: 2 })).toMatchObject({ + kind: 'someFailed', + count: 2 + }) + }) +}) + +describe('isFollowedRow and isLiveRow', () => { + const now = dayjs('2026-11-12T12:00:00Z') + + it.each(['testing', 'test_completed', 'processing', 'winner_selected', 'paused', 'failed'] as const)( + 'follows a %s row', + (status) => { + expect(isFollowedRow(broadcast({ status }), now)).toBe(true) + } + ) + + it.each(['draft', 'scheduled', 'cancelled'] as const)('does not follow a %s row', (status) => { + expect(isFollowedRow(broadcast({ status }), now)).toBe(false) + }) + + it('follows a processed row for three days after it completed', () => { + expect( + isFollowedRow(broadcast({ status: 'processed', completed_at: '2026-11-10T12:00:00Z' }), now) + ).toBe(true) + expect( + isFollowedRow(broadcast({ status: 'processed', completed_at: '2026-11-08T12:00:00Z' }), now) + ).toBe(false) + }) + + it('follows a processed row without completed_at, which may still be draining', () => { + expect(isFollowedRow(broadcast({ status: 'processed' }), now)).toBe(true) + }) + + it('calls a row live while it is sending', () => { + expect(isLiveRow(broadcast({ status: 'testing' }))).toBe(true) + expect(isLiveRow(broadcast({ status: 'processing' }))).toBe(true) + expect(isLiveRow(broadcast({ status: 'winner_selected' }))).toBe(true) + expect(isLiveRow(broadcast({ status: 'processed' }), { kind: 'sending', remaining: 3 })).toBe(true) + expect(isLiveRow(broadcast({ status: 'processed' }), { kind: 'complete' })).toBe(false) + expect(isLiveRow(broadcast({ status: 'paused' }))).toBe(false) + }) +}) + +describe('getRemainingTestTime', () => { + const now = dayjs('2026-11-12T12:00:00Z') + const testing = (over: Partial = {}) => + broadcast({ + status: 'testing', + test_sent_at: '2026-11-12T10:00:00Z', + test_settings: { + enabled: true, + sample_percentage: 20, + auto_send_winner: true, + test_duration_hours: 4, + variations: [] + }, + ...over + }) + + it('returns the duration alone, with no sentence around it', () => { + expect(getRemainingTestTime(testing(), undefined, now)).toBe('2 hours') + }) + + it('prefers the start time of the test results', () => { + expect(getRemainingTestTime(testing(), { test_started_at: '2026-11-12T11:00:00Z' }, now)).toBe('3 hours') + }) + + it('returns null once the test has ended, or outside a test', () => { + expect(getRemainingTestTime(testing({ test_sent_at: '2026-11-12T06:00:00Z' }), undefined, now)).toBeNull() + expect(getRemainingTestTime(testing({ status: 'processing' }), undefined, now)).toBeNull() + expect(getRemainingTestTime(testing({ test_sent_at: undefined }), undefined, now)).toBeNull() + }) +}) + +describe('availableActions', () => { + const sending = { kind: 'sending', remaining: 3 } as const + const failed = { kind: 'completedWithFailures', failed: 2 } as const + const progress = { remaining: 0, processed: 10, enqueuedCount: 10, sentCount: 8, failedCount: 2, inFlight: 0, failedTerminal: 2 } + const on = (set: ReturnType) => Object.entries(set).filter(([, v]) => v).map(([k]) => k) + + it('offers edit and delete on a draft', () => { + expect(on(availableActions({ status: 'draft' }))).toEqual(['edit', 'delete']) + }) + it('offers edit and cancel on a scheduled broadcast', () => { + expect(on(availableActions({ status: 'scheduled' }))).toEqual(['edit', 'cancel']) + }) + it('offers pause and cancel while sending, whether processing or draining', () => { + expect(on(availableActions({ status: 'processing' }))).toEqual(['pause', 'cancel']) + expect(on(availableActions({ status: 'processed' }, sending))).toEqual(['pause', 'cancel']) + }) + it('offers resume and cancel when paused', () => { + expect(on(availableActions({ status: 'paused' }))).toEqual(['resume', 'cancel']) + }) + it('offers retry only for recipients given up on', () => { + expect(on(availableActions({ status: 'processed' }, failed, progress))).toEqual(['retryFailed']) + expect(on(availableActions({ status: 'failed' }, failed, progress))).toEqual(['retryFailed']) + expect(on(availableActions({ status: 'processed' }, failed, { ...progress, failedTerminal: 0 }))).toEqual([]) + expect(on(availableActions({ status: 'processed' }, { kind: 'complete' }, progress))).toEqual([]) + }) +}) diff --git a/console/src/components/broadcasts/broadcastStatus.ts b/console/src/components/broadcasts/broadcastStatus.ts new file mode 100644 index 000000000..7f8873763 --- /dev/null +++ b/console/src/components/broadcasts/broadcastStatus.ts @@ -0,0 +1,203 @@ +import type { Dayjs } from 'dayjs' +import dayjs from '../../lib/dayjs' +import type { Broadcast, BroadcastStatus, TestResultsResponse } from '../../services/api/broadcast' +import type { BroadcastStatsResult } from '../../services/api/messages_history' +import type { PillTone } from '../layout' + +export interface ProgressStats { + remaining: number + processed: number + enqueuedCount: number + sentCount: number + failedCount: number + /** + * Rows still on their way out: pending, processing, paused, or failed with attempts + * left. Undefined when the queue could not be read — callers fall back to the + * message-history arithmetic rather than claiming the campaign is done. + */ + inFlight?: number + /** Rows that spent every attempt. These recipients were given up on. */ + failedTerminal?: number +} + +/** The progress of a broadcast from `messages.broadcastStats` and the task's enqueued count. */ +export function progressStatsFrom(result: BroadcastStatsResult, enqueuedCount?: number): ProgressStats { + const { stats, queue } = result + const processed = stats.total_sent + stats.total_failed + return { + remaining: enqueuedCount ? Math.max(0, enqueuedCount - processed) : 0, + processed, + enqueuedCount: enqueuedCount ?? 0, + sentCount: stats.total_sent, + failedCount: stats.total_failed, + inFlight: queue && queue.pending + queue.processing + queue.paused + queue.failed_retrying, + failedTerminal: queue?.failed_terminal + } +} + +// Whether a campaign has finished, and whether it reached everyone, is a question for the +// queue. Message history cannot answer it: sent_at is stamped on the first attempt whatever +// its outcome. The counts are undefined when the queue could not be read: that is +// "unknown", not "finished", so the old arithmetic stands in. +export type DeliveryVerdict = + | { kind: 'sending'; remaining: number } + | { kind: 'complete' } + | { kind: 'completedWithFailures'; failed: number } + +export function deliveryVerdict(progressStats?: ProgressStats): DeliveryVerdict { + if (progressStats?.inFlight === undefined) { + const remaining = progressStats?.remaining ?? 0 + return remaining > 0 ? { kind: 'sending', remaining } : { kind: 'complete' } + } + if (progressStats.inFlight > 0) return { kind: 'sending', remaining: progressStats.inFlight } + // How many were given up on comes from message history, not from the queue: abandoned + // rows are swept after their retention window, and reading the verdict from them alone + // would repaint a campaign green a week later. + if (progressStats.failedCount > 0) return { kind: 'completedWithFailures', failed: progressStats.failedCount } + return { kind: 'complete' } +} + +/** Which label and tooltip the status pill shows; `BroadcastStatusPill` words them. */ +export type StatusPillKind = + | 'draft' + | 'scheduled' + | 'testing' + | 'pickWinner' + | 'preparing' + | 'winnerSending' + | 'paused' + | 'sending' + | 'sent' + | 'someFailed' + | 'cancelled' + | 'failed' + | 'unknown' + +export interface StatusPillSpec { + tone: PillTone + pulse: boolean + kind: StatusPillKind + /** Recipients still to go (`sending`) or given up on (`someFailed`). */ + count?: number +} + +export interface StatusPillContext { + /** The verdict of a followed row whose queue is loaded. */ + verdict?: DeliveryVerdict + /** `count_failed` of the row in the 30-day stats (L2), for rows without a verdict. */ + l2Failed?: number +} + +const pill = (tone: PillTone, kind: StatusPillKind, pulse = false, count?: number): StatusPillSpec => + count === undefined ? { tone, pulse, kind } : { tone, pulse, kind, count } + +export function statusPill(broadcast: Broadcast, { verdict, l2Failed }: StatusPillContext = {}): StatusPillSpec { + switch (broadcast.status) { + case 'draft': + return pill('neutral', 'draft') + case 'scheduled': + return pill('info', 'scheduled') + case 'testing': + return pill('primary', 'testing', true) + case 'test_completed': + return pill('info', 'pickWinner') + case 'processing': + return pill('primary', 'preparing', true) + case 'winner_selected': + return pill('primary', 'winnerSending', true) + case 'paused': + return pill('warning', 'paused') + case 'processed': + if (verdict) { + if (verdict.kind === 'sending') return pill('primary', 'sending', true, verdict.remaining) + if (verdict.kind === 'completedWithFailures') return pill('warning', 'someFailed', false, verdict.failed) + return pill('success', 'sent') + } + return l2Failed ? pill('warning', 'someFailed', false, l2Failed) : pill('success', 'sent') + case 'cancelled': + return pill('neutral', 'cancelled') + case 'failed': + return pill('danger', 'failed') + default: + return pill('neutral', 'unknown') + } +} + +const FOLLOWED_STATUSES: ReadonlySet = new Set([ + 'testing', + 'test_completed', + 'processing', + 'winner_selected', + 'paused', + 'failed' +]) + +const FOLLOW_PROCESSED_DAYS = 3 + +/** + * A row whose queue and task the list reads: in progress, or processed less than three days + * ago (a later retry is unlikely). A processed row without `completed_at` may still be draining. + */ +export function isFollowedRow(broadcast: Broadcast, now: Dayjs = dayjs()): boolean { + if (FOLLOWED_STATUSES.has(broadcast.status)) return true + if (broadcast.status !== 'processed') return false + if (!broadcast.completed_at) return true + return now.diff(dayjs(broadcast.completed_at), 'day', true) < FOLLOW_PROCESSED_DAYS +} + +/** A row whose numbers move by themselves: the list and its stats refetch while one is loaded. */ +export function isLiveRow(broadcast: Broadcast, verdict?: DeliveryVerdict): boolean { + if (broadcast.status === 'testing' || broadcast.status === 'processing' || broadcast.status === 'winner_selected') { + return true + } + return broadcast.status === 'processed' && verdict?.kind === 'sending' +} + +/** + * The time left in an A/B test, as a duration alone ("2 hours"): the sentence around it is + * a translated message of the caller. Null outside a running test or once it has ended. + */ +export function getRemainingTestTime( + broadcast: Broadcast, + testResults?: Pick, + now: Dayjs = dayjs() +): string | null { + const hours = broadcast.test_settings?.test_duration_hours + if (broadcast.status !== 'testing' || !broadcast.test_settings?.enabled || !hours) return null + const start = testResults?.test_started_at || broadcast.test_sent_at + if (!start) return null + const end = dayjs(start).add(hours, 'hour') + if (now.isAfter(end)) return null + return now.to(end, true) +} + +/** The lifecycle actions a broadcast offers, by status and delivery verdict (list and detail). */ +export interface BroadcastActionSet { + edit: boolean + pause: boolean + resume: boolean + cancel: boolean + /** Recipients given up on can be queued again. */ + retryFailed: boolean + delete: boolean +} + +export function availableActions( + broadcast: Pick, + verdict?: DeliveryVerdict, + progress?: ProgressStats +): BroadcastActionSet { + const { status } = broadcast + const sendingNow = status === 'processing' || (status === 'processed' && verdict?.kind === 'sending') + return { + edit: status === 'draft' || status === 'scheduled', + pause: sendingNow, + resume: status === 'paused', + cancel: sendingNow || status === 'scheduled' || status === 'paused', + retryFailed: + (status === 'processed' || status === 'failed') && + verdict?.kind === 'completedWithFailures' && + (progress?.failedTerminal ?? 0) > 0, + delete: status === 'draft' + } +} diff --git a/console/src/components/broadcasts/compose/AdvancedRow.tsx b/console/src/components/broadcasts/compose/AdvancedRow.tsx new file mode 100644 index 000000000..4c1dc36b3 --- /dev/null +++ b/console/src/components/broadcasts/compose/AdvancedRow.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { useLingui } from '@lingui/react/macro' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { Panel } from '../../layout' +import type { DataFeedSettings, GlobalFeedSettings as GlobalFeed, RecipientFeedSettings as RecipientFeed } from '../../../services/api/broadcast' +import { GlobalFeedSettings } from '../GlobalFeedSettings' +import { RecipientFeedSettings } from '../RecipientFeedSettings' +import type { ComposeValues } from './composeValues' +import type { ComposeUpdate } from './types' + +export interface AdvancedRowProps { + workspaceId: string + /** Absent until the broadcast exists: the feed tests need it. */ + broadcastId?: string + values: ComposeValues + onChange: ComposeUpdate + /** What the global feed last returned, stored on the broadcast. */ + stored?: DataFeedSettings +} + +/** "Off", or the host of the feed's URL. */ +export function feedState(feed: GlobalFeed | RecipientFeed, off: string): string { + if (!feed.enabled) return off + try { + return new URL(feed.url ?? '').host || feed.url || off + } catch { + return feed.url || off + } +} + +/** The two data feeds, folded until opened. */ +export function AdvancedRow({ workspaceId, broadcastId, values, onChange, stored }: AdvancedRowProps) { + const { t } = useLingui() + const [open, setOpen] = useState<'global' | 'recipient' | null>(null) + const row = (key: 'global' | 'recipient', label: string, state: string) => { + const expanded = open === key + return ( + + ) + } + const off = t`Off` + return ( + +
+
+ {row('global', t`Global data feed`, feedState(values.globalFeed, off))} + {open === 'global' ? ( +
+ onChange((v) => ({ ...v, globalFeed }))} + globalFeedData={stored?.global_feed_data} + globalFeedFetchedAt={stored?.global_feed_fetched_at} + /> +
+ ) : null} +
+
+ {row('recipient', t`Per-recipient data feed`, feedState(values.recipientFeed, off))} + {open === 'recipient' ? ( +
+ onChange((v) => ({ ...v, recipientFeed }))} + /> +
+ ) : null} +
+
+
+ ) +} diff --git a/console/src/components/broadcasts/compose/AudienceCard.tsx b/console/src/components/broadcasts/compose/AudienceCard.tsx new file mode 100644 index 000000000..5bbf90562 --- /dev/null +++ b/console/src/components/broadcasts/compose/AudienceCard.tsx @@ -0,0 +1,111 @@ +import { Form, Select, Switch, Tag } from 'antd' +import { useLingui } from '@lingui/react/macro' +import { plural } from '@lingui/core/macro' +import { Users } from 'lucide-react' +import { Panel, useNumberFormat } from '../../layout' +import type { List } from '../../../services/api/list' +import type { Segment } from '../../../services/api/segment' +import type { ComposeEstimate } from '../useAudienceEstimates' +import type { ComposeValues } from './composeValues' +import type { ComposeUpdate } from './types' + +export interface AudienceCardProps { + values: ComposeValues + onChange: ComposeUpdate + lists: Pick[] + segments: Pick[] + /** Without lists.read (or segments.read), the stored value is shown and cannot change. */ + canReadLists: boolean + canReadSegments: boolean + estimate: ComposeEstimate +} + +/** List, segments and unsubscribed contacts, with the recipients they make. */ +export function AudienceCard({ values, onChange, lists, segments, canReadLists, canReadSegments, estimate }: AudienceCardProps) { + const { t } = useLingui() + const { int } = useNumberFormat() + + const listOptions = canReadLists + ? lists.map((list) => { + const active = list.active_count + return { + value: list.id, + label: active === undefined ? list.name : `${list.name} · ${t`${plural(active, { one: '# active', other: '# active' })}`}` + } + }) + : values.list + ? [{ value: values.list, label: values.list }] + : [] + + const segmentLabel = (id: string) => { + const segment = segments.find((s) => s.id === id) + if (!segment) return id + return segment.users_count === undefined ? segment.name : `${segment.name} (${int(segment.users_count)})` + } + + const count = estimate.count + const recipients = + !estimate.available || estimate.error + ? '—' + : estimate.counting || count === undefined + ? t`Counting…` + : `≈ ${t`${plural(count, { one: '# recipient', other: '# recipients' })}`}` + + return ( + } + extra={{recipients}} + > +
+ + s.id) : values.segments).map((id) => ({ + value: id, + label: segmentLabel(id) + }))} + showSearch={{ optionFilterProp: 'label' }} + tagRender={(props) => { + const segment = segments.find((s) => s.id === props.value) + return ( + + {props.label} + + ) + }} + onChange={(chosen: string[]) => onChange((v) => ({ ...v, segments: chosen }))} + /> + + + onChange((v) => ({ ...v, excludeUnsubscribed }))} + /> + + +
+ ) +} diff --git a/console/src/components/broadcasts/compose/BroadcastCompose.test.tsx b/console/src/components/broadcasts/compose/BroadcastCompose.test.tsx new file mode 100644 index 000000000..f6c46c5c4 --- /dev/null +++ b/console/src/components/broadcasts/compose/BroadcastCompose.test.tsx @@ -0,0 +1,322 @@ +import '../../../__tests__/resizeObserverMock' +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { App as AntApp } from 'antd' +import { BroadcastCompose, type BroadcastComposeProps } from './BroadcastCompose' +import { broadcastApi, type Broadcast } from '../../../services/api/broadcast' +import { templatesApi } from '../../../services/api/template' +import { listsApi } from '../../../services/api/list' +import { listSegments } from '../../../services/api/segment' +import { ApiError } from '../../../services/api/errors' +import type { Workspace } from '../../../services/api/types' +import type { UserPermissions } from '../../../services/api/workspace' + +vi.mock('../../../services/api/client', async () => ({ + ...(await vi.importActual('../../../services/api/errors')), + api: { get: vi.fn(), post: vi.fn() } +})) +vi.mock('../../../services/api/broadcast', async (importOriginal) => ({ + ...(await importOriginal()), + broadcastApi: { get: vi.fn(), create: vi.fn(), update: vi.fn(), schedule: vi.fn(), estimateAudience: vi.fn() } +})) +vi.mock('../../../services/api/template', () => ({ templatesApi: { get: vi.fn() } })) +vi.mock('../../../services/api/list', () => ({ listsApi: { list: vi.fn() } })) +vi.mock('../../../services/api/segment', () => ({ listSegments: vi.fn() })) +vi.mock('../detail/TemplatePreviewFrame', () => ({ + TemplatePreviewFrame: () =>
, + useTemplatePreview: () => ({ data: undefined }) +})) +vi.mock('../../templates/TemplateSelectorInput', () => ({ default: () =>
})) +vi.mock('../VariationSenderSelect', () => ({ VariationSenderSelect: () =>
})) +vi.mock('../../templates/SendTemplateModal', () => ({ + default: ({ isOpen, defaultListId }: { isOpen: boolean; defaultListId?: string }) => (isOpen ?
{defaultListId}
: null) +})) +let shouldBlockFn: (args: { current: { pathname: string; search: object }; next: { pathname: string; search: object } }) => boolean | Promise +vi.mock('@tanstack/react-router', () => ({ + useBlocker: (opts: { shouldBlockFn: typeof shouldBlockFn }) => { + shouldBlockFn = opts.shouldBlockFn + return { status: 'idle' } + } +})) + +const workspace = { + id: 'ws1', + name: 'Acme', + settings: { + timezone: 'UTC', + marketing_email_provider_id: 'int1', + email_tracking: { opens_enabled: true, clicks_enabled: true, unknown_consent: 'track' } + }, + integrations: [{ id: 'int1', email_provider: { senders: [{ id: 's1', name: 'Acme', email: 'a@acme.test', is_default: true }] } }] +} as unknown as Workspace + +const permissions = { + broadcasts: { read: true, write: true }, + lists: { read: true, write: false }, + segments: { read: true, write: false }, + templates: { read: true, write: false }, + transactional: { read: true, write: true }, + contacts: { read: true, write: false } +} as unknown as UserPermissions + +const stored = (over: Partial = {}): Broadcast => + ({ + id: 'bc1', + workspace_id: 'ws1', + name: 'News', + status: 'draft', + audience: { list: 'l1', segments: [], exclude_unsubscribed: true }, + schedule: { is_scheduled: false, use_recipient_timezone: false }, + test_settings: { enabled: false, sample_percentage: 50, auto_send_winner: false, variations: [{ variation_name: 'A', template_id: 'tpl-a' }] }, + utm_parameters: { source: 'acme.test', medium: 'email', campaign: 'news' }, + tracking_mode: 'inherit', + data_feed: { global_feed: { enabled: false, url: '', headers: [] }, recipient_feed: { enabled: false, url: '', headers: [] } }, + created_at: '', + updated_at: '', + test_phase_recipient_count: 0, + winner_phase_recipient_count: 0, + ...over + }) as Broadcast + +// The drawer is a dialog itself: a confirmation is found by its title. +const confirmation = async (title: string | RegExp) => { + // antd writes a confirmation's title twice: in a hidden modal title and in the confirm title. + const found = await screen.findAllByText(title) + return found[0].closest('.ant-modal-container, .ant-modal-content') as HTMLElement +} + +const renderCompose = (props: Partial = {}, client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) => { + const handlers = { onClose: vi.fn(), onCreated: vi.fn(), onOpenDetail: vi.fn() } + render( + + + + + + ) + return { ...handlers, client } +} + +describe('BroadcastCompose', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(broadcastApi.get as Mock).mockResolvedValue({ broadcast: stored() }) + ;(broadcastApi.estimateAudience as Mock).mockResolvedValue({ count: 1200 }) + ;(broadcastApi.schedule as Mock).mockResolvedValue({ success: true }) + ;(broadcastApi.update as Mock).mockImplementation(async (p: Broadcast) => ({ broadcast: stored({ ...p, status: 'scheduled' } as Partial) })) + ;(templatesApi.get as Mock).mockResolvedValue({ + template: { id: 'tpl-a', name: 'Alpha', email: { subject: 'Hello', editor_mode: 'code', mjml_source: '{{ unsubscribe_url }}' } } + }) + ;(listsApi.list as Mock).mockResolvedValue({ lists: [{ id: 'l1', name: 'Newsletter', active_count: 1500 }] }) + ;(listSegments as Mock).mockResolvedValue({ segments: [] }) + }) + + it('opens a new broadcast named after today, and creates nothing while it is incomplete', async () => { + renderCompose({ composeId: 'new' }) + const name = (await screen.findByRole('textbox', { name: 'Broadcast name' })) as HTMLInputElement + expect(name.value).toMatch(/^Broadcast /) + expect(await screen.findByText('Incomplete: choose a list')).toBeInTheDocument() + expect(broadcastApi.create).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled() + }) + + it('closes a new broadcast left untouched without asking or creating it', async () => { + const { onClose } = renderCompose({ composeId: 'new' }) + await userEvent.click(await screen.findByRole('button', { name: /Broadcasts/ })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(screen.queryByText('Close without saving?')).not.toBeInTheDocument() + expect(broadcastApi.create).not.toHaveBeenCalled() + }) + + it('asks before closing on an incomplete change, with its reason', async () => { + const { onClose } = renderCompose({ composeId: 'new' }) + const name = await screen.findByRole('textbox', { name: 'Broadcast name' }) + await userEvent.type(name, ' x') + await userEvent.click(screen.getByRole('button', { name: /Broadcasts/ })) + const dialog = await confirmation('Close without saving?') + expect(within(dialog).getByText('The last changes are not saved: choose a list')).toBeInTheDocument() + expect(onClose).not.toHaveBeenCalled() + }) + + it('opens the detail instead for a broadcast that is no longer a draft or scheduled', async () => { + ;(broadcastApi.get as Mock).mockResolvedValue({ broadcast: stored({ status: 'processed' }) }) + const { onOpenDetail } = renderCompose() + await waitFor(() => expect(onOpenDetail).toHaveBeenCalledWith('bc1')) + }) + + it('opens the detail, or closes, for a member who may not write', async () => { + const readOnly = { ...permissions, broadcasts: { read: true, write: false } } as unknown as UserPermissions + const first = renderCompose({ permissions: readOnly }) + await waitFor(() => expect(first.onOpenDetail).toHaveBeenCalledWith('bc1')) + const second = renderCompose({ permissions: readOnly, composeId: 'new' }) + await waitFor(() => expect(second.onClose).toHaveBeenCalled()) + }) + + it('waits for the permissions before gating a link to the composer', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const handlers = { onClose: vi.fn(), onCreated: vi.fn(), onOpenDetail: vi.fn() } + const tree = (perms: UserPermissions | null, composeId: string) => ( + + + + + + ) + const { rerender } = render(tree(null, 'bc1')) + await waitFor(() => expect(broadcastApi.get).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(handlers.onOpenDetail).not.toHaveBeenCalled() + expect(screen.queryByRole('textbox', { name: 'Broadcast name' })).toBeNull() + + rerender(tree(permissions as unknown as UserPermissions, 'bc1')) + expect(await screen.findByRole('textbox', { name: 'Broadcast name' })).toBeInTheDocument() + expect(handlers.onOpenDetail).not.toHaveBeenCalled() + expect(handlers.onClose).not.toHaveBeenCalled() + }) + + it('says Broadcast not found for an unknown id', async () => { + ;(broadcastApi.get as Mock).mockRejectedValue(new ApiError('not found', 404)) + renderCompose() + expect(await screen.findByText('Broadcast not found')).toBeInTheDocument() + }) + + it('sends a complete draft now after confirming, then opens its detail', async () => { + const { onOpenDetail } = renderCompose() + const send = await screen.findByRole('button', { name: /Send now/ }) + await waitFor(() => expect(send).toBeEnabled()) + await userEvent.click(send) + const dialog = await confirmation('Send to ≈ 1200 recipients now?') + await userEvent.click(within(dialog).getByRole('button', { name: 'Send now' })) + await waitFor(() => expect(broadcastApi.schedule).toHaveBeenCalledWith({ workspace_id: 'ws1', id: 'bc1', send_now: true })) + expect(onOpenDetail).toHaveBeenCalledWith('bc1') + }) + + it('schedules a draft at its date, time and timezone, without asking', async () => { + ;(broadcastApi.get as Mock).mockResolvedValue({ + broadcast: stored({ schedule: { is_scheduled: false, scheduled_date: '2030-01-02', scheduled_time: '09:00', timezone: 'Europe/Paris', use_recipient_timezone: false } }) + }) + const { onOpenDetail } = renderCompose() + const button = await screen.findByRole('button', { name: /^Schedule$/ }) + await waitFor(() => expect(button).toBeEnabled()) + await userEvent.click(button) + await waitFor(() => + expect(broadcastApi.schedule).toHaveBeenCalledWith({ + workspace_id: 'ws1', + id: 'bc1', + send_now: false, + scheduled_date: '2030-01-02', + scheduled_time: '09:00', + timezone: 'Europe/Paris', + use_recipient_timezone: false + }) + ) + expect(onOpenDetail).toHaveBeenCalledWith('bc1') + }) + + it('shows the server refusal of a send and stays', async () => { + ;(broadcastApi.schedule as Mock).mockRejectedValue(new Error('Restricted in demo mode')) + const { onOpenDetail } = renderCompose() + const send = await screen.findByRole('button', { name: /Send now/ }) + await waitFor(() => expect(send).toBeEnabled()) + await userEvent.click(send) + await userEvent.click(within(await confirmation(/now\?$/)).getByRole('button', { name: 'Send now' })) + expect(await screen.findByText('Restricted in demo mode')).toBeInTheDocument() + expect(onOpenDetail).not.toHaveBeenCalled() + }) + + it('disables the send without a marketing provider', async () => { + renderCompose({ workspace: { ...workspace, settings: { ...workspace.settings, marketing_email_provider_id: undefined } } as unknown as Workspace }) + const send = await screen.findByRole('button', { name: /Send now/ }) + expect(send).toBeDisabled() + expect(screen.getByText('No marketing email provider')).toBeInTheDocument() + }) + + it('offers no send for a scheduled broadcast: its changes are saved with Save changes, schedule kept', async () => { + ;(broadcastApi.get as Mock).mockResolvedValue({ + broadcast: stored({ status: 'scheduled', schedule: { is_scheduled: true, scheduled_date: '2030-01-02', scheduled_time: '09:00', timezone: 'UTC', use_recipient_timezone: false } }) + }) + renderCompose() + const name = await screen.findByRole('textbox', { name: 'Broadcast name' }) + expect(screen.queryByRole('button', { name: /Send now|^Schedule$/ })).not.toBeInTheDocument() + expect(screen.getByText(/^Scheduled for .* at /)).toBeInTheDocument() + fireEvent.change(name, { target: { value: 'News fixed' } }) + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(broadcastApi.update).toHaveBeenCalledTimes(1)) + const payload = (broadcastApi.update as Mock).mock.calls[0][0] + expect(payload).toMatchObject({ id: 'bc1', name: 'News fixed' }) + expect(payload).not.toHaveProperty('schedule') + }) + + it('shows the refusal of Save changes on a scheduled broadcast', async () => { + ;(broadcastApi.get as Mock).mockResolvedValue({ + broadcast: stored({ status: 'scheduled', schedule: { is_scheduled: true, scheduled_date: '2030-01-02', scheduled_time: '09:00', timezone: 'UTC', use_recipient_timezone: false } }) + }) + ;(broadcastApi.update as Mock).mockRejectedValue(new ApiError('The list no longer exists', 400)) + renderCompose() + fireEvent.change(await screen.findByRole('textbox', { name: 'Broadcast name' }), { target: { value: 'News fixed' } }) + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + expect(await screen.findByText('The list no longer exists')).toBeInTheDocument() + }) + + it('sends a test of a scheduled broadcast without saving its pending changes', async () => { + ;(broadcastApi.get as Mock).mockResolvedValue({ + broadcast: stored({ status: 'scheduled', schedule: { is_scheduled: true, scheduled_date: '2030-01-02', scheduled_time: '09:00', timezone: 'UTC', use_recipient_timezone: false } }) + }) + renderCompose() + fireEvent.change(await screen.findByRole('textbox', { name: 'Broadcast name' }), { target: { value: 'News fixed' } }) + const button = screen.getByRole('button', { name: 'Send test' }) + await waitFor(() => expect(button).toBeEnabled()) + await userEvent.click(button) + expect(await screen.findByTestId('send-test')).toBeInTheDocument() + expect(broadcastApi.update).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Save changes' })).toBeInTheDocument() + }) + + it('still opens the test of an incomplete draft: nothing was refused', async () => { + renderCompose() + fireEvent.change(await screen.findByRole('textbox', { name: 'Broadcast name' }), { target: { value: '' } }) + const button = screen.getByRole('button', { name: 'Send test' }) + await waitFor(() => expect(button).toBeEnabled()) + await userEvent.click(button) + expect(await screen.findByTestId('send-test')).toBeInTheDocument() + expect(broadcastApi.update).not.toHaveBeenCalled() + }) + + it('opens no test when the draft cannot be saved', async () => { + ;(broadcastApi.update as Mock).mockRejectedValue(new ApiError('Refused', 400)) + renderCompose() + fireEvent.change(await screen.findByRole('textbox', { name: 'Broadcast name' }), { target: { value: 'News 2' } }) + const button = screen.getByRole('button', { name: 'Send test' }) + await waitFor(() => expect(button).toBeEnabled()) + await userEvent.click(button) + await waitFor(() => expect(broadcastApi.update).toHaveBeenCalled()) + expect((await screen.findAllByText('Refused')).length).toBeGreaterThan(0) + expect(screen.queryByTestId('send-test')).toBeNull() + }) + + it('sends a test of the previewed template for the broadcast list', async () => { + renderCompose() + const button = await screen.findByRole('button', { name: 'Send test' }) + await waitFor(() => expect(button).toBeEnabled()) + await userEvent.click(button) + expect(await screen.findByTestId('send-test')).toHaveTextContent('l1') + }) + + it('requests no template without Templates read', async () => { + renderCompose({ permissions: { ...permissions, templates: { read: false, write: false } } as unknown as UserPermissions }) + await screen.findByRole('textbox', { name: 'Broadcast name' }) + expect(await screen.findByText("You need Templates read permission to see this template's name and preview")).toBeInTheDocument() + expect(templatesApi.get).not.toHaveBeenCalled() + }) + + it('guards leaving the screen, not its own change of URL after a create', async () => { + renderCompose({ composeId: 'new' }) + await userEvent.type(await screen.findByRole('textbox', { name: 'Broadcast name' }), ' x') + const here = { pathname: '/console/workspace/ws1/broadcasts', search: { compose: 'new' } } + expect(await shouldBlockFn({ current: here, next: { ...here, search: { compose: 'bc1' } } })).toBe(false) + expect(await shouldBlockFn({ current: here, next: { ...here, search: {} } })).toBe(true) + expect(await shouldBlockFn({ current: here, next: { pathname: '/console/workspace/ws1/contacts', search: {} } })).toBe(true) + }) +}) diff --git a/console/src/components/broadcasts/compose/BroadcastCompose.tsx b/console/src/components/broadcasts/compose/BroadcastCompose.tsx new file mode 100644 index 000000000..cb89a9011 --- /dev/null +++ b/console/src/components/broadcasts/compose/BroadcastCompose.tsx @@ -0,0 +1,554 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Alert, App, Button, Input, Result, Skeleton, Tooltip } from 'antd' +import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query' +import { useBlocker } from '@tanstack/react-router' +import { useLingui } from '@lingui/react/macro' +import { plural } from '@lingui/core/macro' +import { Send } from 'lucide-react' +import { FullDrawer, SaveBar } from '../../layout' +import { ApiError } from '../../../services/api/client' +import { broadcastApi, testEmailListProps, type Broadcast } from '../../../services/api/broadcast' +import { listsApi } from '../../../services/api/list' +import { listSegments } from '../../../services/api/segment' +import { templatesApi, type Template } from '../../../services/api/template' +import { canSendTestEmail } from '../../../services/api/permissions' +import type { Workspace } from '../../../services/api/types' +import type { UserPermissions } from '../../../services/api/workspace' +import dayjs from '../../../lib/dayjs' +import SendTemplateModal from '../../templates/SendTemplateModal' +import { BroadcastStatusPill } from '../BroadcastStatusPill' +import { workspaceTimezone } from '../access' +import { useComposeEstimate } from '../useAudienceEstimates' +import { getMarketingSenders } from '../variationSender' +import { AdvancedRow } from './AdvancedRow' +import { AudienceCard } from './AudienceCard' +import { ContentCard } from './ContentCard' +import { PreviewPanel } from './PreviewPanel' +import { SendSummary, errorCount, summaryChecks, useCheckText } from './SendSummary' +import { SendingCard } from './SendingCard' +import { TrackingCard } from './TrackingCard' +import { fromBroadcast, newDraft, withName, type ComposeValues } from './composeValues' +import { useAutosave, type SaveState } from './useAutosave' +import { useIssueText } from './useIssueText' +import { validateDraft } from './validateDraft' +import type { ComposeUpdate } from './types' + +/** The statuses the composer edits; any other opens the detail instead. */ +export const EDITABLE_STATUSES: Broadcast['status'][] = ['draft', 'scheduled'] + +export interface BroadcastComposeProps { + workspace: Workspace + permissions?: UserPermissions | null + /** 'new', or the id of the broadcast; fixed for the life of the composer. */ + composeId: string + /** Removes `compose` from the URL. */ + onClose: () => void + /** A new broadcast was created: `?compose=new` becomes `?compose={id}`, without remounting. */ + onCreated: (id: string) => void + /** Replaces `compose` with `broadcast`: after a send, or for a status the composer does not edit. */ + onOpenDetail: (id: string) => void +} + +const isNotFound = (error: unknown) => error instanceof ApiError && error.status === 404 + +/** The one-screen composer of a broadcast, in a full-width drawer. */ +export function BroadcastCompose(props: BroadcastComposeProps) { + const { t } = useLingui() + const { workspace, permissions, composeId, onClose, onOpenDetail } = props + const ws = workspace.id + const isNew = composeId === 'new' + const canWrite = !!permissions?.broadcasts?.write + + const query = useQuery({ + queryKey: ['broadcast', ws, composeId], + queryFn: async () => (await broadcastApi.get({ workspace_id: ws, id: composeId, with_templates: true })).broadcast, + enabled: !isNew, + retry: (count, error) => !isNotFound(error) && count < 2, + // The composer owns this broadcast while it is open: its own answers keep the cache current. + staleTime: Infinity + }) + + // The gate: only a member who may write edits, and only a draft or a scheduled broadcast. The + // permissions are null until they load: a link to the composer waits for them. + const known = permissions != null + const status = query.data?.status + const gated = known && ((!canWrite && !isNew) || (!!status && !EDITABLE_STATUSES.includes(status))) + useEffect(() => { + if (!known) return + if (!canWrite && isNew) onClose() + else if (gated) onOpenDetail(composeId) + }, [known, canWrite, isNew, gated, composeId, onClose, onOpenDetail]) + + if (!known || (!canWrite && isNew)) return null + if (isNew || (query.data && !gated)) { + return + } + return ( + + {query.isError ? ( + isNotFound(query.error) ? ( + {t`Back to broadcasts`}} /> + ) : ( + query.refetch()}>{t`Retry`}} /> + ) + ) : ( + + )} + + ) +} + +interface ComposeEditorProps extends BroadcastComposeProps { + broadcast?: Broadcast +} + +function ComposeEditor({ workspace, permissions, broadcast, onClose, onCreated, onOpenDetail }: ComposeEditorProps) { + const { t, i18n } = useLingui() + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const ws = workspace.id + const issueText = useIssueText() + + const initial = useMemo(() => { + if (broadcast) return fromBroadcast(broadcast, workspace) + const today = new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'short' }).format(new Date()) + return newDraft(t`Broadcast ${today}`, workspace) + // Fixed at mount: the composer edits from here on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const [values, setValues] = useState(initial) + const [savedValues, setSavedValues] = useState(initial) + const update: ComposeUpdate = (fn) => setValues((current) => fn(current)) + const [previewIndex, setPreviewIndex] = useState(0) + const [testTemplate, setTestTemplate] = useState