Zodiac Tech CRM Docs

Storage & file handling

Where files live in the platform — private recordings, the streaming playback route, generated tenant branding CSS, and retention.

developer Medium ⏱ 10 min Updated 2026-10-05

Storage & file handling

Files fall into two very different buckets: private business data (call recordings) that must never be publicly reachable, and generated public assets (per-tenant branding CSS) that browsers fetch anonymously. This page covers both, plus the retention job that cleans up.

Who this is for

Developers touching uploads/downloads, and hosting admins sizing/mounting storage on cPanel.

Difficulty: Medium · Time: about 10 minutes.

Private recordings (never public)

Mobile recordings are uploaded to the private local disk (Laravel's local disk rooted at storage/app/private, outside the web root):

  • Upload: POST /api/v1/mobile/recordings (multipart, device-token authenticated).
    • Max size 20 MB (RecordingController::MAX_KB = 20480).
    • Content must be audio/* — server-side mime detection with a client-declared-mime fallback for containers PHP cannot sniff (e.g. some .amr/.m4a).
    • The call is resolved within the requesting tenant (a call from another workspace 404s with CALL_NOT_FOUND).
    • Excluded numbers are a hard backstop — audio for an excluded number is rejected (422 NUMBER_EXCLUDED).
    • Re-uploading for the same call is idempotent: the existing stored recording is returned with duplicate: true.
  • Layout: recordings/{tenant_id}/{lead_call_id}/….
  • Playback: GET /api/v1/mobile/recordings/{id} streams the file (RecordingController::show).
    • No public URL exists by design.
    • The CallRecording model is tenant-scoped, so another tenant's id is a 404.
    • A purged/expired recording returns 410 GONE; a missing file returns 404 FILE_MISSING.
    • If transcription is configured for the workspace, a new upload dispatches TranscribeCall (the same gate as the manual web action).

Retention

  • Each recording gets expires_at = now + 30 days (RETENTION_DAYS = 30).
  • php artisan leadhub:purge-recordings runs daily at 03:15 (see Queues, jobs & scheduler):
    • deletes files past expiry from the disk;
    • keeps the metadata row, marked expired with path set to null, so call history/reporting stays intact;
    • supports --dry to preview candidates.

Public branding CSS (generated per tenant)

The white-label theme is a generated stylesheet, not a database-driven runtime paint:

  • App\Services\SettingsService writes public/tenant/{id}/branding.css when branding/settings change (public/ must be writable; a write failure is logged as a warning and never breaks saving).
  • App\Http\Middleware\InjectTenantBranding injects the stylesheet into the tenant panel's pages (registered on the admin panel, including persistent/Livewire requests), with a ?v= cache-buster from the file's mtime.
  • These files are public by design — they contain only colors/theme tokens, never secrets.

public/tenant and public/storage must be writable On cPanel installs set permissions so the web user can write public/tenant/ (branding generation) and create the public/storage symlink.

Some uploaded assets are served through /storage/..., which requires public/storage → storage/app/public:

  • Super Admin → System (App\Filament\SuperAdmin\Pages\SystemHealth) has a storage link action that runs php artisan storage:link in-app and reports success/already-exists/failure — the shared-hosting-friendly way to fix a missing symlink.
  • From a terminal the same thing is just php artisan storage:link.

Backups

php artisan leadhub:backup runs nightly at 02:00 if enabled (GeneralSettings::auto_nightly_backup, or leadhub.backups.auto_nightly in config). Backup scope and restore steps are covered in Backups & restore.

Was this page helpful?