From 0192bb2f81cbbf88ec88e428e2a3743e26108ab8 Mon Sep 17 00:00:00 2001 From: DanMat Date: Fri, 4 Sep 2026 12:16:49 -0400 Subject: [PATCH] Slice 3: Activity timeline (contacts + organizations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the CRM timeline — dated, typed entries (note/call/email/meeting) logged against a contact or an organization. - Schema: crm_activity table + 003_activities migration. Polymorphic soft subject: subject_type ENUM(contact|organization|deal) — 'deal' reserved for the deals slice; kind ENUM; body/occurred_at/author; index on (subject_type, subject_id, occurred_at). Append-only. - Activities service: subject_type is a write-time allow-list (never interpolated) that also picks the table the subject_id must EXIST in; kind allow-listed; body length-capped; occurred_at parsed strictly (accepts datetime-local); author is server-set (the MCP token name), never a client field, so it can't be spoofed. - Total-delete cascade: deleting a contact now also removes its activities (the "forget" leaves nothing behind); deleting an org removes the org's own timeline but keeps its people and theirs — both atomic (transactions). - MCP: crm_activities / _add / _delete, gated on the same wildcard-immune nimbuscms.crm capability; add records the token name as author. - Admin: an inline timeline block (list + log form + delete) on the contact and organization edit pages, sharing one activity-add / activity-delete pair per page (each inherits crm:write + CSRF). No spoofable author field in the admin. - Guide: activities section. - Tests: ActivitiesTest, ActivitiesAdminTest; Contacts/Organizations cascade tests; CrmToolset activity tools + gating + author-not- over-postable; all setups build+truncate the activity table. Co-Authored-By: Claude Opus 4.8 --- src/Activities.php | 224 +++++++++++++++++++++++++++++++ src/ActivitiesAdmin.php | 106 +++++++++++++++ src/Contacts.php | 15 ++- src/ContactsAdmin.php | 23 +++- src/CrmPlugin.php | 53 +++++++- src/CrmToolset.php | 69 +++++++++- src/Guide.php | 31 ++++- src/Organizations.php | 11 +- src/OrganizationsAdmin.php | 18 ++- src/Schema.php | 30 +++++ tests/ActivitiesAdminTest.php | 58 ++++++++ tests/ActivitiesTest.php | 149 ++++++++++++++++++++ tests/ContactsAdminTest.php | 31 ++++- tests/ContactsTest.php | 17 ++- tests/CrmToolsetTest.php | 56 +++++++- tests/OrganizationsAdminTest.php | 8 +- tests/OrganizationsTest.php | 20 ++- 17 files changed, 878 insertions(+), 41 deletions(-) create mode 100644 src/Activities.php create mode 100644 src/ActivitiesAdmin.php create mode 100644 tests/ActivitiesAdminTest.php create mode 100644 tests/ActivitiesTest.php diff --git a/src/Activities.php b/src/Activities.php new file mode 100644 index 0000000..c431e59 --- /dev/null +++ b/src/Activities.php @@ -0,0 +1,224 @@ + Schema::CONTACT, + self::SUBJECT_ORGANIZATION => Schema::ORGANIZATION, + ]; + + /** @var list */ + public const KINDS = ['note', 'call', 'email', 'meeting', 'other']; + + private const MAX_BODY = 20000; + private const MAX_AUTHOR = 191; + + /** @param \Closure():PluginStorage $storage resolved lazily, so construction runs no query */ + public function __construct(private \Closure $storage) + { + } + + /** + * Log an activity against a subject. `author` is passed by the caller (never + * read from `$fields`) so it cannot be over-posted. Returns the new activity id. + * + * @param array $fields + */ + public function add(array $fields, string $now, ?string $author = null): int + { + [$type, $id] = $this->subject($fields); + $kind = $this->kind($fields); + $body = $this->body($fields); + $occurredAt = $this->occurredAt($fields, $now); + $who = $this->author($author); + + return $this->storage()->insert( + 'INSERT INTO ' . Schema::ACTIVITY . ' (subject_type, subject_id, kind, body, occurred_at, author, created_at) + VALUES (:type, :sid, :kind, :body, :occurred, :author, :created)', + ['type' => $type, 'sid' => $id, 'kind' => $kind, 'body' => $body, 'occurred' => $occurredAt, 'author' => $who, 'created' => $now], + ); + } + + /** + * The timeline for one subject, most-recent first. `$type` is validated to the + * allow-list; an unknown type is an empty timeline, not an error. + * + * @return list + */ + public function forSubject(string $type, int $id): array + { + if (!isset(self::SUBJECTS[$type])) { + return []; + } + $rows = $this->storage()->select( + 'SELECT id, subject_type, subject_id, kind, body, occurred_at, author, created_at + FROM ' . Schema::ACTIVITY . ' WHERE subject_type = :type AND subject_id = :id + ORDER BY occurred_at DESC, id DESC', + ['type' => $type, 'id' => $id], + ); + return array_map($this->hydrate(...), $rows); + } + + /** + * @return array{id:int,subject_type:string,subject_id:int,kind:string,body:?string,occurred_at:string,author:?string,created_at:string}|null + */ + public function get(int $id): ?array + { + $row = $this->storage()->selectOne( + 'SELECT id, subject_type, subject_id, kind, body, occurred_at, author, created_at + FROM ' . Schema::ACTIVITY . ' WHERE id = :id', + ['id' => $id], + ); + return $row === null ? null : $this->hydrate($row); + } + + /** Delete one activity outright by id; returns the number of rows removed (0 if none). */ + public function delete(int $id): int + { + return $this->storage()->execute('DELETE FROM ' . Schema::ACTIVITY . ' WHERE id = :id', ['id' => $id]); + } + + // --- validation / hydration ----------------------------------------- + + /** + * Resolve and validate the subject: an allow-listed `subject_type` and a + * `subject_id` that exists in the mapped table. + * + * @param array $fields + * @return array{0:string,1:int} + */ + private function subject(array $fields): array + { + $type = trim((string) ($fields['subject_type'] ?? '')); + if (!isset(self::SUBJECTS[$type])) { + throw new \InvalidArgumentException('"subject_type" must be one of: ' . implode(', ', array_keys(self::SUBJECTS)) . '.'); + } + $raw = trim((string) ($fields['subject_id'] ?? '')); + if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1) { + throw new \InvalidArgumentException('"subject_id" must be a positive whole number.'); + } + $id = (int) $raw; + if ($this->storage()->selectOne('SELECT id FROM ' . self::SUBJECTS[$type] . ' WHERE id = :id', ['id' => $id]) === null) { + throw new \InvalidArgumentException("No {$type} with id {$id}."); + } + return [$type, $id]; + } + + /** @param array $fields */ + private function kind(array $fields): string + { + if (!array_key_exists('kind', $fields)) { + return 'note'; + } + $kind = trim((string) $fields['kind']); + if ($kind === '') { + return 'note'; + } + if (!in_array($kind, self::KINDS, true)) { + throw new \InvalidArgumentException('"kind" must be one of: ' . implode(', ', self::KINDS) . '.'); + } + return $kind; + } + + /** @param array $fields */ + private function body(array $fields): ?string + { + $v = trim((string) ($fields['body'] ?? '')); + if ($v === '') { + return null; + } + if (mb_strlen($v) > self::MAX_BODY) { + throw new \InvalidArgumentException('"body" must be ' . self::MAX_BODY . ' characters or fewer.'); + } + return $v; + } + + /** + * When the activity happened. Absent → now. Accepts a full datetime or an + * `datetime-local` value (`T` separator, no seconds); a sloppy value is + * rejected rather than silently reinterpreted. + * + * @param array $fields + */ + private function occurredAt(array $fields, string $now): string + { + $raw = str_replace('T', ' ', trim((string) ($fields['occurred_at'] ?? ''))); + if ($raw === '') { + return $now; + } + foreach (['Y-m-d H:i:s', 'Y-m-d H:i'] as $fmt) { + $d = \DateTimeImmutable::createFromFormat($fmt, $raw); + if ($d !== false && $d->format($fmt) === $raw) { + return $d->format('Y-m-d H:i:s'); + } + } + throw new \InvalidArgumentException('"occurred_at" must be a valid date and time.'); + } + + private function author(?string $author): ?string + { + if ($author === null) { + return null; + } + $v = trim($author); + if ($v === '') { + return null; + } + return mb_substr($v, 0, self::MAX_AUTHOR); + } + + /** + * @param array $row + * @return array{id:int,subject_type:string,subject_id:int,kind:string,body:?string,occurred_at:string,author:?string,created_at:string} + */ + private function hydrate(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'subject_type' => (string) $row['subject_type'], + 'subject_id' => (int) $row['subject_id'], + 'kind' => (string) $row['kind'], + 'body' => $row['body'] === null ? null : (string) $row['body'], + 'occurred_at' => (string) $row['occurred_at'], + 'author' => $row['author'] === null ? null : (string) $row['author'], + 'created_at' => (string) $row['created_at'], + ]; + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/src/ActivitiesAdmin.php b/src/ActivitiesAdmin.php new file mode 100644 index 0000000..878c441 --- /dev/null +++ b/src/ActivitiesAdmin.php @@ -0,0 +1,106 @@ +` block, since the admin CSP is nonce-only for `style-src`. + */ +final class ActivitiesAdmin +{ + /** + * @param string $csrf CSRF token for the forms + * @param string $page host admin slug (`crm` or `crm-organizations`) + * @param string $subjectType `contact` | `organization` + * @param int $subjectId the record the timeline hangs off + * @param list> $activities the subject's timeline (newest first) + * @param string $nonce the request CSP nonce + */ + public static function render(string $csrf, string $page, string $subjectType, int $subjectId, array $activities, string $nonce): string + { + return self::styles($nonce) + . '
' + . '

Activity

' + . self::form($csrf, $page, $subjectType, $subjectId) + . self::list($csrf, $page, $subjectType, $subjectId, $activities) + . '
'; + } + + private static function form(string $csrf, string $page, string $subjectType, int $subjectId): string + { + $kinds = ''; + foreach (Activities::KINDS as $k) { + $kinds .= ''; + } + + return '
' + . '' + . '' + . '' + . '
' + . '' + . '' + . '
' + . '' + . '
' + . '
'; + } + + /** @param list> $activities */ + private static function list(string $csrf, string $page, string $subjectType, int $subjectId, array $activities): string + { + if ($activities === []) { + return '

No activity logged yet.

'; + } + + $items = ''; + foreach ($activities as $a) { + $body = (string) ($a['body'] ?? ''); + $meta = self::e(ucfirst((string) $a['kind'])) . ' · ' . self::e((string) $a['occurred_at']); + if (($a['author'] ?? null) !== null && (string) $a['author'] !== '') { + $meta .= ' · ' . self::e((string) $a['author']); + } + $items .= '
  • ' + . '
    ' . $meta . '' + . '
    ' + . '' + . '' + . '' + . '' + . '
    ' + . ($body !== '' ? '

    ' . self::e($body) . '

    ' : '') + . '
  • '; + } + + return '
      ' . $items . '
    '; + } + + private static function styles(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/src/Contacts.php b/src/Contacts.php index 1f7dbd4..eccfee8 100644 --- a/src/Contacts.php +++ b/src/Contacts.php @@ -122,10 +122,21 @@ public function all(?string $q = null): array return array_map($this->hydrate(...), $rows); } - /** Delete a contact outright by id; returns the number of rows removed (0 if none). */ + /** + * Delete a contact outright, together with its activity timeline — the GDPR + * "forget" primitive, so nothing about the person is left behind. Atomic: + * the activities and the contact go in one transaction. Returns the number of + * contact rows removed (0 if none). + */ public function delete(int $id): int { - return $this->storage()->execute('DELETE FROM ' . Schema::CONTACT . ' WHERE id = :id', ['id' => $id]); + return (int) $this->storage()->transaction(function () use ($id): int { + $this->storage()->execute( + 'DELETE FROM ' . Schema::ACTIVITY . ' WHERE subject_type = :type AND subject_id = :id', + ['type' => Activities::SUBJECT_CONTACT, 'id' => $id], + ); + return $this->storage()->execute('DELETE FROM ' . Schema::CONTACT . ' WHERE id = :id', ['id' => $id]); + }); } // --- validation / hydration ----------------------------------------- diff --git a/src/ContactsAdmin.php b/src/ContactsAdmin.php index d15a5f0..1000f56 100644 --- a/src/ContactsAdmin.php +++ b/src/ContactsAdmin.php @@ -14,15 +14,21 @@ final class ContactsAdmin { private const NOTICES = [ - 'saved' => ['ok', 'Contact saved.'], - 'deleted' => ['ok', 'Contact deleted.'], - 'bademail' => ['err', 'That email address is not valid.'], - 'noname' => ['err', 'A contact needs a first or last name.'], - 'invalid' => ['err', 'Check the details and try again.'], + 'saved' => ['ok', 'Contact saved.'], + 'deleted' => ['ok', 'Contact deleted.'], + 'activity' => ['ok', 'Activity logged.'], + 'activitygone' => ['ok', 'Activity deleted.'], + '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.'], + 'invalid' => ['err', 'Check the details and try again.'], ]; - public function __construct(private Contacts $contacts, private Organizations $organizations) - { + public function __construct( + private Contacts $contacts, + private Organizations $organizations, + private Activities $activities, + ) { } /** @@ -45,6 +51,9 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit . '

    The people your business keeps track of. This is private data — only roles with the crm capability can see it.

    '; $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 .= $this->list($csrf, $contacts, $q, $editId); return $html; diff --git a/src/CrmPlugin.php b/src/CrmPlugin.php index 9c5b969..4a3b510 100644 --- a/src/CrmPlugin.php +++ b/src/CrmPlugin.php @@ -18,7 +18,8 @@ * 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. Slice 2: organizations + the contact→org link. + * Slice 1: contacts. Slice 2: organizations + the contact→org link. Slice 3: the + * activity timeline against a contact or an organization. */ final class CrmPlugin implements Plugin { @@ -29,6 +30,7 @@ public function register(PluginContext $context): void { $context->migrations()->register('001_contacts', Schema::contacts()); $context->migrations()->register('002_organizations', Schema::organizations()); + $context->migrations()->register('003_activities', Schema::activities()); // Grantable, wildcard-immune: nimbuscms.crm:read / :write. Contact data is // PII — a content *:write token can never read or change it. @@ -38,9 +40,10 @@ public function register(PluginContext $context): void $storage = static fn (): PluginStorage => $context->storage(); $contacts = new Contacts($storage); $organizations = new Organizations($storage); + $activities = new Activities($storage); // The agent surface — every tool gates on nimbuscms.crm:read|write (ADR 0016). - $context->mcp()->register(new CrmToolset($contacts, $organizations)); + $context->mcp()->register(new CrmToolset($contacts, $organizations, $activities)); // 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 @@ -49,7 +52,7 @@ public function register(PluginContext $context): void 'crm', 'Contacts', '👥', - 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), + 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), self::ID . ':write', ); @@ -91,7 +94,7 @@ public function register(PluginContext $context): void '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), + 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), self::ID . ':write', ); @@ -122,6 +125,48 @@ public function register(PluginContext $context): void return Response::redirect('/admin/crm-organizations?ok=deleted'); }); + // Activities are logged inline on a contact or an organization. The same two + // actions serve both record pages (each inherits its page's crm:write + CSRF + // gate); the subject on the form decides where we redirect back to. The admin + // sets no author — there is no spoofable author field (an MCP add records the + // token name; see CrmToolset). + $back = static function (Request $r): string { + $page = ($r->input('subject_type') === Activities::SUBJECT_ORGANIZATION) ? 'crm-organizations' : 'crm'; + $sid = trim((string) ($r->input('subject_id') ?? '')); + $edit = ($sid !== '' && ctype_digit($sid)) ? '?edit=' . $sid . '&' : '?'; + return '/admin/' . $page . $edit; + }; + + $addActivity = static function (Request $r) use ($activities, $back): Response { + $base = $back($r); + $fields = [ + 'subject_type' => (string) ($r->input('subject_type') ?? ''), + 'subject_id' => (string) ($r->input('subject_id') ?? ''), + 'kind' => (string) ($r->input('kind') ?? 'note'), + 'body' => (string) ($r->input('body') ?? ''), + 'occurred_at' => (string) ($r->input('occurred_at') ?? ''), + ]; + try { + $activities->add($fields, date('Y-m-d H:i:s'), null); + return Response::redirect($base . 'ok=activity'); + } catch (\Throwable) { + return Response::redirect($base . 'err=activitybad'); + } + }; + + $deleteActivity = static function (Request $r) use ($activities, $back): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + $activities->delete((int) $idIn); + } + return Response::redirect($back($r) . 'ok=activitygone'); + }; + + foreach (['crm', 'crm-organizations'] as $page) { + $context->adminPages()->action($page, 'activity-add', $addActivity); + $context->adminPages()->action($page, 'activity-delete', $deleteActivity); + } + // 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 45b947b..e986817 100644 --- a/src/CrmToolset.php +++ b/src/CrmToolset.php @@ -25,8 +25,11 @@ */ final class CrmToolset extends PluginToolset { - public function __construct(private Contacts $contacts, private Organizations $organizations) - { + public function __construct( + private Contacts $contacts, + private Organizations $organizations, + private Activities $activities, + ) { } public function namespace(): string @@ -96,6 +99,33 @@ protected function tools(): array 'required' => ['id'], 'properties' => ['id' => $orgId], ], $this->organizationDelete(...)), + + new PluginTool('activities', 'read', 'The activity timeline for one subject (a contact or an organization), most recent first.', [ + 'type' => 'object', + 'required' => ['subject_type', 'subject_id'], + 'properties' => [ + 'subject_type' => ['type' => 'string', 'enum' => [Activities::SUBJECT_CONTACT, Activities::SUBJECT_ORGANIZATION], 'description' => 'Whose timeline: "contact" or "organization".'], + 'subject_id' => ['type' => 'integer', 'description' => 'The contact or organization id.'], + ], + ], $this->activities(...)), + + new PluginTool('activity_add', 'write', 'Log an activity (a note/call/email/meeting) against a contact or organization. Recorded under your token name.', [ + 'type' => 'object', + 'required' => ['subject_type', 'subject_id'], + 'properties' => [ + 'subject_type' => ['type' => 'string', 'enum' => [Activities::SUBJECT_CONTACT, Activities::SUBJECT_ORGANIZATION], 'description' => 'What to attach it to: "contact" or "organization".'], + 'subject_id' => ['type' => 'integer', 'description' => 'The existing contact or organization id.'], + 'kind' => ['type' => 'string', 'enum' => Activities::KINDS, 'description' => 'The kind of activity. Defaults to "note".'], + 'body' => ['type' => 'string', 'description' => 'What happened (plain text). Optional.'], + 'occurred_at' => ['type' => 'string', 'description' => 'When it happened, "YYYY-MM-DD HH:MM[:SS]". Defaults to now.'], + ], + ], $this->activityAdd(...)), + + new PluginTool('activity_delete', 'write', 'Delete one activity outright by id.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The activity id.']], + ], $this->activityDelete(...)), ]; } @@ -186,6 +216,41 @@ private function organizationDelete(array $a, TokenPrincipal $p, EntryOpContext return ['ok' => true, 'deleted' => $this->organizations->delete($id)]; } + /** + * @param array $a + * @return array + */ + private function activities(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $type = (string) ($a['subject_type'] ?? ''); + $id = $this->requireInt($a, 'subject_id'); + $list = $this->activities->forSubject($type, $id); + return ['activities' => $list, 'count' => count($list)]; + } + + /** + * @param array $a + * @return array + */ + private function activityAdd(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a, $p): array { + // author is the token's own name — server-set, never read from args. + $id = $this->activities->add($a, $this->now(), $p->name); + return ['ok' => true, 'activity' => $this->activities->get($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function activityDelete(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['ok' => true, 'deleted' => $this->activities->delete($id) > 0]; + } + // --- helpers --------------------------------------------------------- /** diff --git a/src/Guide.php b/src/Guide.php index cdb8cb4..f7f9747 100644 --- a/src/Guide.php +++ b/src/Guide.php @@ -16,11 +16,12 @@ public static function text(): string return <<<'MD' # CRM - 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. + A back-office CRM: **contacts** (people), the **organizations** (companies) they + belong to, and an **activity timeline** against either; a deal pipeline arrives in + a later slice. 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 @@ -44,8 +45,24 @@ public static function text(): string **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. + `org_id`. + + ## Activities + + A timeline of dated, typed entries against a **subject** — a `contact` or an + `organization`. + + - `crm_activities` — the timeline for one subject (`subject_type`, `subject_id`), + most recent first. + - `crm_activity_add` — log one. `subject_type` + `subject_id` (the subject must + exist), `kind` (`note`/`call`/`email`/`meeting`/`other`, defaults to `note`), + `body` (what happened), `occurred_at` (when; defaults to now). It is recorded + under your token name. + - `crm_activity_delete` — remove one activity by `id`. + + Deleting a contact or organization also removes its activities, so a "forget" + leaves nothing behind. 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 278b24f..f81e90e 100644 --- a/src/Organizations.php +++ b/src/Organizations.php @@ -96,14 +96,19 @@ public function all(?string $q = null): array } /** - * 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. + * Delete an organization atomically: its contacts are **kept** (their `org_id` + * is NULLed, never cascaded into a delete), its own activity timeline is + * removed (those belong to the company, not to any surviving person), and then + * the org itself. 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]); + $this->storage()->execute( + 'DELETE FROM ' . Schema::ACTIVITY . ' WHERE subject_type = :type AND subject_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 bbe30f2..3fe2810 100644 --- a/src/OrganizationsAdmin.php +++ b/src/OrganizationsAdmin.php @@ -13,14 +13,19 @@ 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.'], + 'saved' => ['ok', 'Organization saved.'], + 'deleted' => ['ok', 'Organization deleted — its contacts were kept and unlinked.'], + 'activity' => ['ok', 'Activity logged.'], + 'activitygone' => ['ok', 'Activity deleted.'], + 'noname' => ['err', 'An organization needs a name.'], + 'activitybad' => ['err', 'Could not log that activity — check the details.'], + 'invalid' => ['err', 'Check the details and try again.'], ]; - public function __construct(private Organizations $organizations) - { + public function __construct( + private Organizations $organizations, + private Activities $activities, + ) { } /** @@ -42,6 +47,7 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit . $this->notice($notice) . '

    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) : '') . $this->list($csrf, $orgs, $q); } diff --git a/src/Schema.php b/src/Schema.php index 380cd25..6ed4d20 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -18,6 +18,7 @@ final class Schema { public const CONTACT = 'crm_contact'; public const ORGANIZATION = 'crm_organization'; + public const ACTIVITY = 'crm_activity'; /** @return list each statement individually idempotent (ADR 0005) */ public static function contacts(): array @@ -61,4 +62,33 @@ public static function organizations(): array ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', ]; } + + /** + * The activity timeline — a dated, typed log entry against a **subject** (a + * contact or an organization; `deal` is reserved in the column ENUM for the + * deals slice, but the service rejects it until then). The subject link is + * **polymorphic and soft**: `subject_type` is a write-time allow-list (never + * interpolated) and `subject_id` is validated to exist on write; a subject's + * delete removes its own activities so no dangling row is left behind. + * Append-only (no `updated_at`): a timeline entry is added or removed, not + * edited. + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function activities(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::ACTIVITY . " ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + subject_type ENUM('contact','organization','deal') NOT NULL, + subject_id BIGINT UNSIGNED NOT NULL, + kind ENUM('note','call','email','meeting','other') NOT NULL DEFAULT 'note', + body TEXT NULL, + occurred_at DATETIME NOT NULL, + author VARCHAR(191) NULL, + created_at DATETIME NOT NULL, + INDEX idx_activity_subject (subject_type, subject_id, occurred_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + ]; + } } diff --git a/tests/ActivitiesAdminTest.php b/tests/ActivitiesAdminTest.php new file mode 100644 index 0000000..d7aa8c6 --- /dev/null +++ b/tests/ActivitiesAdminTest.php @@ -0,0 +1,58 @@ +> */ + private function hostileTimeline(): array + { + return [[ + 'id' => 7, + 'subject_type' => 'contact', + 'subject_id' => 3, + 'kind' => 'note', + 'body' => '', + 'occurred_at' => '2026-01-01 09:00:00', + 'author' => '">', + 'created_at' => '2026-01-01 09:00:00', + ]]; + } + + public function test_it_escapes_hostile_body_and_author(): void + { + $html = ActivitiesAdmin::render('CSRF123', 'crm', 'contact', 3, $this->hostileTimeline(), 'test-nonce'); + + self::assertStringNotContainsString('', $html, 'the body is escaped'); + self::assertStringNotContainsString('', $html, 'inline style is nonce-tagged for the admin CSP'); + } +} diff --git a/tests/ActivitiesTest.php b/tests/ActivitiesTest.php new file mode 100644 index 0000000..0f4d9d9 --- /dev/null +++ b/tests/ActivitiesTest.php @@ -0,0 +1,149 @@ + 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()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::CONTACT); + $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + $db->execute('TRUNCATE ' . Schema::ACTIVITY); + + $storage = new PluginStorage($db); + $this->activities = new Activities(static fn (): PluginStorage => $storage); + $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'; + + private function contactId(): int + { + return $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + } + + public function test_add_get_and_timeline_ordering(): void + { + $cid = $this->contactId(); + $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'kind' => 'call', 'body' => 'First', 'occurred_at' => '2026-01-01 08:00:00'], self::NOW); + $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'kind' => 'note', 'body' => 'Second', 'occurred_at' => '2026-01-02 08:00:00'], self::NOW); + + $timeline = $this->activities->forSubject('contact', $cid); + self::assertCount(2, $timeline); + self::assertSame('Second', $timeline[0]['body'], 'most recent occurred_at first'); + self::assertSame('First', $timeline[1]['body']); + self::assertSame('call', $timeline[1]['kind']); + } + + public function test_kind_defaults_to_note(): void + { + $cid = $this->contactId(); + $id = $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'body' => 'x'], self::NOW); + self::assertSame('note', $this->activities->get($id)['kind']); + } + + public function test_occurred_at_defaults_to_now_and_accepts_datetime_local(): void + { + $cid = $this->contactId(); + $a = $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'body' => 'x'], self::NOW); + self::assertSame(self::NOW, $this->activities->get($a)['occurred_at']); + + // A datetime-local value ("T" separator, no seconds) is normalized. + $b = $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'occurred_at' => '2026-03-04T14:30'], self::NOW); + self::assertSame('2026-03-04 14:30:00', $this->activities->get($b)['occurred_at']); + } + + public function test_a_bad_occurred_at_is_rejected(): void + { + $cid = $this->contactId(); + $this->expectException(\InvalidArgumentException::class); + $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'occurred_at' => 'yesterday'], self::NOW); + } + + public function test_an_unknown_kind_is_rejected(): void + { + $cid = $this->contactId(); + $this->expectException(\InvalidArgumentException::class); + $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'kind' => 'carrier-pigeon'], self::NOW); + } + + public function test_subject_type_is_an_allow_list(): void + { + // "deal" is reserved in the column ENUM but not yet a valid write subject. + $this->expectException(\InvalidArgumentException::class); + $this->activities->add(['subject_type' => 'deal', 'subject_id' => '1', 'body' => 'x'], self::NOW); + } + + public function test_an_arbitrary_subject_type_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->activities->add(['subject_type' => 'user', 'subject_id' => '1', 'body' => 'x'], self::NOW); + } + + public function test_the_subject_must_exist(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->activities->add(['subject_type' => 'contact', 'subject_id' => '999999', 'body' => 'ghost'], self::NOW); + } + + public function test_an_activity_can_hang_off_an_organization(): void + { + $orgId = $this->organizations->save(null, ['name' => 'Acme'], self::NOW); + $id = $this->activities->add(['subject_type' => 'organization', 'subject_id' => (string) $orgId, 'body' => 'Renewed.'], self::NOW); + self::assertSame($orgId, $this->activities->get($id)['subject_id']); + self::assertCount(1, $this->activities->forSubject('organization', $orgId)); + } + + public function test_author_is_server_set_never_over_posted(): void + { + $cid = $this->contactId(); + // A forged author key in the field array is ignored; only the explicit arg lands. + $id = $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'author' => 'spoof', 'body' => 'x'], self::NOW, 'crm-bot'); + self::assertSame('crm-bot', $this->activities->get($id)['author']); + } + + public function test_delete_removes_one_entry(): void + { + $cid = $this->contactId(); + $id = $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cid, 'body' => 'x'], self::NOW); + self::assertSame(1, $this->activities->delete($id)); + self::assertNull($this->activities->get($id)); + self::assertSame(0, $this->activities->delete($id), 'a second delete is a no-op'); + } + + public function test_an_unknown_subject_type_yields_an_empty_timeline_not_an_error(): void + { + self::assertSame([], $this->activities->forSubject('deal', 1)); + } +} diff --git a/tests/ContactsAdminTest.php b/tests/ContactsAdminTest.php index 131b263..241b978 100644 --- a/tests/ContactsAdminTest.php +++ b/tests/ContactsAdminTest.php @@ -6,6 +6,7 @@ use Nimbus\Database\Connection; use Nimbus\Plugin\PluginStorage; +use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Contacts; use NimbusCMS\Crm\ContactsAdmin; use NimbusCMS\Crm\Organizations; @@ -21,6 +22,7 @@ final class ContactsAdminTest extends TestCase { private Contacts $contacts; + private Activities $activities; private ContactsAdmin $admin; protected function setUp(): void @@ -32,15 +34,21 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + $db->execute('TRUNCATE ' . Schema::ACTIVITY); - $storage = new PluginStorage($db); - $this->contacts = new Contacts(static fn (): PluginStorage => $storage); - $this->admin = new ContactsAdmin($this->contacts, new Organizations(static fn (): PluginStorage => $storage)); + $storage = new PluginStorage($db); + $this->contacts = new Contacts(static fn (): PluginStorage => $storage); + $this->activities = new Activities(static fn (): PluginStorage => $storage); + $this->admin = new ContactsAdmin( + $this->contacts, + new Organizations(static fn (): PluginStorage => $storage), + $this->activities, + ); } public function test_the_list_escapes_hostile_author_values(): void @@ -73,4 +81,19 @@ public function test_the_edit_form_escapes_the_loaded_values(): void self::assertStringContainsString('<b>note</b> & things', $html); self::assertStringContainsString('Edit contact', $html); } + + public function test_the_timeline_shows_only_when_editing_and_escapes_activity_bodies(): void + { + $id = $this->contacts->save(null, ['first_name' => 'Ada'], '2026-01-01 09:00:00'); + + // No timeline block on the plain list view. + self::assertStringNotContainsString('Log activity', $this->admin->render('CSRF123', null, null, null, 'n')); + + $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $id, 'body' => ''], '2026-01-01 09:00:00'); + $html = $this->admin->render('CSRF123', null, (string) $id, null, 'n'); + + self::assertStringContainsString('Log activity', $html, 'the add-activity form shows on the edit page'); + self::assertStringNotContainsString('', $html, 'a hostile activity body is escaped'); + self::assertStringContainsString('<script>', $html); + } } diff --git a/tests/ContactsTest.php b/tests/ContactsTest.php index 15f5892..5ac137e 100644 --- a/tests/ContactsTest.php +++ b/tests/ContactsTest.php @@ -6,6 +6,7 @@ use Nimbus\Database\Connection; use Nimbus\Plugin\PluginStorage; +use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Contacts; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; @@ -22,6 +23,7 @@ final class ContactsTest extends TestCase { private Contacts $contacts; private Organizations $organizations; + private Activities $activities; protected function setUp(): void { @@ -32,15 +34,17 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + $db->execute('TRUNCATE ' . Schema::ACTIVITY); $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); } private const NOW = '2026-01-01 09:00:00'; @@ -143,6 +147,17 @@ public function test_org_id_can_be_cleared_by_sending_blank(): void self::assertNull($this->contacts->get($id)['org_id'], 'a blank org_id unlinks'); } + public function test_deleting_a_contact_also_forgets_its_activities(): void + { + $id = $this->contacts->save(null, ['first_name' => 'Ada', 'last_name' => 'Lovelace'], self::NOW); + $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $id, 'body' => 'Called about the engine.'], self::NOW); + $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $id, 'kind' => 'note', 'body' => 'Sent notes.'], self::NOW); + self::assertCount(2, $this->activities->forSubject('contact', $id)); + + self::assertSame(1, $this->contacts->delete($id)); + self::assertSame([], $this->activities->forSubject('contact', $id), 'no activity residue after a contact is forgotten'); + } + 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 42e6e27..c0a58de 100644 --- a/tests/CrmToolsetTest.php +++ b/tests/CrmToolsetTest.php @@ -10,6 +10,7 @@ use Nimbus\Database\Connection; use Nimbus\Mcp\McpError; use Nimbus\Plugin\PluginStorage; +use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Contacts; use NimbusCMS\Crm\CrmToolset; use NimbusCMS\Crm\Organizations; @@ -37,16 +38,18 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + $db->execute('TRUNCATE ' . Schema::ACTIVITY); $storage = new PluginStorage($db); $this->toolset = new CrmToolset( new Contacts(static fn (): PluginStorage => $storage), new Organizations(static fn (): PluginStorage => $storage), + new Activities(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'); @@ -72,13 +75,14 @@ public function test_the_tools_are_namespaced_and_split_read_from_write(): void self::assertSame([ 'crm_contacts', 'crm_contact_get', 'crm_contact_set', 'crm_contact_delete', 'crm_organizations', 'crm_organization_get', 'crm_organization_set', 'crm_organization_delete', + 'crm_activities', 'crm_activity_add', 'crm_activity_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', 'crm_organizations', 'crm_organization_get'], $names); + self::assertSame(['crm_contacts', 'crm_contact_get', 'crm_organizations', 'crm_organization_get', 'crm_activities'], $names); } public function test_a_content_token_cannot_reach_contacts(): void @@ -167,4 +171,52 @@ public function test_creating_an_org_without_a_name_comes_back_as_data(): void self::assertFalse($out['ok']); self::assertSame('invalid', $out['error']); } + + public function test_a_content_token_cannot_reach_activities_either(): void + { + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unknown tool "crm_activity_add"'); + $this->toolset->call('crm_activity_add', [], $this->principal('*:read', '*:write'), $this->ctx); + } + + public function test_an_activity_is_logged_under_the_token_name_and_listed(): void + { + $write = $this->principal('nimbuscms.crm:read', 'nimbuscms.crm:write'); + $c = $this->toolset->call('crm_contact_set', ['first_name' => 'Ada'], $write, $this->ctx); + $cid = $c['contact']['id']; + + $out = $this->toolset->call('crm_activity_add', [ + 'subject_type' => 'contact', 'subject_id' => $cid, 'kind' => 'call', 'body' => 'Discussed the engine.', + ], $write, $this->ctx); + self::assertTrue($out['ok']); + self::assertSame('crm-bot', $out['activity']['author'], 'the author is the token name, not a client field'); + + $list = $this->toolset->call('crm_activities', ['subject_type' => 'contact', 'subject_id' => $cid], $write, $this->ctx); + self::assertSame(1, $list['count']); + self::assertSame('Discussed the engine.', $list['activities'][0]['body']); + + $del = $this->toolset->call('crm_activity_delete', ['id' => $out['activity']['id']], $write, $this->ctx); + self::assertTrue($del['deleted']); + } + + public function test_author_cannot_be_over_posted_on_an_activity(): void + { + $write = $this->principal('nimbuscms.crm:read', 'nimbuscms.crm:write'); + $c = $this->toolset->call('crm_contact_set', ['first_name' => 'Ada'], $write, $this->ctx); + + $out = $this->toolset->call('crm_activity_add', [ + 'subject_type' => 'contact', 'subject_id' => $c['contact']['id'], 'body' => 'x', 'author' => 'spoofed-name', + ], $write, $this->ctx); + self::assertTrue($out['ok']); + self::assertSame('crm-bot', $out['activity']['author'], 'a client-supplied author is ignored'); + } + + public function test_logging_against_a_missing_subject_comes_back_as_data(): void + { + $out = $this->toolset->call('crm_activity_add', [ + 'subject_type' => 'contact', 'subject_id' => 999999, 'body' => 'ghost', + ], $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 index d81c927..5f82250 100644 --- a/tests/OrganizationsAdminTest.php +++ b/tests/OrganizationsAdminTest.php @@ -6,6 +6,7 @@ use Nimbus\Database\Connection; use Nimbus\Plugin\PluginStorage; +use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\OrganizationsAdmin; use NimbusCMS\Crm\Schema; @@ -20,6 +21,7 @@ final class OrganizationsAdminTest extends TestCase { private Organizations $organizations; + private Activities $activities; private OrganizationsAdmin $admin; protected function setUp(): void @@ -31,14 +33,16 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach (Schema::organizations() as $sql) { + foreach ([...Schema::organizations(), ...Schema::activities()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + $db->execute('TRUNCATE ' . Schema::ACTIVITY); $storage = new PluginStorage($db); $this->organizations = new Organizations(static fn (): PluginStorage => $storage); - $this->admin = new OrganizationsAdmin($this->organizations); + $this->activities = new Activities(static fn (): PluginStorage => $storage); + $this->admin = new OrganizationsAdmin($this->organizations, $this->activities); } public function test_the_list_escapes_hostile_author_values(): void diff --git a/tests/OrganizationsTest.php b/tests/OrganizationsTest.php index f3edc1c..20ed4d9 100644 --- a/tests/OrganizationsTest.php +++ b/tests/OrganizationsTest.php @@ -6,6 +6,7 @@ use Nimbus\Database\Connection; use Nimbus\Plugin\PluginStorage; +use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Contacts; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; @@ -21,6 +22,7 @@ final class OrganizationsTest extends TestCase { private Organizations $organizations; private Contacts $contacts; + private Activities $activities; protected function setUp(): void { @@ -31,15 +33,17 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + $db->execute('TRUNCATE ' . Schema::ACTIVITY); $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); } private const NOW = '2026-01-01 09:00:00'; @@ -115,6 +119,20 @@ public function test_delete_unlinks_contacts_atomically_and_keeps_them(): void self::assertFalse($this->organizations->delete($orgId), 'a second delete is a no-op'); } + public function test_delete_forgets_the_orgs_own_activities_but_not_its_peoples(): void + { + $orgId = $this->organizations->save(null, ['name' => 'Doomed Co'], self::NOW); + $cId = $this->contacts->save(null, ['first_name' => 'Ada', 'org_id' => (string) $orgId], self::NOW); + + $this->activities->add(['subject_type' => 'organization', 'subject_id' => (string) $orgId, 'body' => 'Signed the contract.'], self::NOW); + $this->activities->add(['subject_type' => 'contact', 'subject_id' => (string) $cId, 'body' => 'Personal note about Ada.'], self::NOW); + + self::assertTrue($this->organizations->delete($orgId)); + + self::assertSame([], $this->activities->forSubject('organization', $orgId), "the company's own timeline is removed with it"); + self::assertCount(1, $this->activities->forSubject('contact', $cId), "the surviving person's activities are kept"); + } + public function test_exists_reports_presence(): void { $id = $this->organizations->save(null, ['name' => 'Acme'], self::NOW);