Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/sim/app/account/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@/components/settings/navigation'
import { getSession } from '@/lib/auth'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { isPlatformAdmin, verifyEffectiveSuperUser } from '@/lib/permissions/super-user'

interface AccountSettingsSectionPageProps {
params: Promise<{ section: string }>
Expand Down Expand Up @@ -50,6 +50,10 @@ export default async function AccountSettingsSectionPage({
const isSuperUser = await isPlatformAdmin(session.user.id)
if (!isSuperUser) notFound()
}
if (parsed === 'newsletters') {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) notFound()
}

/**
* Sections read URL query params via nuqs (which uses `useSearchParams`
Expand Down
10 changes: 7 additions & 3 deletions apps/sim/app/account/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { redirect } from 'next/navigation'
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import { getSession } from '@/lib/auth'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user'

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

return (
<StandaloneSettingsShell plane='account' isSuperUser={isSuperUser}>
<StandaloneSettingsShell
plane='account'
isSuperUser={isSuperUser}
isEffectiveSuperUser={effectiveSuperUser}
>
{children}
</StandaloneSettingsShell>
)
Expand Down
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 })
}
}
)
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 apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts
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 })
}
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
logger.error('Failed to finalize newsletter run', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
50 changes: 50 additions & 0 deletions apps/sim/app/api/superuser/newsletters/runs/[id]/job/route.ts
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 })
}
}
)
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 })
}
}
)
29 changes: 29 additions & 0 deletions apps/sim/app/api/superuser/newsletters/runs/[id]/route.ts
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 })
}
}
)
Loading
Loading