Skip to content

Commit 85ec0dd

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(newsletters): add superuser audience targeting
1 parent 1a23438 commit 85ec0dd

37 files changed

Lines changed: 22278 additions & 40 deletions

File tree

apps/sim/app/account/settings/[section]/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from '@/components/settings/navigation'
1212
import { getSession } from '@/lib/auth'
1313
import { isBillingEnabled } from '@/lib/core/config/env-flags'
14-
import { isPlatformAdmin } from '@/lib/permissions/super-user'
14+
import { isPlatformAdmin, verifyEffectiveSuperUser } from '@/lib/permissions/super-user'
1515

1616
interface AccountSettingsSectionPageProps {
1717
params: Promise<{ section: string }>
@@ -50,6 +50,10 @@ export default async function AccountSettingsSectionPage({
5050
const isSuperUser = await isPlatformAdmin(session.user.id)
5151
if (!isSuperUser) notFound()
5252
}
53+
if (parsed === 'newsletters') {
54+
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
55+
if (!effectiveSuperUser) notFound()
56+
}
5357

5458
/**
5559
* Sections read URL query params via nuqs (which uses `useSearchParams`

apps/sim/app/account/settings/layout.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { redirect } from 'next/navigation'
22
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
33
import { getSession } from '@/lib/auth'
4-
import { isPlatformAdmin } from '@/lib/permissions/super-user'
4+
import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user'
55

66
export default async function AccountSettingsLayout({ children }: { children: React.ReactNode }) {
77
const session = await getSession()
88
if (!session?.user) redirect('/login')
9-
const isSuperUser = await isPlatformAdmin(session.user.id)
9+
const { effectiveSuperUser, isSuperUser } = await verifyEffectiveSuperUser(session.user.id)
1010

1111
return (
12-
<StandaloneSettingsShell plane='account' isSuperUser={isSuperUser}>
12+
<StandaloneSettingsShell
13+
plane='account'
14+
isSuperUser={isSuperUser}
15+
isEffectiveSuperUser={effectiveSuperUser}
16+
>
1317
{children}
1418
</StandaloneSettingsShell>
1519
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { exportNewsletterRunCsvContract } from '@/lib/api/contracts/newsletters'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { validateNewsletterSuperuser } from '@/lib/newsletters/auth'
8+
import { createNewsletterCsvExport } from '@/lib/newsletters/runs'
9+
10+
const logger = createLogger('NewsletterCsvExportAPI')
11+
12+
export const GET = withRouteHandler(
13+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
14+
try {
15+
const auth = await validateNewsletterSuperuser()
16+
if (!auth.success) return auth.response
17+
18+
const parsed = await parseRequest(exportNewsletterRunCsvContract, request, context)
19+
if (!parsed.success) return parsed.response
20+
21+
const { filename, lines } = await createNewsletterCsvExport(parsed.data.params.id)
22+
const iterator = lines[Symbol.asyncIterator]()
23+
const encoder = new TextEncoder()
24+
const stream = new ReadableStream<Uint8Array>({
25+
async pull(controller) {
26+
try {
27+
const next = await iterator.next()
28+
if (next.done) {
29+
controller.close()
30+
return
31+
}
32+
controller.enqueue(encoder.encode(`${next.value}\n`))
33+
} catch (error) {
34+
logger.error('Failed while streaming newsletter CSV', {
35+
error: getErrorMessage(error),
36+
})
37+
controller.error(error)
38+
}
39+
},
40+
async cancel() {
41+
await iterator.return?.(undefined)
42+
},
43+
})
44+
45+
return new NextResponse(stream, {
46+
status: 200,
47+
headers: {
48+
'Content-Type': 'text/csv; charset=utf-8',
49+
'Content-Disposition': `attachment; filename="${filename}"`,
50+
'Cache-Control': 'no-store',
51+
},
52+
})
53+
} catch (error) {
54+
const message = getErrorMessage(error)
55+
if (/not found/i.test(message)) {
56+
return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 })
57+
}
58+
if (/Finalize/i.test(message)) {
59+
return NextResponse.json({ error: message }, { status: 400 })
60+
}
61+
if (/RESEND_API_KEY|Resend .*list|Resend request/i.test(message)) {
62+
return NextResponse.json({ error: message }, { status: 503 })
63+
}
64+
logger.error('Failed to export newsletter CSV', { error: message })
65+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
66+
}
67+
}
68+
)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { finalizeNewsletterRunContract } from '@/lib/api/contracts/newsletters'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { validateNewsletterSuperuser } from '@/lib/newsletters/auth'
8+
import { finalizeNewsletterRun } from '@/lib/newsletters/runs'
9+
10+
const logger = createLogger('NewsletterFinalizeAPI')
11+
12+
export const POST = withRouteHandler(
13+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
14+
try {
15+
const auth = await validateNewsletterSuperuser()
16+
if (!auth.success) return auth.response
17+
18+
const parsed = await parseRequest(finalizeNewsletterRunContract, request, context)
19+
if (!parsed.success) return parsed.response
20+
21+
const { run, oversized } = await finalizeNewsletterRun(parsed.data.params.id)
22+
if (oversized) {
23+
logger.warn('Newsletter audience exceeded the finalization safety limit', {
24+
userId: auth.userId,
25+
runId: run.id,
26+
totalMatched: run.counts.totalMatched,
27+
})
28+
}
29+
return NextResponse.json({ run })
30+
} catch (error) {
31+
const message = getErrorMessage(error)
32+
if (/not found/i.test(message)) {
33+
return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 })
34+
}
35+
if (/already in progress/i.test(message)) {
36+
return NextResponse.json({ error: message }, { status: 409 })
37+
}
38+
if (/RESEND_API_KEY|Resend .*list|Resend request/i.test(message)) {
39+
return NextResponse.json({ error: message }, { status: 503 })
40+
}
41+
logger.error('Failed to finalize newsletter run', { error: message })
42+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
43+
}
44+
}
45+
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { getNewsletterRunJobContract } from '@/lib/api/contracts/newsletters'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { getJobQueue } from '@/lib/core/async-jobs'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { validateNewsletterSuperuser } from '@/lib/newsletters/auth'
9+
import { requireNewsletterRun } from '@/lib/newsletters/runs'
10+
11+
const logger = createLogger('NewsletterJobAPI')
12+
13+
export const GET = withRouteHandler(
14+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
15+
try {
16+
const auth = await validateNewsletterSuperuser()
17+
if (!auth.success) return auth.response
18+
19+
const parsed = await parseRequest(getNewsletterRunJobContract, request, context)
20+
if (!parsed.success) return parsed.response
21+
22+
const run = await requireNewsletterRun(parsed.data.params.id)
23+
if (!run.resendSyncJobId) return NextResponse.json({ job: null })
24+
25+
const queue = await getJobQueue()
26+
const job = await queue.getJob(run.resendSyncJobId)
27+
return NextResponse.json({
28+
job: job
29+
? {
30+
id: job.id,
31+
status: job.status,
32+
attempts: job.attempts,
33+
maxAttempts: job.maxAttempts,
34+
error: job.error ?? null,
35+
createdAt: job.createdAt.toISOString(),
36+
startedAt: job.startedAt?.toISOString() ?? null,
37+
completedAt: job.completedAt?.toISOString() ?? null,
38+
}
39+
: null,
40+
})
41+
} catch (error) {
42+
const message = getErrorMessage(error)
43+
if (/not found/i.test(message)) {
44+
return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 })
45+
}
46+
logger.error('Failed to get newsletter job', { error: message })
47+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
48+
}
49+
}
50+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { pushNewsletterRunToResendContract } from '@/lib/api/contracts/newsletters'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { validateNewsletterSuperuser } from '@/lib/newsletters/auth'
9+
import { enqueueNewsletterResendSync } from '@/lib/newsletters/push-resend'
10+
11+
const logger = createLogger('NewsletterPushResendAPI')
12+
13+
export const POST = withRouteHandler(
14+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
15+
try {
16+
const auth = await validateNewsletterSuperuser()
17+
if (!auth.success) return auth.response
18+
19+
const parsed = await parseRequest(pushNewsletterRunToResendContract, request, context)
20+
if (!parsed.success) return parsed.response
21+
22+
const { run, jobId } = await enqueueNewsletterResendSync(parsed.data.params.id, auth.userId)
23+
return NextResponse.json({ run, jobId })
24+
} catch (error) {
25+
const message = getErrorMessage(error)
26+
if (/not found/i.test(message)) {
27+
return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 })
28+
}
29+
if (/finalize|RESEND_API_KEY/i.test(message)) {
30+
return NextResponse.json({ error: message }, { status: 400 })
31+
}
32+
if (isAsyncJobEnqueueError(error)) {
33+
return NextResponse.json(
34+
{ error: 'Newsletter sync enqueue is uncertain; retry to resume the same attempt' },
35+
{ status: 503 }
36+
)
37+
}
38+
if (/tracking persistence failed/i.test(message)) {
39+
return NextResponse.json(
40+
{ error: 'Newsletter sync was accepted but job tracking is not yet available; retry' },
41+
{ status: 503 }
42+
)
43+
}
44+
logger.error('Failed to enqueue newsletter Resend push', { error: message })
45+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
46+
}
47+
}
48+
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { getNewsletterRunContract } from '@/lib/api/contracts/newsletters'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { validateNewsletterSuperuser } from '@/lib/newsletters/auth'
8+
import { getNewsletterRun } from '@/lib/newsletters/runs'
9+
10+
const logger = createLogger('NewsletterRunAPI')
11+
12+
export const GET = withRouteHandler(
13+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
14+
try {
15+
const auth = await validateNewsletterSuperuser()
16+
if (!auth.success) return auth.response
17+
18+
const parsed = await parseRequest(getNewsletterRunContract, request, context)
19+
if (!parsed.success) return parsed.response
20+
21+
const run = await getNewsletterRun(parsed.data.params.id)
22+
if (!run) return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 })
23+
return NextResponse.json({ run })
24+
} catch (error) {
25+
logger.error('Failed to get newsletter run', { error: getErrorMessage(error) })
26+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
27+
}
28+
}
29+
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockCreateNewsletterRun, mockListNewsletterRuns, mockValidateNewsletterSuperuser } =
8+
vi.hoisted(() => ({
9+
mockCreateNewsletterRun: vi.fn(),
10+
mockListNewsletterRuns: vi.fn(),
11+
mockValidateNewsletterSuperuser: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/newsletters/auth', () => ({
15+
validateNewsletterSuperuser: mockValidateNewsletterSuperuser,
16+
}))
17+
18+
vi.mock('@/lib/newsletters/runs', () => ({
19+
createNewsletterRun: mockCreateNewsletterRun,
20+
listNewsletterRuns: mockListNewsletterRuns,
21+
}))
22+
23+
import { NewsletterTargetingPromptError } from '@/lib/newsletters/targeting'
24+
import { POST } from '@/app/api/superuser/newsletters/runs/route'
25+
26+
function createRequest(prompt = 'Everyone') {
27+
return createMockRequest('POST', {
28+
name: 'July launch',
29+
prompt,
30+
})
31+
}
32+
33+
describe('newsletter runs POST', () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
mockValidateNewsletterSuperuser.mockResolvedValue({
37+
success: true,
38+
userId: 'user-1',
39+
})
40+
})
41+
42+
it('returns the superuser authorization failure before creating a run', async () => {
43+
mockValidateNewsletterSuperuser.mockResolvedValue({
44+
success: false,
45+
response: new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }),
46+
})
47+
48+
const response = await POST(createRequest())
49+
50+
expect(response.status).toBe(403)
51+
expect(mockCreateNewsletterRun).not.toHaveBeenCalled()
52+
})
53+
54+
it('creates a preview run for an authorized superuser', async () => {
55+
const run = {
56+
id: 'run-1',
57+
name: 'July launch',
58+
prompt: 'Everyone',
59+
}
60+
mockCreateNewsletterRun.mockResolvedValue(run)
61+
62+
const response = await POST(createRequest())
63+
64+
expect(response.status).toBe(200)
65+
expect(await response.json()).toEqual({ run })
66+
expect(mockCreateNewsletterRun).toHaveBeenCalledWith({
67+
name: 'July launch',
68+
prompt: 'Everyone',
69+
createdById: 'user-1',
70+
})
71+
})
72+
73+
it('returns a client error for an ambiguous targeting prompt', async () => {
74+
mockCreateNewsletterRun.mockRejectedValue(new NewsletterTargetingPromptError())
75+
76+
const response = await POST(createRequest('Users interested in productivity'))
77+
78+
expect(response.status).toBe(400)
79+
await expect(response.json()).resolves.toEqual({
80+
error:
81+
'Targeting prompt is ambiguous. Use everyone, an Instagram integration or chat target, or a recent activity window.',
82+
})
83+
})
84+
})

0 commit comments

Comments
 (0)