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);
}
}