feat: add safe owner-only migration reset (#1682)
* feat: add safe company migration reset * fix: harden company reset eligibility * fix: close company reset compliance gaps * test: fix migration reset pg-real probes * fix: preserve migration archive access * docs: explain migration numbering continuity * fix: block reset with VAT workflow state * fix: block externally staged reset data * fix: address migration reset review findings * fix: clear stale migration archive estimate * fix: retry migration archive estimates
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(supabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
const params = { params: Promise.resolve({ id: 'company-1' }) }
|
||||
const validBody = {
|
||||
confirm_name: 'Testbolaget AB',
|
||||
reason: 'Den första migreringen fick fel periodindelning.',
|
||||
confirm_no_filed_declarations: true,
|
||||
confirm_retained_archive: true,
|
||||
}
|
||||
|
||||
describe('/api/company/[id]/migration-reset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
supabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1', email: 'owner@example.com' } },
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/company/company-1/migration-reset', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when a strong confirmation is missing', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/company/company-1/migration-reset', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, confirm_retained_archive: false },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when the audit reason is too short', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/company/company-1/migration-reset', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, reason: 'För kort' },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the URL is not the active company', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/company/company-2/migration-reset', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: 'company-2' }) },
|
||||
)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('COMPANY_RESET_NOT_FOUND')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 when the eligibility RPC rejects a non-owner', async () => {
|
||||
enqueue({ data: { ok: false, code: 'COMPANY_RESET_FORBIDDEN' }, error: null })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset'),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('COMPANY_RESET_FORBIDDEN')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
|
||||
it('returns the owner eligibility preview', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
ok: true,
|
||||
eligibility: {
|
||||
eligible: true,
|
||||
display_name: 'Testbolaget AB',
|
||||
counts: { journal_entries: 0, documents: 2, voucher_sequences: 0 },
|
||||
blockers: [],
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset'),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { eligible: boolean; counts: { documents: number } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.eligible).toBe(true)
|
||||
expect(body.data.counts.documents).toBe(2)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(supabase.rpc).toHaveBeenCalledWith(
|
||||
'get_company_migration_reset_eligibility',
|
||||
{ p_company_id: 'company-1' },
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 409 with current blockers when execution is ineligible', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
ok: false,
|
||||
code: 'COMPANY_RESET_INELIGIBLE',
|
||||
details: {
|
||||
eligible: false,
|
||||
blockers: [{ code: 'authority_submission_detected', count: 1 }],
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/company/company-1/migration-reset', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
}),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { blockers: Array<{ code: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('COMPANY_RESET_INELIGIBLE')
|
||||
expect(body.error.details.blockers[0].code).toBe('authority_submission_detected')
|
||||
})
|
||||
|
||||
it('returns the replacement and switches the active-company cookie', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
ok: true,
|
||||
reset_id: 'reset-1',
|
||||
source_company_id: 'company-1',
|
||||
replacement_company_id: 'company-new',
|
||||
archived_at: '2026-08-18T09:00:00.000Z',
|
||||
counts: { journal_entries: 0, documents: 2 },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/company/company-1/migration-reset', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
}),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { resetId: string; replacementCompanyId: string; retainedCounts: unknown }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
resetId: 'reset-1',
|
||||
replacementCompanyId: 'company-new',
|
||||
retainedCounts: { journal_entries: 0, documents: 2 },
|
||||
})
|
||||
expect(response.headers.get('set-cookie')).toContain('gnubok-company-id=company-new')
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('reset_company_for_migration', {
|
||||
p_company_id: 'company-1',
|
||||
p_confirmed_name: validBody.confirm_name,
|
||||
p_reason: validBody.reason,
|
||||
p_confirm_no_filed_declarations: true,
|
||||
p_confirm_retained_archive: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 500 when the atomic RPC fails', async () => {
|
||||
enqueue({ data: null, error: { code: 'XX000', message: 'transaction failed' } })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/company/company-1/migration-reset', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
}),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('COMPANY_RESET_FAILED')
|
||||
})
|
||||
|
||||
it('falls back to COMPANY_RESET_FAILED for an unexpected RPC code', async () => {
|
||||
enqueue({ data: { ok: false, code: 'SOME_INTERNAL_CODE' }, error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/company/company-1/migration-reset', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
}),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('COMPANY_RESET_FAILED')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const {
|
||||
supabase: archiveSupabase,
|
||||
enqueue: enqueueArchive,
|
||||
reset: resetArchive,
|
||||
calls: archiveCalls,
|
||||
} = createQueuedMockSupabase()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(supabase),
|
||||
createServiceClient: () => archiveSupabase,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/full-archive-export', () => ({
|
||||
estimateArchiveSize: vi.fn(),
|
||||
generateBaseDataArchive: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
estimateArchiveSize,
|
||||
generateBaseDataArchive,
|
||||
} from '@/lib/reports/full-archive-export'
|
||||
import { GET } from '../route'
|
||||
|
||||
const mockEstimate = vi.mocked(estimateArchiveSize)
|
||||
const mockGenerate = vi.mocked(generateBaseDataArchive)
|
||||
const params = { params: Promise.resolve({ id: 'company-1' }) }
|
||||
|
||||
function enqueueAuthorizedArchive() {
|
||||
enqueue({ data: { role: 'owner' }, error: null })
|
||||
enqueue({
|
||||
data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' },
|
||||
error: null,
|
||||
})
|
||||
enqueueArchive({
|
||||
data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' },
|
||||
error: null,
|
||||
})
|
||||
enqueueArchive({ data: { role: 'owner' }, error: null })
|
||||
enqueueArchive({ data: { archived_at: '2026-08-18T14:00:00.000Z' }, error: null })
|
||||
}
|
||||
|
||||
describe('GET /api/company/[id]/migration-reset/archive', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
resetArchive()
|
||||
supabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1', email: 'owner@example.com' } },
|
||||
error: null,
|
||||
})
|
||||
mockEstimate.mockResolvedValue({
|
||||
total_bytes: 10_000_000,
|
||||
document_bytes: 1_000_000,
|
||||
document_count: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive'),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the URL is not the active replacement company', async () => {
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-2/migration-reset/archive'),
|
||||
{ params: Promise.resolve({ id: 'company-2' }) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 to a non-owner replacement member', async () => {
|
||||
enqueue({ data: { role: 'admin' }, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await GET(createMockRequest('/api/company/company-1/migration-reset/archive'), params),
|
||||
)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('COMPANY_RESET_FORBIDDEN')
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the active company has no retained reset source', async () => {
|
||||
enqueue({ data: { role: 'owner' }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive'),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns retained-source metadata and size for an owner', async () => {
|
||||
enqueueAuthorizedArchive()
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive', {
|
||||
searchParams: { estimate: '1' },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { archived_at: string; document_count: number; within_limit: boolean }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
archived_at: '2026-08-18T14:00:00.000Z',
|
||||
document_count: 2,
|
||||
within_limit: true,
|
||||
})
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'source-1', 'all')
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads the retained source without changing the active company', async () => {
|
||||
enqueueAuthorizedArchive()
|
||||
mockGenerate.mockResolvedValue(new ArrayBuffer(1024))
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive'),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/zip')
|
||||
expect(response.headers.get('Content-Disposition')).toMatch(
|
||||
/^attachment; filename="migration_reset_archive_\d{8}\.zip"$/,
|
||||
)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockGenerate).toHaveBeenCalledWith(archiveSupabase, 'source-1', {
|
||||
include_documents: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('requires an explicit document-free download when the ZIP is over limit', async () => {
|
||||
enqueueAuthorizedArchive()
|
||||
mockEstimate.mockResolvedValue({
|
||||
total_bytes: 100 * 1024 * 1024,
|
||||
document_bytes: 92 * 1024 * 1024,
|
||||
document_count: 10,
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive'),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads without documents when the planned payload is within the limit', async () => {
|
||||
enqueueAuthorizedArchive()
|
||||
mockEstimate.mockResolvedValue({
|
||||
total_bytes: 100 * 1024 * 1024,
|
||||
document_bytes: 92 * 1024 * 1024,
|
||||
document_count: 10,
|
||||
})
|
||||
mockGenerate.mockResolvedValue(new ArrayBuffer(1024))
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive', {
|
||||
searchParams: { include_documents: 'false' },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockGenerate).toHaveBeenCalledWith(archiveSupabase, 'source-1', {
|
||||
include_documents: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks a document-free download when its planned payload is still over the limit', async () => {
|
||||
enqueueAuthorizedArchive()
|
||||
mockEstimate.mockResolvedValue({
|
||||
total_bytes: 100 * 1024 * 1024,
|
||||
document_bytes: 10 * 1024 * 1024,
|
||||
document_count: 10,
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive', {
|
||||
searchParams: { include_documents: 'false' },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
size_bytes: number
|
||||
size_limit_bytes: number
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(413)
|
||||
expect(body.size_bytes).toBe(90 * 1024 * 1024)
|
||||
expect(body.size_limit_bytes).toBe(80 * 1024 * 1024)
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the archive reachable when retained-source membership changes', async () => {
|
||||
enqueueAuthorizedArchive()
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive', {
|
||||
searchParams: { estimate: '1' },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'source-1', 'all')
|
||||
expect(archiveCalls).not.toContainEqual({
|
||||
table: 'company_members',
|
||||
method: 'eq',
|
||||
args: ['company_id', 'source-1'],
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the retained source is not archived', async () => {
|
||||
enqueue({ data: { role: 'owner' }, error: null })
|
||||
enqueue({
|
||||
data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' },
|
||||
error: null,
|
||||
})
|
||||
enqueueArchive({
|
||||
data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' },
|
||||
error: null,
|
||||
})
|
||||
enqueueArchive({ data: { role: 'owner' }, error: null })
|
||||
enqueueArchive({ data: { archived_at: null }, error: null })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive'),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 500 when archive generation fails', async () => {
|
||||
enqueueAuthorizedArchive()
|
||||
mockGenerate.mockRejectedValue(new Error('storage unavailable'))
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/company/company-1/migration-reset/archive'),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('COMPANY_RESET_FAILED')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
estimateArchiveSize,
|
||||
generateBaseDataArchive,
|
||||
} from '@/lib/reports/full-archive-export'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 300
|
||||
|
||||
const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
|
||||
|
||||
type Params = { params: Promise<{ id: string }> }
|
||||
|
||||
interface ResetArchiveRow {
|
||||
source_company_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/company/[id]/migration-reset/archive
|
||||
*
|
||||
* Gives the replacement-company owner a read-only ZIP of the retained source.
|
||||
* The archived source never becomes active and no source row is modified.
|
||||
*/
|
||||
export const GET = withRouteContext<Params>(
|
||||
'company.migration-reset.archive',
|
||||
async (request, { supabase, companyId, user, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
if (id !== companyId) {
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId }))
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (membershipError) {
|
||||
log.error('failed to authorize migration reset archive', membershipError)
|
||||
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
|
||||
}
|
||||
if (membership?.role !== 'owner') {
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId }))
|
||||
}
|
||||
|
||||
const { data: visibleReset, error: visibleResetError } = await supabase
|
||||
.from('company_migration_resets')
|
||||
.select('source_company_id, created_at')
|
||||
.eq('replacement_company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (visibleResetError) {
|
||||
log.error('failed to find migration reset archive', visibleResetError)
|
||||
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
|
||||
}
|
||||
if (!visibleReset) {
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId }))
|
||||
}
|
||||
|
||||
// Service credentials are required for a complete statutory export, but
|
||||
// authorization is repeated before they are used. The immutable reset row
|
||||
// must still link this active replacement to an archived source. Access is
|
||||
// based on current ownership of the replacement, not mutable membership of
|
||||
// the retained source, so normal team removal or account anonymization
|
||||
// cannot accidentally strand the statutory archive.
|
||||
const archiveClient = createServiceClient()
|
||||
const { data: verifiedReset, error: verifiedResetError } = await archiveClient
|
||||
.from('company_migration_resets')
|
||||
.select('source_company_id, created_at')
|
||||
.eq('replacement_company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (verifiedResetError) {
|
||||
log.error('failed to verify migration reset archive link', verifiedResetError)
|
||||
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
|
||||
}
|
||||
|
||||
const reset = verifiedReset as ResetArchiveRow | null
|
||||
if (!reset || reset.source_company_id !== visibleReset.source_company_id) {
|
||||
log.warn('migration reset archive link verification denied', {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
})
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId }))
|
||||
}
|
||||
|
||||
const [{ data: replacementMembership, error: replacementMembershipError }, {
|
||||
data: sourceCompany,
|
||||
error: sourceCompanyError,
|
||||
}] = await Promise.all([
|
||||
archiveClient
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle(),
|
||||
archiveClient
|
||||
.from('companies')
|
||||
.select('archived_at')
|
||||
.eq('id', reset.source_company_id)
|
||||
.maybeSingle(),
|
||||
])
|
||||
if (replacementMembershipError || sourceCompanyError) {
|
||||
log.error(
|
||||
'failed to verify retained migration source',
|
||||
replacementMembershipError ?? sourceCompanyError,
|
||||
)
|
||||
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
|
||||
}
|
||||
if (replacementMembership?.role !== 'owner' || !sourceCompany?.archived_at) {
|
||||
log.warn('retained migration source access denied', {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
sourceCompanyId: reset.source_company_id,
|
||||
})
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId }))
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const estimateOnly = searchParams.get('estimate') === '1'
|
||||
const includeDocuments = searchParams.get('include_documents') !== 'false'
|
||||
|
||||
try {
|
||||
const estimate = await estimateArchiveSize(archiveClient, reset.source_company_id, 'all')
|
||||
const plannedSizeBytes = includeDocuments
|
||||
? estimate.total_bytes
|
||||
: Math.max(0, estimate.total_bytes - estimate.document_bytes)
|
||||
if (estimateOnly) {
|
||||
return privateNoStore(NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
...estimate,
|
||||
archived_at: reset.created_at,
|
||||
size_limit_bytes: SIZE_LIMIT_BYTES,
|
||||
within_limit: plannedSizeBytes <= SIZE_LIMIT_BYTES,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
if (plannedSizeBytes > SIZE_LIMIT_BYTES) {
|
||||
return privateNoStore(NextResponse.json(
|
||||
{
|
||||
error: 'archive_too_large',
|
||||
size_bytes: plannedSizeBytes,
|
||||
size_limit_bytes: SIZE_LIMIT_BYTES,
|
||||
},
|
||||
{ status: 413 },
|
||||
))
|
||||
}
|
||||
|
||||
const zipBuffer = await generateBaseDataArchive(archiveClient, reset.source_company_id, {
|
||||
include_documents: includeDocuments,
|
||||
})
|
||||
const filename = `migration_reset_archive_${formatDateStamp(new Date())}.zip`
|
||||
|
||||
log.info('migration reset source archive generated', {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
sourceCompanyId: reset.source_company_id,
|
||||
includeDocuments,
|
||||
filename,
|
||||
sizeBytes: zipBuffer.byteLength,
|
||||
})
|
||||
|
||||
return new NextResponse(zipBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('migration reset source archive generation failed', error as Error, {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
sourceCompanyId: reset.source_company_id,
|
||||
})
|
||||
return privateNoStore(
|
||||
errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function formatDateStamp(date: Date): string {
|
||||
const year = date.getUTCFullYear()
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getUTCDate()).padStart(2, '0')
|
||||
return `${year}${month}${day}`
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CompanyMigrationResetSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { CompanyMigrationResetRpcResult } from '@/types'
|
||||
|
||||
type Params = { params: Promise<{ id: string }> }
|
||||
|
||||
const EXPECTED_CODES = new Set([
|
||||
'COMPANY_RESET_NOT_FOUND',
|
||||
'COMPANY_RESET_FORBIDDEN',
|
||||
'COMPANY_RESET_INELIGIBLE',
|
||||
'COMPANY_RESET_CONFIRMATION_MISMATCH',
|
||||
'COMPANY_RESET_REASON_INVALID',
|
||||
'COMPANY_RESET_CONFIRMATION_REQUIRED',
|
||||
])
|
||||
|
||||
function rpcFailure(
|
||||
result: CompanyMigrationResetRpcResult,
|
||||
log: Parameters<typeof errorResponseFromCode>[1],
|
||||
requestId: string,
|
||||
) {
|
||||
const code = result.code && EXPECTED_CODES.has(result.code)
|
||||
? result.code
|
||||
: 'COMPANY_RESET_FAILED'
|
||||
return errorResponseFromCode(code, log, {
|
||||
requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/company/[id]/migration-reset
|
||||
*
|
||||
* Returns the owner-only, fail-closed eligibility preview. The execution RPC
|
||||
* rechecks every condition, so this response is informational only.
|
||||
*/
|
||||
export const GET = withRouteContext<Params>(
|
||||
'company.migration-reset.preview',
|
||||
async (_request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
if (id !== companyId) {
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId }))
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.rpc(
|
||||
'get_company_migration_reset_eligibility',
|
||||
{ p_company_id: companyId },
|
||||
)
|
||||
|
||||
if (error) {
|
||||
log.error('migration reset eligibility RPC failed', error)
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }))
|
||||
}
|
||||
|
||||
const result = data as CompanyMigrationResetRpcResult | null
|
||||
if (!result?.ok) {
|
||||
return privateNoStore(rpcFailure(result ?? { ok: false }, log, requestId))
|
||||
}
|
||||
|
||||
return privateNoStore(NextResponse.json({ data: result.eligibility }))
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/company/[id]/migration-reset
|
||||
*
|
||||
* Atomically archives the source company and creates a clean active company.
|
||||
* No source accounting record is deleted, detached, renumbered, or copied.
|
||||
*/
|
||||
export const POST = withRouteContext<Params>(
|
||||
'company.migration-reset.execute',
|
||||
async (request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
if (id !== companyId) {
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId }))
|
||||
}
|
||||
|
||||
const validation = await validateBody(request, CompanyMigrationResetSchema, {
|
||||
log,
|
||||
operation: 'company.migration-reset.execute',
|
||||
})
|
||||
if (!validation.success) return privateNoStore(validation.response)
|
||||
|
||||
const body = validation.data
|
||||
const { data, error } = await supabase.rpc('reset_company_for_migration', {
|
||||
p_company_id: companyId,
|
||||
p_confirmed_name: body.confirm_name,
|
||||
p_reason: body.reason,
|
||||
p_confirm_no_filed_declarations: body.confirm_no_filed_declarations,
|
||||
p_confirm_retained_archive: body.confirm_retained_archive,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
log.error('migration reset RPC failed', error)
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }))
|
||||
}
|
||||
|
||||
const result = data as CompanyMigrationResetRpcResult | null
|
||||
if (!result?.ok) {
|
||||
return privateNoStore(rpcFailure(result ?? { ok: false }, log, requestId))
|
||||
}
|
||||
if (!result.replacement_company_id) {
|
||||
log.error('migration reset RPC returned no replacement company id')
|
||||
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }))
|
||||
}
|
||||
|
||||
const response = NextResponse.json({
|
||||
data: {
|
||||
resetId: result.reset_id,
|
||||
sourceCompanyId: result.source_company_id,
|
||||
replacementCompanyId: result.replacement_company_id,
|
||||
archivedAt: result.archived_at,
|
||||
retainedCounts: result.counts,
|
||||
},
|
||||
})
|
||||
response.cookies.set('gnubok-company-id', result.replacement_company_id, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
})
|
||||
return privateNoStore(response)
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
Reference in New Issue
Block a user