#!/usr/bin/env python3 from __future__ import annotations import csv from datetime import datetime, timezone from pathlib import Path from typing import Iterable from xml.sax.saxutils import escape from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter from reportlab.lib import colors from reportlab.lib.pagesizes import A3, landscape from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import mm from reportlab.platypus import ( LongTable, PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) ROOT = Path(__file__).resolve().parents[1] OUTPUT_DIR = ROOT / "output" / "pdf" TMP_DIR = ROOT / "tmp" / "pdfs" FEATURE_CSV = OUTPUT_DIR / "magasinet-kbh-feature-matrix.csv" AUDIT_CSV = OUTPUT_DIR / "magasinet-kbh-audit-findings.csv" MOCK_CSV = OUTPUT_DIR / "magasinet-kbh-mock-hardcode-findings.csv" REPORT_PDF = OUTPUT_DIR / "magasinet-kbh-acceptance-audit.pdf" REPORT_XLSX = OUTPUT_DIR / "magasinet-kbh-acceptance-audit.xlsx" FEATURE_FIELDS = [ "id", "area", "capability", "source_scope", "source_refs", "legacy_evidence", "new_evidence", "runtime_check", "verdict", "scope_modifier", "confidence", "business_impact", "notes", ] AUDIT_FIELDS = [ "id", "category", "title", "impact_1_10", "reachable", "evidence", "why_it_matters", "recommendation", ] MOCK_FIELDS = [ "id", "surface", "type", "evidence", "user_visible_effect", "severity", ] def feature( id: str, area: str, capability: str, source_scope: str, source_refs: str, legacy_evidence: str, new_evidence: str, runtime_check: str, verdict: str, scope_modifier: str, confidence: float, business_impact: int, notes: str, ) -> dict[str, str]: return { "id": id, "area": area, "capability": capability, "source_scope": source_scope, "source_refs": source_refs, "legacy_evidence": legacy_evidence, "new_evidence": new_evidence, "runtime_check": runtime_check, "verdict": verdict, "scope_modifier": scope_modifier, "confidence": f"{confidence:.2f}", "business_impact": str(business_impact), "notes": notes, } def audit( id: str, category: str, title: str, impact_1_10: int, reachable: str, evidence: str, why_it_matters: str, recommendation: str, ) -> dict[str, str]: return { "id": id, "category": category, "title": title, "impact_1_10": str(impact_1_10), "reachable": reachable, "evidence": evidence, "why_it_matters": why_it_matters, "recommendation": recommendation, } def mock( id: str, surface: str, type: str, evidence: str, user_visible_effect: str, severity: int, ) -> dict[str, str]: return { "id": id, "surface": surface, "type": type, "evidence": evidence, "user_visible_effect": user_visible_effect, "severity": str(severity), } FEATURE_ROWS = [ feature( "F001", "Backend", "API authorization and security policies", "RFQ+Repo", "RFQ.csv:2; src/app/api/organization/[id]/route.ts:8-136; src/app/api/upload/delete/route.ts:4-32; src/server/modules/paywall-bypass/paywall-bypass-token.router.ts:28-48", "Legacy custom modules enforce admin/editor access patterns across account, paywall, and content flows in mkbh_account_page, mkbh_paywall, mkbh_article_page.", "Auth exists, but multiple sensitive paths are under-protected: arbitrary organization read/update by any logged-in user, unauthenticated upload delete, and public paywall-bypass token/IP list/get/count procedures.", "Local code review only. No contractor deployment or admin credentials were available for live exploitation testing.", "Partial", "required", 0.95, 10, "Security posture is materially below acceptance grade even though auth primitives exist.", ), feature( "F002", "Backend / Users", "User accounts: login, registration, profile, and self-management", "RFQ+Repo", "RFQ.csv:3; RFQ.csv:51; src/server/auth/index.ts:33-135; src/app/(main)/(auth)/_components/sign-up-form.tsx; src/app/(main)/profile/page.tsx", "Legacy account handling lives in __legacy/sites/all/modules/custom/mkbh_account_page and public profile/account surfaces on the Drupal site.", "Better Auth, sign-in/sign-up flows, profile page, subscription list, and organization selection are implemented in the new app.", "Public local route probes confirmed auth entry points render; protected profile routes require session.", "Full", "required", 0.83, 8, "Quality issues remain elsewhere, but core user-account capability is present.", ), feature( "F003", "Backend / CMS", "Article CMS core: create, edit, publish, and schedule content", "RFQ+Repo", "RFQ.csv:4-6; src/components/dashboard/post/post-editor.tsx; src/components/dashboard/post/post-form.tsx; src/server/commands/post-publish-scheduled.command.ts", "Legacy Drupal editor, content types, and scheduling live in mkbh_article_page and theme overrides.", "Dashboard editor, Tiptap content model, post CRUD, and scheduled publish job exist in the Next.js codebase.", "Local build passed and post routes rendered seeded content. No admin session was available to exercise create/edit in browser.", "Full", "required", 0.82, 8, "Core authoring exists; several editorial deltas below are still incomplete.", ), feature( "F004", "Backend / CMS", "Support for public article families: KBH+, city life, city spaces, city housing, opinion, visions, projects, reviews, and photo", "RFQ+Legacy+Repo", "RFQ.csv:5; src/server/modules/post/types/post-type.enum.ts:1-49; src/app/(main)/kbhplus/page.tsx; src/app/(main)/anmeldelser/page.tsx; src/app/(main)/projekter/page.tsx", "Legacy public IA exposes /kbhplus, /byens-rum, /byens-liv, /opinion, /visioner, /projekter, /anmeldelser, /foto on the live Drupal site.", "Equivalent public routes and post-type enum values exist in the new app.", "Local probes returned HTTP 200 for /kbhplus, /projekter, /anmeldelser, /foto, /opinion, /visioner.", "Full", "required", 0.90, 8, "The families exist. Requested taxonomy/menu cleanup is a separate gap.", ), feature( "F005", "Backend / CMS", "Static/basic pages CMS", "RFQ+Specs+Repo", "RFQ.csv:6; KBH 2025 specs.pdf:112-139; src/server/modules/page/page.router.ts; src/app/(main)/about/page.tsx; src/app/(main)/kontakt/page.tsx", "Legacy basic pages, member FAQ, contact, and about pages are Drupal node/page surfaces.", "New app has page module, page CRUD, and multiple public static pages backed by page content fetches.", "Local probes returned HTTP 200 for public page routes such as /nyhedsbrev and /payments/business. /annoncering currently fails at runtime and is tracked separately.", "Full", "required", 0.79, 6, "The page framework exists, but specific page surfaces can still be broken.", ), feature( "F006", "Comments", "Frontend comments widget with threads and replies", "RFQ+Legacy+Repo", "RFQ.csv:7; RFQ.csv:37; src/components/shared/comments/comment-thread.tsx; src/server/modules/comment/comment.router.ts; __legacy/sites/all/modules/custom/mkbh_comment/mkbh_comment.module", "Legacy site supports threaded comments via mkbh_comment and ajax_comments.", "New app includes comment list/create/update/delete, threaded rendering, and reply handling.", "Comment-capable article templates are wired; public local article probes rendered content pages that include comment-section components in code.", "Full", "required", 0.80, 7, "No admin moderation runtime proof was available in this pass.", ), feature( "F007", "Comments", "Comment notifications and unsubscribe flow", "Legacy+Repo", "__legacy/sites/all/modules/custom/mkbh_comment/mkbh_comment.module; src/server/modules/comment; src/server/mailer", "Legacy comment module includes mail-driven comment interactions and unsubscribe-style behavior.", "New code shows likes and CRUD, but no equivalent comment-notification or unsubscribe flow was located.", "Code review only. No notification routes or mail templates specific to comment subscriptions were found.", "Skipped", "required", 0.72, 5, "Community retention feature from legacy appears missing.", ), feature( "F008", "Analytics", "Article metrics: views, shares, reactions, and GA-enriched counts", "RFQ+Legacy+Repo", "RFQ.csv:8; src/server/modules/analytic/analytic.service.ts:38-168; src/components/shared/google-analytics/google-analytics-stat.tsx:5-20", "Legacy custom analytics module mkbh_node_stats tracked public engagement metrics.", "The new app stores view/share/reaction stats and can sum GA views, but dashboard reporting is limited and live proof is incomplete.", "Article detail code triggers `analytic.view`; local runtime exercised public article pages successfully.", "Partial", "required", 0.77, 6, "Metrics plumbing exists, but acceptance-grade reporting and verification are incomplete.", ), feature( "F009", "Media", "Media library, upload workflows, and media CRUD", "RFQ+Legacy+Repo", "RFQ.csv:9; src/server/modules/media/media.router.ts; src/components/dashboard/media/media-list.tsx; src/components/dashboard/media/media-create-many-form.tsx", "Legacy site has custom media flows, photo bulk upload, audio/video players, and inline-image support.", "New app ships media CRUD, multi-upload, preview, caption/alt/author fields, and reusable picker flows.", "Build and seeded runtime passed. No authenticated browser upload was performed in this pass.", "Full", "required", 0.81, 7, "A separate security finding exists for unauthenticated asset deletion.", ), feature( "F010", "Recommender", "Related-content or recommender system", "RFQ+Legacy+Repo", "RFQ.csv:10; src/components/shared/post-sections/related-article.tsx; src/components/shared/post-sections/related-vision.tsx", "Legacy site has related-content blocks and article adjacency in mkbh_blocks and mkbh_article_page.", "New app renders related content UI blocks, but no AI or richer recommendation logic was evidenced.", "Public article templates reference related-content components; no dedicated recommender service was found.", "Partial", "required", 0.73, 4, "Baseline related-content UX exists. RFQ hint toward stronger recommender behavior is unmet.", ), feature( "F011", "Map / Geodata", "Geocoding, geodatabase, and map-backed content", "RFQ+Legacy+Repo", "RFQ.csv:11; src/app/(main)/kort/page.tsx; src/server/modules/post/post.router.ts; src/components/tip-tap/tiptap-widgets/map", "Legacy site has dedicated map and projects modules backed by map markers and geodata.", "The new app exposes a public map page, post geolocation queries, and map widgets, but explicit geocoding workflows were not fully proven.", "Local probe of /kort returned HTTP 200. Public rendering works, but the page heading is sparse and deeper map interactions were not manually exercised.", "Partial", "required", 0.73, 7, "Public map surface exists. Back-office geocoding fidelity needs authenticated verification.", ), feature( "F012", "Archive", "Magazine archive surface and archive management", "RFQ+Legacy+Live+Repo", "RFQ.csv:12; https://www.magasinetkbh.dk/arkiv; __legacy/sites/all/modules/custom/mkbh_archive_page/mkbh_archive_page.module; src/app/(main)/arkiv/page.tsx:19-179", "Legacy live archive exposes the full historical magazine archive with real issue downloads.", "New app replaces the archive with a hardcoded five-row constant that reuses the same image and PDF path while claiming all 57 issues are available.", "Local GET /arkiv -> 200 with heading 'bladarkiv'; page text claims all 57 issues, but code only contains five static entries.", "Partial", "required", 0.96, 8, "This is one of the clearest acceptance failures and also a mock/hardcode finding.", ), feature( "F013", "Newsletter", "Newsletter subscription and management", "RFQ+Legacy+Live+Repo", "RFQ.csv:13; https://www.magasinetkbh.dk/nyhedsbrev; src/server/modules/newsletter/services/newsletter.service.ts:25-46; src/app/(main)/nyhedsbrev/_components/newsletter-form.tsx:35-39", "Legacy site exposes newsletter capture and management surface on /nyhedsbrev.", "Generic newsletter subscribe mutation exists, popup/project widgets call it, but the dedicated newsletter page submit handler is empty and no management UI was found.", "Local GET /nyhedsbrev -> 200 with heading 'Modtag vores nyhedsbrev'. The page renders, but its primary form does not call the backend.", "Partial", "required", 0.96, 8, "Public-facing newsletter entry point is materially fake.", ), feature( "F014", "Newsletter", "RSS feed surface", "Legacy+Live+Repo", "https://www.magasinetkbh.dk/nyhedsbrev; __legacy/sites/all/modules/custom/mkbh_rss_feed; src/app/(main)/nyhedsbrev/page.tsx:63-83", "Legacy system has a custom RSS feed module and public feed discovery.", "New site only links out to an external static XML feed hosted elsewhere; no new in-app RSS generation was found.", "Local GET /nyhedsbrev -> 200; RSS section renders a direct external URL.", "Partial", "required", 0.86, 4, "Users still get a feed link, but the capability was not migrated into the application itself.", ), feature( "F015", "Payments", "Payment processing via Stripe and MobilePay", "RFQ+Legacy+Repo", "RFQ.csv:14-18; src/server/modules/payment/providers/stripe.provider.ts; src/server/modules/payment/providers/mobilepay.provider.ts", "Legacy site used Stripe and MobilePay across member and donation flows.", "Both providers exist in the new backend, but adjacent flows remain incomplete or uneven, especially upgrade/self-service and business seat changes on MobilePay.", "No end-to-end live payment was run. Public payment pages render locally.", "Partial", "required", 0.75, 9, "Core payment providers exist, but acceptance cannot treat the surrounding flow as complete.", ), feature( "F016", "Payments", "Recurring billing management and self-service subscription lifecycle", "RFQ+Legacy+Repo", "RFQ.csv:16; src/components/payment/blocks/subscription-card.tsx:65-211; src/server/modules/payment/services/subscription.service.ts:170-175", "Legacy account pages supported paid-member lifecycle management.", "Cancel and restore exist, but upgrade is stubbed and UI still calls it. Portal behavior also degrades to errors or fallbacks.", "Code review only for authenticated lifecycle flows. Public runtime confirmed test/demo surfaces still expose upgrade attempts.", "Partial", "required", 0.95, 9, "The most obvious self-service upgrade path is not implemented.", ), feature( "F017", "Payments", "Business users, seats, invitations, and sub-accounts", "RFQ+Legacy+Repo", "RFQ.csv:17; src/app/api/organization/dashboard/route.ts:35-227; src/app/api/organization/invitations/route.ts:22-168; src/server/modules/payment/providers/mobilepay.provider.ts:74-76", "Legacy account/business handling supported organization-style memberships and seat concepts.", "The new app does implement organization dashboards, invitations, membership lists, and seat adjustment, but the feature is uneven: seat adjustment is unsupported for MobilePay and a separate by-id organization API is insecure.", "Feature judged primarily from code because business flows require authenticated state and provider access.", "Partial", "required", 0.84, 9, "This is not absent, but it is not cleanly complete enough for an unconditional acceptance.", ), feature( "F018", "Payments", "Webhook integration for Stripe and MobilePay", "RFQ+Repo", "RFQ.csv:18; src/app/api/mobilepay/webhooks/route.ts; src/server/modules/payment/services/mobilepay-webhook-handler.service.ts; src/server/modules/payment/services/stripe-webhook.service.ts", "Legacy payment flows relied on webhook-style asynchronous state updates.", "Webhook routes and handlers exist for both providers in the new application.", "No external provider callbacks were replayed in this audit pass.", "Full", "required", 0.68, 6, "Implemented in code, but runtime confidence is lower without provider-side execution.", ), feature( "F019", "Data", "Pre-launch data migration", "RFQ+Legacy", "RFQ.csv:19; __legacy/sites/all/modules/custom/mkbh_migration", "Legacy codebase contains migration-related custom tooling.", "This audit did not evaluate migrated content/data parity. The new repo includes seeders and schema, but not a validated migration result.", "Explicitly excluded from this pass by audit scope.", "Skipped", "out_of_scope", 0.40, 5, "Not scored in acceptance verdict for this report.", ), feature( "F020", "Non-functional", "SLA / TTLB / deployability expectations", "RFQ+Repo", "RFQ.csv:20; pnpm build output; pnpm test output", "Legacy system at least operates publicly at scale today.", "New app builds locally, but default test command is broken, public /annoncering returns 500 locally, and there is no evidence of SLA enforcement or performance budgets.", "Local `pnpm build` compiled successfully; local `pnpm test` fails because legacy tests are included; local `/annoncering` returned 500.", "Partial", "required", 0.94, 8, "Deployability is better than zero, but not acceptance-clean.", ), feature( "F021", "Dashboard", "User management dashboard", "RFQ+Repo", "RFQ.csv:22-24; src/app/dashboard/auth/users/page.tsx; src/app/dashboard/admin/users/page.tsx:253-258", "Legacy back-office supported user administration via Drupal admin views.", "The new app has user management surfaces, but there are duplicate dashboard implementations and at least one broken create-user link under /dashboard/admin/users/create.", "Code review only. No authenticated dashboard walkthrough was available.", "Partial", "required", 0.88, 7, "Core capability exists, but dashboard integrity is inconsistent.", ), feature( "F022", "Dashboard", "Article management dashboard, writing editor, and custom widget placements", "RFQ+Specs+Repo", "RFQ.csv:25-27; KBH 2025 specs.pdf:21-74; src/components/tip-tap/components/tiptap-toolbar.tsx; src/components/tip-tap/tiptap-node", "Legacy editor supported rich embedded widgets and flexible article layouts.", "New dashboard editor is rich and extensible, with inline widget nodes, media selection, poll widgets, map widgets, and metadata forms.", "Build passed and seeded content renders widget-aware public pages. Admin-side authoring was assessed from code only.", "Full", "required", 0.82, 8, "Several specific widget/taxonomy deltas still fail separately below.", ), feature( "F023", "Dashboard", "Reports for users, subscriptions, and business/content metrics", "RFQ+Legacy+Repo", "RFQ.csv:28-30; src/components/dashboard/dashboard/dashboard-page.tsx:34-119", "Legacy platform had admin/reporting expectations around users, subscriptions, and business metrics.", "New dashboard shows simple counts, but no real subscription reporting, churn reporting, or business/content metrics suite matching the RFQ wording was found.", "Dashboard metrics were assessed from code only.", "Partial", "required", 0.80, 6, "Basic counters exist. Actual reporting depth does not.", ), feature( "F024", "Dashboard", "Automated emails", "RFQ+Legacy+Repo", "RFQ.csv:31-32; src/server/modules/auth/auth.service.ts:149-188; src/server/mailer/templates", "Legacy system used automated email behavior in account and community flows.", "New app sends auth and invitation emails, but no broader automated email/reporting capability matching the RFQ phrasing was located.", "Code review only.", "Partial", "required", 0.73, 5, "Email infrastructure exists; the promised feature breadth does not.", ), feature( "F025", "Dashboard", "CSS injector or admin-controlled dynamic CSS", "RFQ+Specs+Legacy+Repo", "RFQ.csv:33; KBH 2025 specs.pdf:5-16; src/server/modules/site-config/enums/site-config-key.enum.ts:2; src/app/page.tsx:142-152", "Legacy site had dynamic CSS/front-page variant behavior.", "New site only exposes a `homeLayout` switch; no generic CSS injector or equivalent admin CSS control was found.", "Code review only.", "Skipped", "required", 0.86, 4, "The narrow homepage layout toggle does not satisfy a CSS injector feature.", ), feature( "F026", "Frontend", "Dynamic block templates and grid-based composable front page", "RFQ+Legacy+Repo", "RFQ.csv:35-41; src/app/page.tsx:33-175; src/app/(main)/_components", "Legacy home/section pages used custom block composition.", "New homepage is a composed grid of section blocks and most-read/vision/review/map blocks.", "Local GET / -> 200 and rendered homepage sections with seeded content.", "Full", "required", 0.90, 7, "Composition exists and is publicly reachable.", ), feature( "F027", "Frontend", "Manual front-page top-layout variant switch", "Specs+Repo", "KBH 2025 specs.pdf:5-16; src/app/page.tsx:142-152; src/server/modules/site-config/enums/site-config-key.enum.ts:2", "Legacy site reportedly toggled two homepage variants via dynamic CSS/time-based behavior.", "New homepage supports `default` and `compact` layouts via site config, but the spec asked for very easy main-menu access and that UX was not verified.", "Code review plus local homepage render. No authenticated dashboard/menu walkthrough available.", "Partial", "required", 0.76, 5, "Likely functionally present, but the operational UX requirement is unproven.", ), feature( "F028", "Frontend / Map", "Map page and map widget", "RFQ+Legacy+Repo", "RFQ.csv:42; src/app/(main)/kort/page.tsx; src/config/enums/tip-tap.ts:76-80", "Legacy site had a dedicated map page and map-related widgets/markers.", "New app ships a map page and map widget type in the editor.", "Local GET /kort -> 200.", "Full", "required", 0.84, 7, "Deeper interactive map QA was outside this pass.", ), feature( "F029", "Frontend / Sections", "City spaces, city life, and city housing list/detail pages", "RFQ+Legacy+Repo", "RFQ.csv:43; src/app/(main)/byens-rum/page.tsx; src/app/(main)/byens-liv/page.tsx; src/app/(main)/byens-bolig/page.tsx; src/app/(main)/inhold/[slug]/page.tsx", "Legacy public IA includes city-space and city-life section routes and details.", "New app implements list pages for those sections and shared detail rendering under the article route.", "Local probes returned HTTP 200 for seeded /inhold/* content and city-family list routes render via the homepage and section pages.", "Full", "required", 0.82, 7, "Routing defect is covered separately under root-slug migration.", ), feature( "F030", "Frontend / Sections", "Opinion list and detail pages", "RFQ+Legacy+Repo", "RFQ.csv:44; src/app/(main)/opinion/page.tsx; src/app/(main)/opinion/[slug]/page.tsx", "Legacy live site exposes opinion list/detail pages.", "New app implements opinion list and detail routes.", "Local GET /opinion -> 200.", "Full", "required", 0.87, 6, "", ), feature( "F031", "Frontend / Sections", "Visions list and detail pages", "RFQ+Legacy+Live+Repo", "RFQ.csv:45; https://www.magasinetkbh.dk/vision/lav-cykelparkering-over-alle-hovedbanegarden-perroner; src/app/(main)/visioner/page.tsx; src/app/(main)/visioner/[slug]/page.tsx", "Legacy site exposes public vision pages with voting/comment context.", "New app implements visions list/detail routes and renders vote-related stats.", "Local GET /visioner -> 200.", "Full", "required", 0.83, 6, "", ), feature( "F032", "Frontend / Sections", "Projects list and detail pages", "RFQ+Legacy+Live+Repo", "RFQ.csv:46; https://www.magasinetkbh.dk/projekter; https://www.magasinetkbh.dk/projekt/axel-towers; src/app/(main)/projekter/page.tsx; src/app/(main)/projekter/[slug]/page.tsx", "Legacy site exposes project index/detail pages and project-specific update calls to action.", "New app implements project list/detail routes and project metadata rendering.", "Local GET /projekter -> 200.", "Full", "required", 0.86, 7, "Project alerting is a separate missing sub-capability.", ), feature( "F033", "Frontend / Sections", "Reviews list and detail pages", "RFQ+Legacy+Repo", "RFQ.csv:47; src/app/(main)/anmeldelser/page.tsx; src/app/(main)/anmeldelser/[slug]/page.tsx", "Legacy site exposes reviews and scene/film review metadata.", "New app implements review list/detail routes and review metadata fields.", "Local GET /anmeldelser -> 200.", "Full", "required", 0.87, 6, "", ), feature( "F034", "Frontend / Sections", "Photo list and detail pages", "RFQ+Legacy+Repo", "RFQ.csv:48; src/app/(main)/foto/page.tsx; src/app/(main)/foto/[slug]/page.tsx", "Legacy site exposes public photo series/list surfaces.", "New app implements photo list/detail routes and photo media rendering.", "Local GET /foto -> 200.", "Full", "required", 0.86, 6, "", ), feature( "F035", "Frontend / Commerce", "Personal subscription purchase flow", "RFQ+Legacy+Repo", "RFQ.csv:49; src/app/(main)/payments/personal/page.tsx; src/app/(main)/payments/personal/checkout/page.tsx", "Legacy site offered personal member purchase and support flows.", "New app has personal pricing and checkout pages, but surrounding lifecycle is incomplete and the landing page still carries hardcoded marketing values.", "Local GET /payments/personal -> 200. Checkout was not executed without authenticated payment testing.", "Partial", "required", 0.80, 9, "Core flow exists, but acceptance cannot treat it as fully delivered.", ), feature( "F036", "Frontend / Commerce", "Business subscription purchase flow", "RFQ+Legacy+Repo", "RFQ.csv:49; src/app/(main)/payments/business/page.tsx; src/app/(main)/payments/business/checkout/page.tsx", "Legacy site supported organization/business memberships.", "New app has business pricing and checkout scaffolding plus organization dashboard flows, but some provider paths and later management actions are incomplete.", "Local GET /payments/business -> 200.", "Partial", "required", 0.82, 9, "Good amount of code exists, but not enough to call the overall business flow fully accepted.", ), feature( "F037", "Frontend / Commercial", "Advertiser promo surface", "RFQ+Live+Repo", "RFQ.csv:50; src/app/(main)/annoncering/page.tsx; https://www.magasinetkbh.dk/annoncering", "Legacy site has a public advertiser information page.", "New app has an advertising page route, but it crashes locally with a server-side 500.", "Local GET /annoncering -> 500; server log captured `TypeError: a.map is not a function` from the built app.", "Partial", "required", 0.95, 7, "Public commercial entry point is currently broken at runtime.", ), feature( "F038", "Frontend / Commercial", "Sponsor promo surface", "RFQ+Live+Repo", "RFQ.csv:50; src/app/(main)/payments/sponsorship/page.tsx; https://www.magasinetkbh.dk/sponsor", "Legacy site exposes sponsor information and sponsor-related assets.", "New app ships a sponsorship page, sponsor plan CRUD, and sponsor listing, but conversion still relies on mailto/manual contact and legacy assets.", "Local GET /payments/sponsorship -> 200.", "Partial", "required", 0.90, 6, "Usable as a brochure page, not as a fully modernized flow.", ), feature( "F039", "Frontend / Profile", "User profile and subscription self-management", "RFQ+Repo", "RFQ.csv:51; src/app/(main)/profile/page.tsx; src/components/profile/blocks/subscription-list.tsx", "Legacy account page handled profile and membership management.", "New app includes profile area, subscription cards, invoices, organization list, and comment history.", "Protected routes were validated by code review. Public probes confirmed profile entry points exist in the route tree.", "Full", "required", 0.77, 7, "Subscription upgrade remains incomplete, but the broader profile surface exists.", ), feature( "F040", "Frontend / Taxonomy", "Author, district, and tag index pages", "RFQ+Legacy+Live+Repo", "RFQ.csv:52; https://www.magasinetkbh.dk/search/node/metro; src/app/(main)/bydel/[slug]/page.tsx; src/app/(main)/emne/[slug]/page.tsx; src/app/(main)/skribent/[slug]/page.tsx", "Legacy public site exposes taxonomy and author-style landing pages.", "District and tag routes clearly exist and render. Author route exists in code, but live proof was limited because seeded author data was sparse.", "Local GET /bydel/indre-by -> 200; local GET /emne/metro -> 200; local probe of a synthetic author slug returned 404.", "Partial", "required", 0.78, 5, "Route infrastructure is present; author-page confidence is lower without seeded author data.", ), feature( "F041", "Paywall", "SEO-friendly paywall and gated KBH+ behavior", "RFQ+Legacy+Repo", "RFQ.csv:39; src/components/paywall/kbhplus-paywall.tsx; src/server/modules/auth/auth.service.ts:73-127", "Legacy site used paywall logic and subscription-aware access controls.", "New app has KBH+ gating and subscription checks, but paywall bypass exposure and guest-link weaknesses materially undermine the implementation.", "Seeded KBH+ route rendered publicly; authenticated subscription gating was assessed from code only.", "Partial", "required", 0.80, 9, "Feature exists, but the control surface is not safely implemented.", ), feature( "F042", "Widgets", "Inline timeline widget", "Specs+Repo", "KBH 2025 specs.pdf:21-39; src/components/tip-tap/types/types.ts:12-27; src/components/tip-tap/tiptap-node/timeline/timeline-node.tsx", "Legacy editor did not expose this exact new widget; it was a spec delta.", "The new editor stores title, description, timeline items, icon, media, and text, matching the requested shape.", "Code review only for editor-side setup. Public renderer exists in src/components/tip-tap/tiptap-widgets/timeline/timeline-widget.tsx.", "Full", "required", 0.88, 5, "", ), feature( "F043", "Widgets", "Inline chart widget with editorial data entry", "Specs+Repo", "KBH 2025 specs.pdf:40-48; src/config/enums/tip-tap.ts:55-117", "This was a net-new requirement.", "The new editor exposes poll, vote, suggestion, timeline, map, etc., but no generic chart widget type or chart-data-entry workflow was found.", "Code review only.", "Skipped", "required", 0.92, 6, "Existing chart components are tied to poll result rendering, not a generic inline chart authoring feature.", ), feature( "F044", "Widgets", "Inline poll widget with multiple choice, checkboxes, slider, and free text", "Specs+Repo", "KBH 2025 specs.pdf:51-61; src/server/modules/poll/types/poll-question-type.enum.ts:1-16; src/components/form/control/poll-question.collection.tsx:46-71", "This was a new requirement over legacy.", "The editor and poll model support radio, checkbox, slider, and textarea question types.", "Code review only. Public poll renderer exists in the Tiptap widget stack.", "Full", "required", 0.89, 5, "", ), feature( "F045", "Widgets", "Inline CityChange single-suggestion widget with ID field and address override", "Specs+Repo", "KBH 2025 specs.pdf:63-74; src/components/tip-tap/tiptap-node/suggestions/suggestions-node.tsx:37-169; src/components/tip-tap/types/types.ts:76-79", "The spec originally asked for this as new functionality.", "The editor supports a suggestion ID and variant, but no address-override field exists.", "Code review only.", "Partial", "withdrawn", 0.89, 3, "Withdrawn from pass/fail scoring because the spec later deletes this as net-new scope.", ), feature( "F046", "Widgets", "Before/after slider 1px white split stroke", "Specs+Repo", "KBH 2025 specs.pdf:81-85; src/components/tip-tap/tiptap-widgets/before-after-image/before-after-image.tsx:96-103", "Legacy had a before/after slider; spec asked for a styling tweak.", "The slider renders a white split line (`w-1 bg-white`) and draggable handle.", "Code review only.", "Full", "required", 0.89, 3, "", ), feature( "F047", "Editorial taxonomy", "Global tag replace / merge workflow", "Specs+Repo", "KBH 2025 specs.pdf:88-97; src/server/modules/tag; src/components/dashboard/tag", "Legacy editorial workflow explicitly needed tag cleanup/merge ability.", "New tag CRUD exists, but no tag replace/merge flow across articles was found.", "Code review only.", "Skipped", "required", 0.87, 5, "", ), feature( "F048", "Editorial taxonomy", "Improved multi-tag add UX", "Specs+Repo", "KBH 2025 specs.pdf:100-108; src/components/form/control/tag.collection.tsx:135-167", "This was a requested editorial UX improvement.", "Selected tags are maintained separately from search results and the picker clears search state on close, matching the intended flow substantially better than legacy.", "Code review only.", "Full", "required", 0.84, 3, "", ), feature( "F049", "Editorial model", "Reworked article-type taxonomy and creation menu", "Specs+Repo", "KBH 2025 specs.pdf:112-139; src/server/modules/post/types/post-type.enum.ts:1-49; src/components/dashboard/post/post-sidebar-form.tsx:106-123", "Legacy taxonomy and menu structure were explicitly called illogical and needed restructuring.", "New app still exposes a flat topic enum. Several requested distinctions such as Standard-with-review, sponsored as type, and richer hierarchy/menu semantics are not present as specified.", "Code review only.", "Partial", "required", 0.90, 6, "The platform supports the public families, but not the requested editorial creation model.", ), feature( "F050", "Editorial model", "Primary topic tag behavior with frontend display and quote icon for 'kommentar'", "Specs+Repo", "KBH 2025 specs.pdf:141-159; src/components/shared/post-tag.tsx:30-34; src/components/dashboard/post/post-sidebar-form.tsx:162-164", "This is a spec delta modeled on legacy pretitle behavior.", "Primary tag exists in the editor, renders on frontend, links to tag pages, and adds a quote icon when the tag name is `kommentar`.", "Local homepage/article rendering plus code review support this.", "Full", "required", 0.90, 4, "", ), feature( "F051", "Editorial UX", "Special-character shortcut buttons in editor for em-dash and guillemets", "Specs+Repo", "KBH 2025 specs.pdf:164-172; src/components/tip-tap/components/tiptap-toolbar.tsx:132-260", "This was a new editorial convenience requirement.", "Toolbar buttons exist for formatting, alignment, lists, links, etc., but no explicit shortcut buttons for `—`, `»`, or `«` were found.", "Code review only.", "Skipped", "required", 0.91, 3, "", ), feature( "F052", "Routing / SEO", "Root-slug article URLs plus redirects from old article paths", "Specs+Live+Repo", "KBH 2025 specs.pdf:174-179; https://www.magasinetkbh.dk/search/node/metro; src/config/routes.ts:10-18; src/app/(main)/inhold/[slug]/page.tsx:21-83; src/middleware.ts", "Legacy site used `/indhold/...`; spec requested root-slug URLs with redirects.", "New app still routes many article families under `/inhold/${slug}` instead of root slugs, and no redirect implementation for the old scheme was found.", "Local seeded article probe /inhold/byens-kulturelle-liv -> 200.", "Skipped", "required", 0.95, 8, "This is both a spec miss and a migration/SEO miss.", ), feature( "F053", "Editorial media", "Bulk-fill photographer and ALT fields for inline images", "Specs+Legacy+Repo", "KBH 2025 specs.pdf:182-188; __legacy/sites/all/modules/custom/mkbh_photo_series_bulk_upload; src/components/dashboard/media/media-create-many-form.tsx:61-137; src/components/tip-tap/tiptap-node/inline-image/inline-image-modal.tsx:198-229", "Legacy had dedicated photo bulk-upload tooling and the spec asked for mass-populate behavior.", "New app lets editors edit alt/caption/author per image, but no one-shot bulk-populate action for all selected images was found.", "Code review only.", "Skipped", "required", 0.90, 5, "", ), feature( "F054", "Editorial media", "Thumbnail-based inline-image placement in the editor", "Specs+Repo", "KBH 2025 specs.pdf:189-191; src/components/tip-tap/tiptap-node/inline-image/inline-image-node.tsx:57-93; src/components/tip-tap/tiptap-node/inline-image/inline-image-modal.tsx:204-210", "Legacy editor used opaque inline-image tokens; spec asked for thumbnail placement.", "New editor renders selected images as visible previews/thumbnails inside the node UI.", "Code review only.", "Full", "required", 0.89, 4, "", ), feature( "F055", "Editorial media", "Drag-and-drop reordering of inline-image placeholders", "Specs+Repo", "KBH 2025 specs.pdf:192-193; src/components/tip-tap/tiptap-node/inline-image/inline-image-modal.tsx", "Spec marked this as a nice-to-have.", "No drag-and-drop ordering UI for inline-image placeholders was found in the modal or node.", "Code review only.", "Skipped", "optional", 0.86, 2, "Kept in appendix scoring only; does not drive refund on its own.", ), feature( "F056", "Editorial media", "Edit inline-image captions directly from the thumbnail UI", "Specs+Repo", "KBH 2025 specs.pdf:194-197; src/components/tip-tap/tiptap-node/inline-image/inline-image-modal.tsx:219-229", "Spec marked this as nice-to-have if UX was good.", "Caption fields are editable directly in the inline-image modal alongside the thumbnail preview.", "Code review only.", "Full", "optional", 0.86, 2, "Optional item; implemented.", ), feature( "F057", "Editorial UX", "Pre-populate writer/byline from the creating user", "Specs+Repo", "KBH 2025 specs.pdf:200-205; src/components/dashboard/post/post-editor.tsx:191-206", "This was an editorial productivity requirement.", "New post defaults `authorId` to the main profile of the creating user while keeping the field editable.", "Code review only.", "Full", "required", 0.88, 3, "", ), feature( "F058", "Search", "AND semantics for multi-term search", "Specs+Live+Repo", "KBH 2025 specs.pdf:208-212; https://www.magasinetkbh.dk/search/node/metro; src/server/modules/post/repositories/post.repository.ts:381-384", "Legacy search behavior used OR and the spec explicitly asked to change it.", "New search repository still uses `or(ilike(title), ilike(description))`, so the requested AND semantics were not implemented.", "Local GET /search?s=test -> 200. Semantics assessed from repository code.", "Partial", "required", 0.94, 5, "Feature exists, but the required semantic change does not.", ), feature( "F059", "Design system", "Reuse Caecilia and Montserrat fonts from the old site", "Specs+Legacy+Repo", "KBH 2025 specs.pdf:213-221; __legacy/sites/all/themes/custom/mkbh/assets/fonts; src/lib/utils/font.ts:7-37; src/components/tip-tap/components/text-style-control.tsx:54-60", "Legacy theme ships Caecilia and Montserrat assets.", "New app imports local Caecilia files and Montserrat via next/font, and editor font controls expose both.", "Code review only.", "Full", "required", 0.88, 2, "", ), feature( "F060", "Design system", "Use additional 'Publish' title font pre-launch", "Specs", "KBH 2025 specs.pdf:219-221", "Originally floated as an extra font change in the spec PDF.", "The same spec explicitly marks this as removed.", "Not scored.", "Skipped", "withdrawn", 0.90, 1, "Withdrawn requirement; excluded from pass/fail scoring.", ), feature( "F061", "CityChange", "Deep links from CityChange suggestions into the app", "Specs+Repo", "KBH 2025 specs.pdf:224-250; src/components/tip-tap/tiptap-widgets/suggestion/suggestion-widget.tsx:227-273", "Legacy/live widget existed already; spec asked only for deep-link enrichment.", "The existing location-based suggestion widget now builds the requested `citychange.page.link` deep links per suggestion ID.", "Code review only.", "Full", "required", 0.90, 3, "", ), feature( "F062", "Paywall", "Guest/share links that bypass the paywall", "Specs+Repo", "KBH 2025 specs.pdf:252-286; src/components/dashboard/post/post-guest-link-dialog.tsx:17-66; src/components/dashboard/paywall-bypass-token/paywall-bypass-token-list.tsx:63-69", "Spec asked for one-time and time-window guest links.", "New app can mint tokens with optional expiry, but it does not implement one-time invalidation, list/edit UI is broken, and URL generation is wrong for `/inhold/*` content.", "Code review only. Public local article route accepts `?token=` in access checks, but no full guest-link workflow was safely exercised.", "Partial", "required", 0.94, 8, "Feature exists in reduced and unsafe form.", ), feature( "F063", "Profile", "Profile page list of the user's comments with deep link and reply marker", "Specs+Repo", "KBH 2025 specs.pdf:287-296; src/app/(main)/profile/_components/blocks/comments-block.tsx:25-131; src/components/shared/comments/comment-thread.tsx:19-116", "This was added late in the spec.", "New profile comments block lists user comments, marks replies, and links to the specific comment highlight on the article page.", "Code review only.", "Full", "required", 0.88, 4, "", ), feature( "F064", "Payments", "Admin control to pause all subscriber payments while preserving access", "Specs+Repo", "KBH 2025 specs.pdf:287-341; src/server/modules/payment; src/app/dashboard/payment", "Spec explicitly asked for a pause-all-payments admin control across Stripe and MobilePay handling.", "No pause-all-subscriber control, service, or admin surface matching this requirement was found.", "Code review only.", "Skipped", "required", 0.87, 8, "", ), feature( "F065", "Projects", "Project-specific update alerts / email notifications", "Legacy+Repo", "https://www.magasinetkbh.dk/projekt/axel-towers; src/components/shared/post-newsletter-form.tsx:61-89; src/server/modules/newsletter/services/newsletter.service.ts:25-37", "Legacy project detail pages advertise update email alerts.", "The new CTA text promises project-specific alerts, but the handler only subscribes the email to the generic newsletter list and sends no project context.", "Project CTA is present in article templates; behavior assessed from code.", "Skipped", "required", 0.96, 7, "This is a deceptive mismatch between copy and implementation.", ), feature( "F066", "Dashboard", "Paywall-bypass admin UI integrity", "Legacy+Repo", "__legacy/sites/all/modules/custom/mkbh_paywall; src/components/dashboard/paywall-bypass-token/paywall-bypass-token-list.tsx:111-142; src/components/dashboard/paywall-bypass-token/paywall-bypass-token-editor.tsx:38-79; src/app/dashboard/paywall-bypass/tokens/page.tsx", "Legacy paywall bypass/admin utilities existed in custom paywall tooling.", "New app has token and IP list UIs, but token edit routes are missing and the token editor binds `expireAt` instead of `expiresAt`.", "Code review only.", "Partial", "required", 0.95, 7, "Admin tooling exists but is internally broken.", ), ] AUDIT_FINDINGS = [ audit( "A001", "Security", "Tracked secrets and third-party credentials are committed in `.env.example`", 10, "Repo-wide", ".env.example:29-30, 38-39, 62-63, 75-87, 106-107 include real-looking Better Auth, SMTP host, Google, Facebook, Stripe, and MobilePay credentials.", "This is direct credential exposure in a deliverable repository. Even if some keys are test-only, it normalizes unsafe handling and exposes integration surface details that do not belong in versioned examples.", "Rotate every exposed secret, purge them from history if possible, and replace the example file with placeholders only.", ), audit( "A002", "Security", "Paywall-bypass tokens and bypass IPs are publicly enumerable through tRPC", 10, "Reachable to any client that can call public procedures", "src/server/modules/paywall-bypass/paywall-bypass-token.router.ts:28-48 and paywall-bypass-ip.router.ts:28-48 expose get/list/count as `publicProcedure`; corresponding services at token.service.ts:66-84 and ip.service.ts:61-79 do not authorize access.", "This turns paywall exception data into public metadata. It undermines the access-control model and exposes operational bypass mechanisms that should be tightly restricted.", "Change list/get/count to protected admin-only procedures and enforce gate authorization in the service layer.", ), audit( "A003", "Security", "Any authenticated user can read and update arbitrary organizations by ID", 10, "Reachable to any logged-in user", "src/app/api/organization/[id]/route.ts:8-50 performs only a session check before returning org data; PUT at lines 68-122 updates by raw organization ID with no membership or role check.", "This is a read/write insecure direct object reference on organization data. It exposes and allows modification of another organization's profile details with nothing more than a valid session.", "Require membership/role checks before GET and PUT, and route these operations through a single authorized service instead of raw table access.", ), audit( "A004", "Security", "Unauthenticated upload-delete endpoint can delete assets by path or ID", 9, "Public API surface", "src/app/api/upload/delete/route.ts:4-32 accepts DELETE with a `path` query parameter and calls deleteAsset; src/lib/utils/upload-utils.ts:24-33 passes the identifier straight to `nup.deleteAsset`.", "This is an obvious availability and integrity problem. Any caller that can guess or discover an asset path can attempt deletion without authentication.", "Require authentication and ownership/permission checks before deletion. Prefer signed, short-lived deletion intents rather than raw public file-path deletion.", ), audit( "A005", "Engineering quality", "Public `/test-stripe` demo page ships in the deliverable", 9, "Public route", "src/app/test-stripe/page.tsx:8-195 renders a public Stripe test dashboard that attempts subscription upgrades, organization creation, and organization listing.", "A public demo/test route on a production magazine codebase is not a harmless leftover. It exposes non-customer flows, confuses acceptance testing, and increases attack surface.", "Remove the route from production code or lock it behind an internal-only environment gate.", ), audit( "A006", "Engineering quality", "Core subscription upgrade path is stubbed while the UI still advertises it", 9, "Authenticated users", "src/server/modules/payment/services/subscription.service.ts:170-175 leaves `upgrade()` as TODO and returns `{}`; src/components/payment/blocks/subscription-card.tsx:144-151 still calls it.", "This is not a minor defect. It is an acceptance-critical commerce flow presented as if it works.", "Implement upgrade end-to-end or remove/disable the UI until it exists.", ), audit( "A007", "Engineering quality", "Default test command is broken by vendored legacy tests", 7, "Repo-wide", "package.json:39 defines `test` as `vitest run`; local `pnpm test` fails on `__legacy` suites such as beerslider, googleanalytics, jquery-match-height, and Drupal views tests.", "A deliverable that cannot pass its default test command without manual scoping is not cleanly maintainable or CI-ready.", "Exclude `__legacy` from Vitest, or scope tests to the new app explicitly and add that configuration to the repo.", ), audit( "A008", "Availability", "Public advertiser page crashes at runtime", 7, "Public route", "Local GET `http://127.0.0.1:3100/annoncering` returned 500; the running server logged `TypeError: a.map is not a function`. Route source: src/app/(main)/annoncering/page.tsx.", "This is a public-facing commercial page. If the contractor claimed the site was ready, a deterministic runtime 500 on a public route is an acceptance blocker.", "Fix the content-shape assumption in the advertising page renderer and add route smoke tests for all public landing pages.", ), audit( "A009", "Performance", "Homepage and article routes do heavy server work without evidence of caching discipline", 5, "Public routes", "src/app/page.tsx:38-118 fires eleven server fetches for the homepage; article pages such as src/app/(main)/inhold/[slug]/page.tsx:47-66 fetch content and trigger analytics on render.", "The code may work at low volume, but it leaves too much expensive server work on hot public routes without visible acceptance evidence for caching or performance budgets.", "Add explicit caching strategy, route-level performance budgets, and smoke benchmarks for the homepage and article detail routes.", ), audit( "A010", "Security / Quality", "Auth configuration still contains demo/test-only values", 4, "Repo-wide", "src/server/auth/index.ts:101 trusts provider `demo-app`; src/server/auth/index.ts:473-480 injects `dd: \"test\"` into the session payload.", "These are not catastrophic on their own, but they are classic signs of unfinished or non-production auth hardening.", "Remove demo-only trusted providers and test payload fields before acceptance.", ), audit( "A011", "Engineering quality", "Next.js middleware convention is deprecated in the current framework version", 3, "Build-time", "Local `pnpm build` emitted `The \"middleware\" file convention is deprecated. Please use \"proxy\" instead.`; repo uses src/middleware.ts:1-52. Official Next.js 16.1.5 docs confirm `middleware` is deprecated and renamed to `proxy`.", "This is not a business-critical defect, but it is a clear sign the contractor did not finish framework hygiene on a modern stack they chose themselves.", "Run the official Next.js middleware-to-proxy migration and update the file/export convention.", ), audit( "A012", "Security / Ops", "Public health endpoint reveals uptime, environment, version, and database state", 3, "Public route", "src/app/api/health/route.ts:4-55 returns environment, uptime, version, and database connectivity status to any caller.", "Health checks are useful, but this version discloses more operational detail than necessary for a public website.", "Restrict detailed health data to internal callers or return a minimal boolean/liveness response publicly.", ), audit( "A013", "Release engineering", "App startup hard-requires Stripe and MobilePay env vars even outside payment flows", 9, "Startup / deploy-time", "src/config/env.ts:62-84 makes Stripe and MobilePay variables mandatory during env validation; src/server/auth/index.ts:221-252 registers the Better Auth Stripe integration unconditionally and reads plans from the database.", "This blocks minimal bootstraps and makes the app non-portable across environments that only need core web/database/storage services. A release candidate should not refuse to start just because optional payment credentials are absent.", "Make payment providers truly optional at startup: gate env validation and provider/plugin registration behind explicit feature flags, and only initialize Stripe/MobilePay when those integrations are enabled.", ), audit( "A014", "Ops / Data safety", "Default `pnpm db:seed` command is destructive demo seeding, not production bootstrap", 10, "Repo-wide", "package.json:24 exposes `db:seed`; src/server/commands/db-seed.command.ts:35-95 starts by calling clearAllSeeder; src/lib/database/seeders/clear-all-seeder.ts:14-26 deletes existing comments, tags, memberships, invitations, and organizations before the script creates demo users/content.", "This is a release blocker because the repo's primary seed command is unsafe on a real environment and mixes destructive wipes with demo fixture creation. Running the wrong documented bootstrap step can destroy data and pollute production with test content.", "Split seeding into clearly named commands: a non-destructive, idempotent production bootstrap for required system records, and a separate demo/dev seed. Hard-fail the demo seed in production environments.", ), audit( "A015", "Release engineering", "Fresh databases are incomplete after migrations and require manual Stripe sync plus destructive seed paths", 9, "Startup / bootstrap", "package.json:30 wires Stripe sync to a separate `stripe:fetch-plans` command; src/server/commands/payment/stripe-fetch-plans.command.ts:1-15 manually runs `syncAllProducts()` and `syncAllPrices()`; src/server/modules/payment/services/stripe.service.ts:33-50 fetches products/prices from Stripe, while src/server/auth/index.ts:239-252 expects active prices to already exist in the database; src/server/database/seeders/payment-plan.seeder.ts:210-219 skips missing plans, and src/server/database/seeders/payment-price.seeder.ts:102-117 only updates rows that already exist.", "A clean database is not release-ready after migrations alone. Checkout plan resolution depends on Stripe-backed plan/price rows being present, but the repo provides no safe, idempotent production bootstrap for that catalog or for required content/admin scaffolding.", "Automate plan and price reconciliation as part of startup or a dedicated idempotent production bootstrap, and keep the mapping driven from live Stripe product metadata instead of contractor-specific preloaded rows. Add a separate non-destructive bootstrap for required pages, blocks, and admin state.", ), audit( "A016", "Release engineering", "Deployment tooling mixes dev-only mounts, fake production tags, and machine-specific compose files", 8, "Repo-wide", "docker-compose.yml:28-31 and 43-55 still use `:dev` image tags and bind mounts; docker-compose.yml:93-99 mounts source and env files directly into the running container; Makefile:189-198 pushes a Swarm-style `docker stack deploy` path; infrastructure/docker/docker-compose.yml:9-10,42-43,69-72 hardcodes another developer's local filesystem.", "This is not a cosmetic cleanup issue. It signals that the deployment story is fragmented and partly non-portable, which increases the chance that the accepted deployment path is undocumented, unreproducible, or simply not the one checked into the repo.", "Separate development and production orchestration cleanly, remove bind mounts and `:dev` tags from release definitions, delete or template machine-specific compose files, and document one reproducible deployment path that actually matches the target platform.", ), audit( "A017", "Security / Release engineering", "Mailchimp config is split between env and database, and the DB-backed secret path is publicly readable", 9, "Public tRPC + runtime + scheduled jobs", "src/server/modules/site-config/router/index.ts:7-13 exposes `values` as `publicProcedure`; src/server/modules/site-config/services/site-config.service.ts:21-31 returns the full config map; src/server/modules/site-config/validators/site-config.validator.ts:10-12 includes `MAILCHIMP_API_KEY` and `MAILCHIMP_LIST_ID`; src/components/dashboard/site-config/config-editor.tsx:579-590 writes those values through the admin UI; meanwhile src/server/modules/newsletter/services/newsletter.service.ts:17-21 still defaults the newsletter list ID from env, while src/server/clients/mailchimp/mailchimp.service.ts:16-19 and src/lib/mailchimp/client.ts:6-12 read the API key from site-config; src/server/commands/site-config-update-stats.command.ts:20-35 also reads the list ID from site-config and requires an admin user.", "This is worse than ordinary config drift. The repo creates two competing Mailchimp configuration paths, and the one stored in the database is exposed through a public query if operators use the dashboard for secret storage.", "Remove secrets from public site-config entirely, split secret config from public metadata config, and enforce one authoritative Mailchimp configuration path for app and job runtime.", ), audit( "A018", "Data integrity / Analytics", "Google Analytics sync injects hardcoded fake page-view data", 8, "Analytics sync command", "src/server/clients/google-analytics.service.ts:66-104 appends `result.set('/kort/test', 1000)` after the real GA report loop; src/server/commands/update-ga-page-views.command.ts:11-24 persists every returned path into analytic views.", "This poisons the analytics dataset with fabricated rows and proves the sync path is not trustworthy for acceptance, reporting, or business decisions.", "Delete the hardcoded test record, quarantine analytics fixtures to test-only code, and add a regression test that rejects unknown synthetic paths in production syncs.", ), audit( "A019", "Release engineering / Analytics", "GA credentials are wired as a file-path assumption rather than a deploy-safe secret input", 8, "Build and runtime configuration", "src/server/clients/google-analytics.service.ts:15-19 constructs `BetaAnalyticsDataClient()` with default ADC behavior; src/config/env.ts:90-93 exposes only `GOOGLE_APPLICATION_CREDENTIALS`; .env.example:70 points that variable at `/ga-service-account.json`; docker-compose.dev.yml:108-110 passes the path through. Repo search of tracked deploy files found no credential JSON mount or copy beyond that env reference.", "The deployment depends on an undocumented credential file appearing inside the runtime container. That is brittle, hard to reproduce, and not encoded in the checked-in deployment path.", "Support env-injected JSON credentials or workload identity, and fail bootstrap with an explicit validation error if the analytics auth mechanism is incomplete.", ), audit( "A020", "Release engineering / Payments", "Business Stripe checkout customization is effectively unreachable because runtime keys off UUID plan names", 9, "Checkout, sync, and subscription update flows", "src/server/auth/index.ts:239-250 exports Better Auth plans with `name: price.id`; src/server/modules/payment/providers/stripe.provider.ts:22-29 purchases with `plan: price.id`; src/server/modules/payment/tables/plan-price.table.ts:20-26 defines that ID as a UUID; src/server/auth/index.ts:268-282 then tests `plan.name.includes('business')` and `plan.name.includes('annual')` to decide business seat pricing and annual behavior. Sync is still brittle too: src/server/modules/payment/services/stripe.service.ts:54-61 falls back from `metadata.type` to `productData.name.includes('business')`, despite metadata already being modeled in src/server/modules/payment/tables/plan.table.ts:17-25 and src/server/modules/payment/types/plan-metadata.type.ts:1-6.", "The business-only Stripe branch that should set seat-based pricing, billing metadata, tax ID collection, and address requirements cannot be trusted when the decisive field is a UUID-shaped price ID. The catalog may sync, but the runtime logic still depends on label heuristics and dead assumptions.", "Make Stripe metadata authoritative end-to-end, pass stable typed identifiers instead of UUID-as-name conventions, and delete all name-based business/annual branching from sync and checkout logic.", ), audit( "A021", "Release engineering / Runtime", "Every runtime container and scheduled job runs migrations on boot", 8, "Container startup and cron jobs", "Dockerfile:115-117 sets `docker-entrypoint.sh` for runtime containers; infrastructure/docker/common/docker-entrypoint.sh:66-76 and 104-119 always wait for DB and run `node migrate.js` before the requested command; docker-compose.dev.yml:134-195 defines scheduled job services with the same image and entrypoint pattern.", "App replicas, one-off jobs, and cron tasks all become implicit migration runners. That creates avoidable startup coupling, migration races, and failure modes where an unrelated job cannot run because schema migration behavior changed.", "Move schema migration to an explicit one-shot deploy step and keep app and job containers focused on their actual command.", ), audit( "A022", "Security / Ops", "Migration script logs the full database connection string", 7, "Migration logs", "migrate.js:27-34 prints debug state and then logs `Connection string value:` followed by the raw connection string.", "That leaks database credentials into CI, container, or platform logs. It is careless at best and operationally unsafe in any shared logging environment.", "Redact secrets in migration logging and keep only non-sensitive connection diagnostics such as host classification or presence checks.", ), audit( "A023", "Release engineering", "Checked-in bootstrap scripts are local-dev oriented, partially broken, and seed localhost content", 8, "Repository bootstrap path", "infrastructure/seed_db.sh:135-145 checks for non-existent `src/lib/database/seed.ts` and tells operators to run missing `./infrastructure/setup_docker.sh`; infrastructure/setup_env.sh:64-132 generates a localhost-only `.env.local`; infrastructure/seed_db.sh:179-195 advertises seeded demo accounts and localhost services; src/server/database/seeders/page.seeder.ts:755-763 hardcodes `http://localhost:3000/faq` into seeded page content.", "The documented bootstrap path is not production-ready. It is misleading, partly broken, and it injects developer URLs and demo assumptions into the dataset that would be unacceptable in a real environment.", "Split dev bootstrap from production bootstrap, fix the broken script references, remove localhost URLs from seeded content, and make base content creation idempotent and environment-aware.", ), audit( "A024", "Engineering quality / Admin tooling", "The dashboard 'Google' integration UI does not control the actual analytics integration", 6, "Admin dashboard + runtime config", "src/server/modules/site-config/enums/site-config-key.enum.ts:7-9 only defines `GOOGLE_ADS_ID` for Google-related site-config; src/components/dashboard/site-config/config-editor.tsx:571-577 exposes only 'Google ADS Id' in the integrations tab; actual page tagging uses env-backed config in src/app/layout.tsx:31 and src/config/env.ts:240-244, while server-side GA reporting uses env-backed config in src/server/clients/google-analytics.service.ts:17-18.", "Operators are shown a Google integration control panel that does not actually govern the live analytics implementation. That is a fake control surface and a reliable way to misconfigure production.", "Either wire the admin UI to the real integration state or remove it. Keep Ads, GA tag ID, GA property ID, and service-account auth as explicit, separately documented settings.", ), audit( "A025", "Release engineering / Config", "Hardcoded domains and localhost defaults still leak into auth, build config, and seeded content", 7, "Runtime config + seeded CMS content", "src/server/auth/index.ts:489-492 falls back to `magasinetkbh.vercel.app` for cross-subdomain cookies; Dockerfile:46-60 bakes localhost and dummy host defaults into build-time auth/app/mobilepay env; src/server/database/seeders/page.seeder.ts:755-763 seeds `http://localhost:3000/faq`; src/app/(main)/payments/personal/page.tsx:28-52 and src/app/(main)/payments/sponsorship/page.tsx:20-44 hardcode live-domain share URLs and legacy asset URLs.", "Wrong host defaults do not stay theoretical. They bleed into cookie scoping, generated links, seeded pages, and public marketing surfaces, which makes deployment behavior environment-dependent and error-prone.", "Move every host/domain value behind validated configuration, remove hardcoded domains from seed data and page constants, and fail builds when required public URLs are missing instead of falling back to localhost or contractor domains.", ), audit( "A026", "Release engineering / Payments", "Organization dashboard and invitation seat checks still filter subscriptions by dead business slugs", 8, "Organization billing and seat-management APIs", "src/server/auth/index.ts:226-250 remaps Better Auth subscription `plan` data around current price IDs; src/app/api/organization/dashboard/route.ts:13 and 150-157 filters `subscription.plan` against `['business-monthly', 'business-annual']`; src/app/api/organization/invitations/route.ts:14 and 73-80 repeats the same filter; src/app/api/organization/invitations/accept/route.ts:12 and 69-76 does it again during invitation acceptance.", "A valid business subscription can be invisible to the organization dashboard, invitation flow, and seat enforcement because those routes still look for legacy slug strings instead of the live plan identity model.", "Drive organization entitlement checks from normalized plan type or related price/plan records, not hardcoded legacy slug arrays.", ), audit( "A027", "Security / Release engineering", "Dependency baseline is materially outdated and already carries critical/high vulnerabilities", 9, "Repo-wide runtime and build chain", "Local `pnpm outdated --prod --format json` on 2026-04-17 reported 72 outdated top-level production dependencies. Examples include `next` 16.1.5 -> 16.2.4 ([package.json:136]), `drizzle-orm` 0.38.4 -> 0.45.2 ([package.json:121], [pnpm-lock.yaml:15867]), `@aws-sdk/client-s3` 3.907.0 -> 3.1031.0 ([package.json:49]), `@sentry/nextjs` 10.19.0 -> 10.49.0 ([package.json:90], [pnpm-lock.yaml:136]), `next-intl` 4.8.3 -> 4.9.1 ([package.json:137], [pnpm-lock.yaml:18046]), and `react` / `react-dom` 19.1.0 -> 19.2.5 ([package.json:148-151], [pnpm-lock.yaml:18725], [pnpm-lock.yaml:18632]). Local `pnpm audit --prod --json` on 2026-04-17 reported 78 production vulnerabilities: 3 critical, 39 high, 27 moderate, 9 low. Concrete runtime examples include direct `happy-dom` 18.0.1 ([package.json:127], [pnpm-lock.yaml:16774]) with a critical RCE advisory, direct `drizzle-orm` 0.38.4 with a high SQL-injection advisory, direct `next` 16.1.5 with a high denial-of-service advisory, critical `protobufjs` 7.5.4 via `@google-analytics/data` ([package.json:56], [pnpm-lock.yaml:18572]), and critical/high `fast-xml-parser` 5.2.5 via `@aws-sdk/client-s3` ([package.json:49], [pnpm-lock.yaml:16466]).", "This is not a cosmetic 'updates available' complaint. The production dependency graph is both stale and already vulnerable in runtime-relevant packages. A production-ready release candidate should at minimum be on patched versions for known security advisories and should not lag this broadly across core framework, ORM, mail, analytics, and storage dependencies.", "Upgrade direct production dependencies to current patched releases, eliminate critical/high advisories in runtime paths, refresh vulnerable transitive dependencies, and add a dependency-review cadence plus CI audit gating so the stack cannot drift back into this state.", ), audit( "A028", "Payments / Webhooks", "Stripe webhook implementation is incomplete and misses business-critical invoice and payment lifecycle handling", 9, "Stripe webhook path and billing back office flows", "Better Auth's Stripe docs state the plugin automatically handles only common subscription events (`checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`) and exposes `onEvent` for the rest: https://better-auth.com/docs/plugins/stripe. In this repo, src/server/auth/index.ts:383-452 wires `onSubscriptionComplete`, `onSubscriptionUpdate`, `onSubscriptionCancel`, and `onEvent`, but those handlers only create/cancel subscription metadata, log updates, and forward all other events to StripeWebhookService. src/server/modules/payment/services/stripe-webhook.service.ts:14-28 then handles only `product.created`, `product.updated`, `price.created`, and `price.updated`. src/server/modules/payment/services/invoice.service.ts:16-47 is read-only and just lists paid invoices directly from Stripe. By contrast, the legacy app had explicit business webhook handlers in __legacy/sites/all/modules/custom/mkbh_general/src/Stripe/Webhooks/Handlers/InvoicePaymentSucceeded.php:21-193 and InvoicePaymentFailed.php:16-65 that processed invoice success/failure, generated and stored business invoice PDFs, and triggered customer notifications.", "The billing surface looks integrated because Stripe checkout and Better Auth hooks exist, but the application does not own the revenue-critical lifecycle that the legacy system depended on. There is no evidence of explicit handling for invoice payment success/failure, charge lifecycle, customer lifecycle, business invoice persistence/PDF generation, or billing failure notifications. That is an incomplete Stripe setup, not a production-ready migration.", "Implement explicit Stripe webhook handling for invoice, payment, charge, and customer lifecycle events; drive business-plan behavior from synchronized metadata rather than plan-name strings; persist invoice/business outcomes locally; and add replayable webhook tests covering the real event set before acceptance.", ), audit( "A029", "Payments / Billing", "Stripe price handling has no authoritative source of truth and is split across DB sync, env-mapped IDs, and hardcoded business tariffs", 9, "Deployment bootstrap, public pricing pages, upgrade flow, and business checkout", "src/server/auth/index.ts:239-250 tells Better Auth to load subscription plans from the local `plan_price` table, but there is no startup synchronization path: the only full catalog sync is the manual CLI command in src/server/commands/payment/stripe-fetch-plans.command.ts:1-23, while src/instrumentation.ts:20-49 does only env validation and Sentry startup. The DB seed path does not bootstrap Stripe pricing either: src/server/database/seeders/payment-plan.seeder.ts:207-224 and payment-price.seeder.ts:99-118 only update content on plans/prices that already exist and silently skip missing records. Personal upgrade billing then bypasses the local catalog and uses env-pinned Stripe price IDs in src/app/api/subscriptions/upgrade/route.ts:49-58 via config entries from src/config/env.ts:66-71 and 202-208. Business checkout goes the other direction and bypasses stored Stripe prices entirely by recalculating seat pricing from hardcoded tiers in src/server/auth/index.ts:268-366. The organization billing UI still contains a hardcoded business tariff table with an explicit TODO at src/app/(main)/profile/organization/_components/organization-content.tsx:19-91. Repo docs are also inconsistent, still instructing operators to replace hardcoded Stripe price IDs in `auth.ts` and separately noting that business pricing is handled by inline `price_data` in docs/stripe.md:133-142, 230-260, 669, 737.", "This is split-brain billing. Depending on which path a user hits, price selection comes from the local DB, env-configured Stripe IDs, or hardcoded arithmetic in code. A fresh deployment can come up without synchronized Stripe plans/prices unless someone remembers to run a separate fetch command or receives the right webhooks, and the business checkout path can drift independently from whatever is shown in the stored catalog or dashboard. That is not production-ready pricing control.", "Choose one authoritative pricing model and enforce it everywhere. Either sync Stripe products/prices into the app automatically during deployment/startup with hard failure on drift, or manage prices locally and generate Stripe catalog from that source. Remove env-based personal plan mapping, remove hardcoded business tariff tables and calculators that bypass stored prices, and add an explicit catalog-sync verification step to release/bootstrap.", ), audit( "A030", "Auth / Schema", "Better Auth schema was hand-written instead of kept aligned with generated schema, and core auth/organization/subscription tables have already drifted", 8, "Core auth, organization, and billing data model", "Better Auth's official CLI docs state that `generate` creates the schema required by Better Auth for Drizzle: https://better-auth.com/docs/concepts/cli. The repo contains a generated Better Auth schema snapshot at auth-schema.ts, but the live Drizzle schema is handwritten elsewhere and materially different. Concrete drift includes: generated SessionTable has `activeOrganizationId` at auth-schema.ts:31-50, but manual SessionTable in src/server/modules/auth/tables/session.table.ts:11-26 does not; generated OrganizationTable requires `slug`, stores `metadata` as text, and has no `updatedAt` at auth-schema.ts:92-108, while src/server/modules/organization/tables/organization.table.ts:14-29 makes `slug` nullable, uses JSON metadata, and adds `updatedAt`; generated SubscriptionTable at auth-schema.ts:151-169 includes `cancelAt`, `endedAt`, `billingInterval`, `stripeScheduleId`, and default `status=\"incomplete\"`, while src/server/modules/payment/tables/subscription.table.ts:21-48 omits those fields and adds its own `provider`, `period`, `plan`, `metadata`, `limits`, and `userId`; generated UserTable uses text IDs and text `avatarId` at auth-schema.ts:12-29, while src/server/modules/user/tables/user.table.ts:18-37 uses UUID IDs, UUID avatar FK, and extra `firstName`/`lastName`; generated InvitationTable makes `inviterId` required with cascade and `role` optional at auth-schema.ts:129-149, while src/server/modules/organization/tables/invitation.table.ts:14-30 makes `inviterId` nullable with `set null`, requires `role`, and adds `updatedAt`. The repo also keeps a manual two-factor table at src/server/modules/auth/tables/two-factor.table.ts:10-21 even though the current auth config in src/server/auth/index.ts:135-470 does not enable the Better Auth 2FA plugin and the generated schema snapshot contains no two-factor table.", "This is not an abstract style preference. The authentication, organization, and Stripe subscription plugins are being run against a handwritten schema that already diverges from the schema Better Auth generates for the current config. That creates latent breakage around active organization switching, invitation semantics, subscription lifecycle fields, future Better Auth upgrades, and any migration or support work that assumes generated-schema parity.", "Regenerate the Better Auth schema from the current auth config, reconcile the handwritten Drizzle tables to match it or explicitly map every intentional deviation, remove dead auth tables that are not backed by enabled plugins, and add a CI drift check so generated auth schema changes cannot silently diverge from the live database model.", ), ] MOCK_FINDINGS = [ mock( "M001", "Archive page", "hardcoded data", "src/app/(main)/arkiv/page.tsx:19-99 defines a fixed `items` array with five records and repeated image/pdf assets while the page copy claims all 57 issues are available.", "Users see something that looks like an archive, but it is a fabricated five-row sample instead of the real legacy archive.", 10, ), mock( "M002", "Newsletter page", "dead submit handler", "src/app/(main)/nyhedsbrev/_components/newsletter-form.tsx:35-39 leaves the submit action as a comment placeholder.", "The primary newsletter signup page looks complete, accepts input, and then does nothing.", 10, ), mock( "M003", "Subscription upgrade", "stubbed backend", "src/server/modules/payment/services/subscription.service.ts:170-175 returns `{}` from `upgrade()` while src/components/payment/blocks/subscription-card.tsx:144-151 still calls it.", "Users are offered an upgrade flow that is not implemented.", 10, ), mock( "M004", "Project alerts", "misrepresented behavior", "src/components/shared/post-newsletter-form.tsx:61-69 sends only `{ email }` to the generic newsletter mutation, despite CTA copy promising project-specific alerts.", "The UI claims project update alerts, but the implementation is just a generic newsletter signup.", 9, ), mock( "M005", "Public Stripe test page", "demo/test surface", "src/app/test-stripe/page.tsx:8-195 ships a public demo control panel for test subscription and organization actions.", "A non-customer demo flow is presented on the public app surface.", 8, ), mock( "M006", "Personal payments page", "hardcoded marketing data", "src/app/(main)/payments/personal/page.tsx:136-175 hardcodes `2808` members and `MobilePay 711911`; lines 28-52 use legacy asset URLs.", "The page mixes real dynamic plan data with fake/static proof points that can mislead acceptance review and end users.", 7, ), mock( "M007", "Sponsorship page", "manual fallback posing as productized flow", "src/app/(main)/payments/sponsorship/page.tsx:92-97 converts through `mailto:` and lines 20-44 and 111-117 pull legacy-hosted assets.", "The surface looks migrated, but conversion is still manual and piggybacks on legacy assets.", 6, ), mock( "M008", "Guest links", "reduced implementation sold as full feature", "src/components/dashboard/post/post-guest-link-dialog.tsx:17-66 only supports an optional expiration date; it does not implement the requested one-time mode and builds the link with raw `token` querystring.", "Editors are given a 'guest link' tool that looks finished, but it omits key promised behavior.", 6, ), mock( "M009", "Organization pricing component", "dead hardcoded fallback", "src/app/(main)/profile/organization/_components/organization-content.tsx:19-110 contains a hardcoded business plan table and `currentPlan` fixed to `undefined`, but the active route uses a different component.", "Not currently user-visible on the active route, but it is still unfinished fallback code that imitates real pricing logic.", 3, ), mock( "M010", "Google Analytics sync", "hardcoded fake data", "src/server/clients/google-analytics.service.ts:102 injects `/kort/test` with 1000 views, and src/server/commands/update-ga-page-views.command.ts:14-23 persists that fabricated row like real analytics data.", "Analytics can appear populated and healthy even when part of the dataset is synthetic contractor filler.", 9, ), ] def write_csv(path: Path, fieldnames: list[str], rows: Iterable[dict[str, str]]) -> None: with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.DictWriter(handle, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) def read_csv(path: Path) -> list[dict[str, str]]: with path.open("r", newline="", encoding="utf-8") as handle: return list(csv.DictReader(handle)) def page_footer(canvas, doc) -> None: canvas.saveState() canvas.setFont("Helvetica", 8) canvas.setFillColor(colors.HexColor("#555555")) canvas.drawString(doc.leftMargin, 10 * mm, "Magasinet KBH acceptance audit") canvas.drawRightString(doc.pagesize[0] - doc.rightMargin, 10 * mm, f"Page {doc.page}") canvas.restoreState() XLSX_TITLE_FILL = PatternFill("solid", fgColor="FF10243E") XLSX_SUBTITLE_FILL = PatternFill("solid", fgColor="FFF6F8FB") XLSX_HEADER_FILL = PatternFill("solid", fgColor="FFE9EEF5") XLSX_ALT_FILL = PatternFill("solid", fgColor="FFF8FAFC") XLSX_WHITE_FILL = PatternFill("solid", fgColor="FFFFFFFF") XLSX_BORDER_SIDE = Side(style="thin", color="FFC9CCD1") XLSX_GRID_BORDER = Border( left=XLSX_BORDER_SIDE, right=XLSX_BORDER_SIDE, top=XLSX_BORDER_SIDE, bottom=XLSX_BORDER_SIDE, ) XLSX_TITLE_FONT = Font(name="Calibri", size=16, bold=True, color="FFFFFFFF") XLSX_SUBTITLE_FONT = Font(name="Calibri", size=10, color="FF10243E") XLSX_HEADER_FONT = Font(name="Calibri", size=10, bold=True, color="FF10243E") XLSX_BODY_FONT = Font(name="Calibri", size=10, color="FF222222") XLSX_BODY_ALIGNMENT = Alignment(vertical="top", wrap_text=True) def summarize_acceptance( feature_rows_data: list[dict[str, str]], audit_rows_data: list[dict[str, str]], mock_rows_data: list[dict[str, str]], ) -> dict[str, int | str]: required_rows = [row for row in feature_rows_data if row["scope_modifier"] == "required"] full_required = sum(1 for row in required_rows if row["verdict"] == "Full") partial_required = sum(1 for row in required_rows if row["verdict"] == "Partial") skipped_required = sum(1 for row in required_rows if row["verdict"] == "Skipped") blocking_gaps = sum( 1 for row in required_rows if row["verdict"] != "Full" and int(row["business_impact"]) >= 8 ) critical_audit = sum(1 for row in audit_rows_data if int(row["impact_1_10"]) >= 9) severe_mock = sum(1 for row in mock_rows_data if int(row["severity"]) >= 8) return { "verdict": "Reject/refund candidate", "required_scored": len(required_rows), "required_full": full_required, "required_partial": partial_required, "required_skipped": skipped_required, "blocking_gaps": blocking_gaps, "critical_audit": critical_audit, "severe_mock": severe_mock, } def style_range(ws, row: int, values: list[str], fill: PatternFill, font: Font) -> None: for column_index, value in enumerate(values, start=1): cell = ws.cell(row=row, column=column_index, value=value) cell.fill = fill cell.font = font cell.border = XLSX_GRID_BORDER cell.alignment = XLSX_BODY_ALIGNMENT def verdict_fill(value: str) -> PatternFill: mapping = { "Full": PatternFill("solid", fgColor="FFE7F4EA"), "Partial": PatternFill("solid", fgColor="FFFFF4D6"), "Skipped": PatternFill("solid", fgColor="FFFDE7E7"), } return mapping.get(value, XLSX_WHITE_FILL) def scope_fill(value: str) -> PatternFill: mapping = { "required": PatternFill("solid", fgColor="FFFFFFFF"), "optional": PatternFill("solid", fgColor="FFF3F4F6"), "withdrawn": PatternFill("solid", fgColor="FFEFF6FF"), "out_of_scope": PatternFill("solid", fgColor="FFF3F4F6"), } return mapping.get(value, XLSX_WHITE_FILL) def score_fill(score: int, high_is_bad: bool = True) -> PatternFill: if high_is_bad: if score >= 9: return PatternFill("solid", fgColor="FFFDE7E7") if score >= 7: return PatternFill("solid", fgColor="FFFFF4D6") return PatternFill("solid", fgColor="FFEFF6FF") if score >= 0: return XLSX_WHITE_FILL return XLSX_WHITE_FILL def apply_sheet_frame(ws, title: str, subtitle: str, columns_count: int) -> int: end_column = get_column_letter(columns_count) ws.merge_cells(f"A1:{end_column}1") ws["A1"] = title ws["A1"].fill = XLSX_TITLE_FILL ws["A1"].font = XLSX_TITLE_FONT ws["A1"].alignment = Alignment(horizontal="left", vertical="center") ws.merge_cells(f"A2:{end_column}2") ws["A2"] = subtitle ws["A2"].fill = XLSX_SUBTITLE_FILL ws["A2"].font = XLSX_SUBTITLE_FONT ws["A2"].alignment = XLSX_BODY_ALIGNMENT ws.row_dimensions[1].height = 24 ws.row_dimensions[2].height = 36 ws.sheet_view.showGridLines = False return 4 def autosize_columns(ws, widths: dict[str, float], columns: list[tuple[str, str]]) -> None: for column_index, (field, _) in enumerate(columns, start=1): ws.column_dimensions[get_column_letter(column_index)].width = widths.get(field, 18) def write_table_sheet( wb: Workbook, title: str, subtitle: str, rows: list[dict[str, str]], columns: list[tuple[str, str]], widths: dict[str, float], ) -> None: ws = wb.create_sheet(title) header_row = apply_sheet_frame(ws, title, subtitle, len(columns)) for column_index, (_, label) in enumerate(columns, start=1): cell = ws.cell(row=header_row, column=column_index, value=label) cell.fill = XLSX_HEADER_FILL cell.font = XLSX_HEADER_FONT cell.border = XLSX_GRID_BORDER cell.alignment = XLSX_BODY_ALIGNMENT for row_index, row in enumerate(rows, start=header_row + 1): stripe_fill = XLSX_ALT_FILL if (row_index - header_row) % 2 == 0 else XLSX_WHITE_FILL for column_index, (field, _) in enumerate(columns, start=1): value = row.get(field, "") cell = ws.cell(row=row_index, column=column_index, value=value) cell.font = XLSX_BODY_FONT cell.border = XLSX_GRID_BORDER cell.alignment = XLSX_BODY_ALIGNMENT cell.fill = stripe_fill if field == "verdict": cell.fill = verdict_fill(str(value)) cell.font = Font(name="Calibri", size=10, bold=True, color="FF10243E") elif field == "scope_modifier": cell.fill = scope_fill(str(value)) elif field in {"business_impact", "impact_1_10", "severity"}: try: cell.fill = score_fill(int(value), high_is_bad=True) except ValueError: pass end_row = header_row + max(len(rows), 1) end_column = get_column_letter(len(columns)) ws.freeze_panes = f"A{header_row + 1}" ws.auto_filter.ref = f"A{header_row}:{end_column}{end_row}" autosize_columns(ws, widths, columns) def build_workbook( feature_rows_data: list[dict[str, str]], audit_rows_data: list[dict[str, str]], mock_rows_data: list[dict[str, str]], ) -> None: summary = summarize_acceptance(feature_rows_data, audit_rows_data, mock_rows_data) appendix_rows = [ row for row in feature_rows_data if row["scope_modifier"] in {"withdrawn", "out_of_scope", "optional"} ] wb = Workbook() ws = wb.active ws.title = "Executive Summary" apply_sheet_frame( ws, "Magasinet KBH Acceptance Audit", "Editable workbook export generated from the same evidence tables as the PDF report.", 4, ) ws.column_dimensions["A"].width = 32 ws.column_dimensions["B"].width = 22 ws.column_dimensions["C"].width = 32 ws.column_dimensions["D"].width = 120 summary_rows = [ ("Verdict", str(summary["verdict"])), ("Required capabilities scored", str(summary["required_scored"])), ("Required full", str(summary["required_full"])), ("Required partial", str(summary["required_partial"])), ("Required skipped", str(summary["required_skipped"])), ("High-impact required gaps (impact >= 8)", str(summary["blocking_gaps"])), ("Critical audit findings (9-10)", str(summary["critical_audit"])), ("Severe mocked/hardcoded findings (>= 8)", str(summary["severe_mock"])), ] for row_index, (label, value) in enumerate(summary_rows, start=4): style_range(ws, row_index, [label, value], XLSX_SUBTITLE_FILL, XLSX_BODY_FONT) ws.cell(row=row_index, column=1).font = Font(name="Calibri", size=10, bold=True, color="FF10243E") ws.merge_cells(start_row=row_index, start_column=2, end_row=row_index, end_column=4) ws.cell(row=row_index, column=2).alignment = XLSX_BODY_ALIGNMENT notes_start = 14 note_blocks = [ ( "Methodology", "1. Built a canonical requirement list from RFQ.csv, KBH 2025 specs.pdf, the legacy Drupal custom modules, and the public legacy site structure.\n" "2. Traced the new Next.js codebase statically across app routes, server modules, components, and payment/auth paths.\n" "3. Ran local evidence checks: pnpm build, pnpm test, DB migrate/seed, and public route smoke probes on a local server.\n" "4. Separated required, optional, withdrawn, and out-of-scope rows so the verdict is based on mandatory feature delivery rather than wishful accounting." ), ( "Confidence Limits", "Public runtime was available locally, but contractor-hosted environments and admin/payment credentials were not. Authenticated, payment, and organization-management flows were therefore judged mostly from code paths and local unauthenticated smoke checks." ), ( "Evidence Notes", "Legacy baseline URLs checked: https://www.magasinetkbh.dk/, /projekter, /arkiv, /nyhedsbrev, /search/node/metro, /projekt/axel-towers, and /vision/lav-cykelparkering-over-alle-hovedbanegarden-perroner.\n" "Local smoke highlights: /, /arkiv, /nyhedsbrev, /payments/personal, /payments/sponsorship, /payments/business, /projekter, /anmeldelser, /foto, /opinion, /visioner, /kbhplus, /bydel/indre-by, /emne/metro, and seeded /inhold/... returned HTTP 200; /annoncering returned HTTP 500; /test-stripe returned HTTP 200." ), ( "Artifacts", f"PDF: {REPORT_PDF}\nFeature CSV: {FEATURE_CSV}\nAudit CSV: {AUDIT_CSV}\nMock/Hardcode CSV: {MOCK_CSV}" ), ] current_row = notes_start for heading, text in note_blocks: ws.cell(row=current_row, column=1, value=heading) ws.cell(row=current_row, column=1).font = Font(name="Calibri", size=11, bold=True, color="FF10243E") ws.cell(row=current_row, column=1).alignment = XLSX_BODY_ALIGNMENT ws.merge_cells(start_row=current_row, start_column=2, end_row=current_row, end_column=4) ws.cell(row=current_row, column=2, value=text) ws.cell(row=current_row, column=2).alignment = XLSX_BODY_ALIGNMENT ws.cell(row=current_row, column=1).border = XLSX_GRID_BORDER ws.cell(row=current_row, column=1).fill = XLSX_WHITE_FILL ws.cell(row=current_row, column=2).border = XLSX_GRID_BORDER ws.cell(row=current_row, column=2).fill = XLSX_WHITE_FILL ws.cell(row=current_row, column=2).font = XLSX_BODY_FONT current_row += 2 write_table_sheet( wb, "Feature Matrix", "Canonical capability matrix with parity verdicts and evidence references.", feature_rows_data, [(field, field) for field in FEATURE_FIELDS], { "id": 10, "area": 20, "capability": 34, "source_scope": 16, "source_refs": 40, "legacy_evidence": 44, "new_evidence": 44, "runtime_check": 34, "verdict": 12, "scope_modifier": 14, "confidence": 12, "business_impact": 12, "notes": 36, }, ) write_table_sheet( wb, "Audit Findings", "Ranked security, availability, performance, and engineering-quality findings.", audit_rows_data, [(field, field) for field in AUDIT_FIELDS], { "id": 10, "category": 20, "title": 34, "impact_1_10": 12, "reachable": 28, "evidence": 58, "why_it_matters": 54, "recommendation": 54, }, ) write_table_sheet( wb, "Mock Findings", "Surfaces where the implementation presents itself as functional while relying on reduced or fake behavior.", mock_rows_data, [(field, field) for field in MOCK_FIELDS], { "id": 10, "surface": 28, "type": 24, "evidence": 62, "user_visible_effect": 54, "severity": 12, }, ) write_table_sheet( wb, "Appendix", "Optional, withdrawn, and out-of-scope rows separated from pass/fail scoring.", appendix_rows, [ ("id", "id"), ("capability", "capability"), ("scope_modifier", "scope_modifier"), ("verdict", "verdict"), ("notes", "notes"), ], { "id": 10, "capability": 48, "scope_modifier": 18, "verdict": 12, "notes": 64, }, ) wb.save(REPORT_XLSX) def make_paragraph(text: str, style: ParagraphStyle) -> Paragraph: safe = escape(text).replace("\n", "
") return Paragraph(safe, style) def make_table( headers: list[str], rows: Iterable[list[str]], widths: list[float], cell_style: ParagraphStyle, header_style: ParagraphStyle, grid_color: colors.Color = colors.HexColor("#C9CCD1"), ) -> LongTable: data = [[make_paragraph(header, header_style) for header in headers]] for row in rows: data.append([make_paragraph(str(value), cell_style) for value in row]) table = LongTable(data, colWidths=widths, repeatRows=1) table.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E9EEF5")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#10243E")), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("ALIGN", (0, 0), (-1, 0), "LEFT"), ("LEFTPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 4), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), ("GRID", (0, 0), (-1, -1), 0.35, grid_color), ] ) ) return table def build_report( feature_rows_data: list[dict[str, str]], audit_rows_data: list[dict[str, str]], mock_rows_data: list[dict[str, str]], ) -> None: styles = getSampleStyleSheet() title_style = ParagraphStyle( "AuditTitle", parent=styles["Title"], fontName="Helvetica-Bold", fontSize=22, leading=26, textColor=colors.HexColor("#10243E"), spaceAfter=10, ) h1 = ParagraphStyle( "AuditH1", parent=styles["Heading1"], fontName="Helvetica-Bold", fontSize=15, leading=18, textColor=colors.HexColor("#10243E"), spaceBefore=8, spaceAfter=6, ) h2 = ParagraphStyle( "AuditH2", parent=styles["Heading2"], fontName="Helvetica-Bold", fontSize=11, leading=14, textColor=colors.HexColor("#173A63"), spaceBefore=6, spaceAfter=4, ) body = ParagraphStyle( "AuditBody", parent=styles["BodyText"], fontName="Helvetica", fontSize=8.3, leading=10.2, textColor=colors.HexColor("#222222"), spaceAfter=4, ) body_small = ParagraphStyle( "AuditBodySmall", parent=body, fontSize=6.5, leading=7.8, spaceAfter=0, ) header_small = ParagraphStyle( "AuditHeaderSmall", parent=body_small, fontName="Helvetica-Bold", textColor=colors.HexColor("#10243E"), ) summary = summarize_acceptance(feature_rows_data, audit_rows_data, mock_rows_data) required_rows = [row for row in feature_rows_data if row["scope_modifier"] == "required"] appendix_rows = [ row for row in feature_rows_data if row["scope_modifier"] in {"withdrawn", "out_of_scope", "optional"} ] blocking_gaps = [ row for row in required_rows if row["verdict"] != "Full" and int(row["business_impact"]) >= 8 ] summary_table = Table( [ ["Verdict", str(summary["verdict"])], ["Required capabilities scored", str(summary["required_scored"])], ["Required full", str(summary["required_full"])], ["Required partial", str(summary["required_partial"])], ["Required skipped", str(summary["required_skipped"])], ["High-impact required gaps (impact >= 8)", str(summary["blocking_gaps"])], ["Critical audit findings (9-10)", str(summary["critical_audit"])], ["Severe mocked/hardcoded findings (>= 8)", str(summary["severe_mock"])], ], colWidths=[95 * mm, 120 * mm], ) summary_table.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F6F8FB")), ("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#10243E")), ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), ("FONTNAME", (1, 0), (1, -1), "Helvetica"), ("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#C9CCD1")), ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ] ) ) feature_headers = [ "ID", "Area", "Capability", "Requirement / Refs", "Legacy vs New Evidence", "Runtime Check", "Verdict", "Scope", "Conf.", "Impact", "Notes", ] feature_widths = [14 * mm, 22 * mm, 43 * mm, 50 * mm, 96 * mm, 40 * mm, 15 * mm, 18 * mm, 12 * mm, 12 * mm, 46 * mm] feature_rows = [ [ row["id"], row["area"], row["capability"], f"Scope: {row['source_scope']}\nRefs: {row['source_refs']}", f"Legacy: {row['legacy_evidence']}\nNew: {row['new_evidence']}", row["runtime_check"], row["verdict"], row["scope_modifier"], row["confidence"], row["business_impact"], row["notes"], ] for row in feature_rows_data ] audit_headers = ["ID", "Category", "Title", "Impact", "Reachable", "Evidence", "Why It Matters", "Recommendation"] audit_widths = [15 * mm, 24 * mm, 44 * mm, 14 * mm, 27 * mm, 70 * mm, 55 * mm, 55 * mm] audit_rows = [ [ row["id"], row["category"], row["title"], row["impact_1_10"], row["reachable"], row["evidence"], row["why_it_matters"], row["recommendation"], ] for row in audit_rows_data ] mock_headers = ["ID", "Surface", "Type", "Evidence", "User Visible Effect", "Severity"] mock_widths = [15 * mm, 32 * mm, 28 * mm, 95 * mm, 80 * mm, 15 * mm] mock_rows = [ [ row["id"], row["surface"], row["type"], row["evidence"], row["user_visible_effect"], row["severity"], ] for row in mock_rows_data ] appendix_headers = ["ID", "Capability", "Scope Modifier", "Verdict", "Notes"] appendix_widths = [16 * mm, 95 * mm, 28 * mm, 18 * mm, 95 * mm] appendix_table_rows = [ [row["id"], row["capability"], row["scope_modifier"], row["verdict"], row["notes"]] for row in appendix_rows ] doc = SimpleDocTemplate( str(REPORT_PDF), pagesize=landscape(A3), leftMargin=16 * mm, rightMargin=16 * mm, topMargin=16 * mm, bottomMargin=18 * mm, title="Magasinet KBH Acceptance Audit", author="OpenAI Codex", ) story = [ Paragraph("Magasinet KBH Acceptance Audit", title_style), make_paragraph( "Repository under test: /Users/qodeboy/Workspace/UpWare/playground/magasinet-kbh", body, ), make_paragraph( f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", body, ), Spacer(1, 4 * mm), Paragraph("Executive Verdict", h1), make_paragraph( "Recommendation band: Reject/refund candidate. The deliverable does build locally, but it still fails too many high-impact acceptance checks: core mandatory capabilities are only partially implemented, several public-facing surfaces are fake or stubbed, and the security posture is weak enough to block sign-off on its own.", body, ), make_paragraph( "The strongest rejection signals are not cosmetic. They include public exposure of paywall-bypass data, insecure organization and upload APIs, a stubbed subscription upgrade path, a fake newsletter page, and a fabricated archive. That is not normal punch-list territory.", body, ), summary_table, Spacer(1, 5 * mm), Paragraph("Methodology", h1), make_paragraph( "1. Built a canonical requirement list from RFQ.csv, KBH 2025 specs.pdf, the legacy Drupal custom modules, and the public legacy site structure. 2. Traced the new Next.js codebase statically across app routes, server modules, components, and payment/auth paths. 3. Ran local evidence checks: pnpm build, pnpm test, DB migrate/seed, and public route smoke probes on a local server. 4. Separated required, optional, withdrawn, and out-of-scope rows so the verdict is based on mandatory feature delivery rather than wishful accounting.", body, ), make_paragraph( "Confidence limits: public runtime was available locally, but contractor-hosted environments and admin/payment credentials were not. Authenticated, payment, and organization-management flows were therefore judged mostly from code paths and local unauthenticated smoke checks. Confidence scores are lowered where runtime proof was not available.", body, ), make_paragraph( "Evidence commands used in this pass included: `pnpm build`, `pnpm test`, local HTTP probes against `http://127.0.0.1:3100`, and selective live-site checks against `https://www.magasinetkbh.dk/` for legacy-baseline confirmation.", body, ), Paragraph("Feature Parity Matrix", h1), make_paragraph( "Required rows drive the verdict. Optional, withdrawn, and out-of-scope rows are still included for transparency and repeated again in the appendix.", body, ), make_table(feature_headers, feature_rows, feature_widths, body_small, header_small), PageBreak(), Paragraph("Audit Findings", h1), make_paragraph( "These findings rank security, availability, performance, and engineering quality issues by business impact. Scores 9-10 are acceptance blockers.", body, ), make_table(audit_headers, audit_rows, audit_widths, body_small, header_small), Spacer(1, 4 * mm), Paragraph("Mocked / Hardcoded / Faked Functionality", h1), make_paragraph( "This section isolates surfaces where the implementation presents itself as functional while relying on static data, dead handlers, demo scaffolding, or materially reduced behavior.", body, ), make_table(mock_headers, mock_rows, mock_widths, body_small, header_small), Spacer(1, 4 * mm), Paragraph("Appendices", h1), Paragraph("Optional, Withdrawn, and Out-of-Scope Items", h2), make_table(appendix_headers, appendix_table_rows, appendix_widths, body_small, header_small), Spacer(1, 4 * mm), Paragraph("Evidence Notes", h2), make_paragraph( "Legacy baseline URLs checked in this pass: https://www.magasinetkbh.dk/, https://www.magasinetkbh.dk/projekter, https://www.magasinetkbh.dk/arkiv, https://www.magasinetkbh.dk/nyhedsbrev, https://www.magasinetkbh.dk/search/node/metro, https://www.magasinetkbh.dk/projekt/axel-towers, https://www.magasinetkbh.dk/vision/lav-cykelparkering-over-alle-hovedbanegarden-perroner.", body, ), make_paragraph( "Local smoke highlights: `/`, `/arkiv`, `/nyhedsbrev`, `/payments/personal`, `/payments/sponsorship`, `/payments/business`, `/projekter`, `/anmeldelser`, `/foto`, `/opinion`, `/visioner`, `/kbhplus`, `/bydel/indre-by`, `/emne/metro`, and seeded `/inhold/...` routes returned HTTP 200; `/annoncering` returned HTTP 500; `/test-stripe` returned HTTP 200 and exposed a public Stripe test page.", body, ), make_paragraph( "Framework note validated against official docs: Next.js 16.1.5 deprecates the `middleware` file convention in favor of `proxy`, which matches the local build warning emitted by this repository.", body, ), ] doc.build(story, onFirstPage=page_footer, onLaterPages=page_footer) def main() -> None: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) TMP_DIR.mkdir(parents=True, exist_ok=True) build_workbook(FEATURE_ROWS, AUDIT_FINDINGS, MOCK_FINDINGS) print(REPORT_XLSX) if __name__ == "__main__": main()