Feat/cloud backup (#277)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-04-20 10:49:59 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 2ea5a72b3d
commit d708a85d4c
34 changed files with 2900 additions and 314 deletions
@@ -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: <email>` 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: <customer@domain>` (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: <concise imperative title, ≤ 80 chars>
Labels: <from: bug, feature, report, improvement, error + priority label>
Priority: <high | medium | low>
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):
<The full body of the customer's first message, with all personal names replaced by `x`. See the anonymization rules below.>
Customer: <email address>
Gmail thread ID: <threadId>
```
**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: #<number> — <existing title>
URL: <issue url>
Gmail thread ID: <threadId>
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-<N>.md <<'EOF'
<description paragraph>
## Relevant code
- path/to/file.ts:line — note
- path/to/other.ts — note
## Next steps
- Step 1
- Step 2
- Step 3
## Customer email (anonymized)
> <Full body of the customer's first message, wrapped as a blockquote, with all personal names replaced by `x`.>
---
**Customer:** <email>
**Gmail thread ID:** `<threadId>`
EOF
# Create the issue
gh issue create \
--repo erp-mafia/gnubok \
--title "<approved title>" \
--body-file /tmp/issue-body-<N>.md \
--label "<label1>" --label "<label2>"
```
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 <issue-number> \
--repo erp-mafia/gnubok \
--body "Another customer report of this issue. Customer: \`<email>\`. Gmail thread ID: \`<threadId>\`."
```
**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.).
+1
View File
@@ -231,6 +231,7 @@ export default async function DashboardPage() {
return (
<DashboardContent
firstName={firstName}
companyId={companyId}
settings={settings}
summary={{
ytd: ytdTotals,
+22
View File
@@ -0,0 +1,22 @@
import { BackupDownloadForm } from '@/components/settings/BackupDownloadForm'
export default function BackupSettingsPage() {
return (
<div className="space-y-8">
<section className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Säkerhetsbackup
</h2>
<p className="text-sm text-muted-foreground max-w-prose">
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.
</p>
</section>
<BackupDownloadForm />
</div>
)
}
+2 -2
View File
@@ -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',
+1 -1
View File
@@ -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(
@@ -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' })
})
})
+70 -8
View File
@@ -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}`
}
+15 -7
View File
@@ -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 (
<NewUserChecklist
onFreshStart={() => {
localStorage.setItem(SETUP_FRESH_START_KEY, 'true')
localStorage.setItem(setupFreshStartKey(companyId), 'true')
setSetupGateActive(false)
}}
/>
@@ -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 (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Cloud className="h-12 w-12 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-medium text-foreground">Molnsynkronisering</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-md">
Koppla ditt Google Drive-konto under Säkerhetsbackup för att synka arkiv till din
egen molnlagring.
</p>
<Button asChild variant="outline" className="mt-4">
<Link href="/settings/backup">
<Settings className="mr-2 h-4 w-4" />
Gå till säkerhetsbackup
</Link>
</Button>
</div>
)
}
+359
View File
@@ -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<Scope>('all')
const [includeDocuments, setIncludeDocuments] = useState(true)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriodId, setSelectedPeriodId] = useState<string>('')
const [estimate, setEstimate] = useState<EstimateResponse | null>(null)
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [lastDownloadedAt, setLastDownloadedAt] = useState<string | null>(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 (
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>Skapa backup</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Omfattning</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<ScopeRadio
checked={scope === 'all'}
onChange={() => setScope('all')}
label="Hela historiken"
description="Alla räkenskapsår och verifikationer"
recommended
/>
<ScopeRadio
checked={scope === 'period'}
onChange={() => setScope('period')}
label="En period"
description="Välj ett specifikt räkenskapsår"
/>
</div>
</div>
{scope === 'period' && (
<div className="space-y-2">
<Label htmlFor="backup-period">Räkenskapsår</Label>
<select
id="backup-period"
value={selectedPeriodId}
onChange={(e) => setSelectedPeriodId(e.target.value)}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={periods.length === 0}
>
{periods.length === 0 && <option value="">Inga räkenskapsår</option>}
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.period_start} – {p.period_end}
</option>
))}
</select>
</div>
)}
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="include-documents">Inkludera kvitton och underlag</Label>
<p className="text-xs text-muted-foreground max-w-prose">
Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en.
Stäng av för en mindre backup med bara bokföringsdata.
</p>
</div>
<Switch
id="include-documents"
checked={includeDocuments}
onCheckedChange={setIncludeDocuments}
/>
</div>
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="h-3.5 w-3.5" />
{isLoadingEstimate ? (
<span>Beräknar storlek…</span>
) : estimate ? (
<span>
Uppskattad storlek: <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
{' '}({estimate.document_count} {estimate.document_count === 1 ? 'bilaga' : 'bilagor'})
</span>
) : (
<span>Storlek beräknas när omfattning är vald.</span>
)}
</div>
{isOverLimit && (
<p className="mt-2 text-xs text-destructive">
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.
</p>
)}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button onClick={handleDownload} disabled={!canDownload}>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar backup…
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Skapa och ladda ner
</>
)}
</Button>
{lastDownloadedAt && (
<p className="text-xs text-muted-foreground">
Senaste nedladdning: {formatDate(lastDownloadedAt)}
</p>
)}
</div>
</CardContent>
</Card>
{hasCloudBackup && CloudBackupPanel ? (
<CloudBackupPanel />
) : (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="h-4 w-4 text-muted-foreground" />
Molnsynkronisering
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground max-w-prose">
Aktivera tillägget &ldquo;Molnsynkronisering&rdquo; för att koppla Google
Drive och ladda upp säkerhetsbackupen med ett klick.
</p>
</CardContent>
</Card>
)}
</div>
)
}
interface ScopeRadioProps {
checked: boolean
onChange: () => void
label: string
description: string
recommended?: boolean
}
function ScopeRadio({ checked, onChange, label, description, recommended }: ScopeRadioProps) {
return (
<button
type="button"
onClick={onChange}
className={`flex-1 rounded-lg border-2 p-3 text-left transition-colors ${
checked ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'
}`}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{label}</span>
{recommended && (
<span className="text-[10px] font-medium uppercase tracking-wider text-primary">
Rekommenderas
</span>
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
</button>
)
}
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',
})
}
+2 -9
View File
@@ -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() {
<RetentionNotice variant="company" />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" className="w-full sm:w-auto" asChild>
<Link href="/reports?type=full-archive">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera fullständigt arkiv
</Link>
</Button>
<div className="flex justify-end">
<Button
variant="destructive"
className="w-full sm:w-auto"
+1
View File
@@ -31,6 +31,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/salary', label: 'Löner', show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
{ href: '/settings/backup', label: 'Säkerhetsbackup', show: hasCompany },
{ href: '/settings/account', label: 'Konto', show: true },
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
].filter(item => item.show)
+30 -9
View File
@@ -1,5 +1,6 @@
'use client'
import Link from 'next/link'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -23,18 +24,38 @@ export function RetentionNotice({ variant, className }: RetentionNoticeProps) {
variant === 'company'
? {
title: 'Bokföringen behålls i 7 år',
body:
'Enligt bokföringslagen (BFL 7 kap. 2§) sparas räkenskapsinformation i 7 år. ' +
'När du raderar företaget döljs det i gnubok, men verifikationer, dokument och ' +
'bokföring behålls säkert tills lagkravet löpt ut.',
body: (
<>
Enligt bokföringslagen (BFL 7 kap. 2§) sparas räkenskapsinformation i 7 år.
När du raderar företaget döljs det i gnubok, men verifikationer, dokument och
bokföring behålls säkert tills lagkravet löpt ut. Du kan{' '}
<Link
href="/settings/backup"
className="underline underline-offset-2 hover:text-foreground"
>
ladda ner en säkerhetsbackup
</Link>{' '}
innan du fortsätter.
</>
),
}
: {
title: 'Kontoraderingen är permanent',
body:
'Ditt konto avidentifieras och du loggas ut från alla enheter. Räkenskaps­information ' +
'från företag du ägt behålls säkert i 7 år enligt BFL 7 kap. 2§. Du kan inte skapa ett ' +
'nytt konto med samma e-postadress — kontakta support om du vill återaktivera kontot ' +
'i framtiden. Ladda gärna ner ett fullständigt arkiv innan du fortsätter.',
body: (
<>
Ditt konto avidentifieras och du loggas ut från alla enheter.
Räkenskapsinformation från företag du ägt behålls säkert i 7 år enligt BFL 7
kap. 2§. Du kan inte skapa ett nytt konto med samma e-postadress — kontakta
support om du vill återaktivera kontot i framtiden. Ladda gärna ner en{' '}
<Link
href="/settings/backup"
className="underline underline-offset-2 hover:text-foreground"
>
säkerhetsbackup
</Link>{' '}
innan du fortsätter.
</>
),
}
return (
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server"]}
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup"]}
+2 -1
View File
@@ -27,7 +27,8 @@
"arcim-migration",
"tic",
"mcp-server",
"skatteverket"
"skatteverket",
"cloud-backup"
]
},
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
@@ -0,0 +1,263 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/components/ui/use-toast'
import { Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
import type { CloudBackupStatus } from '../types'
const API_BASE = '/api/extensions/ext/cloud-backup'
export default function CloudBackupCard() {
const { toast } = useToast()
const searchParams = useSearchParams()
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isConnecting, setIsConnecting] = useState(false)
const [isSyncing, setIsSyncing] = useState(false)
const [isDisconnecting, setIsDisconnecting] = useState(false)
const loadStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/status`)
if (!res.ok) throw new Error('Kunde inte hämta status')
const { data } = (await res.json()) as { data: CloudBackupStatus }
setStatus(data)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadStatus()
}, [loadStatus])
// Handle OAuth callback redirect params.
useEffect(() => {
const result = searchParams.get('cloud_backup')
if (!result) return
if (result === 'connected') {
toast({ title: 'Google Drive kopplat', description: 'Du kan nu synka till din Drive.' })
} else if (result === 'error') {
const reason = searchParams.get('reason') || 'Okänt fel'
toast({
title: 'Kunde inte koppla Google Drive',
description: reason,
variant: 'destructive',
})
}
// Clean the URL so refresh doesn't re-fire the toast.
const url = new URL(window.location.href)
url.searchParams.delete('cloud_backup')
url.searchParams.delete('reason')
window.history.replaceState({}, '', url.toString())
}, [searchParams, toast])
const handleConnect = useCallback(async () => {
setIsConnecting(true)
try {
const res = await fetch(`${API_BASE}/connect`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte starta anslutning')
}
const { url } = (await res.json()) as { url: string }
window.location.href = url
} catch (err) {
toast({
title: 'Kunde inte koppla Google Drive',
description: err instanceof Error ? err.message : 'Försök igen.',
variant: 'destructive',
})
setIsConnecting(false)
}
}, [toast])
const handleDisconnect = useCallback(async () => {
setIsDisconnecting(true)
try {
const res = await fetch(`${API_BASE}/disconnect`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte koppla bort')
}
toast({ title: 'Google Drive bortkopplat' })
await loadStatus()
} catch (err) {
toast({
title: 'Kunde inte koppla bort',
description: err instanceof Error ? err.message : 'Försök igen.',
variant: 'destructive',
})
} finally {
setIsDisconnecting(false)
}
}, [loadStatus, toast])
const handleSync = useCallback(async () => {
setIsSyncing(true)
try {
const res = await fetch(`${API_BASE}/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ include_documents: true }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
if (res.status === 413) {
const mb = body.size_bytes
? Math.round(body.size_bytes / (1024 * 1024))
: null
throw new Error(
mb
? `Arkivet är ${mb} MB — större än nuvarande gräns. Minska omfattning eller avvakta bakgrundssynk.`
: 'Arkivet är för stort för direktsynk.'
)
}
throw new Error(body.error || 'Synkningen misslyckades')
}
const { data } = (await res.json()) as {
data: { file_name: string; file_size_bytes: number; web_view_link: string }
}
toast({
title: 'Uppladdad till Google Drive',
description: `${data.file_name} (${formatMb(data.file_size_bytes)})`,
})
await loadStatus()
} catch (err) {
toast({
title: 'Synkningen misslyckades',
description: err instanceof Error ? err.message : 'Försök igen.',
variant: 'destructive',
})
} finally {
setIsSyncing(false)
}
}, [loadStatus, toast])
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="h-4 w-4 text-muted-foreground" />
Google Drive
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Laddar…</p>
) : status?.connected ? (
<>
<div className="text-sm">
<p>
Ansluten som <span className="font-medium">{status.account_email}</span>
</p>
{status.connected_at && (
<p className="text-xs text-muted-foreground">
Kopplat {formatDate(status.connected_at)}
</p>
)}
</div>
{status.last_sync ? (
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-sm">
<p>
Senaste synk: <span className="font-medium">{status.last_sync.file_name}</span>
</p>
<p className="text-xs text-muted-foreground">
{formatDate(status.last_sync.at)} · {formatMb(status.last_sync.file_size_bytes)}
</p>
<a
href={`https://drive.google.com/file/d/${status.last_sync.file_id}/view`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Öppna i Drive
<ExternalLink className="h-3 w-3" />
</a>
</div>
) : (
<p className="text-xs text-muted-foreground">
Ingen synk än — kör &ldquo;Synka nu&rdquo; för att ladda upp första arkivet.
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Synkar…
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Synka nu
</>
)}
</Button>
<Button
variant="outline"
onClick={handleDisconnect}
disabled={isDisconnecting}
>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar bort…
</>
) : (
<>
<Unplug className="mr-2 h-4 w-4" />
Koppla bort
</>
)}
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
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
<span className="font-mono text-xs"> drive.file</span>).
</p>
<Button onClick={handleConnect} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Omdirigerar…
</>
) : (
<>
<Cloud className="mr-2 h-4 w-4" />
Koppla Google Drive
</>
)}
</Button>
</>
)}
</CardContent>
</Card>
)
}
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',
})
}
+309
View File
@@ -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<GoogleDriveConnection | null> {
return ctx.settings.get<GoogleDriveConnection>(CONNECTION_KEY)
}
async function getFreshAccessToken(
ctx: ExtensionContext,
connection: GoogleDriveConnection
): Promise<string> {
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<string> {
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<GoogleDriveLastSync>(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
)
}
},
},
],
}
@@ -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
}
})
})
@@ -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<string, string>
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/)
})
})
@@ -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')
})
})
@@ -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
}
}
@@ -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<Response> {
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<DriveFile | null> {
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<DriveFile> {
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<DriveFile> {
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<UploadResult> {
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, "\\'")
}
@@ -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<TokenExchangeResult> {
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<AccessTokenResult> {
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<void> {
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<string> {
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'
}
@@ -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."
}
}
+36
View File
@@ -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
}
+9 -9
View File
@@ -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<AuditLogEntry[]> {
@@ -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<AuditLogEntry[]> {
@@ -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 })
+3 -3
View File
@@ -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', () => {
@@ -6,4 +6,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'arcim-migration',
'tic',
'mcp-server',
'cloud-backup',
])
@@ -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,
]
@@ -68,5 +68,17 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"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."
},
],
}
@@ -7,4 +7,5 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
'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')),
}
@@ -13,6 +13,9 @@ const SETTINGS_PANELS: Record<string, ComponentType> = {
'enable-banking': dynamic(
() => import('@/extensions/general/enable-banking/components/BankingSettingsPanel')
),
'cloud-backup': dynamic(
() => import('@/extensions/general/cloud-backup/components/CloudBackupCard')
),
}
/**
+265 -147
View File
@@ -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<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['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<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['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)
})
})
+345 -117
View File
@@ -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<ArrayBuffer> {
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<CompanyInfo> {
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<FiscalPeriodRow> {
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<FiscalPeriodRow[]> {
const rows = await fetchAllRows<FiscalPeriodRow>(({ 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<PeriodReports> {
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<void> {
const dokument = zip.folder('dokument')!
const manifest: DocumentManifestEntry[] = []
try {
const documents = await fetchAllRows<DocumentRow>(({ 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<Map<string, string>> {
const map = new Map<string, string>()
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<AuditLogEntry[]> {
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<Record<string, unknown>> {
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}`
}