From 5e1b0f791d2aaa58093471139d845c591c38da68 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:17:41 +0200 Subject: [PATCH] feat(branding): implement dynamic branding in service worker and reports (#383) * feat(branding): implement dynamic branding in service worker and reports * refactor(service-worker): remove push notification handling code * feat(service-worker): implement dynamic branding in service worker and related scripts --- .gitignore | 4 ++++ Dockerfile | 3 +++ WHITELABEL.md | 13 +++++++++- app/api/reports/sie-export/route.ts | 1 - docker-entrypoint.sh | 9 +++++++ lib/reports/full-archive-export.ts | 2 -- lib/reports/ne-bilaga/sru-generator.ts | 7 +++++- lib/reports/sie-export.ts | 8 ++++++- package.json | 4 ++-- public/{sw.js => sw.template.js} | 6 ++++- scripts/inject-public-branding.mjs | 33 ++++++++++++++++++++++++++ 11 files changed, 81 insertions(+), 9 deletions(-) rename public/{sw.js => sw.template.js} (87%) create mode 100644 scripts/inject-public-branding.mjs diff --git a/.gitignore b/.gitignore index 825ac5a8..fa50bb80 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,10 @@ supabase/.temp/ # swarm audit reports (generated by /swarm) .swarm/ +# generated service worker (built from public/sw.template.js by +# scripts/inject-public-branding.mjs — runs via predev/prebuild) +/public/sw.js + # dev docs (internal reference, not published) /dev_docs diff --git a/Dockerfile b/Dockerfile index 5073236d..dbf0c590 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,9 @@ ENV NEXT_PUBLIC_APP_URL=__NEXT_PUBLIC_APP_URL__ ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=__NEXT_PUBLIC_VAPID_PUBLIC_KEY__ ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__ ENV NEXT_PUBLIC_REQUIRE_MFA=__NEXT_PUBLIC_REQUIRE_MFA__ +# Keep the branding placeholder intact through prebuild's inject script so +# docker-entrypoint.sh can substitute the runtime value into public/sw.js. +ENV NEXT_PUBLIC_BRANDING_APP_NAME=__NEXT_PUBLIC_BRANDING_APP_NAME__ ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/WHITELABEL.md b/WHITELABEL.md index 6d660d88..6d5adc12 100644 --- a/WHITELABEL.md +++ b/WHITELABEL.md @@ -53,6 +53,17 @@ Resolution order (last wins): **defaults → env vars → extension override**. `NEXT_PUBLIC_*` env vars are inlined at build time. Changing them requires a fresh `npm run build` to propagate. +`NEXT_PUBLIC_BRANDING_APP_NAME` also stamps the service worker push-notification fallback title in `public/sw.js`. This happens at build time for Vercel/local builds (via `scripts/inject-public-branding.mjs`, run from `prebuild`) and at container start for Docker (via `docker-entrypoint.sh`). + +### Email / Resend (when `email` or `invoice-inbox` extensions are enabled) + +| Env var | Purpose | +|---|---| +| `RESEND_API_KEY` | Resend API key — required for both outbound mail and the inbox webhook | +| `RESEND_FROM_EMAIL` | Default `From` address (e.g. `noreply@your-brand.se`); also used as the address you From-spoof through Resend | +| `RESEND_INBOUND_DOMAIN` | Domain used to compose per-company invoice-inbox addresses: `{local-part}@{RESEND_INBOUND_DOMAIN}` | +| `RESEND_INBOUND_WEBHOOK_SECRET` | Verifies the `/inbound` webhook signature from Resend | + ## Things you MUST NOT change These are stable contracts. Renaming them breaks existing data, sessions, or external clients (npm package consumers, MCP connectors, browser sessions, invite links). Leave them alone in your fork: @@ -75,9 +86,9 @@ A few things that look brand-related but are configured elsewhere: - **Resend sending domain** — verify `noreply@your-brand.se` (or wherever) in Resend, set `RESEND_FROM_EMAIL`. - **DNS / domain** — point `app.your-brand.se` at your Vercel deployment. - **OAuth redirect allowlist for MCP** — `app/api/mcp-oauth/authorize/route.ts` lists `claude.ai/api/*`, `claude.com/api/*`, and localhost. Your domain is the OAuth issuer, not a redirect target — no change needed unless you're integrating with new MCP clients. -- **Service worker push notification fallback title** (`public/sw.js`) — currently hardcoded as `'Ekonomi'`. Service workers can't read env vars at runtime; change the file directly in your fork if it matters. - **iCal feed PRODID** (`lib/calendar/ics-generator.ts`) — defaults to `erp-base.se`, callers may pass their domain. - **`NEXT_PUBLIC_APP_URL`** — used as the OAuth issuer. Set this to your domain (e.g. `https://app.your-brand.se`). +- **Skatteverket submission identity** — `extensions/general/skatteverket/lib/api-client.ts` does not set a custom `User-Agent`; submissions go out with the Node/Vercel runtime default. If your deployment needs to identify itself to Skatteverket under a different brand, that's a future enhancement (env var + header), not something the current branding service covers. ## Staying in sync with upstream diff --git a/app/api/reports/sie-export/route.ts b/app/api/reports/sie-export/route.ts index 34b7643e..06d3dcc6 100644 --- a/app/api/reports/sie-export/route.ts +++ b/app/api/reports/sie-export/route.ts @@ -36,7 +36,6 @@ export async function GET(request: Request) { fiscal_period_id: periodId, company_name: company.company_name || 'Unknown', org_number: company.org_number, - program_name: 'ERPBase', }) // Return as downloadable file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index b1be8795..fecd2954 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -40,4 +40,13 @@ if [ -d /app/.next/static ]; then {} + fi +# Stamp the service worker fallback notification title with the brand name. +# public/sw.js is served as a static file (not bundled by Next), so NEXT_PUBLIC_* +# inlining doesn't reach it — substitute the placeholder here at container start. +if [ -f /app/public/sw.js ]; then + sed -i \ + -e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \ + /app/public/sw.js +fi + exec "$@" diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 7803532b..a1171a37 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -118,7 +118,6 @@ export async function generateFullArchive( 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) @@ -135,7 +134,6 @@ export async function generateFullArchive( company_name: company.company_name || 'Unknown', trade_name: company.trade_name, org_number: company.org_number, - program_name: 'ERPBase', }) zip.file('bokforing.se', sie) diff --git a/lib/reports/ne-bilaga/sru-generator.ts b/lib/reports/ne-bilaga/sru-generator.ts index 4ae7f88c..5793cdb2 100644 --- a/lib/reports/ne-bilaga/sru-generator.ts +++ b/lib/reports/ne-bilaga/sru-generator.ts @@ -1,4 +1,9 @@ import type { NEDeclaration, SRUFile, SRURecord } from '@/lib/reports/ne-bilaga/types' +import { getBranding } from '@/lib/branding/service' + +function sanitizeString(str: string): string { + return str.replace(/#/g, '').replace(/[\r\n]/g, ' ').substring(0, 250) +} /** * SRU File Generator @@ -54,7 +59,7 @@ export function generateSRUFile(declaration: NEDeclaration): SRUFile { // File header records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' }) records.push({ fieldCode: 'SESSION', value: '1' }) - records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' }) + records.push({ fieldCode: 'PROGRAMNAMN', value: sanitizeString(getBranding().appName) }) records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' }) records.push({ fieldCode: 'SKAPAT', diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index e05e6c11..0c6ab025 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -1,7 +1,12 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { getBranding } from '@/lib/branding/service' import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types' +function sanitizeProgramName(str: string): string { + return str.replace(/"/g, '').replace(/[\r\n]/g, ' ').substring(0, 60) +} + /** * Generate SIE4 export file * @@ -81,7 +86,8 @@ export async function generateSIEExport( lines.push('#FLAGGA 0') lines.push('#FORMAT PC8') lines.push('#SIETYP 4') - lines.push(`#PROGRAM "${options.program_name || 'ERPBase'}" "1.0"`) + const programName = sanitizeProgramName(options.program_name || getBranding().appName) + lines.push(`#PROGRAM "${programName}" "1.0"`) lines.push(`#GEN ${formatSIEDate(now)}`) if (options.org_number) { diff --git a/package.json b/package.json index 09f63925..7006bec6 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,9 @@ "license": "AGPL-3.0-or-later", "scripts": { "setup:extensions": "npx tsx scripts/generate-extension-registry.ts", - "predev": "npm run setup:extensions", + "predev": "npm run setup:extensions && node scripts/inject-public-branding.mjs", "dev": "next dev", - "prebuild": "npm run setup:extensions", + "prebuild": "npm run setup:extensions && node scripts/inject-public-branding.mjs", "build": "next build", "start": "next start", "lint": "eslint", diff --git a/public/sw.js b/public/sw.template.js similarity index 87% rename from public/sw.js rename to public/sw.template.js index 7096d7ce..6d6a8035 100644 --- a/public/sw.js +++ b/public/sw.template.js @@ -1,6 +1,10 @@ /** * Service Worker for Push Notifications * Handles incoming push notifications and notification click events + * + * NOTE: This is the source template. The deployed file at public/sw.js is + * generated by scripts/inject-public-branding.mjs (runs via predev/prebuild) + * and is gitignored. Edit this template, not the generated file. */ // Handle push events @@ -32,7 +36,7 @@ self.addEventListener('push', (event) => { } event.waitUntil( - self.registration.showNotification(title || 'Ekonomi', options) + self.registration.showNotification(title || '__NEXT_PUBLIC_BRANDING_APP_NAME__', options) ) }) diff --git a/scripts/inject-public-branding.mjs b/scripts/inject-public-branding.mjs new file mode 100644 index 00000000..93986228 --- /dev/null +++ b/scripts/inject-public-branding.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Stamps public/sw.js from public/sw.template.js with the runtime brand name. + * + * The service worker is served as a static file (Next.js does not bundle + * public/), so NEXT_PUBLIC_* inlining doesn't reach it. We generate sw.js + * from a template at build time (Vercel/local) so the deployed file shows + * the configured brand name. + * + * Docker uses a different strategy: the builder stage exports + * NEXT_PUBLIC_BRANDING_APP_NAME=__NEXT_PUBLIC_BRANDING_APP_NAME__ so the + * placeholder survives the build, and docker-entrypoint.sh substitutes the + * runtime value via sed at container start. + * + * public/sw.js is gitignored — the source of truth is the template. + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' + +const TEMPLATE_PATH = join(process.cwd(), 'public', 'sw.template.js') +const OUTPUT_PATH = join(process.cwd(), 'public', 'sw.js') +const PLACEHOLDER = '__NEXT_PUBLIC_BRANDING_APP_NAME__' +const value = process.env.NEXT_PUBLIC_BRANDING_APP_NAME || 'Gnubok' + +if (!existsSync(TEMPLATE_PATH)) { + console.log(`[inject-public-branding] ${TEMPLATE_PATH} not found, skipping`) + process.exit(0) +} + +const template = readFileSync(TEMPLATE_PATH, 'utf8') +const output = template.split(PLACEHOLDER).join(value) +writeFileSync(OUTPUT_PATH, output) +console.log(`[inject-public-branding] generated public/sw.js with brand "${value}"`)