-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(newsletters): add superuser audience targeting #6069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
734a067
feat(newsletters): add superuser audience targeting
46eb81e
fix(newsletters): handle review recovery cases
928a7c9
fix(newsletters): propagate sync cancellation
07ff1c5
fix(newsletters): keep exports and recovery responsive
493375b
fix(newsletters): fence resend recovery state
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
69 changes: 69 additions & 0 deletions
69
apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { exportNewsletterRunCsvContract } from '@/lib/api/contracts/newsletters' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' | ||
| import { isNewsletterResendError } from '@/lib/newsletters/resend' | ||
| import { createNewsletterCsvExport } from '@/lib/newsletters/runs' | ||
|
|
||
| const logger = createLogger('NewsletterCsvExportAPI') | ||
|
|
||
| export const GET = withRouteHandler( | ||
| async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { | ||
| try { | ||
| const auth = await validateNewsletterSuperuser() | ||
| if (!auth.success) return auth.response | ||
|
|
||
| const parsed = await parseRequest(exportNewsletterRunCsvContract, request, context) | ||
| if (!parsed.success) return parsed.response | ||
|
|
||
| const { filename, lines } = await createNewsletterCsvExport(parsed.data.params.id) | ||
| const iterator = lines[Symbol.asyncIterator]() | ||
| const encoder = new TextEncoder() | ||
| const stream = new ReadableStream<Uint8Array>({ | ||
| async pull(controller) { | ||
| try { | ||
| const next = await iterator.next() | ||
| if (next.done) { | ||
| controller.close() | ||
| return | ||
| } | ||
| controller.enqueue(encoder.encode(`${next.value}\n`)) | ||
| } catch (error) { | ||
| logger.error('Failed while streaming newsletter CSV', { | ||
| error: getErrorMessage(error), | ||
| }) | ||
| controller.error(error) | ||
| } | ||
| }, | ||
| async cancel() { | ||
| await iterator.return?.(undefined) | ||
| }, | ||
| }) | ||
|
|
||
| return new NextResponse(stream, { | ||
| status: 200, | ||
| headers: { | ||
| 'Content-Type': 'text/csv; charset=utf-8', | ||
| 'Content-Disposition': `attachment; filename="${filename}"`, | ||
| 'Cache-Control': 'no-store', | ||
| }, | ||
| }) | ||
| } catch (error) { | ||
| const message = getErrorMessage(error) | ||
| if (/not found/i.test(message)) { | ||
| return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) | ||
| } | ||
| if (/Finalize/i.test(message)) { | ||
| return NextResponse.json({ error: message }, { status: 400 }) | ||
| } | ||
| if (isNewsletterResendError(error)) { | ||
| return NextResponse.json({ error: message }, { status: 503 }) | ||
| } | ||
| logger.error('Failed to export newsletter CSV', { error: message }) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } | ||
| ) |
63 changes: 63 additions & 0 deletions
63
apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /** | ||
| * @vitest-environment node | ||
| */ | ||
| import { createMockRequest } from '@sim/testing' | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| const { mockFinalizeNewsletterRun, mockValidateNewsletterSuperuser } = vi.hoisted(() => ({ | ||
| mockFinalizeNewsletterRun: vi.fn(), | ||
| mockValidateNewsletterSuperuser: vi.fn(), | ||
| })) | ||
|
|
||
| vi.mock('@/lib/newsletters/auth', () => ({ | ||
| validateNewsletterSuperuser: mockValidateNewsletterSuperuser, | ||
| })) | ||
|
|
||
| vi.mock('@/lib/newsletters/runs', () => ({ | ||
| finalizeNewsletterRun: mockFinalizeNewsletterRun, | ||
| })) | ||
|
|
||
| import { NewsletterResendError } from '@/lib/newsletters/resend' | ||
| import { POST } from '@/app/api/superuser/newsletters/runs/[id]/finalize/route' | ||
|
|
||
| function callRoute() { | ||
| const request = createMockRequest( | ||
| 'POST', | ||
| undefined, | ||
| {}, | ||
| 'http://localhost:3000/api/superuser/newsletters/runs/run-1/finalize' | ||
| ) | ||
| return POST(request, { params: Promise.resolve({ id: 'run-1' }) }) | ||
| } | ||
|
|
||
| describe('newsletter run finalization', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| mockValidateNewsletterSuperuser.mockResolvedValue({ | ||
| success: true, | ||
| userId: 'admin-1', | ||
| }) | ||
| }) | ||
|
|
||
| it.each([ | ||
| 'Resend suppression pagination returned no cursor', | ||
| 'Resend contact pagination returned no cursor', | ||
| 'Resend contact property pagination returned no cursor', | ||
| ])('maps a Resend service failure to 503: %s', async (message) => { | ||
| mockFinalizeNewsletterRun.mockRejectedValueOnce(new NewsletterResendError(message)) | ||
|
|
||
| const response = await callRoute() | ||
|
|
||
| expect(response.status).toBe(503) | ||
| await expect(response.json()).resolves.toEqual({ error: message }) | ||
| }) | ||
|
|
||
| it('does not classify an unrelated error by message text', async () => { | ||
| mockFinalizeNewsletterRun.mockRejectedValueOnce(new Error('Resend text from unrelated code')) | ||
|
|
||
| const response = await callRoute() | ||
|
|
||
| expect(response.status).toBe(500) | ||
| await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) | ||
| }) | ||
| }) |
46 changes: 46 additions & 0 deletions
46
apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { finalizeNewsletterRunContract } from '@/lib/api/contracts/newsletters' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' | ||
| import { isNewsletterResendError } from '@/lib/newsletters/resend' | ||
| import { finalizeNewsletterRun } from '@/lib/newsletters/runs' | ||
|
|
||
| const logger = createLogger('NewsletterFinalizeAPI') | ||
|
|
||
| export const POST = withRouteHandler( | ||
| async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { | ||
| try { | ||
| const auth = await validateNewsletterSuperuser() | ||
| if (!auth.success) return auth.response | ||
|
|
||
| const parsed = await parseRequest(finalizeNewsletterRunContract, request, context) | ||
| if (!parsed.success) return parsed.response | ||
|
|
||
| const { run, oversized } = await finalizeNewsletterRun(parsed.data.params.id) | ||
| if (oversized) { | ||
| logger.warn('Newsletter audience exceeded the finalization safety limit', { | ||
| userId: auth.userId, | ||
| runId: run.id, | ||
| totalMatched: run.counts.totalMatched, | ||
| }) | ||
| } | ||
| return NextResponse.json({ run }) | ||
| } catch (error) { | ||
| const message = getErrorMessage(error) | ||
| if (/not found/i.test(message)) { | ||
| return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) | ||
| } | ||
| if (/already in progress/i.test(message)) { | ||
| return NextResponse.json({ error: message }, { status: 409 }) | ||
| } | ||
| if (isNewsletterResendError(error)) { | ||
| return NextResponse.json({ error: message }, { status: 503 }) | ||
| } | ||
| logger.error('Failed to finalize newsletter run', { error: message }) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } | ||
| ) | ||
50 changes: 50 additions & 0 deletions
50
apps/sim/app/api/superuser/newsletters/runs/[id]/job/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { getNewsletterRunJobContract } from '@/lib/api/contracts/newsletters' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { getJobQueue } from '@/lib/core/async-jobs' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' | ||
| import { requireNewsletterRun } from '@/lib/newsletters/runs' | ||
|
|
||
| const logger = createLogger('NewsletterJobAPI') | ||
|
|
||
| export const GET = withRouteHandler( | ||
| async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { | ||
| try { | ||
| const auth = await validateNewsletterSuperuser() | ||
| if (!auth.success) return auth.response | ||
|
|
||
| const parsed = await parseRequest(getNewsletterRunJobContract, request, context) | ||
| if (!parsed.success) return parsed.response | ||
|
|
||
| const run = await requireNewsletterRun(parsed.data.params.id) | ||
| if (!run.resendSyncJobId) return NextResponse.json({ job: null }) | ||
|
|
||
| const queue = await getJobQueue() | ||
| const job = await queue.getJob(run.resendSyncJobId) | ||
| return NextResponse.json({ | ||
| job: job | ||
| ? { | ||
| id: job.id, | ||
| status: job.status, | ||
| attempts: job.attempts, | ||
| maxAttempts: job.maxAttempts, | ||
| error: job.error ?? null, | ||
| createdAt: job.createdAt.toISOString(), | ||
| startedAt: job.startedAt?.toISOString() ?? null, | ||
| completedAt: job.completedAt?.toISOString() ?? null, | ||
| } | ||
| : null, | ||
| }) | ||
| } catch (error) { | ||
| const message = getErrorMessage(error) | ||
| if (/not found/i.test(message)) { | ||
| return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) | ||
| } | ||
| logger.error('Failed to get newsletter job', { error: message }) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } | ||
| ) |
48 changes: 48 additions & 0 deletions
48
apps/sim/app/api/superuser/newsletters/runs/[id]/push-resend/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { pushNewsletterRunToResendContract } from '@/lib/api/contracts/newsletters' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' | ||
| import { enqueueNewsletterResendSync } from '@/lib/newsletters/push-resend' | ||
|
|
||
| const logger = createLogger('NewsletterPushResendAPI') | ||
|
|
||
| export const POST = withRouteHandler( | ||
| async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { | ||
| try { | ||
| const auth = await validateNewsletterSuperuser() | ||
| if (!auth.success) return auth.response | ||
|
|
||
| const parsed = await parseRequest(pushNewsletterRunToResendContract, request, context) | ||
| if (!parsed.success) return parsed.response | ||
|
|
||
| const { run, jobId } = await enqueueNewsletterResendSync(parsed.data.params.id, auth.userId) | ||
| return NextResponse.json({ run, jobId }) | ||
| } catch (error) { | ||
| const message = getErrorMessage(error) | ||
| if (/not found/i.test(message)) { | ||
| return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) | ||
| } | ||
| if (/finalize|RESEND_API_KEY/i.test(message)) { | ||
| return NextResponse.json({ error: message }, { status: 400 }) | ||
| } | ||
| if (isAsyncJobEnqueueError(error)) { | ||
| return NextResponse.json( | ||
| { error: 'Newsletter sync enqueue is uncertain; retry to resume the same attempt' }, | ||
| { status: 503 } | ||
| ) | ||
| } | ||
| if (/tracking persistence failed/i.test(message)) { | ||
| return NextResponse.json( | ||
| { error: 'Newsletter sync was accepted but job tracking is not yet available; retry' }, | ||
| { status: 503 } | ||
| ) | ||
| } | ||
| logger.error('Failed to enqueue newsletter Resend push', { error: message }) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { getNewsletterRunContract } from '@/lib/api/contracts/newsletters' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' | ||
| import { getNewsletterRun } from '@/lib/newsletters/runs' | ||
|
|
||
| const logger = createLogger('NewsletterRunAPI') | ||
|
|
||
| export const GET = withRouteHandler( | ||
| async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { | ||
| try { | ||
| const auth = await validateNewsletterSuperuser() | ||
| if (!auth.success) return auth.response | ||
|
|
||
| const parsed = await parseRequest(getNewsletterRunContract, request, context) | ||
| if (!parsed.success) return parsed.response | ||
|
|
||
| const run = await getNewsletterRun(parsed.data.params.id) | ||
| if (!run) return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) | ||
| return NextResponse.json({ run }) | ||
| } catch (error) { | ||
| logger.error('Failed to get newsletter run', { error: getErrorMessage(error) }) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.