From 5363fe15a4704d069179bf7f445e85931ba3f4c2 Mon Sep 17 00:00:00 2001 From: DanMat Date: Fri, 4 Sep 2026 17:11:30 -0400 Subject: [PATCH] Slice 5: Tags + filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds normalized, polymorphic tags — the last CRM slice. - Schema: crm_tag + crm_taggable (005_tags). Normalized rather than a JSON column so "every contact tagged X" is a real indexed query. Tag name unique (case-insensitive); a (tag, subject) link is unique so tagging twice is a no-op. - Tags service: allow-listed + existence-checked subject type (no dangling links, never interpolated); unique names; findOrCreate; idempotent attach/detach; tagsFor / idsFor (the filter); clearFor (called from each entity's delete); delete-a-tag clears links but keeps subjects (transaction). - Cross-entity delete: deleting a contact/org/deal now also clears its tag links. - MCP: crm_tags / _create / _delete / _attach / _detach / _tags_for / _tagged (the last resolves "all contacts tagged X" to full records), all on the wildcard-immune capability. - Admin: a Tags management page (list with usage counts, create/rename/ delete); a reusable tag block (removable chips + add-existing-or-new) on every record edit page; and a tag filter bar on every list/board that filters to "everything tagged X". Shared tag-attach/tag-detach actions across crm/crm-organizations/crm-deals. - Guide: tags section. - Tests: TagsTest, TagsAdminTest; per-entity tag-link-cleared-on-delete; CrmToolset tag tools + gating + cross-type + resolve; all setups build + truncate the tag tables. Co-Authored-By: Claude Opus 4.8 --- src/Contacts.php | 4 + src/ContactsAdmin.php | 14 +- src/CrmPlugin.php | 81 +++++++++- src/CrmToolset.php | 162 ++++++++++++++++++++ src/Deals.php | 4 + src/DealsAdmin.php | 47 ++++-- src/Guide.php | 33 +++- src/Organizations.php | 4 + src/OrganizationsAdmin.php | 14 +- src/Schema.php | 36 +++++ src/Tags.php | 249 +++++++++++++++++++++++++++++++ src/TagsAdmin.php | 214 ++++++++++++++++++++++++++ tests/ContactsAdminTest.php | 6 +- tests/ContactsTest.php | 19 ++- tests/CrmToolsetTest.php | 70 ++++++++- tests/DealsAdminTest.php | 6 +- tests/DealsTest.php | 17 ++- tests/OrganizationsAdminTest.php | 9 +- tests/OrganizationsTest.php | 17 ++- tests/TagsAdminTest.php | 89 +++++++++++ tests/TagsTest.php | 168 +++++++++++++++++++++ 21 files changed, 1230 insertions(+), 33 deletions(-) create mode 100644 src/Tags.php create mode 100644 src/TagsAdmin.php create mode 100644 tests/TagsAdminTest.php create mode 100644 tests/TagsTest.php diff --git a/src/Contacts.php b/src/Contacts.php index b193780..9ddefc0 100644 --- a/src/Contacts.php +++ b/src/Contacts.php @@ -137,6 +137,10 @@ public function delete(int $id): int ); // A deal outlives the person, but must not dangle: clear the link. $this->storage()->execute('UPDATE ' . Schema::DEAL . ' SET contact_id = NULL WHERE contact_id = :id', ['id' => $id]); + $this->storage()->execute( + 'DELETE FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND taggable_id = :id', + ['type' => Activities::SUBJECT_CONTACT, 'id' => $id], + ); return $this->storage()->execute('DELETE FROM ' . Schema::CONTACT . ' WHERE id = :id', ['id' => $id]); }); } diff --git a/src/ContactsAdmin.php b/src/ContactsAdmin.php index 1000f56..d3a6590 100644 --- a/src/ContactsAdmin.php +++ b/src/ContactsAdmin.php @@ -18,9 +18,12 @@ final class ContactsAdmin 'deleted' => ['ok', 'Contact deleted.'], 'activity' => ['ok', 'Activity logged.'], 'activitygone' => ['ok', 'Activity deleted.'], + 'tagged' => ['ok', 'Tag added.'], + 'untagged' => ['ok', 'Tag removed.'], 'bademail' => ['err', 'That email address is not valid.'], 'noname' => ['err', 'A contact needs a first or last name.'], 'activitybad' => ['err', 'Could not log that activity — check the details.'], + 'tagbad' => ['err', 'Could not add that tag — check the details.'], 'invalid' => ['err', 'Check the details and try again.'], ]; @@ -28,6 +31,7 @@ public function __construct( private Contacts $contacts, private Organizations $organizations, private Activities $activities, + private Tags $tags, ) { } @@ -37,13 +41,19 @@ public function __construct( * @param ?string $edit a contact id to load into the form (from ?edit=) * @param ?string $q a search term (from ?q=) * @param string $nonce the request CSP nonce + * @param ?string $tag a tag id to filter the list by (from ?tag=) */ - public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = ''): string + public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = '', ?string $tag = null): string { $editId = ($edit !== null && preg_match('/^\d+$/', trim($edit)) === 1) ? (int) trim($edit) : null; $editContact = $editId !== null ? $this->contacts->get($editId) : null; $q = $q !== null ? trim($q) : ''; + $tagId = ($tag !== null && preg_match('/^\d+$/', trim($tag)) === 1) ? (int) trim($tag) : null; $contacts = $this->contacts->all($q === '' ? null : $q); + if ($tagId !== null) { + $ids = $this->tags->idsFor(Activities::SUBJECT_CONTACT, $tagId); + $contacts = array_values(array_filter($contacts, static fn (array $c): bool => in_array((int) $c['id'], $ids, true))); + } $html = $this->styles($nonce) . '

Contacts

' @@ -53,7 +63,9 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit $html .= $this->form($csrf, $editContact); if ($editContact !== null) { $html .= ActivitiesAdmin::render($csrf, 'crm', Activities::SUBJECT_CONTACT, (int) $editContact['id'], $this->activities->forSubject(Activities::SUBJECT_CONTACT, (int) $editContact['id']), $nonce); + $html .= TagsAdmin::block($csrf, 'crm', Activities::SUBJECT_CONTACT, (int) $editContact['id'], $this->tags->tagsFor(Activities::SUBJECT_CONTACT, (int) $editContact['id']), $this->tags->allTags(), $nonce); } + $html .= TagsAdmin::filterBar('crm', $this->tags->allTags(), $tagId); $html .= $this->list($csrf, $contacts, $q, $editId); return $html; diff --git a/src/CrmPlugin.php b/src/CrmPlugin.php index 484478f..f551da9 100644 --- a/src/CrmPlugin.php +++ b/src/CrmPlugin.php @@ -19,7 +19,7 @@ * touching no core data; no public/site surface at all. * * Slice 1: contacts. Slice 2: organizations + the contact→org link. Slice 3: the - * activity timeline. Slice 4: the deal pipeline. + * activity timeline. Slice 4: the deal pipeline. Slice 5: tags + filtering. */ final class CrmPlugin implements Plugin { @@ -32,6 +32,7 @@ public function register(PluginContext $context): void $context->migrations()->register('002_organizations', Schema::organizations()); $context->migrations()->register('003_activities', Schema::activities()); $context->migrations()->register('004_deals', Schema::deals()); + $context->migrations()->register('005_tags', Schema::tags()); // Grantable, wildcard-immune: nimbuscms.crm:read / :write. Contact data is // PII — a content *:write token can never read or change it. @@ -43,9 +44,10 @@ public function register(PluginContext $context): void $organizations = new Organizations($storage); $activities = new Activities($storage); $deals = new Deals($storage); + $tags = new Tags($storage); // The agent surface — every tool gates on nimbuscms.crm:read|write (ADR 0016). - $context->mcp()->register(new CrmToolset($contacts, $organizations, $activities, $deals)); + $context->mcp()->register(new CrmToolset($contacts, $organizations, $activities, $deals, $tags)); // Admin: search + list + create/edit form. Gated on nimbuscms.crm:write // (same as the write tools) so a content-only editor can't reach PII; the @@ -54,7 +56,7 @@ public function register(PluginContext $context): void 'crm', 'Contacts', '👥', - static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ContactsAdmin($contacts, $organizations, $activities))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce), + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ContactsAdmin($contacts, $organizations, $activities, $tags))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce, $r->query('tag')), self::ID . ':write', ); @@ -96,7 +98,7 @@ public function register(PluginContext $context): void 'crm-organizations', 'Organizations', '🏢', - static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new OrganizationsAdmin($organizations, $activities))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce), + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new OrganizationsAdmin($organizations, $activities, $tags))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce, $r->query('tag')), self::ID . ':write', ); @@ -132,7 +134,7 @@ public function register(PluginContext $context): void 'crm-deals', 'Deals', '📊', - static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new DealsAdmin($deals, $contacts, $organizations, $activities))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce), + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new DealsAdmin($deals, $contacts, $organizations, $activities, $tags))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce, $r->query('tag')), self::ID . ':write', ); @@ -208,11 +210,80 @@ public function register(PluginContext $context): void return Response::redirect($back($r) . 'ok=activitygone'); }; + // Tags are attached/removed inline on a record; the same two actions serve + // every record page and redirect back via the shared $back helper. + $attachTag = static function (Request $r) use ($tags, $back): Response { + $base = $back($r); + $type = (string) ($r->input('subject_type') ?? ''); + $sid = trim((string) ($r->input('subject_id') ?? '')); + $name = trim((string) ($r->input('tag_name') ?? '')); + $tagIn = trim((string) ($r->input('tag_id') ?? '')); + if ($sid === '' || !ctype_digit($sid)) { + return Response::redirect($base . 'err=tagbad'); + } + try { + $now = date('Y-m-d H:i:s'); + $tagId = $name !== '' ? $tags->findOrCreate($name, $now) : (($tagIn !== '' && ctype_digit($tagIn)) ? (int) $tagIn : 0); + if ($tagId === 0) { + return Response::redirect($base . 'err=tagbad'); + } + $tags->attach($type, (int) $sid, $tagId, $now); + return Response::redirect($base . 'ok=tagged'); + } catch (\Throwable) { + return Response::redirect($base . 'err=tagbad'); + } + }; + + $detachTag = static function (Request $r) use ($tags, $back): Response { + $type = (string) ($r->input('subject_type') ?? ''); + $sid = trim((string) ($r->input('subject_id') ?? '')); + $tagIn = trim((string) ($r->input('tag_id') ?? '')); + if ($sid !== '' && ctype_digit($sid) && $tagIn !== '' && ctype_digit($tagIn)) { + $tags->detach($type, (int) $sid, (int) $tagIn); + } + return Response::redirect($back($r) . 'ok=untagged'); + }; + foreach (['crm', 'crm-organizations', 'crm-deals'] as $page) { $context->adminPages()->action($page, 'activity-add', $addActivity); $context->adminPages()->action($page, 'activity-delete', $deleteActivity); + $context->adminPages()->action($page, 'tag-attach', $attachTag); + $context->adminPages()->action($page, 'tag-detach', $detachTag); } + // Tags management: the shared vocabulary of labels. Same crm:write gate. + $context->adminPages()->register( + 'crm-tags', + 'Tags', + '🏷️', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new TagsAdmin($tags))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $nonce), + self::ID . ':write', + ); + + $context->adminPages()->action('crm-tags', 'tag-save', static function (Request $r) use ($tags): Response { + $name = (string) ($r->input('name') ?? ''); + $idIn = trim((string) ($r->input('id') ?? '')); + $id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null; + try { + $tags->saveTag($id, $name, date('Y-m-d H:i:s')); + return Response::redirect('/admin/crm-tags?ok=saved'); + } catch (\InvalidArgumentException $e) { + $msg = $e->getMessage(); + $code = str_contains($msg, 'already exists') ? 'dupe' : (str_contains($msg, 'name') ? 'noname' : 'invalid'); + return Response::redirect('/admin/crm-tags?err=' . $code); + } catch (\Throwable) { + return Response::redirect('/admin/crm-tags?err=invalid'); + } + }); + + $context->adminPages()->action('crm-tags', 'tag-delete', static function (Request $r) use ($tags): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + $tags->deleteTag((int) $idIn); + } + return Response::redirect('/admin/crm-tags?ok=deleted'); + }); + // Teach an MCP agent how to drive the CRM (ADR 0013). $context->skills()->register('CRM', Guide::text()); } diff --git a/src/CrmToolset.php b/src/CrmToolset.php index ee83972..51bc2a8 100644 --- a/src/CrmToolset.php +++ b/src/CrmToolset.php @@ -31,6 +31,7 @@ public function __construct( private Organizations $organizations, private Activities $activities, private Deals $deals, + private Tags $tags, ) { } @@ -163,6 +164,61 @@ protected function tools(): array 'required' => ['id'], 'properties' => ['id' => ['type' => 'integer', 'description' => 'The deal id.']], ], $this->dealDelete(...)), + + new PluginTool('tags', 'read', 'List every tag with how many records carry it.', [ + 'type' => 'object', 'properties' => new \stdClass(), + ], $this->tags(...)), + + new PluginTool('tag_create', 'write', 'Create a tag by name (or return the existing one with that name).', [ + 'type' => 'object', + 'required' => ['name'], + 'properties' => ['name' => ['type' => 'string', 'description' => 'The tag name.']], + ], $this->tagCreate(...)), + + new PluginTool('tag_delete', 'write', 'Delete a tag by id, removing it from everything it is on (the records are kept).', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The tag id.']], + ], $this->tagDelete(...)), + + new PluginTool('tag_attach', 'write', 'Apply a tag to a contact, organization or deal. Give either tag_id or tag_name (a new name is created).', [ + 'type' => 'object', + 'required' => ['subject_type', 'subject_id'], + 'properties' => [ + 'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'What to tag: "contact", "organization" or "deal".'], + 'subject_id' => ['type' => 'integer', 'description' => 'The existing subject id.'], + 'tag_id' => ['type' => 'integer', 'description' => 'An existing tag id.'], + 'tag_name' => ['type' => 'string', 'description' => 'A tag name (found or created) — an alternative to tag_id.'], + ], + ], $this->tagAttach(...)), + + new PluginTool('tag_detach', 'write', 'Remove a tag from a contact, organization or deal.', [ + 'type' => 'object', + 'required' => ['subject_type', 'subject_id', 'tag_id'], + 'properties' => [ + 'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'The subject: "contact", "organization" or "deal".'], + 'subject_id' => ['type' => 'integer', 'description' => 'The subject id.'], + 'tag_id' => ['type' => 'integer', 'description' => 'The tag id to remove.'], + ], + ], $this->tagDetach(...)), + + new PluginTool('tags_for', 'read', 'The tags on one contact, organization or deal.', [ + 'type' => 'object', + 'required' => ['subject_type', 'subject_id'], + 'properties' => [ + 'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'The subject: "contact", "organization" or "deal".'], + 'subject_id' => ['type' => 'integer', 'description' => 'The subject id.'], + ], + ], $this->tagsFor(...)), + + new PluginTool('tagged', 'read', 'Every record of one type carrying a tag — "all contacts tagged X".', [ + 'type' => 'object', + 'required' => ['subject_type', 'tag_id'], + 'properties' => [ + 'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'Which records: "contact", "organization" or "deal".'], + 'tag_id' => ['type' => 'integer', 'description' => 'The tag id.'], + ], + ], $this->tagged(...)), ]; } @@ -330,6 +386,112 @@ private function dealDelete(array $a, TokenPrincipal $p, EntryOpContext $c): arr return ['ok' => true, 'deleted' => $this->deals->delete($id) > 0]; } + /** + * @param array $a + * @return array + */ + private function tags(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $list = $this->tags->allTags(); + return ['tags' => $list, 'count' => count($list)]; + } + + /** + * @param array $a + * @return array + */ + private function tagCreate(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->tags->findOrCreate((string) ($a['name'] ?? ''), $this->now()); + return ['ok' => true, 'tag' => $this->tags->getTag($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function tagDelete(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['ok' => true, 'deleted' => $this->tags->deleteTag($id)]; + } + + /** + * @param array $a + * @return array + */ + private function tagAttach(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $type = (string) ($a['subject_type'] ?? ''); + $sid = $this->requireInt($a, 'subject_id'); + $tagId = $this->nullableInt($a, 'tag_id'); + $name = $this->nullableStr($a, 'tag_name'); + if ($tagId === null && $name === null) { + throw new \InvalidArgumentException('Give a "tag_id" or a "tag_name".'); + } + if ($tagId === null) { + $tagId = $this->tags->findOrCreate((string) $name, $this->now()); + } + $made = $this->tags->attach($type, $sid, $tagId, $this->now()); + return ['ok' => true, 'attached' => $made, 'tag_id' => $tagId]; + }); + } + + /** + * @param array $a + * @return array + */ + private function tagDetach(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $type = (string) ($a['subject_type'] ?? ''); + $sid = $this->requireInt($a, 'subject_id'); + $tagId = $this->requireInt($a, 'tag_id'); + return ['ok' => true, 'detached' => $this->tags->detach($type, $sid, $tagId) > 0]; + } + + /** + * @param array $a + * @return array + */ + private function tagsFor(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $type = (string) ($a['subject_type'] ?? ''); + $sid = $this->requireInt($a, 'subject_id'); + $list = $this->tags->tagsFor($type, $sid); + return ['tags' => $list, 'count' => count($list)]; + } + + /** + * Every record of one type carrying a tag — the ids resolved to full records so an + * agent gets "all contacts tagged X" in one call. + * + * @param array $a + * @return array + */ + private function tagged(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $type = (string) ($a['subject_type'] ?? ''); + $tagId = $this->requireInt($a, 'tag_id'); + $ids = $this->tags->idsFor($type, $tagId); + + $records = []; + foreach ($ids as $id) { + $record = match ($type) { + Activities::SUBJECT_CONTACT => $this->contacts->get($id), + Activities::SUBJECT_ORGANIZATION => $this->organizations->get($id), + Activities::SUBJECT_DEAL => $this->deals->get($id), + default => null, + }; + if ($record !== null) { + $records[] = $record; + } + } + return ['subject_type' => $type, 'tag_id' => $tagId, 'records' => $records, 'count' => count($records)]; + } + // --- helpers --------------------------------------------------------- /** diff --git a/src/Deals.php b/src/Deals.php index b4195a5..23eb2d3 100644 --- a/src/Deals.php +++ b/src/Deals.php @@ -133,6 +133,10 @@ public function delete(int $id): int 'DELETE FROM ' . Schema::ACTIVITY . ' WHERE subject_type = :type AND subject_id = :id', ['type' => Activities::SUBJECT_DEAL, 'id' => $id], ); + $this->storage()->execute( + 'DELETE FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND taggable_id = :id', + ['type' => Activities::SUBJECT_DEAL, 'id' => $id], + ); return $this->storage()->execute('DELETE FROM ' . Schema::DEAL . ' WHERE id = :id', ['id' => $id]); }); } diff --git a/src/DealsAdmin.php b/src/DealsAdmin.php index 30265af..7679973 100644 --- a/src/DealsAdmin.php +++ b/src/DealsAdmin.php @@ -20,8 +20,11 @@ final class DealsAdmin 'deleted' => ['ok', 'Deal deleted.'], 'activity' => ['ok', 'Activity logged.'], 'activitygone' => ['ok', 'Activity deleted.'], + 'tagged' => ['ok', 'Tag added.'], + 'untagged' => ['ok', 'Tag removed.'], 'notitle' => ['err', 'A deal needs a title.'], 'activitybad' => ['err', 'Could not log that activity — check the details.'], + 'tagbad' => ['err', 'Could not add that tag — check the details.'], 'invalid' => ['err', 'Check the details and try again.'], ]; @@ -37,14 +40,18 @@ public function __construct( private Contacts $contacts, private Organizations $organizations, private Activities $activities, + private Tags $tags, ) { } - public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = ''): string + public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = '', ?string $tag = null): string { $editId = ($edit !== null && preg_match('/^\d+$/', trim($edit)) === 1) ? (int) trim($edit) : null; $editDeal = $editId !== null ? $this->deals->get($editId) : null; $q = $q !== null ? trim($q) : ''; + $tagId = ($tag !== null && preg_match('/^\d+$/', trim($tag)) === 1) ? (int) trim($tag) : null; + // When a tag filter is active, the id set restricts the board and results. + $only = $tagId !== null ? $this->tags->idsFor(Activities::SUBJECT_DEAL, $tagId) : null; $html = $this->styles($nonce) . '

Deals

' @@ -54,14 +61,31 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit if ($editDeal !== null) { $html .= ActivitiesAdmin::render($csrf, 'crm-deals', Activities::SUBJECT_DEAL, (int) $editDeal['id'], $this->activities->forSubject(Activities::SUBJECT_DEAL, (int) $editDeal['id']), $nonce); + $html .= TagsAdmin::block($csrf, 'crm-deals', Activities::SUBJECT_DEAL, (int) $editDeal['id'], $this->tags->tagsFor(Activities::SUBJECT_DEAL, (int) $editDeal['id']), $this->tags->allTags(), $nonce); } $html .= $this->search($q); - $html .= $q !== '' ? $this->results($csrf, $q) : $this->board($csrf); + $html .= TagsAdmin::filterBar('crm-deals', $this->tags->allTags(), $tagId); + $html .= $q !== '' ? $this->results($csrf, $q, $only) : $this->board($csrf, $only); return $html; } + /** + * Keep only rows whose id is in `$only`; a null filter keeps everything. + * + * @param list> $rows + * @param list|null $only + * @return list> + */ + private function restrict(array $rows, ?array $only): array + { + if ($only === null) { + return $rows; + } + return array_values(array_filter($rows, static fn (array $d): bool => in_array((int) $d['id'], $only, true))); + } + /** @param array|null $edit */ private function form(string $csrf, ?array $edit): string { @@ -128,9 +152,10 @@ private function search(string $q): string . ''; } - private function results(string $csrf, string $q): string + /** @param list|null $only */ + private function results(string $csrf, string $q, ?array $only): string { - $deals = $this->deals->all($q); + $deals = $this->restrict($this->deals->all($q), $only); if ($deals === []) { return '

No deals match “' . self::e($q) . '”.

'; } @@ -141,9 +166,10 @@ private function results(string $csrf, string $q): string return '
    ' . $cards . '
'; } - private function board(string $csrf): string + /** @param list|null $only */ + private function board(string $csrf, ?array $only): string { - $open = $this->deals->all(null, 'open'); + $open = $this->restrict($this->deals->all(null, 'open'), $only); $byStage = []; foreach (Deals::STAGES as $s) { @@ -174,13 +200,14 @@ private function board(string $csrf): string . ''; } - return '
' . $cols . '
' . $this->closed($csrf); + return '
' . $cols . '
' . $this->closed($csrf, $only); } - private function closed(string $csrf): string + /** @param list|null $only */ + private function closed(string $csrf, ?array $only): string { - $won = $this->deals->all(null, 'won'); - $lost = $this->deals->all(null, 'lost'); + $won = $this->restrict($this->deals->all(null, 'won'), $only); + $lost = $this->restrict($this->deals->all(null, 'lost'), $only); if ($won === [] && $lost === []) { return ''; } diff --git a/src/Guide.php b/src/Guide.php index 7d43f44..dd4637e 100644 --- a/src/Guide.php +++ b/src/Guide.php @@ -17,11 +17,11 @@ public static function text(): string # CRM A back-office CRM: **contacts** (people), the **organizations** (companies) they - belong to, an **activity timeline** against any of them, and a **deal pipeline**. - It is **PII**, so every tool is gated by the `nimbuscms.crm` capability: a read - needs `nimbuscms.crm:read`, a write needs `nimbuscms.crm:write`. A content - `*:write` token cannot reach it, and a tool you lack the capability for is - invisible. + belong to, an **activity timeline** against any of them, a **deal pipeline**, and + **tags** you apply and filter by. It is **PII**, so every tool is gated by the + `nimbuscms.crm` capability: a read needs `nimbuscms.crm:read`, a write needs + `nimbuscms.crm:write`. A content `*:write` token cannot reach it, and a tool you + lack the capability for is invisible. ## Contacts @@ -80,8 +80,27 @@ public static function text(): string - `crm_deal_delete` — remove a deal by `id`, together with its activities. Deleting a contact or organization keeps any deal that referenced it — the link - is simply cleared. Values are stored as you send them and escaped when displayed; - there is no public page for CRM data. + is simply cleared. + + ## Tags + + Labels applied to any contact, organization or deal, so you can group and filter + them ("all contacts tagged VIP"). + + - `crm_tags` — list every tag with its usage count. + - `crm_tag_create` — create a tag by `name` (or return the one that already has + that name). + - `crm_tag_delete` — delete a tag by `id`; it is removed from everything it is + on, but the records are kept. + - `crm_tag_attach` — apply a tag to a subject: `subject_type` + (`contact`/`organization`/`deal`) + `subject_id`, and either an existing + `tag_id` or a `tag_name` (found or created). Idempotent. + - `crm_tag_detach` — remove a tag (`tag_id`) from a subject. + - `crm_tags_for` — the tags on one subject. + - `crm_tagged` — every record of a type carrying a tag ("all contacts tagged X"). + + Deleting a contact, organization or deal removes its tag links. Values are stored + as you send them and escaped when displayed; there is no public page for CRM data. MD; } } diff --git a/src/Organizations.php b/src/Organizations.php index 4598c65..98bf775 100644 --- a/src/Organizations.php +++ b/src/Organizations.php @@ -111,6 +111,10 @@ public function delete(int $id): bool 'DELETE FROM ' . Schema::ACTIVITY . ' WHERE subject_type = :type AND subject_id = :id', ['type' => Activities::SUBJECT_ORGANIZATION, 'id' => $id], ); + $this->storage()->execute( + 'DELETE FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND taggable_id = :id', + ['type' => Activities::SUBJECT_ORGANIZATION, 'id' => $id], + ); return $this->storage()->execute('DELETE FROM ' . Schema::ORGANIZATION . ' WHERE id = :id', ['id' => $id]) > 0; }); } diff --git a/src/OrganizationsAdmin.php b/src/OrganizationsAdmin.php index 3fe2810..043384d 100644 --- a/src/OrganizationsAdmin.php +++ b/src/OrganizationsAdmin.php @@ -17,14 +17,18 @@ final class OrganizationsAdmin 'deleted' => ['ok', 'Organization deleted — its contacts were kept and unlinked.'], 'activity' => ['ok', 'Activity logged.'], 'activitygone' => ['ok', 'Activity deleted.'], + 'tagged' => ['ok', 'Tag added.'], + 'untagged' => ['ok', 'Tag removed.'], 'noname' => ['err', 'An organization needs a name.'], 'activitybad' => ['err', 'Could not log that activity — check the details.'], + 'tagbad' => ['err', 'Could not add that tag — check the details.'], 'invalid' => ['err', 'Check the details and try again.'], ]; public function __construct( private Organizations $organizations, private Activities $activities, + private Tags $tags, ) { } @@ -34,13 +38,19 @@ public function __construct( * @param ?string $edit an organization id to load into the form (from ?edit=) * @param ?string $q a search term (from ?q=) * @param string $nonce the request CSP nonce + * @param ?string $tag a tag id to filter the list by (from ?tag=) */ - public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = ''): string + public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = '', ?string $tag = null): string { $editId = ($edit !== null && preg_match('/^\d+$/', trim($edit)) === 1) ? (int) trim($edit) : null; $editOrg = $editId !== null ? $this->organizations->get($editId) : null; $q = $q !== null ? trim($q) : ''; + $tagId = ($tag !== null && preg_match('/^\d+$/', trim($tag)) === 1) ? (int) trim($tag) : null; $orgs = $this->organizations->all($q === '' ? null : $q); + if ($tagId !== null) { + $ids = $this->tags->idsFor(Activities::SUBJECT_ORGANIZATION, $tagId); + $orgs = array_values(array_filter($orgs, static fn (array $o): bool => in_array((int) $o['id'], $ids, true))); + } return $this->styles($nonce) . '

Organizations

' @@ -48,6 +58,8 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit . '

The companies your contacts belong to. Deleting one keeps its people — they are simply unlinked.

' . $this->form($csrf, $editOrg) . ($editOrg !== null ? ActivitiesAdmin::render($csrf, 'crm-organizations', Activities::SUBJECT_ORGANIZATION, (int) $editOrg['id'], $this->activities->forSubject(Activities::SUBJECT_ORGANIZATION, (int) $editOrg['id']), $nonce) : '') + . ($editOrg !== null ? TagsAdmin::block($csrf, 'crm-organizations', Activities::SUBJECT_ORGANIZATION, (int) $editOrg['id'], $this->tags->tagsFor(Activities::SUBJECT_ORGANIZATION, (int) $editOrg['id']), $this->tags->allTags(), $nonce) : '') + . TagsAdmin::filterBar('crm-organizations', $this->tags->allTags(), $tagId) . $this->list($csrf, $orgs, $q); } diff --git a/src/Schema.php b/src/Schema.php index 5024bc0..0141abc 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -20,6 +20,8 @@ final class Schema public const ORGANIZATION = 'crm_organization'; public const ACTIVITY = 'crm_activity'; public const DEAL = 'crm_deal'; + public const TAG = 'crm_tag'; + public const TAGGABLE = 'crm_taggable'; /** @return list each statement individually idempotent (ADR 0005) */ public static function contacts(): array @@ -123,4 +125,38 @@ public static function deals(): array ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ]; } + + /** + * Tags — a shared vocabulary of labels, and the **normalized, polymorphic** links + * that apply them to a contact, organization or deal (`crm_taggable`). Normalized + * rather than a JSON column so "every contact tagged X" is a real indexed query. + * A tag name is unique (case-insensitive, via the default collation); a link is + * unique per (tag, subject) so tagging twice is a no-op. A subject's delete clears + * its links; deleting a tag clears the links but never the subjects. + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function tags(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::TAG . ' ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(60) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uniq_tag_name (name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + + 'CREATE TABLE IF NOT EXISTS ' . self::TAGGABLE . " ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tag_id BIGINT UNSIGNED NOT NULL, + taggable_type ENUM('contact','organization','deal') NOT NULL, + taggable_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE KEY uniq_link (tag_id, taggable_type, taggable_id), + INDEX idx_taggable (taggable_type, taggable_id), + INDEX idx_tag (tag_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + ]; + } } diff --git a/src/Tags.php b/src/Tags.php new file mode 100644 index 0000000..94a557b --- /dev/null +++ b/src/Tags.php @@ -0,0 +1,249 @@ + Schema::CONTACT, + Activities::SUBJECT_ORGANIZATION => Schema::ORGANIZATION, + Activities::SUBJECT_DEAL => Schema::DEAL, + ]; + + private const MAX_NAME = 60; + + /** @param \Closure():PluginStorage $storage resolved lazily, so construction runs no query */ + public function __construct(private \Closure $storage) + { + } + + // --- tags ------------------------------------------------------------ + + /** + * Create (id null) or rename (id given) a tag. The name is required, capped and + * unique (case-insensitive) — a clash is rejected as data. Returns the tag id. + */ + public function saveTag(?int $id, string $name, string $now): int + { + $name = $this->name($name); + $clash = $this->storage()->selectOne( + 'SELECT id FROM ' . Schema::TAG . ' WHERE name = :name' . ($id !== null ? ' AND id <> :id' : ''), + $id !== null ? ['name' => $name, 'id' => $id] : ['name' => $name], + ); + if ($clash !== null) { + throw new \InvalidArgumentException("A tag named \"{$name}\" already exists."); + } + + if ($id === null) { + return $this->storage()->insert( + 'INSERT INTO ' . Schema::TAG . ' (name, created_at, updated_at) VALUES (:name, :created, :updated)', + ['name' => $name, 'created' => $now, 'updated' => $now], + ); + } + if ($this->getTag($id) === null) { + throw new \InvalidArgumentException("No tag with id {$id}."); + } + $this->storage()->execute( + 'UPDATE ' . Schema::TAG . ' SET name = :name, updated_at = :now WHERE id = :id', + ['name' => $name, 'now' => $now, 'id' => $id], + ); + return $id; + } + + /** Find a tag by name (case-insensitive), or create it. Returns the tag id. */ + public function findOrCreate(string $name, string $now): int + { + $name = $this->name($name); + $row = $this->storage()->selectOne('SELECT id FROM ' . Schema::TAG . ' WHERE name = :name', ['name' => $name]); + if ($row !== null) { + return (int) $row['id']; + } + return $this->storage()->insert( + 'INSERT INTO ' . Schema::TAG . ' (name, created_at, updated_at) VALUES (:name, :created, :updated)', + ['name' => $name, 'created' => $now, 'updated' => $now], + ); + } + + /** + * @return array{id:int,name:string,created_at:string,updated_at:string}|null + */ + public function getTag(int $id): ?array + { + $row = $this->storage()->selectOne('SELECT id, name, created_at, updated_at FROM ' . Schema::TAG . ' WHERE id = :id', ['id' => $id]); + if ($row === null) { + return null; + } + return [ + 'id' => (int) $row['id'], + 'name' => (string) $row['name'], + 'created_at' => (string) $row['created_at'], + 'updated_at' => (string) $row['updated_at'], + ]; + } + + /** + * All tags, name-ordered, each with its total usage count across every subject + * type — for the management page and the filter bars. + * + * @return list + */ + public function allTags(): array + { + $rows = $this->storage()->select( + 'SELECT t.id, t.name, COUNT(tg.id) AS cnt + FROM ' . Schema::TAG . ' t LEFT JOIN ' . Schema::TAGGABLE . ' tg ON tg.tag_id = t.id + GROUP BY t.id, t.name ORDER BY t.name', + ); + return array_map(static fn (array $r): array => [ + 'id' => (int) $r['id'], + 'name' => (string) $r['name'], + 'count' => (int) $r['cnt'], + ], $rows); + } + + /** Delete a tag and every link to it, atomically. Returns true when a tag was removed. */ + public function deleteTag(int $id): bool + { + return (bool) $this->storage()->transaction(function () use ($id): bool { + $this->storage()->execute('DELETE FROM ' . Schema::TAGGABLE . ' WHERE tag_id = :id', ['id' => $id]); + return $this->storage()->execute('DELETE FROM ' . Schema::TAG . ' WHERE id = :id', ['id' => $id]) > 0; + }); + } + + // --- tagging --------------------------------------------------------- + + /** + * Apply a tag to a subject. The subject type is allow-listed and the subject and + * tag must exist. Idempotent: returns true if a new link was made, false if it + * was already tagged. + */ + public function attach(string $type, int $subjectId, int $tagId, string $now): bool + { + $table = $this->table($type); + if ($this->getTag($tagId) === null) { + throw new \InvalidArgumentException("No tag with id {$tagId}."); + } + if ($this->storage()->selectOne('SELECT id FROM ' . $table . ' WHERE id = :id', ['id' => $subjectId]) === null) { + throw new \InvalidArgumentException("No {$type} with id {$subjectId}."); + } + $exists = $this->storage()->selectOne( + 'SELECT id FROM ' . Schema::TAGGABLE . ' WHERE tag_id = :tag AND taggable_type = :type AND taggable_id = :sid', + ['tag' => $tagId, 'type' => $type, 'sid' => $subjectId], + ); + if ($exists !== null) { + return false; + } + $this->storage()->insert( + 'INSERT INTO ' . Schema::TAGGABLE . ' (tag_id, taggable_type, taggable_id, created_at) VALUES (:tag, :type, :sid, :created)', + ['tag' => $tagId, 'type' => $type, 'sid' => $subjectId, 'created' => $now], + ); + return true; + } + + /** Remove a tag from a subject. Returns the number of links removed (0 if it wasn't tagged). */ + public function detach(string $type, int $subjectId, int $tagId): int + { + if (!isset(self::TAGGABLES[$type])) { + return 0; + } + return $this->storage()->execute( + 'DELETE FROM ' . Schema::TAGGABLE . ' WHERE tag_id = :tag AND taggable_type = :type AND taggable_id = :sid', + ['tag' => $tagId, 'type' => $type, 'sid' => $subjectId], + ); + } + + /** + * The tags on one subject, name-ordered. + * + * @return list + */ + public function tagsFor(string $type, int $subjectId): array + { + if (!isset(self::TAGGABLES[$type])) { + return []; + } + $rows = $this->storage()->select( + 'SELECT t.id, t.name FROM ' . Schema::TAGGABLE . ' tg + JOIN ' . Schema::TAG . ' t ON t.id = tg.tag_id + WHERE tg.taggable_type = :type AND tg.taggable_id = :sid ORDER BY t.name', + ['type' => $type, 'sid' => $subjectId], + ); + return array_map(static fn (array $r): array => ['id' => (int) $r['id'], 'name' => (string) $r['name']], $rows); + } + + /** + * The ids of subjects of `$type` carrying `$tagId` — the "everything tagged X" + * filter (a real indexed join). An unknown type yields none. + * + * @return list + */ + public function idsFor(string $type, int $tagId): array + { + if (!isset(self::TAGGABLES[$type])) { + return []; + } + $rows = $this->storage()->select( + 'SELECT taggable_id FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND tag_id = :tag', + ['type' => $type, 'tag' => $tagId], + ); + return array_map(static fn (array $r): int => (int) $r['taggable_id'], $rows); + } + + /** Remove every tag link on a subject — the cleanup an entity's delete calls. Returns links removed. */ + public function clearFor(string $type, int $subjectId): int + { + if (!isset(self::TAGGABLES[$type])) { + return 0; + } + return $this->storage()->execute( + 'DELETE FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND taggable_id = :sid', + ['type' => $type, 'sid' => $subjectId], + ); + } + + // --- validation ------------------------------------------------------ + + private function name(string $name): string + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('A tag needs a name.'); + } + if (mb_strlen($name) > self::MAX_NAME) { + throw new \InvalidArgumentException('A tag name must be ' . self::MAX_NAME . ' characters or fewer.'); + } + return $name; + } + + private function table(string $type): string + { + if (!isset(self::TAGGABLES[$type])) { + throw new \InvalidArgumentException('"type" must be one of: ' . implode(', ', array_keys(self::TAGGABLES)) . '.'); + } + return self::TAGGABLES[$type]; + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/src/TagsAdmin.php b/src/TagsAdmin.php new file mode 100644 index 0000000..4aa3188 --- /dev/null +++ b/src/TagsAdmin.php @@ -0,0 +1,214 @@ +` block. + */ +final class TagsAdmin +{ + private const NOTICES = [ + 'saved' => ['ok', 'Tag saved.'], + 'deleted' => ['ok', 'Tag deleted.'], + 'noname' => ['err', 'A tag needs a name.'], + 'dupe' => ['err', 'A tag with that name already exists.'], + 'invalid' => ['err', 'Check the details and try again.'], + ]; + + public function __construct(private Tags $tags) + { + } + + public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, string $nonce = ''): string + { + $editId = ($edit !== null && preg_match('/^\d+$/', trim($edit)) === 1) ? (int) trim($edit) : null; + $editTag = $editId !== null ? $this->tags->getTag($editId) : null; + $tags = $this->tags->allTags(); + + return $this->styles($nonce) + . '

Tags

' + . $this->notice($notice) + . '

Labels you can apply to contacts, organizations and deals — then filter by. Deleting a tag removes it from everything; the records are kept.

' + . $this->form($csrf, $editTag) + . $this->list($csrf, $tags); + } + + /** @param array|null $edit */ + private function form(string $csrf, ?array $edit): string + { + $val = $edit !== null ? self::e((string) $edit['name']) : ''; + $idField = $edit !== null ? '' : ''; + + return '

' . ($edit !== null ? 'Rename tag' : 'Add a tag') . '

' + . '
' + . '' . $idField + . '' + . '
' + . ($edit !== null ? ' Cancel' : '') + . '
'; + } + + /** @param list $tags */ + private function list(string $csrf, array $tags): string + { + if ($tags === []) { + return '

No tags yet — add one above.

'; + } + $rows = ''; + foreach ($tags as $t) { + $rows .= '' + . '' . self::e($t['name']) . '' + . '' . self::e((string) $t['count']) . '' + . '' + . '
' + . '' + . '' + . '
' + . ''; + } + return '' . $rows . '
TagUsed
'; + } + + /** + * The tag block for a record's edit page. + * + * @param list $on tags already on the subject + * @param list $all every tag (to offer) + */ + public static function block(string $csrf, string $page, string $subjectType, int $subjectId, array $on, array $all, string $nonce): string + { + $chips = ''; + foreach ($on as $t) { + $chips .= '' . self::e($t['name']) + . '
' + . '' + . '' + . '' + . '' + . '
'; + } + if ($chips === '') { + $chips = 'No tags yet.'; + } + + $onIds = array_column($on, 'id'); + $options = ''; + foreach ($all as $t) { + if (in_array($t['id'], $onIds, true)) { + continue; + } + $options .= ''; + } + + return self::blockStyles($nonce) + . '

Tags

' + . '
' . $chips . '
' + . '
' + . '' + . '' + . '' + . '' + . '' + . '' + . '
'; + } + + /** + * The filter chips for a list page. `$activeId` is the tag currently filtered on. + * + * @param list $all + */ + public static function filterBar(string $page, array $all, ?int $activeId): string + { + if ($all === []) { + return ''; + } + $chips = ''; + foreach ($all as $t) { + if ($t['count'] === 0) { + continue; + } + $active = $activeId === $t['id']; + $chips .= '' + . self::e($t['name']) . ' ' . self::e((string) $t['count']) . ''; + } + if ($chips === '') { + return ''; + } + return '
Tags:' . $chips + . ($activeId !== null ? 'Clear' : '') + . '
'; + } + + private function notice(?string $code): string + { + if ($code === null || !isset(self::NOTICES[$code])) { + return ''; + } + [$kind, $message] = self::NOTICES[$code]; + return '
' . self::e($message) . '
'; + } + + private function styles(string $nonce): string + { + return ''; + } + + private static function blockStyles(string $nonce): string + { + return ''; + } + + /** Escape a value for HTML output (the admin CSP is nonce-only; every value is escaped). */ + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/tests/ContactsAdminTest.php b/tests/ContactsAdminTest.php index 241b978..8ea3eec 100644 --- a/tests/ContactsAdminTest.php +++ b/tests/ContactsAdminTest.php @@ -11,6 +11,7 @@ use NimbusCMS\Crm\ContactsAdmin; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; +use NimbusCMS\Crm\Tags; use PHPUnit\Framework\TestCase; /** @@ -34,12 +35,14 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals(), ...Schema::tags()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); + $db->execute('TRUNCATE ' . Schema::TAG); + $db->execute('TRUNCATE ' . Schema::TAGGABLE); $storage = new PluginStorage($db); $this->contacts = new Contacts(static fn (): PluginStorage => $storage); @@ -48,6 +51,7 @@ protected function setUp(): void $this->contacts, new Organizations(static fn (): PluginStorage => $storage), $this->activities, + new Tags(static fn (): PluginStorage => $storage), ); } diff --git a/tests/ContactsTest.php b/tests/ContactsTest.php index 981cbbd..d2c7444 100644 --- a/tests/ContactsTest.php +++ b/tests/ContactsTest.php @@ -11,6 +11,7 @@ use NimbusCMS\Crm\Deals; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; +use NimbusCMS\Crm\Tags; use PHPUnit\Framework\TestCase; /** @@ -26,6 +27,7 @@ final class ContactsTest extends TestCase private Organizations $organizations; private Activities $activities; private Deals $deals; + private Tags $tags; protected function setUp(): void { @@ -36,19 +38,22 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals(), ...Schema::tags()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); $db->execute('TRUNCATE ' . Schema::DEAL); + $db->execute('TRUNCATE ' . Schema::TAG); + $db->execute('TRUNCATE ' . Schema::TAGGABLE); $storage = new PluginStorage($db); $this->contacts = new Contacts(static fn (): PluginStorage => $storage); $this->organizations = new Organizations(static fn (): PluginStorage => $storage); $this->activities = new Activities(static fn (): PluginStorage => $storage); $this->deals = new Deals(static fn (): PluginStorage => $storage); + $this->tags = new Tags(static fn (): PluginStorage => $storage); } private const NOW = '2026-01-01 09:00:00'; @@ -174,6 +179,18 @@ public function test_deleting_a_contact_keeps_its_deals_but_clears_the_link(): v self::assertNull($deal['contact_id'], 'the dangling contact link is cleared'); } + public function test_deleting_a_contact_clears_its_tag_links(): void + { + $id = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $tagId = $this->tags->findOrCreate('VIP', self::NOW); + $this->tags->attach('contact', $id, $tagId, self::NOW); + self::assertCount(1, $this->tags->tagsFor('contact', $id)); + + self::assertSame(1, $this->contacts->delete($id)); + self::assertSame([], $this->tags->idsFor('contact', $tagId), 'no tag residue for the forgotten contact'); + self::assertNotNull($this->tags->getTag($tagId), 'the tag itself survives'); + } + public function test_deleting_an_org_unlinks_its_contacts_but_keeps_them(): void { $orgId = $this->organizations->save(null, ['name' => 'Doomed Co'], self::NOW); diff --git a/tests/CrmToolsetTest.php b/tests/CrmToolsetTest.php index a80c8f1..cadad91 100644 --- a/tests/CrmToolsetTest.php +++ b/tests/CrmToolsetTest.php @@ -16,6 +16,7 @@ use NimbusCMS\Crm\Deals; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; +use NimbusCMS\Crm\Tags; use PHPUnit\Framework\TestCase; /** @@ -39,13 +40,15 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals(), ...Schema::tags()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); $db->execute('TRUNCATE ' . Schema::DEAL); + $db->execute('TRUNCATE ' . Schema::TAG); + $db->execute('TRUNCATE ' . Schema::TAGGABLE); $storage = new PluginStorage($db); $this->toolset = new CrmToolset( @@ -53,6 +56,7 @@ protected function setUp(): void new Organizations(static fn (): PluginStorage => $storage), new Activities(static fn (): PluginStorage => $storage), new Deals(static fn (): PluginStorage => $storage), + new Tags(static fn (): PluginStorage => $storage), ); $this->toolset->bindTo('nimbuscms.crm'); // the registrar does this in prod $this->ctx = new EntryOpContext('127.0.0.1', '/api/v1/mcp'); @@ -80,13 +84,17 @@ public function test_the_tools_are_namespaced_and_split_read_from_write(): void 'crm_organizations', 'crm_organization_get', 'crm_organization_set', 'crm_organization_delete', 'crm_activities', 'crm_activity_add', 'crm_activity_delete', 'crm_deals', 'crm_deal_get', 'crm_deal_set', 'crm_deal_delete', + 'crm_tags', 'crm_tag_create', 'crm_tag_delete', 'crm_tag_attach', 'crm_tag_detach', 'crm_tags_for', 'crm_tagged', ], $names); } public function test_a_read_only_token_sees_only_the_read_tools(): void { $names = array_column($this->toolset->definitions($this->principal('nimbuscms.crm:read')), 'name'); - self::assertSame(['crm_contacts', 'crm_contact_get', 'crm_organizations', 'crm_organization_get', 'crm_activities', 'crm_deals', 'crm_deal_get'], $names); + self::assertSame([ + 'crm_contacts', 'crm_contact_get', 'crm_organizations', 'crm_organization_get', + 'crm_activities', 'crm_deals', 'crm_deal_get', 'crm_tags', 'crm_tags_for', 'crm_tagged', + ], $names); } public function test_a_content_token_cannot_reach_contacts(): void @@ -283,4 +291,62 @@ public function test_a_bad_deal_value_comes_back_as_data(): void self::assertFalse($out['ok']); self::assertSame('invalid', $out['error']); } + + public function test_a_content_token_cannot_reach_tags_either(): void + { + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unknown tool "crm_tag_attach"'); + $this->toolset->call('crm_tag_attach', [], $this->principal('*:read', '*:write'), $this->ctx); + } + + public function test_tag_attach_by_name_then_tagged_returns_the_records(): void + { + $write = $this->principal('nimbuscms.crm:read', 'nimbuscms.crm:write'); + $a = $this->toolset->call('crm_contact_set', ['first_name' => 'Ada'], $write, $this->ctx)['contact']['id']; + $b = $this->toolset->call('crm_contact_set', ['first_name' => 'Alan'], $write, $this->ctx)['contact']['id']; + + // Attaching a new tag by name creates it and applies it. + $out = $this->toolset->call('crm_tag_attach', ['subject_type' => 'contact', 'subject_id' => $a, 'tag_name' => 'VIP'], $write, $this->ctx); + self::assertTrue($out['ok']); + self::assertTrue($out['attached']); + $tagId = $out['tag_id']; + + // The same tag on a second contact, by id this time. + $this->toolset->call('crm_tag_attach', ['subject_type' => 'contact', 'subject_id' => $b, 'tag_id' => $tagId], $write, $this->ctx); + + // Attaching again is idempotent (no second link). + $again = $this->toolset->call('crm_tag_attach', ['subject_type' => 'contact', 'subject_id' => $a, 'tag_id' => $tagId], $write, $this->ctx); + self::assertFalse($again['attached'], 'already tagged is a no-op'); + + // "all contacts tagged VIP" resolves to full records. + $tagged = $this->toolset->call('crm_tagged', ['subject_type' => 'contact', 'tag_id' => $tagId], $write, $this->ctx); + self::assertSame(2, $tagged['count']); + self::assertSame('Ada', $tagged['records'][0]['first_name']); + + // Detach one, and it drops out. + $this->toolset->call('crm_tag_detach', ['subject_type' => 'contact', 'subject_id' => $a, 'tag_id' => $tagId], $write, $this->ctx); + self::assertSame(1, $this->toolset->call('crm_tagged', ['subject_type' => 'contact', 'tag_id' => $tagId], $write, $this->ctx)['count']); + } + + public function test_tagging_a_missing_subject_comes_back_as_data(): void + { + $out = $this->toolset->call('crm_tag_attach', ['subject_type' => 'contact', 'subject_id' => 999999, 'tag_name' => 'X'], $this->principal('nimbuscms.crm:write'), $this->ctx); + self::assertFalse($out['ok']); + self::assertSame('invalid', $out['error']); + } + + public function test_a_tag_can_span_types_and_delete_removes_it_everywhere(): void + { + $write = $this->principal('nimbuscms.crm:read', 'nimbuscms.crm:write'); + $c = $this->toolset->call('crm_contact_set', ['first_name' => 'Ada'], $write, $this->ctx)['contact']['id']; + $d = $this->toolset->call('crm_deal_set', ['title' => 'Engine'], $write, $this->ctx)['deal']['id']; + $tagId = $this->toolset->call('crm_tag_create', ['name' => 'Priority'], $write, $this->ctx)['tag']['id']; + + $this->toolset->call('crm_tag_attach', ['subject_type' => 'contact', 'subject_id' => $c, 'tag_id' => $tagId], $write, $this->ctx); + $this->toolset->call('crm_tag_attach', ['subject_type' => 'deal', 'subject_id' => $d, 'tag_id' => $tagId], $write, $this->ctx); + self::assertSame(2, $this->toolset->call('crm_tags', [], $write, $this->ctx)['tags'][0]['count'], 'the tag is used twice, across types'); + + $this->toolset->call('crm_tag_delete', ['id' => $tagId], $write, $this->ctx); + self::assertSame([], $this->toolset->call('crm_tags_for', ['subject_type' => 'contact', 'subject_id' => $c], $write, $this->ctx)['tags'], 'deleting a tag removes it everywhere'); + } } diff --git a/tests/DealsAdminTest.php b/tests/DealsAdminTest.php index 86ea0d8..0bcdad5 100644 --- a/tests/DealsAdminTest.php +++ b/tests/DealsAdminTest.php @@ -12,6 +12,7 @@ use NimbusCMS\Crm\DealsAdmin; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; +use NimbusCMS\Crm\Tags; use PHPUnit\Framework\TestCase; /** @@ -35,13 +36,15 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals(), ...Schema::tags()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); $db->execute('TRUNCATE ' . Schema::DEAL); + $db->execute('TRUNCATE ' . Schema::TAG); + $db->execute('TRUNCATE ' . Schema::TAGGABLE); $storage = new PluginStorage($db); $this->deals = new Deals(static fn (): PluginStorage => $storage); @@ -50,6 +53,7 @@ protected function setUp(): void new Contacts(static fn (): PluginStorage => $storage), new Organizations(static fn (): PluginStorage => $storage), new Activities(static fn (): PluginStorage => $storage), + new Tags(static fn (): PluginStorage => $storage), ); } diff --git a/tests/DealsTest.php b/tests/DealsTest.php index 382f205..3830016 100644 --- a/tests/DealsTest.php +++ b/tests/DealsTest.php @@ -11,6 +11,7 @@ use NimbusCMS\Crm\Deals; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; +use NimbusCMS\Crm\Tags; use PHPUnit\Framework\TestCase; /** @@ -26,6 +27,7 @@ final class DealsTest extends TestCase private Contacts $contacts; private Organizations $organizations; private Activities $activities; + private Tags $tags; protected function setUp(): void { @@ -36,19 +38,22 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals(), ...Schema::tags()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); $db->execute('TRUNCATE ' . Schema::DEAL); + $db->execute('TRUNCATE ' . Schema::TAG); + $db->execute('TRUNCATE ' . Schema::TAGGABLE); $storage = new PluginStorage($db); $this->deals = new Deals(static fn (): PluginStorage => $storage); $this->contacts = new Contacts(static fn (): PluginStorage => $storage); $this->organizations = new Organizations(static fn (): PluginStorage => $storage); $this->activities = new Activities(static fn (): PluginStorage => $storage); + $this->tags = new Tags(static fn (): PluginStorage => $storage); } private const NOW = '2026-01-01 09:00:00'; @@ -169,6 +174,16 @@ public function test_delete_takes_the_activity_timeline_with_it(): void self::assertSame(0, $this->deals->delete($id), 'a second delete is a no-op'); } + public function test_delete_clears_its_tag_links(): void + { + $id = $this->deals->save(null, ['title' => 'X'], self::NOW); + $tagId = $this->tags->findOrCreate('Hot', self::NOW); + $this->tags->attach('deal', $id, $tagId, self::NOW); + + self::assertSame(1, $this->deals->delete($id)); + self::assertSame([], $this->tags->idsFor('deal', $tagId), 'no tag residue for the deleted deal'); + } + public function test_updating_a_missing_deal_is_rejected(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/OrganizationsAdminTest.php b/tests/OrganizationsAdminTest.php index 5f82250..457ac16 100644 --- a/tests/OrganizationsAdminTest.php +++ b/tests/OrganizationsAdminTest.php @@ -10,6 +10,7 @@ use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\OrganizationsAdmin; use NimbusCMS\Crm\Schema; +use NimbusCMS\Crm\Tags; use PHPUnit\Framework\TestCase; /** @@ -22,6 +23,7 @@ final class OrganizationsAdminTest extends TestCase { private Organizations $organizations; private Activities $activities; + private Tags $tags; private OrganizationsAdmin $admin; protected function setUp(): void @@ -33,16 +35,19 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::organizations(), ...Schema::activities()] as $sql) { + foreach ([...Schema::organizations(), ...Schema::activities(), ...Schema::tags()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); + $db->execute('TRUNCATE ' . Schema::TAG); + $db->execute('TRUNCATE ' . Schema::TAGGABLE); $storage = new PluginStorage($db); $this->organizations = new Organizations(static fn (): PluginStorage => $storage); $this->activities = new Activities(static fn (): PluginStorage => $storage); - $this->admin = new OrganizationsAdmin($this->organizations, $this->activities); + $this->tags = new Tags(static fn (): PluginStorage => $storage); + $this->admin = new OrganizationsAdmin($this->organizations, $this->activities, $this->tags); } public function test_the_list_escapes_hostile_author_values(): void diff --git a/tests/OrganizationsTest.php b/tests/OrganizationsTest.php index 37d756a..c1b6881 100644 --- a/tests/OrganizationsTest.php +++ b/tests/OrganizationsTest.php @@ -11,6 +11,7 @@ use NimbusCMS\Crm\Deals; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; +use NimbusCMS\Crm\Tags; use PHPUnit\Framework\TestCase; /** @@ -25,6 +26,7 @@ final class OrganizationsTest extends TestCase private Contacts $contacts; private Activities $activities; private Deals $deals; + private Tags $tags; protected function setUp(): void { @@ -35,19 +37,22 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals(), ...Schema::tags()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); $db->execute('TRUNCATE ' . Schema::DEAL); + $db->execute('TRUNCATE ' . Schema::TAG); + $db->execute('TRUNCATE ' . Schema::TAGGABLE); $storage = new PluginStorage($db); $this->organizations = new Organizations(static fn (): PluginStorage => $storage); $this->contacts = new Contacts(static fn (): PluginStorage => $storage); $this->activities = new Activities(static fn (): PluginStorage => $storage); $this->deals = new Deals(static fn (): PluginStorage => $storage); + $this->tags = new Tags(static fn (): PluginStorage => $storage); } private const NOW = '2026-01-01 09:00:00'; @@ -149,6 +154,16 @@ public function test_delete_keeps_its_deals_but_clears_the_link(): void self::assertNull($deal['org_id'], 'the dangling org link is cleared'); } + public function test_delete_clears_its_tag_links(): void + { + $orgId = $this->organizations->save(null, ['name' => 'Acme'], self::NOW); + $tagId = $this->tags->findOrCreate('Partner', self::NOW); + $this->tags->attach('organization', $orgId, $tagId, self::NOW); + + self::assertTrue($this->organizations->delete($orgId)); + self::assertSame([], $this->tags->idsFor('organization', $tagId), 'no tag residue for the deleted org'); + } + public function test_exists_reports_presence(): void { $id = $this->organizations->save(null, ['name' => 'Acme'], self::NOW); diff --git a/tests/TagsAdminTest.php b/tests/TagsAdminTest.php new file mode 100644 index 0000000..8548f61 --- /dev/null +++ b/tests/TagsAdminTest.php @@ -0,0 +1,89 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach (Schema::tags() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TAG); + $db->execute('TRUNCATE ' . Schema::TAGGABLE); + + $storage = new PluginStorage($db); + $this->tags = new Tags(static fn (): PluginStorage => $storage); + $this->admin = new TagsAdmin($this->tags); + } + + public function test_the_management_page_escapes_a_hostile_tag_name(): void + { + $this->tags->saveTag(null, '', '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, 'n'); + + self::assertStringNotContainsString('', $html, 'the tag name is escaped'); + self::assertStringContainsString('<script>', $html); + self::assertStringContainsString('value="CSRF123"', $html, 'the CSRF token is in the forms'); + } + + public function test_the_block_escapes_a_hostile_tag_and_carries_subject_and_csrf(): void + { + $on = [['id' => 1, 'name' => '">']]; + $all = [['id' => 2, 'name' => 'VIP', 'count' => 0]]; + + $html = TagsAdmin::block('CSRF123', 'crm', 'contact', 7, $on, $all, 'n'); + + self::assertStringNotContainsString('VIP<', $html, 'an unused tag is offered to add'); + } + + public function test_the_filter_bar_marks_the_active_tag_and_hides_unused(): void + { + $all = [ + ['id' => 1, 'name' => 'VIP', 'count' => 3], + ['id' => 2, 'name' => 'Unused', 'count' => 0], + ]; + + $html = TagsAdmin::filterBar('crm', $all, 1); + + self::assertStringContainsString('VIP', $html); + self::assertStringNotContainsString('Unused', $html, 'a tag on nothing is not offered as a filter'); + self::assertStringContainsString('is-active', $html, 'the active tag is marked'); + self::assertStringContainsString('Clear', $html); + } + + public function test_the_filter_bar_is_empty_when_there_are_no_tags(): void + { + self::assertSame('', TagsAdmin::filterBar('crm', [], null)); + } +} diff --git a/tests/TagsTest.php b/tests/TagsTest.php new file mode 100644 index 0000000..f292211 --- /dev/null +++ b/tests/TagsTest.php @@ -0,0 +1,168 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals(), ...Schema::tags()] as $sql) { + $db->execute($sql); + } + foreach ([Schema::CONTACT, Schema::ORGANIZATION, Schema::ACTIVITY, Schema::DEAL, Schema::TAG, Schema::TAGGABLE] as $t) { + $db->execute('TRUNCATE ' . $t); + } + + $storage = new PluginStorage($db); + $this->tags = new Tags(static fn (): PluginStorage => $storage); + $this->contacts = new Contacts(static fn (): PluginStorage => $storage); + $this->deals = new Deals(static fn (): PluginStorage => $storage); + } + + private const NOW = '2026-01-01 09:00:00'; + + public function test_create_rename_and_reject_duplicate(): void + { + $id = $this->tags->saveTag(null, 'VIP', self::NOW); + self::assertSame('VIP', $this->tags->getTag($id)['name']); + + $this->tags->saveTag($id, 'Very Important', self::NOW); + self::assertSame('Very Important', $this->tags->getTag($id)['name']); + + $this->tags->saveTag(null, 'Partner', self::NOW); + $this->expectException(\InvalidArgumentException::class); + $this->tags->saveTag($id, 'Partner', self::NOW); // clashes with another tag + } + + public function test_a_tag_needs_a_name(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->tags->saveTag(null, ' ', self::NOW); + } + + public function test_find_or_create_is_case_insensitive(): void + { + $a = $this->tags->findOrCreate('VIP', self::NOW); + $b = $this->tags->findOrCreate('vip', self::NOW); + self::assertSame($a, $b, 'the same tag is reused, not duplicated'); + } + + public function test_all_tags_reports_usage_counts(): void + { + $cid = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $tagId = $this->tags->findOrCreate('VIP', self::NOW); + $this->tags->findOrCreate('Unused', self::NOW); + $this->tags->attach('contact', $cid, $tagId, self::NOW); + + $all = $this->tags->allTags(); + $byName = []; + foreach ($all as $t) { + $byName[$t['name']] = $t['count']; + } + self::assertSame(1, $byName['VIP']); + self::assertSame(0, $byName['Unused']); + } + + public function test_tagging_is_idempotent_and_validated(): void + { + $cid = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $tagId = $this->tags->findOrCreate('VIP', self::NOW); + + self::assertTrue($this->tags->attach('contact', $cid, $tagId, self::NOW)); + self::assertFalse($this->tags->attach('contact', $cid, $tagId, self::NOW), 'tagging twice is a no-op'); + self::assertCount(1, $this->tags->tagsFor('contact', $cid)); + } + + public function test_attach_rejects_an_unknown_subject_type(): void + { + $tagId = $this->tags->findOrCreate('VIP', self::NOW); + $this->expectException(\InvalidArgumentException::class); + $this->tags->attach('user', 1, $tagId, self::NOW); + } + + public function test_attach_rejects_a_missing_subject(): void + { + $tagId = $this->tags->findOrCreate('VIP', self::NOW); + $this->expectException(\InvalidArgumentException::class); + $this->tags->attach('contact', 999999, $tagId, self::NOW); + } + + public function test_attach_rejects_a_missing_tag(): void + { + $cid = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $this->expectException(\InvalidArgumentException::class); + $this->tags->attach('contact', $cid, 999999, self::NOW); + } + + public function test_a_tag_spans_types_and_ids_for_filters(): void + { + $cid = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $did = $this->deals->save(null, ['title' => 'Engine'], self::NOW); + $tagId = $this->tags->findOrCreate('Priority', self::NOW); + + $this->tags->attach('contact', $cid, $tagId, self::NOW); + $this->tags->attach('deal', $did, $tagId, self::NOW); + + self::assertSame([$cid], $this->tags->idsFor('contact', $tagId)); + self::assertSame([$did], $this->tags->idsFor('deal', $tagId)); + } + + public function test_detach_removes_one_link(): void + { + $cid = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $tagId = $this->tags->findOrCreate('VIP', self::NOW); + $this->tags->attach('contact', $cid, $tagId, self::NOW); + + self::assertSame(1, $this->tags->detach('contact', $cid, $tagId)); + self::assertSame([], $this->tags->tagsFor('contact', $cid)); + } + + public function test_deleting_a_tag_clears_its_links_but_keeps_subjects(): void + { + $cid = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $tagId = $this->tags->findOrCreate('VIP', self::NOW); + $this->tags->attach('contact', $cid, $tagId, self::NOW); + + self::assertTrue($this->tags->deleteTag($tagId)); + self::assertNull($this->tags->getTag($tagId)); + self::assertSame([], $this->tags->tagsFor('contact', $cid), 'the link is gone'); + self::assertNotNull($this->contacts->get($cid), 'the contact is kept'); + } + + public function test_clear_for_removes_every_link_on_a_subject(): void + { + $cid = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $this->tags->attach('contact', $cid, $this->tags->findOrCreate('A', self::NOW), self::NOW); + $this->tags->attach('contact', $cid, $this->tags->findOrCreate('B', self::NOW), self::NOW); + + self::assertSame(2, $this->tags->clearFor('contact', $cid)); + self::assertSame([], $this->tags->tagsFor('contact', $cid)); + } +}