Fix/attributes config (#926)

* fix(git): pin LF on generated extension registry and vitest snapshots

setup:extensions and vitest write these files with LF; with
core.autocrlf=true git expects CRLF and flags them as phantom
modifications on every dev/build run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route

mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt.

Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route transactions endpoints through withRouteContext

Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route SIE import and bank reconciliation through withRouteContext

Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route salary endpoints through withRouteContext

Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route report endpoints through withRouteContext

Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext

Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route documents, events, team and account endpoints through withRouteContext

Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route settings and pending-operations endpoints through withRouteContext

Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration

Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil"

Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by
upload instead of typing every ruta into the form. Extract buildFiledAmounts()
as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so
the XML file and the manual-filing PDF can never disagree. Adds the /eskd API
route, an XML option in the report export menu, and the upload button on the
manual-filing card. Strings in sv + en.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(vat): add 'vat_settlement' source type and update related components

* fix(booking): adjust search input layout and enable autofocus

* fix(vat): support 12-digit org numbers and adjust emission order for eSKD file

* fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-08 09:54:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 63e05c4eec
commit abe9ac9d8c
204 changed files with 9841 additions and 6362 deletions
+10 -4
View File
@@ -7,8 +7,10 @@ import {
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
@@ -34,7 +36,7 @@ beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
// Reset write-permission mock to default ok
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
})
@@ -45,7 +47,11 @@ function makeReq() {
describe('DELETE /api/documents/[id]', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
@@ -1,6 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
// GET /api/documents/:id/extraction-status
//
@@ -18,48 +17,43 @@ import { requireCompanyId } from '@/lib/company/context'
// stays untouched indefinitely). Client times out and shows
// a quiet fallback. We don't distinguish this from running
// server-side: the client decides based on elapsed time.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'document.extraction_status',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const { data, error } = await supabase
.from('document_attachments')
.select('id, extracted_at, extracted_data, extraction_model')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
const { data, error } = await supabase
.from('document_attachments')
.select('id, extracted_at, extracted_data, extraction_model')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const extractedAt = data.extracted_at as string | null
const extractedData = data.extracted_data as Record<string, unknown> | null
const model = data.extraction_model as string | null
const extractedAt = data.extracted_at as string | null
const extractedData = data.extracted_data as Record<string, unknown> | null
const model = data.extraction_model as string | null
let status: 'running' | 'succeeded' | 'failed' | 'unsupported'
if (!extractedAt) {
status = 'running'
} else if (extractedData) {
status = 'succeeded'
} else if (model?.startsWith('skipped:')) {
status = 'unsupported'
} else {
status = 'failed'
}
let status: 'running' | 'succeeded' | 'failed' | 'unsupported'
if (!extractedAt) {
status = 'running'
} else if (extractedData) {
status = 'succeeded'
} else if (model?.startsWith('skipped:')) {
status = 'unsupported'
} else {
status = 'failed'
return NextResponse.json({
data: {
id: data.id,
status,
extracted_at: extractedAt,
extraction_model: model,
},
})
}
return NextResponse.json({
data: {
id: data.id,
status,
extracted_at: extractedAt,
extraction_model: model,
},
})
}
)
+63 -86
View File
@@ -1,8 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { deleteDocument } from '@/lib/core/documents/document-service'
import { eventBus } from '@/lib/events'
@@ -12,62 +10,52 @@ ensureInitialized()
* GET /api/documents/:id
* Fetch document metadata + signed download URL (60 min expiry)
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'document.get',
async (_request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
// Fetch document record
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Create signed download URL (60 minutes)
const { data: signedUrl, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(doc.storage_path, 3600)
if (signError) {
return NextResponse.json(
{ error: `Failed to create download URL: ${signError.message}` },
{ status: 500 }
)
}
await eventBus.emit({
type: 'document.accessed',
payload: {
document: { id: doc.id, file_name: doc.file_name },
userId: user.id,
companyId,
},
})
return NextResponse.json({
data: {
...doc,
download_url: signedUrl.signedUrl,
},
})
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
// Fetch document record
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Create signed download URL (60 minutes)
const { data: signedUrl, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(doc.storage_path, 3600)
if (signError) {
return NextResponse.json(
{ error: `Failed to create download URL: ${signError.message}` },
{ status: 500 }
)
}
await eventBus.emit({
type: 'document.accessed',
payload: {
document: { id: doc.id, file_name: doc.file_name },
userId: user.id,
companyId,
},
})
return NextResponse.json({
data: {
...doc,
download_url: signedUrl.signedUrl,
},
})
}
)
/**
* DELETE /api/documents/:id
@@ -76,37 +64,26 @@ export async function GET(
* BFL 7 kap 2§ and must be retained for 7 years. For linked docs the caller
* should use POST /api/documents/:id/versions to supersede via a new version.
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'document.delete',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await deleteDocument(supabase, companyId, id)
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (!result.ok) {
return NextResponse.json({ error: result.message }, { status: result.status })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
try {
const result = await deleteDocument(supabase, companyId, id)
if (!result.ok) {
return NextResponse.json({ error: result.message }, { status: result.status })
return NextResponse.json({ data: { id: result.document.id, deleted: true } })
} catch (error) {
console.error('[documents/DELETE] Failed to delete document:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to delete document' },
{ status: 500 }
)
}
return NextResponse.json({ data: { id: result.document.id, deleted: true } })
} catch (error) {
console.error('[documents/DELETE] Failed to delete document:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to delete document' },
{ status: 500 }
)
}
}
},
{ requireWrite: true }
)
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const mockVerifyIntegrity = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
verifyIntegrity: (...args: unknown[]) => mockVerifyIntegrity(...args),
}))
import { POST } from '../route'
import { requireWritePermission } from '@/lib/auth/require-write'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function makeReq() {
return new Request('http://localhost/api/documents/doc-1/verify', { method: 'POST' })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
})
describe('POST /api/documents/[id]/verify', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 403 when caller has read-only role', async () => {
vi.mocked(requireWritePermission).mockResolvedValue({
ok: false,
response: NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
),
})
const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(403)
expect(mockVerifyIntegrity).not.toHaveBeenCalled()
})
it('returns the integrity result on success', async () => {
mockVerifyIntegrity.mockResolvedValue({ verified: true, hash_matches: true })
const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{ data: { verified: boolean } }>(res)
expect(status).toBe(200)
expect(body.data.verified).toBe(true)
expect(mockVerifyIntegrity).toHaveBeenCalledWith(mockSupabase, 'company-1', 'doc-1')
})
})
+18 -32
View File
@@ -1,9 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { verifyIntegrity } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -11,34 +9,22 @@ ensureInitialized()
* POST /api/documents/:id/verify
* Verify document integrity by re-computing SHA-256 and comparing
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'document.verify',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
try {
const result = await verifyIntegrity(supabase, companyId, id)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
try {
const result = await verifyIntegrity(supabase, companyId, id)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[documents/verify/POST] Verification failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Verification failed' },
{ status: 500 }
)
}
}
return NextResponse.json({ data: result })
} catch (error) {
console.error('[documents/verify/POST] Verification failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Verification failed' },
{ status: 500 }
)
}
},
{ requireWrite: true }
)
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const mockCreateNewVersion = vi.fn()
const mockValidateDocumentFile = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
createNewVersion: (...args: unknown[]) => mockCreateNewVersion(...args),
validateDocumentFile: (...args: unknown[]) => mockValidateDocumentFile(...args),
}))
import { POST } from '../route'
import { requireWritePermission } from '@/lib/auth/require-write'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function makeReq(withFile = true) {
const form = new FormData()
if (withFile) {
form.append('file', new File(['content'], 'kvitto.pdf', { type: 'application/pdf' }))
}
return new Request('http://localhost/api/documents/doc-1/versions', {
method: 'POST',
body: form,
})
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
mockValidateDocumentFile.mockReturnValue(null)
})
describe('POST /api/documents/[id]/versions', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 403 when caller has read-only role', async () => {
vi.mocked(requireWritePermission).mockResolvedValue({
ok: false,
response: NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
),
})
const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(403)
expect(mockCreateNewVersion).not.toHaveBeenCalled()
})
it('returns 400 when no file is provided', async () => {
const res = await POST(makeReq(false), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('No file provided')
})
it('creates a new version on the happy path', async () => {
mockCreateNewVersion.mockResolvedValue({ id: 'doc-2', version: 2 })
const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{ data: { id: string; version: number } }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ id: 'doc-2', version: 2 })
expect(mockCreateNewVersion).toHaveBeenCalledWith(
mockSupabase,
'user-1',
'doc-1',
expect.objectContaining({ name: 'kvitto.pdf', type: 'application/pdf' }),
)
})
})
+63 -87
View File
@@ -1,9 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createNewVersion, validateDocumentFile } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -14,102 +12,80 @@ ensureInitialized()
* Accepts multipart/form-data with:
* - file: The new version file
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'document.versions.create',
async (request, { supabase, user }, { params }) => {
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const validationError = validateDocumentFile({ size: file.size, type: file.type })
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 })
}
const companyId = await requireCompanyId(supabase, user.id)
const buffer = await file.arrayBuffer()
const { id } = await params
const newVersion = await createNewVersion(supabase, user.id, id, {
name: file.name,
buffer,
type: file.type,
})
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
return NextResponse.json({ data: newVersion })
} catch (error) {
console.error('[documents/versions/POST] Version creation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Version creation failed' },
{ status: 500 }
)
}
const validationError = validateDocumentFile({ size: file.size, type: file.type })
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 })
}
const buffer = await file.arrayBuffer()
const newVersion = await createNewVersion(supabase, user.id, id, {
name: file.name,
buffer,
type: file.type,
})
return NextResponse.json({ data: newVersion })
} catch (error) {
console.error('[documents/versions/POST] Version creation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Version creation failed' },
{ status: 500 }
)
}
}
},
{ requireWrite: true }
)
/**
* GET /api/documents/:id/versions
* List all versions in the document chain
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'document.versions.list',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
// First, check if the document belongs to the company
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, original_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// The root document is either the original_id or the document itself
const rootId = doc.original_id || doc.id
// Fetch all versions in the chain
const { data: versions, error: versionsError } = await supabase
.from('document_attachments')
.select('*')
.eq('company_id', companyId)
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
.order('version', { ascending: true })
if (versionsError) {
return NextResponse.json({ error: versionsError.message }, { status: 500 })
}
return NextResponse.json({ data: versions })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
// First, check if the document belongs to the company
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, original_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// The root document is either the original_id or the document itself
const rootId = doc.original_id || doc.id
// Fetch all versions in the chain
const { data: versions, error: versionsError } = await supabase
.from('document_attachments')
.select('*')
.eq('company_id', companyId)
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
.order('version', { ascending: true })
if (versionsError) {
return NextResponse.json({ error: versionsError.message }, { status: 500 })
}
return NextResponse.json({ data: versions })
}
)