Fix/original sie export (#279)

* feat: cloud backup to Google Drive + full-archive all-scope

Adds a cloud-backup extension that uploads a full-company backup ZIP to
the user's own Google Drive via OAuth (drive.file scope only). Refresh
tokens are AES-256-GCM encrypted before being stored in extension_data.

The full-archive export gains a scope=all mode for whole-company
backups (per-period SIE under sie/, per-period rapporter/ subfolders,
flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size
guard short-circuits generation before the platform response limit.

Also fixes a latent bug in lib/core/audit/audit-service.ts where the
parameter was named userId while the query filtered by company_id; the
audit-trail API route was passing user.id so audit queries returned
empty unless user and company shared a UUID.

Drive-by: scope the dashboard "fresh start" localStorage key per
companyId so dismissing the setup checklist in one company no longer
carries over to others.

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

* fix: address review comments on cloud backup + archive export

- Extend audit trail to_date to end-of-day so last-day entries aren't
  silently excluded from period-scoped archives.
- Apply 413 size-limit guard regardless of include_documents, using the
  overhead-only figure when documents are excluded.
- Use crypto.randomUUID() for Drive multipart boundary to eliminate any
  collision risk with ZIP payload bytes.

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

* fix: migrate legacy setup-gate localStorage keys on dashboard

Users who previously dismissed the setup checklist via the old global
erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after
the switch to a company-scoped key. Fall back to the legacy keys on read
and migrate them to the scoped key on first hit.

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

* fix: update customer email handling and anonymization rules in supportmail-to-ticket skill

* test: update audit trail to_date expectation for end-of-day timestamp

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

* fix: enhance full archive generation to include SIE source files and master data dumps

* Update lib/reports/full-archive-export.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Mattsson
2026-04-20 12:33:58 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 greptile-apps[bot]
parent 23664e79cb
commit c0b74d50e4
2 changed files with 340 additions and 4 deletions
@@ -307,6 +307,117 @@ describe('generateFullArchive', () => {
generateFullArchive(supabase as any, 'company-1', { scope: 'all' })
).rejects.toThrow('No fiscal periods found')
})
it('includes imported SIE source files and master-data dumps in all-mode', async () => {
const importRow = {
id: 'import-1',
filename: 'original.se',
file_hash: 'abc123',
file_storage_path: 'company-1/import-1.se',
org_number: '5560000000',
company_name: 'Test AB',
sie_type: 4,
fiscal_year_start: '2024-01-01',
fiscal_year_end: '2024-12-31',
accounts_count: 42,
transactions_count: 120,
status: 'completed',
fiscal_period_id: PERIOD_2024.id,
imported_at: '2024-11-01T10:00:00Z',
created_at: '2024-11-01T09:55:00Z',
}
enqueueMany([
{ data: COMPANY_ROW }, // fetchCompany
{ data: [PERIOD_2024] }, // fetchAllPeriods
{ data: [] }, // document_attachments
{ data: [importRow] }, // sie_imports
{ data: [{ source_account: '9999', target_account: '1510' }] }, // sie_account_mappings
{ data: [{ id: 'cust-1', name: 'Acme AB' }] }, // customers
{ data: [{ id: 'sup-1', name: 'Supplier AB' }] }, // suppliers
{ data: [{ id: 'inv-1', invoice_number: 'F-001' }] }, // invoices
{ data: [] }, // invoice_items
{ data: [] }, // invoice_payments
{ data: [] }, // supplier_invoices
{ data: [] }, // supplier_invoice_items
{ data: [] }, // receipts
{ data: [] }, // receipt_line_items
{ data: [] }, // transactions
{ data: [] }, // mapping_rules
{ data: [] }, // categorization_templates
{ data: [] }, // bank_file_imports
{ data: [COMPANY_ROW] }, // company_settings
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const originalFile = zip.file('sie/original/import-1_original.se')
expect(originalFile).not.toBeNull()
const manifestFile = zip.file('sie/original/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest[0].import_id).toBe('import-1')
expect(manifest[0].status).toBe('downloaded')
const imports = JSON.parse(await zip.file('sie/imports.json')!.async('text'))
expect(imports[0].filename).toBe('original.se')
expect(zip.file('sie/account_mappings.json')).not.toBeNull()
expect(zip.file('data/customers.json')).not.toBeNull()
expect(zip.file('data/suppliers.json')).not.toBeNull()
expect(zip.file('data/invoices.json')).not.toBeNull()
expect(zip.file('data/invoice_items.json')).not.toBeNull()
expect(zip.file('data/invoice_payments.json')).not.toBeNull()
expect(zip.file('data/supplier_invoices.json')).not.toBeNull()
expect(zip.file('data/supplier_invoice_items.json')).not.toBeNull()
expect(zip.file('data/receipts.json')).not.toBeNull()
expect(zip.file('data/receipt_line_items.json')).not.toBeNull()
expect(zip.file('data/transactions.json')).not.toBeNull()
expect(zip.file('data/mapping_rules.json')).not.toBeNull()
expect(zip.file('data/categorization_templates.json')).not.toBeNull()
expect(zip.file('data/bank_file_imports.json')).not.toBeNull()
expect(zip.file('data/company_settings.json')).not.toBeNull()
const customers = JSON.parse(await zip.file('data/customers.json')!.async('text'))
expect(customers).toEqual([{ id: 'cust-1', name: 'Acme AB' }])
})
it('skips raw SIE blobs when include_documents is false but keeps metadata', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{
data: [
{
id: 'import-1',
filename: 'x.se',
file_hash: 'h',
file_storage_path: 'company-1/import-1.se',
status: 'completed',
imported_at: '2024-11-01T10:00:00Z',
created_at: '2024-11-01T09:55:00Z',
},
],
}, // sie_imports
{ data: [] }, // sie_account_mappings
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
include_documents: false,
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('sie/imports.json')).not.toBeNull()
expect(zip.file('sie/account_mappings.json')).not.toBeNull()
expect(zip.file('sie/original/import-1_x.se')).toBeNull()
expect(zip.file('sie/original/manifest.json')).toBeNull()
expect(zip.file('data/customers.json')).not.toBeNull()
})
})
})
@@ -336,8 +447,8 @@ describe('estimateArchiveSize', () => {
expect(result.document_bytes).toBe(3_500_000)
expect(result.document_count).toBe(2)
// overhead is +5 MB
expect(result.total_bytes).toBe(3_500_000 + 5 * 1024 * 1024)
// overhead is +8 MB
expect(result.total_bytes).toBe(3_500_000 + 8 * 1024 * 1024)
})
it('returns overhead only when no documents in scope', async () => {
@@ -350,6 +461,6 @@ describe('estimateArchiveSize', () => {
expect(result.document_bytes).toBe(0)
expect(result.document_count).toBe(0)
expect(result.total_bytes).toBe(5 * 1024 * 1024)
expect(result.total_bytes).toBe(8 * 1024 * 1024)
})
})
+226 -1
View File
@@ -70,7 +70,10 @@ interface PeriodReports {
}
const REPORT_CONCURRENCY = 3
const ARCHIVE_OVERHEAD_BYTES = 5 * 1024 * 1024
// 5 MB for SIE + reports + audit + system doc, +3 MB headroom for master-data
// JSON dumps and raw imported SIE files (the bucket caps each file at 50 MB,
// but typical SIE4 files are tens of KB so a few MB covers most companies).
const ARCHIVE_OVERHEAD_BYTES = 8 * 1024 * 1024
/**
* Generate a full archive ZIP for a company.
@@ -144,6 +147,11 @@ export async function generateFullArchive(
await writeDocuments(zip, supabase, companyId, periods, options.scope)
}
if (options.scope === 'all') {
await writeSieSourceFiles(zip, supabase, companyId, options.include_documents !== false)
await writeMasterData(zip, supabase, companyId)
}
const revision = zip.folder('revision')!
const auditFilters =
@@ -388,6 +396,223 @@ async function writeDocuments(
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
interface SieImportRow {
id: string
filename: string | null
file_hash: string | null
file_storage_path: string | null
org_number: string | null
company_name: string | null
sie_type: number | null
fiscal_year_start: string | null
fiscal_year_end: string | null
accounts_count: number | null
transactions_count: number | null
status: string | null
fiscal_period_id: string | null
imported_at: string | null
created_at: string | null
}
interface SieSourceManifestEntry {
import_id: string
filename: string | null
storage_path: string | null
sha256_hash: string | null
sie_type: number | null
fiscal_year_start: string | null
fiscal_year_end: string | null
imported_at: string | null
status: 'downloaded' | 'missing' | 'skipped'
zip_file_name: string | null
error?: string
}
/**
* Copy raw imported SIE files from the `sie-files` storage bucket into the
* archive under `sie/original/`. Preserves the byte-identical source that the
* user uploaded (vs the `sie/<period>.se` files which gnubok re-generates from
* the current journal entries).
*
* `sie/imports.json` and `sie/account_mappings.json` are written regardless of
* `includeFiles` — they're small and critical for reconstructing the import
* history. Blob download is gated behind `includeFiles` since the files can be
* large and share the documents opt-out.
*/
async function writeSieSourceFiles(
zip: JSZip,
supabase: SupabaseClient,
companyId: string,
includeFiles: boolean
): Promise<void> {
const sieFolder = zip.folder('sie')!
try {
const imports = await fetchAllRows<SieImportRow>(({ from, to }) =>
supabase
.from('sie_imports')
.select(
'id, filename, file_hash, file_storage_path, org_number, company_name, sie_type, fiscal_year_start, fiscal_year_end, accounts_count, transactions_count, status, fiscal_period_id, imported_at, created_at'
)
.eq('company_id', companyId)
.order('created_at', { ascending: true })
.range(from, to)
)
sieFolder.file('imports.json', JSON.stringify(imports, null, 2))
const manifest: SieSourceManifestEntry[] = []
if (includeFiles && imports.length > 0) {
const originalFolder = sieFolder.folder('original')!
for (const imp of imports) {
if (!imp.file_storage_path) {
manifest.push({
import_id: imp.id,
filename: imp.filename,
storage_path: null,
sha256_hash: imp.file_hash,
sie_type: imp.sie_type,
fiscal_year_start: imp.fiscal_year_start,
fiscal_year_end: imp.fiscal_year_end,
imported_at: imp.imported_at,
status: 'skipped',
zip_file_name: null,
error: 'No storage path on record',
})
continue
}
const zipFileName = `${imp.id}_${sanitizeFileName(imp.filename || `${imp.id}.se`)}`
try {
const { data: fileData, error } = await supabase.storage
.from('sie-files')
.download(imp.file_storage_path)
if (error || !fileData) {
manifest.push({
import_id: imp.id,
filename: imp.filename,
storage_path: imp.file_storage_path,
sha256_hash: imp.file_hash,
sie_type: imp.sie_type,
fiscal_year_start: imp.fiscal_year_start,
fiscal_year_end: imp.fiscal_year_end,
imported_at: imp.imported_at,
status: 'missing',
zip_file_name: null,
error: error?.message || 'Download returned no data',
})
continue
}
const buffer = await fileData.arrayBuffer()
originalFolder.file(zipFileName, buffer)
manifest.push({
import_id: imp.id,
filename: imp.filename,
storage_path: imp.file_storage_path,
sha256_hash: imp.file_hash,
sie_type: imp.sie_type,
fiscal_year_start: imp.fiscal_year_start,
fiscal_year_end: imp.fiscal_year_end,
imported_at: imp.imported_at,
status: 'downloaded',
zip_file_name: zipFileName,
})
} catch (err) {
manifest.push({
import_id: imp.id,
filename: imp.filename,
storage_path: imp.file_storage_path,
sha256_hash: imp.file_hash,
sie_type: imp.sie_type,
fiscal_year_start: imp.fiscal_year_start,
fiscal_year_end: imp.fiscal_year_end,
imported_at: imp.imported_at,
status: 'missing',
zip_file_name: null,
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
originalFolder.file('manifest.json', JSON.stringify(manifest, null, 2))
}
const mappings = await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
supabase
.from('sie_account_mappings')
.select('*')
.eq('company_id', companyId)
.range(from, to)
)
sieFolder.file('account_mappings.json', JSON.stringify(mappings, null, 2))
} catch {
// SIE metadata fetch failed — archive will still contain the re-generated SIE files
}
}
/**
* Dump structured master data as JSON under `data/`. These records are implicit
* in the SIE export (as journal entries) but not recoverable as domain objects
* without this dump — critical for disaster recovery of a company's state.
*/
async function writeMasterData(
zip: JSZip,
supabase: SupabaseClient,
companyId: string
): Promise<void> {
const data = zip.folder('data')!
const tables: Array<{ name: string; file: string; orderBy?: string }> = [
{ name: 'customers', file: 'customers.json', orderBy: 'created_at' },
{ name: 'suppliers', file: 'suppliers.json', orderBy: 'created_at' },
{ name: 'invoices', file: 'invoices.json', orderBy: 'invoice_date' },
{ name: 'invoice_items', file: 'invoice_items.json' },
{ name: 'invoice_payments', file: 'invoice_payments.json', orderBy: 'payment_date' },
{ name: 'supplier_invoices', file: 'supplier_invoices.json', orderBy: 'invoice_date' },
{ name: 'supplier_invoice_items', file: 'supplier_invoice_items.json' },
{ name: 'receipts', file: 'receipts.json', orderBy: 'receipt_date' },
{ name: 'receipt_line_items', file: 'receipt_line_items.json' },
{ name: 'transactions', file: 'transactions.json', orderBy: 'booking_date' },
{ name: 'mapping_rules', file: 'mapping_rules.json' },
{ name: 'categorization_templates', file: 'categorization_templates.json' },
{ name: 'bank_file_imports', file: 'bank_file_imports.json', orderBy: 'created_at' },
{ name: 'company_settings', file: 'company_settings.json' },
]
await Promise.all(
tables.map(async (t) => {
try {
const rows = await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
let q = supabase.from(t.name).select('*').eq('company_id', companyId)
if (t.orderBy) {
q = q.order(t.orderBy, { ascending: true })
}
return q.range(from, to)
})
data.file(t.file, JSON.stringify(rows, null, 2))
} catch (err) {
data.file(
t.file,
JSON.stringify(
{ error: err instanceof Error ? err.message : 'Fetch failed', rows: [] },
null,
2
)
)
}
})
)
}
function sanitizeFileName(name: string): string {
return name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 120)
}
async function buildEntryToPeriodMap(
supabase: SupabaseClient,
companyId: string,