# Media (Shared · `Src\Common`)

**Context:** Shared library (not a portal module) · **Routes / UI:** none of its own · **Used by:** Profile (avatar + IC upload), WhatsApp (inbound media), AI Video (input materials + output MP4), Phone Call + Showroom F2F (recording audio), Events · Slot Posters (uploaded/generated poster images), File Uploads (ad-hoc files up to 500 MB, via `ChunkedUploadService`) — designed to be reused by any module.

## What it does
A **general-purpose stored-file service**: take some bytes (raw or an HTTP upload), store them on a configured filesystem disk (**private Google Cloud Storage by default**), record them as a `Media` row, and hand back the model. The `Media` record is **polymorphic** — it can hang off *any* Eloquent model (a WhatsApp message, a user, a property, …) via a `mediable` morph, or stand alone and be referenced by a plain foreign key (e.g. `whatsapp_attachments.media_id`). Because the bucket is private, files are never served by a public URL; instead the service mints **short-lived signed (temporary) URLs** on demand.

It is the shared counterpart to `Src\Common\Address` — reusable cross-model data living in `src/Common` (GUIDELINES §7, *Shared / polymorphic data*).

## How it works
- **Three pieces, clean separation of concerns:**
  - `Src\Common\Media` — the Eloquent record only (schema, the `mediable()` morph, the `isImage()` helper). A standard **key model** (uuid + blame + soft delete).
  - `Src\Common\Services\MediaService` — the **storage** layer: write/read/delete bytes on the disk, infer the file extension, mint signed/public URLs; coordinates durable cleanup intents with `MediaCleanupOutbox`. Two public members exist for owners whose rows the cleanup retry cannot reach: **`hasDurableDeleteIntent(Media): bool`** ("will a failed delete still be retried for this object?" — see *deleting an owner*, below) and **`processCleanupTask(MediaCleanupTask)`**, which now *requires* an already-claimed task and throws `LogicException` on an unclaimed one.
    `MediaCleanupOutbox` owns the intent lifecycle beside it — `record()` / `recordWritingStore()`, `claim()` / `releaseClaim()`, `hasIntent()` — over a **three-value status**: `pending` (due work), `writing` (a live upload owns it) and `processing` (a worker or a synchronous delete owns it while its storage I/O runs).
  - `Src\Common\Repositories\MediaRepository` — the **DB-write** layer: `create` / `delete`, each inside `DB::transaction`, per GUIDELINES.
- **Storing.** `MediaService::store()` (or `storeUpload()` for an `UploadedFile`) calls the low-level `put()`, which writes the bytes to the disk under a unique, collision-proof path — `{directory}/{collection}/{uuid}.{ext}` — then delegates to `MediaRepository::create()` to persist the row (disk, path, size, mime, dimensions, `collection`, optional `meta`, and the owner morph). Objects are **grouped by collection** in the bucket (`media/avatar/…`, `media/id_front/…`, `media/whatsapp/…`) so lifecycle / IAM rules can target a type; callers may override the whole sub-path via the `directory` option. The filename is a UUID (no PII in keys). The file is written **before** the row, so a failed insert leaves an orphaned file (the safe direction) rather than a row pointing at nothing.
  - ⚠️ **`put()` throws when the write fails, and callers may rely on that.** `Storage::put()` *returns `false`* on failure — it does not throw, because no disk in `config/filesystems.php` sets `'throw'` — and that return used to be discarded. A GCS 5xx, an expired service-account key or a quota hit therefore produced a `Media` row pointing at an object **that does not exist**, and every caller reported success. On the public payment-claim form that meant telling a customer *"we have your receipt"* while the bucket held nothing, and the compensating rollback written around this service (`PaymentClaimRepository`'s `forceDelete()` of the half-made claim) never fired, because nothing threw. Throwing is what every such rollback already assumed; the check makes the assumption true. Preferred over `'throw' => true` in the disk config, which would change behaviour for every `Storage::` call in the app including read paths that legitimately expect `false`.
- **Reading.** `temporaryUrl()` produces a signed URL valid for `media.signed_url_minutes` (default 15 min). `stream()` opens a read stream for an authorized application response, including private local development files; the consuming controller enforces access and byte ranges. `url()` exists for media on a public disk; `displayUrl()` picks the right one by disk visibility and never throws (returns null on failure). Provider/expiring source URLs are never persisted or surfaced.
- **Deleting.** `MediaService::delete()` is a **hard delete** (no soft-delete tombstone), because the file is gone and the row could never be restored to a working state. `MediaRepository::delete()` (soft) remains available for a future recoverable-trash flow that keeps the file. It runs in four steps, and the shape matters — **no transaction is open while the bucket is called** (GUIDELINES §2):
  1. record a durable `DELETE_INDEXED` outbox intent;
  2. **claim** it in a short transaction — status `processing`, a fresh `owner_token`, and a lease of `media.cleanup_claim_minutes`;
  3. delete the bucket object **outside every transaction**;
  4. in a second short transaction, re-verify the claim is still its own, then force-delete **every row in that scope indexing the same disk+path** (falling back to the passed model when the scope's table holds none) together with the intent.

  On failure the claim is released, a `CleanupMediaObject` retry is dispatched and a generic `RuntimeException('Media cleanup failed.')` is thrown — including when a **live** owner already holds the claim, whose outcome `delete()` cannot confirm. (An already-expired claim is taken over rather than refused; see *Durable cleanup*.) If the task row has vanished entirely, a worker has already finished the job and `delete()` returns quietly.
- **Attaching to an owner.** Pass the owner model as the first argument to `store()`/`storeUpload()` and it fills `mediable_type` + `mediable_id`. On the owner side, declare `morphMany(\Src\Common\Media::class, 'mediable')`. Owners may also reference a standalone `Media` by a normal nullable, indexed `media_id` column (the WhatsApp attachment pattern) — both wirings are first-class because the morph columns are nullable.

### Reference usage — Profile avatar & ID upload (reference implementation)
See the [Profile](/docs/modules_handbook/shared/profile/readMe.md) handbook for the full feature; the media flow is:
1. On the Profile page, picking a photo posts it to `POST {base}/avatar`; `UploadAvatarRequest` validates it (the HTTP boundary — `MediaService` doesn't validate uploads itself).
2. `HandlesProfile::replaceProfileMedia()` ensures the user has a profile row, **hard-deletes** any existing `avatar` media (file + record), then calls `MediaService::storeUpload($profile, $file, ['collection' => 'avatar'])` — stored on GCS under `media/avatar/…`, owned by the `UserProfile`.
3. The page re-renders; `HandlesProfile::profileProps()` reads the profile's media keyed by collection and returns fresh `MediaService::displayUrl()`s. The avatar is also shared globally (`HandleInertiaRequests` → `auth.user.avatar`) so every page's nav shows it.
4. **ID** is the same flow with collections `id_front` / `id_back` (Passport uses `id_front` only), each side auto-saving independently; `IdUploadTab` previews images inline and links PDFs.

## Usage
```php
use Src\Common\Services\MediaService;

// Inject the service (constructor or app(MediaService::class)) — there is no Facade.
public function __construct(protected MediaService $media) {}

// 1) Store raw bytes attached to an owner model
$file = $this->media->store($user, $bytes, [
    'collection' => 'avatar',          // logical grouping (free-form)
    'mime'       => 'image/png',
    'name'       => 'avatar.png',
    'width'      => 256,
    'height'     => 256,
    'meta'       => ['source' => 'upload'],
]);

// 2) Store an HTTP upload (mime + original name inferred from the UploadedFile)
$file = $this->media->storeUpload($user, $request->file('photo'), ['collection' => 'avatar']);

// 3) Store standalone (no owner) — reference it later by media_id
$file = $this->media->store(null, $bytes, ['collection' => 'export']);

// 4) Read — short-lived signed URL for a private file
$url = $this->media->temporaryUrl($file);          // default TTL (config)
$url = $this->media->temporaryUrl($file, 60);      // 60-minute TTL

// 5) Delete file + record
$this->media->delete($file);
```

Attach the relationship on any owner model:
```php
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
    return $this->morphMany(\Src\Common\Media::class, 'mediable');
}
```

> `storeUpload()` trusts that the caller has already **validated** the `UploadedFile` (mime / size limits) in its own Form Request — the service does not validate uploads. Enforce upload rules at the consuming module's HTTP boundary (GUIDELINES §8, §10).

### Reference usage — files larger than one request (`ChunkedUploadService`)

A request body over **100 MB never reaches PHP**: Cloudflare's edge answers `413` itself. So no single-request upload can exceed ~90 MB, whatever `post_max_size` says. For bigger files, **`Src\Common\Services\ChunkedUploadService`** takes the file in 8 MiB pieces, rebuilds it in a server temp file, and hands the finished file to **`MediaService::storeFromPath()`** — the same durable store/cleanup path as every other upload. The reference consumer is [File Uploads](/docs/modules_handbook/manage/uploads/readMe.md) (`ChunkedUploadsController` + `resources/js/utils/chunkedUpload.js`).

```php
// 1) Open — validate name/size in your own Form Request first.
$upload = $chunks->start($user->id, $name, $size, ['collection' => Media::COLLECTION_UPLOAD]);

// 2) Each piece — the client sends `offset` + `chunk`; find() is scoped to the opener.
$upload = $chunks->find($id, $user->id);                     // null → 404
$result = $chunks->append($upload, $request->file('chunk'), $offset);
// $result['media'] is null until the last byte lands, then the stored Media.
```

- **The server owns the offset.** `append()` runs under a per-upload cache lock and cuts the temp file back to the recorded offset before writing. A piece the server already holds is acknowledged, not written again. A piece past the offset throws `ChunkOutOfOrderException` carrying `received`, and the client resends from there — so a lost response or a retry racing its original never corrupts the file.
- **Retryable vs. final.** A plain `RuntimeException` (a write or bucket failure) is worth retrying: the temp file and state are **kept**, so resending the last piece finishes the upload instead of restarting it. `ChunkedUploadRejectedException` (expired, overran its size, wrong assembled length) is not. The controller maps these to `409` / `503` / `422` — the retry contract `chunkedUpload.js` follows.
- **Type is sniffed on piece 0** (`mimes:` in the chunk request) and stored as the Media `mime`. A file's signature is in its first bytes — xlsx, zip, gz, pdf and mp4 prefixes were verified to sniff correctly. The browser's claimed type is never used.
- **Where the bytes wait.** Pieces wait in `sys_get_temp_dir()/chunked-uploads` — under Apache that is its systemd **PrivateTmp**, cleared on restart. `start()` refuses a file that would leave under 2 GB free, and sweeps temp files idle for 6 h. ⚠️ This assumes every piece reaches the **same web server**. Behind a multi-node load balancer the temp directory would need to be shared.
- **Why not browser → GCS directly?** It would skip the double hop, but it needs bucket CORS for the site origin, and the app's service account has no `storage.buckets.get/update` permission to set (or even read) it. The shared `petav3-prod` bucket is a GCP-admin change. Server → GCS measured ~100 MB/s from this box, so a 500 MB file's final push is ~5 s.

### Reference usage — shared Area Guide assets

Area Guide media uses the same service with **explicit server-created ownership**. Passing an Area Guide owner alone does not switch databases: omitting `context` preserves the existing local `media` / `media_cleanup_tasks` defaults for every consumer.

```php
use Src\Common\Services\MediaService;
use Src\Common\Support\MediaContext;

// Validate upload type, size and access in the consuming feature first.
$replacement = $mediaService->storeFromPath($asset, $temporaryPath, [
    'context' => MediaContext::areaGuide(),
    'collection' => 'area-guide-panorama',
    'mime' => 'image/jpeg',
    'name' => 'panorama.jpg',
]);
// The asset repository attaches $replacement->id using its revision check.
// After that transaction commits, delete the superseded AreaGuideMedia:
$mediaService->delete($previousMedia);
```

`store()`, `storeUpload()` and `copyFrom()` also accept this context. All shared writes use the durable `storeFromPath()` flow; uploads and copies are streamed from temporary files. Shared media rows are `Src\Common\AreaGuideMedia` in `area_guide_media`, and their outbox is `area_guide_media_cleanups`. Both use `AreaGuideContentConnection`; these are **separate from catalogue `media`**, whose mirror/copy lifecycle must not replace Area Guide uploads. An asset's `media_id` / `cover_id` relationship must target `AreaGuideMedia`, not the local `Media` class. Media metadata includes `stored_by` with `site_key`, `user_uuid` and `name`; local numeric blame IDs alone do not identify a person across sites.

The server selects `area_guide_content.media_disk` (`AREA_GUIDE_MEDIA_DISK`) and `media_directory` (`AREA_GUIDE_MEDIA_DIRECTORY`) — both declared config keys, detailed under *Configuration → Area Guide shared disks*. Shared disks must be private. A local filesystem driver is accepted only in `local` / `testing`; a production deployment requires shared storage. The database connection is also server-selected and ownership-guarded. Neither the context, disk nor connection is accepted directly from request input. `MediaService` itself does not decide whether a user may view an asset: authorize the parent asset before `stream()` or URL generation. A private disk does not replace application authorization.

Use **store new → attach with revision check → commit → delete previous** for replacements. Call storage before the asset transaction so the cleanup intent is durable independently of that transaction. If attachment fails, send only the newly stored row to `MediaService::delete()`; leave the previous row and asset pointer intact. A failed upload or index insert leaves an outbox intent, allowing cleanup without deleting a working previous file.

### Durable cleanup and database scope

`storeFromPath()` records a `writing` intent before storage I/O (Input/Output). Index creation and intent removal share the owning database transaction. A failed store marks the intent pending; an expired writing lease is also recoverable by the scanner. Compensation first checks the **same scope's** media table and preserves an object that was successfully indexed.

**The writing lease is configurable, and its length is load-bearing.** It is `config('media.writing_lease_minutes')`, default **60 minutes**; a value that is absent, blank or non-positive falls back to that default rather than shrinking (`MEDIA_WRITING_LEASE_MINUTES=` with nothing after it casts to `0`, and this project's CakePHP `env()` hands back that empty string instead of the default). It must stay well above the slowest realistic upload — `area_guide_content` caps video at 80 MiB — because **every** writer site's hourly scanner reclaims expired leases from the *same* shared `area_guide_media_cleanups` table. A reclaimed intent no longer belongs to the writer holding it: when the upload then finishes, `storeFromPath()` finds the claim gone and **deletes the object it just wrote** (best effort, logged as *"Reclaimed media upload could not remove its object."* by `task_id` / `cleanup_key` / `media_scope` — never the path, since no durable intent is left to retry it). That is the correct outcome for an intent someone else now owns, but it is why a short lease loses uploads.

**Storage I/O never runs inside a transaction, and never under a row lock.** `delete()` and the worker share one three-phase shape: a short transaction **claims** the task (`processing` + `owner_token` + a lease of `config('media.cleanup_claim_minutes')`, default **15 minutes**); the bucket call runs with nothing open; a second short transaction re-verifies the claim and writes. A claim lost mid-I/O (its lease expired and another owner took over) makes the completion return false and write nothing, so the new owner's work is never overwritten — `delete()` then reports failure even though the object is in fact gone, because at that point the other owner's outcome is not confirmed.

**Claim takeover.** `MediaCleanupOutbox::claim()` takes a task that is `pending`, **and also one whose `processing` lease has already expired** — the same rule the scanner applies, so a worker that died mid storage I/O cannot make every synchronous delete of that object fail for the length of its lease. A *live* claim is still refused, and a `writing` intent is never taken this way: it belongs to a live uploader until the scanner hands it back. Both claimers serialise on the same `lockForUpdate`, so a takeover cannot cause double processing; the dead owner's completion, release and job-failure paths are all `owner_token`-guarded and become no-ops.

`MediaService::processCleanupTask()` refuses an unclaimed task (`LogicException`) rather than racing whoever owns it.

⚠️ **Latency is the price of the long lease.** With a 60-minute writing lease and an hourly `media:dispatch-cleanup`, an object left behind by a crashed upload can sit for up to ~2 h before it is swept (against ~70 min under the old 10-minute lease). That is deliberate — a lease must outlive the slowest upload — not a defect. Shorten `media.writing_lease_minutes` only if the slowest real upload is known to be far below it.

The existing scheduler command `media:dispatch-cleanup` scans only local media. A site authorized to write shared Area Guide content also schedules `media:dispatch-cleanup --scope=area-guide`; do not schedule shared cleanup on a read-only mirror. Both commands use the same `CleanupMediaObject` job and claim leases, and both reclaim **two** kinds of expired lease back to `pending`: a `writing` intent whose uploader never indexed its object, and a `processing` claim whose worker (or synchronous delete) died mid storage I/O. New jobs carry the registered scope, connection name, a database endpoint fingerprint and `cleanup_key`, so equal numeric IDs in local/shared databases or a recycled task ID cannot target another file. Credentials, paths and models are not serialized into the queue payload. A changed database target rejects the queued task before looking up its ID; the scanner on the correct deployment can redispatch the durable row. Old jobs containing only an ID keep their local-only interpretation. Such a job now fails **permanently on the first attempt** — logged once (*"Media cleanup job no longer matches its media database."*) and failed with `$this->fail()`, instead of being retried 5 times over ~81 minutes for a payload no retry can fix. `failed()` never throws: it resolves the identity defensively, suppresses the duplicate log when the failure it is handed is the identity error `handle()` already reported, and only ever touches a task still in status `pending` — a `writing` or `processing` row belongs to its owner and is recovered by that owner's lease instead.

For isolated development, apply only `2026_09_13_220000_create_area_guide_media.php` on the selected content database along with the Area Guide content migrations; do not run catalogue mirror/copy or repoint global catalogue project reads. Note the side effect that is easy to misread: `AppServiceProvider::boot()` calls `loadMigrationsFrom(database_path('migrations/catalogue'))`, so a plain `php artisan migrate` **also** creates empty `area_guide_media` and `area_guide_media_cleanups` tables in every site's own default database. Nothing ever reads them unless `AreaGuideContentConnection::name()` happens to resolve to that database; the shared master gets the real tables from the Hub deploy's `migrate --path=database/migrations/catalogue --database=catalogue`. The migration creates the two dedicated tables and refuses to roll them back while media or cleanup rows remain. Production grants can be restricted to these tables plus the Area Guide content tables; no new write grant to catalogue project/media tables is required.

### Reference usage — deleting an owner without stranding its files

`MediaService::delete()` is the **only** path that removes the bucket object as well as the row: `Media` has no `deleting` hook, and deleting an owner row (or its `media` row directly) leaves the object in GCS **forever** — the `disk` + `path` that identified it are gone with the row. When a feature deletes owners in bulk, follow the order used by `LeadRepository::purgeAll()` (see the [Leads handbook](/docs/modules_handbook/manage/leads/readMe.md)):

1. **Resolve** the `Media` models first — via the `mediable` morph *and* via any `media_id` column pointing at them (`call_recordings`, `f2f_recordings`, `whatsapp_attachments`, `messenger_attachments`).
2. **Delete the files** with `MediaService::delete()` **outside** the `DB::transaction` — a bucket delete cannot be rolled back — and log-and-continue on failure: a stranded object is recoverable, a half-deleted database is not.
3. **Then** delete the owner rows inside the transaction.

**Guarded soft-delete exception — lead action items:** `LeadActionItemRepository::delete()` first checks journey ownership under its customer/task locks, records `DELETE_INDEXED` intents for its local media in the same transaction, and soft-deletes the task. `ActionItemsController` then calls `MediaService::delete()` outside that transaction. This prevents a rejected journey-linked deletion from deleting files first. The task row and each media disk/path survive until cleanup, and the durable intent covers a crash before the controller's storage call.

Files written straight to a disk instead of `media` (e.g. `concierge_request_photos.path` on the local `public` disk) are invisible to `MediaService` and must be deleted with `Storage::disk(...)->delete()`.

**A media row in ANOTHER schema is the owner's problem, not the retry's.** The `DELETE_INDEXED` retry job only ever searches the media table of the task's **own scope**. A row living in a different schema — the catalogue master's `MasterMedia`, reached through `CatalogMediaRepository` — can never be removed by that retry, so it would outlive the object the retry deletes. The owning repository therefore does it: after a failed `MediaService::delete()` it calls **`MediaService::hasDurableDeleteIntent($stored)`** and, *only* when that confirms an intent exists, force-deletes the stored media row alongside its `catalog_media` row. Without a confirmed intent the row is the object's only remaining trace, so it is deliberately kept. `CatalogMediaRepository` applies the same rule on **both** sides: `delete()`, and `create()`'s compensating rollback — where the `catalog_media` insert is rolled back but the `MasterMedia` row written before it is outside that transaction and does not roll back with it.

## Data model (`media` table)
| Column | Type | Notes |
|--------|------|-------|
| `id` | `bigIncrements` | primary key; the target of every FK (`media_id`) |
| `uuid` | `uuid` unique | public identifier (`HasUuid`) |
| `mediable_type` / `mediable_id` | `nullableMorphs` | owner morph; nullable so media may be standalone (indexed, no FK) |
| `collection` | `string` indexed, default `default` | logical grouping, e.g. `whatsapp`, `avatar` |
| `disk` | `string` | the filesystem disk the file lives on |
| `path` | `string(1024)` | path within the disk |
| `name` | `string` nullable | original / display filename — ⚠️ **stored verbatim as the client sent it**, and `extension()` takes the stored object's extension from it in preference to the sniffed MIME. On an **authenticated** uploader that is a display convenience. On anything a stranger can reach it is not: real PNG bytes named `receipt.html` were stored as `.html`, and a 1,500-character name produced a path silently truncated to 1,024 by the column, leaving a bucket object nothing could ever find or purge. It is also raw user text, so it becomes a stored-XSS / CSV-formula surface the moment a screen or an export renders it (GUIDELINES §14). **Any unauthenticated caller must pass its own `name` option** — see `PaymentClaimRepository::submit()`, which passes `receipt-{reference}.{guessExtension()}`: `guessExtension()` is the sniffed value the `mimes:` rule just validated, so the customer's string never enters the system. |
| `mime` | `string` nullable | MIME type (`isImage()` keys off `image/*`) |
| `size` | `unsignedBigInteger` nullable | bytes |
| `width` / `height` | `unsignedInteger` nullable | image/video dimensions |
| `meta` | `json` nullable | free-form extra metadata (cast to array) |
| `created_by` / `updated_by` / `deleted_by` | `unsignedInteger` nullable | blame (`RecordsBlame`) |
| `timestamps`, `softDeletes` | | created/updated + soft delete |

Linking migration: `…_add_media_id_to_whatsapp_attachments_table` adds a nullable, indexed `media_id` to `whatsapp_attachments` (no schema-level FK, GUIDELINES §7).

## Configuration
`config/media.php` (env-backed):

| Key | Env | Default | Meaning |
|-----|-----|---------|---------|
| `disk` | `MEDIA_DISK` | `gcs` | filesystem disk media is stored on |
| `signed_url_minutes` | `GOOGLE_CLOUD_STORAGE_SIGNED_URL_MINUTES` | `15` | signed-URL TTL for private media |
| `directory` | `MEDIA_DIRECTORY` | `media` | base directory within the disk; each collection is a sub-folder (e.g. `media/avatar`) |
| `writing_lease_minutes` | `MEDIA_WRITING_LEASE_MINUTES` | `60` | minutes a stream upload owns its `writing` cleanup intent before a scanner may reclaim it. Must exceed the slowest realistic upload; a blank or non-positive value falls back to 60 rather than shrinking |
| `cleanup_claim_minutes` | `MEDIA_CLEANUP_CLAIM_MINUTES` | `15` | minutes a claimed cleanup task stays owned while its storage delete runs outside any transaction; an expired claim is handed back to the scanner or taken over by the next claimer |

The default `gcs` disk is defined in `config/filesystems.php` (`visibility: private`, backed by **spatie/laravel-google-cloud-storage**) and needs `GOOGLE_CLOUD_PROJECT_ID`, `GOOGLE_CLOUD_KEY_FILE`, `GOOGLE_CLOUD_STORAGE_BUCKET` (+ optional `GOOGLE_CLOUD_STORAGE_PATH_PREFIX`) in `.env`. Point `MEDIA_DISK` at `public` or `local` for non-cloud setups — `url()` then returns a normal public URL.

> **Uniform bucket-level access (required gotcha).** Buckets created with **Uniform bucket-level access** reject per-object (legacy) ACLs. The `gcs` disk therefore sets `'visibility_handler' => \League\Flysystem\GoogleCloudStorage\UniformBucketLevelAccessVisibility::class` so writes send **no** ACL. Without it every upload fails server-side with *"Cannot insert legacy ACL for an object when uniform bucket-level access is enabled"* — but **silently**, because `Storage::put()` swallows the exception and returns `false`, so `store()` still returns a `Media` row and `temporaryUrl()` still mints a (signed, but 404-ing) URL. If files appear to "save" yet the signed URL returns `NoSuchKey`, this handler is the fix.

**Local development (no GCS credentials).** The default `gcs` disk needs a
service-account key at `storage/app/google/service-account.json`; without it
any upload throws `GoogleException: Given keyfile path … does not exist`. To
develop against local storage instead, put `MEDIA_DISK=public` in your own
`.env` (gitignored — other developers and every deployed env are unaffected),
run `php artisan storage:link` once, then `php artisan config:clear`. Uploads
land in `storage/app/public/media/{collection}/` and `displayUrl()` returns a
plain `APP_URL/storage/…` URL because that disk is `visibility: public`, so no
signing is involved. **Each `media` row stores its own `disk`**, so switching
only affects NEW uploads — rows already written against `gcs` keep pointing at
`gcs` and are not broken or migrated by the change.

**Service-account setup (GCS):** create a service account, grant it **Storage Object Admin** on the bucket (bucket → Permissions → Grant access — scoped, not project-wide), download a JSON key to `storage/app/google/service-account.json` (gitignored — share out-of-band like `.env`), then set the env above and run `php artisan config:clear`.

### Area Guide shared disks

The shared Area Guide scope does **not** use `media.disk`. `config/area_guide_content.php` picks its own, and both candidate disks are defined in `config/filesystems.php`:

| Disk | Driver | Settings |
|------|--------|----------|
| `area_guide_shared` | `gcs` | `AREA_GUIDE_GCS_PROJECT_ID`, `AREA_GUIDE_GCS_KEY_FILE`, `AREA_GUIDE_GCS_BUCKET`, `AREA_GUIDE_GCS_PATH_PREFIX` — **each falls back (`?:`, so an empty line counts as unset) to its general `GOOGLE_CLOUD_*` twin**, which is what lets one platform-wide bucket be configured once; `visibility: private` plus the `UniformBucketLevelAccessVisibility` handler (the same gotcha as `gcs`, above). A project-relative key-file path is resolved to an absolute one at config load, so it does not break under a web server whose working directory is `public/`. |
| `area_guide_local` | `local` | root `storage/app/area-guide-private`, private **file and directory** visibility, gitignored. Isolated developer preview only — `MediaContext` refuses every local-driver disk for shared media outside `local` / `testing`. |

Selected by `area_guide_content.media_disk` (`AREA_GUIDE_MEDIA_DISK`, **code default `gcs`**, with `.env.example` setting `area_guide_shared`) and `media_directory` (`AREA_GUIDE_MEDIA_DIRECTORY`, default `area-guide`).

⚠️ **An operator precondition the guard does NOT check.** `MediaContext::disk()` enforces only that the disk exists, is private, and is not a local driver in production. It cannot tell whether the bucket is genuinely **shared**, because a media row stores only the disk *name* — every site resolves that name through its own config. So every participating writer site must point at the *same* bucket and prefix. When the whole platform already shares ONE bucket, leaving every `AREA_GUIDE_GCS_*` empty is the shortest correct setup: `area_guide_shared` inherits the general `GOOGLE_CLOUD_*` values, so there is one service account to rotate, and `media_directory` keeps shared uploads under their own key prefix. Prefer that alias over setting `AREA_GUIDE_MEDIA_DISK=gcs`: both resolve to the same bucket today, but only the alias survives one site later moving its own media to a per-site bucket. Get this wrong and site B's signed URLs 404 against its own bucket while B's delete quietly succeeds (the GCS adapter swallows a missing object), orphaning the file in A's bucket. See the [Area Guide map handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md) for the deployment checklist.

## GUIDELINES alignment
Overall this module is a **clean, textbook fit** for the guidelines. Highlights and the few things to be aware of:

**Conforms**
- **§7 Standard key model** — `Media extends SoftDeleteModel` + `HasUuid` + `RecordsBlame` ⇒ `id`, `uuid`, blame columns, timestamps, soft delete. ✔
- **§7 Shared / polymorphic data** — lives in `src/Common`, polymorphic via `mediable`, mirroring the canonical `Address`. ✔
- **§7 Migrations** — snake_case columns, no schema-level FKs (`nullableMorphs` + indexed `media_id`), `->index()` on `collection`/`media_id`, `->nullable()` on optional fields, `_by`/`_at` suffixes. ✔
- **§2 Repository pattern** — media row writes go through `MediaRepository` in the owning connection's transaction; input is filtered with `data_only()`, and `create()` returns a refreshed model from the nested `media` input. Durable outbox claims and worker retries are coordinated by the service/outbox/job on that same connection. Crucially, **remote storage I/O runs OUTSIDE every transaction**: a short transaction claims the task, the bucket call runs unlocked, a short transaction verifies the claim and writes. No `DB::transaction` spans a network call, and no row lock is held on the shared master for the length of a GCS timeout. ✔
- **Separation of concerns** — filesystem I/O in `MediaService`, media row writes in `MediaRepository`, cleanup intent lifecycle in `MediaCleanupOutbox` and its worker/scanner, schema/relationships/`isImage()` on the models.
- **§4 Models** — `$fillable` (never `$guarded`); relationship `mediable(): MorphTo` is camelCase with an explicit return type. ✔
- **§11 / §1** — full PHPDoc (`@param`/`@return`) on every class & method; PSR-12; English; `use` imports without leading backslash. ✔

**Minor notes (none are blockers)**
1. **No Facade for `MediaRepository`.** §2/§12 describe a `Facades` convention, but the project has **no `src/**/Facades` at all** and the sibling `WhatsappRepository` is likewise injected directly. So this *follows the actually-emulated pattern* (Primary Directive #1) rather than the older path table — consistent, but worth flagging if a Facade convention is ever reinstated.
2. **Blame columns in `$fillable`.** `created_by` / `updated_by` / `deleted_by` are listed in `$fillable` even though `RecordsBlame` auto-populates them and the repository's `data_only()` whitelist never passes them. Harmless but redundant — safe to drop for clarity.
3. **No `TYPES` / `STATUSES` constants (§3).** `Media` has no status/type enum; `collection` is intentionally free-form for a general-purpose store. Fine as-is — promote to constants only if collections become a fixed set.
4. **Store/delete ordering.** The legacy local `store()` writes the file before the DB row. `storeFromPath()` and all explicit shared-context stores protect that window with a durable cleanup intent, whose lease is now configurable (`media.writing_lease_minutes`, default 60) — and a writer whose intent is reclaimed *after* a successful put removes its own object, so an expired lease can no longer strand one. `delete()` likewise retains a retryable intent if object or row removal fails. These paths do not make external storage transactional; callers must still attach replacements before deleting a working file.
5. **No controller / route / Form Request.** Correct — `Media` is an internal programmatic service (like `Address`), not a user-facing CRUD resource, so §3/§6/§8 don't apply here. Validation of uploads is the **consuming** module's responsibility (see the note under *Usage*).
6. **Two known gaps, stated rather than hidden.** (a) `completeClaimedTask()` opens a transaction on the *context's* connection and, on the `MasterMedia` fallback path, calls `MediaRepository::forceDelete()`, which opens a nested transaction on a **different** (catalogue) connection — a write, not a cross-connection join, and the pre-restructure code did the same inside its larger transaction, but the inner delete cannot be rolled back by the outer one. (b) When `discardReclaimedUpload()` cannot remove the object it just wrote, that object **is** orphaned with no remaining intent — only a log line. Both are deliberate trade-offs today; a stronger answer to (b) would be to record a fresh store-compensation intent so the scanner retries it.

## Related files
**Backend — Model**
- [src/Common/Media.php](/src/Common/Media.php) — polymorphic stored-file record (uuid + blame + soft delete); `mediable()` morph; `isImage()`.
- [src/Common/AreaGuideMedia.php](/src/Common/AreaGuideMedia.php) and [AreaGuideMediaCleanup.php](/src/Common/AreaGuideMediaCleanup.php) — dedicated shared content media and cleanup tables.

**Backend — Service & Repository**
- [src/Common/Services/MediaService.php](/src/Common/Services/MediaService.php) — store bytes / uploads, `put()`, `directoryFor()`, `temporaryUrl()` / `url()` / `displayUrl()`, `delete()` (hard), extension inference.
- [src/Common/Repositories/MediaRepository.php](/src/Common/Repositories/MediaRepository.php) — transactional `create()` / `delete()` (soft) / `forceDelete()` (hard).
- [src/Common/Support/MediaContext.php](/src/Common/Support/MediaContext.php) — registered local/shared ownership, private shared disk guard and queue target identity.
- [src/Common/Services/MediaCleanupOutbox.php](/src/Common/Services/MediaCleanupOutbox.php), [CleanupMediaObject.php](/app/Jobs/Media/CleanupMediaObject.php) and [DispatchMediaCleanupTasks.php](/app/Console/Commands/DispatchMediaCleanupTasks.php) — scoped durable cleanup lifecycle.
- [src/Common/Services/ChunkedUploadService.php](/src/Common/Services/ChunkedUploadService.php) with [ChunkOutOfOrderException](/src/Common/Exceptions/ChunkOutOfOrderException.php) / [ChunkedUploadRejectedException](/src/Common/Exceptions/ChunkedUploadRejectedException.php) — piece-by-piece uploads past the 100 MB request limit, finished through `storeFromPath()`.

**Config**
- [config/media.php](/config/media.php) — disk, signed-URL TTL, base directory, writing/claim lease minutes.
- [config/filesystems.php](/config/filesystems.php) — the private `gcs` disk (spatie/laravel-google-cloud-storage), plus the `area_guide_shared` (gcs) and `area_guide_local` disks.
- [config/area_guide_content.php](/config/area_guide_content.php) — content connection, source, editing switch, `media_disk` and `media_directory`.

**Migrations**
- [database/migrations/2026_06_04_000001_create_media_table.php](/database/migrations/2026_06_04_000001_create_media_table.php) — the shared polymorphic `media` table.
- [database/migrations/2026_06_04_000002_add_media_id_to_whatsapp_attachments_table.php](/database/migrations/2026_06_04_000002_add_media_id_to_whatsapp_attachments_table.php) — links WhatsApp attachments to their stored file.
- [database/migrations/catalogue/2026_09_13_220000_create_area_guide_media.php](/database/migrations/catalogue/2026_09_13_220000_create_area_guide_media.php) — dedicated Area Guide media/outbox schema on the content connection.

**Tests**
- [tests/Unit/Services/AreaGuideMediaTest.php](/tests/Unit/Services/AreaGuideMediaTest.php) — isolated in-memory databases prove local/shared ownership, ID collision isolation, streaming, failure compensation, queue target identity and retry behavior without booting the application or reading its database credentials. Also covers the configurable writing lease (and its fallback when the setting is blank), the reclaimed-writer self-cleanup, storage I/O running at transaction level 0 on **both** connections, concurrent claim ownership, expired-claim takeover, and permanent failure on a database-identity mismatch.
- [tests/Feature/Common/CatalogMediaDeleteCleanupTest.php](/tests/Feature/Common/CatalogMediaDeleteCleanupTest.php) — a catalogue-schema media row is removed once its object's cleanup intent is durable, and is KEPT when no intent was recorded; covers both the delete path and `create()`'s rollback.
- [tests/Feature/Common/MediaCleanupOutboxTest.php](/tests/Feature/Common/MediaCleanupOutboxTest.php) · [CleanupMediaObjectTest.php](/tests/Feature/Common/CleanupMediaObjectTest.php) — the local-scope intent lifecycle and worker behaviour.
- [tests/Feature/Manage/Uploads/ChunkedUploadTest.php](/tests/Feature/Manage/Uploads/ChunkedUploadTest.php) — `ChunkedUploadService` through its controller: byte-for-byte reassembly, one store per upload, duplicate/skipped pieces, a failed store finished by a resend, owner scoping, and type sniffing on real file bytes.

**Reference usage**
- [Profile](/docs/modules_handbook/shared/profile/readMe.md) — avatar + IC upload (front/back); the canonical example of storing, displaying and replacing profile-scoped media.
- [app/Http/Controllers/Concerns/HandlesProfile.php](/app/Http/Controllers/Concerns/HandlesProfile.php) — `replaceProfileMedia()` (ensure profile → hard-delete old → `storeUpload()`), `clearProfileMedia()`, and `displayUrl()` in the page props.

**Also consumed by**
- Phone Call + Showroom F2F (recording audio) — every ingest (manual upload, Dowayai poll, yhy webhook) stores the audio via `MediaService::store()`/`storeUpload()`, owned by `media_id` on `call_recordings` / `f2f_recordings` (the WhatsApp-attachment `media_id` pattern, collections `call-audio` / `f2f-audio`). The transcription jobs read raw bytes via `MediaService::bytes()`; the pages play it via `MediaService::displayUrl()`; delete hard-removes via `MediaService::delete()`. See [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md) · [Showroom F2F](/docs/modules_handbook/manage/f2f/readMe.md).
- WhatsApp (inbound media) — [ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) calls `MediaService::store()`; [MessagePresenter.php](/src/Whatsapp/Support/MessagePresenter.php) renders via `MediaService::temporaryUrl()`.
- AI Video — the [AI Video](/docs/modules_handbook/shared/video/readMe.md) generator stores **input materials** (uploaded images / video / audio / text, brochure & PDF-extracted photos) and the **output MP4** as `Media` rows under collections `video_input` / `video_output`, via `MediaService::storeUpload()` / `store()`. The payload builder reads video/audio back as `MediaService::displayUrl()` signed URLs to hand to Seedance.
- Zoom recording archive — `App\Jobs\Zoom\ArchiveZoomRecording` re-hosts a Zoom cloud recording's video/audio bytes so playback survives Zoom-side deletion: `MediaService::storeFromPath(null, $tmp, [...])` (streamed from a temp file, collection `zoom-recording`) owned by `media_id` on `zoom_recordings` (the same `call_recordings`/`f2f_recordings` `media_id`-FK pattern — **never** the `mediable` morph). A `--force` re-archive is a **swap**: store-new → verify size → attach (`ZoomRecordingRepository::attachMedia()`) → only then `MediaService::delete()` the superseded `Media`, so a failed re-archive never destroys a working copy. Playback (`ZoomRecordingStreamController`) reads the bytes via `Storage::disk($media->disk)->readStream()` with hand-rolled byte-range handling (default) or a signed-URL ranged fetch (opt-in). See [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) → *Durable GCS archive*.
- Events · Slot Posters — a new collection, **`slot_poster`** — uploaded or AI-generated funnel-slot poster art, owned via the `mediable` morph on **the slot** (`EventSeries::media()`), not the `SlotPoster` row itself (`SlotPoster::media()` is a plain `belongsTo` FK lookup pointing at the same `Media` row). `MediaService::storeUpload()` stores a direct admin upload; `MediaService::store($slot, $bytes, ['collection' => SlotPoster::COLLECTION, 'mime' => 'image/png'])` stores an OpenAI-generated one from `App\Jobs\Event\GenerateSlotPosterJob`. Delete is a deliberate **split**: `MediaService::delete()` still hard-destroys the GCS object + `Media` row exactly as documented above, but the **owning `SlotPoster` row is only soft-deleted** (so its per-slot `#N` version label stays permanently reserved) — see [Events · Slot Posters](/docs/modules_handbook/manage/events/slot-posters/readMe.md) for why. The slot's *current final* poster is additionally served **publicly** through the existing `og-image` streaming route, widened from `og_banner`-only to also accept the `slot_poster` collection — but **only** when the media's owning poster is `is_final = true`, `READY`, and not soft-deleted (`App\Http\Controllers\Main\OgImageController`); every other poster stays private, viewed in the drawer via `MediaService::temporaryUrl()`.

**See also:** [Profile](/docs/modules_handbook/shared/profile/readMe.md) (reference consumer) · [WhatsApp module](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) · [AI Video](/docs/modules_handbook/shared/video/readMe.md) · [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) · [Events · Slot Posters](/docs/modules_handbook/manage/events/slot-posters/readMe.md).
