Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Activities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string> */
Expand Down
2 changes: 2 additions & 0 deletions src/Contacts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
}
Expand Down
64 changes: 55 additions & 9 deletions src/CrmPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
99 changes: 89 additions & 10 deletions src/CrmToolset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +30,7 @@ public function __construct(
private Contacts $contacts,
private Organizations $organizations,
private Activities $activities,
private Deals $deals,
) {
}

Expand All @@ -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).', [
Expand Down Expand Up @@ -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.'],
Expand All @@ -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(...)),
];
}

Expand Down Expand Up @@ -251,6 +288,48 @@ private function activityDelete(array $a, TokenPrincipal $p, EntryOpContext $c):
return ['ok' => true, 'deleted' => $this->activities->delete($id) > 0];
}

/**
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
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<string,mixed> $a
* @return array<string,mixed>
*/
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<string,mixed> $a
* @return array<string,mixed>
*/
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<string,mixed> $a
* @return array<string,mixed>
*/
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 ---------------------------------------------------------

/**
Expand Down
Loading
Loading