# FirebaseNotificationQuery Decomposition Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Split `app/Http/Queries/FirebaseNotificationQuery.php` (1,261 lines, 29 public query methods) into nine per-campaign query classes under `app/Http/Queries/Firebase/`, without changing a single character of generated SQL.

**Architecture:** The consumer mapping is a perfect 1:1 partition — every one of the 28 live methods is called by exactly one of nine services, and no method is shared. So each new class pairs with the service that consumes it, and the consumers are repointed directly. **No facade is needed**, unlike the `UserProgramService` decomposition: nothing outside those nine services touches this class. Each new class takes the same single dependency, `ActiveDayService`.

**Tech Stack:** Laravel 13 / PHP 8.3, Pest 3, Mockery, PHPStan level 5 (larastan) + `phpstan-baseline.neon`, Laravel Pint.

**Spec:** Self-contained; the "Measured Facts" section below is the spec. No separate design document exists.

## Global Constraints

- **PHP 8.3**, Laravel 13. Every **new** file carries `declare(strict_types=1)` and a class-level PHPDoc block.
- **`declare(strict_types=1)` is NOT a free addition here.** Adding it to code moved out of a non-strict file changes behaviour: weak-mode coercion stops, and a resulting `TypeError` is an `\Error`, not an `\Exception`, so a surrounding `catch (\Exception)` will not catch it. Before adding the declaration to a new class, walk every call in the moved methods for coercion the declaration would reject and preserve the old behaviour with an **explicit cast**. Casts reproduce weak mode; guards and `??` fallbacks do not — do not add those. This exact trap cost two fix rounds during the `UserProgramService` work and broke `ChapterProgressReader` in an earlier decomposition.
- **Constructor style:** promoted `private readonly`, matching `app/Services/User/Program/SubscriptionDetailService.php`.
- **Namespace:** `App\Http\Queries\Firebase\` (directory `app/Http/Queries/Firebase/`).
- **No interfaces.** The five services extracted from `UserProgramService` each got one because they were bound in the container and injected behind a contract. These are concrete query classes injected directly by their single consumer, matching the existing `app/Http/Queries/Marketing/` precedent — which *does* have interfaces, so note the divergence: those were extracted behind a facade that needed swappable bindings. Here there is no facade and one consumer each, so a concrete class is correct. Do not add interfaces or `AppServiceProvider` bindings; Laravel autowires these.
- **Method signatures are carried across verbatim during the moves (Tasks 3–11), then typed in one pass (Task 12).** Several are untyped (`$programId, $dateRanges`), several are fully typed (`: Collection`). Do not retype them *while moving* — a move and a signature change in the same commit make a golden-master failure ambiguous. Task 12 owns the typing and re-proves the fixture afterwards.
- **Argument type coercion is governed by the CALLING file, not the declaring one.** All nine consumers are non-strict, so after Task 12 a string `"1"` passed to `int $programId` still coerces exactly as it does today. A new parameter type only rejects genuinely non-coercible values — `null`, arrays, objects. That is why Task 12 requires checking each argument's *provenance for nullability* rather than just its apparent type.
- **Gates, run from the project root:**
  - `XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest` (serial; the default 128M CLI limit exhausts partway)
  - `XDEBUG_MODE=off vendor/bin/pest --parallel`
  - `XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress` — must end `[OK] No errors`
  - `XDEBUG_MODE=off vendor/bin/pint <changed files>` — **fix mode on new files only.** Never run fix mode on a legacy file you are only lightly editing: it reflows the whole file and buries the semantic change. Use `--test` to check those.
- **PHPStan baseline:** re-home entries whose code moved (change `path:` only); delete entries that stop firing (the config leaves `reportUnmatchedIgnoredErrors` at its default `true`, so a stale ignore is a hard error); prefer widening an inaccurate callee `@param` over adding a new suppression. **Never run `--generate-baseline`.**
- **Baseline for "green":** 2,822 passing / 10 skipped / 0 failed as of 2026-08-26.
- **Three pre-existing timing-flaky tests** can fail spuriously under load: `tests/Unit/Helpers/CommonHelperTest.php:233`, `tests/Unit/Services/MetaCapiServiceTest.php:155`, `tests/Unit/Services/User/UserUtilityServiceTest.php:170`. Each asserts two IDs generated across a `usleep(1000)` differ. If one of those is the only failure, re-run before investigating.
- **The user stages and commits their own work** unless they say otherwise for a specific branch. Where a task says "Commit", prepare the change and report it as ready.

---

## Measured Facts

Measured against the working tree on 2026-08-26.

### This is not a god class in the usual sense

| | `UserProgramService` (before its split) | `FirebaseNotificationQuery` |
|---|---|---|
| Lines | 1,410 | **1,261** |
| Constructor dependencies | 32 | **1** (`ActiveDayService`) |
| Private helpers | 13 | **0** |
| Methods over 100 lines | 3 | **0** (largest is 79) |
| Internal call graph | dense | **empty** |

29 independent public query builders in one file. There is no coupling to unpick — the split is a partition, not a disentangling.

**A hypothesis that measurement disproved:** the first method read (`getD1M1CompleteD1IncompleteUsers`) suggests a shared skeleton — numeric guard, dynamic `tbl_User{$programId}Chapters` table names, a common "active, onboarded, has-device" predicate. It is not representative. Across all 29 methods: the `is_numeric` guard appears in 3 (10%), `getDefaultActiveDay` in 7 (24%), `DB::table('tbl_Users as a')` in 14 (48%), and the full `select('a.iUserID', 'a.vDeviceType', 'g.vDeviceID')` in **1**. **Do not try to extract a shared base query builder** — there isn't one.

### What drives this code

**No HTTP routes.** Nothing in `routes/` reaches this class. It is cron-only, through one entry point:

```
php artisan firebase:notification {type}
```

`app/Console/Commands/RunFirebaseNotification.php` resolves `{type}` against `FirebaseCampaignRegistry::HANDLERS` — **36 campaign keys** — dispatching to the nine consumers below.

### The partition — zero shared methods

| Consumer | Methods | ~Lines | New class |
|---|---|---|---|
| `ReEngagementCampaignService` | `getD1M1CompleteD1IncompleteUsers`, `getD1CompleteUsers`, `getFacebookUsers`, `getLongLastUser` | 168 | `ReEngagementNotificationQuery` |
| `TrackedBehaviorCampaignService` | `getNeverTrackedUsers`, `getTrackedOnceUsers`, `getTrackedOftenStreakUsers`, `getTrackedOftenReminderUsers`, `getTrackedOftenReportUsers` | 156 | `TrackedBehaviorNotificationQuery` |
| `ModuleCampaignService` | `getModuleIncompleteUsers`, `getModuleIncompleteWeeklyUsers` | 141 | `ModuleNotificationQuery` |
| `FinalScriptCampaignService` | `getFinalScriptUsers`, `getPostQuitFinalScriptUsers`, `getPostQuitReasonJournalUsers`, `getWatchFinalScript21DaysUsers`, `getPostQuitMotivationUsers`, `getJournalIncompleteUsers` | 130 | `FinalScriptNotificationQuery` |
| `MilestoneCampaignService` | `getDay5ReviewDeceptionUsers`, `getVolunteerUsers`, `getRateUsUsers`, `getComeBackUsers`, `getPostQuitCommunityUsers` | 117 | `MilestoneNotificationQuery` |
| `DayUnlockCampaignService` | `getDayUnlockUsers`, `getHelpSaveOtherUsers` | 79 | `DayUnlockNotificationQuery` |
| `OnboardingCampaignService` | `getOnboardIncompleteUsers`, `getOnboardIncompleteWeeklyUsers` | 68 | `OnboardingNotificationQuery` |
| `UnlockUserLTPProcessor` | `getDayUnlockUsersLTP` | 65 | `LtpUnlockNotificationQuery` |
| `OnlyDownloadCampaignService` | `getSendOnlyDownloadUsers` | 22 | `OnlyDownloadNotificationQuery` |

**`getD1M1IncompleteAllUsers` (41 lines, at :530) has no consumer** — zero references across `app/`, `tests/` and `routes/`. Task 2 deletes it.

### There is no test protection at all

All nine consumer tests do `Mockery::mock(FirebaseNotificationQuery::class)` and bind that in. They test the *campaign services*; they would stay green if every method in this class were deleted. **1,261 lines of SQL currently have nothing guarding them.** Task 1 fixes that before any code moves.

### Typing gaps — in scope, Task 12

**Return types: 9 of 29 methods declare one; 20 do not.** Every one of the 29 was classified by its terminal database operation (indirection-proof — it looks for the executing call, not the returned variable, because six methods do `$query = …->get(); return $query;` and a naive reader mistakes those for Builder returns):

> **All 29 methods terminate in `->get()`. All return `Illuminate\Support\Collection`. Zero mismatches** against the nine that already declare `: Collection`.

So the return type is uniform and safe to add to all 20.

**Parameter types: 18 methods carry at least one untyped parameter.** The values they receive:

| Parameter | Appears in | Correct type | Evidence |
|---|---|---|---|
| `$programId` / `$iProgramID` | 7 methods | `int` | Passed as `$program->iProgramID` or `$pid`; `tbl_Programs.iProgramID` is `int unsigned`, NOT NULL, PK |
| `$dateRanges` | 9 methods | `array` | Always passed as an array variable built by the caller |
| `$activeDayId` | `getDay5ReviewDeceptionUsers` | `int` | Passed as `$dayId` — **verify provenance, see Task 12** |
| `$limit` | `getComeBackUsers` | `int` | Passed as `$limit` — **verify provenance** |

No `declare(strict_types=1)` on the current file; each new file gets it (see Global Constraints for the audit that requires).

---

## File Structure

**Created:**

| File | Responsibility |
|---|---|
| `tests/Unit/Http/Queries/Firebase/FirebaseNotificationQuerySqlSnapshotTest.php` | Golden master over all 28 live methods |
| `tests/Unit/Http/Queries/Firebase/__sql__/firebasenotification.php` | Committed SQL fixture |
| `app/Http/Queries/Firebase/ReEngagementNotificationQuery.php` | 4 re-engagement audience queries |
| `app/Http/Queries/Firebase/TrackedBehaviorNotificationQuery.php` | 5 tracking-behaviour audience queries |
| `app/Http/Queries/Firebase/ModuleNotificationQuery.php` | 2 module-incomplete audience queries |
| `app/Http/Queries/Firebase/FinalScriptNotificationQuery.php` | 6 post-quit / final-script audience queries |
| `app/Http/Queries/Firebase/MilestoneNotificationQuery.php` | 5 milestone audience queries |
| `app/Http/Queries/Firebase/DayUnlockNotificationQuery.php` | 2 day-unlock audience queries |
| `app/Http/Queries/Firebase/OnboardingNotificationQuery.php` | 2 onboarding-incomplete audience queries |
| `app/Http/Queries/Firebase/LtpUnlockNotificationQuery.php` | 1 LTP day-unlock audience query |
| `app/Http/Queries/Firebase/OnlyDownloadNotificationQuery.php` | 1 download-only audience query |

**Modified:** the nine consumer services (constructor type-hint + call sites), `phpstan-baseline.neon`.

**Deleted:** `app/Http/Queries/FirebaseNotificationQuery.php` (Task 13, once empty).

---

### Task 1: SQL golden master for all 28 live methods

Nothing protects this class today. This is the whole safety net — every later task's proof that SQL did not change.

**Files:**
- Create: `tests/Unit/Http/Queries/Firebase/FirebaseNotificationQuerySqlSnapshotTest.php`
- Create: `tests/Unit/Http/Queries/Firebase/__sql__/firebasenotification.php`
- Read for reference: `tests/Unit/Http/Queries/Marketing/MarketingV2QuerySqlSnapshotTest.php` and its `__sql__/marketingv2.php`

**Interfaces:**
- Consumes: nothing
- Produces: a test that every later task runs unchanged.

- [ ] **Step 1: Read the working precedent**

Run:
```bash
sed -n '1,90p' tests/Unit/Http/Queries/Marketing/MarketingV2QuerySqlSnapshotTest.php
head -20 tests/Unit/Http/Queries/Marketing/__sql__/marketingv2.php
```
That test captures SQL under `DB::pretend()` — no database is touched, no rows are read, only the SQL text and bindings each method *would* send. Copy its `captureSql()` helper verbatim; it is already correct.

- [ ] **Step 2: Pin the two sources of non-determinism**

Two things make the captured SQL vary between runs, and both must be frozen in `beforeEach` or the fixture is worthless:

1. **Time.** `Carbon::setTestNow('2026-08-26 00:00:00')`.
2. **The active-day config.** Seven methods call `$this->activeDayService->getDefaultActiveDay($programId)`, which is `getActiveDayEnv()` in `app/Helpers/CommonHelper.php:369` — it reads `config('constants.active_days')` and falls back to `safe_env("ACTIVE{$programId}DAYID", 0)`. That value lands in query **bindings**, so pin it:

```php
config(['constants.active_days' => [1 => 7, 2 => 7, 3 => 7]]);
```

It is a config read, not a database query, so `DB::pretend()` does not interfere with it.

- [ ] **Step 3: Write the case table**

One entry per method, with arguments that reach each distinct branch. Signatures vary — some untyped, some fully typed. Use these exact call shapes:

```php
$firebaseSqlCases = [
    'getD1M1CompleteD1IncompleteUsers' => ['default' => [1, $ranges]],
    'getD1CompleteUsers'               => ['default' => [1, $ranges]],
    'getFacebookUsers'                 => ['default' => [1, $ranges]],
    'getLongLastUser'                  => ['default' => [1, $ranges]],
    'getDayUnlockUsers'                => ['default' => [1]],
    'getDayUnlockUsersLTP'             => ['default' => []],
    'getHelpSaveOtherUsers'            => ['default' => [$ranges]],
    'getFinalScriptUsers'              => ['default' => []],
    'getPostQuitFinalScriptUsers'      => ['default' => [$ranges]],
    'getPostQuitReasonJournalUsers'    => ['default' => [$ranges]],
    'getDay5ReviewDeceptionUsers'      => ['default' => [1, 7, $ranges]],
    'getVolunteerUsers'                => ['default' => [$ranges]],
    'getRateUsUsers'                   => ['default' => [$ranges]],
    'getComeBackUsers'                 => ['default' => [1, 100]],
    'getSendOnlyDownloadUsers'         => ['default' => []],
    'getWatchFinalScript21DaysUsers'   => ['default' => [$ranges]],
    'getPostQuitMotivationUsers'       => ['default' => [$ranges]],
    'getOnboardIncompleteUsers'        => [
        'hours'        => [24, 48, 'hour', false],
        'already_quit' => [24, 48, 'hour', true],
    ],
    'getOnboardIncompleteWeeklyUsers'  => ['default' => [1]],
    'getModuleIncompleteUsers'         => [
        'plain'         => [1, 24, 48, 'hour', false, false, false, false],
        'cost'          => [1, 24, 48, 'hour', true, false, false, false],
        'extended_url'  => [1, 24, 48, 'hour', false, true, false, false],
        'already_quit'  => [1, 24, 48, 'hour', false, false, true, false],
        'unsubscribed'  => [1, 24, 48, 'hour', false, false, false, true],
    ],
    'getModuleIncompleteWeeklyUsers'   => [
        'plain' => [1, 1, false],
        'cost'  => [1, 1, true],
    ],
    'getNeverTrackedUsers'             => [
        'plain'        => ['2026-01-01', '2026-01-31', false],
        'extended_url' => ['2026-01-01', '2026-01-31', true],
    ],
    'getTrackedOnceUsers'              => ['default' => [$ranges]],
    'getTrackedOftenStreakUsers'       => ['default' => ['2026-01-01', '2026-01-31']],
    'getTrackedOftenReminderUsers'     => ['default' => [$ranges]],
    'getTrackedOftenReportUsers'       => ['default' => []],
    'getPostQuitCommunityUsers'        => ['default' => ['2026-01-01', '2026-01-31']],
    'getJournalIncompleteUsers'        => ['default' => [1, 10, 20]],
];
```

`$ranges` is the `$dateRanges` array these methods destructure. Different methods read different keys, and a missing one is an undefined-index warning that `HandleExceptions` turns into a thrown `ErrorException` — so the array must carry **every** key any method reads. These are all ten, derived from the current file:

```php
$ranges = [
    'range1Start' => '2026-01-01 00:00:00',
    'range1End'   => '2026-01-07 23:59:59',
    'range2Start' => '2026-01-08 00:00:00',
    'range2End'   => '2026-01-14 23:59:59',
    'rangeStart'  => '2026-01-01 00:00:00',
    'rangeEnd'    => '2026-01-31 23:59:59',
    'dStartTime'  => '2026-01-01 00:00:00',
    'dNextTime'   => '2026-01-02 00:00:00',
    'toDate'      => '2026-01-31',
    'daysAgo'     => 7,
];
```

Re-derive them before you start, in case the file has moved on:

```bash
grep -o "\$dateRanges\['[a-zA-Z0-9]*'\]" app/Http/Queries/FirebaseNotificationQuery.php | sort -u
```

- [ ] **Step 4: Write the test body**

Mirror the precedent exactly — a `dataset()` over `"{$method}.{$label}"` keys and one `it()` comparing against the fixture:

```php
it('matches the committed SQL golden master', function (string $method, string $label, array $args) {
    $key = "{$method}.{$label}";

    $captured = captureSql(fn () => app(FirebaseNotificationQuery::class)->{$method}(...$args));

    $fixture = require __DIR__.'/__sql__/firebasenotification.php';

    expect($fixture)->toHaveKey($key)
        ->and($captured)->not->toBeEmpty()
        ->and($captured)->toEqual($fixture[$key]);
})->with('firebase-sql-cases');
```

The `->not->toBeEmpty()` assertion is load-bearing: without it, a method that silently stops issuing any query would still "match" an empty fixture entry.

- [ ] **Step 5: Generate the fixture**

Write a throwaway Pest test in the same directory that runs every case through `captureSql()` and `var_export`s the result to `__sql__/firebasenotification.php`. Run it once, inspect the output, then **delete the generator**. The fixture is captured from the current, pre-refactor code — that is what makes it a golden master.

Prepend the same header the Marketing fixture carries, stating it is generated and must not be hand-edited.

- [ ] **Step 6: Run the snapshot test — it must pass**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Unit/Http/Queries/Firebase/FirebaseNotificationQuerySqlSnapshotTest.php
```
Expected: all cases PASS. A golden master is written against working code, so it must be green immediately. If a case fails, the *fixture capture* is wrong — fix that, not the production code.

- [ ] **Step 7: Prove it bites**

Change one `where` clause in `getD1CompleteUsers` (e.g. `'a.bActive', '=', 1` → `0`), re-run, confirm that case **FAILS**, then restore and confirm green. Put the failing output in your report. A golden master that cannot fail is worthless, and this step is the only thing that proves yours can.

- [ ] **Step 8: Run the gates and prepare the commit**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pest --parallel
XDEBUG_MODE=off vendor/bin/pint tests/Unit/Http/Queries/Firebase
```
Expected: 2,822 + your new cases / 10 skipped / 0 failed.

Suggested message: `test(firebase): SQL golden master for FirebaseNotificationQuery`

---

### Task 2: Delete the dead `getD1M1IncompleteAllUsers`

**Files:**
- Modify: `app/Http/Queries/FirebaseNotificationQuery.php` (remove the method at ~:530, 41 lines, plus its docblock)

**Interfaces:**
- Consumes: Task 1's golden master
- Produces: nothing

- [ ] **Step 1: Re-prove it is dead**

Run:
```bash
grep -rn "getD1M1IncompleteAllUsers" app tests routes | grep -v "app/Http/Queries/FirebaseNotificationQuery.php"
```
Expected: **no output.** If anything comes back, **stop and report BLOCKED** — the method is live and this task is void.

- [ ] **Step 2: Delete the method and its docblock**

Locate it by name (`grep -n 'function getD1M1IncompleteAllUsers'`) — do not trust the line number above, it drifts.

- [ ] **Step 3: Confirm the golden master is unaffected**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Unit/Http/Queries/Firebase/FirebaseNotificationQuerySqlSnapshotTest.php
```
Expected: PASS, unchanged. The method has no fixture entry (Task 1 covers only the 28 live methods), so deleting it must not move anything.

- [ ] **Step 4: Gates**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pest --parallel
XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress
XDEBUG_MODE=off vendor/bin/pint --test app/Http/Queries/FirebaseNotificationQuery.php
```
If PHPStan reports `ignore.unmatched` for baseline entries anchored to the deleted method, **delete those entries** — do not regenerate the baseline.

Suggested message: `refactor(firebase): remove unused getD1M1IncompleteAllUsers`

---

### Task 3: Extract `OnlyDownloadNotificationQuery` — the template task

Smallest slice (1 method, 22 lines). Do this one first: it establishes the exact shape every later extraction copies, at the lowest possible risk.

**Files:**
- Create: `app/Http/Queries/Firebase/OnlyDownloadNotificationQuery.php`
- Modify: `app/Http/Queries/FirebaseNotificationQuery.php` (remove the moved method), `app/Services/Firebase/Campaigns/OnlyDownloadCampaignService.php`
- Test: `tests/Unit/Services/Firebase/Campaigns/OnlyDownloadCampaignServiceTest.php` (update the mocked class only)

**Interfaces:**
- Consumes: Task 1's golden master
- Produces: `App\Http\Queries\Firebase\OnlyDownloadNotificationQuery::getSendOnlyDownloadUsers()` — signature verbatim, no parameters.

- [ ] **Step 1: Create the new class**

```php
<?php

declare(strict_types=1);

namespace App\Http\Queries\Firebase;

use App\Services\ActiveDayService;
use Illuminate\Support\Facades\DB;

/**
 * Audience query for the download-only Firebase campaign.
 *
 * Extracted verbatim from the pre-split App\Http\Queries\FirebaseNotificationQuery.
 * Consumed solely by App\Services\Firebase\Campaigns\OnlyDownloadCampaignService,
 * which the `firebase:notification OnlyDownloadMorning` cron reaches via
 * FirebaseCampaignRegistry.
 */
final class OnlyDownloadNotificationQuery
{
    public function __construct(
        private readonly ActiveDayService $activeDayService,
    ) {}
}
```

Move `getSendOnlyDownloadUsers()` into it **verbatim**, including its docblock. If the moved method does not reference `$this->activeDayService`, **drop the constructor entirely** rather than carrying a dead dependency — check with `grep -n 'activeDayService' ` on the moved body first.

- [ ] **Step 2: Do the strict_types audit before trusting the declaration**

The new file has `declare(strict_types=1)`; the file it came from does not. Walk every call in the moved method for weak-mode coercion the declaration would now reject — a numeric string into an `int` parameter, a value into an internal function like `strtolower()`/`date()`. Preserve the old behaviour with an **explicit cast** at the call site.

If you find nothing to cast, say so explicitly in your report and list what you checked. That is a legitimate outcome for a method with no parameters.

- [ ] **Step 3: Repoint the consumer**

In `OnlyDownloadCampaignService`, change the constructor type-hint from `FirebaseNotificationQuery $users` to `OnlyDownloadNotificationQuery $users` and fix the `use` statement. The property name stays `$users`, so **no call site changes**. Laravel autowires the concrete class; no binding is needed.

- [ ] **Step 4: Update the consumer's test**

`OnlyDownloadCampaignServiceTest.php` mocks `FirebaseNotificationQuery::class`. Change that one reference to `OnlyDownloadNotificationQuery::class` and its import. Change nothing else — the expectations are unaffected.

- [ ] **Step 5: Point the golden master at the new class**

The snapshot test resolves `app(FirebaseNotificationQuery::class)`. For the moved method it must now resolve the new class. Add a per-method class map to the test:

```php
$firebaseSqlOwners = [
    'getSendOnlyDownloadUsers' => \App\Http\Queries\Firebase\OnlyDownloadNotificationQuery::class,
];

$owner = $firebaseSqlOwners[$method] ?? FirebaseNotificationQuery::class;
$captured = captureSql(fn () => app($owner)->{$method}(...$args));
```

Each later task adds its methods to this map. **The fixture itself must never change** — that is the proof the SQL is identical.

- [ ] **Step 6: Run the golden master — the fixture must still match**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Unit/Http/Queries/Firebase/FirebaseNotificationQuerySqlSnapshotTest.php
```
Expected: PASS with `__sql__/firebasenotification.php` **unmodified**. Confirm with `git diff --stat tests/Unit/Http/Queries/Firebase/__sql__/` — it must be empty. **If the fixture needs editing to pass, the move was not verbatim: revert and redo it.**

- [ ] **Step 7: Gates**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Unit/Services/Firebase/Campaigns/OnlyDownloadCampaignServiceTest.php
XDEBUG_MODE=off vendor/bin/pest --parallel
XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress
XDEBUG_MODE=off vendor/bin/pint app/Http/Queries/Firebase
```

Suggested message: `refactor(firebase): extract OnlyDownloadNotificationQuery`

---

### Tasks 4–11: Extract the remaining eight query classes

**Each of these is the same shape as Task 3.** Follow Task 3's seven steps exactly, substituting the class name, methods and consumer from the table below. Do them one at a time, in this order (smallest and most isolated first):

| Task | New class | Methods to move | Consumer to repoint | Consumer test to update |
|---|---|---|---|---|
| 4 | `LtpUnlockNotificationQuery` | `getDayUnlockUsersLTP` | `Processors/UnlockUserLTPProcessor` | `Processors/UnlockUserLTPProcessorTest` |
| 5 | `OnboardingNotificationQuery` | `getOnboardIncompleteUsers`, `getOnboardIncompleteWeeklyUsers` | `Campaigns/OnboardingCampaignService` | `Campaigns/OnboardingCampaignServiceTest` |
| 6 | `DayUnlockNotificationQuery` | `getDayUnlockUsers`, `getHelpSaveOtherUsers` | `Campaigns/DayUnlockCampaignService` | `Campaigns/DayUnlockCampaignServiceTest` |
| 7 | `MilestoneNotificationQuery` | `getDay5ReviewDeceptionUsers`, `getVolunteerUsers`, `getRateUsUsers`, `getComeBackUsers`, `getPostQuitCommunityUsers` | `Campaigns/MilestoneCampaignService` | `Campaigns/MilestoneCampaignServiceTest` |
| 8 | `FinalScriptNotificationQuery` | `getFinalScriptUsers`, `getPostQuitFinalScriptUsers`, `getPostQuitReasonJournalUsers`, `getWatchFinalScript21DaysUsers`, `getPostQuitMotivationUsers`, `getJournalIncompleteUsers` | `Campaigns/FinalScriptCampaignService` | `Campaigns/FinalScriptCampaignServiceTest` |
| 9 | `ModuleNotificationQuery` | `getModuleIncompleteUsers`, `getModuleIncompleteWeeklyUsers` | `Campaigns/ModuleCampaignService` | `Campaigns/ModuleCampaignServiceTest` |
| 10 | `TrackedBehaviorNotificationQuery` | `getNeverTrackedUsers`, `getTrackedOnceUsers`, `getTrackedOftenStreakUsers`, `getTrackedOftenReminderUsers`, `getTrackedOftenReportUsers` | `Campaigns/TrackedBehaviorCampaignService` | `Campaigns/TrackedBehaviorCampaignServiceTest` |
| 11 | `ReEngagementNotificationQuery` | `getD1M1CompleteD1IncompleteUsers`, `getD1CompleteUsers`, `getFacebookUsers`, `getLongLastUser` | `Campaigns/ReEngagementCampaignService` | `Campaigns/ReEngagementCampaignServiceTest` |

Per task, in order:

- [ ] **Step 1:** Create the class (`declare(strict_types=1)`, class PHPDoc naming the consumer and the cron path, promoted `private readonly ActiveDayService` **only if a moved method uses it** — check with grep).
- [ ] **Step 2:** Move every listed method **verbatim**, docblocks included. Locate them by name; line numbers drift with every task.
- [ ] **Step 3:** Run the strict_types audit over the moved bodies; add explicit casts where weak mode used to coerce; report what you checked even when the answer is "nothing needed".
- [ ] **Step 4:** Repoint the consumer's constructor type-hint and `use` statement. Keep the property name so call sites are untouched.
- [ ] **Step 5:** Update the consumer test's mocked class and import — nothing else.
- [ ] **Step 6:** Add each moved method to `$firebaseSqlOwners` in the snapshot test.
- [ ] **Step 7:** Run the golden master. It must pass with the fixture **byte-identical** (`git diff --stat` on `__sql__/` empty). If not, revert and redo the move.
- [ ] **Step 8:** Run the full gates, re-home or delete any orphaned PHPStan baseline entries, and prepare the commit: `refactor(firebase): extract <ClassName>`.

**Task 9 carries one extra hazard.** `getModuleIncompleteUsers` has eight parameters including four booleans, and the golden master covers five of its branches. After moving it, confirm all five `getModuleIncompleteUsers.*` fixture cases still pass — not just the first.

---

### Task 12: Add return and parameter types across the nine new classes

Done as one pass, after all nine moves, so that every extraction commit stays a pure verbatim move and the golden master's proof is unambiguous.

**Files:**
- Modify: all nine files in `app/Http/Queries/Firebase/`
- Modify: `phpstan-baseline.neon` (entries that stop firing once types are declared)

**Interfaces:**
- Consumes: Tasks 3–11
- Produces: fully typed signatures on all 28 methods; no name or arity change anywhere.

- [ ] **Step 1: Add the return type to the 20 methods missing it**

Every method terminates in `->get()` and returns `Illuminate\Support\Collection`. Add `: Collection` to each of the 20 that lacks it, and add `use Illuminate\Support\Collection;` where the file does not already import it.

Do not change the nine that already declare it. Do not change the method bodies.

Verify no method was missed:
```bash
grep -c 'public function get' app/Http/Queries/Firebase/*.php
grep -c 'public function get\w*([^)]*)\s*:\s*Collection' app/Http/Queries/Firebase/*.php
```
Expected: the two counts match, file by file.

- [ ] **Step 2: Check each untyped argument's provenance for nullability — before typing it**

This is the step that carries the risk. Argument coercion is governed by the **calling** file, and all nine consumers are non-strict, so `"1"` → `int 1` keeps working. What a new type *does* reject is `null`.

For each untyped parameter, trace what the caller actually passes and confirm it cannot be null:

| Parameter | Confirm |
|---|---|
| `$programId` / `$iProgramID` | Caller passes `$program->iProgramID` or `$pid`. `tbl_Programs.iProgramID` is `int unsigned` NOT NULL — safe. |
| `$dateRanges` | Caller always builds an array literal — safe. |
| `$activeDayId` in `getDay5ReviewDeceptionUsers` | Passed as `$dayId` in `MilestoneCampaignService`. **Trace where `$dayId` is assigned.** If it can be null, type it `?int` and leave the body alone — do not add a guard. |
| `$limit` in `getComeBackUsers` | Passed as `$limit` in `MilestoneCampaignService`. **Trace its assignment.** Same rule. |

A nullable-but-typed-`int` parameter is exactly the bug shape that broke `CouponCodeGenerator::generate()` in production this week — a nullable column reaching a non-nullable `string`. Do not repeat it. When in doubt, `?int` is correct and costs nothing.

Record what you traced for each of the four in your report, including the two you confirmed safe.

- [ ] **Step 3: Add the parameter types**

Apply the types from Step 2. The full set of changes:

```
int $programId, array $dateRanges     — getD1M1CompleteD1IncompleteUsers, getD1CompleteUsers,
                                        getFacebookUsers, getLongLastUser
int $programId = 1                    — getDayUnlockUsers  (keep the existing default)
array $dateRanges                     — getHelpSaveOtherUsers, getPostQuitFinalScriptUsers,
                                        getPostQuitReasonJournalUsers, getVolunteerUsers,
                                        getRateUsUsers, getWatchFinalScript21DaysUsers
int $programId, <int|?int> $activeDayId, array $dateRanges
                                      — getDay5ReviewDeceptionUsers   (per Step 2)
int $programId, <int|?int> $limit     — getComeBackUsers              (per Step 2)
int $iProgramID                       — getJournalIncompleteUsers     (first param only;
                                        $minMinutes/$maxMinutes are already int)
```

Methods already fully typed — leave untouched: `getPostQuitMotivationUsers`, `getOnboardIncompleteUsers`, `getOnboardIncompleteWeeklyUsers`, `getModuleIncompleteUsers`, `getModuleIncompleteWeeklyUsers`, `getNeverTrackedUsers`, `getTrackedOnceUsers`, `getTrackedOftenStreakUsers`, `getTrackedOftenReminderUsers`, `getPostQuitCommunityUsers`.

Parameterless — nothing to do: `getDayUnlockUsersLTP`, `getFinalScriptUsers`, `getSendOnlyDownloadUsers`, `getTrackedOftenReportUsers`.

- [ ] **Step 4: Run the golden master — the fixture must be byte-identical**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Unit/Http/Queries/Firebase/FirebaseNotificationQuerySqlSnapshotTest.php
git diff --stat tests/Unit/Http/Queries/Firebase/__sql__/
```
Expected: all cases PASS and the second command prints **nothing**. Types do not change generated SQL; if the fixture moved, something else changed and you must find out what before continuing.

- [ ] **Step 5: Run the consumer tests**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Unit/Services/Firebase
```
Expected: green. These call the methods through mocks, so a signature that no longer accepts what a consumer passes shows up here.

- [ ] **Step 6: Gates**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pest --parallel
XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress
XDEBUG_MODE=off vendor/bin/pint app/Http/Queries/Firebase
```

Declaring types usually **removes** PHPStan findings. Any baseline entry that stops firing is now a hard error (`reportUnmatchedIgnoredErrors` defaults to `true`) — **delete those entries**. Do not run `--generate-baseline`.

- [ ] **Step 7: Prepare the commit**

Suggested message: `refactor(firebase): add return and parameter types to the extracted query classes`

---

### Task 13: Verify the parent class is empty, then delete it

**Files:**
- Delete: `app/Http/Queries/FirebaseNotificationQuery.php`
- Modify: `phpstan-baseline.neon`

**Interfaces:**
- Consumes: Tasks 3–12
- Produces: nothing

- [ ] **Step 1: Confirm nothing is left behind**

Run:
```bash
grep -cE 'public function get\w+\(' app/Http/Queries/FirebaseNotificationQuery.php
```
Expected: **0**. If any method remains, it was missed by the partition — **stop and report BLOCKED** with its name rather than deleting it.

- [ ] **Step 2: Confirm nothing still references the class**

Run:
```bash
grep -rn "FirebaseNotificationQuery" app tests routes | grep -v "SqlSnapshotTest"
```
Expected: no output. The snapshot test still imports it only if its `?? FirebaseNotificationQuery::class` fallback survives — remove that fallback now that every method has an owner, then re-check.

- [ ] **Step 3: Delete the file and clean the baseline**

Delete `app/Http/Queries/FirebaseNotificationQuery.php`. Remove every `phpstan-baseline.neon` entry whose `path:` still points at it.

- [ ] **Step 4: Full verification**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pest --parallel
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest
XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress
XDEBUG_MODE=off vendor/bin/pint --test $(git diff --name-only HEAD -- '*.php'; git ls-files --others --exclude-standard -- '*.php')
```
Expected: both suite runs green; `[OK] No errors`; pint passes.

- [ ] **Step 5: Confirm the cron entry point is untouched**

Run:
```bash
git diff --name-only -- app/Console/Commands/RunFirebaseNotification.php app/Services/Firebase/FirebaseCampaignRegistry.php routes/
```
Expected: **empty output.** Neither the command nor the registry nor any route should have changed — that is the proof all 36 campaign keys still dispatch exactly as before.

- [ ] **Step 6: Record the outcome**

```bash
wc -l app/Http/Queries/Firebase/*.php
```
Expected: nine files, none over ~170 lines, replacing one of 1,261.

Suggested message: `refactor(firebase): remove the emptied FirebaseNotificationQuery`

---

## Self-Review

**Spec coverage.** All 28 live methods are assigned to exactly one task via the partition table (Tasks 3–11), matching the measured consumer mapping. The dead method is Task 2. The absent safety net is Task 1. Return and parameter types are Task 12 — every one of the 29 methods appears there in exactly one of four buckets (20 needing a return type; 18 with untyped parameters; 10 already fully typed and explicitly left alone; 4 parameterless). Parent-class removal and cron-path verification are Task 13. The two facts that shape the plan — no HTTP routes, and all nine consumer tests mocking the class away — are stated in Measured Facts and drive Tasks 1 and 13 Step 5 respectively.

**Placeholder scan.** Tasks 4–11 are deliberately expressed as one parameterised table rather than eight near-identical task bodies, because they *are* the same task with different names, and repeating 300 lines eight times would invite copy errors rather than prevent them. Task 3 carries the full worked template they refer to. Step 3 of Task 1 requires deriving the `$dateRanges` keys by grep rather than guessing them — that is a real instruction with a command, not a TBD.

**Type consistency.** Signatures are copied verbatim through Tasks 3–11, then changed in exactly one place — Task 12 — with the full set of edits listed literally rather than described. The return type is uniform (`: Collection`) and was verified against every method's terminal operation, not inferred from the returned variable, because six methods assign `$query = …->get()` and then `return $query;`. The two parameters whose nullability could not be settled from the schema alone (`$activeDayId`, `$limit`) are flagged in Task 12 Step 2 with `?int` named as the correct answer if the trace shows they can be null. `$firebaseSqlOwners` is introduced in Task 3 Step 5, extended by every later task, and its fallback is removed in Task 13 Step 2.

**Known risk left open.** The golden master pins **SQL text and bindings**, not results — it proves the queries are constructed identically, which is exactly what a move-code refactor can break. It would not catch a change in what the database returns, but no task here touches schema or data. The residual gap is a method whose SQL is identical but whose *post-query* PHP differs; the verbatim-move discipline plus the consumer tests are what cover that.
