File: /home/xedaptot/ai.naniguide.com/app/Services/Segment/SegmentOperatorCatalog.php
<?php
namespace App\Services\Segment;
/**
* F4.2 — single source of truth for segment operator metadata. Mirrors the
* scattered static helpers on `App\Model\Segment` (`operators()`,
* `dateOperators()`, `verificationOperators()`, `tagOperators()`, etc.) but
* carries richer descriptive metadata used by `segment.explain_operator` +
* `segment.build_from_nl` heuristics. The legacy model methods stay as the
* shim the wizard renders dropdowns from; the catalog is the agent-facing
* presentation layer.
*
* Read-only. Static. Zero injection. Adds zero coupling to the legacy model.
*/
class SegmentOperatorCatalog
{
/**
* Operator → metadata mapping. `family` matches the entity-list shape in
* `segment.json` so the UI / AI surface can group operators
* consistently.
*
* @return array<string, array{family:string, description:string, accepts:string, applies_to:list<string>, examples:list<string>}>
*/
public static function map(): array
{
return [
// Generic equality
'equal' => [
'family' => 'generic-equality',
'description' => 'Exact match. Case-insensitive on text fields, numeric on number-typed fields.',
'accepts' => 'string|number',
'applies_to' => ['text', 'number', 'select'],
'examples' => ["country equal 'Vietnam'", "age equal 21"],
],
'not_equal' => [
'family' => 'generic-equality',
'description' => 'Negation of `equal`. Matches subscribers whose value differs (or is NULL).',
'accepts' => 'string|number',
'applies_to' => ['text', 'number', 'select'],
'examples' => ["status not_equal 'unsubscribed'"],
],
// Substring
'contains' => [
'family' => 'generic-substring',
'description' => 'Substring match anywhere in the value. Case-insensitive.',
'accepts' => 'string',
'applies_to' => ['text'],
'examples' => ["email contains '@gmail'"],
],
'not_contains' => [
'family' => 'generic-substring',
'description' => 'Field does NOT contain the substring (NULL also matches per Acelle convention).',
'accepts' => 'string',
'applies_to' => ['text'],
'examples' => ["bio not_contains 'spam'"],
],
'starts' => [
'family' => 'generic-substring',
'description' => 'Value begins with the given prefix. Case-insensitive.',
'accepts' => 'string',
'applies_to' => ['text'],
'examples' => ["first_name starts 'Mar'"],
],
'starts_with' => [
'family' => 'generic-substring',
'description' => 'Synonym for `starts`. Some Acelle versions emit either form.',
'accepts' => 'string',
'applies_to' => ['text'],
'examples' => ["first_name starts_with 'Mar'"],
],
'ends' => [
'family' => 'generic-substring',
'description' => 'Value ends with the given suffix. Case-insensitive.',
'accepts' => 'string',
'applies_to' => ['text'],
'examples' => ["email ends '@gmail.com'"],
],
'ends_with' => [
'family' => 'generic-substring',
'description' => 'Synonym for `ends`. Some Acelle versions emit either form.',
'accepts' => 'string',
'applies_to' => ['text'],
'examples' => ["email ends_with '@gmail.com'"],
],
'not_starts' => [
'family' => 'generic-substring',
'description' => 'Value does NOT begin with the given prefix.',
'accepts' => 'string',
'applies_to' => ['text'],
'examples' => ["email not_starts 'no-reply@'"],
],
'not_ends' => [
'family' => 'generic-substring',
'description' => 'Value does NOT end with the given suffix.',
'accepts' => 'string',
'applies_to' => ['text'],
'examples' => ["email not_ends '@example.com'"],
],
// Comparison
'greater' => [
'family' => 'generic-comparison',
'description' => 'Numeric or lexicographic greater-than. Number-typed fields cast to SIGNED INT.',
'accepts' => 'number|string',
'applies_to' => ['number', 'text'],
'examples' => ["age greater 21"],
],
'greater_than' => [
'family' => 'generic-comparison',
'description' => 'Synonym for `greater`.',
'accepts' => 'number|string',
'applies_to' => ['number', 'text'],
'examples' => ["score greater_than 80"],
],
'less' => [
'family' => 'generic-comparison',
'description' => 'Numeric or lexicographic less-than. Number-typed fields cast to SIGNED INT.',
'accepts' => 'number|string',
'applies_to' => ['number', 'text'],
'examples' => ["age less 18"],
],
'less_than' => [
'family' => 'generic-comparison',
'description' => 'Synonym for `less`.',
'accepts' => 'number|string',
'applies_to' => ['number', 'text'],
'examples' => ["score less_than 50"],
],
// Blank
'blank' => [
'family' => 'generic-blank',
'description' => 'Field is empty or NULL.',
'accepts' => 'none',
'applies_to' => ['text', 'number', 'select'],
'examples' => ["first_name blank"],
],
'not_blank' => [
'family' => 'generic-blank',
'description' => 'Field has a non-empty value.',
'accepts' => 'none',
'applies_to' => ['text', 'number', 'select'],
'examples' => ["industry not_blank"],
],
// Engagement — open
'last_open_email_less_than_days' => [
'family' => 'engagement-open',
'description' => 'Subscriber\'s most recent email open is within the past N days. Joins `tracking_logs` + `open_logs`.',
'accepts' => 'integer (day count)',
'applies_to' => ['last_open_email'],
'examples' => ["last_open_email less_than_days 7", "last_open_email less_than_days 30"],
],
'last_open_email_greater_than_days' => [
'family' => 'engagement-open',
'description' => 'Subscriber\'s most recent email open is OLDER than N days, OR they have never opened. Useful for dormant detection.',
'accepts' => 'integer (day count)',
'applies_to' => ['last_open_email'],
'examples' => ["last_open_email greater_than_days 90"],
],
// Engagement — click
'last_link_click_less_than_days' => [
'family' => 'engagement-click',
'description' => 'Subscriber\'s most recent click is within the past N days. Joins `tracking_logs` + `click_logs`.',
'accepts' => 'integer (day count)',
'applies_to' => ['last_link_click'],
'examples' => ["last_link_click less_than_days 14"],
],
'last_link_click_greater_than_days' => [
'family' => 'engagement-click',
'description' => 'Subscriber\'s most recent click is OLDER than N days OR they have never clicked.',
'accepts' => 'integer (day count)',
'applies_to' => ['last_link_click'],
'examples' => ["last_link_click greater_than_days 60"],
],
// Tag membership
'tag_contains' => [
'family' => 'tag-membership',
'description' => 'Subscriber has the named tag (case-sensitive match against `subscribers.tags` JSON column).',
'accepts' => 'string (tag name)',
'applies_to' => ['tags'],
'examples' => ["tag_contains 'VIP'"],
],
'tag_not_contains' => [
'family' => 'tag-membership',
'description' => 'Subscriber does NOT have the named tag.',
'accepts' => 'string (tag name)',
'applies_to' => ['tags'],
'examples' => ["tag_not_contains 'churned'"],
],
// Verification
'verification_equal' => [
'family' => 'verification-status',
'description' => 'Email Verification Status (EVS plugin) equals the given value.',
'accepts' => 'string (verified|unverified|disposable|risky|unknown)',
'applies_to' => ['verification_status'],
'examples' => ["verification_equal 'verified'"],
],
'verification_not_equal' => [
'family' => 'verification-status',
'description' => 'Email Verification Status differs from the given value (NULL also matches).',
'accepts' => 'string',
'applies_to' => ['verification_status'],
'examples' => ["verification_not_equal 'disposable'"],
],
// Created date
'created_date_greater' => [
'family' => 'created-date',
'description' => 'Subscriber\'s sign-up timestamp is AFTER the given datetime.',
'accepts' => 'datetime (Y-m-d H:i:s)',
'applies_to' => ['created_at'],
'examples' => ["created_date_greater '2026-01-01 00:00:00'"],
],
'created_date_less' => [
'family' => 'created-date',
'description' => 'Subscriber\'s sign-up timestamp is BEFORE the given datetime.',
'accepts' => 'datetime (Y-m-d H:i:s)',
'applies_to' => ['created_at'],
'examples' => ["created_date_less '2026-04-01 00:00:00'"],
],
'created_date_last_x_days' => [
'family' => 'created-date',
'description' => 'Subscriber signed up within the last N days. Convenience operator — Acelle internally translates to `UNIX_TIMESTAMP(created_at) >= now() - N*86400`.',
'accepts' => 'integer (day count)',
'applies_to' => ['created_at'],
'examples' => ["created_date_last_x_days 14"],
],
];
}
/**
* Look up a single operator's metadata. Returns null when unknown — caller
* decides whether to throw or return a graceful fallback.
*
* @return array{family:string, description:string, accepts:string, applies_to:list<string>, examples:list<string>}|null
*/
public static function get(string $operatorCode): ?array
{
$map = self::map();
return $map[$operatorCode] ?? null;
}
/**
* All operator codes the catalog knows about. Used by the
* `segment.explain_operator` enum hint + Pest reflection tests.
*
* @return list<string>
*/
public static function knownOperators(): array
{
return array_keys(self::map());
}
/**
* Returns the family bucket every operator code belongs to. Mirrors the
* `entities.operator_families[].id` keys in `segment.json`.
*
* @return list<string>
*/
public static function knownFamilies(): array
{
return [
'generic-equality',
'generic-substring',
'generic-comparison',
'generic-blank',
'engagement-open',
'engagement-click',
'tag-membership',
'verification-status',
'created-date',
];
}
}