diff --git a/src/Activities.php b/src/Activities.php index c431e59..6062e26 100644 --- a/src/Activities.php +++ b/src/Activities.php @@ -14,8 +14,7 @@ * * - **`subject_type` is a write-time allow-list** ({@see SUBJECTS}), never * interpolated into SQL. It selects the table the subject must live in, and is - * stored as a bound parameter. `deal` is reserved in the column ENUM but not in - * the allow-list — it is rejected until the deals slice adds it here. + * stored as a bound parameter — a contact, an organization or a deal. * - **The subject must exist.** A bound `SELECT` against the mapped table rejects a * dangling reference, so an activity can never point at a contact/org that isn't * there. @@ -37,6 +36,7 @@ final class Activities private const SUBJECTS = [ self::SUBJECT_CONTACT => Schema::CONTACT, self::SUBJECT_ORGANIZATION => Schema::ORGANIZATION, + self::SUBJECT_DEAL => Schema::DEAL, ]; /** @var list */ diff --git a/src/Contacts.php b/src/Contacts.php index eccfee8..b193780 100644 --- a/src/Contacts.php +++ b/src/Contacts.php @@ -135,6 +135,8 @@ public function delete(int $id): int 'DELETE FROM ' . Schema::ACTIVITY . ' WHERE subject_type = :type AND subject_id = :id', ['type' => Activities::SUBJECT_CONTACT, 'id' => $id], ); + // A deal outlives the person, but must not dangle: clear the link. + $this->storage()->execute('UPDATE ' . Schema::DEAL . ' SET contact_id = NULL WHERE contact_id = :id', ['id' => $id]); return $this->storage()->execute('DELETE FROM ' . Schema::CONTACT . ' WHERE id = :id', ['id' => $id]); }); } diff --git a/src/CrmPlugin.php b/src/CrmPlugin.php index 4a3b510..484478f 100644 --- a/src/CrmPlugin.php +++ b/src/CrmPlugin.php @@ -19,7 +19,7 @@ * touching no core data; no public/site surface at all. * * Slice 1: contacts. Slice 2: organizations + the contact→org link. Slice 3: the - * activity timeline against a contact or an organization. + * activity timeline. Slice 4: the deal pipeline. */ final class CrmPlugin implements Plugin { @@ -31,6 +31,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()); + $context->migrations()->register('004_deals', Schema::deals()); // Grantable, wildcard-immune: nimbuscms.crm:read / :write. Contact data is // PII — a content *:write token can never read or change it. @@ -41,9 +42,10 @@ public function register(PluginContext $context): void $contacts = new Contacts($storage); $organizations = new Organizations($storage); $activities = new Activities($storage); + $deals = new Deals($storage); // The agent surface — every tool gates on nimbuscms.crm:read|write (ADR 0016). - $context->mcp()->register(new CrmToolset($contacts, $organizations, $activities)); + $context->mcp()->register(new CrmToolset($contacts, $organizations, $activities, $deals)); // 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 @@ -125,13 +127,57 @@ 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). + // Deals: the pipeline board. Same crm:write gate. + $context->adminPages()->register( + 'crm-deals', + 'Deals', + '📊', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new DealsAdmin($deals, $contacts, $organizations, $activities))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce), + self::ID . ':write', + ); + + $context->adminPages()->action('crm-deals', 'deal-save', static function (Request $r) use ($deals): Response { + $fields = [ + 'title' => (string) ($r->input('title') ?? ''), + 'value' => (string) ($r->input('value') ?? ''), + 'currency' => (string) ($r->input('currency') ?? ''), + 'stage' => (string) ($r->input('stage') ?? ''), + 'status' => (string) ($r->input('status') ?? ''), + 'contact_id' => (string) ($r->input('contact_id') ?? ''), + 'org_id' => (string) ($r->input('org_id') ?? ''), + ]; + $idIn = trim((string) ($r->input('id') ?? '')); + $id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null; + try { + $deals->save($id, $fields, date('Y-m-d H:i:s')); + return Response::redirect('/admin/crm-deals?ok=saved'); + } catch (\InvalidArgumentException $e) { + $code = str_contains($e->getMessage(), 'title') ? 'notitle' : 'invalid'; + return Response::redirect('/admin/crm-deals?err=' . $code); + } catch (\Throwable) { + return Response::redirect('/admin/crm-deals?err=invalid'); + } + }); + + $context->adminPages()->action('crm-deals', 'deal-delete', static function (Request $r) use ($deals): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + $deals->delete((int) $idIn); + } + return Response::redirect('/admin/crm-deals?ok=deleted'); + }); + + // Activities are logged inline on a contact, an organization or a deal. The + // same two actions serve every record page (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'; + $page = match ($r->input('subject_type')) { + Activities::SUBJECT_ORGANIZATION => 'crm-organizations', + Activities::SUBJECT_DEAL => 'crm-deals', + default => 'crm', + }; $sid = trim((string) ($r->input('subject_id') ?? '')); $edit = ($sid !== '' && ctype_digit($sid)) ? '?edit=' . $sid . '&' : '?'; return '/admin/' . $page . $edit; @@ -162,7 +208,7 @@ public function register(PluginContext $context): void return Response::redirect($back($r) . 'ok=activitygone'); }; - foreach (['crm', 'crm-organizations'] as $page) { + foreach (['crm', 'crm-organizations', 'crm-deals'] as $page) { $context->adminPages()->action($page, 'activity-add', $addActivity); $context->adminPages()->action($page, 'activity-delete', $deleteActivity); } diff --git a/src/CrmToolset.php b/src/CrmToolset.php index e986817..ee83972 100644 --- a/src/CrmToolset.php +++ b/src/CrmToolset.php @@ -12,8 +12,9 @@ /** * The CRM over MCP — an agent is a first-class operator of the CRM (ADR 0009/0016). * - * Tools under the `crm` namespace: `contacts` (list/search) and `contact_get` - * (reads); `contact_set` (create/update) and `contact_delete` (writes). The + * Tools under the `crm` namespace cover contacts, organizations, the activity + * timeline and the deal pipeline — reads (`*s`/`*_get`/`activities`) and writes + * (`*_set`/`*_add`/`*_delete`). The * {@see PluginToolset} base gates every one on this plugin's own `nimbuscms.crm` * capability (ADR 0015/0016) — a write tool needs `:write`, a read needs `:read`, * both **unreachable by a content `*:write` token** and invisible (a denied tool @@ -29,6 +30,7 @@ public function __construct( private Contacts $contacts, private Organizations $organizations, private Activities $activities, + private Deals $deals, ) { } @@ -39,8 +41,9 @@ public function namespace(): string protected function tools(): array { - $id = ['type' => 'integer', 'description' => 'The contact id.']; - $orgId = ['type' => 'integer', 'description' => 'The organization id.']; + $id = ['type' => 'integer', 'description' => 'The contact id.']; + $orgId = ['type' => 'integer', 'description' => 'The organization id.']; + $subjects = [Activities::SUBJECT_CONTACT, Activities::SUBJECT_ORGANIZATION, Activities::SUBJECT_DEAL]; return [ new PluginTool('contacts', 'read', 'List contacts (all, or those whose name or email matches a search).', [ @@ -100,21 +103,21 @@ protected function tools(): array 'properties' => ['id' => $orgId], ], $this->organizationDelete(...)), - new PluginTool('activities', 'read', 'The activity timeline for one subject (a contact or an organization), most recent first.', [ + new PluginTool('activities', 'read', 'The activity timeline for one subject (a contact, organization or deal), 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.'], + 'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'Whose timeline: "contact", "organization" or "deal".'], + 'subject_id' => ['type' => 'integer', 'description' => 'The contact, organization or deal 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.', [ + new PluginTool('activity_add', 'write', 'Log an activity (a note/call/email/meeting) against a contact, organization or deal. 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.'], + 'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'What to attach it to: "contact", "organization" or "deal".'], + 'subject_id' => ['type' => 'integer', 'description' => 'The existing contact, organization or deal 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.'], @@ -126,6 +129,40 @@ protected function tools(): array 'required' => ['id'], 'properties' => ['id' => ['type' => 'integer', 'description' => 'The activity id.']], ], $this->activityDelete(...)), + + new PluginTool('deals', 'read', 'List deals in the pipeline (optionally filtered by status, or searched by title).', [ + 'type' => 'object', + 'properties' => [ + 'q' => ['type' => 'string', 'description' => 'Optional search over the deal title.'], + 'status' => ['type' => 'string', 'enum' => Deals::STATUSES, 'description' => 'Optional filter: "open", "won" or "lost".'], + ], + ], $this->deals(...)), + + new PluginTool('deal_get', 'read', 'One deal by id, or none.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The deal id.']], + ], $this->dealGet(...)), + + new PluginTool('deal_set', 'write', 'Create a deal (omit id) or update one (with id). Only the fields you send change.', [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'Existing deal id to update; omit to create.'], + 'title' => ['type' => 'string', 'description' => 'Deal title (required to create).'], + 'value' => ['type' => 'string', 'description' => 'Money value, non-negative, up to 2 decimals. Optional; blank to clear.'], + 'currency' => ['type' => 'string', 'description' => '3-letter currency code. Defaults to USD.'], + 'stage' => ['type' => 'string', 'enum' => Deals::STAGES, 'description' => 'Pipeline stage. Defaults to "lead".'], + 'status' => ['type' => 'string', 'enum' => Deals::STATUSES, 'description' => 'Deal status. Defaults to "open".'], + 'contact_id' => ['type' => 'integer', 'description' => 'An existing contact to link. Optional; blank to unlink.'], + 'org_id' => ['type' => 'integer', 'description' => 'An existing organization to link. Optional; blank to unlink.'], + ], + ], $this->dealSet(...)), + + new PluginTool('deal_delete', 'write', 'Delete a deal by id, together with its activity timeline.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The deal id.']], + ], $this->dealDelete(...)), ]; } @@ -251,6 +288,48 @@ private function activityDelete(array $a, TokenPrincipal $p, EntryOpContext $c): return ['ok' => true, 'deleted' => $this->activities->delete($id) > 0]; } + /** + * @param array $a + * @return array + */ + private function deals(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $list = $this->deals->all($this->nullableStr($a, 'q'), $this->nullableStr($a, 'status')); + return ['deals' => $list, 'count' => count($list)]; + } + + /** + * @param array $a + * @return array + */ + private function dealGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['id' => $id, 'deal' => $this->deals->get($id)]; + } + + /** + * @param array $a + * @return array + */ + private function dealSet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->deals->save($this->nullableInt($a, 'id'), $a, $this->now()); + return ['ok' => true, 'deal' => $this->deals->get($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function dealDelete(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['ok' => true, 'deleted' => $this->deals->delete($id) > 0]; + } + // --- helpers --------------------------------------------------------- /** diff --git a/src/Deals.php b/src/Deals.php new file mode 100644 index 0000000..b4195a5 --- /dev/null +++ b/src/Deals.php @@ -0,0 +1,293 @@ + the pipeline columns, in order */ + public const STAGES = ['lead', 'qualified', 'proposal', 'negotiation']; + + /** @var list */ + public const STATUSES = ['open', 'won', 'lost']; + + private const MAX_TITLE = 200; + // DECIMAL(18,2): up to 16 integer digits before the point. + private const MAX_VALUE_INT_DIGITS = 16; + + /** @param \Closure():PluginStorage $storage resolved lazily, so construction runs no query */ + public function __construct(private \Closure $storage) + { + } + + /** + * Create (id null) or update (id given) a deal from an allow-listed field set; + * unknown keys are ignored. Returns the deal id. + * + * @param array $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 deal with id {$id}."); + } + + $title = $this->title($fields, $existing); + $value = $this->value($fields, $existing); + $currency = $this->currency($fields, $existing); + $stage = $this->enum($fields, $existing, 'stage', self::STAGES, 'lead'); + $status = $this->enum($fields, $existing, 'status', self::STATUSES, 'open'); + $contactId = $this->link($fields, $existing, 'contact_id', Schema::CONTACT); + $orgId = $this->link($fields, $existing, 'org_id', Schema::ORGANIZATION); + + $params = ['title' => $title, 'value' => $value, 'currency' => $currency, 'stage' => $stage, 'status' => $status, 'contact' => $contactId, 'org' => $orgId]; + + if ($id === null) { + return $this->storage()->insert( + 'INSERT INTO ' . Schema::DEAL . ' (title, value, currency, stage, status, contact_id, org_id, created_at, updated_at) + VALUES (:title, :value, :currency, :stage, :status, :contact, :org, :created, :updated)', + $params + ['created' => $now, 'updated' => $now], + ); + } + + $this->storage()->execute( + 'UPDATE ' . Schema::DEAL . ' SET title = :title, value = :value, currency = :currency, stage = :stage, status = :status, contact_id = :contact, org_id = :org, updated_at = :updated WHERE id = :id', + $params + ['updated' => $now, 'id' => $id], + ); + return $id; + } + + /** + * @return array{id:int,title:string,value:?string,currency:string,stage:string,status:string,contact_id:?int,contact:?string,org_id:?int,organization:?string,created_at:string,updated_at:string}|null + */ + public function get(int $id): ?array + { + $row = $this->storage()->selectOne( + $this->selectExpr() . ' WHERE d.id = :id', + ['id' => $id], + ); + return $row === null ? null : $this->hydrate($row); + } + + /** + * Deals for the board / MCP, optionally filtered by an allow-listed `$status` + * and/or a bound, wildcard-escaped title search. Ordered by pipeline stage then + * most-recently-updated, so the board can bucket them in column order. + * + * @return list + */ + public function all(?string $q = null, ?string $status = null): array + { + $where = []; + $params = []; + + if ($status !== null && $status !== '' && in_array($status, self::STATUSES, true)) { + $where[] = 'd.status = :status'; + $params['status'] = $status; + } + $q = $q !== null ? trim($q) : ''; + if ($q !== '') { + $params['q'] = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_substr($q, 0, 100)) . '%'; + $where[] = "d.title LIKE :q ESCAPE '\\\\'"; + } + + $sql = $this->selectExpr(); + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= " ORDER BY FIELD(d.stage, 'lead', 'qualified', 'proposal', 'negotiation'), d.updated_at DESC, d.id DESC"; + + return array_map($this->hydrate(...), $this->storage()->select($sql, $params)); + } + + /** + * Delete a deal and its activity timeline, atomically. Returns the number of + * deal rows removed (0 if none). + */ + public function delete(int $id): int + { + 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_DEAL, 'id' => $id], + ); + return $this->storage()->execute('DELETE FROM ' . Schema::DEAL . ' WHERE id = :id', ['id' => $id]); + }); + } + + // --- validation / hydration ----------------------------------------- + + private function selectExpr(): string + { + return 'SELECT d.id, d.title, d.value, d.currency, d.stage, d.status, d.contact_id, d.org_id, + TRIM(CONCAT(COALESCE(c.first_name, \'\'), \' \', COALESCE(c.last_name, \'\'))) AS contact, + o.name AS organization, d.created_at, d.updated_at + FROM ' . Schema::DEAL . ' d + LEFT JOIN ' . Schema::CONTACT . ' c ON c.id = d.contact_id + LEFT JOIN ' . Schema::ORGANIZATION . ' o ON o.id = d.org_id'; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function title(array $fields, ?array $existing): string + { + if (!array_key_exists('title', $fields)) { + if ($existing !== null) { + return (string) $existing['title']; + } + throw new \InvalidArgumentException('A deal needs a title.'); + } + $title = trim((string) $fields['title']); + if ($title === '') { + throw new \InvalidArgumentException('A deal needs a title.'); + } + if (mb_strlen($title) > self::MAX_TITLE) { + throw new \InvalidArgumentException('A deal title must be ' . self::MAX_TITLE . ' characters or fewer.'); + } + return $title; + } + + /** + * A non-negative money value with at most two decimal places, or null. Absent on + * an update keeps the stored value. + * + * @param array $fields + * @param array|null $existing + */ + private function value(array $fields, ?array $existing): ?string + { + if (!array_key_exists('value', $fields)) { + return $existing !== null ? ($existing['value'] === null ? null : (string) $existing['value']) : null; + } + $raw = str_replace([',', ' '], '', trim((string) $fields['value'])); + if ($raw === '') { + return null; + } + if (preg_match('/^\d{1,' . self::MAX_VALUE_INT_DIGITS . '}(\.\d{1,2})?$/', $raw) !== 1) { + throw new \InvalidArgumentException('"value" must be a non-negative amount with up to two decimal places.'); + } + return number_format((float) $raw, 2, '.', ''); + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function currency(array $fields, ?array $existing): string + { + if (!array_key_exists('currency', $fields)) { + return $existing !== null ? (string) $existing['currency'] : 'USD'; + } + $raw = trim((string) $fields['currency']); + if ($raw === '') { + return 'USD'; + } + if (preg_match('/^[A-Za-z]{3}$/', $raw) !== 1) { + throw new \InvalidArgumentException('"currency" must be a 3-letter code (e.g. USD).'); + } + return strtoupper($raw); + } + + /** + * A value from a fixed allow-list; never interpolated into SQL. Absent on an + * update keeps the stored value; absent on a create takes `$default`. + * + * @param array $fields + * @param array|null $existing + * @param list $allowed + */ + private function enum(array $fields, ?array $existing, string $key, array $allowed, string $default): string + { + if (!array_key_exists($key, $fields)) { + return $existing !== null ? (string) $existing[$key] : $default; + } + $v = trim((string) $fields[$key]); + if ($v === '') { + return $default; + } + if (!in_array($v, $allowed, true)) { + throw new \InvalidArgumentException("\"{$key}\" must be one of: " . implode(', ', $allowed) . '.'); + } + return $v; + } + + /** + * A soft link (contact_id/org_id): null, or an id that must exist in `$table`. + * Absent on an update keeps the stored value. + * + * @param array $fields + * @param array|null $existing + */ + private function link(array $fields, ?array $existing, string $key, string $table): ?int + { + if (!array_key_exists($key, $fields)) { + return $existing !== null ? ($existing[$key] === null ? null : (int) $existing[$key]) : null; + } + $raw = trim((string) $fields[$key]); + if ($raw === '') { + return null; + } + if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1) { + throw new \InvalidArgumentException("\"{$key}\" must be a positive whole number or blank."); + } + $linkId = (int) $raw; + if ($this->storage()->selectOne('SELECT id FROM ' . $table . ' WHERE id = :id', ['id' => $linkId]) === null) { + $what = $table === Schema::CONTACT ? 'contact' : 'organization'; + throw new \InvalidArgumentException("No {$what} with id {$linkId}."); + } + return $linkId; + } + + /** + * @param array $row + * @return array{id:int,title:string,value:?string,currency:string,stage:string,status:string,contact_id:?int,contact:?string,org_id:?int,organization:?string,created_at:string,updated_at:string} + */ + private function hydrate(array $row): array + { + $contact = isset($row['contact']) && trim((string) $row['contact']) !== '' ? (string) $row['contact'] : null; + + return [ + 'id' => (int) $row['id'], + 'title' => (string) $row['title'], + 'value' => $row['value'] === null ? null : (string) $row['value'], + 'currency' => (string) $row['currency'], + 'stage' => (string) $row['stage'], + 'status' => (string) $row['status'], + 'contact_id' => $row['contact_id'] === null ? null : (int) $row['contact_id'], + 'contact' => $contact, + 'org_id' => $row['org_id'] === null ? null : (int) $row['org_id'], + 'organization' => ($row['organization'] ?? null) === null ? null : (string) $row['organization'], + 'created_at' => (string) $row['created_at'], + 'updated_at' => (string) $row['updated_at'], + ]; + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/src/DealsAdmin.php b/src/DealsAdmin.php new file mode 100644 index 0000000..30265af --- /dev/null +++ b/src/DealsAdmin.php @@ -0,0 +1,274 @@ +` block (the admin CSP is nonce-only for `style-src`), and the page + its + * POST actions are gated on `nimbuscms.crm:write` + CSRF by core (ADR 0020). The + * board reflows to a single column on a phone. + */ +final class DealsAdmin +{ + private const NOTICES = [ + 'saved' => ['ok', 'Deal saved.'], + 'deleted' => ['ok', 'Deal deleted.'], + 'activity' => ['ok', 'Activity logged.'], + 'activitygone' => ['ok', 'Activity deleted.'], + 'notitle' => ['err', 'A deal needs a title.'], + 'activitybad' => ['err', 'Could not log that activity — check the details.'], + 'invalid' => ['err', 'Check the details and try again.'], + ]; + + private const STAGE_LABELS = [ + 'lead' => 'Lead', + 'qualified' => 'Qualified', + 'proposal' => 'Proposal', + 'negotiation' => 'Negotiation', + ]; + + public function __construct( + private Deals $deals, + private Contacts $contacts, + private Organizations $organizations, + private Activities $activities, + ) { + } + + 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; + $editDeal = $editId !== null ? $this->deals->get($editId) : null; + $q = $q !== null ? trim($q) : ''; + + $html = $this->styles($nonce) + . '

Deals

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

Your pipeline of opportunities. Move a deal along by changing its stage; mark it won or lost when it closes.

' + . $this->form($csrf, $editDeal); + + if ($editDeal !== null) { + $html .= ActivitiesAdmin::render($csrf, 'crm-deals', Activities::SUBJECT_DEAL, (int) $editDeal['id'], $this->activities->forSubject(Activities::SUBJECT_DEAL, (int) $editDeal['id']), $nonce); + } + + $html .= $this->search($q); + $html .= $q !== '' ? $this->results($csrf, $q) : $this->board($csrf); + + return $html; + } + + /** @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 deal' : 'Add a deal') . '

' + . '
' + . '' . $idField + . '' + . '
' + . '' + . '' + . '
' + . '
' + . '' + . '' + . '
' + . '
' + . '' + . '' + . '
' + . '
' + . ($edit !== null ? ' Cancel' : '') + . '
'; + } + + /** @param list $allowed */ + private function enumSelect(string $name, array $allowed, string $current): string + { + $options = ''; + foreach ($allowed as $v) { + $label = self::STAGE_LABELS[$v] ?? ucfirst($v); + $options .= ''; + } + return ''; + } + + private function contactSelect(?int $current): string + { + $options = ''; + foreach ($this->contacts->all() as $c) { + $name = trim(((string) $c['first_name']) . ' ' . ((string) $c['last_name'])); + $options .= ''; + } + return ''; + } + + private function orgSelect(?int $current): string + { + $options = ''; + foreach ($this->organizations->all() as $o) { + $options .= ''; + } + return ''; + } + + private function search(string $q): string + { + return ''; + } + + private function results(string $csrf, string $q): string + { + $deals = $this->deals->all($q); + if ($deals === []) { + return '

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

'; + } + $cards = ''; + foreach ($deals as $d) { + $cards .= $this->card($csrf, $d, true); + } + return '
    ' . $cards . '
'; + } + + private function board(string $csrf): string + { + $open = $this->deals->all(null, 'open'); + + $byStage = []; + foreach (Deals::STAGES as $s) { + $byStage[$s] = []; + } + foreach ($open as $d) { + $byStage[(string) $d['stage']][] = $d; + } + + $cols = ''; + foreach (Deals::STAGES as $stage) { + $deals = $byStage[$stage]; + $sum = 0.0; + foreach ($deals as $d) { + $sum += $d['value'] !== null ? (float) $d['value'] : 0.0; + } + $cards = ''; + foreach ($deals as $d) { + $cards .= $this->card($csrf, $d, false); + } + if ($cards === '') { + $cards = '

No deals.

'; + } + $total = $sum > 0 ? '' . self::e(number_format($sum, 2, '.', ',')) . '' : ''; + $cols .= '
' + . '

' . self::e(self::STAGE_LABELS[$stage]) . ' ' . count($deals) . '' . $total . '

' + . '
' . $cards . '
' + . '
'; + } + + return '
' . $cols . '
' . $this->closed($csrf); + } + + private function closed(string $csrf): string + { + $won = $this->deals->all(null, 'won'); + $lost = $this->deals->all(null, 'lost'); + if ($won === [] && $lost === []) { + return ''; + } + $cards = ''; + foreach ([...$won, ...$lost] as $d) { + $cards .= $this->card($csrf, $d, true); + } + return '

Closed

    ' . $cards . '
'; + } + + /** + * One deal card. `$showStatus` adds a status badge (used off-board, where the + * column no longer implies the stage/status). + * + * @param array $d + */ + private function card(string $csrf, array $d, bool $showStatus): string + { + $money = ''; + if ($d['value'] !== null) { + $money = '' . self::e((string) $d['currency']) . ' ' . self::e(number_format((float) $d['value'], 2, '.', ',')) . ''; + } + $who = []; + if (($d['contact'] ?? null) !== null && (string) $d['contact'] !== '') { + $who[] = self::e((string) $d['contact']); + } + if (($d['organization'] ?? null) !== null && (string) $d['organization'] !== '') { + $who[] = self::e((string) $d['organization']); + } + $badge = $showStatus ? '' . self::e(ucfirst((string) $d['status'])) . '' : ''; + + return '
  • ' + . '' + . ($money !== '' ? '
    ' . $money . '
    ' : '') + . ($who !== [] ? '
    ' . implode(' · ', $who) . '
    ' : '') + . '
    ' + . '' + . '' + . '
    ' + . '
  • '; + } + + 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 (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/Guide.php b/src/Guide.php index f7f9747..7d43f44 100644 --- a/src/Guide.php +++ b/src/Guide.php @@ -17,11 +17,11 @@ public static function text(): string # CRM A back-office CRM: **contacts** (people), the **organizations** (companies) they - belong to, 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. + belong to, an **activity timeline** against any of them, and a **deal pipeline**. + It is **PII**, so every tool is gated by the `nimbuscms.crm` capability: a read + needs `nimbuscms.crm:read`, a write needs `nimbuscms.crm:write`. A content + `*:write` token cannot reach it, and a tool you lack the capability for is + invisible. ## Contacts @@ -49,8 +49,8 @@ public static function text(): string ## Activities - A timeline of dated, typed entries against a **subject** — a `contact` or an - `organization`. + A timeline of dated, typed entries against a **subject** — a `contact`, an + `organization` or a `deal`. - `crm_activities` — the timeline for one subject (`subject_type`, `subject_id`), most recent first. @@ -60,9 +60,28 @@ public static function text(): string 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. + Deleting a contact, organization or deal also removes its activities, so a + "forget" leaves nothing behind. + + ## Deals + + The sales pipeline — an opportunity with a `title`, an optional money `value`, + the `stage` it sits in and a `status`. + + - `crm_deals` — list the pipeline; filter by `status` (`open`/`won`/`lost`) or + search by title (`q`). + - `crm_deal_get` — one deal by `id`. + - `crm_deal_set` — create (omit `id`) or update (with `id`). Fields: `title` + (required to create), `value` (non-negative, ≤ 2 decimals), `currency` + (3-letter code, defaults USD), `stage` (`lead`/`qualified`/`proposal`/ + `negotiation`, defaults `lead`), `status` (`open`/`won`/`lost`, defaults + `open`), `contact_id` and `org_id` (existing records to link; blank to unlink). + Only the fields you send change. + - `crm_deal_delete` — remove a deal by `id`, together with its activities. + + Deleting a contact or organization keeps any deal that referenced it — the link + is simply cleared. Values are stored as you send them and escaped when displayed; + there is no public page for CRM data. MD; } } diff --git a/src/Organizations.php b/src/Organizations.php index f81e90e..4598c65 100644 --- a/src/Organizations.php +++ b/src/Organizations.php @@ -96,15 +96,17 @@ public function all(?string $q = null): array } /** - * 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. + * Delete an organization atomically: its contacts and deals 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 or deal), 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]); + // A deal outlives the company, but must not dangle: clear the link. + $this->storage()->execute('UPDATE ' . Schema::DEAL . ' 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], diff --git a/src/Schema.php b/src/Schema.php index 6ed4d20..5024bc0 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -19,6 +19,7 @@ final class Schema public const CONTACT = 'crm_contact'; public const ORGANIZATION = 'crm_organization'; public const ACTIVITY = 'crm_activity'; + public const DEAL = 'crm_deal'; /** @return list each statement individually idempotent (ADR 0005) */ public static function contacts(): array @@ -91,4 +92,35 @@ public static function activities(): array ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ]; } + + /** + * The deal pipeline — an opportunity worth a `value`, moving through a `stage` + * and ending `open`/`won`/`lost`. `contact_id`/`org_id` are **soft references** + * (no hard FK) validated at write and NULLed when their contact/org is deleted, + * so a deal is never destroyed — or left dangling — because a linked record was. + * `stage` and `status` are write-time allow-lists (never interpolated); `value` + * is a bounded, non-negative decimal. + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function deals(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::DEAL . " ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + value DECIMAL(18,2) NULL, + currency CHAR(3) NOT NULL DEFAULT 'USD', + stage ENUM('lead','qualified','proposal','negotiation') NOT NULL DEFAULT 'lead', + status ENUM('open','won','lost') NOT NULL DEFAULT 'open', + contact_id BIGINT UNSIGNED NULL, + org_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_deal_status_stage (status, stage), + INDEX idx_deal_contact (contact_id), + INDEX idx_deal_org (org_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + ]; + } } diff --git a/tests/ActivitiesTest.php b/tests/ActivitiesTest.php index 0f4d9d9..783be55 100644 --- a/tests/ActivitiesTest.php +++ b/tests/ActivitiesTest.php @@ -8,6 +8,7 @@ use Nimbus\Plugin\PluginStorage; use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Contacts; +use NimbusCMS\Crm\Deals; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; use PHPUnit\Framework\TestCase; @@ -23,6 +24,7 @@ final class ActivitiesTest extends TestCase private Activities $activities; private Contacts $contacts; private Organizations $organizations; + private Deals $deals; protected function setUp(): void { @@ -33,17 +35,19 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); + $db->execute('TRUNCATE ' . Schema::DEAL); $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); + $this->deals = new Deals(static fn (): PluginStorage => $storage); } private const NOW = '2026-01-01 09:00:00'; @@ -98,11 +102,12 @@ public function test_an_unknown_kind_is_rejected(): void $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 + public function test_an_activity_can_hang_off_a_deal(): 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); + $dealId = $this->deals->save(null, ['title' => 'Big one'], self::NOW); + $id = $this->activities->add(['subject_type' => 'deal', 'subject_id' => (string) $dealId, 'body' => 'Proposal sent.'], self::NOW); + self::assertSame($dealId, $this->activities->get($id)['subject_id']); + self::assertCount(1, $this->activities->forSubject('deal', $dealId)); } public function test_an_arbitrary_subject_type_is_rejected(): void @@ -144,6 +149,6 @@ public function test_delete_removes_one_entry(): void public function test_an_unknown_subject_type_yields_an_empty_timeline_not_an_error(): void { - self::assertSame([], $this->activities->forSubject('deal', 1)); + self::assertSame([], $this->activities->forSubject('user', 1)); } } diff --git a/tests/ContactsTest.php b/tests/ContactsTest.php index 5ac137e..981cbbd 100644 --- a/tests/ContactsTest.php +++ b/tests/ContactsTest.php @@ -8,6 +8,7 @@ use Nimbus\Plugin\PluginStorage; use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Contacts; +use NimbusCMS\Crm\Deals; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; use PHPUnit\Framework\TestCase; @@ -24,6 +25,7 @@ final class ContactsTest extends TestCase private Contacts $contacts; private Organizations $organizations; private Activities $activities; + private Deals $deals; protected function setUp(): void { @@ -34,17 +36,19 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); + $db->execute('TRUNCATE ' . Schema::DEAL); $storage = new PluginStorage($db); $this->contacts = new Contacts(static fn (): PluginStorage => $storage); $this->organizations = new Organizations(static fn (): PluginStorage => $storage); $this->activities = new Activities(static fn (): PluginStorage => $storage); + $this->deals = new Deals(static fn (): PluginStorage => $storage); } private const NOW = '2026-01-01 09:00:00'; @@ -158,6 +162,18 @@ public function test_deleting_a_contact_also_forgets_its_activities(): void self::assertSame([], $this->activities->forSubject('contact', $id), 'no activity residue after a contact is forgotten'); } + public function test_deleting_a_contact_keeps_its_deals_but_clears_the_link(): void + { + $id = $this->contacts->save(null, ['first_name' => 'Ada'], self::NOW); + $dealId = $this->deals->save(null, ['title' => 'Engine build', 'contact_id' => (string) $id], self::NOW); + + self::assertSame(1, $this->contacts->delete($id)); + + $deal = $this->deals->get($dealId); + self::assertNotNull($deal, 'the deal outlives the contact'); + self::assertNull($deal['contact_id'], 'the dangling contact link is cleared'); + } + 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 c0a58de..a80c8f1 100644 --- a/tests/CrmToolsetTest.php +++ b/tests/CrmToolsetTest.php @@ -13,6 +13,7 @@ use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Contacts; use NimbusCMS\Crm\CrmToolset; +use NimbusCMS\Crm\Deals; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; use PHPUnit\Framework\TestCase; @@ -38,18 +39,20 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); + $db->execute('TRUNCATE ' . Schema::DEAL); $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), + new Deals(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'); @@ -76,13 +79,14 @@ public function test_the_tools_are_namespaced_and_split_read_from_write(): void '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', + 'crm_deals', 'crm_deal_get', 'crm_deal_set', 'crm_deal_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', 'crm_activities'], $names); + self::assertSame(['crm_contacts', 'crm_contact_get', 'crm_organizations', 'crm_organization_get', 'crm_activities', 'crm_deals', 'crm_deal_get'], $names); } public function test_a_content_token_cannot_reach_contacts(): void @@ -219,4 +223,64 @@ public function test_logging_against_a_missing_subject_comes_back_as_data(): voi self::assertFalse($out['ok']); self::assertSame('invalid', $out['error']); } + + public function test_a_content_token_cannot_reach_deals_either(): void + { + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unknown tool "crm_deals"'); + $this->toolset->call('crm_deals', [], $this->principal('*:read', '*:write'), $this->ctx); + } + + public function test_deal_set_get_delete_round_trip_with_links(): void + { + $write = $this->principal('nimbuscms.crm:read', 'nimbuscms.crm:write'); + $c = $this->toolset->call('crm_contact_set', ['first_name' => 'Ada'], $write, $this->ctx); + $o = $this->toolset->call('crm_organization_set', ['name' => 'Acme'], $write, $this->ctx); + + $out = $this->toolset->call('crm_deal_set', [ + 'title' => 'Engine build', 'value' => '1500.5', 'currency' => 'gbp', 'stage' => 'proposal', + 'contact_id' => $c['contact']['id'], 'org_id' => $o['organization']['id'], + ], $write, $this->ctx); + self::assertTrue($out['ok']); + self::assertSame('1500.50', $out['deal']['value'], 'money is normalised to two places'); + self::assertSame('GBP', $out['deal']['currency'], 'currency is upper-cased'); + self::assertSame('Ada', $out['deal']['contact']); + self::assertSame('Acme', $out['deal']['organization']); + $dealId = $out['deal']['id']; + + $got = $this->toolset->call('crm_deal_get', ['id' => $dealId], $write, $this->ctx); + self::assertSame('proposal', $got['deal']['stage']); + + // An activity can hang off the deal, and goes with it on delete. + $this->toolset->call('crm_activity_add', ['subject_type' => 'deal', 'subject_id' => $dealId, 'body' => 'Sent proposal.'], $write, $this->ctx); + self::assertSame(1, $this->toolset->call('crm_activities', ['subject_type' => 'deal', 'subject_id' => $dealId], $write, $this->ctx)['count']); + + $del = $this->toolset->call('crm_deal_delete', ['id' => $dealId], $write, $this->ctx); + self::assertTrue($del['deleted']); + self::assertSame([], $this->toolset->call('crm_activities', ['subject_type' => 'deal', 'subject_id' => $dealId], $write, $this->ctx)['activities'], 'the deal timeline goes with it'); + } + + public function test_deals_filter_by_status(): void + { + $write = $this->principal('nimbuscms.crm:read', 'nimbuscms.crm:write'); + $this->toolset->call('crm_deal_set', ['title' => 'Open one', 'status' => 'open'], $write, $this->ctx); + $this->toolset->call('crm_deal_set', ['title' => 'Won one', 'status' => 'won'], $write, $this->ctx); + + self::assertSame(1, $this->toolset->call('crm_deals', ['status' => 'won'], $write, $this->ctx)['count']); + self::assertSame(2, $this->toolset->call('crm_deals', [], $write, $this->ctx)['count']); + } + + public function test_a_deal_without_a_title_comes_back_as_data(): void + { + $out = $this->toolset->call('crm_deal_set', ['value' => '100'], $this->principal('nimbuscms.crm:write'), $this->ctx); + self::assertFalse($out['ok']); + self::assertSame('invalid', $out['error']); + } + + public function test_a_bad_deal_value_comes_back_as_data(): void + { + $out = $this->toolset->call('crm_deal_set', ['title' => 'X', 'value' => '-5'], $this->principal('nimbuscms.crm:write'), $this->ctx); + self::assertFalse($out['ok']); + self::assertSame('invalid', $out['error']); + } } diff --git a/tests/DealsAdminTest.php b/tests/DealsAdminTest.php new file mode 100644 index 0000000..86ea0d8 --- /dev/null +++ b/tests/DealsAdminTest.php @@ -0,0 +1,98 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::CONTACT); + $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + $db->execute('TRUNCATE ' . Schema::ACTIVITY); + $db->execute('TRUNCATE ' . Schema::DEAL); + + $storage = new PluginStorage($db); + $this->deals = new Deals(static fn (): PluginStorage => $storage); + $this->admin = new DealsAdmin( + $this->deals, + new Contacts(static fn (): PluginStorage => $storage), + new Organizations(static fn (): PluginStorage => $storage), + new Activities(static fn (): PluginStorage => $storage), + ); + } + + public function test_the_board_escapes_a_hostile_deal_title(): void + { + $this->deals->save(null, ['title' => '', 'stage' => 'lead'], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'n'); + + self::assertStringNotContainsString('', $html, 'the title is escaped'); + self::assertStringContainsString('<script>', $html); + self::assertStringContainsString('value="CSRF123"', $html, 'the CSRF token is in the forms'); + self::assertStringContainsString('Lead', $html, 'stage columns are rendered'); + } + + public function test_won_and_lost_land_in_the_closed_section_not_the_board(): void + { + $this->deals->save(null, ['title' => 'Open deal', 'status' => 'open'], '2026-01-01 09:00:00'); + $this->deals->save(null, ['title' => 'Bagged it', 'status' => 'won'], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'n'); + + self::assertStringContainsString('Closed', $html); + self::assertStringContainsString('cr-badge-won', $html, 'a won badge shows in the closed list'); + } + + public function test_the_edit_form_and_timeline_show_when_editing(): void + { + $id = $this->deals->save(null, ['title' => 'Editable', 'value' => '1200'], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, (string) $id, null, 'n'); + + self::assertStringContainsString('Edit deal', $html); + self::assertStringContainsString('value="1200.00"', $html, 'the stored value is loaded into the form'); + self::assertStringContainsString('Log activity', $html, 'the timeline shows on a deal edit page'); + } + + public function test_search_shows_a_flat_result_list(): void + { + $this->deals->save(null, ['title' => 'Acme renewal'], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, 'Acme', 'n'); + self::assertStringContainsString('Acme renewal', $html); + self::assertStringContainsString('Back to board', $html, 'searching offers a way back to the board'); + } +} diff --git a/tests/DealsTest.php b/tests/DealsTest.php new file mode 100644 index 0000000..382f205 --- /dev/null +++ b/tests/DealsTest.php @@ -0,0 +1,177 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::CONTACT); + $db->execute('TRUNCATE ' . Schema::ORGANIZATION); + $db->execute('TRUNCATE ' . Schema::ACTIVITY); + $db->execute('TRUNCATE ' . Schema::DEAL); + + $storage = new PluginStorage($db); + $this->deals = new Deals(static fn (): PluginStorage => $storage); + $this->contacts = new Contacts(static fn (): PluginStorage => $storage); + $this->organizations = new Organizations(static fn (): PluginStorage => $storage); + $this->activities = new Activities(static fn (): PluginStorage => $storage); + } + + private const NOW = '2026-01-01 09:00:00'; + + public function test_create_defaults_and_update_round_trip(): void + { + $id = $this->deals->save(null, ['title' => 'Engine build'], self::NOW); + $d = $this->deals->get($id); + self::assertNotNull($d); + self::assertSame('lead', $d['stage'], 'stage defaults to lead'); + self::assertSame('open', $d['status'], 'status defaults to open'); + self::assertSame('USD', $d['currency']); + self::assertNull($d['value']); + + // Update only the stage; the title is unchanged. + $this->deals->save($id, ['stage' => 'qualified'], '2026-01-02 09:00:00'); + $d = $this->deals->get($id); + self::assertSame('qualified', $d['stage']); + self::assertSame('Engine build', $d['title']); + } + + public function test_a_deal_needs_a_title(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->deals->save(null, ['value' => '100'], self::NOW); + } + + public function test_money_is_normalised_and_bounded(): void + { + $id = $this->deals->save(null, ['title' => 'X', 'value' => '1,500.5', 'currency' => 'gbp'], self::NOW); + $d = $this->deals->get($id); + self::assertSame('1500.50', $d['value'], 'commas stripped, two decimal places'); + self::assertSame('GBP', $d['currency'], 'currency upper-cased'); + } + + public function test_a_negative_value_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->deals->save(null, ['title' => 'X', 'value' => '-5'], self::NOW); + } + + public function test_a_non_numeric_value_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->deals->save(null, ['title' => 'X', 'value' => '1000; DROP TABLE'], self::NOW); + } + + public function test_stage_and_status_are_allow_listed(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->deals->save(null, ['title' => 'X', 'stage' => 'schmoozing'], self::NOW); + } + + public function test_an_invalid_status_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->deals->save(null, ['title' => 'X', 'status' => 'maybe'], self::NOW); + } + + public function test_links_must_exist_and_resolve_names(): void + { + $cid = $this->contacts->save(null, ['first_name' => 'Ada', 'last_name' => 'Lovelace'], self::NOW); + $orgId = $this->organizations->save(null, ['name' => 'Acme'], self::NOW); + + $id = $this->deals->save(null, ['title' => 'X', 'contact_id' => (string) $cid, 'org_id' => (string) $orgId], self::NOW); + $d = $this->deals->get($id); + self::assertSame('Ada Lovelace', $d['contact']); + self::assertSame('Acme', $d['organization']); + } + + public function test_a_missing_contact_link_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->deals->save(null, ['title' => 'X', 'contact_id' => '999999'], self::NOW); + } + + public function test_over_posting_is_ignored(): void + { + $id = $this->deals->save(null, ['title' => 'X', 'id' => 4242, 'created_at' => '1900-01-01 00:00:00', 'evil' => 'x'], self::NOW); + self::assertNotSame(4242, $id); + $d = $this->deals->get($id); + self::assertSame(self::NOW, $d['created_at'], 'created_at is server-set'); + self::assertArrayNotHasKey('evil', $d); + } + + public function test_search_is_bound_and_status_filters(): void + { + $this->deals->save(null, ['title' => 'Acme renewal', 'status' => 'open'], self::NOW); + $this->deals->save(null, ['title' => 'Acme upsell', 'status' => 'won'], self::NOW); + $this->deals->save(null, ['title' => 'Globex', 'status' => 'open'], self::NOW); + + self::assertCount(2, $this->deals->all('Acme')); + self::assertCount(0, $this->deals->all('%'), 'a literal % is not match-all'); + self::assertCount(0, $this->deals->all("' OR '1'='1")); + self::assertCount(2, $this->deals->all(null, 'open')); + self::assertCount(1, $this->deals->all(null, 'won')); + } + + public function test_board_is_ordered_by_pipeline_stage(): void + { + $this->deals->save(null, ['title' => 'A', 'stage' => 'negotiation'], self::NOW); + $this->deals->save(null, ['title' => 'B', 'stage' => 'lead'], self::NOW); + $this->deals->save(null, ['title' => 'C', 'stage' => 'proposal'], self::NOW); + + $stages = array_column($this->deals->all(null, 'open'), 'stage'); + self::assertSame(['lead', 'proposal', 'negotiation'], $stages, 'ordered along the pipeline'); + } + + public function test_delete_takes_the_activity_timeline_with_it(): void + { + $id = $this->deals->save(null, ['title' => 'X'], self::NOW); + $this->activities->add(['subject_type' => 'deal', 'subject_id' => (string) $id, 'body' => 'note'], self::NOW); + self::assertCount(1, $this->activities->forSubject('deal', $id)); + + self::assertSame(1, $this->deals->delete($id)); + self::assertNull($this->deals->get($id)); + self::assertSame([], $this->activities->forSubject('deal', $id), 'no activity residue'); + self::assertSame(0, $this->deals->delete($id), 'a second delete is a no-op'); + } + + public function test_updating_a_missing_deal_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->deals->save(424242, ['title' => 'Ghost'], self::NOW); + } +} diff --git a/tests/OrganizationsTest.php b/tests/OrganizationsTest.php index 20ed4d9..37d756a 100644 --- a/tests/OrganizationsTest.php +++ b/tests/OrganizationsTest.php @@ -8,6 +8,7 @@ use Nimbus\Plugin\PluginStorage; use NimbusCMS\Crm\Activities; use NimbusCMS\Crm\Contacts; +use NimbusCMS\Crm\Deals; use NimbusCMS\Crm\Organizations; use NimbusCMS\Crm\Schema; use PHPUnit\Framework\TestCase; @@ -23,6 +24,7 @@ final class OrganizationsTest extends TestCase private Organizations $organizations; private Contacts $contacts; private Activities $activities; + private Deals $deals; protected function setUp(): void { @@ -33,17 +35,19 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities()] as $sql) { + foreach ([...Schema::contacts(), ...Schema::organizations(), ...Schema::activities(), ...Schema::deals()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::CONTACT); $db->execute('TRUNCATE ' . Schema::ORGANIZATION); $db->execute('TRUNCATE ' . Schema::ACTIVITY); + $db->execute('TRUNCATE ' . Schema::DEAL); $storage = new PluginStorage($db); $this->organizations = new Organizations(static fn (): PluginStorage => $storage); $this->contacts = new Contacts(static fn (): PluginStorage => $storage); $this->activities = new Activities(static fn (): PluginStorage => $storage); + $this->deals = new Deals(static fn (): PluginStorage => $storage); } private const NOW = '2026-01-01 09:00:00'; @@ -133,6 +137,18 @@ public function test_delete_forgets_the_orgs_own_activities_but_not_its_peoples( self::assertCount(1, $this->activities->forSubject('contact', $cId), "the surviving person's activities are kept"); } + public function test_delete_keeps_its_deals_but_clears_the_link(): void + { + $orgId = $this->organizations->save(null, ['name' => 'Doomed Co'], self::NOW); + $dealId = $this->deals->save(null, ['title' => 'Big renewal', 'org_id' => (string) $orgId], self::NOW); + + self::assertTrue($this->organizations->delete($orgId)); + + $deal = $this->deals->get($dealId); + self::assertNotNull($deal, 'the deal outlives the company'); + self::assertNull($deal['org_id'], 'the dangling org link is cleared'); + } + public function test_exists_reports_presence(): void { $id = $this->organizations->save(null, ['name' => 'Acme'], self::NOW);