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: 4 additions & 0 deletions src/Contacts.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ public function delete(int $id): int
);
// A deal outlives the person, but must not dangle: clear the link.
$this->storage()->execute('UPDATE ' . Schema::DEAL . ' SET contact_id = NULL WHERE contact_id = :id', ['id' => $id]);
$this->storage()->execute(
'DELETE FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND taggable_id = :id',
['type' => Activities::SUBJECT_CONTACT, 'id' => $id],
);
return $this->storage()->execute('DELETE FROM ' . Schema::CONTACT . ' WHERE id = :id', ['id' => $id]);
});
}
Expand Down
14 changes: 13 additions & 1 deletion src/ContactsAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,20 @@ final class ContactsAdmin
'deleted' => ['ok', 'Contact deleted.'],
'activity' => ['ok', 'Activity logged.'],
'activitygone' => ['ok', 'Activity deleted.'],
'tagged' => ['ok', 'Tag added.'],
'untagged' => ['ok', 'Tag removed.'],
'bademail' => ['err', 'That email address is not valid.'],
'noname' => ['err', 'A contact needs a first or last name.'],
'activitybad' => ['err', 'Could not log that activity — check the details.'],
'tagbad' => ['err', 'Could not add that tag — check the details.'],
'invalid' => ['err', 'Check the details and try again.'],
];

public function __construct(
private Contacts $contacts,
private Organizations $organizations,
private Activities $activities,
private Tags $tags,
) {
}

Expand All @@ -37,13 +41,19 @@ public function __construct(
* @param ?string $edit a contact id to load into the form (from ?edit=)
* @param ?string $q a search term (from ?q=)
* @param string $nonce the request CSP nonce
* @param ?string $tag a tag id to filter the list by (from ?tag=)
*/
public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = ''): string
public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $q = null, string $nonce = '', ?string $tag = null): string
{
$editId = ($edit !== null && preg_match('/^\d+$/', trim($edit)) === 1) ? (int) trim($edit) : null;
$editContact = $editId !== null ? $this->contacts->get($editId) : null;
$q = $q !== null ? trim($q) : '';
$tagId = ($tag !== null && preg_match('/^\d+$/', trim($tag)) === 1) ? (int) trim($tag) : null;
$contacts = $this->contacts->all($q === '' ? null : $q);
if ($tagId !== null) {
$ids = $this->tags->idsFor(Activities::SUBJECT_CONTACT, $tagId);
$contacts = array_values(array_filter($contacts, static fn (array $c): bool => in_array((int) $c['id'], $ids, true)));
}

$html = $this->styles($nonce)
. '<div class="nb-page-head"><h1>Contacts</h1></div>'
Expand All @@ -53,7 +63,9 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit
$html .= $this->form($csrf, $editContact);
if ($editContact !== null) {
$html .= ActivitiesAdmin::render($csrf, 'crm', Activities::SUBJECT_CONTACT, (int) $editContact['id'], $this->activities->forSubject(Activities::SUBJECT_CONTACT, (int) $editContact['id']), $nonce);
$html .= TagsAdmin::block($csrf, 'crm', Activities::SUBJECT_CONTACT, (int) $editContact['id'], $this->tags->tagsFor(Activities::SUBJECT_CONTACT, (int) $editContact['id']), $this->tags->allTags(), $nonce);
}
$html .= TagsAdmin::filterBar('crm', $this->tags->allTags(), $tagId);
$html .= $this->list($csrf, $contacts, $q, $editId);

return $html;
Expand Down
81 changes: 76 additions & 5 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. Slice 4: the deal pipeline.
* activity timeline. Slice 4: the deal pipeline. Slice 5: tags + filtering.
*/
final class CrmPlugin implements Plugin
{
Expand All @@ -32,6 +32,7 @@ public function register(PluginContext $context): void
$context->migrations()->register('002_organizations', Schema::organizations());
$context->migrations()->register('003_activities', Schema::activities());
$context->migrations()->register('004_deals', Schema::deals());
$context->migrations()->register('005_tags', Schema::tags());

// Grantable, wildcard-immune: nimbuscms.crm:read / :write. Contact data is
// PII — a content *:write token can never read or change it.
Expand All @@ -43,9 +44,10 @@ public function register(PluginContext $context): void
$organizations = new Organizations($storage);
$activities = new Activities($storage);
$deals = new Deals($storage);
$tags = new Tags($storage);

// The agent surface — every tool gates on nimbuscms.crm:read|write (ADR 0016).
$context->mcp()->register(new CrmToolset($contacts, $organizations, $activities, $deals));
$context->mcp()->register(new CrmToolset($contacts, $organizations, $activities, $deals, $tags));

// Admin: search + list + create/edit form. Gated on nimbuscms.crm:write
// (same as the write tools) so a content-only editor can't reach PII; the
Expand All @@ -54,7 +56,7 @@ public function register(PluginContext $context): void
'crm',
'Contacts',
'👥',
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ContactsAdmin($contacts, $organizations, $activities))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce),
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ContactsAdmin($contacts, $organizations, $activities, $tags))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce, $r->query('tag')),
self::ID . ':write',
);

Expand Down Expand Up @@ -96,7 +98,7 @@ public function register(PluginContext $context): void
'crm-organizations',
'Organizations',
'🏢',
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new OrganizationsAdmin($organizations, $activities))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce),
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new OrganizationsAdmin($organizations, $activities, $tags))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce, $r->query('tag')),
self::ID . ':write',
);

Expand Down Expand Up @@ -132,7 +134,7 @@ public function register(PluginContext $context): void
'crm-deals',
'Deals',
'📊',
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new DealsAdmin($deals, $contacts, $organizations, $activities))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce),
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new DealsAdmin($deals, $contacts, $organizations, $activities, $tags))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce, $r->query('tag')),
self::ID . ':write',
);

Expand Down Expand Up @@ -208,11 +210,80 @@ public function register(PluginContext $context): void
return Response::redirect($back($r) . 'ok=activitygone');
};

// Tags are attached/removed inline on a record; the same two actions serve
// every record page and redirect back via the shared $back helper.
$attachTag = static function (Request $r) use ($tags, $back): Response {
$base = $back($r);
$type = (string) ($r->input('subject_type') ?? '');
$sid = trim((string) ($r->input('subject_id') ?? ''));
$name = trim((string) ($r->input('tag_name') ?? ''));
$tagIn = trim((string) ($r->input('tag_id') ?? ''));
if ($sid === '' || !ctype_digit($sid)) {
return Response::redirect($base . 'err=tagbad');
}
try {
$now = date('Y-m-d H:i:s');
$tagId = $name !== '' ? $tags->findOrCreate($name, $now) : (($tagIn !== '' && ctype_digit($tagIn)) ? (int) $tagIn : 0);
if ($tagId === 0) {
return Response::redirect($base . 'err=tagbad');
}
$tags->attach($type, (int) $sid, $tagId, $now);
return Response::redirect($base . 'ok=tagged');
} catch (\Throwable) {
return Response::redirect($base . 'err=tagbad');
}
};

$detachTag = static function (Request $r) use ($tags, $back): Response {
$type = (string) ($r->input('subject_type') ?? '');
$sid = trim((string) ($r->input('subject_id') ?? ''));
$tagIn = trim((string) ($r->input('tag_id') ?? ''));
if ($sid !== '' && ctype_digit($sid) && $tagIn !== '' && ctype_digit($tagIn)) {
$tags->detach($type, (int) $sid, (int) $tagIn);
}
return Response::redirect($back($r) . 'ok=untagged');
};

foreach (['crm', 'crm-organizations', 'crm-deals'] as $page) {
$context->adminPages()->action($page, 'activity-add', $addActivity);
$context->adminPages()->action($page, 'activity-delete', $deleteActivity);
$context->adminPages()->action($page, 'tag-attach', $attachTag);
$context->adminPages()->action($page, 'tag-detach', $detachTag);
}

// Tags management: the shared vocabulary of labels. Same crm:write gate.
$context->adminPages()->register(
'crm-tags',
'Tags',
'🏷️',
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new TagsAdmin($tags))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $nonce),
self::ID . ':write',
);

$context->adminPages()->action('crm-tags', 'tag-save', static function (Request $r) use ($tags): Response {
$name = (string) ($r->input('name') ?? '');
$idIn = trim((string) ($r->input('id') ?? ''));
$id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null;
try {
$tags->saveTag($id, $name, date('Y-m-d H:i:s'));
return Response::redirect('/admin/crm-tags?ok=saved');
} catch (\InvalidArgumentException $e) {
$msg = $e->getMessage();
$code = str_contains($msg, 'already exists') ? 'dupe' : (str_contains($msg, 'name') ? 'noname' : 'invalid');
return Response::redirect('/admin/crm-tags?err=' . $code);
} catch (\Throwable) {
return Response::redirect('/admin/crm-tags?err=invalid');
}
});

$context->adminPages()->action('crm-tags', 'tag-delete', static function (Request $r) use ($tags): Response {
$idIn = trim((string) ($r->input('id') ?? ''));
if ($idIn !== '' && ctype_digit($idIn)) {
$tags->deleteTag((int) $idIn);
}
return Response::redirect('/admin/crm-tags?ok=deleted');
});

// Teach an MCP agent how to drive the CRM (ADR 0013).
$context->skills()->register('CRM', Guide::text());
}
Expand Down
162 changes: 162 additions & 0 deletions src/CrmToolset.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public function __construct(
private Organizations $organizations,
private Activities $activities,
private Deals $deals,
private Tags $tags,
) {
}

Expand Down Expand Up @@ -163,6 +164,61 @@ protected function tools(): array
'required' => ['id'],
'properties' => ['id' => ['type' => 'integer', 'description' => 'The deal id.']],
], $this->dealDelete(...)),

new PluginTool('tags', 'read', 'List every tag with how many records carry it.', [
'type' => 'object', 'properties' => new \stdClass(),
], $this->tags(...)),

new PluginTool('tag_create', 'write', 'Create a tag by name (or return the existing one with that name).', [
'type' => 'object',
'required' => ['name'],
'properties' => ['name' => ['type' => 'string', 'description' => 'The tag name.']],
], $this->tagCreate(...)),

new PluginTool('tag_delete', 'write', 'Delete a tag by id, removing it from everything it is on (the records are kept).', [
'type' => 'object',
'required' => ['id'],
'properties' => ['id' => ['type' => 'integer', 'description' => 'The tag id.']],
], $this->tagDelete(...)),

new PluginTool('tag_attach', 'write', 'Apply a tag to a contact, organization or deal. Give either tag_id or tag_name (a new name is created).', [
'type' => 'object',
'required' => ['subject_type', 'subject_id'],
'properties' => [
'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'What to tag: "contact", "organization" or "deal".'],
'subject_id' => ['type' => 'integer', 'description' => 'The existing subject id.'],
'tag_id' => ['type' => 'integer', 'description' => 'An existing tag id.'],
'tag_name' => ['type' => 'string', 'description' => 'A tag name (found or created) — an alternative to tag_id.'],
],
], $this->tagAttach(...)),

new PluginTool('tag_detach', 'write', 'Remove a tag from a contact, organization or deal.', [
'type' => 'object',
'required' => ['subject_type', 'subject_id', 'tag_id'],
'properties' => [
'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'The subject: "contact", "organization" or "deal".'],
'subject_id' => ['type' => 'integer', 'description' => 'The subject id.'],
'tag_id' => ['type' => 'integer', 'description' => 'The tag id to remove.'],
],
], $this->tagDetach(...)),

new PluginTool('tags_for', 'read', 'The tags on one contact, organization or deal.', [
'type' => 'object',
'required' => ['subject_type', 'subject_id'],
'properties' => [
'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'The subject: "contact", "organization" or "deal".'],
'subject_id' => ['type' => 'integer', 'description' => 'The subject id.'],
],
], $this->tagsFor(...)),

new PluginTool('tagged', 'read', 'Every record of one type carrying a tag — "all contacts tagged X".', [
'type' => 'object',
'required' => ['subject_type', 'tag_id'],
'properties' => [
'subject_type' => ['type' => 'string', 'enum' => $subjects, 'description' => 'Which records: "contact", "organization" or "deal".'],
'tag_id' => ['type' => 'integer', 'description' => 'The tag id.'],
],
], $this->tagged(...)),
];
}

Expand Down Expand Up @@ -330,6 +386,112 @@ private function dealDelete(array $a, TokenPrincipal $p, EntryOpContext $c): arr
return ['ok' => true, 'deleted' => $this->deals->delete($id) > 0];
}

/**
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
private function tags(array $a, TokenPrincipal $p, EntryOpContext $c): array
{
$list = $this->tags->allTags();
return ['tags' => $list, 'count' => count($list)];
}

/**
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
private function tagCreate(array $a, TokenPrincipal $p, EntryOpContext $c): array
{
return $this->guard(function () use ($a): array {
$id = $this->tags->findOrCreate((string) ($a['name'] ?? ''), $this->now());
return ['ok' => true, 'tag' => $this->tags->getTag($id)];
});
}

/**
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
private function tagDelete(array $a, TokenPrincipal $p, EntryOpContext $c): array
{
$id = $this->requireInt($a, 'id');
return ['ok' => true, 'deleted' => $this->tags->deleteTag($id)];
}

/**
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
private function tagAttach(array $a, TokenPrincipal $p, EntryOpContext $c): array
{
return $this->guard(function () use ($a): array {
$type = (string) ($a['subject_type'] ?? '');
$sid = $this->requireInt($a, 'subject_id');
$tagId = $this->nullableInt($a, 'tag_id');
$name = $this->nullableStr($a, 'tag_name');
if ($tagId === null && $name === null) {
throw new \InvalidArgumentException('Give a "tag_id" or a "tag_name".');
}
if ($tagId === null) {
$tagId = $this->tags->findOrCreate((string) $name, $this->now());
}
$made = $this->tags->attach($type, $sid, $tagId, $this->now());
return ['ok' => true, 'attached' => $made, 'tag_id' => $tagId];
});
}

/**
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
private function tagDetach(array $a, TokenPrincipal $p, EntryOpContext $c): array
{
$type = (string) ($a['subject_type'] ?? '');
$sid = $this->requireInt($a, 'subject_id');
$tagId = $this->requireInt($a, 'tag_id');
return ['ok' => true, 'detached' => $this->tags->detach($type, $sid, $tagId) > 0];
}

/**
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
private function tagsFor(array $a, TokenPrincipal $p, EntryOpContext $c): array
{
$type = (string) ($a['subject_type'] ?? '');
$sid = $this->requireInt($a, 'subject_id');
$list = $this->tags->tagsFor($type, $sid);
return ['tags' => $list, 'count' => count($list)];
}

/**
* Every record of one type carrying a tag — the ids resolved to full records so an
* agent gets "all contacts tagged X" in one call.
*
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
private function tagged(array $a, TokenPrincipal $p, EntryOpContext $c): array
{
$type = (string) ($a['subject_type'] ?? '');
$tagId = $this->requireInt($a, 'tag_id');
$ids = $this->tags->idsFor($type, $tagId);

$records = [];
foreach ($ids as $id) {
$record = match ($type) {
Activities::SUBJECT_CONTACT => $this->contacts->get($id),
Activities::SUBJECT_ORGANIZATION => $this->organizations->get($id),
Activities::SUBJECT_DEAL => $this->deals->get($id),
default => null,
};
if ($record !== null) {
$records[] = $record;
}
}
return ['subject_type' => $type, 'tag_id' => $tagId, 'records' => $records, 'count' => count($records)];
}

// --- helpers ---------------------------------------------------------

/**
Expand Down
Loading
Loading