# MarketingEmailService Refactor — Implementation Plan

## Context

`app/Services/MarketingEmailService.php` is a **1607-line god class** with 22 public campaign methods, each duplicating the same 5-step pattern: `config check → date ranges → program iteration → per-user fetch → Sendgrid POST → cron_logs write`. The same anti-pattern just shipped in `CronService` (1255 → 78 lines) and `FirebaseBusinessService` (2924 → 120 lines) using a shared `LogsCronExecution` trait + domain services + facade pattern.

This refactor applies the same playbook: **reuse the already-built `App\Support\Logging` infrastructure**, split into 7 cohesive domain services, introduce reusable processors for the repeated patterns, keep all 22 public method names on a `MarketingEmailService` facade, and switch the magic dispatch in `SendMarketingEmails` to an explicit registry.

The intended outcome: zero behavior change, every legacy artisan command keeps working, every helper has one responsibility, and the codebase mirrors the patterns the team now knows from Cron + Firebase.

---

## 1. High-level architecture improvements

| # | Improvement | Rationale |
|---|---|---|
| 1 | **Reuse `LogsCronExecution` trait + `CronResult` DTO** | Already built for Cron + Firebase. Marketing currently duplicates the same try-catch + `cron_logs` write 22 times. One trait, one source of truth. |
| 2 | **Split into 7 domain services by campaign group** | Each method falls naturally into one of: NotOpened, Onboarding, OpenedNotCompleted, NotSubscribed, CostOfProcrastination, Angel, OneOff (Congratulations + Referral). One file per group keeps each ≤300 lines. |
| 3 | **`MarketingEmailService` becomes a thin facade** | All 22 method signatures preserved; the artisan `emails:send-marketing` magic dispatch (`$type` → method name) keeps working without code edits to the consumer. |
| 4 | **`MarketingCampaignRegistry`** | Explicit map `CampaignName → [ServiceClass, method]`. Replaces method-name-as-string lookup in `SendMarketingEmails`. Grep-friendly for humans and agents. |
| 5 | **`SendgridClient` service** | Extract the 60-line `sendSendgridTemplate` HTTP POST + payload build into a dedicated class with a tiny `EmailResult` DTO. Tests can mock it cleanly. |
| 6 | **`MailerClient` service** | Same for `sendEmail` (Laravel `Mail::mailer('sendgrid')`). Used only by Angel emails (Blade view rendering). |
| 7 | **`CouponDispatcher` + `CouponCodeGenerator`** | Extracts the Android-coupon-vs-iOS-Stripe-link branching used by `NotSubscribed Template 2` and `Template 4` (currently duplicated ~50 lines × 2 = 100 lines). |
| 8 | **`UserEmailResolver` (batched)** | Replaces the per-user `userInfoQuery->getUserInfo($user->iUserID)` N+1 call inside every campaign loop. The dormant `getUserEmailMapping` private helper already implements the batch — promote and use. |
| 9 | **`ProgramAndUserCampaignProcessor`** | The 14× duplicated `foreach($programs) → query users → foreach($users) → sendgrid` pattern → one processor (mirror of `ProgramCampaignProcessor` from Firebase refactor). |
| 10 | **`EmailResult` DTO** | Replace the loose `['status' => true \| false, 'error' => ?string]` array with a typed object so PHPStan can verify call sites. |
| 11 | **`MarketingCampaignConfig` (PHP enum or typed wrapper)** | Replace the `$this->emailConfigs[$templateKey]` array lookups with a typed accessor that fails loudly on missing keys instead of silently returning null. |
| 12 | **Remove `microtime(true)` from constructor** | Same long-lived-worker bug class as Cron + Firebase. Trait captures `microtime(true)` at the per-method `executeCron` call. |
| 13 | **Optional: Queue dispatch via `SendMarketingCampaignJob`** | For large segments (e.g., `ReferralEmailFreeUsers` cold-start), dispatch one job per user-chunk through Laravel's queue rather than blocking the cron worker for the full sweep. Not required for correctness; opens scalability headroom. |

---

## 2. Code smells & issues found

### Bugs (must fix during refactor)

| Severity | Location | Issue |
|---|---|---|
| 🔴 Bug | [MarketingEmailService.php:664-670](app/Services/MarketingEmailService.php#L664-L670) | `catch (Exception $e)` references `$result['error']` but `$result` is undefined when the exception was thrown BEFORE the Sendgrid call (e.g., during coupon insertion). Causes PHP warning + missing error context. Same pattern repeats at line 775-781 and likely Template 4. **Fix:** use `$e->getMessage()` instead. |
| 🔴 Bug | [MarketingEmailService.php:77](app/Services/MarketingEmailService.php#L77) | `if ($users)` — `$users` is an `Illuminate\Support\Collection` which is always truthy (even when empty). Should be `if ($users->isNotEmpty())` for consistency with the other 21 methods that use `->count()` or `->isNotEmpty()`. Currently runs an empty `foreach` (harmless) but confuses readers. |
| 🟡 Smell | [MarketingEmailService.php:517-518](app/Services/MarketingEmailService.php#L517-L518) | `if ($user->decCostPerMonth \|\| $user->decCostPerMonth == 0)` — second clause is always true when first is false-y. The intent is "field is set, including zero." Use `!is_null($user->decCostPerMonth)`. |
| 🟡 Smell | [MarketingEmailService.php:626](app/Services/MarketingEmailService.php#L626) | `'iProgramId' => 100` is a hardcoded magic number for the coupon's program assignment. Document why (likely "all programs / cross-program coupon"). Constantize on `CouponDispatcher::CROSS_PROGRAM_COUPON_ID`. |
| 🟡 Smell | [MarketingEmailService.php:1186-1196](app/Services/MarketingEmailService.php#L1186-L1196) | `AngelEmails($templateKey)` accepts `$templateKey` but ignores it (uses 3 hardcoded keys internally for the 3 sub-emails). Misleading signature. **Preserve** but document. |

### Performance issues

| # | Location | Problem | Fix |
|---|---|---|---|
| 1 | Every campaign loop | **N+1 query**: `$userInfoQuery->getUserInfo($user->iUserID)` per user. For 5000 users → 5000 queries. | Use the existing dormant `getUserEmailMapping(userIds)` helper (line 1476) to batch-fetch once per chunk. |
| 2 | Every campaign loop | **N+1 HTTP**: one Sendgrid POST per user. Each POST is a TLS handshake + auth. | Sendgrid supports up to 1000 personalizations per call → batch in chunks. Trade-off: single failures fan-out (one bad recipient fails whole batch). Mitigation: chunk 100-500, retry per-recipient on batch failure. |
| 3 | Every campaign loop | **Full materialization**: `$users = $query->get()` loads all users into memory. | Use Eloquent `->lazy()` or `->chunkById($size, callback)` for queries known to return large segments (Referral, Cost-of-Procrastination). |
| 4 | `sendSendgridTemplate` | **No retry/backoff** on transient Sendgrid 5xx. | Wrap in `Http::retry(3, 200, fn($e, $r) => $r?->serverError())`. |
| 5 | `__construct` | `microtime(true)` captured at container resolution, not at method invocation. | Move into the trait (already does this correctly). |
| 6 | `__construct` | `config('marketing-emails.templates')` loaded once → stale if config changes after container resolution (rare for cron, edge case for long-lived workers). | Move into the accessor that fetches per-template. |

### Duplication metrics

| Repeated block | Times duplicated | Approx LOC each | Total duplicate LOC |
|---|---|---|---|
| Config-check + early-return | 22 | 7 | 154 |
| Date-range setup (now + addHour) | 16 | 2 | 32 |
| Program iteration + user fetch | 14 | 8 | 112 |
| Per-user templateData + customArgs assembly | 22 | 10-25 | ~400 |
| `sendSendgridTemplate` call + error handling | 22 | 8 | 176 |
| `cronLogQuery->create([...])` | 22 | 7 | 154 |
| Coupon Android/iOS branching | 2 | ~50 | ~100 |
| Angel email sub-methods (3 near-identical) | 3 | 65 | ~195 |

**Total duplication: ~1300 lines (81% of the file).** Post-refactor target: ≤450 lines spread across 7 services + 6 processors/helpers, with the duplicated blocks each appearing exactly once.

### Dead/dormant code

- `getUserEmailMapping` (line 1476) is a private batch-fetch helper that **no method calls**. Either delete OR wire it through `UserEmailResolver` (recommend the latter — it fixes the N+1 in #1 above).

---

## 3. Refactored structure proposal

### Domain split (7 services + 1 facade)

| Service | Methods | Lines (est.) |
|---|---|---|
| `NotOpenedCampaignService` | NotOpenedD1M1Template2, NotOpenedD1M1Template3 | ~70 |
| `OnboardingCampaignService` | HaveNotCompletedOnBoardingTemplate4 | ~60 |
| `OpenedNotCompletedCampaignService` | OpenedD1M1ButNotCompletedTemplate3, Template4NonFemale, Template4Female | ~110 |
| `NotSubscribedCampaignService` | CompletedD1M1NotSubscribedTemplate1, Template2, Template3, Template4 | ~180 |
| `CostOfProcrastinationCampaignService` | CostOfProcrastinationTemplate1, Template2, CostOfProcrastination (dispatcher), Template3 (protected), Template4 (protected) | ~200 |
| `AngelCampaignService` | AngelEmails (dispatcher) + 3 protected sub-sends + CongratulationsForCompletingTheProgram | ~150 |
| `ReferralCampaignService` | ReferralEmailFreeUsers | ~50 |

### Reusable components (8 classes + 2 DTOs/traits)

| Component | Responsibility |
|---|---|
| `App\Support\Logging\LogsCronExecution` (existing) | try/catch + `cron_logs` write — REUSED, not duplicated |
| `App\Support\Logging\CronResult` (existing) | DTO holding total/notSent/message/context/skipLog — REUSED |
| `App\Services\Marketing\Registry\MarketingCampaignRegistry` | `'NotOpenedD1M1Template2' => [NotOpenedCampaignService::class, 'NotOpenedD1M1Template2']` for all 22 campaigns |
| `App\Services\Marketing\Sendgrid\SendgridClient` | Owns the HTTP POST to Sendgrid v3/mail/send + payload assembly + retry/backoff |
| `App\Services\Marketing\Sendgrid\SendgridPayloadBuilder` | Pure builder: `(template_id, to, data, customArgs, config) → array` (for v3 API shape) |
| `App\Services\Marketing\Mail\MailerClient` | Owns the Laravel `Mail::mailer('sendgrid')` path for Angel emails (Blade view rendering) |
| `App\Services\Marketing\Coupon\CouponDispatcher` | Centralizes Android-coupon-vs-iOS-Stripe-link branching |
| `App\Services\Marketing\Coupon\CouponCodeGenerator` | Pure code generator: name → `NAME60`, fallback to email-derived, uniqueness check via `CouponQuery` |
| `App\Services\Marketing\Support\UserEmailResolver` | Batched `iUserID → vEmail` map (promotes the dormant `getUserEmailMapping`) |
| `App\Services\Marketing\Support\HourlyDateRange` | Tiny immutable value object: `now`, `nextHour` (eliminates the 16× repeated `Carbon::now`/`addHour` lines) |
| `App\Services\Marketing\Processors\ProgramAndUserCampaignProcessor` | The 14× repeated foreach-program-foreach-user pattern (mirror of `Firebase/Processors/ProgramCampaignProcessor`) |
| `App\Services\Marketing\Dto\EmailResult` | `{success: bool, error: ?string}` — replaces loose arrays |
| `App\Services\Marketing\Dto\MarketingTemplateData` | Optional: typed bag for template + customArgs (reduces typo risk in `'unsubscribe-link'`, `'userid'`) |

### Facade (`App\Services\MarketingEmailService`)

Same 22 public method signatures as today. Each method is a one-line pass-through to the relevant domain service. Constructor injects 7 services. ~110 lines total.

---

## 4. Concrete optimized code examples

### 4.1 — `EmailResult` DTO

```php
namespace App\Services\Marketing\Dto;

final readonly class EmailResult
{
    private function __construct(public bool $success, public ?string $error = null) {}
    public static function success(): self { return new self(true); }
    public static function failure(string $error): self { return new self(false, $error); }
}
```

### 4.2 — `SendgridPayloadBuilder` (pure)

```php
namespace App\Services\Marketing\Sendgrid;

class SendgridPayloadBuilder
{
    /**
     * @param  array{template_id:string, subject:string, sender_email:string, sender_name:string,
     *               categories:array, reply_to_email?:string, reply_to_name?:string} & array<string, string>  $config
     * @param  array<string, mixed>  $templateData  May include 'templateIdKey' for per-device template selection.
     * @param  array<string, string>  $customArgs
     * @return array<string, mixed> SendGrid v3 /mail/send body
     */
    public function build(string $toEmail, array $templateData, array $customArgs, array $config): array
    {
        $templateId = $this->resolveTemplateId($templateData, $config);
        $subject = $this->interpolate($config['subject'], $templateData);

        $payload = [
            'personalizations' => [['to' => [['email' => $toEmail]], 'dynamic_template_data' => $templateData]],
            'subject' => $subject,
            'from' => ['email' => $config['sender_email'], 'name' => $config['sender_name']],
            'template_id' => $templateId,
            'categories' => $config['categories'],
            'custom_args' => $customArgs,
        ];

        if (isset($config['reply_to_email'])) {
            $payload['reply_to'] = ['email' => $config['reply_to_email'], 'name' => $config['reply_to_name']];
        }
        return $payload;
    }

    private function resolveTemplateId(array &$data, array $config): string
    {
        if (isset($data['templateIdKey'])) {
            $id = $config[$data['templateIdKey']];
            unset($data['templateIdKey']);
            return $id;
        }
        return $config['template_id'];
    }

    private function interpolate(string $template, array $vars): string
    {
        foreach ($vars as $k => $v) {
            $template = str_replace('{' . $k . '}', (string) $v, $template);
        }
        return $template;
    }
}
```

### 4.3 — `SendgridClient` (HTTP + retry + result DTO)

```php
namespace App\Services\Marketing\Sendgrid;

use App\Services\Marketing\Dto\EmailResult;
use Illuminate\Support\Facades\Http;

class SendgridClient
{
    private const ENDPOINT = 'https://api.sendgrid.com/v3/mail/send';

    public function __construct(protected SendgridPayloadBuilder $builder) {}

    public function send(string $toEmail, array $templateData, array $customArgs, array $config): EmailResult
    {
        $payload = $this->builder->build($toEmail, $templateData, $customArgs, $config);

        $response = Http::withHeaders([
                'Authorization' => 'Bearer ' . config('services.sendgrid.api_key'),
                'Content-Type'  => 'application/json',
            ])
            ->retry(3, 200, fn ($e, $req) => $e instanceof \Illuminate\Http\Client\ConnectionException, throw: false)
            ->post(self::ENDPOINT, $payload);

        return $response->successful() ? EmailResult::success() : EmailResult::failure($response->body());
    }
}
```

### 4.4 — `ProgramAndUserCampaignProcessor` (the workhorse)

```php
namespace App\Services\Marketing\Processors;

use App\Http\Queries\ProgramQuery;
use App\Services\Marketing\Dto\EmailResult;
use Illuminate\Support\Collection;

class ProgramAndUserCampaignProcessor
{
    public function __construct(protected ProgramQuery $programQuery) {}

    /**
     * @param  callable(int $programId): Collection  $userFetcher
     * @param  callable(object $user, object $program): EmailResult  $sendOne
     * @return array{total:int, notSent:int, context:list<array{iUserID:int, error:string}>}
     */
    public function run(callable $userFetcher, callable $sendOne): array
    {
        $total = 0;
        $notSent = 0;
        $context = [];

        foreach ($this->programQuery->getPrograms() as $program) {
            $users = $userFetcher((int) $program->iProgramID);
            if ($users->isEmpty()) {
                continue;
            }
            $total += $users->count();

            foreach ($users as $user) {
                $result = $sendOne($user, $program);
                if (! $result->success) {
                    $notSent++;
                    $context[] = ['iUserID' => $user->iUserID, 'error' => $result->error ?? 'unknown'];
                }
            }
        }
        return compact('total', 'notSent', 'context');
    }
}
```

### 4.5 — Typical campaign method (post-refactor — `NotOpenedD1M1Template2`)

```php
/** @command php artisan emails:send-marketing NotOpenedD1M1Template2 */
public function NotOpenedD1M1Template2(string $templateKey): void
{
    if (! $this->campaigns->enabled($templateKey)) {
        return;
    }

    $this->executeCron($templateKey, '', function (CronResult $r) use ($templateKey) {
        $range = HourlyDateRange::nowToNextHour();
        $config = $this->campaigns->config($templateKey);

        $out = $this->processor->run(
            fn (int $pid) => $this->marketingEmailQuery->getNotOpenedD1M1Template2($pid, $range->start, $range->end),
            fn (object $user) => $this->sendStandardCampaign($templateKey, $user, $config),
        );

        $r->total = $out['total'];
        $r->notSent = $out['notSent'];
        $r->context = $out['context'];
    });
}

private function sendStandardCampaign(string $templateKey, object $user, array $config): EmailResult
{
    $toEmail = $this->emailResolver->resolve((int) $user->iUserID);
    if ($toEmail === null) {
        return EmailResult::failure('no_email');
    }
    return $this->sendgrid->send(
        $toEmail,
        [
            'name' => $user->vName,
            'unsubscribe-link' => front_url('emails/unsubscribe-request/' . encryptData($user->iUserID)),
        ],
        [
            'userid' => encryptData($user->iUserID),
            'server' => config('constants.server_tag'),
        ],
        $config,
    );
}
```

**Method went from ~60 lines to ~25 lines and now uses 0 duplicated infrastructure.**

### 4.6 — `CouponDispatcher` (the Android-vs-iOS branching, used by NotSubscribed 2 + 4)

```php
namespace App\Services\Marketing\Coupon;

final class CouponDecision
{
    public function __construct(
        public readonly string $templateIdKey,  // 'android_template_id' | 'ios_template_id'
        public readonly string $couponCode = '',
        public readonly string $iosLink = '',
        public readonly bool $skipUser = false, // true when coupon collision prevented send
    ) {}
}

class CouponDispatcher
{
    public function __construct(
        protected CouponCodeGenerator $generator,
        protected \App\Http\Queries\CouponQuery $couponQuery,
    ) {}

    public function decideForTemplate2(object $user, object $program, int $discountPct): CouponDecision
    {
        if (strtolower($user->vDeviceType) !== 'android') {
            return $this->stripeDecision($user, $program, $discountPct);
        }
        $code = $this->generator->generate($user, '60', (int) $program->iProgramID);
        if ($code === null) {
            return new CouponDecision('android_template_id', skipUser: true);
        }
        $this->couponQuery->addCoupons([
            'vCode' => $code,
            'vDescription' => $user->iUserID . ' Completed D1M1 Not Subscribed Template 2',
            'vMessage' => 'a 60% discount for your QuitSure Program',
            'iCountLeft' => 1,
            'iUserId' => $user->iUserID,
            'dValidTill' => now()->addHours(48)->format('Y-m-d H:i:00'),
            'iProgramId' => self::CROSS_PROGRAM_COUPON_ID,  // 100 — preserved magic
            'iDiscount' => $discountPct,
            'iCouponTypeId' => 2,
        ]);
        return new CouponDecision('android_template_id', couponCode: $code);
    }
    private const CROSS_PROGRAM_COUPON_ID = 100;
    // stripeDecision(...) + decideForTemplate4(...) follow the same shape
}
```

### 4.7 — `MarketingCampaignRegistry` + `SendMarketingEmails` switch

```php
namespace App\Services\Marketing\Registry;

class MarketingCampaignRegistry
{
    /** @var array<string, array{0: class-string, 1: string}> */
    private const HANDLERS = [
        'NotOpenedD1M1Template2' => [\App\Services\Marketing\Campaigns\NotOpenedCampaignService::class, 'NotOpenedD1M1Template2'],
        'NotOpenedD1M1Template3' => [\App\Services\Marketing\Campaigns\NotOpenedCampaignService::class, 'NotOpenedD1M1Template3'],
        // ... 20 more
    ];

    public function resolve(string $name): ?array { return self::HANDLERS[$name] ?? null; }
    /** @return list<string> */
    public function allNames(): array { return array_keys(self::HANDLERS); }
}
```

```php
// app/Console/Commands/SendMarketingEmails.php (rewritten)
public function handle(MarketingCampaignRegistry $registry): int
{
    $type = $this->argument('type');
    $handler = $registry->resolve($type);
    if ($handler === null) {
        $this->error("Email type '{$type}' not found");
        return 1;
    }
    [$class, $method] = $handler;
    app($class)->{$method}($type);
    $this->info("Executed {$type} successfully");
    return 0;
}
```

---

## 5. Suggested folder structure

```
app/Services/
├── MarketingEmailService.php                        Facade — 22 pass-through methods (~110 lines)
└── Marketing/
    ├── Campaigns/
    │   ├── NotOpenedCampaignService.php
    │   ├── OnboardingCampaignService.php
    │   ├── OpenedNotCompletedCampaignService.php
    │   ├── NotSubscribedCampaignService.php
    │   ├── CostOfProcrastinationCampaignService.php
    │   ├── AngelCampaignService.php
    │   └── ReferralCampaignService.php
    ├── Sendgrid/
    │   ├── SendgridClient.php                       HTTP POST + retry
    │   └── SendgridPayloadBuilder.php               Pure payload assembly
    ├── Mail/
    │   └── MailerClient.php                         Laravel Mail (for Angel Blade views)
    ├── Coupon/
    │   ├── CouponDispatcher.php                     Android-vs-iOS branching
    │   └── CouponCodeGenerator.php                  NAME60 + email-fallback + uniqueness
    ├── Support/
    │   ├── UserEmailResolver.php                    Batched email lookup (fixes N+1)
    │   ├── HourlyDateRange.php                      Value object for now/nextHour
    │   └── MarketingCampaignConfig.php              Typed accessor for emailConfigs
    ├── Processors/
    │   └── ProgramAndUserCampaignProcessor.php      The 14× extracted loop
    ├── Dto/
    │   ├── EmailResult.php                          {success, error?}
    │   └── MarketingTemplateData.php                (optional) typed template vars
    └── Registry/
        └── MarketingCampaignRegistry.php            22 entries

app/Console/Commands/
└── SendMarketingEmails.php                          Registry-based dispatch (~25 lines)

app/Jobs/                                            (optional — Phase 2)
└── SendMarketingCampaignChunkJob.php                Queue dispatch for large segments

tests/Unit/Services/Marketing/
├── Campaigns/                                       7 test files, ~3-5 cases each
├── Sendgrid/
├── Coupon/
├── Support/
├── Processors/
├── Dto/
└── MarketingEmailServiceFacadeTest.php              22 generated delegation tests
tests/Feature/
└── SendMarketingEmailsCommandTest.php
```

---

## 6. Performance optimization suggestions

| # | Optimization | Expected gain | Effort |
|---|---|---|---|
| 1 | **Batch user-email lookup** via `UserEmailResolver` (promotes dormant `getUserEmailMapping`) | Eliminates N+1: 5000 queries → ~10 queries per campaign | Low — already coded, just wire up |
| 2 | **Sendgrid batched personalizations** (up to 1000 per call) | 5000 HTTP POSTs → 5 POSTs per campaign. Network-bound campaigns drop from minutes to seconds. | Medium — needs error-fan-out handling on batch failure |
| 3 | **`->chunkById()` on large queries** (Referral, CostOfProcrastination) | Memory: O(n) → O(chunk). Prevents OOM as user base grows. | Low |
| 4 | **`Http::retry` on Sendgrid 5xx** | Reduces spurious `not_sent` counters during Sendgrid blips | Low — one-liner |
| 5 | **Queue-dispatch per chunk** via `SendMarketingCampaignChunkJob` | Decouples cron worker from Sendgrid latency; cron returns immediately. Visibility via Horizon. | Medium |
| 6 | **Drop `microtime(true)` from constructor** | Fixes long-lived-worker timestamp staleness | Trivial — already fixed in trait |
| 7 | **Cache `getPrograms()` per request** (decorator) | 22 cron methods × `programQuery->getPrograms()` per minute = 22 queries/min that always return the same ~10 rows | Low — small Laravel cache layer or in-memory request-scoped store |
| 8 | **Skip Sendgrid call entirely when `toEmail` is null/invalid** | Currently sends an HTTP request to Sendgrid even when `$userInfo->vEmail` is null (Sendgrid 400s, counted as not_sent). Pre-filter saves a round-trip. | Low — `UserEmailResolver` returns `?string` and the campaign skips when null. |
| 9 | **Index check on `cron_logs.cron`** | If dashboards query by cron name and the column isn't indexed, marketing's 22 daily writes pile up unindexed scans. Verify via `SHOW INDEX FROM cron_logs`. | DBA task |
| 10 | **Async event for `CongratulationsForCompletingTheProgram`** | Currently called synchronously from the API request path. A `UserCompletedProgram` event + queued listener removes the latency. | Medium |

---

## 7. Final improved version of critical reusable methods

### 7.1 `SendgridClient::send` (replaces the 60-line `sendSendgridTemplate`)

See section 4.3 above. **Key wins:** typed return (`EmailResult`), retry on transient failures, payload assembly extracted to `SendgridPayloadBuilder`, no inline magic strings.

### 7.2 `UserEmailResolver::resolve` and `resolveMany`

```php
namespace App\Services\Marketing\Support;

use App\Http\Queries\UserInfoQuery;
use Illuminate\Support\Collection;

class UserEmailResolver
{
    /** @var array<int, ?string> */
    private array $cache = [];

    public function __construct(protected UserInfoQuery $userInfoQuery) {}

    /** Single lookup (uses + populates the per-instance cache). */
    public function resolve(int $userId): ?string
    {
        if (! array_key_exists($userId, $this->cache)) {
            $info = $this->userInfoQuery->getUserInfo($userId);
            $this->cache[$userId] = $info?->vEmail;
        }
        return $this->cache[$userId];
    }

    /** Batched lookup (one query per chunk). Mutates the cache; returns the new map. */
    public function resolveMany(Collection|array $userIds, int $chunkSize = 500): Collection
    {
        $ids = collect($userIds)->filter(fn ($id) => ! array_key_exists((int) $id, $this->cache));
        $ids->chunk($chunkSize)->each(function ($chunk) {
            $infoMap = collect($this->userInfoQuery->getMultipleUserInfo($chunk->toArray())->toArray())
                ->pluck('vEmail', 'iUserID');
            foreach ($infoMap as $uid => $email) {
                $this->cache[(int) $uid] = $email;
            }
        });
        return collect($userIds)->mapWithKeys(fn ($id) => [(int) $id => $this->cache[(int) $id] ?? null]);
    }
}
```

### 7.3 `CouponCodeGenerator::generate`

```php
namespace App\Services\Marketing\Coupon;

class CouponCodeGenerator
{
    public function __construct(protected \App\Http\Queries\CouponQuery $couponQuery) {}

    /**
     * Generates a coupon code like `SURESH60`. Falls back to email-derived code on collision.
     * Returns null if both attempts collide — caller should skip the user.
     */
    public function generate(object $user, string $suffix, int $programId): ?string
    {
        $primary = $this->fromName($user->vName, $suffix);
        if ($this->isUsable($primary, $programId)) {
            return $primary;
        }

        // Fallback: derive from email local-part (toEmail may not be loaded here — caller must pass)
        $fallback = $this->fromEmail((string) ($user->vEmail ?? ''), $suffix);
        return $this->isUsable($fallback, $programId) ? $fallback : null;
    }

    private function fromName(string $name, string $suffix): string
    {
        $first = explode(' ', trim($name))[0] ?? $name;
        return str_replace(' ', '', strtoupper($first) . $suffix);
    }

    private function fromEmail(string $email, string $suffix): string
    {
        $local = preg_replace('/[^A-Za-z0-9\-]/', '', strtok($email, '@') ?: '');
        return str_replace(' ', '', strtoupper($local) . $suffix);
    }

    private function isUsable(string $code, int $programId): bool
    {
        return strlen($code) >= 4 && $this->couponQuery->couponCodeExists($code, $programId) === 0;
    }
}
```

### 7.4 `HourlyDateRange` value object (replaces 16× duplicated `Carbon::now`/`addHour` lines)

```php
namespace App\Services\Marketing\Support;

use Carbon\Carbon;

final readonly class HourlyDateRange
{
    public function __construct(public string $start, public string $end) {}

    public static function nowToNextHour(): self
    {
        $now = Carbon::now();
        return new self(
            $now->format('H:i:00'),
            $now->copy()->addHour()->format('H:i:00'),
        );
    }
}
```

### 7.5 Campaign-service method skeleton (canonical template for all 22)

See section 4.5. Each method is 8-12 lines, uses `executeCron` (trait), defers to `processor->run()` (loop), `sendgrid->send()` (HTTP), and `emailResolver->resolve()` (batched email lookup). Zero duplication.

---

## Out-of-scope (explicitly NOT changing)

- `MarketingEmailQuery` interfaces — all 22 query methods preserved verbatim.
- `CouponQuery`, `UserInfoQuery`, `ProgramQuery`, `CronLogQuery` interfaces — preserved.
- `cron_logs` schema and column shape (`cron, start_time, total, not_sent, context`; marketing legacy doesn't set `message` and that stays).
- `CustomEmail` mailable + `emails.*` Blade templates — untouched.
- Sendgrid v3 API contract — the payload shape passed to `/v3/mail/send` is byte-identical to today.
- `config('marketing-emails.templates')` keys and values — read-only.
- The artisan signature `emails:send-marketing {type?}` — preserved.
- `CongratulationsForCompletingTheProgram(int $iUserID)` API call signature — preserved (different from cron methods).
- All 22 PascalCase public method names on `MarketingEmailService` facade.
- Coupon validity period (48 hours), discount percentages (60%, 65%), hardcoded `iProgramId => 100` — preserved (constantized but not changed).
- Hardcoded referral URL `https://quitsure.app.link/e/5ybWVATkYAb` — preserved.

---

## Task decomposition

The execution itself, once approved, follows the same 18-step subagent-driven pattern as the Cron + Firebase refactors:

| Task | Description | Tests added |
|---|---|---|
| 1 | Baseline snapshot (method list, cron names, baseline pest count) | — |
| 2 | `EmailResult` DTO | ~4 |
| 3 | `HourlyDateRange` value object | ~3 |
| 4 | `SendgridPayloadBuilder` | ~6 |
| 5 | `SendgridClient` (HTTP + retry) | ~5 |
| 6 | `MailerClient` (Laravel Mail) | ~3 |
| 7 | `UserEmailResolver` (batched + cached) | ~5 |
| 8 | `CouponCodeGenerator` + `CouponDispatcher` | ~8 |
| 9 | `ProgramAndUserCampaignProcessor` | ~5 |
| 10 | `MarketingCampaignConfig` typed accessor | ~3 |
| 11 | `NotOpenedCampaignService` (2 methods) | ~3 |
| 12 | `OnboardingCampaignService` (1 method) | ~2 |
| 13 | `OpenedNotCompletedCampaignService` (3 methods) | ~4 |
| 14 | `NotSubscribedCampaignService` (4 methods) | ~6 |
| 15 | `CostOfProcrastinationCampaignService` (4 + 1 dispatcher) | ~6 |
| 16 | `AngelCampaignService` (4 methods) | ~5 |
| 17 | `ReferralCampaignService` (1 method) + `MarketingCampaignRegistry` | ~5 |
| 18 | `MarketingEmailService` facade rewrite (22 pass-throughs + delegation test) | ~23 |
| 19 | `SendMarketingEmails` command → registry + final verification | ~3 |

**Estimated new tests: ~100. Total time: comparable to Cron refactor (~12 tasks × small + ~6 large = manageable in one focused session if dispatched via subagents).**

---

## Quality constraints (verify each commit)

- No file > 300 lines (currently 1607 lines in one file)
- No method > 40 lines (currently several at 60-130 lines)
- `cronLogQuery->create(` appears exactly once after the refactor — only in `App\Support\Logging\LogsCronExecution` (currently 22 times in MarketingEmailService)
- All 22 public method names preserved on `MarketingEmailService` facade
- Pest suite green: baseline 250 passing → target ~350 passing
- PHPStan clean on `app/Services/Marketing/` + `app/Services/MarketingEmailService.php`
- Pint clean on the same paths

---

## AI-compatibility improvements

1. **`@command php artisan emails:send-marketing <Name>` PHPDoc tag** on every campaign method
2. **Explicit registry** (greppable; no string-concat dispatch)
3. **`EmailResult` DTO** over loose arrays — PHPStan can verify call sites
4. **One file = one responsibility** — every Marketing concern fits in agent context
5. **`HourlyDateRange::nowToNextHour()`** is greppable; raw `Carbon::now()->format('H:i:00')` is not
6. **`MarketingCampaignConfig::enabled($key)` + `config($key)`** — fail loudly on typos instead of silently returning null
7. **Constants for magic numbers** (`CROSS_PROGRAM_COUPON_ID = 100`, `COUPON_VALIDITY_HOURS = 48`, `DEFAULT_DISCOUNT_PCT = 60`)
8. **Casing quirks documented** (this codebase uses PascalCase methods unlike the Cron snake-case)
