Status 2026-07-13: implemented on feature/130-report-builder (#608) — as-built notes in the #130 status section.
Parent: #130 (Report Builder — bands + bricks). Phase 1. Part of epic #506, Track A.
Scope
Persistence is file-based only — no report_templates table, no DB rows. Layout:
resources/report-templates/ # shipped, version-controlled
invoice/default/manifest.json # { name, slug, type, version }
invoice/default/bands.json # { header: […], group_header: […], details: […], group_footer: […], footer: […] }
quote/default/{manifest,bands}.json
storage/app/report_templates/ # runtime disk 'report_templates'
system/… # published via `php artisan reports:sync-system`
{company_id}/{slug}/{manifest,bands}.json # company clones
bands.json entries are data only: { "brick": "header_company", "width": "half", "config": { … } } — never code.
Build ReportTemplateStorage (rework of the old branch's MasonTemplateStorage + ReportTemplateFileRepository):
list($companyId) — disk scan of system/ + the company folder
loadManifest()/loadBands()/save()/delete()
clone($from, $companyId, $newName) — copy folder + rewrite manifest (name, slug, cloned_from)
- Validation on load: unknown brick ids skipped, config filtered against the brick's schema, width/band values validated against enums
- Slug sanitization:
[a-z0-9-] only, .. rejected; company path always derived from the current tenant, never from user input
Plus the reports:sync-system artisan command (idempotent publish of resources/report-templates/ → storage/app/report_templates/system/).
Acceptance criteria
Test scenarios (PHPUnit, #[Test], no Pest)
- Round-trip, clone, sanitization, tenant-isolation and sync-idempotency tests (adapt the harvested
MasonStorageAdapterTest / ReportTemplateFileRepositoryTest suites to the no-DB design).
Status 2026-07-13: implemented on
feature/130-report-builder(#608) — as-built notes in the #130 status section.Parent: #130 (Report Builder — bands + bricks). Phase 1. Part of epic #506, Track A.
Scope
Persistence is file-based only — no
report_templatestable, no DB rows. Layout:bands.jsonentries are data only:{ "brick": "header_company", "width": "half", "config": { … } }— never code.Build
ReportTemplateStorage(rework of the old branch'sMasonTemplateStorage+ReportTemplateFileRepository):list($companyId)— disk scan ofsystem/+ the company folderloadManifest()/loadBands()/save()/delete()clone($from, $companyId, $newName)— copy folder + rewrite manifest (name,slug,cloned_from)[a-z0-9-]only,..rejected; company path always derived from the current tenant, never from user inputPlus the
reports:sync-systemartisan command (idempotent publish ofresources/report-templates/→storage/app/report_templates/system/).Acceptance criteria
../, uppercase, and non-slug characters are rejected with an exception.reports:sync-systemis idempotent and never touches company folders.Test scenarios (PHPUnit,
#[Test], no Pest)MasonStorageAdapterTest/ReportTemplateFileRepositoryTestsuites to the no-DB design).