[a-z0-9-]{1,64})'; public const FORMAT = 'wconvert-occasion'; public function __construct( private readonly OptinRepository $optins, private readonly OccasionStore $occasions, private readonly TemplateLibrary $library, private readonly TemplateVocabulary $vocabulary, private readonly OptinConfigBuilder $builder, ) { } public function registerRoutes(): void { register_rest_route(Routes::NAMESPACE, '/occasions', [ 'methods' => 'GET', 'callback' => fn (): WP_REST_Response => new WP_REST_Response($this->occasions->read()), 'permission_callback' => [Routes::class, 'canManage'], ]); register_rest_route(Routes::NAMESPACE, '/occasions/' . self::ID . '/copy', [ 'methods' => 'POST', 'callback' => [$this, 'copy'], 'permission_callback' => [Routes::class, 'canManage'], ]); register_rest_route(Routes::NAMESPACE, '/occasions/' . self::ID . '/export', [ 'methods' => 'GET', 'callback' => [$this, 'export'], 'permission_callback' => [Routes::class, 'canManage'], ]); register_rest_route(Routes::NAMESPACE, '/occasions/import', [ 'methods' => 'POST', 'callback' => [$this, 'import'], 'permission_callback' => [Routes::class, 'canManage'], 'args' => ['document' => ['required' => true, 'type' => 'object']], ]); } /** The occasion a year later, with a draft of each of its campaigns. */ public function copy(WP_REST_Request $request): WP_REST_Response|WP_Error { $source = $this->occasions->find((string) $request->get_param('id')); if ($source === null) return self::notFound(); $year = (int) substr($source['start'], 0, 4); $occasion = [ 'id' => (string) substr(NextYear::name(substr($source['id'], 0, 59), $year, '-'), 0, 64), 'name' => NextYear::name(mb_substr($source['name'], 0, 115), $year), 'start' => (string) NextYear::day($source['start']), 'end' => (string) NextYear::day($source['end']), ]; $occasion = $this->occasions->add($occasion); if ($occasion instanceof WP_Error) return $occasion; $created = []; $failed = []; foreach ($this->campaignsOf($source['id']) as $campaign) { $config = $campaign->config; foreach (['starts_at', 'ends_at'] as $key) { $shifted = NextYear::wall(is_string($config[$key] ?? null) ? $config[$key] : null); if ($shifted === null) unset($config[$key]); else $config[$key] = $shifted; } // As Duplicate does: a label names one campaign in someone's reports. if (is_array($config['analytics'] ?? null)) $config['analytics']['label'] = ''; $config['occasion'] = $occasion['id']; $name = NextYear::name($campaign->name, $year, null); $result = self::createDraft($name, $campaign->goal, $config); if (is_string($result)) $failed[] = ['name' => $name, 'message' => $result]; else $created[] = $result; } return new WP_REST_Response(['occasion' => $occasion, 'created' => $created, 'failed' => $failed], 201); } /** One occasion and its campaigns as the document ADR 0153 describes. */ public function export(WP_REST_Request $request): WP_REST_Response|WP_Error { $occasion = $this->occasions->find((string) $request->get_param('id')); if ($occasion === null) return self::notFound(); $campaigns = []; foreach ($this->campaignsOf($occasion['id']) as $campaign) { $config = $campaign->config; $tree = $config['template']['tree'] ?? null; $design = is_string($config['template_id'] ?? null) && $this->library->find($config['template_id']) !== null ? $config['template_id'] : null; $entry = [ 'name' => $campaign->name, 'goal' => $campaign->goal, 'display_type' => (DisplayType::of($config['display_type'] ?? null) ?? DisplayType::Popup)->value, 'design' => $design, 'copy' => is_array($tree) ? SlotRoles::copyFrom($tree, $this->vocabulary) : [], 'link' => OptinConfigBuilder::linkIn($config), ]; // A design the library does not hold cannot be named, so it travels whole. if ($design === null && is_array($config['template'] ?? null)) $entry['template'] = $config['template']; foreach (['targeting', 'frequency', 'display_rules'] as $key) { if (isset($config[$key])) $entry[$key] = $config[$key]; } $entry['starts_at'] = $config['starts_at'] ?? null; $entry['ends_at'] = $config['ends_at'] ?? null; $campaigns[] = $entry; } return new WP_REST_Response(['format' => self::FORMAT, 'schema' => 1, 'occasion' => $occasion, 'campaigns' => $campaigns]); } /** * Create the occasion and a draft of each campaign the document carries. * * The whole document is checked before anything is written: a document * with a mistake in its fourth campaign leaves no occasion behind with * three of them in it. What CAN refuse one campaign alone is what this * site cannot hold — a Pro display type on free, a design this library * does not have — and those campaigns are named in the reply. */ public function import(WP_REST_Request $request): WP_REST_Response|WP_Error { $document = $request->get_param('document'); $errors = is_array($document) ? self::errorsIn($document) : ['document' => __('This is not an occasion file.', 'wconvert')]; if ($errors !== []) { return new WP_Error('wconvert_occasion_invalid', __('This file cannot be imported. Nothing was changed.', 'wconvert'), ['status' => 422, 'errors' => $errors]); } /** @var array{occasion: array, campaigns: list>} $document */ $occasion = $this->occasions->add(array_intersect_key($document['occasion'], array_flip(['id', 'name', 'start', 'end']))); if ($occasion instanceof WP_Error) return $occasion; $created = []; $refused = []; foreach ($document['campaigns'] as $campaign) { $config = $this->builder->build($campaign, $occasion['id']); $result = is_string($config) ? $config : self::createDraft($campaign['name'], $campaign['goal'], $config); if (is_string($result)) $refused[] = ['name' => $campaign['name'], 'reason' => $result]; else $created[] = $result; } // Nothing came across, so nothing stays: an empty occasion is not // what the file described. Its id retires, and nothing links to it. if ($created === [] && $refused !== []) { $id = $occasion['id']; $this->occasions->write(static fn (array $items): array => array_values(array_filter($items, static fn (array $item): bool => $item['id'] !== $id))); return new WP_Error('wconvert_occasion_nothing_imported', __('No campaign in this file can be imported here. Nothing was changed.', 'wconvert'), ['status' => 422, 'refused' => $refused]); } return new WP_REST_Response(['occasion' => $occasion, 'created' => $created, 'refused' => $refused], 201); } /** * Where a document is wrong, keyed by the path to the value. * * @param array $document * @return array */ public static function errorsIn(array $document): array { $errors = []; if (($document['format'] ?? null) !== self::FORMAT || ($document['schema'] ?? null) !== 1) { return ['format' => __('This is not an occasion file this version can read.', 'wconvert')]; } try { \WConvert\Discovery\PickerDocuments::validateOccasions([$document['occasion'] ?? null]); } catch (\InvalidArgumentException $invalid) { $errors['occasion'] = $invalid->getMessage(); } $campaigns = $document['campaigns'] ?? null; if (!is_array($campaigns) || !array_is_list($campaigns) || count($campaigns) > 50) { return $errors + ['campaigns' => __('List at most 50 campaigns.', 'wconvert')]; } foreach ($campaigns as $index => $campaign) { $at = 'campaigns.' . $index; if (!is_array($campaign)) { $errors[$at] = __('Invalid campaign.', 'wconvert'); continue; } $name = $campaign['name'] ?? null; if (!is_string($name) || trim($name) === '' || mb_strlen($name) > 190) $errors[$at . '.name'] = __('Give each campaign a name under 190 characters.', 'wconvert'); if (\WConvert\Goal\Goal::tryFrom(is_string($campaign['goal'] ?? null) ? $campaign['goal'] : '') === null) $errors[$at . '.goal'] = __('Name a goal WConvert has.', 'wconvert'); if (DisplayType::of($campaign['display_type'] ?? null) === null) $errors[$at . '.display_type'] = __('Use a known display type.', 'wconvert'); $design = $campaign['design'] ?? null; if ($design !== null && (!is_string($design) || !preg_match('/^[a-z0-9-]{1,100}$/D', $design))) $errors[$at . '.design'] = __('Name a design by its id.', 'wconvert'); if ($design === null && !is_array($campaign['template'] ?? null)) $errors[$at . '.design'] = __('Name a design, or include its template.', 'wconvert'); if (isset($campaign['copy']) && (!is_array($campaign['copy']) || ($campaign['copy'] !== [] && array_is_list($campaign['copy'])) || array_filter($campaign['copy'], static fn ($words) => !is_string($words) && !is_array($words)) !== [])) $errors[$at . '.copy'] = __('Copy is a map of words by role.', 'wconvert'); if (isset($campaign['link']) && (!is_string($campaign['link']) || strlen($campaign['link']) > 2000)) $errors[$at . '.link'] = __('Use a link of up to 2000 characters.', 'wconvert'); foreach (['targeting', 'frequency', 'display_rules'] as $key) { if (isset($campaign[$key]) && !is_array($campaign[$key])) $errors[$at . '.' . $key] = __('Invalid campaign settings.', 'wconvert'); } foreach (['starts_at', 'ends_at'] as $key) { $value = $campaign[$key] ?? null; if ($value !== null && (!is_string($value) || !self::isWallTime($value))) $errors[$at . '.' . $key] = __('Use a date and time like 2026-11-20 00:00.', 'wconvert'); } } return $errors; } /** * The campaigns an occasion holds: parents only, as Duplicate copies no arm. * * @return list */ private function campaignsOf(string $occasion): array { $found = []; foreach ($this->optins->summaries() as $row) { if (($row['occasion'] ?? null) !== $occasion) continue; $optin = $this->optins->find((string) $row['id']); if ($optin !== null && $optin->deletedAt === null && $optin->parentId === null) $found[] = $optin; } return array_reverse($found); } /** * Create one draft through the editor's own route. * * @param array $config * @return array{id: string, name: string}|string The draft, or why it was refused. */ private static function createDraft(string $name, string $goal, array $config): array|string { $result = InternalRest::run('POST', '/optins', ['name' => $name, 'goal' => $goal, 'config' => $config]); $data = $result['data']; if (InternalRest::failed($result) || !is_array($data) || !is_string($data['id'] ?? null)) { return InternalRest::message($result, __('This campaign could not be created.', 'wconvert')); } return ['id' => $data['id'], 'name' => (string) ($data['name'] ?? $name)]; } private static function isWallTime(string $value): bool { $parsed = \DateTimeImmutable::createFromFormat('!' . Schedule::FORMAT, $value); return $parsed !== false && $parsed->format(Schedule::FORMAT) === $value; } private static function notFound(): WP_Error { return new WP_Error('wconvert_occasion_not_found', __('No such occasion.', 'wconvert'), ['status' => 404]); } }