' . $this->closed($csrf, $only);
}
- private function closed(string $csrf): string
+ /** @param list|null $only */
+ private function closed(string $csrf, ?array $only): string
{
- $won = $this->deals->all(null, 'won');
- $lost = $this->deals->all(null, 'lost');
+ $won = $this->restrict($this->deals->all(null, 'won'), $only);
+ $lost = $this->restrict($this->deals->all(null, 'lost'), $only);
if ($won === [] && $lost === []) {
return '';
}
diff --git a/src/Guide.php b/src/Guide.php
index 7d43f44..dd4637e 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, 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.
+ belong to, an **activity timeline** against any of them, a **deal pipeline**, and
+ **tags** you apply and filter by. 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
@@ -80,8 +80,27 @@ public static function text(): string
- `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.
+ is simply cleared.
+
+ ## Tags
+
+ Labels applied to any contact, organization or deal, so you can group and filter
+ them ("all contacts tagged VIP").
+
+ - `crm_tags` — list every tag with its usage count.
+ - `crm_tag_create` — create a tag by `name` (or return the one that already has
+ that name).
+ - `crm_tag_delete` — delete a tag by `id`; it is removed from everything it is
+ on, but the records are kept.
+ - `crm_tag_attach` — apply a tag to a subject: `subject_type`
+ (`contact`/`organization`/`deal`) + `subject_id`, and either an existing
+ `tag_id` or a `tag_name` (found or created). Idempotent.
+ - `crm_tag_detach` — remove a tag (`tag_id`) from a subject.
+ - `crm_tags_for` — the tags on one subject.
+ - `crm_tagged` — every record of a type carrying a tag ("all contacts tagged X").
+
+ Deleting a contact, organization or deal removes its tag links. 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 4598c65..98bf775 100644
--- a/src/Organizations.php
+++ b/src/Organizations.php
@@ -111,6 +111,10 @@ public function delete(int $id): bool
'DELETE FROM ' . Schema::ACTIVITY . ' WHERE subject_type = :type AND subject_id = :id',
['type' => Activities::SUBJECT_ORGANIZATION, 'id' => $id],
);
+ $this->storage()->execute(
+ 'DELETE FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND taggable_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 3fe2810..043384d 100644
--- a/src/OrganizationsAdmin.php
+++ b/src/OrganizationsAdmin.php
@@ -17,14 +17,18 @@ final class OrganizationsAdmin
'deleted' => ['ok', 'Organization deleted — its contacts were kept and unlinked.'],
'activity' => ['ok', 'Activity logged.'],
'activitygone' => ['ok', 'Activity deleted.'],
+ 'tagged' => ['ok', 'Tag added.'],
+ 'untagged' => ['ok', 'Tag removed.'],
'noname' => ['err', 'An organization needs a 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 Organizations $organizations,
private Activities $activities,
+ private Tags $tags,
) {
}
@@ -34,13 +38,19 @@ public function __construct(
* @param ?string $edit an organization id to load into the form (from ?edit=)
* @param ?string $q a search term (from ?q=)
* @param string $nonce the request CSP nonce
+ * @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;
$editOrg = $editId !== null ? $this->organizations->get($editId) : null;
$q = $q !== null ? trim($q) : '';
+ $tagId = ($tag !== null && preg_match('/^\d+$/', trim($tag)) === 1) ? (int) trim($tag) : null;
$orgs = $this->organizations->all($q === '' ? null : $q);
+ if ($tagId !== null) {
+ $ids = $this->tags->idsFor(Activities::SUBJECT_ORGANIZATION, $tagId);
+ $orgs = array_values(array_filter($orgs, static fn (array $o): bool => in_array((int) $o['id'], $ids, true)));
+ }
return $this->styles($nonce)
. '
Organizations
'
@@ -48,6 +58,8 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit
. '
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) : '')
+ . ($editOrg !== null ? TagsAdmin::block($csrf, 'crm-organizations', Activities::SUBJECT_ORGANIZATION, (int) $editOrg['id'], $this->tags->tagsFor(Activities::SUBJECT_ORGANIZATION, (int) $editOrg['id']), $this->tags->allTags(), $nonce) : '')
+ . TagsAdmin::filterBar('crm-organizations', $this->tags->allTags(), $tagId)
. $this->list($csrf, $orgs, $q);
}
diff --git a/src/Schema.php b/src/Schema.php
index 5024bc0..0141abc 100644
--- a/src/Schema.php
+++ b/src/Schema.php
@@ -20,6 +20,8 @@ final class Schema
public const ORGANIZATION = 'crm_organization';
public const ACTIVITY = 'crm_activity';
public const DEAL = 'crm_deal';
+ public const TAG = 'crm_tag';
+ public const TAGGABLE = 'crm_taggable';
/** @return list each statement individually idempotent (ADR 0005) */
public static function contacts(): array
@@ -123,4 +125,38 @@ public static function deals(): array
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
];
}
+
+ /**
+ * Tags — a shared vocabulary of labels, and the **normalized, polymorphic** links
+ * that apply them to a contact, organization or deal (`crm_taggable`). Normalized
+ * rather than a JSON column so "every contact tagged X" is a real indexed query.
+ * A tag name is unique (case-insensitive, via the default collation); a link is
+ * unique per (tag, subject) so tagging twice is a no-op. A subject's delete clears
+ * its links; deleting a tag clears the links but never the subjects.
+ *
+ * @return list each statement individually idempotent (ADR 0005)
+ */
+ public static function tags(): array
+ {
+ return [
+ 'CREATE TABLE IF NOT EXISTS ' . self::TAG . ' (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(60) NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ UNIQUE KEY uniq_tag_name (name)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
+
+ 'CREATE TABLE IF NOT EXISTS ' . self::TAGGABLE . " (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ tag_id BIGINT UNSIGNED NOT NULL,
+ taggable_type ENUM('contact','organization','deal') NOT NULL,
+ taggable_id BIGINT UNSIGNED NOT NULL,
+ created_at DATETIME NOT NULL,
+ UNIQUE KEY uniq_link (tag_id, taggable_type, taggable_id),
+ INDEX idx_taggable (taggable_type, taggable_id),
+ INDEX idx_tag (tag_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ ];
+ }
}
diff --git a/src/Tags.php b/src/Tags.php
new file mode 100644
index 0000000..94a557b
--- /dev/null
+++ b/src/Tags.php
@@ -0,0 +1,249 @@
+ Schema::CONTACT,
+ Activities::SUBJECT_ORGANIZATION => Schema::ORGANIZATION,
+ Activities::SUBJECT_DEAL => Schema::DEAL,
+ ];
+
+ private const MAX_NAME = 60;
+
+ /** @param \Closure():PluginStorage $storage resolved lazily, so construction runs no query */
+ public function __construct(private \Closure $storage)
+ {
+ }
+
+ // --- tags ------------------------------------------------------------
+
+ /**
+ * Create (id null) or rename (id given) a tag. The name is required, capped and
+ * unique (case-insensitive) — a clash is rejected as data. Returns the tag id.
+ */
+ public function saveTag(?int $id, string $name, string $now): int
+ {
+ $name = $this->name($name);
+ $clash = $this->storage()->selectOne(
+ 'SELECT id FROM ' . Schema::TAG . ' WHERE name = :name' . ($id !== null ? ' AND id <> :id' : ''),
+ $id !== null ? ['name' => $name, 'id' => $id] : ['name' => $name],
+ );
+ if ($clash !== null) {
+ throw new \InvalidArgumentException("A tag named \"{$name}\" already exists.");
+ }
+
+ if ($id === null) {
+ return $this->storage()->insert(
+ 'INSERT INTO ' . Schema::TAG . ' (name, created_at, updated_at) VALUES (:name, :created, :updated)',
+ ['name' => $name, 'created' => $now, 'updated' => $now],
+ );
+ }
+ if ($this->getTag($id) === null) {
+ throw new \InvalidArgumentException("No tag with id {$id}.");
+ }
+ $this->storage()->execute(
+ 'UPDATE ' . Schema::TAG . ' SET name = :name, updated_at = :now WHERE id = :id',
+ ['name' => $name, 'now' => $now, 'id' => $id],
+ );
+ return $id;
+ }
+
+ /** Find a tag by name (case-insensitive), or create it. Returns the tag id. */
+ public function findOrCreate(string $name, string $now): int
+ {
+ $name = $this->name($name);
+ $row = $this->storage()->selectOne('SELECT id FROM ' . Schema::TAG . ' WHERE name = :name', ['name' => $name]);
+ if ($row !== null) {
+ return (int) $row['id'];
+ }
+ return $this->storage()->insert(
+ 'INSERT INTO ' . Schema::TAG . ' (name, created_at, updated_at) VALUES (:name, :created, :updated)',
+ ['name' => $name, 'created' => $now, 'updated' => $now],
+ );
+ }
+
+ /**
+ * @return array{id:int,name:string,created_at:string,updated_at:string}|null
+ */
+ public function getTag(int $id): ?array
+ {
+ $row = $this->storage()->selectOne('SELECT id, name, created_at, updated_at FROM ' . Schema::TAG . ' WHERE id = :id', ['id' => $id]);
+ if ($row === null) {
+ return null;
+ }
+ return [
+ 'id' => (int) $row['id'],
+ 'name' => (string) $row['name'],
+ 'created_at' => (string) $row['created_at'],
+ 'updated_at' => (string) $row['updated_at'],
+ ];
+ }
+
+ /**
+ * All tags, name-ordered, each with its total usage count across every subject
+ * type — for the management page and the filter bars.
+ *
+ * @return list
+ */
+ public function allTags(): array
+ {
+ $rows = $this->storage()->select(
+ 'SELECT t.id, t.name, COUNT(tg.id) AS cnt
+ FROM ' . Schema::TAG . ' t LEFT JOIN ' . Schema::TAGGABLE . ' tg ON tg.tag_id = t.id
+ GROUP BY t.id, t.name ORDER BY t.name',
+ );
+ return array_map(static fn (array $r): array => [
+ 'id' => (int) $r['id'],
+ 'name' => (string) $r['name'],
+ 'count' => (int) $r['cnt'],
+ ], $rows);
+ }
+
+ /** Delete a tag and every link to it, atomically. Returns true when a tag was removed. */
+ public function deleteTag(int $id): bool
+ {
+ return (bool) $this->storage()->transaction(function () use ($id): bool {
+ $this->storage()->execute('DELETE FROM ' . Schema::TAGGABLE . ' WHERE tag_id = :id', ['id' => $id]);
+ return $this->storage()->execute('DELETE FROM ' . Schema::TAG . ' WHERE id = :id', ['id' => $id]) > 0;
+ });
+ }
+
+ // --- tagging ---------------------------------------------------------
+
+ /**
+ * Apply a tag to a subject. The subject type is allow-listed and the subject and
+ * tag must exist. Idempotent: returns true if a new link was made, false if it
+ * was already tagged.
+ */
+ public function attach(string $type, int $subjectId, int $tagId, string $now): bool
+ {
+ $table = $this->table($type);
+ if ($this->getTag($tagId) === null) {
+ throw new \InvalidArgumentException("No tag with id {$tagId}.");
+ }
+ if ($this->storage()->selectOne('SELECT id FROM ' . $table . ' WHERE id = :id', ['id' => $subjectId]) === null) {
+ throw new \InvalidArgumentException("No {$type} with id {$subjectId}.");
+ }
+ $exists = $this->storage()->selectOne(
+ 'SELECT id FROM ' . Schema::TAGGABLE . ' WHERE tag_id = :tag AND taggable_type = :type AND taggable_id = :sid',
+ ['tag' => $tagId, 'type' => $type, 'sid' => $subjectId],
+ );
+ if ($exists !== null) {
+ return false;
+ }
+ $this->storage()->insert(
+ 'INSERT INTO ' . Schema::TAGGABLE . ' (tag_id, taggable_type, taggable_id, created_at) VALUES (:tag, :type, :sid, :created)',
+ ['tag' => $tagId, 'type' => $type, 'sid' => $subjectId, 'created' => $now],
+ );
+ return true;
+ }
+
+ /** Remove a tag from a subject. Returns the number of links removed (0 if it wasn't tagged). */
+ public function detach(string $type, int $subjectId, int $tagId): int
+ {
+ if (!isset(self::TAGGABLES[$type])) {
+ return 0;
+ }
+ return $this->storage()->execute(
+ 'DELETE FROM ' . Schema::TAGGABLE . ' WHERE tag_id = :tag AND taggable_type = :type AND taggable_id = :sid',
+ ['tag' => $tagId, 'type' => $type, 'sid' => $subjectId],
+ );
+ }
+
+ /**
+ * The tags on one subject, name-ordered.
+ *
+ * @return list
+ */
+ public function tagsFor(string $type, int $subjectId): array
+ {
+ if (!isset(self::TAGGABLES[$type])) {
+ return [];
+ }
+ $rows = $this->storage()->select(
+ 'SELECT t.id, t.name FROM ' . Schema::TAGGABLE . ' tg
+ JOIN ' . Schema::TAG . ' t ON t.id = tg.tag_id
+ WHERE tg.taggable_type = :type AND tg.taggable_id = :sid ORDER BY t.name',
+ ['type' => $type, 'sid' => $subjectId],
+ );
+ return array_map(static fn (array $r): array => ['id' => (int) $r['id'], 'name' => (string) $r['name']], $rows);
+ }
+
+ /**
+ * The ids of subjects of `$type` carrying `$tagId` — the "everything tagged X"
+ * filter (a real indexed join). An unknown type yields none.
+ *
+ * @return list
+ */
+ public function idsFor(string $type, int $tagId): array
+ {
+ if (!isset(self::TAGGABLES[$type])) {
+ return [];
+ }
+ $rows = $this->storage()->select(
+ 'SELECT taggable_id FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND tag_id = :tag',
+ ['type' => $type, 'tag' => $tagId],
+ );
+ return array_map(static fn (array $r): int => (int) $r['taggable_id'], $rows);
+ }
+
+ /** Remove every tag link on a subject — the cleanup an entity's delete calls. Returns links removed. */
+ public function clearFor(string $type, int $subjectId): int
+ {
+ if (!isset(self::TAGGABLES[$type])) {
+ return 0;
+ }
+ return $this->storage()->execute(
+ 'DELETE FROM ' . Schema::TAGGABLE . ' WHERE taggable_type = :type AND taggable_id = :sid',
+ ['type' => $type, 'sid' => $subjectId],
+ );
+ }
+
+ // --- validation ------------------------------------------------------
+
+ private function name(string $name): string
+ {
+ $name = trim($name);
+ if ($name === '') {
+ throw new \InvalidArgumentException('A tag needs a name.');
+ }
+ if (mb_strlen($name) > self::MAX_NAME) {
+ throw new \InvalidArgumentException('A tag name must be ' . self::MAX_NAME . ' characters or fewer.');
+ }
+ return $name;
+ }
+
+ private function table(string $type): string
+ {
+ if (!isset(self::TAGGABLES[$type])) {
+ throw new \InvalidArgumentException('"type" must be one of: ' . implode(', ', array_keys(self::TAGGABLES)) . '.');
+ }
+ return self::TAGGABLES[$type];
+ }
+
+ private function storage(): PluginStorage
+ {
+ return ($this->storage)();
+ }
+}
diff --git a/src/TagsAdmin.php b/src/TagsAdmin.php
new file mode 100644
index 0000000..4aa3188
--- /dev/null
+++ b/src/TagsAdmin.php
@@ -0,0 +1,214 @@
+` block.
+ */
+final class TagsAdmin
+{
+ private const NOTICES = [
+ 'saved' => ['ok', 'Tag saved.'],
+ 'deleted' => ['ok', 'Tag deleted.'],
+ 'noname' => ['err', 'A tag needs a name.'],
+ 'dupe' => ['err', 'A tag with that name already exists.'],
+ 'invalid' => ['err', 'Check the details and try again.'],
+ ];
+
+ public function __construct(private Tags $tags)
+ {
+ }
+
+ public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, string $nonce = ''): string
+ {
+ $editId = ($edit !== null && preg_match('/^\d+$/', trim($edit)) === 1) ? (int) trim($edit) : null;
+ $editTag = $editId !== null ? $this->tags->getTag($editId) : null;
+ $tags = $this->tags->allTags();
+
+ return $this->styles($nonce)
+ . '
Tags
'
+ . $this->notice($notice)
+ . '
Labels you can apply to contacts, organizations and deals — then filter by. Deleting a tag removes it from everything; the records are kept.