HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Services/SubscriberService.php
<?php

namespace App\Services;

use App\Model\Form;
use App\Model\JobMonitor;
use App\Model\MailList;
use App\Model\Segment;
use App\Model\Subscriber;
use App\Model\Timeline;
use App\Model\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\URL;
use RuntimeException;

class SubscriberService
{
    public function addByCustomer(MailList $list, Request $request, $customer): array
    {
        [$validator, $subscriber] = $list->subscribe($request, Subscriber::SUBSCRIPTION_TYPE_ADDED);

        if (!is_null($subscriber) && $subscriber->isInDb()) {
            Timeline::recordAddedByCustomer($subscriber, $customer);
        }

        return [$validator, $subscriber];
    }

    public function optInFromWeb(MailList $list, Request $request, $logger = null): array
    {
        return $this->optIn(
            $list,
            $request,
            MailList::SOURCE_WEB,
            function ($subscriber) {
                Timeline::recordSignUpFormOptIn($subscriber);
            },
            $logger
        );
    }

    public function optInFromEmbeddedForm(MailList $list, Request $request): array
    {
        return $this->optIn(
            $list,
            $request,
            MailList::SOURCE_EMBEDDED_FORM,
            function ($subscriber) {
                Timeline::recordEmbeddedFormOptIn($subscriber);
            }
        );
    }

    public function optInFromPopupForm(Form $form, Request $request): array
    {
        return $this->optIn(
            $form->mailList,
            $request,
            MailList::SOURCE_EMBEDDED_FORM,
            function ($subscriber) use ($form) {
                Timeline::recordPopupFormOptIn($subscriber, $form);
            }
        );
    }

    public function generateSignedConfirmationUrl(Subscriber $subscriber, bool $absolute = true): string
    {
        return URL::signedRoute('lists.subscribe_confirm', [
            'list_uid' => $subscriber->mailList->uid,
            'id' => $subscriber->id,
            'customer_uid' => $subscriber->mailList->customer->uid,
        ], null, $absolute);
    }

    public function confirmOptIn(Subscriber $subscriber): bool
    {
        if ($subscriber->status === Subscriber::STATUS_UNCONFIRMED) {
            return $subscriber->confirm();
        }

        if ($subscriber->isSubscribed()) {
            return false;
        }

        throw new RuntimeException('Subscriber cannot be confirmed from the current status');
    }

    private function optIn(MailList $list, Request $request, string $source, callable $timelineRecorder, $logger = null): array
    {
        [$validator, $subscriber] = $list->subscribe($request, $source, $logger);

        if (!is_null($subscriber) && $subscriber->isInDb()) {
            $timelineRecorder($subscriber);
        }

        return [$validator, $subscriber];
    }

    /**
     * F5.2 — Find a subscriber by email within a single mail list.
     *
     * Single source of truth shared by `subscriber.find_by_email` agent
     * tool and any future per-list lookup helpers. Caller is responsible
     * for the read Policy gate (see SubscriberPolicy::read).
     */
    public function findByEmail(MailList $list, string $email): ?Subscriber
    {
        $email = strtolower(trim($email));
        if ($email === '') {
            return null;
        }

        return $list->subscribers()->where('email', $email)->first();
    }

    /**
     * F5.2 — Update custom-field values on a subscriber (agent surface).
     *
     * Mirrors `SubscriberController::update` ($this->validate + updateFields
     * + event(MailListUpdated)). Single Service entry point so the agent and
     * the web edit form share identical mutation semantics. Throws
     * \RuntimeException on Policy denial; throws ValidationException via
     * Validator on field-rule failure.
     *
     * Only fields declared on the parent MailList are accepted (everything
     * else is silently dropped — Subscriber::updateFields ignores unknown
     * tags as the legacy controller already does).
     *
     * @param  array<string,mixed>  $fields  Field tag => value map.
     */
    public function updateFieldsForUser(User $user, Subscriber $subscriber, array $fields): Subscriber
    {
        if (! Gate::forUser($user)->allows('update', $subscriber)) {
            throw new RuntimeException('Subscriber update denied by policy.');
        }

        // Validate against the parent list's field rules so the agent path
        // gets the exact same enforcement as the web edit form.
        $rules = $subscriber->getRules();
        if (! empty($rules)) {
            \Validator::make($fields, $rules)->validate();
        }

        $subscriber->updateFields($fields, $updateAll = false);

        event(new \App\Events\SubscriptionUpdated($subscriber));
        $rel = $subscriber->getRelationValue('mailList');
        if ($rel instanceof MailList) {
            event(new \App\Events\MailListUpdated($rel));
        }

        return $subscriber->refresh();
    }

    /**
     * F5.2 — Delete subscribers by uid scoped to a single mail list.
     *
     * Source of truth for `SubscriberController::delete` (legacy
     * controller will continue to call its inline path until cross-cut)
     * AND for the new `subscriber.delete` agent tool. Per-row Policy gate
     * matches the legacy foreach.
     *
     * @param  list<string>  $subscriberUids
     * @return int Number of subscribers actually deleted (after Policy filter).
     */
    public function deleteForUser(User $user, MailList $list, array $subscriberUids): int
    {
        if (empty($subscriberUids)) {
            return 0;
        }

        $deleted = 0;
        $subscribers = $list->subscribers()->whereIn('uid', $subscriberUids)->get();

        foreach ($subscribers as $subscriber) {
            if (Gate::forUser($user)->allows('delete', $subscriber)) {
                $subscriber->delete();
                $deleted++;
            }
        }

        if ($deleted > 0) {
            event(new \App\Events\MailListUpdated($list));
        }

        return $deleted;
    }

    /**
     * F5.2 — Add tags to a subscriber, deduped against existing tag set.
     *
     * Wraps `Subscriber::addTags()` (which dispatches SubscribersTagsUpdate
     * + persists) behind a Policy gate. Returns the post-state tag list so
     * the agent receipt can show the actual delta to the user.
     *
     * @param  list<string>  $tags
     * @return list<string> Final tag list on the subscriber after add.
     */
    public function addTagsForUser(User $user, Subscriber $subscriber, array $tags): array
    {
        if (! Gate::forUser($user)->allows('update', $subscriber)) {
            throw new RuntimeException('Subscriber tag update denied by policy.');
        }

        $clean = array_values(array_filter(array_map(
            fn ($t) => is_string($t) ? trim($t) : '',
            $tags,
        ), fn ($t) => $t !== ''));

        if ($clean === []) {
            return $subscriber->getTags();
        }

        $subscriber->addTags($clean);

        return $subscriber->refresh()->getTags();
    }

    /**
     * F5.2 — Remove tags from a subscriber. No-op for tags the subscriber
     * doesn't already carry. Returns the post-state list.
     *
     * @param  list<string>  $tags
     * @return list<string> Final tag list on the subscriber after remove.
     */
    public function removeTagsForUser(User $user, Subscriber $subscriber, array $tags): array
    {
        if (! Gate::forUser($user)->allows('update', $subscriber)) {
            throw new RuntimeException('Subscriber tag update denied by policy.');
        }

        $clean = array_values(array_filter(array_map(
            fn ($t) => is_string($t) ? trim($t) : '',
            $tags,
        ), fn ($t) => $t !== ''));

        if ($clean === []) {
            return $subscriber->getTags();
        }

        $subscriber->removeTags($clean);

        return $subscriber->refresh()->getTags();
    }

    /**
     * F5.2 — Inline batch import. Subscribes each row (idempotent — reuses
     * the existing `$list->subscribe` path so duplicates are upserted
     * instead of erroring) and returns aggregate counts. Use for
     * sub-threshold imports the agent can hold in memory; large CSVs go
     * through `dispatchImportFromCsv` which uses the existing job
     * dispatcher.
     *
     * Each row must include at least `EMAIL`. Other field tags are
     * passed straight through to the list's custom fields. Rows that
     * fail validation (bad email, missing required field) are
     * counted in `failed[]` with the row's index + reason — caller
     * decides whether to surface them all or summarise.
     *
     * @param  list<array<string,mixed>>  $rows
     * @return array{imported:int, failed:list<array{index:int, email:string, reason:string}>}
     */
    public function inlineImport(MailList $list, array $rows): array
    {
        $imported = 0;
        $failed = [];

        foreach ($rows as $i => $row) {
            $email = '';
            if (is_array($row)) {
                $email = is_string($row['EMAIL'] ?? null)
                    ? $row['EMAIL']
                    : (is_string($row['email'] ?? null) ? $row['email'] : '');
            }
            $email = strtolower(trim($email));

            if ($email === '') {
                $failed[] = ['index' => (int) $i, 'email' => '', 'reason' => 'missing_email'];
                continue;
            }

            $payload = is_array($row) ? $row : [];
            // Normalise to upper-case EMAIL key (parent list expects it).
            $payload['EMAIL'] = $email;
            unset($payload['email']);

            $synthetic = new Request();
            $synthetic->replace($payload);

            try {
                [$validator, $subscriber] = $list->subscribe($synthetic, MailList::SOURCE_API);
                if ($validator->fails()) {
                    $failed[] = [
                        'index' => (int) $i,
                        'email' => $email,
                        'reason' => 'validation:'.implode('|', array_keys($validator->errors()->toArray())),
                    ];
                    continue;
                }
                if ($subscriber instanceof Subscriber) {
                    $imported++;
                }
            } catch (\Throwable $e) {
                $failed[] = [
                    'index' => (int) $i,
                    'email' => $email,
                    'reason' => 'exception:'.class_basename($e),
                ];
                Log::channel('ai-agent-actions')->warning('subscriber.inline_import row failed', [
                    'list_uid' => $list->uid,
                    'email' => $email,
                    'error' => $e->getMessage(),
                ]);
            }
        }

        return ['imported' => $imported, 'failed' => $failed];
    }

    /**
     * F5.2 — Persist a CSV string to the list's import temp dir then
     * dispatch the existing ImportSubscribersJob. Mirrors the legacy
     * `SubscriberController::dispatchImportJob` (uploadCsv + dispatchImportJob)
     * with the synthetic-Request gap closed. Returns the JobMonitor.
     *
     * @param  array<string,string>  $mapping  Custom-field column mapping
     *                                         (defaults to EMAIL → first column).
     */
    public function dispatchImportFromCsv(MailList $list, string $csvContent, array $mapping = []): JobMonitor
    {
        $filename = 'import-'.uniqid().'.csv';
        $path = $list->getImportFilePath($filename);

        // Ensure the dir exists (parity with `MailList::uploadCsv`).
        $dir = dirname($path);
        if (! is_dir($dir)) {
            @mkdir($dir, 0775, true);
        }

        if (file_put_contents($path, $csvContent) === false) {
            throw new RuntimeException('Failed to write CSV to import temp dir.');
        }
        @chmod($path, 0775);

        if ($mapping === []) {
            $mapping = ['EMAIL' => 'EMAIL']; // safe default — the job autoresolves headers
        }

        return $list->dispatchImportJob($path, $mapping);
    }

    /**
     * F5.2 — Dispatch an export job. Mirrors
     * `SubscriberController::dispatchExportJob` ($list->dispatchExportJob).
     * Returns the JobMonitor so the agent can emit a polling deep link.
     */
    public function dispatchExport(MailList $list, ?Segment $segment = null): JobMonitor
    {
        return $list->dispatchExportJob($segment);
    }
}