diff --git a/.claude/skills/supportmail-to-ticket/SKILL.md b/.claude/skills/supportmail-to-ticket/SKILL.md new file mode 100644 index 00000000..ff17fcdd --- /dev/null +++ b/.claude/skills/supportmail-to-ticket/SKILL.md @@ -0,0 +1,248 @@ +--- +name: supportmail-to-ticket +description: "Triage Gnubok customer support emails and turn them into GitHub issues in the erp-mafia/gnubok repo. Use this skill whenever the user invokes /supportmail-to-ticket (with or without a number argument), or asks to 'triage support mail', 'turn support emails into tickets', 'process gnubok support', 'check the support inbox and file issues', or any similar phrasing involving the Gnubok support mailbox. Also trigger this skill if the user mentions [gnubok support] emails and wants them converted into actionable work — even if they don't use the exact slash command." +--- + +# supportmail-to-ticket + +Triage `[gnubok support]` emails from Gmail, cross-reference them against the local erp-base codebase, and draft GitHub issues for the `erp-mafia/gnubok` repo — with inline user approval before anything gets created. + +## Invocation + +Primary form: + +``` +/supportmail-to-ticket [N] +``` + +- `N` = number of most recent `[gnubok support]` threads to triage. Optional. Default: `3`. +- Examples: `/supportmail-to-ticket`, `/supportmail-to-ticket 10`, `/supportmail-to-ticket 1` + +If the user phrases the request in natural language ("triage the last 5 support mails", "check the inbox"), extract the number if present, otherwise use `3`. + +## Required tools + +Before running anything, verify these are available. If any is missing, stop and tell the user which one to configure — do not attempt workarounds. + +- **Gmail MCP** — `search_threads`, `get_thread` +- **GitHub CLI (`gh`)** — used via `bash_tool` for all GitHub operations. Verify with `gh --version` and `gh auth status`. If either fails, stop and say: *"I need the GitHub CLI (gh) installed and authenticated. Install from https://cli.github.com/ and run `gh auth login`, then retry. This skill uses gh as a hard requirement — there is no MCP fallback."* Do not proceed without it. +- **Filesystem MCP** pointing at `C:\Users\emilm\projects\erp-base` — the skill is explicitly designed around local code search. If the filesystem tool is not available or the path doesn't resolve, stop and say: *"I need the Filesystem MCP configured with access to `C:\Users\emilm\projects\erp-base`. Please set it up in Claude Desktop's MCP config and retry."* Do not fall back to remote code search — the user has asked for local-only. + +## Workflow + +Follow these five phases in order. Do not skip phase 4 (approval). + +### Phase 1 — Fetch emails + +Call Gmail `search_threads` with: + +- `query`: `subject:"[gnubok support]"` +- `pageSize`: the requested N (default 3) + +For each returned thread, call `get_thread` with `messageFormat: FULL_CONTENT` to retrieve the full body. Extract per thread: + +- `threadId` +- `subject` +- Sender of the first message (the original customer — note that `invoiceservice@arcim.io` is a relay; the actual customer address is in the body as `Från: ` for Swedish or directly visible in the body text) +- Date of the first message +- Full body text of the first message (this is the customer's actual complaint) +- Any reply messages (if the team has already responded, mention that but still propose a ticket unless the reply clearly resolves it) + +**Customer email extraction**: The Gnubok support relay wraps the original mail. The body typically starts with `Från: ` (Swedish) — parse this line for the real customer email. Fall back to the thread's first-message sender if parsing fails. + +### Phase 2 — Codebase analysis (local only) + +For each email, analyze the complaint and search the local codebase at `C:\Users\emilm\projects\erp-base`. + +**Step 2a — Extract search terms from the email.** From the customer's message, pull: + +- Domain nouns (e.g., "SIE", "import", "bank", "fiscal year", "bokslut", "moms", "verifikat") +- Quoted error strings or UI labels +- Feature names the customer references + +The emails are often in Swedish. Translate/expand Swedish terms to likely code identifiers (examples: `importera` → `import`, `räkenskapsår` → `fiscalYear`/`fiscal_year`, `bank` → `bank`, `SIE-fil` → `sie`/`SIE`, `verifikat` → `verification`/`voucher`, `moms` → `vat`/`tax`). + +**Step 2b — Search.** Use the filesystem tool to run targeted searches. Prefer grep-style or directory reads over reading whole files. For each search term: + +- Search filenames and paths first (fast, high signal) +- Then search file contents, case-insensitive +- Focus on `.ts`, `.tsx`, `.js`, `.py`, `.go`, `.rs`, `.java`, `.kt` — whatever extensions actually exist in the repo (check with a directory listing first if unsure) +- Skip `node_modules`, `dist`, `build`, `.git`, `.next`, `target` + +Collect up to ~5 most relevant file paths per email, with a line number or function name if you can identify one. Don't dump search results wholesale — synthesize. + +**Step 2c — Severity heuristic.** + +- **high**: core function broken (import fails completely, data loss risk, cannot log in, money/accounting math wrong, multiple users reporting same issue in this batch) +- **medium**: feature partially broken, workaround exists, affects one customer, UX friction in a common flow +- **low**: cosmetic, feature request, documentation gap, edge case +- **feature**: customer is asking for something that doesn't exist yet (use label `feature`, priority is less relevant) + +### Phase 3 — Draft tickets + +For each email, produce one draft issue. Output format per ticket (inline in chat): + +``` +━━━ Ticket 1 of N ━━━ +Title: +Labels: +Priority: + +Description: +<2–4 sentence summary of the customer's problem in your own words. State the observed behavior and expected behavior if you can infer it.> + +Relevant code: +- path/to/file.ts:line — short note on why this is relevant +- path/to/other.ts — short note +(If nothing found locally, say "No direct match in codebase — investigation needed" and suggest where to start looking.) + +Next steps: +- Concrete first thing a developer should do +- Second concrete step +- (2–4 steps total, specific not vague) + +Customer email (anonymized): + + +Customer: +Gmail thread ID: +``` + +**Anonymization rules — applied to the email body before it goes into the ticket**: + +- **Replace personal first and last names with `x`**. Example: *"Hey this is amazing. My name is Emil and I do bla bla"* → *"Hey this is amazing. My name is x and I do bla bla"*. Handles greetings (*"Hej Anna,"* → *"Hej x,"*) and signatures (*"/Lars Andersson"* → *"/x"*). +- **Keep company names, product names, domain terms, error messages, account numbers, SIE references, dates, and amounts.** These are operational details developers need. Only personal names get redacted. +- **Keep the customer's email address in the `Customer:` metadata line** (outside the anonymized body). The team needs it to reply; developers generally don't read metadata to learn names. +- **Keep the Gmail thread ID** as a plain identifier (no URL). It's used for duplicate detection across runs. +- If unsure whether something is a personal name, redact it — false positives are harmless, leaked names aren't. + +**Priority label convention**: use `priority:high`, `priority:medium`, `priority:low`. If the repo already has `P0`/`P1`/`P2` labels (check in phase 4), prefer those instead. + +**Duplicate check (before presenting)**: For each draft, run: + +```bash +gh issue list --repo erp-mafia/gnubok --state open --limit 100 --json number,title,body,url +``` + +Parse the JSON output and scan titles + bodies for: + +- The Gmail thread ID (exact match → definitely a duplicate) +- Overlapping key nouns from the title (likely duplicate → flag, don't auto-skip) + +If a duplicate is found, replace that ticket block with: + +``` +━━━ Ticket N of M ━━━ [DUPLICATE] +Matches existing issue: # — +URL: +Gmail thread ID: +Suggested action: add a comment to the existing issue linking this new customer report. +``` + +Run the `gh issue list` call **once** at the start of phase 3 and reuse its results across all drafts — don't call it per ticket. + +### Phase 4 — Approval (inline, required) + +After presenting all drafts, ask: + +> Reply with your decisions per ticket. Examples: +> - `1 approve, 2 approve, 3 reject` +> - `all approve` +> - `1 approve but change title to "Fix bank import for Swedbank"` +> - `2 edit: change priority to high and add label "error"` +> - `3 comment on existing #42 instead of new issue` +> +> I'll wait for your reply before creating anything on GitHub. + +Wait for the user's response. **Do not create issues until they reply.** If the user's reply is ambiguous, ask specifically rather than guessing. + +Parse their response per ticket. Apply edits to the draft. If they reject a ticket, drop it silently. If they ask to comment on an existing issue instead of creating new, add that to the action list. + +### Phase 5 — Create on GitHub + +For each approved ticket, use the `gh` CLI via `bash_tool`. + +**Creating a new issue.** Write the body to a temp file first (avoids shell-escaping pain with multi-line content and special characters), then pass it via `--body-file`: + +```bash +# Write the body to a temp file +cat > /tmp/issue-body-.md <<'EOF' + + +## Relevant code +- path/to/file.ts:line — note +- path/to/other.ts — note + +## Next steps +- Step 1 +- Step 2 +- Step 3 + +## Customer email (anonymized) + +> + +--- +**Customer:** +**Gmail thread ID:** `` +EOF + +# Create the issue +gh issue create \ + --repo erp-mafia/gnubok \ + --title "" \ + --body-file /tmp/issue-body-.md \ + --label "" --label "" +``` + +The command prints the new issue's URL on success — capture it for the summary. Use `--json number,url` + `gh issue create ... | cat` if you need to parse the result programmatically; otherwise the stdout URL is fine. + +**Commenting on an existing issue** (for duplicates where the user chose "comment on existing"): + +```bash +gh issue comment \ + --repo erp-mafia/gnubok \ + --body "Another customer report of this issue. Customer: \`\`. Gmail thread ID: \`\`." +``` + +**Label notes**: + +- Available labels in this skill: `bug`, `feature`, `report`, `improvement`, `error`, plus priority (`priority:high`, `priority:medium`, `priority:low`). +- If `gh issue create` fails with an error mentioning an unknown label (exit code non-zero, stderr contains `"could not add label"` or `"not found"`), retry the command without that `--label` flag and tell the user which labels are missing so they can create them manually — do **not** attempt to create labels automatically. +- You can check available labels once at the start of phase 5 with: `gh label list --repo erp-mafia/gnubok --limit 100 --json name` — useful if multiple label errors happen in a row. + +**Project board**: Issues are created in the `erp-mafia/gnubok` repo. The Gnubok project board (`erp-mafia/projects/...`) aggregates issues but adding to a project via `gh` requires `gh project item-add` with the project number and GraphQL scopes that may not be in the current auth token. After creating issues, output the project URL once and remind the user they may want to drag the new issues onto the board. Example wording: *"Issues created. If you want them on the Gnubok project board, you'll need to add them manually at https://github.com/orgs/erp-mafia/projects — or run `gh project item-add` if you have project scopes on your token."* + +### Phase 6 — Summary + +End with a compact summary: + +``` +Created 2 issues: +- #47 Fix SIE import failure for first fiscal year — https://github.com/erp-mafia/gnubok/issues/47 +- #48 Investigate bank import for banks without BankID — https://github.com/erp-mafia/gnubok/issues/48 + +Commented on 1 existing: +- #42 — added new customer report + +Skipped: 0 +``` + +## Error handling + +- **No emails found**: say so, don't invent any. +- **Gmail thread fetch fails for one email**: skip that one, report which, continue with the rest. +- **Filesystem MCP not configured / path not found**: stop entirely, instruct the user to set it up. Do not proceed with remote-only search. +- **`gh` not installed or not authenticated**: stop entirely at the pre-check. Do not proceed. +- **`gh issue list` fails during duplicate check**: proceed without duplicate detection and warn the user in the summary. +- **`gh issue create` fails for one ticket**: report which ticket failed, include the `gh` stderr, continue with the remaining approved ones. +- **`gh` rate-limited** (HTTP 403 with "rate limit"): stop, tell the user to wait or check `gh api rate_limit`. Don't retry in a loop. + +## Style notes for generated tickets + +- Titles are imperative ("Fix X", "Investigate Y", "Add Z"), not declarative ("X is broken"). +- Titles ≤ 80 characters. No emoji. No brackets. +- Descriptions are ≤ 4 sentences. Describe what the customer sees and what should happen instead. +- Next steps are concrete — "Check `importSie()` for silent catch blocks on line 142", not "Investigate the import code". +- Paste the customer's email body verbatim into the `Customer email (anonymized)` section, with personal names replaced by `x`. No Gmail link — the ticket should be self-contained. +- If the email is in Swedish, the summary/description/next steps are in English, but the anonymized email body stays in its original language. Keep domain terms that match the code (`SIE`, `verifikat` if the code uses that spelling, etc.). diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index a83e44f7..cd96fe83 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -231,6 +231,7 @@ export default async function DashboardPage() { return ( +
+

+ Säkerhetsbackup +

+

+ Ladda ner en egen kopia av all räkenskapsinformation — SIE-filer, kvitton, + underlag och behandlingshistorik — i en enda ZIP-fil. Säkerhetsbackupen är din + egen kopia för trygghet och portabilitet. gnubok arkiverar all + räkenskapsinformation i minst 7 år enligt BFL 7 kap. 2 §, så din backup ersätter + inte vårt lagkrav — den kompletterar det. +

+
+ + + + ) +} diff --git a/app/api/audit-trail/__tests__/route.test.ts b/app/api/audit-trail/__tests__/route.test.ts index 4fa9550b..73ca69fb 100644 --- a/app/api/audit-trail/__tests__/route.test.ts +++ b/app/api/audit-trail/__tests__/route.test.ts @@ -63,7 +63,7 @@ describe('GET /api/audit-trail', () => { expect(body.count).toBe(2) expect(mockGetAuditLog).toHaveBeenCalledWith( expect.anything(), - 'user-1', + 'company-1', expect.objectContaining({}) ) }) @@ -88,7 +88,7 @@ describe('GET /api/audit-trail', () => { expect(mockGetAuditLog).toHaveBeenCalledWith( expect.anything(), - 'user-1', + 'company-1', { action: 'INSERT', table_name: 'journal_entries', diff --git a/app/api/audit-trail/route.ts b/app/api/audit-trail/route.ts index cf5c4b3a..9548bc21 100644 --- a/app/api/audit-trail/route.ts +++ b/app/api/audit-trail/route.ts @@ -27,7 +27,7 @@ export async function GET(request: Request) { } try { - const result = await getAuditLog(supabase, user.id, filters) + const result = await getAuditLog(supabase, companyId, filters) return NextResponse.json({ data: result.data, count: result.count }) } catch (err) { return NextResponse.json( diff --git a/app/api/reports/full-archive/__tests__/route.test.ts b/app/api/reports/full-archive/__tests__/route.test.ts new file mode 100644 index 00000000..c9827654 --- /dev/null +++ b/app/api/reports/full-archive/__tests__/route.test.ts @@ -0,0 +1,208 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/reports/full-archive-export', () => ({ + generateFullArchive: vi.fn(), + estimateArchiveSize: vi.fn(), +})) + +import { createClient } from '@/lib/supabase/server' +import { + generateFullArchive, + estimateArchiveSize, +} from '@/lib/reports/full-archive-export' +import { GET } from '../route' + +const mockCreateClient = vi.mocked(createClient) +const mockGenerate = vi.mocked(generateFullArchive) +const mockEstimate = vi.mocked(estimateArchiveSize) + +function mockAuth(userId: string | null) { + mockCreateClient.mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: userId ? { id: userId } : null }, + }), + }, + } as any) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/reports/full-archive', () => { + it('returns 401 when not authenticated', async () => { + mockAuth(null) + const { status, body } = await parseJsonResponse( + await GET(createMockRequest('/api/reports/full-archive')) + ) + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns estimate-only response when ?estimate=1', async () => { + mockAuth('user-1') + mockEstimate.mockResolvedValue({ + total_bytes: 10_000_000, + document_bytes: 5_000_000, + document_count: 7, + }) + + const { status, body } = await parseJsonResponse<{ + data: { + total_bytes: number + size_limit_bytes: number + within_limit: boolean + } + }>( + await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { estimate: '1', scope: 'all' }, + }) + ) + ) + + expect(status).toBe(200) + expect(body.data.total_bytes).toBe(10_000_000) + expect(body.data.within_limit).toBe(true) + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('returns 413 archive_too_large when estimate exceeds limit', async () => { + mockAuth('user-1') + mockEstimate.mockResolvedValue({ + total_bytes: 200 * 1024 * 1024, + document_bytes: 195 * 1024 * 1024, + document_count: 200, + }) + + const response = await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { scope: 'all' }, + }) + ) + const { status, body } = await parseJsonResponse<{ + error: string + size_bytes: number + size_limit_bytes: number + }>(response) + + expect(status).toBe(413) + expect(body.error).toBe('archive_too_large') + expect(body.size_bytes).toBe(200 * 1024 * 1024) + expect(body.size_limit_bytes).toBe(80 * 1024 * 1024) + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('skips 413 when include_documents=false', async () => { + mockAuth('user-1') + mockEstimate.mockResolvedValue({ + total_bytes: 200 * 1024 * 1024, + document_bytes: 195 * 1024 * 1024, + document_count: 200, + }) + mockGenerate.mockResolvedValue(new ArrayBuffer(1024)) + + const response = await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { scope: 'all', include_documents: 'false' }, + }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/zip') + expect(mockGenerate).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + expect.objectContaining({ scope: 'all', include_documents: false }) + ) + }) + + it('defaults to scope=all when no params given', async () => { + mockAuth('user-1') + mockEstimate.mockResolvedValue({ + total_bytes: 1_000_000, + document_bytes: 500_000, + document_count: 2, + }) + mockGenerate.mockResolvedValue(new ArrayBuffer(1024)) + + const response = await GET(createMockRequest('/api/reports/full-archive')) + + expect(response.status).toBe(200) + expect(mockGenerate).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + expect.objectContaining({ scope: 'all' }) + ) + }) + + it('uses scope=period when period_id is provided without explicit scope', async () => { + mockAuth('user-1') + mockEstimate.mockResolvedValue({ + total_bytes: 1_000_000, + document_bytes: 500_000, + document_count: 2, + }) + mockGenerate.mockResolvedValue(new ArrayBuffer(1024)) + + const response = await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { period_id: 'period-1' }, + }) + ) + + expect(response.status).toBe(200) + expect(mockGenerate).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + expect.objectContaining({ scope: 'period', period_id: 'period-1' }) + ) + }) + + it('returns 400 when scope=period without period_id', async () => { + mockAuth('user-1') + const { status, body } = await parseJsonResponse( + await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { scope: 'period' }, + }) + ) + ) + expect(status).toBe(400) + expect(body).toEqual({ error: 'period_id is required when scope=period' }) + expect(mockGenerate).not.toHaveBeenCalled() + expect(mockEstimate).not.toHaveBeenCalled() + }) + + it('returns 404 when generate throws "not found"', async () => { + mockAuth('user-1') + mockEstimate.mockResolvedValue({ + total_bytes: 1_000_000, + document_bytes: 500_000, + document_count: 2, + }) + mockGenerate.mockRejectedValue(new Error('Fiscal period not found')) + + const { status, body } = await parseJsonResponse( + await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { scope: 'period', period_id: 'nope' }, + }) + ) + ) + expect(status).toBe(404) + expect(body).toEqual({ error: 'Fiscal period not found' }) + }) +}) diff --git a/app/api/reports/full-archive/route.ts b/app/api/reports/full-archive/route.ts index 19eb7cf3..69f4e3bc 100644 --- a/app/api/reports/full-archive/route.ts +++ b/app/api/reports/full-archive/route.ts @@ -1,8 +1,17 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { generateFullArchive } from '@/lib/reports/full-archive-export' +import { + generateFullArchive, + estimateArchiveSize, + type ArchiveScope, +} from '@/lib/reports/full-archive-export' import { requireCompanyId } from '@/lib/company/context' +export const runtime = 'nodejs' +export const maxDuration = 300 + +const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 + export async function GET(request: Request) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -14,23 +23,69 @@ export async function GET(request: Request) { const companyId = await requireCompanyId(supabase, user.id) const { searchParams } = new URL(request.url) + const scopeParam = searchParams.get('scope') const periodId = searchParams.get('period_id') + const estimateOnly = searchParams.get('estimate') === '1' + const includeDocuments = searchParams.get('include_documents') !== 'false' - if (!periodId) { - return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) + // Backward compat: a bare `period_id` without `scope` is treated as scope=period. + const scope: ArchiveScope = + scopeParam === 'period' || (!scopeParam && periodId) ? 'period' : 'all' + + if (scope === 'period' && !periodId) { + return NextResponse.json( + { error: 'period_id is required when scope=period' }, + { status: 400 } + ) } try { - const zipBuffer = await generateFullArchive(supabase, companyId, { - period_id: periodId, - include_documents: searchParams.get('include_documents') !== 'false', - }) + const estimate = await estimateArchiveSize( + supabase, + companyId, + scope, + scope === 'period' ? periodId! : undefined + ) + + if (estimateOnly) { + return NextResponse.json({ + data: { + ...estimate, + size_limit_bytes: SIZE_LIMIT_BYTES, + within_limit: estimate.total_bytes <= SIZE_LIMIT_BYTES, + }, + }) + } + + if (includeDocuments && estimate.total_bytes > SIZE_LIMIT_BYTES) { + return NextResponse.json( + { + error: 'archive_too_large', + size_bytes: estimate.total_bytes, + size_limit_bytes: SIZE_LIMIT_BYTES, + }, + { status: 413 } + ) + } + + const zipBuffer = await generateFullArchive( + supabase, + companyId, + scope === 'period' + ? { scope: 'period', period_id: periodId!, include_documents: includeDocuments } + : { scope: 'all', include_documents: includeDocuments } + ) + + const filename = + scope === 'period' + ? `arkiv_${periodId}.zip` + : `arkiv_full_${companyId}_${formatDateStamp(new Date())}.zip` return new NextResponse(zipBuffer, { status: 200, headers: { 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="arkiv_${periodId}.zip"`, + 'Content-Disposition': `attachment; filename="${filename}"`, }, }) } catch (err) { @@ -39,3 +94,10 @@ export async function GET(request: Request) { return NextResponse.json({ error: message }, { status }) } } + +function formatDateStamp(d: Date): string { + const y = d.getUTCFullYear() + const m = String(d.getUTCMonth() + 1).padStart(2, '0') + const day = String(d.getUTCDate()).padStart(2, '0') + return `${y}${m}${day}` +} diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 3e68c301..28a1cd4d 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -24,10 +24,11 @@ import { resolveIcon } from '@/lib/extensions/icon-resolver' import type { QuickActionDefinition } from '@/lib/extensions/types' import type { CompanySettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' -const SETUP_FRESH_START_KEY = 'erp_setup_fresh_start' +const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}` interface DashboardContentProps { firstName?: string | null + companyId: string settings: CompanySettings | null summary: { ytd: { income: number; expenses: number; net: number } @@ -49,7 +50,7 @@ interface DashboardContentProps { onboardingProgress?: OnboardingProgress } -export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) { +export default function DashboardContent({ firstName, companyId, settings, summary, onboardingProgress }: DashboardContentProps) { const [showAllAlerts, setShowAllAlerts] = useState(false) const [showMore, setShowMore] = useState(false) const [greeting, setGreeting] = useState('Hej') @@ -68,16 +69,23 @@ export default function DashboardContent({ firstName, settings, summary, onboard setSetupGateActive(false) return } - const freshStart = localStorage.getItem(SETUP_FRESH_START_KEY) === 'true' - const oldDismissed = localStorage.getItem('erp_checklist_dismissed') === 'true' - if (freshStart || oldDismissed) setSetupGateActive(false) - }, [needsSetup]) + const scopedKey = setupFreshStartKey(companyId) + const freshStart = localStorage.getItem(scopedKey) === 'true' + const legacyFreshStart = localStorage.getItem('erp_setup_fresh_start') === 'true' + const legacyDismissed = localStorage.getItem('erp_checklist_dismissed') === 'true' + if (freshStart || legacyFreshStart || legacyDismissed) { + if (!freshStart) { + localStorage.setItem(scopedKey, 'true') + } + setSetupGateActive(false) + } + }, [needsSetup, companyId]) if (setupGateActive) { return ( { - localStorage.setItem(SETUP_FRESH_START_KEY, 'true') + localStorage.setItem(setupFreshStartKey(companyId), 'true') setSetupGateActive(false) }} /> diff --git a/components/extensions/general/CloudBackupWorkspace.tsx b/components/extensions/general/CloudBackupWorkspace.tsx new file mode 100644 index 00000000..6c658910 --- /dev/null +++ b/components/extensions/general/CloudBackupWorkspace.tsx @@ -0,0 +1,25 @@ +'use client' + +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import { Button } from '@/components/ui/button' +import { Cloud, Settings } from 'lucide-react' +import Link from 'next/link' + +export default function CloudBackupWorkspace(_props: WorkspaceComponentProps) { + return ( +
+ +

Molnsynkronisering

+

+ Koppla ditt Google Drive-konto under Säkerhetsbackup för att synka arkiv till din + egen molnlagring. +

+ +
+ ) +} diff --git a/components/settings/BackupDownloadForm.tsx b/components/settings/BackupDownloadForm.tsx new file mode 100644 index 00000000..9c0b9578 --- /dev/null +++ b/components/settings/BackupDownloadForm.tsx @@ -0,0 +1,359 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { useToast } from '@/components/ui/use-toast' +import { useCompany } from '@/contexts/CompanyContext' +import { Cloud, Download, Info, Loader2 } from 'lucide-react' +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' +import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' +import type { FiscalPeriod } from '@/types' + +const CloudBackupPanel = getSettingsPanel('cloud-backup') +const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup') + +type Scope = 'all' | 'period' + +interface EstimateResponse { + total_bytes: number + document_bytes: number + document_count: number + size_limit_bytes: number + within_limit: boolean +} + +const LAST_DOWNLOAD_STORAGE_KEY = 'gnubok:last-backup-download' + +export function BackupDownloadForm() { + const { toast } = useToast() + const { company } = useCompany() + + const [scope, setScope] = useState('all') + const [includeDocuments, setIncludeDocuments] = useState(true) + const [periods, setPeriods] = useState([]) + const [selectedPeriodId, setSelectedPeriodId] = useState('') + const [estimate, setEstimate] = useState(null) + const [isLoadingEstimate, setIsLoadingEstimate] = useState(false) + const [isDownloading, setIsDownloading] = useState(false) + const [lastDownloadedAt, setLastDownloadedAt] = useState(null) + + const storageKey = useMemo( + () => (company ? `${LAST_DOWNLOAD_STORAGE_KEY}:${company.id}` : null), + [company] + ) + + useEffect(() => { + if (!storageKey) return + setLastDownloadedAt(window.localStorage.getItem(storageKey)) + }, [storageKey]) + + useEffect(() => { + let cancelled = false + async function loadPeriods() { + try { + const res = await fetch('/api/bookkeeping/fiscal-periods') + const { data } = await res.json() + if (cancelled) return + const sorted = (data || []) as FiscalPeriod[] + setPeriods(sorted) + if (sorted.length > 0 && !selectedPeriodId) { + setSelectedPeriodId(sorted[0].id) + } + } catch { + // silent: scope=all still works without periods loaded + } + } + loadPeriods() + return () => { + cancelled = true + } + // Intentionally run once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const estimateUrl = useMemo(() => { + const params = new URLSearchParams({ estimate: '1', scope }) + if (scope === 'period' && selectedPeriodId) { + params.set('period_id', selectedPeriodId) + } + if (!includeDocuments) { + params.set('include_documents', 'false') + } + return `/api/reports/full-archive?${params.toString()}` + }, [scope, selectedPeriodId, includeDocuments]) + + useEffect(() => { + if (scope === 'period' && !selectedPeriodId) { + setEstimate(null) + return + } + let cancelled = false + setIsLoadingEstimate(true) + setEstimate(null) + ;(async () => { + try { + const res = await fetch(estimateUrl) + if (!res.ok) return + const { data } = (await res.json()) as { data: EstimateResponse } + if (!cancelled) setEstimate(data) + } catch { + // leave estimate null; we still let users attempt the download + } finally { + if (!cancelled) setIsLoadingEstimate(false) + } + })() + return () => { + cancelled = true + } + }, [estimateUrl, scope, selectedPeriodId]) + + const downloadUrl = useMemo(() => { + const params = new URLSearchParams({ scope }) + if (scope === 'period' && selectedPeriodId) { + params.set('period_id', selectedPeriodId) + } + if (!includeDocuments) { + params.set('include_documents', 'false') + } + return `/api/reports/full-archive?${params.toString()}` + }, [scope, selectedPeriodId, includeDocuments]) + + const handleDownload = useCallback(async () => { + if (scope === 'period' && !selectedPeriodId) return + + setIsDownloading(true) + try { + const res = await fetch(downloadUrl) + if (!res.ok) { + if (res.status === 413) { + const body = await res.json().catch(() => ({})) + const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null + toast({ + title: 'Arkivet är för stort för direktnedladdning', + description: sizeMb + ? `Ditt arkiv är cirka ${sizeMb} MB. Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.` + : 'Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.', + variant: 'destructive', + }) + return + } + const body = await res.json().catch(() => ({})) + throw new Error(body.error || 'Kunde inte skapa arkivet') + } + + const blob = await res.blob() + const contentDisposition = res.headers.get('Content-Disposition') || '' + const match = contentDisposition.match(/filename="?([^";]+)"?/) + const filename = match?.[1] || 'arkiv.zip' + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + const now = new Date().toISOString() + if (storageKey) { + window.localStorage.setItem(storageKey, now) + setLastDownloadedAt(now) + } + + toast({ title: 'Säkerhetsbackup skapad', description: filename }) + } catch (err) { + toast({ + title: 'Kunde inte skapa säkerhetsbackup', + description: err instanceof Error ? err.message : 'Försök igen.', + variant: 'destructive', + }) + } finally { + setIsDownloading(false) + } + }, [downloadUrl, scope, selectedPeriodId, storageKey, toast]) + + const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments + const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!selectedPeriodId) + + return ( +
+ + + Skapa backup + + +
+ +
+ setScope('all')} + label="Hela historiken" + description="Alla räkenskapsår och verifikationer" + recommended + /> + setScope('period')} + label="En period" + description="Välj ett specifikt räkenskapsår" + /> +
+
+ + {scope === 'period' && ( +
+ + +
+ )} + +
+
+ +

+ Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en. + Stäng av för en mindre backup med bara bokföringsdata. +

+
+ +
+ +
+
+ + {isLoadingEstimate ? ( + Beräknar storlek… + ) : estimate ? ( + + Uppskattad storlek: {formatBytes(estimate.total_bytes)} + {' '}({estimate.document_count} {estimate.document_count === 1 ? 'bilaga' : 'bilagor'}) + + ) : ( + Storlek beräknas när omfattning är vald. + )} +
+ {isOverLimit && ( +

+ Arkivet är större än {formatBytes(estimate!.size_limit_bytes)} och kan inte laddas ner + direkt. Välj en enskild period eller stäng av bilagor tills vidare — + automatisk molnsynkronisering kommer i senare version. +

+ )} +
+ +
+ + {lastDownloadedAt && ( +

+ Senaste nedladdning: {formatDate(lastDownloadedAt)} +

+ )} +
+
+
+ + {hasCloudBackup && CloudBackupPanel ? ( + + ) : ( + + + + + Molnsynkronisering + + + +

+ Aktivera tillägget “Molnsynkronisering” för att koppla Google + Drive och ladda upp säkerhetsbackupen med ett klick. +

+
+
+ )} +
+ ) +} + +interface ScopeRadioProps { + checked: boolean + onChange: () => void + label: string + description: string + recommended?: boolean +} + +function ScopeRadio({ checked, onChange, label, description, recommended }: ScopeRadioProps) { + return ( + + ) +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(1)} kB` + const mb = kb / 1024 + if (mb < 1024) return `${mb.toFixed(1)} MB` + return `${(mb / 1024).toFixed(2)} GB` +} + +function formatDate(iso: string): string { + const d = new Date(iso) + return d.toLocaleString('sv-SE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/components/settings/CompanyDangerZone.tsx b/components/settings/CompanyDangerZone.tsx index 64f17674..ffbd8019 100644 --- a/components/settings/CompanyDangerZone.tsx +++ b/components/settings/CompanyDangerZone.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' -import Link from 'next/link' import { useCompany } from '@/contexts/CompanyContext' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -16,7 +15,7 @@ import { DialogTitle, } from '@/components/ui/dialog' import { RetentionNotice } from '@/components/ui/retention-notice' -import { ExternalLink, Loader2 } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' /** @@ -84,13 +83,7 @@ export function CompanyDangerZone() { -
- +
+ +
+ + ) : ( + <> +

+ Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen + Drive. gnubok får bara tillgång till filer som appen själv skapar (scope + drive.file). +

+ + + )} + + + ) +} + +function formatMb(bytes: number): string { + const mb = bytes / (1024 * 1024) + return `${mb.toFixed(1)} MB` +} + +function formatDate(iso: string): string { + const d = new Date(iso) + return d.toLocaleString('sv-SE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/extensions/general/cloud-backup/index.ts b/extensions/general/cloud-backup/index.ts new file mode 100644 index 00000000..3c3cf716 --- /dev/null +++ b/extensions/general/cloud-backup/index.ts @@ -0,0 +1,309 @@ +import type { Extension, ExtensionContext } from '@/lib/extensions/types' +import { NextResponse } from 'next/server' +import { + generateFullArchive, + estimateArchiveSize, + type ArchiveScope, +} from '@/lib/reports/full-archive-export' +import { + buildAuthorizationUrl, + exchangeCodeForTokens, + fetchUserEmail, + getOAuthEnv, + refreshAccessToken, + revokeToken, +} from './lib/google-oauth' +import { ensureFolder, uploadFile } from './lib/google-drive' +import { + createOAuthState, + decryptToken, + encryptToken, + verifyOAuthState, +} from './lib/crypto' +import type { + CloudBackupStatus, + GoogleDriveConnection, + GoogleDriveLastSync, +} from './types' + +const CONNECTION_KEY = 'google_drive_connection' +const LAST_SYNC_KEY = 'google_drive_last_sync' +const ROOT_FOLDER_NAME = 'gnubok' +const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 + +function jsonError(message: string, status = 500): Response { + return NextResponse.json({ error: message }, { status }) +} + +async function loadConnection( + ctx: ExtensionContext +): Promise { + return ctx.settings.get(CONNECTION_KEY) +} + +async function getFreshAccessToken( + ctx: ExtensionContext, + connection: GoogleDriveConnection +): Promise { + const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + const env = getOAuthEnv(origin) + const refreshToken = decryptToken(connection.refresh_token_encrypted) + const { access_token } = await refreshAccessToken(env, refreshToken) + return access_token +} + +async function fetchCompanyName(ctx: ExtensionContext): Promise { + const { data } = await ctx.supabase + .from('company_settings') + .select('company_name, org_number') + .eq('company_id', ctx.companyId) + .single() + const name = (data?.company_name as string) || 'företag' + const org = (data?.org_number as string) || ctx.companyId.slice(0, 8) + return `${name} (${org})`.replace(/[\\/]/g, '-') +} + +export const cloudBackupExtension: Extension = { + id: 'cloud-backup', + name: 'Molnsynkronisering', + version: '1.0.0', + sector: 'general', + + settingsPanel: { + label: 'Molnsynkronisering', + path: '/settings/backup', + }, + + apiRoutes: [ + // Kick off OAuth: return the Google consent URL. + { + method: 'POST', + path: '/connect', + handler: async (request, ctx) => { + if (!ctx) return jsonError('Missing context', 500) + try { + const origin = new URL(request.url).origin + const env = getOAuthEnv(origin) + const state = createOAuthState(ctx.userId, ctx.companyId) + const url = buildAuthorizationUrl(env, state) + return NextResponse.json({ url }) + } catch (err) { + ctx.log.error('connect failed', err) + return jsonError( + err instanceof Error ? err.message : 'Could not start OAuth', + 500 + ) + } + }, + }, + + // Google redirects here after the user consents. + { + method: 'GET', + path: '/oauth/callback', + handler: async (request, ctx) => { + if (!ctx) return jsonError('Missing context', 500) + const url = new URL(request.url) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const errorParam = url.searchParams.get('error') + const origin = url.origin + const redirect = (status: string, reason?: string) => { + const target = new URL('/settings/backup', origin) + target.searchParams.set('cloud_backup', status) + if (reason) target.searchParams.set('reason', reason) + return NextResponse.redirect(target) + } + + if (errorParam) { + return redirect('error', errorParam) + } + if (!code || !state) { + return redirect('error', 'missing_params') + } + + const verified = verifyOAuthState(state) + if (!verified) { + return redirect('error', 'invalid_state') + } + if (verified.userId !== ctx.userId || verified.companyId !== ctx.companyId) { + return redirect('error', 'state_mismatch') + } + + try { + const env = getOAuthEnv(origin) + const tokens = await exchangeCodeForTokens(env, code) + const email = await fetchUserEmail(tokens.access_token) + + const connection: GoogleDriveConnection = { + refresh_token_encrypted: encryptToken(tokens.refresh_token), + account_email: email, + connected_at: new Date().toISOString(), + root_folder_id: null, + company_folder_id: null, + } + await ctx.settings.set(CONNECTION_KEY, connection) + return redirect('connected') + } catch (err) { + ctx.log.error('oauth callback failed', err) + return redirect( + 'error', + err instanceof Error ? err.message.slice(0, 80) : 'exchange_failed' + ) + } + }, + }, + + // Revoke the refresh token and clear the stored connection. + { + method: 'POST', + path: '/disconnect', + handler: async (_request, ctx) => { + if (!ctx) return jsonError('Missing context', 500) + try { + const connection = await loadConnection(ctx) + if (connection) { + try { + const refreshToken = decryptToken(connection.refresh_token_encrypted) + await revokeToken(refreshToken) + } catch (err) { + ctx.log.warn('token revoke failed (continuing)', err) + } + } + await ctx.settings.set(CONNECTION_KEY, null) + await ctx.settings.set(LAST_SYNC_KEY, null) + return NextResponse.json({ ok: true }) + } catch (err) { + ctx.log.error('disconnect failed', err) + return jsonError( + err instanceof Error ? err.message : 'Disconnect failed', + 500 + ) + } + }, + }, + + // Read-only status used by the UI to show connected/last-sync info. + { + method: 'GET', + path: '/status', + handler: async (_request, ctx) => { + if (!ctx) return jsonError('Missing context', 500) + const connection = await loadConnection(ctx) + const lastSync = await ctx.settings.get(LAST_SYNC_KEY) + const status: CloudBackupStatus = { + connected: !!connection, + account_email: connection?.account_email ?? null, + connected_at: connection?.connected_at ?? null, + last_sync: lastSync ?? null, + } + return NextResponse.json({ data: status }) + }, + }, + + // Generate an archive and upload it to Drive. Returns the Drive file info. + { + method: 'POST', + path: '/sync', + handler: async (request, ctx) => { + if (!ctx) return jsonError('Missing context', 500) + try { + const connection = await loadConnection(ctx) + if (!connection) { + return jsonError('not_connected', 400) + } + + const body = (await request.json().catch(() => ({}))) as { + include_documents?: boolean + } + const scope: ArchiveScope = 'all' + const includeDocuments = body.include_documents !== false + + const estimate = await estimateArchiveSize(ctx.supabase, ctx.companyId, scope) + const effectiveBytes = includeDocuments + ? estimate.total_bytes + : estimate.total_bytes - estimate.document_bytes + if (effectiveBytes > SIZE_LIMIT_BYTES) { + return NextResponse.json( + { + error: 'archive_too_large', + size_bytes: effectiveBytes, + size_limit_bytes: SIZE_LIMIT_BYTES, + }, + { status: 413 } + ) + } + + const accessToken = await getFreshAccessToken(ctx, connection) + + // Ensure folder structure. Persist ids on first sync so later runs skip the lookup. + let rootFolderId = connection.root_folder_id + let companyFolderId = connection.company_folder_id + if (!rootFolderId) { + const root = await ensureFolder(accessToken, ROOT_FOLDER_NAME, null) + rootFolderId = root.id + } + if (!companyFolderId) { + const companyName = await fetchCompanyName(ctx) + const companyFolder = await ensureFolder( + accessToken, + companyName, + rootFolderId + ) + companyFolderId = companyFolder.id + } + if ( + rootFolderId !== connection.root_folder_id || + companyFolderId !== connection.company_folder_id + ) { + await ctx.settings.set(CONNECTION_KEY, { + ...connection, + root_folder_id: rootFolderId, + company_folder_id: companyFolderId, + }) + } + + const archive = await generateFullArchive(ctx.supabase, ctx.companyId, { + scope: 'all', + include_documents: includeDocuments, + }) + + const stamp = new Date() + .toISOString() + .replace(/[-:]/g, '') + .replace(/\..+/, '') + const fileName = `arkiv_full_${stamp}.zip` + + const uploaded = await uploadFile( + accessToken, + companyFolderId, + fileName, + archive + ) + + const lastSync: GoogleDriveLastSync = { + at: new Date().toISOString(), + file_id: uploaded.id, + file_name: uploaded.name, + file_size_bytes: uploaded.size_bytes, + folder_id: companyFolderId, + } + await ctx.settings.set(LAST_SYNC_KEY, lastSync) + + return NextResponse.json({ + data: { + ...lastSync, + web_view_link: uploaded.web_view_link, + }, + }) + } catch (err) { + ctx.log.error('sync failed', err) + return jsonError( + err instanceof Error ? err.message : 'Sync failed', + 500 + ) + } + }, + }, + ], +} diff --git a/extensions/general/cloud-backup/lib/__tests__/crypto.test.ts b/extensions/general/cloud-backup/lib/__tests__/crypto.test.ts new file mode 100644 index 00000000..4ae4aec9 --- /dev/null +++ b/extensions/general/cloud-backup/lib/__tests__/crypto.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + encryptToken, + decryptToken, + createOAuthState, + verifyOAuthState, +} from '../crypto' + +beforeEach(() => { + process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key-that-is-long-enough' +}) + +describe('token encryption', () => { + it('round-trips a refresh token', () => { + const token = '1//0abcdef_refresh_token_value' + const encrypted = encryptToken(token) + expect(encrypted).not.toContain(token) + expect(decryptToken(encrypted)).toBe(token) + }) + + it('produces different ciphertext for the same plaintext (IV is random)', () => { + const token = 'same-token' + const a = encryptToken(token) + const b = encryptToken(token) + expect(a).not.toBe(b) + expect(decryptToken(a)).toBe(token) + expect(decryptToken(b)).toBe(token) + }) + + it('fails to decrypt tampered ciphertext', () => { + const encrypted = encryptToken('secret') + // Flip a byte in the middle of the ciphertext. + const buf = Buffer.from(encrypted, 'base64url') + buf[30] ^= 0xff + const tampered = buf.toString('base64url') + expect(() => decryptToken(tampered)).toThrow() + }) +}) + +describe('OAuth state', () => { + it('round-trips userId and companyId', () => { + const state = createOAuthState('user-1', 'company-1') + expect(verifyOAuthState(state)).toEqual({ + userId: 'user-1', + companyId: 'company-1', + }) + }) + + it('returns null for garbage state', () => { + expect(verifyOAuthState('not-a-valid-state')).toBeNull() + }) + + it('returns null for expired state', () => { + const state = createOAuthState('user-1', 'company-1') + // Fast-forward past the 10-minute TTL. + const realNow = Date.now + Date.now = () => realNow() + 11 * 60 * 1000 + try { + expect(verifyOAuthState(state)).toBeNull() + } finally { + Date.now = realNow + } + }) +}) diff --git a/extensions/general/cloud-backup/lib/__tests__/google-drive.test.ts b/extensions/general/cloud-backup/lib/__tests__/google-drive.test.ts new file mode 100644 index 00000000..0c2a5a50 --- /dev/null +++ b/extensions/general/cloud-backup/lib/__tests__/google-drive.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { ensureFolder, uploadFile } from '../google-drive' + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('ensureFolder', () => { + it('returns existing folder when found', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ files: [{ id: 'folder-1', name: 'gnubok' }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const folder = await ensureFolder('at', 'gnubok', null) + expect(folder.id).toBe('folder-1') + // Only the search call; no create needed. + expect(fetchMock).toHaveBeenCalledTimes(1) + const url = fetchMock.mock.calls[0][0] as string + expect(url).toContain('/files?q=') + expect(decodeURIComponent(url)).toContain(`name = 'gnubok'`) + expect(decodeURIComponent(url)).toContain(`'root' in parents`) + }) + + it('creates a new folder when none exists', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify({ files: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'new-id', name: 'gnubok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + const folder = await ensureFolder('at', 'gnubok', null) + expect(folder.id).toBe('new-id') + expect(fetchMock).toHaveBeenCalledTimes(2) + const createCall = fetchMock.mock.calls[1] + expect((createCall[1] as RequestInit).method).toBe('POST') + const body = JSON.parse(String((createCall[1] as RequestInit).body)) + expect(body.mimeType).toBe('application/vnd.google-apps.folder') + expect(body.name).toBe('gnubok') + }) + + it('escapes single quotes in folder name and scopes to parent', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + // findFolderByName → match so we never hit create. + new Response(JSON.stringify({ files: [{ id: 'x', name: "Kalle's" }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + await ensureFolder('at', "Kalle's", 'parent-id') + const searchUrl = decodeURIComponent(fetchMock.mock.calls[0][0] as string) + expect(searchUrl).toContain(`name = 'Kalle\\'s'`) + expect(searchUrl).toContain(`'parent-id' in parents`) + }) +}) + +describe('uploadFile', () => { + it('posts multipart body with metadata + binary and returns parsed result', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + id: 'file-123', + name: 'arkiv.zip', + size: '2048', + webViewLink: 'https://drive.google.com/file/d/file-123/view', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + + const data = new Uint8Array([1, 2, 3, 4, 5]).buffer + const result = await uploadFile('access-tok', 'folder-1', 'arkiv.zip', data) + + expect(result.id).toBe('file-123') + expect(result.size_bytes).toBe(2048) + expect(result.web_view_link).toContain('file-123') + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toContain('uploadType=multipart') + const contentType = (init as RequestInit).headers as Record + expect(contentType.Authorization).toBe('Bearer access-tok') + expect(contentType['Content-Type']).toContain('multipart/related') + // Body must be a Buffer that contains the metadata JSON. + const body = (init as RequestInit).body as Buffer + expect(Buffer.isBuffer(body)).toBe(true) + expect(body.toString('utf8')).toContain('"name":"arkiv.zip"') + expect(body.toString('utf8')).toContain('"parents":["folder-1"]') + }) + + it('throws with Drive error body when upload fails', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('quota exceeded', { status: 403 }) + ) + await expect( + uploadFile('at', 'folder', 'a.zip', new Uint8Array(1).buffer) + ).rejects.toThrow(/403/) + }) +}) diff --git a/extensions/general/cloud-backup/lib/__tests__/google-oauth.test.ts b/extensions/general/cloud-backup/lib/__tests__/google-oauth.test.ts new file mode 100644 index 00000000..ecd3264c --- /dev/null +++ b/extensions/general/cloud-backup/lib/__tests__/google-oauth.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + buildAuthorizationUrl, + exchangeCodeForTokens, + refreshAccessToken, + getOAuthEnv, +} from '../google-oauth' + +beforeEach(() => { + process.env.GOOGLE_CLIENT_ID = 'test-client-id' + process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' + vi.restoreAllMocks() +}) + +describe('getOAuthEnv', () => { + it('builds redirect URI from origin', () => { + const env = getOAuthEnv('https://app.example.com') + expect(env.redirectUri).toBe( + 'https://app.example.com/api/extensions/ext/cloud-backup/oauth/callback' + ) + expect(env.clientId).toBe('test-client-id') + }) + + it('throws when env vars missing', () => { + delete process.env.GOOGLE_CLIENT_ID + expect(() => getOAuthEnv('http://localhost:3000')).toThrow(/GOOGLE_CLIENT_ID/) + }) +}) + +describe('buildAuthorizationUrl', () => { + it('includes scope, offline access, consent prompt, and state', () => { + const env = getOAuthEnv('http://localhost:3000') + const url = buildAuthorizationUrl(env, 'abc123state') + const parsed = new URL(url) + expect(parsed.origin + parsed.pathname).toBe( + 'https://accounts.google.com/o/oauth2/v2/auth' + ) + expect(parsed.searchParams.get('access_type')).toBe('offline') + expect(parsed.searchParams.get('prompt')).toBe('consent') + expect(parsed.searchParams.get('state')).toBe('abc123state') + expect(parsed.searchParams.get('scope')).toContain( + 'https://www.googleapis.com/auth/drive.file' + ) + }) +}) + +describe('exchangeCodeForTokens', () => { + it('posts form-encoded body and parses token response', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + access_token: 'at', + refresh_token: 'rt', + expires_in: 3600, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const env = getOAuthEnv('http://localhost:3000') + const result = await exchangeCodeForTokens(env, 'auth-code') + + expect(result.refresh_token).toBe('rt') + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://oauth2.googleapis.com/token') + expect((init as RequestInit).method).toBe('POST') + expect(String((init as RequestInit).body)).toContain('grant_type=authorization_code') + expect(String((init as RequestInit).body)).toContain('code=auth-code') + }) + + it('throws a clear error when no refresh_token is returned', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ access_token: 'at', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + const env = getOAuthEnv('http://localhost:3000') + await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/refresh token/i) + }) + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Bad Request', { status: 400 }) + ) + const env = getOAuthEnv('http://localhost:3000') + await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/400/) + }) +}) + +describe('refreshAccessToken', () => { + it('returns a fresh access token', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ access_token: 'new-at', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + const env = getOAuthEnv('http://localhost:3000') + const result = await refreshAccessToken(env, 'old-refresh') + expect(result.access_token).toBe('new-at') + }) +}) diff --git a/extensions/general/cloud-backup/lib/crypto.ts b/extensions/general/cloud-backup/lib/crypto.ts new file mode 100644 index 00000000..78ce8ccb --- /dev/null +++ b/extensions/general/cloud-backup/lib/crypto.ts @@ -0,0 +1,77 @@ +import crypto from 'crypto' + +/** + * AES-256-GCM encryption for long-lived refresh tokens stored in + * extension_data. Key is derived from SUPABASE_SERVICE_ROLE_KEY (same + * trust boundary as the database itself — anyone who can exfiltrate the + * key can already read the data). + */ + +const ALGORITHM = 'aes-256-gcm' + +function getKey(): Buffer { + const secret = process.env.SUPABASE_SERVICE_ROLE_KEY + if (!secret) throw new Error('SUPABASE_SERVICE_ROLE_KEY is required') + // Scope the key with a purpose string so this can't be confused with + // oauth-codes.ts's derivation if both are ever compromised together. + return crypto + .createHash('sha256') + .update('cloud-backup:v1:' + secret) + .digest() +} + +export function encryptToken(plaintext: string): string { + const key = getKey() + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return Buffer.concat([iv, tag, encrypted]).toString('base64url') +} + +export function decryptToken(ciphertext: string): string { + const key = getKey() + const combined = Buffer.from(ciphertext, 'base64url') + const iv = combined.subarray(0, 12) + const tag = combined.subarray(12, 28) + const encrypted = combined.subarray(28) + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(tag) + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]) + return decrypted.toString('utf8') +} + +/** + * Short-lived signed state parameter for OAuth CSRF protection. + * + * The state encodes `{userId, companyId, exp}` and is verified on the + * callback. Stateless (no DB round-trip) and self-expiring. + */ +const STATE_TTL_MS = 10 * 60 * 1000 + +interface StatePayload { + u: string + c: string + e: number +} + +export function createOAuthState(userId: string, companyId: string): string { + const payload: StatePayload = { + u: userId, + c: companyId, + e: Date.now() + STATE_TTL_MS, + } + return encryptToken(JSON.stringify(payload)) +} + +export function verifyOAuthState( + state: string +): { userId: string; companyId: string } | null { + try { + const payload = JSON.parse(decryptToken(state)) as StatePayload + if (Date.now() > payload.e) return null + return { userId: payload.u, companyId: payload.c } + } catch { + return null + } +} diff --git a/extensions/general/cloud-backup/lib/google-drive.ts b/extensions/general/cloud-backup/lib/google-drive.ts new file mode 100644 index 00000000..59081791 --- /dev/null +++ b/extensions/general/cloud-backup/lib/google-drive.ts @@ -0,0 +1,162 @@ +/** + * Minimal Google Drive v3 client — just enough to: + * - find or create a named folder, + * - upload a file via multipart. + * + * We operate on `drive.file` scope, so we can only see files we created. + * Queries by name return only app-created folders with that name. + */ + +const DRIVE_API = 'https://www.googleapis.com/drive/v3' +const DRIVE_UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3/files' +const FOLDER_MIME = 'application/vnd.google-apps.folder' + +interface DriveFile { + id: string + name: string +} + +async function driveFetch( + accessToken: string, + path: string, + init?: RequestInit +): Promise { + const res = await fetch(`${DRIVE_API}${path}`, { + ...init, + headers: { + ...(init?.headers || {}), + Authorization: `Bearer ${accessToken}`, + }, + }) + if (!res.ok) { + const body = await res.text() + throw new Error(`Drive API ${res.status}: ${body.slice(0, 200)}`) + } + return res +} + +/** + * Find a folder by name under a parent (or root). Returns null if none exists. + * Uses q= filter; drive.file scope only sees app-created folders. + */ +async function findFolderByName( + accessToken: string, + name: string, + parentId: string | null +): Promise { + const parentClause = parentId ? `'${parentId}' in parents` : `'root' in parents` + const q = [ + `mimeType = '${FOLDER_MIME}'`, + `name = '${escapeName(name)}'`, + parentClause, + 'trashed = false', + ].join(' and ') + const url = `/files?q=${encodeURIComponent(q)}&fields=files(id,name)&pageSize=1` + const res = await driveFetch(accessToken, url) + const json = (await res.json()) as { files: DriveFile[] } + return json.files[0] || null +} + +async function createFolder( + accessToken: string, + name: string, + parentId: string | null +): Promise { + const body = { + name, + mimeType: FOLDER_MIME, + parents: parentId ? [parentId] : undefined, + } + const res = await driveFetch(accessToken, '/files?fields=id,name', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return (await res.json()) as DriveFile +} + +export async function ensureFolder( + accessToken: string, + name: string, + parentId: string | null +): Promise { + const existing = await findFolderByName(accessToken, name, parentId) + if (existing) return existing + return createFolder(accessToken, name, parentId) +} + +export interface UploadResult { + id: string + name: string + size_bytes: number + web_view_link: string +} + +/** + * Multipart upload: metadata + bytes in one request. Suitable for files + * up to ~100 MB; beyond that Drive recommends resumable uploads. + */ +export async function uploadFile( + accessToken: string, + folderId: string, + fileName: string, + data: ArrayBuffer, + contentType = 'application/zip' +): Promise { + const boundary = `gnubok-${crypto.randomUUID().replace(/-/g, '')}` + const metadata = JSON.stringify({ + name: fileName, + parents: [folderId], + }) + + const head = + `--${boundary}\r\n` + + `Content-Type: application/json; charset=UTF-8\r\n\r\n` + + `${metadata}\r\n` + + `--${boundary}\r\n` + + `Content-Type: ${contentType}\r\n\r\n` + const tail = `\r\n--${boundary}--` + + const body = Buffer.concat([ + Buffer.from(head, 'utf8'), + Buffer.from(data), + Buffer.from(tail, 'utf8'), + ]) + + const res = await fetch( + `${DRIVE_UPLOAD_API}?uploadType=multipart&fields=id,name,size,webViewLink`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': `multipart/related; boundary=${boundary}`, + 'Content-Length': String(body.length), + }, + body, + } + ) + + if (!res.ok) { + const errText = await res.text() + throw new Error(`Drive upload failed: ${res.status} ${errText.slice(0, 200)}`) + } + + const json = (await res.json()) as { + id: string + name: string + size?: string + webViewLink?: string + } + + return { + id: json.id, + name: json.name, + size_bytes: json.size ? Number(json.size) : data.byteLength, + web_view_link: json.webViewLink || `https://drive.google.com/file/d/${json.id}/view`, + } +} + +function escapeName(name: string): string { + // Drive query string: escape single quotes and backslashes. + return name.replace(/\\/g, '\\\\').replace(/'/g, "\\'") +} diff --git a/extensions/general/cloud-backup/lib/google-oauth.ts b/extensions/general/cloud-backup/lib/google-oauth.ts new file mode 100644 index 00000000..9eeefd85 --- /dev/null +++ b/extensions/general/cloud-backup/lib/google-oauth.ts @@ -0,0 +1,130 @@ +/** + * Minimal Google OAuth 2.0 client for the cloud-backup extension. + * + * Scope: `drive.file` — app-created files only, not the user's full Drive. + * Access type: `offline` — returns a refresh token on first consent. + * Prompt: `consent` — forces the consent screen so the refresh token is + * re-issued even if the user has previously authorised the app. + */ + +const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file' +const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth' +const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token' +const USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo' + +export interface OAuthEnv { + clientId: string + clientSecret: string + redirectUri: string +} + +export function getOAuthEnv(origin: string): OAuthEnv { + const clientId = process.env.GOOGLE_CLIENT_ID + const clientSecret = process.env.GOOGLE_CLIENT_SECRET + if (!clientId || !clientSecret) { + throw new Error( + 'Google OAuth is not configured: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET' + ) + } + return { + clientId, + clientSecret, + redirectUri: `${origin}/api/extensions/ext/cloud-backup/oauth/callback`, + } +} + +export function buildAuthorizationUrl(env: OAuthEnv, state: string): string { + const params = new URLSearchParams({ + client_id: env.clientId, + redirect_uri: env.redirectUri, + response_type: 'code', + scope: `openid email ${DRIVE_SCOPE}`, + access_type: 'offline', + prompt: 'consent', + include_granted_scopes: 'true', + state, + }) + return `${AUTH_ENDPOINT}?${params.toString()}` +} + +export interface TokenExchangeResult { + access_token: string + refresh_token: string + expires_in: number + id_token?: string +} + +export async function exchangeCodeForTokens( + env: OAuthEnv, + code: string +): Promise { + const body = new URLSearchParams({ + code, + client_id: env.clientId, + client_secret: env.clientSecret, + redirect_uri: env.redirectUri, + grant_type: 'authorization_code', + }) + const res = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + if (!res.ok) { + const errText = await res.text() + throw new Error(`Google token exchange failed: ${res.status} ${errText}`) + } + const json = (await res.json()) as TokenExchangeResult + if (!json.refresh_token) { + throw new Error( + 'No refresh token returned — Google only issues one on first consent. ' + + 'Revoke the app at myaccount.google.com/permissions and try again.' + ) + } + return json +} + +export interface AccessTokenResult { + access_token: string + expires_in: number +} + +export async function refreshAccessToken( + env: OAuthEnv, + refreshToken: string +): Promise { + const body = new URLSearchParams({ + client_id: env.clientId, + client_secret: env.clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + }) + const res = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + if (!res.ok) { + const errText = await res.text() + throw new Error(`Google token refresh failed: ${res.status} ${errText}`) + } + return (await res.json()) as AccessTokenResult +} + +export async function revokeToken(token: string): Promise { + await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) +} + +export async function fetchUserEmail(accessToken: string): Promise { + const res = await fetch(USERINFO_ENDPOINT, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) { + throw new Error(`Failed to fetch Google user info: ${res.status}`) + } + const json = (await res.json()) as { email?: string } + return json.email || 'unknown@google' +} diff --git a/extensions/general/cloud-backup/manifest.json b/extensions/general/cloud-backup/manifest.json new file mode 100644 index 00000000..7fc1c22e --- /dev/null +++ b/extensions/general/cloud-backup/manifest.json @@ -0,0 +1,19 @@ +{ + "id": "cloud-backup", + "sector": "general", + "exportName": "cloudBackupExtension", + "entryPoint": "@/extensions/general/cloud-backup", + "workspace": "@/components/extensions/general/CloudBackupWorkspace", + "requiredEnvVars": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], + "npmDependencies": [], + "definition": { + "name": "Molnsynkronisering", + "category": "operations", + "icon": "Cloud", + "dataPattern": "manual", + "hasOwnData": true, + "description": "Synka säkerhetsbackup till din egen molnlagring", + "longDescription": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Gnubok skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.", + "subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive — ingen data lagras hos tredje part utöver Google." + } +} diff --git a/extensions/general/cloud-backup/types.ts b/extensions/general/cloud-backup/types.ts new file mode 100644 index 00000000..754ad4d8 --- /dev/null +++ b/extensions/general/cloud-backup/types.ts @@ -0,0 +1,36 @@ +/** + * Connection stored per company in extension_data under key + * `google_drive_connection`. The refresh token is AES-256-GCM encrypted + * (see lib/crypto.ts) — never store it in plaintext. + */ +export interface GoogleDriveConnection { + refresh_token_encrypted: string + account_email: string + connected_at: string + /** ID of the top-level "gnubok" folder in the user's Drive. */ + root_folder_id: string | null + /** ID of the per-company subfolder. */ + company_folder_id: string | null +} + +/** + * Last-sync snapshot stored under key `google_drive_last_sync`. + */ +export interface GoogleDriveLastSync { + at: string + file_id: string + file_name: string + file_size_bytes: number + folder_id: string +} + +/** + * Status returned to the UI. Mirrors the two storage shapes above in a + * shape safe to expose to the client (no encrypted token). + */ +export interface CloudBackupStatus { + connected: boolean + account_email: string | null + connected_at: string | null + last_sync: GoogleDriveLastSync | null +} diff --git a/lib/core/audit/audit-service.ts b/lib/core/audit/audit-service.ts index 7c2d5c15..f554ed72 100644 --- a/lib/core/audit/audit-service.ts +++ b/lib/core/audit/audit-service.ts @@ -19,11 +19,11 @@ export interface AuditLogFilters { } /** - * Get paginated audit log entries for a user + * Get paginated audit log entries for a company */ export async function getAuditLog( supabase: SupabaseClient, - userId: string, + companyId: string, filters: AuditLogFilters = {} ): Promise<{ data: AuditLogEntry[]; count: number }> { const page = filters.page ?? 1 @@ -33,7 +33,7 @@ export async function getAuditLog( let query = supabase .from('audit_log') .select('*', { count: 'exact' }) - .eq('company_id', userId) + .eq('company_id', companyId) .order('created_at', { ascending: false }) .range(offset, offset + pageSize - 1) @@ -70,7 +70,7 @@ export async function getAuditLog( */ export async function getEntityHistory( supabase: SupabaseClient, - userId: string, + companyId: string, tableName: string, recordId: string ): Promise { @@ -78,7 +78,7 @@ export async function getEntityHistory( const { data, error } = await supabase .from('audit_log') .select('*') - .eq('company_id', userId) + .eq('company_id', companyId) .eq('table_name', tableName) .eq('record_id', recordId) .order('created_at', { ascending: true }) @@ -96,7 +96,7 @@ export async function getEntityHistory( */ export async function getCorrectionChain( supabase: SupabaseClient, - userId: string, + companyId: string, journalEntryId: string ): Promise { @@ -105,7 +105,7 @@ export async function getCorrectionChain( .from('journal_entries') .select('id, reverses_id, reversed_by_id, correction_of_id') .eq('id', journalEntryId) - .eq('company_id', userId) + .eq('company_id', companyId) .single() if (entryError || !entry) { @@ -122,7 +122,7 @@ export async function getCorrectionChain( const { data: referencing } = await supabase .from('journal_entries') .select('id') - .eq('company_id', userId) + .eq('company_id', companyId) .or(`reverses_id.eq.${journalEntryId},reversed_by_id.eq.${journalEntryId},correction_of_id.eq.${journalEntryId}`) for (const ref of referencing || []) { @@ -133,7 +133,7 @@ export async function getCorrectionChain( const { data, error } = await supabase .from('audit_log') .select('*') - .eq('company_id', userId) + .eq('company_id', companyId) .eq('table_name', 'journal_entries') .in('record_id', Array.from(relatedIds)) .order('created_at', { ascending: true }) diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index 1798dc03..b454da69 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -48,8 +48,8 @@ describe('sectors registry', () => { expect(SECTORS.length).toBe(1) }) - it('should have 9 total extensions', () => { - expect(getAllExtensions().length).toBe(9) + it('should have 10 total extensions', () => { + expect(getAllExtensions().length).toBe(10) }) it('should have unique slugs within each sector', () => { @@ -94,7 +94,7 @@ describe('sectors registry', () => { it('getExtensionsBySector returns extensions for a sector', () => { const extensions = getExtensionsBySector('general') - expect(extensions.length).toBe(9) + expect(extensions.length).toBe(10) }) it('all extensions have required fields', () => { diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts index 3109d694..177c994e 100644 --- a/lib/extensions/_generated/enabled-extensions.ts +++ b/lib/extensions/_generated/enabled-extensions.ts @@ -6,4 +6,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([ 'arcim-migration', 'tic', 'mcp-server', + 'cloud-backup', ]) diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts index 53822df0..3c9e88f5 100644 --- a/lib/extensions/_generated/extension-list.ts +++ b/lib/extensions/_generated/extension-list.ts @@ -5,6 +5,7 @@ import { emailExtension } from '@/extensions/general/email' import { arcimMigrationExtension } from '@/extensions/general/arcim-migration' import { ticExtension } from '@/extensions/general/tic' import { mcpServerExtension } from '@/extensions/general/mcp-server' +import { cloudBackupExtension } from '@/extensions/general/cloud-backup' export const FIRST_PARTY_EXTENSIONS: Extension[] = [ enableBankingExtension, @@ -12,4 +13,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [ arcimMigrationExtension, ticExtension, mcpServerExtension, + cloudBackupExtension, ] diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index 0bd3653e..b58035ee 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -68,5 +68,17 @@ export const EXTENSION_DEFINITIONS: Record = { "description": "Gör bokföring via Claude, Cursor eller annan MCP-klient", "longDescription": "Exponerar gnuboks bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor." }, + { + "slug": "cloud-backup", + "name": "Molnsynkronisering", + "sector": "general", + "category": "operations", + "icon": "Cloud", + "dataPattern": "manual", + "description": "Synka säkerhetsbackup till din egen molnlagring", + "longDescription": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Gnubok skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.", + "hasOwnData": true, + "subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive — ingen data lagras hos tredje part utöver Google." + }, ], } diff --git a/lib/extensions/_generated/workspace-map.tsx b/lib/extensions/_generated/workspace-map.tsx index 886570bf..f5699696 100644 --- a/lib/extensions/_generated/workspace-map.tsx +++ b/lib/extensions/_generated/workspace-map.tsx @@ -7,4 +7,5 @@ export const WORKSPACES: Record> 'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')), 'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')), 'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')), + 'general/cloud-backup': dynamic(() => import('@/components/extensions/general/CloudBackupWorkspace')), } diff --git a/lib/extensions/settings-panel-registry.tsx b/lib/extensions/settings-panel-registry.tsx index 1c01f659..bafb16ed 100644 --- a/lib/extensions/settings-panel-registry.tsx +++ b/lib/extensions/settings-panel-registry.tsx @@ -13,6 +13,9 @@ const SETTINGS_PANELS: Record = { 'enable-banking': dynamic( () => import('@/extensions/general/enable-banking/components/BankingSettingsPanel') ), + 'cloud-backup': dynamic( + () => import('@/extensions/general/cloud-backup/components/CloudBackupCard') + ), } /** diff --git a/lib/reports/__tests__/full-archive-export.test.ts b/lib/reports/__tests__/full-archive-export.test.ts index 073dc351..ffb77657 100644 --- a/lib/reports/__tests__/full-archive-export.test.ts +++ b/lib/reports/__tests__/full-archive-export.test.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach } from 'vitest' import JSZip from 'jszip' -import { generateFullArchive } from '../full-archive-export' +import { generateFullArchive, estimateArchiveSize } from '../full-archive-export' import { createQueuedMockSupabase } from '@/tests/helpers' +import { getAuditLog } from '@/lib/core/audit/audit-service' vi.mock('../sie-export', () => ({ generateSIEExport: vi.fn().mockResolvedValue('#FLAGGA 0\n#PROGRAM "ERPBase"'), @@ -62,10 +63,257 @@ vi.mock('@/lib/core/audit/audit-service', () => ({ getAuditLog: vi.fn().mockResolvedValue({ data: [], count: 0 }), })) +const mockGetAuditLog = vi.mocked(getAuditLog) + +const COMPANY_ROW = { + company_name: 'Test AB', + trade_name: null, + org_number: '5566778899', + moms_period: 'quarterly', +} + +const PERIOD_2024 = { + id: 'period-2024', + period_start: '2024-01-01', + period_end: '2024-12-31', + opening_balance_entry_id: null, +} + +const PERIOD_2023 = { + id: 'period-2023', + period_start: '2023-01-01', + period_end: '2023-12-31', + opening_balance_entry_id: null, +} + describe('generateFullArchive', () => { let supabase: ReturnType['supabase'] let enqueueMany: ReturnType['enqueueMany'] + beforeEach(() => { + vi.clearAllMocks() + mockGetAuditLog.mockResolvedValue({ data: [], count: 0 }) + const mock = createQueuedMockSupabase() + supabase = mock.supabase + enqueueMany = mock.enqueueMany + }) + + describe('scope: period', () => { + it('generates a ZIP with expected file structure', async () => { + enqueueMany([ + { data: COMPANY_ROW }, // company_settings + { data: PERIOD_2024 }, // fiscal_periods (single) + { data: [] }, // document_attachments + ]) + + const buffer = await generateFullArchive(supabase as any, 'company-1', { + scope: 'period', + period_id: PERIOD_2024.id, + }) + + const zip = await JSZip.loadAsync(buffer) + + expect(zip.file('bokforing.se')).not.toBeNull() + expect(zip.file('rapporter/saldobalans.json')).not.toBeNull() + expect(zip.file('rapporter/resultatrakning.json')).not.toBeNull() + expect(zip.file('rapporter/balansrakning.json')).not.toBeNull() + expect(zip.file('rapporter/huvudbok.json')).not.toBeNull() + expect(zip.file('rapporter/grundbok.json')).not.toBeNull() + expect(zip.file('rapporter/momsdeklaration.json')).not.toBeNull() + expect(zip.file('dokument/manifest.json')).not.toBeNull() + expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull() + expect(zip.file('revision/systemdokumentation.json')).not.toBeNull() + }) + + it('handles missing documents gracefully', async () => { + enqueueMany([ + { data: COMPANY_ROW }, + { data: PERIOD_2024 }, + { + data: [ + { + id: 'doc-1', + file_name: 'receipt.pdf', + storage_path: 'documents/user-1/receipt.pdf', + journal_entry_id: 'entry-1', + }, + ], + }, + { data: [{ id: 'entry-1', fiscal_period_id: PERIOD_2024.id }] }, + ]) + + supabase.storage.from = vi.fn().mockReturnValue({ + download: vi.fn().mockResolvedValue({ + data: null, + error: { message: 'File not found' }, + }), + }) + + const buffer = await generateFullArchive(supabase as any, 'company-1', { + scope: 'period', + period_id: PERIOD_2024.id, + }) + + const zip = await JSZip.loadAsync(buffer) + const manifestFile = zip.file('dokument/manifest.json') + expect(manifestFile).not.toBeNull() + + const manifest = JSON.parse(await manifestFile!.async('text')) + expect(manifest).toHaveLength(1) + expect(manifest[0].status).toBe('error') + expect(manifest[0].error).toBe('File not found') + expect(manifest[0].fiscal_period_id).toBe(PERIOD_2024.id) + }) + + it('skips documents when include_documents is false', async () => { + enqueueMany([ + { data: COMPANY_ROW }, + { data: PERIOD_2024 }, + ]) + + const buffer = await generateFullArchive(supabase as any, 'company-1', { + scope: 'period', + period_id: PERIOD_2024.id, + include_documents: false, + }) + + const zip = await JSZip.loadAsync(buffer) + + expect(zip.file('dokument/manifest.json')).toBeNull() + expect(zip.file('bokforing.se')).not.toBeNull() + expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull() + }) + + it('throws when fiscal period not found', async () => { + enqueueMany([ + { data: COMPANY_ROW }, + { data: null }, + ]) + + await expect( + generateFullArchive(supabase as any, 'company-1', { + scope: 'period', + period_id: 'nonexistent', + }) + ).rejects.toThrow('Fiscal period not found') + }) + + it('filters audit trail by period dates', async () => { + enqueueMany([ + { data: COMPANY_ROW }, + { data: PERIOD_2024 }, + { data: [] }, + ]) + + await generateFullArchive(supabase as any, 'company-1', { + scope: 'period', + period_id: PERIOD_2024.id, + }) + + expect(mockGetAuditLog).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + expect.objectContaining({ + from_date: PERIOD_2024.period_start, + to_date: `${PERIOD_2024.period_end}T23:59:59.999Z`, + }) + ) + }) + }) + + describe('scope: all', () => { + it('generates per-period SIE files and report subfolders', async () => { + enqueueMany([ + { data: COMPANY_ROW }, + { data: [PERIOD_2023, PERIOD_2024] }, // fiscal_periods (list for fetchAllPeriods) + { data: [] }, // document_attachments + ]) + + const buffer = await generateFullArchive(supabase as any, 'company-1', { + scope: 'all', + }) + + const zip = await JSZip.loadAsync(buffer) + + expect(zip.file('sie/2023-01-01_2023-12-31.se')).not.toBeNull() + expect(zip.file('sie/2024-01-01_2024-12-31.se')).not.toBeNull() + expect(zip.file('rapporter/2023-01-01_2023-12-31/saldobalans.json')).not.toBeNull() + expect(zip.file('rapporter/2024-01-01_2024-12-31/saldobalans.json')).not.toBeNull() + expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull() + expect(zip.file('revision/systemdokumentation.json')).not.toBeNull() + // No root bokforing.se in all-mode + expect(zip.file('bokforing.se')).toBeNull() + }) + + it('does not filter audit trail by date in all-mode', async () => { + enqueueMany([ + { data: COMPANY_ROW }, + { data: [PERIOD_2024] }, + { data: [] }, + ]) + + await generateFullArchive(supabase as any, 'company-1', { scope: 'all' }) + + const call = mockGetAuditLog.mock.calls[0] + expect(call[2]).not.toHaveProperty('from_date') + expect(call[2]).not.toHaveProperty('to_date') + }) + + it('tags each document with its fiscal_period_id across periods', async () => { + enqueueMany([ + { data: COMPANY_ROW }, + { data: [PERIOD_2023, PERIOD_2024] }, + { + data: [ + { id: 'doc-2023', file_name: 'r23.pdf', storage_path: 'p/r23.pdf', journal_entry_id: 'e-2023' }, + { id: 'doc-2024', file_name: 'r24.pdf', storage_path: 'p/r24.pdf', journal_entry_id: 'e-2024' }, + ], + }, + { + data: [ + { id: 'e-2023', fiscal_period_id: PERIOD_2023.id }, + { id: 'e-2024', fiscal_period_id: PERIOD_2024.id }, + ], + }, + ]) + + const buffer = await generateFullArchive(supabase as any, 'company-1', { + scope: 'all', + }) + + const zip = await JSZip.loadAsync(buffer) + const manifestFile = zip.file('dokument/manifest.json') + expect(manifestFile).not.toBeNull() + + const manifest = JSON.parse(await manifestFile!.async('text')) + expect(manifest).toHaveLength(2) + const byId = Object.fromEntries( + (manifest as Array<{ document_id: string; fiscal_period_id: string | null }>).map((m) => [ + m.document_id, + m.fiscal_period_id, + ]) + ) + expect(byId['doc-2023']).toBe(PERIOD_2023.id) + expect(byId['doc-2024']).toBe(PERIOD_2024.id) + }) + + it('throws when no fiscal periods exist', async () => { + enqueueMany([ + { data: COMPANY_ROW }, + { data: [] }, + ]) + + await expect( + generateFullArchive(supabase as any, 'company-1', { scope: 'all' }) + ).rejects.toThrow('No fiscal periods found') + }) + }) +}) + +describe('estimateArchiveSize', () => { + let supabase: ReturnType['supabase'] + let enqueueMany: ReturnType['enqueueMany'] + beforeEach(() => { vi.clearAllMocks() const mock = createQueuedMockSupabase() @@ -73,165 +321,35 @@ describe('generateFullArchive', () => { enqueueMany = mock.enqueueMany }) - function enqueueStandardResponses(opts?: { includeDocuments?: boolean }) { - // 1. fiscal_periods query + it('sums document file_size_bytes in all-mode plus overhead', async () => { enqueueMany([ - { - data: { - id: 'period-1', - period_start: '2024-01-01', - period_end: '2024-12-31', - user_id: 'user-1', - }, - }, - // 2. company_settings query - { - data: { - company_name: 'Test AB', - org_number: '5566778899', - moms_period: 'quarterly', - }, - }, - ]) - - if (opts?.includeDocuments !== false) { - enqueueMany([ - // 3. document_attachments query - { data: [] }, - ]) - } - } - - it('generates a ZIP with expected file structure', async () => { - enqueueStandardResponses() - - const buffer = await generateFullArchive(supabase as any, 'company-1', { - period_id: 'period-1', - }) - - const zip = await JSZip.loadAsync(buffer) - - expect(zip.file('bokforing.se')).not.toBeNull() - expect(zip.file('rapporter/saldobalans.json')).not.toBeNull() - expect(zip.file('rapporter/resultatrakning.json')).not.toBeNull() - expect(zip.file('rapporter/balansrakning.json')).not.toBeNull() - expect(zip.file('rapporter/huvudbok.json')).not.toBeNull() - expect(zip.file('rapporter/grundbok.json')).not.toBeNull() - expect(zip.file('rapporter/momsdeklaration.json')).not.toBeNull() - expect(zip.file('dokument/manifest.json')).not.toBeNull() - expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull() - }) - - it('handles missing documents gracefully', async () => { - const mock = createQueuedMockSupabase() - supabase = mock.supabase - enqueueMany = mock.enqueueMany - - enqueueMany([ - // fiscal_periods - { - data: { - id: 'period-1', - period_start: '2024-01-01', - period_end: '2024-12-31', - user_id: 'user-1', - }, - }, - // company_settings - { - data: { - company_name: 'Test AB', - org_number: '5566778899', - moms_period: 'quarterly', - }, - }, - // document_attachments — one document { data: [ - { - id: 'doc-1', - file_name: 'receipt.pdf', - storage_path: 'documents/user-1/receipt.pdf', - journal_entry_id: 'entry-1', - }, + { file_size_bytes: 1_000_000, journal_entry_id: 'e1' }, + { file_size_bytes: 2_500_000, journal_entry_id: 'e2' }, ], - }, - // journal_entries in period - { - data: [{ id: 'entry-1' }], + count: 2, }, ]) - // Mock storage download to fail - supabase.storage.from = vi.fn().mockReturnValue({ - download: vi.fn().mockResolvedValue({ - data: null, - error: { message: 'File not found' }, - }), - }) + const result = await estimateArchiveSize(supabase as any, 'company-1', 'all') - const buffer = await generateFullArchive(supabase as any, 'company-1', { - period_id: 'period-1', - }) - - const zip = await JSZip.loadAsync(buffer) - const manifestFile = zip.file('dokument/manifest.json') - expect(manifestFile).not.toBeNull() - - const manifest = JSON.parse(await manifestFile!.async('text')) - expect(manifest).toHaveLength(1) - expect(manifest[0].status).toBe('error') - expect(manifest[0].error).toBe('File not found') + 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) }) - it('skips documents when include_documents is false', async () => { - const mock = createQueuedMockSupabase() - supabase = mock.supabase - enqueueMany = mock.enqueueMany - + it('returns overhead only when no documents in scope', async () => { enqueueMany([ - // fiscal_periods - { - data: { - id: 'period-1', - period_start: '2024-01-01', - period_end: '2024-12-31', - user_id: 'user-1', - }, - }, - // company_settings - { - data: { - company_name: 'Test AB', - org_number: '5566778899', - moms_period: 'quarterly', - }, - }, + { data: [], count: 0 }, // journal_entries for periodEntryIds + { data: [], count: 0 }, // document_attachments ]) - const buffer = await generateFullArchive(supabase as any, 'company-1', { - period_id: 'period-1', - include_documents: false, - }) + const result = await estimateArchiveSize(supabase as any, 'company-1', 'period', 'p-1') - const zip = await JSZip.loadAsync(buffer) - - // Should not have dokument folder - expect(zip.file('dokument/manifest.json')).toBeNull() - // Should still have other files - expect(zip.file('bokforing.se')).not.toBeNull() - expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull() - }) - - it('throws when fiscal period not found', async () => { - const mock = createQueuedMockSupabase() - supabase = mock.supabase - enqueueMany = mock.enqueueMany - - enqueueMany([{ data: null }]) - - await expect( - generateFullArchive(supabase as any, 'company-1', { period_id: 'nonexistent' }) - ).rejects.toThrow('Fiscal period not found') + expect(result.document_bytes).toBe(0) + expect(result.document_count).toBe(0) + expect(result.total_bytes).toBe(5 * 1024 * 1024) }) }) diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 3f6a6c3c..1f02d846 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -11,10 +11,11 @@ import { getAuditLog } from '@/lib/core/audit/audit-service' import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { AuditLogEntry } from '@/types' -export interface FullArchiveOptions { - period_id: string - include_documents?: boolean -} +export type FullArchiveOptions = + | { scope: 'period'; period_id: string; include_documents?: boolean } + | { scope: 'all'; include_documents?: boolean } + +export type ArchiveScope = FullArchiveOptions['scope'] interface DocumentManifestEntry { document_id: string @@ -22,6 +23,7 @@ interface DocumentManifestEntry { storage_path: string sha256_hash: string journal_entry_id: string | null + fiscal_period_id: string | null version: number digitization_date: string | null upload_source: string | null @@ -31,133 +33,320 @@ interface DocumentManifestEntry { error?: string } +interface FiscalPeriodRow { + id: string + period_start: string + period_end: string + opening_balance_entry_id: string | null +} + +interface CompanyInfo { + company_name: string | null + trade_name: string | null + org_number: string | null + moms_period: string | null +} + +interface DocumentRow { + id: string + file_name: string + storage_path: string + journal_entry_id: string | null + sha256_hash: string + version: number + digitization_date: string | null + upload_source: string | null + mime_type: string | null + file_size_bytes: number | null +} + +interface PeriodReports { + trialBalance: unknown + incomeStatement: unknown + balanceSheet: unknown + generalLedger: unknown + journalRegister: unknown + vatDeclaration: unknown | null +} + +const REPORT_CONCURRENCY = 3 +const ARCHIVE_OVERHEAD_BYTES = 5 * 1024 * 1024 + /** - * Generate a full archive ZIP for a fiscal period. + * Generate a full archive ZIP for a company. * - * Contains SIE4 file, all financial reports, attached documents, and audit trail. - * This fulfills the Swedish accounting law (BFL) requirement for complete archives. + * `scope: 'period'` produces the single-period archive used by account/company + * deletion flows: `bokforing.se`, flat `rapporter/*.json`, `dokument/*`, and + * `revision/*`. + * + * `scope: 'all'` produces the "säkerhetsbackup" covering the entire company + * history: one SIE4 file per period under `sie/`, per-period `rapporter/` + * subfolders, a flat `dokument/` with manifest tagged by fiscal_period_id, + * and an unfiltered `revision/behandlingshistorik.json`. */ export async function generateFullArchive( supabase: SupabaseClient, companyId: string, options: FullArchiveOptions ): Promise { - const { period_id, include_documents = true } = options + const company = await fetchCompany(supabase, companyId) + const periods = + options.scope === 'all' + ? await fetchAllPeriods(supabase, companyId) + : [await fetchSinglePeriod(supabase, companyId, options.period_id)] - // Fetch fiscal period - const { data: period } = await supabase - .from('fiscal_periods') - .select('*') - .eq('id', period_id) - .eq('company_id', companyId) - .single() - - if (!period) { - throw new Error('Fiscal period not found') + if (periods.length === 0) { + throw new Error('No fiscal periods found') } - // Fetch company settings - const { data: company } = await supabase + const zip = new JSZip() + + if (options.scope === 'all') { + const sieFolder = zip.folder('sie')! + const rapporterFolder = zip.folder('rapporter')! + + for (let i = 0; i < periods.length; i += REPORT_CONCURRENCY) { + const batch = periods.slice(i, i + REPORT_CONCURRENCY) + await Promise.all( + batch.map(async (period) => { + const sie = await generateSIEExport(supabase, companyId, { + fiscal_period_id: period.id, + company_name: company.company_name || 'Unknown', + trade_name: company.trade_name, + org_number: company.org_number, + program_name: 'ERPBase', + }) + sieFolder.file(`${periodLabel(period)}.se`, sie) + + const reports = await generatePeriodReports(supabase, companyId, period) + const periodFolder = rapporterFolder.folder(periodLabel(period))! + writeReports(periodFolder, reports) + }) + ) + } + } else { + const period = periods[0] + const sie = await generateSIEExport(supabase, companyId, { + fiscal_period_id: period.id, + company_name: company.company_name || 'Unknown', + trade_name: company.trade_name, + org_number: company.org_number, + program_name: 'ERPBase', + }) + zip.file('bokforing.se', sie) + + const reports = await generatePeriodReports(supabase, companyId, period) + const rapporter = zip.folder('rapporter')! + writeReports(rapporter, reports) + } + + if (options.include_documents !== false) { + await writeDocuments(zip, supabase, companyId, periods, options.scope) + } + + const revision = zip.folder('revision')! + + const auditFilters = + options.scope === 'period' + ? { + from_date: periods[0].period_start, + to_date: `${periods[0].period_end}T23:59:59.999Z`, + } + : {} + const auditEntries = await fetchAllAuditEntries(supabase, companyId, auditFilters) + revision.file('behandlingshistorik.json', JSON.stringify(auditEntries, null, 2)) + + const systemDoc = await buildSystemDoc(supabase, companyId, periods, options.scope) + revision.file('systemdokumentation.json', JSON.stringify(systemDoc, null, 2)) + + return zip.generateAsync({ type: 'arraybuffer' }) +} + +/** + * Estimate the uncompressed size of the archive in bytes. + * + * Sums `file_size_bytes` across all documents in scope plus a fixed overhead + * for SIE, reports, audit trail, and system documentation. Used by the API + * route to short-circuit generation when the payload would exceed the + * platform's response-size ceiling. + */ +export async function estimateArchiveSize( + supabase: SupabaseClient, + companyId: string, + scope: ArchiveScope, + periodId?: string +): Promise<{ total_bytes: number; document_bytes: number; document_count: number }> { + let query = supabase + .from('document_attachments') + .select('file_size_bytes, journal_entry_id', { count: 'exact' }) + .eq('company_id', companyId) + .not('journal_entry_id', 'is', null) + + if (scope === 'period') { + if (!periodId) { + throw new Error('period_id is required for scope=period') + } + const periodEntryIds = await fetchAllRows<{ id: string }>(({ from, to }) => + supabase + .from('journal_entries') + .select('id') + .eq('company_id', companyId) + .eq('fiscal_period_id', periodId) + .in('status', ['posted', 'reversed']) + .range(from, to) + ) + const ids = periodEntryIds.map((e) => e.id) + if (ids.length === 0) { + return { total_bytes: ARCHIVE_OVERHEAD_BYTES, document_bytes: 0, document_count: 0 } + } + query = query.in('journal_entry_id', ids) + } + + const { data, error } = await query + if (error) { + throw new Error(`Failed to estimate archive size: ${error.message}`) + } + + const rows = (data as { file_size_bytes: number | null }[]) || [] + const documentBytes = rows.reduce((sum, r) => sum + (Number(r.file_size_bytes) || 0), 0) + + return { + total_bytes: documentBytes + ARCHIVE_OVERHEAD_BYTES, + document_bytes: documentBytes, + document_count: rows.length, + } +} + +async function fetchCompany(supabase: SupabaseClient, companyId: string): Promise { + const { data } = await supabase .from('company_settings') .select('company_name, trade_name, org_number, moms_period') .eq('company_id', companyId) .single() - if (!company) { + if (!data) { throw new Error('Company settings not found') } + return data as CompanyInfo +} - const zip = new JSZip() +async function fetchSinglePeriod( + supabase: SupabaseClient, + companyId: string, + periodId: string +): Promise { + const { data } = await supabase + .from('fiscal_periods') + .select('id, period_start, period_end, opening_balance_entry_id') + .eq('id', periodId) + .eq('company_id', companyId) + .single() - // 1. SIE4 export - const sieContent = await generateSIEExport(supabase, companyId, { - fiscal_period_id: period_id, - company_name: company.company_name || 'Unknown', - trade_name: company.trade_name, - org_number: company.org_number, - program_name: 'ERPBase', - }) - zip.file('bokforing.se', sieContent) + if (!data) { + throw new Error('Fiscal period not found') + } + return data as FiscalPeriodRow +} - // 2. Reports folder - const rapporter = zip.folder('rapporter')! +async function fetchAllPeriods( + supabase: SupabaseClient, + companyId: string +): Promise { + const rows = await fetchAllRows(({ from, to }) => + supabase + .from('fiscal_periods') + .select('id, period_start, period_end, opening_balance_entry_id') + .eq('company_id', companyId) + .order('period_start', { ascending: true }) + .range(from, to) + ) + return rows +} +async function generatePeriodReports( + supabase: SupabaseClient, + companyId: string, + period: FiscalPeriodRow +): Promise { const [trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister] = await Promise.all([ - generateTrialBalance(supabase, companyId, period_id), - generateIncomeStatement(supabase, companyId, period_id), - generateBalanceSheet(supabase, companyId, period_id), - generateGeneralLedger(supabase, companyId, period_id), - generateJournalRegister(supabase, companyId, period_id), + generateTrialBalance(supabase, companyId, period.id), + generateIncomeStatement(supabase, companyId, period.id), + generateBalanceSheet(supabase, companyId, period.id), + generateGeneralLedger(supabase, companyId, period.id), + generateJournalRegister(supabase, companyId, period.id), ]) - rapporter.file('saldobalans.json', JSON.stringify(trialBalance, null, 2)) - rapporter.file('resultatrakning.json', JSON.stringify(incomeStatement, null, 2)) - rapporter.file('balansrakning.json', JSON.stringify(balanceSheet, null, 2)) - rapporter.file('huvudbok.json', JSON.stringify(generalLedger, null, 2)) - rapporter.file('grundbok.json', JSON.stringify(journalRegister, null, 2)) - - // VAT declaration — calculate for the full fiscal period as yearly + let vatDeclaration: unknown = null try { const startDate = new Date(period.period_start) - const vatDeclaration = await calculateVatDeclaration( + vatDeclaration = await calculateVatDeclaration( supabase, companyId, 'yearly', startDate.getFullYear(), 1 ) - rapporter.file('momsdeklaration.json', JSON.stringify(vatDeclaration, null, 2)) } catch { // VAT declaration may fail if no relevant entries exist — skip gracefully } - // 3. Documents folder - if (include_documents) { - const dokument = zip.folder('dokument')! - const manifest: DocumentManifestEntry[] = [] + return { trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister, vatDeclaration } +} - // Fetch document attachments linked to journal entries in this period - // Wrapped in try/catch to match VAT section — a failed document fetch - // should not prevent the rest of the archive from being generated. - try { - const documents = await fetchAllRows<{ - id: string; file_name: string; storage_path: string; journal_entry_id: string | null - sha256_hash: string; version: number; digitization_date: string | null - upload_source: string | null; mime_type: string | null; file_size_bytes: number | null - }>(({ from, to }) => +function writeReports(folder: JSZip, reports: PeriodReports): void { + folder.file('saldobalans.json', JSON.stringify(reports.trialBalance, null, 2)) + folder.file('resultatrakning.json', JSON.stringify(reports.incomeStatement, null, 2)) + folder.file('balansrakning.json', JSON.stringify(reports.balanceSheet, null, 2)) + folder.file('huvudbok.json', JSON.stringify(reports.generalLedger, null, 2)) + folder.file('grundbok.json', JSON.stringify(reports.journalRegister, null, 2)) + if (reports.vatDeclaration) { + folder.file('momsdeklaration.json', JSON.stringify(reports.vatDeclaration, null, 2)) + } +} + +async function writeDocuments( + zip: JSZip, + supabase: SupabaseClient, + companyId: string, + periods: FiscalPeriodRow[], + scope: ArchiveScope +): Promise { + const dokument = zip.folder('dokument')! + const manifest: DocumentManifestEntry[] = [] + + try { + const documents = await fetchAllRows(({ from, to }) => supabase .from('document_attachments') - .select('id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes') + .select( + 'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes' + ) .eq('company_id', companyId) .not('journal_entry_id', 'is', null) .range(from, to) ) if (documents.length > 0) { - // Filter to entries in this period - const periodEntryIds = await fetchAllRows<{ id: string }>(({ from, to }) => - supabase - .from('journal_entries') - .select('id') - .eq('company_id', companyId) - .eq('fiscal_period_id', period_id) - .in('status', ['posted', 'reversed']) - .range(from, to) - ) + const entryIdToPeriodId = await buildEntryToPeriodMap(supabase, companyId, periods, scope) - const periodEntryIdSet = new Set(periodEntryIds.map((e) => e.id)) - const periodDocuments = documents.filter( - (d: { journal_entry_id: string | null }) => d.journal_entry_id && periodEntryIdSet.has(d.journal_entry_id) - ) + const inScopeDocuments = + scope === 'period' + ? documents.filter((d) => d.journal_entry_id && entryIdToPeriodId.has(d.journal_entry_id)) + : documents.filter((d) => d.journal_entry_id) // all-mode: keep every linked doc + + for (const doc of inScopeDocuments) { + const fiscalPeriodId = doc.journal_entry_id + ? entryIdToPeriodId.get(doc.journal_entry_id) ?? null + : null - for (const doc of periodDocuments) { const baseManifest = { document_id: doc.id, file_name: doc.file_name, storage_path: doc.storage_path, sha256_hash: doc.sha256_hash, journal_entry_id: doc.journal_entry_id, + fiscal_period_id: fiscalPeriodId, version: doc.version, digitization_date: doc.digitization_date, upload_source: doc.upload_source, @@ -180,13 +369,9 @@ export async function generateFullArchive( } const buffer = await fileData.arrayBuffer() - // Prefix with document ID to prevent duplicate filename collisions const zipFileName = `${doc.id}_${doc.file_name}` dokument.file(zipFileName, buffer) - manifest.push({ - ...baseManifest, - status: 'downloaded', - }) + manifest.push({ ...baseManifest, status: 'downloaded' }) } catch (err) { manifest.push({ ...baseManifest, @@ -196,50 +381,90 @@ export async function generateFullArchive( } } } - } catch { - // Document fetch failed — archive will still contain reports and audit trail - } - - dokument.file('manifest.json', JSON.stringify(manifest, null, 2)) + } catch { + // Document fetch failed — archive will still contain reports and audit trail } - // 4. Audit trail - const revision = zip.folder('revision')! - const allAuditEntries: AuditLogEntry[] = [] + dokument.file('manifest.json', JSON.stringify(manifest, null, 2)) +} + +async function buildEntryToPeriodMap( + supabase: SupabaseClient, + companyId: string, + periods: FiscalPeriodRow[], + scope: ArchiveScope +): Promise> { + const map = new Map() + const periodIds = periods.map((p) => p.id) + if (periodIds.length === 0) return map + + let query = supabase + .from('journal_entries') + .select('id, fiscal_period_id') + .eq('company_id', companyId) + .in('status', ['posted', 'reversed']) + + if (scope === 'period') { + query = query.eq('fiscal_period_id', periodIds[0]) + } else { + query = query.in('fiscal_period_id', periodIds) + } + + const entries = await fetchAllRows<{ id: string; fiscal_period_id: string }>(({ from, to }) => + query.range(from, to) + ) + + for (const entry of entries) { + map.set(entry.id, entry.fiscal_period_id) + } + return map +} + +async function fetchAllAuditEntries( + supabase: SupabaseClient, + companyId: string, + filters: { from_date?: string; to_date?: string } +): Promise { + const all: AuditLogEntry[] = [] let page = 1 const pageSize = 500 while (true) { - const result = await getAuditLog(supabase, companyId, { - from_date: period.period_start, - to_date: period.period_end, - page, - pageSize, - }) - allAuditEntries.push(...result.data) - if (allAuditEntries.length >= result.count || result.data.length < pageSize) { + const result = await getAuditLog(supabase, companyId, { ...filters, page, pageSize }) + all.push(...result.data) + if (all.length >= result.count || result.data.length < pageSize) { break } page++ } + return all +} - revision.file('behandlingshistorik.json', JSON.stringify(allAuditEntries, null, 2)) +async function buildSystemDoc( + supabase: SupabaseClient, + companyId: string, + periods: FiscalPeriodRow[], + scope: ArchiveScope +): Promise> { + let voucherSeriesQuery = supabase + .from('voucher_sequences') + .select('voucher_series, last_number, fiscal_period_id') + .eq('company_id', companyId) + + if (scope === 'period') { + voucherSeriesQuery = voucherSeriesQuery.eq('fiscal_period_id', periods[0].id) + } - // 5. Systemdokumentation (BFNAR 2013:2 kap 8) const [accountsResult, voucherSeriesResult] = await Promise.all([ supabase .from('chart_of_accounts') .select('account_number, account_name, account_type, is_active') .eq('company_id', companyId) .order('account_number'), - supabase - .from('voucher_sequences') - .select('voucher_series, last_number') - .eq('company_id', companyId) - .eq('fiscal_period_id', period_id), + voucherSeriesQuery, ]) - const systemdokumentation = { + return { system: { name: 'gnubok', description: 'Bokforingssystem for enskild firma och aktiebolag', @@ -249,10 +474,13 @@ export async function generateFullArchive( standard: 'BAS 2026', accounts: accountsResult.data || [], }, - verifikationsserier: (voucherSeriesResult.data || []).map((vs: { voucher_series: string; last_number: number }) => ({ - serie: vs.voucher_series, - senaste_nummer: vs.last_number, - })), + verifikationsserier: (voucherSeriesResult.data || []).map( + (vs: { voucher_series: string; last_number: number; fiscal_period_id?: string }) => ({ + serie: vs.voucher_series, + senaste_nummer: vs.last_number, + fiscal_period_id: vs.fiscal_period_id ?? null, + }) + ), behorighetskontroll: { description: 'Rollbaserad atkomstkontroll med owner/admin/member/viewer', mfa_stod: true, @@ -270,14 +498,14 @@ export async function generateFullArchive( export_format: 'SIE4', }, generated_at: new Date().toISOString(), - fiscal_period: { - id: period.id, - start: period.period_start, - end: period.period_end, - }, + fiscal_periods: periods.map((p) => ({ + id: p.id, + start: p.period_start, + end: p.period_end, + })), } +} - revision.file('systemdokumentation.json', JSON.stringify(systemdokumentation, null, 2)) - - return zip.generateAsync({ type: 'arraybuffer' }) +function periodLabel(period: FiscalPeriodRow): string { + return `${period.period_start}_${period.period_end}` }