# 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) — 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. No DB writes live here.
  - `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.** Private GCS files are exposed only through `temporaryUrl()` (a signed URL valid for `media.signed_url_minutes`, default 15 min). `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()` removes the underlying file from the disk first, then calls `MediaRepository::forceDelete()` — 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.
- **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 — 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.

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()`.

## 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`) |

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`.

## 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** — every DB write goes through `MediaRepository` inside `DB::transaction`; input filtered with `data_only()` **before** the transaction; the transaction contains only the write; `create()` returns a refreshed model and takes a nested array keyed by `media`. ✔ (textbook)
- **Separation of concerns** — filesystem I/O in `MediaService`, DB writes in `MediaRepository`, schema/relationships/`isImage()` on the model. Mirrors the existing non-repository `FacebookAdsService` precedent. ✔
- **§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.** `store()` writes the file before the DB row, and `delete()` removes the file before hard-deleting the row. The failure window leaves an *orphaned file* (safe) rather than a row pointing at a missing file — acceptable, but worth a periodic GCS-orphan sweep if volume grows.
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*).

## Related files
**Backend — Model**
- [src/Common/Media.php](/src/Common/Media.php) — polymorphic stored-file record (uuid + blame + soft delete); `mediable()` morph; `isImage()`.

**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).

**Config**
- [config/media.php](/config/media.php) — disk, signed-URL TTL, base directory.
- [config/filesystems.php](/config/filesystems.php) — the private `gcs` disk (spatie/laravel-google-cloud-storage).

**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.

**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).
