From 3824fcd1736f0483aff1458ecaa4bca66815b8ab Mon Sep 17 00:00:00 2001 From: DanMat Date: Fri, 4 Sep 2026 11:54:19 -0400 Subject: [PATCH] =?UTF-8?q?Slice=202:=20Organizations=20+=20contact?= =?UTF-8?q?=E2=86=94org=20linking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second CRM entity — organizations (companies) — and the soft contact→org link, on the same discipline as contacts. - Schema: crm_organization table (002_organizations migration); org_id column + index on crm_contact (added directly to Schema::contacts() since there are zero installs to migrate). - Organizations service: allow-listed save (name required, length caps), bound + wildcard-escaped name search, and a delete that unlinks its contacts in one transaction (NULL org_id, then remove) — a person is never destroyed because their company was. - Contacts: org_id validated to an existing org at write (soft ref, no hard FK); get()/all() LEFT JOIN the org name; hydrate exposes org_id + organization. - MCP: crm_organizations / _get / _set / _delete tools, gated on the same wildcard-immune nimbuscms.crm capability; org_id added to contact_set. - Admin: Organizations page (search/list/create/edit/delete) and an organization dropdown on the contact form + column in the list. - Guide: organizations section. - Tests: OrganizationsTest, OrganizationsAdminTest; ContactsTest and CrmToolsetTest extended for org linking, org-delete-unlinks, and the org tools' capability gating. Co-Authored-By: Claude Opus 4.8 --- src/Contacts.php | 64 ++++++++--- src/ContactsAdmin.php | 19 +++- src/CrmPlugin.php | 49 ++++++++- src/CrmToolset.php | 75 ++++++++++++- src/Guide.php | 29 +++-- src/Organizations.php | 180 +++++++++++++++++++++++++++++++ src/OrganizationsAdmin.php | 139 ++++++++++++++++++++++++ src/Schema.php | 29 ++++- tests/ContactsAdminTest.php | 6 +- tests/ContactsTest.php | 51 ++++++++- tests/CrmToolsetTest.php | 57 +++++++++- tests/OrganizationsAdminTest.php | 73 +++++++++++++ tests/OrganizationsTest.php | 130 ++++++++++++++++++++++ 13 files changed, 856 insertions(+), 45 deletions(-) create mode 100644 src/Organizations.php create mode 100644 src/OrganizationsAdmin.php create mode 100644 tests/OrganizationsAdminTest.php create mode 100644 tests/OrganizationsTest.php diff --git a/src/Contacts.php b/src/Contacts.php index d661cd0..1f7dbd4 100644 --- a/src/Contacts.php +++ b/src/Contacts.php @@ -60,18 +60,19 @@ public function save(?int $id, array $fields, string $now): int $email = $this->email($fields, $existing); $phone = $this->optStr($fields, 'phone', $existing, self::MAX_PHONE); $notes = $this->optStr($fields, 'notes', $existing, self::MAX_NOTES); + $orgId = $this->orgId($fields, $existing); if ($id === null) { return $this->storage()->insert( - 'INSERT INTO ' . Schema::CONTACT . ' (first_name, last_name, email, phone, notes, created_at, updated_at) - VALUES (:first, :last, :email, :phone, :notes, :created, :updated)', - ['first' => $first, 'last' => $last, 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'created' => $now, 'updated' => $now], + 'INSERT INTO ' . Schema::CONTACT . ' (org_id, first_name, last_name, email, phone, notes, created_at, updated_at) + VALUES (:org, :first, :last, :email, :phone, :notes, :created, :updated)', + ['org' => $orgId, 'first' => $first, 'last' => $last, 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'created' => $now, 'updated' => $now], ); } $this->storage()->execute( - 'UPDATE ' . Schema::CONTACT . ' SET first_name = :first, last_name = :last, email = :email, phone = :phone, notes = :notes, updated_at = :now WHERE id = :id', - ['first' => $first, 'last' => $last, 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'now' => $now, 'id' => $id], + 'UPDATE ' . Schema::CONTACT . ' SET org_id = :org, first_name = :first, last_name = :last, email = :email, phone = :phone, notes = :notes, updated_at = :now WHERE id = :id', + ['org' => $orgId, 'first' => $first, 'last' => $last, 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'now' => $now, 'id' => $id], ); return $id; } @@ -80,12 +81,14 @@ public function save(?int $id, array $fields, string $now): int * One contact by id, or null. Returned raw (unescaped) — the render layer * escapes. * - * @return array{id:int,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string}|null + * @return array{id:int,org_id:?int,organization:?string,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string}|null */ public function get(int $id): ?array { $row = $this->storage()->selectOne( - 'SELECT id, first_name, last_name, email, phone, notes, created_at, updated_at FROM ' . Schema::CONTACT . ' WHERE id = :id', + 'SELECT c.id, c.org_id, c.first_name, c.last_name, c.email, c.phone, c.notes, c.created_at, c.updated_at, o.name AS organization + FROM ' . Schema::CONTACT . ' c LEFT JOIN ' . Schema::ORGANIZATION . ' o ON o.id = c.org_id + WHERE c.id = :id', ['id' => $id], ); return $row === null ? null : $this->hydrate($row); @@ -96,24 +99,24 @@ public function get(int $id): ?array * filtered to those whose name or email contains `$q` (a bound, wildcard-escaped * LIKE — never string-built SQL). * - * @return list + * @return list */ public function all(?string $q = null): array { + $select = 'SELECT c.id, c.org_id, c.first_name, c.last_name, c.email, c.phone, c.notes, c.created_at, c.updated_at, o.name AS organization + FROM ' . Schema::CONTACT . ' c LEFT JOIN ' . Schema::ORGANIZATION . ' o ON o.id = c.org_id'; + $q = $q !== null ? trim($q) : ''; if ($q === '') { - $rows = $this->storage()->select( - 'SELECT id, first_name, last_name, email, phone, notes, created_at, updated_at FROM ' . Schema::CONTACT . ' ORDER BY updated_at DESC, id DESC', - ); + $rows = $this->storage()->select($select . ' ORDER BY c.updated_at DESC, c.id DESC'); return array_map($this->hydrate(...), $rows); } // Escape LIKE wildcards so a `%`/`_` in the term is a literal, and bind it. $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_substr($q, 0, 100)) . '%'; $rows = $this->storage()->select( - 'SELECT id, first_name, last_name, email, phone, notes, created_at, updated_at FROM ' . Schema::CONTACT . - " WHERE first_name LIKE :q ESCAPE '\\\\' OR last_name LIKE :q2 ESCAPE '\\\\' OR email LIKE :q3 ESCAPE '\\\\' - ORDER BY updated_at DESC, id DESC", + $select . " WHERE c.first_name LIKE :q ESCAPE '\\\\' OR c.last_name LIKE :q2 ESCAPE '\\\\' OR c.email LIKE :q3 ESCAPE '\\\\' + ORDER BY c.updated_at DESC, c.id DESC", ['q' => $like, 'q2' => $like, 'q3' => $like], ); return array_map($this->hydrate(...), $rows); @@ -187,14 +190,43 @@ private function optStr(array $fields, string $key, ?array $existing, int $max): return $v; } + /** + * The organization a contact belongs to: null, or an id that must exist in this + * install (a soft ref, checked at write so a dangling id can't be stored). Absent + * on an update keeps the stored value. + * + * @param array $fields + * @param array|null $existing + */ + private function orgId(array $fields, ?array $existing): ?int + { + if (!array_key_exists('org_id', $fields)) { + return $existing !== null ? ($existing['org_id'] === null ? null : (int) $existing['org_id']) : null; + } + $raw = trim((string) $fields['org_id']); + if ($raw === '') { + return null; + } + if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1) { + throw new \InvalidArgumentException('"org_id" must be a positive whole number or blank.'); + } + $id = (int) $raw; + if ($this->storage()->selectOne('SELECT id FROM ' . Schema::ORGANIZATION . ' WHERE id = :id', ['id' => $id]) === null) { + throw new \InvalidArgumentException("No organization with id {$id}."); + } + return $id; + } + /** * @param array $row - * @return array{id:int,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string} + * @return array{id:int,org_id:?int,organization:?string,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string} */ private function hydrate(array $row): array { return [ - 'id' => (int) $row['id'], + 'id' => (int) $row['id'], + 'org_id' => $row['org_id'] === null ? null : (int) $row['org_id'], + 'organization' => ($row['organization'] ?? null) === null ? null : (string) $row['organization'], 'first_name' => (string) $row['first_name'], 'last_name' => (string) $row['last_name'], 'email' => $row['email'] === null ? null : (string) $row['email'], diff --git a/src/ContactsAdmin.php b/src/ContactsAdmin.php index 8c87f71..d15a5f0 100644 --- a/src/ContactsAdmin.php +++ b/src/ContactsAdmin.php @@ -21,7 +21,7 @@ final class ContactsAdmin 'invalid' => ['err', 'Check the details and try again.'], ]; - public function __construct(private Contacts $contacts) + public function __construct(private Contacts $contacts, private Organizations $organizations) { } @@ -55,6 +55,7 @@ private function form(string $csrf, ?array $edit): string { $val = static fn (string $k): string => $edit !== null && $edit[$k] !== null ? self::e((string) $edit[$k]) : ''; $idField = $edit !== null ? '' : ''; + $currentOrg = $edit !== null && $edit['org_id'] !== null ? (int) $edit['org_id'] : null; return '

' . ($edit !== null ? 'Edit contact' : 'Add a contact') . '

' . '
' @@ -67,12 +68,23 @@ private function form(string $csrf, ?array $edit): string . '' . '' . '' + . '' . '' . '
' . ($edit !== null ? ' Cancel' : '') . '
'; } + private function orgSelect(?int $current): string + { + $options = ''; + foreach ($this->organizations->all() as $o) { + $sel = $current === (int) $o['id'] ? ' selected' : ''; + $options .= ''; + } + return ''; + } + /** * @param list> $contacts */ @@ -94,6 +106,7 @@ private function list(string $csrf, array $contacts, string $q, ?int $editId): s $name = trim(((string) $c['first_name']) . ' ' . ((string) $c['last_name'])); $rows .= '' . '' . self::e($name === '' ? '(no name)' : $name) . '' + . '' . self::e((string) ($c['organization'] ?? '')) . '' . '' . self::e((string) ($c['email'] ?? '')) . '' . '' . self::e((string) ($c['phone'] ?? '')) . '' . '' @@ -106,7 +119,7 @@ private function list(string $csrf, array $contacts, string $q, ?int $editId): s } return $html - . '' + . '
NameEmailPhone
' . $rows . '
NameOrganizationEmailPhone
'; } @@ -126,7 +139,7 @@ private function styles(string $nonce): string . '.cr-form{max-width:44rem;display:flex;flex-direction:column;gap:.75rem;margin:0 0 2rem}' . '.cr-row{display:flex;gap:.75rem;flex-wrap:wrap}' . '.cr-form label{display:flex;flex-direction:column;gap:.25rem;flex:1 1 14rem;font-weight:600;font-size:.85rem}' - . '.cr-form input,.cr-form textarea{font:inherit;padding:.5rem .6rem;min-height:44px;box-sizing:border-box}' + . '.cr-form input,.cr-form textarea,.cr-form select{font:inherit;padding:.5rem .6rem;min-height:44px;box-sizing:border-box}' . '.cr-form textarea{min-height:5rem}' . '.cr-search{display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;margin:0 0 1rem}' . '.cr-search input{font:inherit;padding:.5rem .6rem;min-height:44px;flex:1 1 16rem}' diff --git a/src/CrmPlugin.php b/src/CrmPlugin.php index 7c0d50e..9c5b969 100644 --- a/src/CrmPlugin.php +++ b/src/CrmPlugin.php @@ -18,7 +18,7 @@ * token can never reach contact data. All on its **own tables** (ADR 0005), * touching no core data; no public/site surface at all. * - * Slice 1: contacts (CRUD over the admin + MCP). + * Slice 1: contacts. Slice 2: organizations + the contact→org link. */ final class CrmPlugin implements Plugin { @@ -28,17 +28,19 @@ final class CrmPlugin implements Plugin public function register(PluginContext $context): void { $context->migrations()->register('001_contacts', Schema::contacts()); + $context->migrations()->register('002_organizations', Schema::organizations()); // Grantable, wildcard-immune: nimbuscms.crm:read / :write. Contact data is // PII — a content *:write token can never read or change it. $context->capabilities()->declare('CRM', ['read', 'write']); // Storage is taken lazily, so register() runs no query and loads without a database. - $storage = static fn (): PluginStorage => $context->storage(); - $contacts = new Contacts($storage); + $storage = static fn (): PluginStorage => $context->storage(); + $contacts = new Contacts($storage); + $organizations = new Organizations($storage); // The agent surface — every tool gates on nimbuscms.crm:read|write (ADR 0016). - $context->mcp()->register(new CrmToolset($contacts)); + $context->mcp()->register(new CrmToolset($contacts, $organizations)); // 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 @@ -47,7 +49,7 @@ public function register(PluginContext $context): void 'crm', 'Contacts', '👥', - static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ContactsAdmin($contacts))->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))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce), self::ID . ':write', ); @@ -60,6 +62,7 @@ public function register(PluginContext $context): void 'email' => (string) ($r->input('email') ?? ''), 'phone' => (string) ($r->input('phone') ?? ''), 'notes' => (string) ($r->input('notes') ?? ''), + 'org_id' => (string) ($r->input('org_id') ?? ''), ]; $idIn = trim((string) ($r->input('id') ?? '')); $id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null; @@ -83,6 +86,42 @@ public function register(PluginContext $context): void return Response::redirect('/admin/crm?ok=deleted'); }); + // Organizations: the companies contacts belong to. Same crm:write gate. + $context->adminPages()->register( + 'crm-organizations', + 'Organizations', + '🏢', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new OrganizationsAdmin($organizations))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce), + self::ID . ':write', + ); + + $context->adminPages()->action('crm-organizations', 'org-save', static function (Request $r) use ($organizations): Response { + $fields = [ + 'name' => (string) ($r->input('name') ?? ''), + 'website' => (string) ($r->input('website') ?? ''), + 'notes' => (string) ($r->input('notes') ?? ''), + ]; + $idIn = trim((string) ($r->input('id') ?? '')); + $id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null; + try { + $organizations->save($id, $fields, date('Y-m-d H:i:s')); + return Response::redirect('/admin/crm-organizations?ok=saved'); + } catch (\InvalidArgumentException $e) { + $code = str_contains($e->getMessage(), 'name') ? 'noname' : 'invalid'; + return Response::redirect('/admin/crm-organizations?err=' . $code); + } catch (\Throwable) { + return Response::redirect('/admin/crm-organizations?err=invalid'); + } + }); + + $context->adminPages()->action('crm-organizations', 'org-delete', static function (Request $r) use ($organizations): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + $organizations->delete((int) $idIn); + } + return Response::redirect('/admin/crm-organizations?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 1a4beb3..45b947b 100644 --- a/src/CrmToolset.php +++ b/src/CrmToolset.php @@ -25,7 +25,7 @@ */ final class CrmToolset extends PluginToolset { - public function __construct(private Contacts $contacts) + public function __construct(private Contacts $contacts, private Organizations $organizations) { } @@ -36,7 +36,8 @@ public function namespace(): string protected function tools(): array { - $id = ['type' => 'integer', 'description' => 'The contact id.']; + $id = ['type' => 'integer', 'description' => 'The contact id.']; + $orgId = ['type' => 'integer', 'description' => 'The organization id.']; return [ new PluginTool('contacts', 'read', 'List contacts (all, or those whose name or email matches a search).', [ @@ -59,6 +60,7 @@ protected function tools(): array 'email' => ['type' => 'string', 'description' => 'Email address (validated). Optional.'], 'phone' => ['type' => 'string', 'description' => 'Phone number. Optional.'], 'notes' => ['type' => 'string', 'description' => 'Free-text notes (plain text). Optional.'], + 'org_id' => ['type' => 'integer', 'description' => 'An existing organization id to link the contact to. Optional; blank to unlink.'], ], ], $this->contactSet(...)), @@ -67,6 +69,33 @@ protected function tools(): array 'required' => ['id'], 'properties' => ['id' => $id], ], $this->contactDelete(...)), + + new PluginTool('organizations', 'read', 'List organizations (all, or those whose name matches a search).', [ + 'type' => 'object', + 'properties' => ['q' => ['type' => 'string', 'description' => 'Optional search over the organization name.']], + ], $this->organizations(...)), + + new PluginTool('organization_get', 'read', 'One organization by id, or none.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => $orgId], + ], $this->organizationGet(...)), + + new PluginTool('organization_set', 'write', 'Create an organization (omit id) or update one (with id). Only the fields you send change.', [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'Existing organization id to update; omit to create.'], + 'name' => ['type' => 'string', 'description' => 'Organization name (required to create).'], + 'website' => ['type' => 'string', 'description' => 'Website URL. Optional.'], + 'notes' => ['type' => 'string', 'description' => 'Free-text notes (plain text). Optional.'], + ], + ], $this->organizationSet(...)), + + new PluginTool('organization_delete', 'write', 'Delete an organization by id; its contacts are kept but unlinked (org_id cleared).', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => $orgId], + ], $this->organizationDelete(...)), ]; } @@ -115,6 +144,48 @@ private function contactDelete(array $a, TokenPrincipal $p, EntryOpContext $c): return ['ok' => true, 'deleted' => $removed > 0]; } + /** + * @param array $a + * @return array + */ + private function organizations(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $list = $this->organizations->all($this->nullableStr($a, 'q')); + return ['organizations' => $list, 'count' => count($list)]; + } + + /** + * @param array $a + * @return array + */ + private function organizationGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['id' => $id, 'organization' => $this->organizations->get($id)]; + } + + /** + * @param array $a + * @return array + */ + private function organizationSet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->organizations->save($this->nullableInt($a, 'id'), $a, $this->now()); + return ['ok' => true, 'organization' => $this->organizations->get($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function organizationDelete(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['ok' => true, 'deleted' => $this->organizations->delete($id)]; + } + // --- helpers --------------------------------------------------------- /** diff --git a/src/Guide.php b/src/Guide.php index af03c73..cdb8cb4 100644 --- a/src/Guide.php +++ b/src/Guide.php @@ -16,11 +16,11 @@ public static function text(): string return <<<'MD' # CRM - A back-office CRM: **contacts** (people), and — in later slices — organizations, - activities 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. + A back-office CRM: **contacts** (people) and the **organizations** (companies) + they belong to; activities and a deal pipeline arrive in later slices. 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 @@ -28,11 +28,24 @@ public static function text(): string - `crm_contact_get` — one contact by `id`. - `crm_contact_set` — create (omit `id`) or update (with `id`). Fields: `first_name`, `last_name` (a contact needs at least one), `email` (validated), - `phone`, `notes`. Only the fields you send change. + `phone`, `notes`, `org_id` (an existing organization to link to; blank to + unlink). Only the fields you send change. - `crm_contact_delete` — remove a contact outright by `id` (the "forget" primitive). - Values are stored as you send them and escaped when displayed; there is no public - page for CRM data. + A contact read includes its `org_id` and the resolved `organization` name. + + ## Organizations + + - `crm_organizations` — list organizations, or search by name (`q`). + - `crm_organization_get` — one organization by `id`. + - `crm_organization_set` — create (omit `id`) or update (with `id`). Fields: + `name` (required to create), `website`, `notes`. Only the fields you send change. + - `crm_organization_delete` — remove an organization by `id`. Its contacts are + **kept** — their `org_id` is simply cleared. + + Link a contact to a company by passing that company's `id` as the contact's + `org_id`. 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 new file mode 100644 index 0000000..278b24f --- /dev/null +++ b/src/Organizations.php @@ -0,0 +1,180 @@ + $fields + */ + public function save(?int $id, array $fields, string $now): int + { + $existing = $id !== null ? $this->get($id) : null; + if ($id !== null && $existing === null) { + throw new \InvalidArgumentException("No organization with id {$id}."); + } + + $name = $this->name($fields, $existing); + $website = $this->optStr($fields, 'website', $existing, self::MAX_WEBSITE); + $notes = $this->optStr($fields, 'notes', $existing, self::MAX_NOTES); + + if ($id === null) { + return $this->storage()->insert( + 'INSERT INTO ' . Schema::ORGANIZATION . ' (name, website, notes, created_at, updated_at) + VALUES (:name, :website, :notes, :created, :updated)', + ['name' => $name, 'website' => $website, 'notes' => $notes, 'created' => $now, 'updated' => $now], + ); + } + + $this->storage()->execute( + 'UPDATE ' . Schema::ORGANIZATION . ' SET name = :name, website = :website, notes = :notes, updated_at = :now WHERE id = :id', + ['name' => $name, 'website' => $website, 'notes' => $notes, 'now' => $now, 'id' => $id], + ); + return $id; + } + + /** + * @return array{id:int,name:string,website:?string,notes:?string,created_at:string,updated_at:string}|null + */ + public function get(int $id): ?array + { + $row = $this->storage()->selectOne( + 'SELECT id, name, website, notes, created_at, updated_at FROM ' . Schema::ORGANIZATION . ' WHERE id = :id', + ['id' => $id], + ); + return $row === null ? null : $this->hydrate($row); + } + + /** + * Organizations for the admin list / MCP, name-first, optionally filtered to + * those whose name contains `$q` (a bound, wildcard-escaped LIKE). + * + * @return list + */ + public function all(?string $q = null): array + { + $q = $q !== null ? trim($q) : ''; + if ($q === '') { + $rows = $this->storage()->select( + 'SELECT id, name, website, notes, created_at, updated_at FROM ' . Schema::ORGANIZATION . ' ORDER BY name', + ); + return array_map($this->hydrate(...), $rows); + } + + $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_substr($q, 0, 100)) . '%'; + $rows = $this->storage()->select( + 'SELECT id, name, website, notes, created_at, updated_at FROM ' . Schema::ORGANIZATION . + " WHERE name LIKE :q ESCAPE '\\\\' ORDER BY name", + ['q' => $like], + ); + return array_map($this->hydrate(...), $rows); + } + + /** + * Delete an organization and unlink (never delete) its contacts — atomically: + * every contact's `org_id` that pointed here is NULLed, then the org is removed. + * Returns true when an org was removed. + */ + public function delete(int $id): bool + { + return (bool) $this->storage()->transaction(function () use ($id): bool { + $this->storage()->execute('UPDATE ' . Schema::CONTACT . ' SET org_id = NULL WHERE org_id = :id', ['id' => $id]); + return $this->storage()->execute('DELETE FROM ' . Schema::ORGANIZATION . ' WHERE id = :id', ['id' => $id]) > 0; + }); + } + + /** Whether an organization id exists — the soft-ref check {@see Contacts} uses on write. */ + public function exists(int $id): bool + { + return $this->storage()->selectOne('SELECT id FROM ' . Schema::ORGANIZATION . ' WHERE id = :id', ['id' => $id]) !== null; + } + + // --- validation / hydration ----------------------------------------- + + /** + * @param array $fields + * @param array|null $existing + */ + private function name(array $fields, ?array $existing): string + { + if (!array_key_exists('name', $fields)) { + if ($existing !== null) { + return (string) $existing['name']; + } + throw new \InvalidArgumentException('An organization name is required.'); + } + $name = trim((string) $fields['name']); + if ($name === '') { + throw new \InvalidArgumentException('An organization name is required.'); + } + if (mb_strlen($name) > self::MAX_NAME) { + throw new \InvalidArgumentException('An organization name must be ' . self::MAX_NAME . ' characters or fewer.'); + } + return $name; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function optStr(array $fields, string $key, ?array $existing, int $max): ?string + { + if (!array_key_exists($key, $fields)) { + return $existing !== null ? ($existing[$key] === null ? null : (string) $existing[$key]) : null; + } + $v = trim((string) $fields[$key]); + if ($v === '') { + return null; + } + if (mb_strlen($v) > $max) { + throw new \InvalidArgumentException("\"{$key}\" must be {$max} characters or fewer."); + } + return $v; + } + + /** + * @param array $row + * @return array{id:int,name:string,website:?string,notes:?string,created_at:string,updated_at:string} + */ + private function hydrate(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'name' => (string) $row['name'], + 'website' => $row['website'] === null ? null : (string) $row['website'], + 'notes' => $row['notes'] === null ? null : (string) $row['notes'], + 'created_at' => (string) $row['created_at'], + 'updated_at' => (string) $row['updated_at'], + ]; + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/src/OrganizationsAdmin.php b/src/OrganizationsAdmin.php new file mode 100644 index 0000000..bbe30f2 --- /dev/null +++ b/src/OrganizationsAdmin.php @@ -0,0 +1,139 @@ +` block, + * and the page + its POST actions are gated on `nimbuscms.crm:write` + CSRF. + */ +final class OrganizationsAdmin +{ + private const NOTICES = [ + 'saved' => ['ok', 'Organization saved.'], + 'deleted' => ['ok', 'Organization deleted — its contacts were kept and unlinked.'], + 'noname' => ['err', 'An organization needs a name.'], + 'invalid' => ['err', 'Check the details and try again.'], + ]; + + public function __construct(private Organizations $organizations) + { + } + + /** + * @param string $csrf CSRF token for the forms + * @param ?string $notice a fixed notice code from the ?ok=/?err= redirect + * @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 + */ + public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = ''): 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) : ''; + $orgs = $this->organizations->all($q === '' ? null : $q); + + return $this->styles($nonce) + . '

Organizations

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

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

' + . $this->form($csrf, $editOrg) + . $this->list($csrf, $orgs, $q); + } + + /** @param array|null $edit */ + private function form(string $csrf, ?array $edit): string + { + $val = static fn (string $k): string => $edit !== null && $edit[$k] !== null ? self::e((string) $edit[$k]) : ''; + $idField = $edit !== null ? '' : ''; + + return '

' . ($edit !== null ? 'Edit organization' : 'Add an organization') . '

' + . '
' + . '' . $idField + . '' + . '' + . '' + . '
' + . ($edit !== null ? ' Cancel' : '') + . '
'; + } + + /** + * @param list> $orgs + */ + private function list(string $csrf, array $orgs, string $q): string + { + $html = '

All organizations

' + . ''; + + if ($orgs === []) { + return $html . '

' . ($q !== '' ? 'No organizations match “' . self::e($q) . '”.' : 'No organizations yet — add one above.') . '

'; + } + + $rows = ''; + foreach ($orgs as $o) { + $rows .= '' + . '' . self::e((string) $o['name']) . '' + . '' . self::e((string) ($o['website'] ?? '')) . '' + . '' + . '
' + . '' + . '' + . '' + . '
' + . ''; + } + + return $html + . '' + . $rows . '
NameWebsite
'; + } + + 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 ''; + } + + /** Escape a value for HTML output. */ + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/src/Schema.php b/src/Schema.php index 2423cad..380cd25 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -16,7 +16,8 @@ */ final class Schema { - public const CONTACT = 'crm_contact'; + public const CONTACT = 'crm_contact'; + public const ORGANIZATION = 'crm_organization'; /** @return list each statement individually idempotent (ADR 0005) */ public static function contacts(): array @@ -24,6 +25,7 @@ public static function contacts(): array return [ 'CREATE TABLE IF NOT EXISTS ' . self::CONTACT . ' ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + org_id BIGINT UNSIGNED NULL, first_name VARCHAR(120) NOT NULL DEFAULT \'\', last_name VARCHAR(120) NOT NULL DEFAULT \'\', email VARCHAR(191) NULL, @@ -32,7 +34,30 @@ public static function contacts(): array created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX idx_contact_name (last_name, first_name), - INDEX idx_contact_email (email) + INDEX idx_contact_email (email), + INDEX idx_contact_org (org_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + ]; + } + + /** + * Organizations (companies), and the contact→org link. `org_id` on a contact is + * a **soft reference** (no hard FK) validated at write and NULLed when its org is + * deleted — a person is never destroyed because their company was removed. + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function organizations(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::ORGANIZATION . ' ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL, + website VARCHAR(255) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_org_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', ]; } diff --git a/tests/ContactsAdminTest.php b/tests/ContactsAdminTest.php index 2820d78..131b263 100644 --- a/tests/ContactsAdminTest.php +++ b/tests/ContactsAdminTest.php @@ -8,6 +8,7 @@ use Nimbus\Plugin\PluginStorage; use NimbusCMS\Crm\Contacts; use NimbusCMS\Crm\ContactsAdmin; +use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; use PHPUnit\Framework\TestCase; @@ -31,14 +32,15 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach (Schema::contacts() as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); + $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $storage = new PluginStorage($db); $this->contacts = new Contacts(static fn (): PluginStorage => $storage); - $this->admin = new ContactsAdmin($this->contacts); + $this->admin = new ContactsAdmin($this->contacts, new Organizations(static fn (): PluginStorage => $storage)); } public function test_the_list_escapes_hostile_author_values(): void diff --git a/tests/ContactsTest.php b/tests/ContactsTest.php index 4b91f21..15f5892 100644 --- a/tests/ContactsTest.php +++ b/tests/ContactsTest.php @@ -7,6 +7,7 @@ use Nimbus\Database\Connection; use Nimbus\Plugin\PluginStorage; use NimbusCMS\Crm\Contacts; +use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; use PHPUnit\Framework\TestCase; @@ -14,10 +15,13 @@ * The Contacts service — the write discipline the security review pinned: a field * allow-list (no over-posting), validation (name required, email well-formed, * length caps), bound + wildcard-escaped search, store-raw, and a total delete. + * Slice 2 adds the org link: `org_id` validated to an existing org on write, and + * cleared (never cascaded into a delete) when its org is removed. */ final class ContactsTest extends TestCase { private Contacts $contacts; + private Organizations $organizations; protected function setUp(): void { @@ -28,13 +32,15 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach (Schema::contacts() as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); + $db->execute('TRUNCATE ' . Schema::ORGANIZATION); - $storage = new PluginStorage($db); - $this->contacts = new Contacts(static fn (): PluginStorage => $storage); + $storage = new PluginStorage($db); + $this->contacts = new Contacts(static fn (): PluginStorage => $storage); + $this->organizations = new Organizations(static fn (): PluginStorage => $storage); } private const NOW = '2026-01-01 09:00:00'; @@ -109,4 +115,43 @@ public function test_updating_a_missing_contact_is_rejected(): void $this->expectException(\InvalidArgumentException::class); $this->contacts->save(424242, ['first_name' => 'Ghost'], self::NOW); } + + public function test_a_contact_links_to_an_existing_organization(): void + { + $orgId = $this->organizations->save(null, ['name' => 'Analytical Engines Ltd'], self::NOW); + $id = $this->contacts->save(null, ['first_name' => 'Ada', 'org_id' => (string) $orgId], self::NOW); + + $c = $this->contacts->get($id); + self::assertNotNull($c); + self::assertSame($orgId, $c['org_id']); + self::assertSame('Analytical Engines Ltd', $c['organization'], 'the org name is resolved by the join'); + } + + public function test_linking_to_a_missing_organization_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->contacts->save(null, ['first_name' => 'Ada', 'org_id' => '999999'], self::NOW); + } + + public function test_org_id_can_be_cleared_by_sending_blank(): void + { + $orgId = $this->organizations->save(null, ['name' => 'Acme'], self::NOW); + $id = $this->contacts->save(null, ['first_name' => 'Ada', 'org_id' => (string) $orgId], self::NOW); + self::assertSame($orgId, $this->contacts->get($id)['org_id']); + + $this->contacts->save($id, ['org_id' => ''], self::NOW); + self::assertNull($this->contacts->get($id)['org_id'], 'a blank org_id unlinks'); + } + + public function test_deleting_an_org_unlinks_its_contacts_but_keeps_them(): void + { + $orgId = $this->organizations->save(null, ['name' => 'Doomed Co'], self::NOW); + $id = $this->contacts->save(null, ['first_name' => 'Ada', 'org_id' => (string) $orgId], self::NOW); + + self::assertTrue($this->organizations->delete($orgId)); + + $c = $this->contacts->get($id); + self::assertNotNull($c, 'the person survives the company being deleted'); + self::assertNull($c['org_id'], 'the dangling link is cleared, not cascaded'); + } } diff --git a/tests/CrmToolsetTest.php b/tests/CrmToolsetTest.php index dfb1622..42e6e27 100644 --- a/tests/CrmToolsetTest.php +++ b/tests/CrmToolsetTest.php @@ -12,6 +12,7 @@ use Nimbus\Plugin\PluginStorage; use NimbusCMS\Crm\Contacts; use NimbusCMS\Crm\CrmToolset; +use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; use PHPUnit\Framework\TestCase; @@ -36,13 +37,17 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach (Schema::contacts() as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); + $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $storage = new PluginStorage($db); - $this->toolset = new CrmToolset(new Contacts(static fn (): PluginStorage => $storage)); + $this->toolset = new CrmToolset( + new Contacts(static fn (): PluginStorage => $storage), + new Organizations(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'); @@ -64,13 +69,16 @@ private function principal(string ...$scopes): TokenPrincipal public function test_the_tools_are_namespaced_and_split_read_from_write(): void { $names = array_column($this->toolset->definitions($this->principal('nimbuscms.crm:read', 'nimbuscms.crm:write')), 'name'); - self::assertSame(['crm_contacts', 'crm_contact_get', 'crm_contact_set', 'crm_contact_delete'], $names); + self::assertSame([ + 'crm_contacts', 'crm_contact_get', 'crm_contact_set', 'crm_contact_delete', + 'crm_organizations', 'crm_organization_get', 'crm_organization_set', 'crm_organization_delete', + ], $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'], $names); + self::assertSame(['crm_contacts', 'crm_contact_get', 'crm_organizations', 'crm_organization_get'], $names); } public function test_a_content_token_cannot_reach_contacts(): void @@ -118,4 +126,45 @@ public function test_an_invalid_email_comes_back_as_data_not_an_exception(): voi self::assertFalse($out['ok']); self::assertSame('invalid', $out['error']); } + + public function test_a_content_token_cannot_reach_organizations_either(): void + { + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unknown tool "crm_organizations"'); + $this->toolset->call('crm_organizations', [], $this->principal('*:read', '*:write'), $this->ctx); + } + + public function test_a_read_token_cannot_call_an_org_write_tool(): void + { + $this->expectException(McpError::class); + $this->toolset->call('crm_organization_set', ['name' => 'X'], $this->principal('nimbuscms.crm:read'), $this->ctx); + } + + public function test_organization_set_get_and_delete_round_trip(): void + { + $write = $this->principal('nimbuscms.crm:read', 'nimbuscms.crm:write'); + + $out = $this->toolset->call('crm_organization_set', ['name' => 'Acme', 'website' => 'https://acme.test'], $write, $this->ctx); + self::assertTrue($out['ok']); + $orgId = $out['organization']['id']; + + // Link a contact to it, then delete the org — the contact must survive, unlinked. + $c = $this->toolset->call('crm_contact_set', ['first_name' => 'Ada', 'org_id' => $orgId], $write, $this->ctx); + self::assertSame($orgId, $c['contact']['org_id']); + self::assertSame('Acme', $c['contact']['organization']); + + $del = $this->toolset->call('crm_organization_delete', ['id' => $orgId], $write, $this->ctx); + self::assertTrue($del['deleted']); + + $still = $this->toolset->call('crm_contact_get', ['id' => $c['contact']['id']], $write, $this->ctx); + self::assertNotNull($still['contact'], 'the contact outlives its deleted org'); + self::assertNull($still['contact']['org_id'], 'the link is cleared, not cascaded'); + } + + public function test_creating_an_org_without_a_name_comes_back_as_data(): void + { + $out = $this->toolset->call('crm_organization_set', [], $this->principal('nimbuscms.crm:write'), $this->ctx); + self::assertFalse($out['ok']); + self::assertSame('invalid', $out['error']); + } } diff --git a/tests/OrganizationsAdminTest.php b/tests/OrganizationsAdminTest.php new file mode 100644 index 0000000..d81c927 --- /dev/null +++ b/tests/OrganizationsAdminTest.php @@ -0,0 +1,73 @@ + 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::organizations() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + + $storage = new PluginStorage($db); + $this->organizations = new Organizations(static fn (): PluginStorage => $storage); + $this->admin = new OrganizationsAdmin($this->organizations); + } + + public function test_the_list_escapes_hostile_author_values(): void + { + $this->organizations->save(null, [ + 'name' => '', + 'website' => '">', + ], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'test-nonce'); + + self::assertStringNotContainsString('', $html, 'the name is escaped in the list'); + self::assertStringNotContainsString('organizations->save(null, [ + 'name' => '">', + 'notes' => 'note & things', + ], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, (string) $id, null, 'test-nonce'); + + self::assertStringNotContainsString('', $html, 'the loaded name is escaped in the form'); + self::assertStringNotContainsString('note', $html, 'the loaded notes are escaped in the textarea'); + self::assertStringContainsString('<b>note</b> & things', $html); + self::assertStringContainsString('Edit organization', $html); + } +} diff --git a/tests/OrganizationsTest.php b/tests/OrganizationsTest.php new file mode 100644 index 0000000..f3edc1c --- /dev/null +++ b/tests/OrganizationsTest.php @@ -0,0 +1,130 @@ + 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()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::CONTACT); + $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + + $storage = new PluginStorage($db); + $this->organizations = new Organizations(static fn (): PluginStorage => $storage); + $this->contacts = new Contacts(static fn (): PluginStorage => $storage); + } + + private const NOW = '2026-01-01 09:00:00'; + + public function test_create_get_and_update_round_trip(): void + { + $id = $this->organizations->save(null, ['name' => 'Acme', 'website' => 'https://acme.test', 'notes' => 'A customer.'], self::NOW); + self::assertGreaterThan(0, $id); + + $o = $this->organizations->get($id); + self::assertNotNull($o); + self::assertSame('Acme', $o['name']); + self::assertSame('https://acme.test', $o['website']); + + // Update only the website; the rest keep their stored value. + $this->organizations->save($id, ['website' => 'https://acme.example'], '2026-01-02 09:00:00'); + $o = $this->organizations->get($id); + self::assertSame('https://acme.example', $o['website']); + self::assertSame('Acme', $o['name'], 'unsent fields are unchanged'); + } + + public function test_a_name_is_required(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->organizations->save(null, ['website' => 'https://noname.test'], self::NOW); + } + + public function test_over_posting_is_ignored(): void + { + $id = $this->organizations->save(null, [ + 'name' => 'Grace Inc', 'id' => 9999, 'created_at' => '1900-01-01 00:00:00', 'evil' => 'x', + ], self::NOW); + + self::assertNotSame(9999, $id, 'the id is server-assigned'); + $o = $this->organizations->get($id); + self::assertNotNull($o); + self::assertSame(self::NOW, $o['created_at'], 'created_at is server-set, not over-posted'); + self::assertArrayNotHasKey('evil', $o); + } + + public function test_search_is_bound_and_wildcard_escaped(): void + { + $this->organizations->save(null, ['name' => 'Acme'], self::NOW); + $this->organizations->save(null, ['name' => 'Acme Analytics'], self::NOW); + $this->organizations->save(null, ['name' => 'Globex'], self::NOW); + + self::assertCount(2, $this->organizations->all('Acme')); + self::assertCount(0, $this->organizations->all('%'), 'a literal % is not match-all'); + self::assertCount(0, $this->organizations->all("' OR '1'='1"), 'a quote does not break the query'); + } + + public function test_all_is_ordered_by_name(): void + { + $this->organizations->save(null, ['name' => 'Zeta'], self::NOW); + $this->organizations->save(null, ['name' => 'Alpha'], self::NOW); + + $names = array_column($this->organizations->all(), 'name'); + self::assertSame(['Alpha', 'Zeta'], $names); + } + + public function test_delete_unlinks_contacts_atomically_and_keeps_them(): void + { + $orgId = $this->organizations->save(null, ['name' => 'Doomed Co'], self::NOW); + $a = $this->contacts->save(null, ['first_name' => 'Ada', 'org_id' => (string) $orgId], self::NOW); + $b = $this->contacts->save(null, ['first_name' => 'Alan', 'org_id' => (string) $orgId], self::NOW); + + self::assertTrue($this->organizations->delete($orgId)); + self::assertNull($this->organizations->get($orgId)); + self::assertNull($this->contacts->get($a)['org_id'], 'every linked contact is unlinked'); + self::assertNull($this->contacts->get($b)['org_id']); + self::assertNotNull($this->contacts->get($a), 'contacts are kept, not cascaded'); + + self::assertFalse($this->organizations->delete($orgId), 'a second delete is a no-op'); + } + + public function test_exists_reports_presence(): void + { + $id = $this->organizations->save(null, ['name' => 'Acme'], self::NOW); + self::assertTrue($this->organizations->exists($id)); + self::assertFalse($this->organizations->exists(999999)); + } + + public function test_updating_a_missing_organization_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->organizations->save(424242, ['name' => 'Ghost'], self::NOW); + } +}