# Reddit Conversions API — SignUp Event 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:** Send a server-side "Sign Up" conversion event to the Reddit Conversions API (v3) whenever a new user is created via `User/SocialLogin`, fired asynchronously and gated behind a feature flag.

**Architecture:** One new `RedditCapiService` (Laravel `Http` facade, no SDK) is constructor-injected into `BaseAuthService`. In `getUserData()`'s new-user branch, request/postData values are captured and a `defer()` closure calls the service after the response is sent. The service is a no-op unless the feature flag is on and credentials are present.

**Tech Stack:** PHP 8.3, Laravel, Pest 4 (Feature/Unit), `Illuminate\Support\Facades\Http`.

**Spec:** [docs/superpowers/specs/2026-07-01-reddit-capi-signup-design.md](../specs/2026-07-01-reddit-capi-signup-design.md)

## Global Constraints

- **Reddit CAPI v3 endpoint:** `POST https://ads-api.reddit.com/api/v3/pixels/{pixel_id}/conversion_events`
- **Auth:** `Authorization: Bearer {conversion_access_token}` (use `Http::withToken()`).
- **`event_type.tracking_type`** must be the exact string `"Sign Up"` (with a space).
- **`event_at`** is a Unix epoch timestamp in **milliseconds** (integer).
- **`click_id`** is an **event-level** field (sibling of `user`), plaintext, sourced from `rdt_cid`.
- **Hashing:** email arrives **pre-hashed** (`tbl_Users.vHashedEmail`) and is sent verbatim. `external_id`, `aaid`/`idfa`, `ip_address`, `user_agent`, `click_id` are **plaintext**. The service performs **no hashing**.
- **Advertising id routing:** `advertising_id` → `idfa` when `vDeviceType === 'ios'`, → `aaid` when `vDeviceType === 'android'`.
- **Feature flag:** `REDDIT_CAPI_ENABLED` (config `services.reddit.enabled`). Sending happens only when the flag is on AND `pixel_id` AND `access_token` are non-empty; otherwise a logged no-op.
- **Never block or break signup:** all failures are caught in the service and logged by the caller via `Log::error`; nothing is thrown.
- **Git:** The user stages and commits their own work. Do **not** run `git add`/`commit`/`push`. Each task ends by leaving changes staged-ready for the user.
- **Formatting/analysis:** run `./vendor/bin/pint` and `./vendor/bin/phpstan analyse` on changed files before finishing each task.

---

## File Structure

- **Create** `app/Services/RedditCapiService.php` — builds the Reddit v3 payload and POSTs it; owns the feature-flag/config guard, HTTP call, and error handling.
- **Create** `tests/Unit/Services/RedditCapiServiceTest.php` — Pest unit tests using `Http::fake()`.
- **Modify** `config/services.php` — add the `reddit` config block.
- **Modify** `.env` and `.env.testing` — add the four `REDDIT_*` keys.
- **Modify** `app/Http/Queries/UserQuery.php` — add `tbl_Users.vHashedEmail` to `getUserLoginData()`'s select.
- **Modify** `app/Services/Auth/BaseAuthService.php` — inject `RedditCapiService`; add the `defer()` call in `getUserData()`.
- **Modify** `app/Services/Auth/SSOLoginService.php` — thread the new constructor param through to `parent::__construct()`.

---

### Task 1: Config, env, and RedditCapiService guard (no-op path)

Creates the service with its constructor + feature-flag/config guard only. The guard path is fully testable before any HTTP logic exists.

**Files:**
- Create: `app/Services/RedditCapiService.php`
- Create: `tests/Unit/Services/RedditCapiServiceTest.php`
- Modify: `config/services.php` (add `reddit` block after the `stripe`/`meta` blocks)
- Modify: `.env`, `.env.testing`

**Interfaces:**
- Consumes: nothing (leaf service). Reads `config('services.reddit.*')`.
- Produces: `RedditCapiService::sendSignupEvent(array $data): array` returning `['success' => bool]` or `['success' => bool, 'error' => mixed]`. Accepted `$data` keys: `email`, `external_id`, `client_ip`, `user_agent`, `advertising_id`, `device_type`, `click_id`.

- [ ] **Step 1: Add the `reddit` config block**

In `config/services.php`, add this entry inside the returned array (e.g. immediately after the existing `'meta' => [ ... ],` block):

```php
    'reddit' => [
        'enabled'      => env('REDDIT_CAPI_ENABLED', false),
        'pixel_id'     => env('REDDIT_PIXEL_ID'),
        'access_token' => env('REDDIT_CONVERSION_ACCESS_TOKEN'),
        'test_mode'    => env('REDDIT_TEST_MODE', false),
    ],
```

- [ ] **Step 2: Add env keys**

Append to `.env` (values supplied by the team; leave blank if not yet available):

```
REDDIT_CAPI_ENABLED=false
REDDIT_PIXEL_ID=
REDDIT_CONVERSION_ACCESS_TOKEN=
REDDIT_TEST_MODE=false
```

Append the same four keys to `.env.testing` with `REDDIT_CAPI_ENABLED=false` and the rest blank, so the existing suite never fires Reddit.

- [ ] **Step 3: Write the failing guard tests**

Create `tests/Unit/Services/RedditCapiServiceTest.php`:

```php
<?php

use App\Services\RedditCapiService;
use Illuminate\Support\Facades\Http;

/**
 * Build a service with config applied. Pass overrides keyed by the short
 * config name (enabled, pixel_id, access_token, test_mode).
 */
function makeRedditService(array $overrides = []): RedditCapiService
{
    config([
        'services.reddit.enabled'      => true,
        'services.reddit.pixel_id'     => 'px_123',
        'services.reddit.access_token' => 'tok_abc',
        'services.reddit.test_mode'    => false,
    ]);

    foreach ($overrides as $key => $value) {
        config(["services.reddit.$key" => $value]);
    }

    return new RedditCapiService();
}

function redditSignupData(array $overrides = []): array
{
    return array_merge([
        'email'          => 'hashedemailvalue',
        'external_id'    => 'QS-euser-1',
        'client_ip'      => '203.0.113.9',
        'user_agent'     => 'QuitSure/1.0 (iPhone)',
        'advertising_id' => 'ad-id-123',
        'device_type'    => 'ios',
        'click_id'       => 'rdt-cid-xyz',
    ], $overrides);
}

it('does not call Reddit when the feature flag is off', function () {
    Http::fake();

    $result = makeRedditService(['enabled' => false])->sendSignupEvent(redditSignupData());

    Http::assertNothingSent();
    expect($result['success'])->toBeFalse();
});

it('does not call Reddit when the pixel id is missing', function () {
    Http::fake();

    $result = makeRedditService(['pixel_id' => ''])->sendSignupEvent(redditSignupData());

    Http::assertNothingSent();
    expect($result['success'])->toBeFalse();
});

it('does not call Reddit when the access token is missing', function () {
    Http::fake();

    $result = makeRedditService(['access_token' => ''])->sendSignupEvent(redditSignupData());

    Http::assertNothingSent();
    expect($result['success'])->toBeFalse();
});
```

- [ ] **Step 4: Run the tests to verify they fail**

Run: `php artisan test tests/Unit/Services/RedditCapiServiceTest.php`
Expected: FAIL — `Class "App\Services\RedditCapiService" not found`.

- [ ] **Step 5: Create the service with the guard only**

Create `app/Services/RedditCapiService.php`:

```php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Log;

class RedditCapiService
{
    private const ENDPOINT = 'https://ads-api.reddit.com/api/v3/pixels/%s/conversion_events';
    private const TRACKING_TYPE_SIGNUP = 'Sign Up';

    private bool $enabled;
    private ?string $pixelId;
    private ?string $accessToken;
    private bool $testMode;

    public function __construct()
    {
        $this->enabled     = (bool) config('services.reddit.enabled', false);
        $this->pixelId     = config('services.reddit.pixel_id');
        $this->accessToken = config('services.reddit.access_token');
        $this->testMode    = (bool) config('services.reddit.test_mode', false);
    }

    /**
     * Send a "Sign Up" conversion event to the Reddit Conversions API.
     *
     * @param array{email?: ?string, external_id?: ?string, client_ip?: ?string, user_agent?: ?string, advertising_id?: ?string, device_type?: ?string, click_id?: ?string} $data
     * @return array{success: bool, error?: mixed}
     */
    public function sendSignupEvent(array $data): array
    {
        if (! $this->enabled || empty($this->pixelId) || empty($this->accessToken)) {
            Log::debug('Reddit CAPI skipped: disabled or unconfigured');

            return ['success' => false];
        }

        // HTTP send added in Task 2.
        return ['success' => false];
    }
}
```

- [ ] **Step 6: Run the tests to verify they pass**

Run: `php artisan test tests/Unit/Services/RedditCapiServiceTest.php`
Expected: PASS (3 passed).

- [ ] **Step 7: Lint and analyse**

Run: `./vendor/bin/pint app/Services/RedditCapiService.php tests/Unit/Services/RedditCapiServiceTest.php config/services.php`
Run: `./vendor/bin/phpstan analyse app/Services/RedditCapiService.php`
Expected: no errors.

- [ ] **Step 8: Hand off for commit**

Leave the changes in the working tree for the user to stage and commit (do not run git).

---

### Task 2: RedditCapiService payload build + HTTP send

Implements the real send: builds the v3 payload and POSTs it, with success/failure/exception handling.

**Files:**
- Modify: `app/Services/RedditCapiService.php`
- Modify: `tests/Unit/Services/RedditCapiServiceTest.php`

**Interfaces:**
- Consumes: `sendSignupEvent(array $data): array` from Task 1 (same signature).
- Produces: on 2xx → `['success' => true]`; on non-2xx → `['success' => false, 'error' => ['status' => int, 'body' => string]]`; on transport exception → `['success' => false, 'error' => string]`. Emits exactly one POST to the v3 endpoint with a `Bearer` token; body shape per the Global Constraints.

- [ ] **Step 1: Write the failing send tests**

Append to `tests/Unit/Services/RedditCapiServiceTest.php`:

```php
use Illuminate\Http\Client\Request;

it('posts a correctly shaped Sign Up event on success', function () {
    Http::fake([
        'ads-api.reddit.com/*' => Http::response(['status' => 'ok'], 200),
    ]);

    $result = makeRedditService()->sendSignupEvent(redditSignupData());

    expect($result['success'])->toBeTrue();

    Http::assertSent(function (Request $request) {
        $event = $request['events'][0];

        return $request->url() === 'https://ads-api.reddit.com/api/v3/pixels/px_123/conversion_events'
            && $request->method() === 'POST'
            && $request->hasHeader('Authorization', 'Bearer tok_abc')
            && $request['test_mode'] === false
            && is_int($event['event_at'])
            && $event['event_type']['tracking_type'] === 'Sign Up'
            && $event['click_id'] === 'rdt-cid-xyz'
            && $event['user']['email'] === 'hashedemailvalue'
            && $event['user']['external_id'] === 'QS-euser-1'
            && $event['user']['ip_address'] === '203.0.113.9'
            && $event['user']['user_agent'] === 'QuitSure/1.0 (iPhone)';
    });
});

it('maps advertising_id to idfa for ios devices', function () {
    Http::fake(['ads-api.reddit.com/*' => Http::response([], 200)]);

    makeRedditService()->sendSignupEvent(redditSignupData(['device_type' => 'ios']));

    Http::assertSent(function (Request $request) {
        $user = $request['events'][0]['user'];

        return ($user['idfa'] ?? null) === 'ad-id-123' && ! isset($user['aaid']);
    });
});

it('maps advertising_id to aaid for android devices', function () {
    Http::fake(['ads-api.reddit.com/*' => Http::response([], 200)]);

    makeRedditService()->sendSignupEvent(redditSignupData(['device_type' => 'android']));

    Http::assertSent(function (Request $request) {
        $user = $request['events'][0]['user'];

        return ($user['aaid'] ?? null) === 'ad-id-123' && ! isset($user['idfa']);
    });
});

it('omits empty user fields and event click_id from the payload', function () {
    Http::fake(['ads-api.reddit.com/*' => Http::response([], 200)]);

    makeRedditService()->sendSignupEvent(redditSignupData([
        'advertising_id' => null,
        'click_id'       => null,
    ]));

    Http::assertSent(function (Request $request) {
        $event = $request['events'][0];

        return ! array_key_exists('click_id', $event)
            && ! isset($event['user']['idfa'])
            && ! isset($event['user']['aaid']);
    });
});

it('returns failure with status and body on a non-2xx response', function () {
    Http::fake([
        'ads-api.reddit.com/*' => Http::response('bad request', 400),
    ]);

    $result = makeRedditService()->sendSignupEvent(redditSignupData());

    expect($result['success'])->toBeFalse()
        ->and($result['error']['status'])->toBe(400)
        ->and($result['error']['body'])->toBe('bad request');
});

it('returns failure and does not throw on a transport exception', function () {
    $connectEx = new \GuzzleHttp\Exception\ConnectException(
        'connection refused',
        new \GuzzleHttp\Psr7\Request('POST', 'https://ads-api.reddit.com/api/v3/pixels/px_123/conversion_events'),
    );

    Http::fake(function () use ($connectEx) {
        throw $connectEx;
    });

    $result = makeRedditService()->sendSignupEvent(redditSignupData());

    expect($result['success'])->toBeFalse()
        ->and($result['error'])->toBeString();
});
```

- [ ] **Step 2: Run the new tests to verify they fail**

Run: `php artisan test tests/Unit/Services/RedditCapiServiceTest.php`
Expected: FAIL — the send tests fail because `sendSignupEvent` still returns `['success' => false]` without sending (assertions on sent requests / success fail).

- [ ] **Step 3: Implement the payload build and HTTP send**

In `app/Services/RedditCapiService.php`, add the `Http` import and replace the Task 1 placeholder body of `sendSignupEvent()`, then add the two private helpers.

Add to the imports at the top:

```php
use Illuminate\Support\Facades\Http;
```

Replace the `// HTTP send added in Task 2.` line and its following `return` with:

```php
        $event = [
            'event_at'   => (int) round(microtime(true) * 1000),
            'event_type' => ['tracking_type' => self::TRACKING_TYPE_SIGNUP],
            'user'       => $this->buildUser($data),
        ];

        if (! empty($data['click_id'])) {
            $event['click_id'] = $data['click_id'];
        }

        return $this->execute([
            'test_mode' => $this->testMode,
            'events'    => [$event],
        ]);
```

Add these private methods to the class (after `sendSignupEvent`):

```php
    /**
     * Build the Reddit `user` object, including only non-empty fields.
     *
     * @param array<string, mixed> $data
     * @return array<string, string>
     */
    private function buildUser(array $data): array
    {
        $user = [];

        if (! empty($data['email'])) {
            $user['email'] = (string) $data['email'];
        }
        if (! empty($data['external_id'])) {
            $user['external_id'] = (string) $data['external_id'];
        }
        if (! empty($data['client_ip'])) {
            $user['ip_address'] = (string) $data['client_ip'];
        }
        if (! empty($data['user_agent'])) {
            $user['user_agent'] = (string) $data['user_agent'];
        }

        $adId   = $data['advertising_id'] ?? null;
        $device = strtolower((string) ($data['device_type'] ?? ''));
        if (! empty($adId)) {
            if ($device === 'ios') {
                $user['idfa'] = (string) $adId;
            } elseif ($device === 'android') {
                $user['aaid'] = (string) $adId;
            }
        }

        return $user;
    }

    /**
     * POST the payload to the Reddit Conversions API.
     *
     * @param array<string, mixed> $payload
     * @return array{success: bool, error?: mixed}
     */
    private function execute(array $payload): array
    {
        try {
            $response = Http::withToken($this->accessToken)
                ->timeout(10)
                ->retry(2, 250)
                ->post(sprintf(self::ENDPOINT, $this->pixelId), $payload);

            if ($response->successful()) {
                return ['success' => true];
            }

            return [
                'success' => false,
                'error'   => [
                    'status' => $response->status(),
                    'body'   => $response->body(),
                ],
            ];
        } catch (\Throwable $e) {
            return ['success' => false, 'error' => $e->getMessage()];
        }
    }
```

- [ ] **Step 4: Run the full service test file to verify it passes**

Run: `php artisan test tests/Unit/Services/RedditCapiServiceTest.php`
Expected: PASS (9 passed — 3 guard + 6 send).

- [ ] **Step 5: Lint and analyse**

Run: `./vendor/bin/pint app/Services/RedditCapiService.php tests/Unit/Services/RedditCapiServiceTest.php`
Run: `./vendor/bin/phpstan analyse app/Services/RedditCapiService.php`
Expected: no errors.

- [ ] **Step 6: Hand off for commit**

Leave the changes in the working tree for the user to stage and commit.

---

### Task 3: Wire the event into the signup flow

Selects `vHashedEmail`, injects the service, and fires the deferred event for new users only. Also updates `SSOLoginService`'s explicit `parent::__construct()` call so DI keeps working.

**Files:**
- Modify: `app/Http/Queries/UserQuery.php` (getUserLoginData select, ~line 287)
- Modify: `app/Services/Auth/BaseAuthService.php` (import, property, constructor param + assignment, `getUserData()` call site ~line 803)
- Modify: `app/Services/Auth/SSOLoginService.php` (constructor param + parent call)

**Interfaces:**
- Consumes: `RedditCapiService::sendSignupEvent(array $data): array` (Task 2). Reads `$user->vHashedEmail` (added to the login query) and `$postData` keys `advertising_id`, `vDeviceType`, `rdt_cid`.
- Produces: no new public API — a side effect (deferred Reddit event) on new-user signup.

- [ ] **Step 1: Add `vHashedEmail` to the login query select**

In `app/Http/Queries/UserQuery.php`, inside `getUserLoginData()`'s `->select([ ... ])`, add the column to the `tbl_Users` group (e.g. right after `'tbl_Users.vEUserID',`):

```php
                'tbl_Users.vHashedEmail',
```

- [ ] **Step 2: Import and inject `RedditCapiService` in `BaseAuthService`**

In `app/Services/Auth/BaseAuthService.php`:

Add the import next to the other service imports:

```php
use App\Services\RedditCapiService;
```

Add the property next to the other service properties (e.g. after `protected $sourceDataService;`):

```php
    protected $redditCapiService;
```

Add the constructor parameter as the last parameter (after `SourceDataService $sourceDataService,`):

```php
        RedditCapiService $redditCapiService,
```

Add the assignment in the constructor body (after `$this->sourceDataService = $sourceDataService;`):

```php
        $this->redditCapiService = $redditCapiService;
```

- [ ] **Step 3: Add the deferred Reddit call in `getUserData()`**

In `app/Services/Auth/BaseAuthService.php`, replace the existing `if (! $isExisting) { ... }` block that holds the commented-out Meta signup call with:

```php
        if (! $isExisting) {
            $hashedEmail = $user->vHashedEmail;          // pre-hashed sha256(vEmail)
            $eUserID     = $user->vEUserID;
            $ip          = request()->ip();
            $ua          = request()->userAgent();
            $adId        = $postData['advertising_id'] ?? null;
            $device      = $postData['vDeviceType'] ?? null;
            $clickId     = $postData['rdt_cid'] ?? null;

            defer(function () use ($hashedEmail, $eUserID, $ip, $ua, $adId, $device, $clickId) {
                $res = $this->redditCapiService->sendSignupEvent([
                    'email'          => $hashedEmail,   // already hashed; sent verbatim
                    'external_id'    => $eUserID,
                    'client_ip'      => $ip,
                    'user_agent'     => $ua,
                    'advertising_id' => $adId,
                    'device_type'    => $device,
                    'click_id'       => $clickId,
                ]);
                if (! ($res['success'] ?? false)) {
                    Log::error('Reddit Signup Event Error', $res);
                }
            });
        }
```

(`Log` is already imported in this file.)

- [ ] **Step 4: Thread the param through `SSOLoginService`**

In `app/Services/Auth/SSOLoginService.php`:

Add the import (with the other `use` statements):

```php
use App\Services\RedditCapiService;
```

Add the constructor parameter as the last parameter (after `SourceDataService $sourceDataService,`):

```php
        RedditCapiService $redditCapiService,
```

Add `$redditCapiService` as the last argument passed to `parent::__construct(...)` (after `$sourceDataService`):

```php
            $sourceDataService,
            $redditCapiService
        );
```

- [ ] **Step 5: Run the auth test suites to verify no regression**

Run: `php artisan test tests/Feature/Api/Auth tests/Unit/Services/Auth`
Expected: PASS — existing SocialLogin / SSO / EmailLogin tests still green (DI resolves the new dependency; the mocked `UserService` path is unaffected).

- [ ] **Step 6: Run the full test suite**

Run: `php artisan test`
Expected: PASS — no failures introduced anywhere, including the new `RedditCapiServiceTest`.

- [ ] **Step 7: Lint and analyse the changed files**

Run: `./vendor/bin/pint app/Http/Queries/UserQuery.php app/Services/Auth/BaseAuthService.php app/Services/Auth/SSOLoginService.php`
Run: `./vendor/bin/phpstan analyse app/Http/Queries/UserQuery.php app/Services/Auth/BaseAuthService.php app/Services/Auth/SSOLoginService.php`
Expected: no errors.

- [ ] **Step 8: Manual verification (test mode)**

With real credentials in `.env`, set `REDDIT_CAPI_ENABLED=true` and `REDDIT_TEST_MODE=true`, then perform a new-user social login. Confirm the event appears in Reddit's Events Manager → Test Events, `tracking_type` shows as a sign-up, and match quality is reported for the sent fields. Revert `REDDIT_TEST_MODE=false` for production.

- [ ] **Step 9: Hand off for commit**

Leave the changes in the working tree for the user to stage and commit.

---

## Self-Review Notes

- **Spec coverage:** trigger point (Task 3 Step 3), v3 endpoint/auth/tracking_type/event_at (Tasks 1–2 + Global Constraints), field map incl. `vHashedEmail` verbatim + plaintext others + advertising-id routing (Task 2), event-level `click_id` (Task 2), feature-flag + config guard (Task 1), `defer()` async + new-users-only + error isolation (Task 3), and all six spec test scenarios (guard/flag → Task 1; payload/routing/failure/exception → Task 2; new-users-only + regression → Task 3). The `getUserLoginData` select gap the spec flagged is Task 3 Step 1.
- **Type consistency:** `sendSignupEvent(array): array` and its `$data` keys (`email`, `external_id`, `client_ip`, `user_agent`, `advertising_id`, `device_type`, `click_id`) match between the call site (Task 3) and the service/tests (Tasks 1–2). Config keys (`enabled`, `pixel_id`, `access_token`, `test_mode`) are identical across config, env, service, and test helper.
- **Note on new-users-only test:** the existing Feature suite fully mocks `UserService`, so `getUserData()`/`defer()` are not exercised by an automated end-to-end test; new-user behavior is covered at the service level (Tasks 1–2) plus regression (Task 3 Steps 5–6) and manual test-mode verification (Step 8).
