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
64 changes: 48 additions & 16 deletions src/Contacts.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,19 @@ public function save(?int $id, array $fields, string $now): int
$email = $this->email($fields, $existing);
$phone = $this->optStr($fields, 'phone', $existing, self::MAX_PHONE);
$notes = $this->optStr($fields, 'notes', $existing, self::MAX_NOTES);
$orgId = $this->orgId($fields, $existing);

if ($id === null) {
return $this->storage()->insert(
'INSERT INTO ' . Schema::CONTACT . ' (first_name, last_name, email, phone, notes, created_at, updated_at)
VALUES (:first, :last, :email, :phone, :notes, :created, :updated)',
['first' => $first, 'last' => $last, 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'created' => $now, 'updated' => $now],
'INSERT INTO ' . Schema::CONTACT . ' (org_id, first_name, last_name, email, phone, notes, created_at, updated_at)
VALUES (:org, :first, :last, :email, :phone, :notes, :created, :updated)',
['org' => $orgId, 'first' => $first, 'last' => $last, 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'created' => $now, 'updated' => $now],
);
}

$this->storage()->execute(
'UPDATE ' . Schema::CONTACT . ' SET first_name = :first, last_name = :last, email = :email, phone = :phone, notes = :notes, updated_at = :now WHERE id = :id',
['first' => $first, 'last' => $last, 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'now' => $now, 'id' => $id],
'UPDATE ' . Schema::CONTACT . ' SET org_id = :org, first_name = :first, last_name = :last, email = :email, phone = :phone, notes = :notes, updated_at = :now WHERE id = :id',
['org' => $orgId, 'first' => $first, 'last' => $last, 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'now' => $now, 'id' => $id],
);
return $id;
}
Expand All @@ -80,12 +81,14 @@ public function save(?int $id, array $fields, string $now): int
* One contact by id, or null. Returned raw (unescaped) — the render layer
* escapes.
*
* @return array{id:int,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string}|null
* @return array{id:int,org_id:?int,organization:?string,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string}|null
*/
public function get(int $id): ?array
{
$row = $this->storage()->selectOne(
'SELECT id, first_name, last_name, email, phone, notes, created_at, updated_at FROM ' . Schema::CONTACT . ' WHERE id = :id',
'SELECT c.id, c.org_id, c.first_name, c.last_name, c.email, c.phone, c.notes, c.created_at, c.updated_at, o.name AS organization
FROM ' . Schema::CONTACT . ' c LEFT JOIN ' . Schema::ORGANIZATION . ' o ON o.id = c.org_id
WHERE c.id = :id',
['id' => $id],
);
return $row === null ? null : $this->hydrate($row);
Expand All @@ -96,24 +99,24 @@ public function get(int $id): ?array
* filtered to those whose name or email contains `$q` (a bound, wildcard-escaped
* LIKE — never string-built SQL).
*
* @return list<array{id:int,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string}>
* @return list<array{id:int,org_id:?int,organization:?string,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string}>
*/
public function all(?string $q = null): array
{
$select = 'SELECT c.id, c.org_id, c.first_name, c.last_name, c.email, c.phone, c.notes, c.created_at, c.updated_at, o.name AS organization
FROM ' . Schema::CONTACT . ' c LEFT JOIN ' . Schema::ORGANIZATION . ' o ON o.id = c.org_id';

$q = $q !== null ? trim($q) : '';
if ($q === '') {
$rows = $this->storage()->select(
'SELECT id, first_name, last_name, email, phone, notes, created_at, updated_at FROM ' . Schema::CONTACT . ' ORDER BY updated_at DESC, id DESC',
);
$rows = $this->storage()->select($select . ' ORDER BY c.updated_at DESC, c.id DESC');
return array_map($this->hydrate(...), $rows);
}

// Escape LIKE wildcards so a `%`/`_` in the term is a literal, and bind it.
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_substr($q, 0, 100)) . '%';
$rows = $this->storage()->select(
'SELECT id, first_name, last_name, email, phone, notes, created_at, updated_at FROM ' . Schema::CONTACT .
" WHERE first_name LIKE :q ESCAPE '\\\\' OR last_name LIKE :q2 ESCAPE '\\\\' OR email LIKE :q3 ESCAPE '\\\\'
ORDER BY updated_at DESC, id DESC",
$select . " WHERE c.first_name LIKE :q ESCAPE '\\\\' OR c.last_name LIKE :q2 ESCAPE '\\\\' OR c.email LIKE :q3 ESCAPE '\\\\'
ORDER BY c.updated_at DESC, c.id DESC",
['q' => $like, 'q2' => $like, 'q3' => $like],
);
return array_map($this->hydrate(...), $rows);
Expand Down Expand Up @@ -187,14 +190,43 @@ private function optStr(array $fields, string $key, ?array $existing, int $max):
return $v;
}

/**
* The organization a contact belongs to: null, or an id that must exist in this
* install (a soft ref, checked at write so a dangling id can't be stored). Absent
* on an update keeps the stored value.
*
* @param array<string,mixed> $fields
* @param array<string,mixed>|null $existing
*/
private function orgId(array $fields, ?array $existing): ?int
{
if (!array_key_exists('org_id', $fields)) {
return $existing !== null ? ($existing['org_id'] === null ? null : (int) $existing['org_id']) : null;
}
$raw = trim((string) $fields['org_id']);
if ($raw === '') {
return null;
}
if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1) {
throw new \InvalidArgumentException('"org_id" must be a positive whole number or blank.');
}
$id = (int) $raw;
if ($this->storage()->selectOne('SELECT id FROM ' . Schema::ORGANIZATION . ' WHERE id = :id', ['id' => $id]) === null) {
throw new \InvalidArgumentException("No organization with id {$id}.");
}
return $id;
}

/**
* @param array<string,mixed> $row
* @return array{id:int,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string}
* @return array{id:int,org_id:?int,organization:?string,first_name:string,last_name:string,email:?string,phone:?string,notes:?string,created_at:string,updated_at:string}
*/
private function hydrate(array $row): array
{
return [
'id' => (int) $row['id'],
'id' => (int) $row['id'],
'org_id' => $row['org_id'] === null ? null : (int) $row['org_id'],
'organization' => ($row['organization'] ?? null) === null ? null : (string) $row['organization'],
'first_name' => (string) $row['first_name'],
'last_name' => (string) $row['last_name'],
'email' => $row['email'] === null ? null : (string) $row['email'],
Expand Down
19 changes: 16 additions & 3 deletions src/ContactsAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final class ContactsAdmin
'invalid' => ['err', 'Check the details and try again.'],
];

public function __construct(private Contacts $contacts)
public function __construct(private Contacts $contacts, private Organizations $organizations)
{
}

Expand Down Expand Up @@ -55,6 +55,7 @@ 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 ? '<input type="hidden" name="id" value="' . self::e((string) $edit['id']) . '">' : '';
$currentOrg = $edit !== null && $edit['org_id'] !== null ? (int) $edit['org_id'] : null;

return '<h2>' . ($edit !== null ? 'Edit contact' : 'Add a contact') . '</h2>'
. '<form method="post" action="/admin/crm/contact-save" class="cr-form">'
Expand All @@ -67,12 +68,23 @@ private function form(string $csrf, ?array $edit): string
. '<label>Email<input type="email" name="email" value="' . $val('email') . '" maxlength="191"></label>'
. '<label>Phone<input type="text" name="phone" value="' . $val('phone') . '" maxlength="60"></label>'
. '</div>'
. '<label>Organization' . $this->orgSelect($currentOrg) . '</label>'
. '<label>Notes<textarea name="notes" rows="3" maxlength="10000">' . $val('notes') . '</textarea></label>'
. '<div class="cr-actions"><button type="submit" class="nb-btn">' . ($edit !== null ? 'Save contact' : 'Add contact') . '</button>'
. ($edit !== null ? ' <a class="nb-btn nb-btn-quiet" href="/admin/crm">Cancel</a>' : '')
. '</div></form>';
}

private function orgSelect(?int $current): string
{
$options = '<option value="">— none —</option>';
foreach ($this->organizations->all() as $o) {
$sel = $current === (int) $o['id'] ? ' selected' : '';
$options .= '<option value="' . self::e((string) $o['id']) . '"' . $sel . '>' . self::e((string) $o['name']) . '</option>';
}
return '<select name="org_id">' . $options . '</select>';
}

/**
* @param list<array<string,mixed>> $contacts
*/
Expand All @@ -94,6 +106,7 @@ private function list(string $csrf, array $contacts, string $q, ?int $editId): s
$name = trim(((string) $c['first_name']) . ' ' . ((string) $c['last_name']));
$rows .= '<tr>'
. '<td data-label="Name"><a href="/admin/crm?edit=' . self::e((string) $c['id']) . '">' . self::e($name === '' ? '(no name)' : $name) . '</a></td>'
. '<td data-label="Organization">' . self::e((string) ($c['organization'] ?? '')) . '</td>'
. '<td data-label="Email">' . self::e((string) ($c['email'] ?? '')) . '</td>'
. '<td data-label="Phone">' . self::e((string) ($c['phone'] ?? '')) . '</td>'
. '<td data-label="" class="cr-rowact">'
Expand All @@ -106,7 +119,7 @@ private function list(string $csrf, array $contacts, string $q, ?int $editId): s
}

return $html
. '<table class="cr-table"><thead><tr><th>Name</th><th>Email</th><th>Phone</th><th></th></tr></thead><tbody>'
. '<table class="cr-table"><thead><tr><th>Name</th><th>Organization</th><th>Email</th><th>Phone</th><th></th></tr></thead><tbody>'
. $rows . '</tbody></table>';
}

Expand All @@ -126,7 +139,7 @@ private function styles(string $nonce): string
. '.cr-form{max-width:44rem;display:flex;flex-direction:column;gap:.75rem;margin:0 0 2rem}'
. '.cr-row{display:flex;gap:.75rem;flex-wrap:wrap}'
. '.cr-form label{display:flex;flex-direction:column;gap:.25rem;flex:1 1 14rem;font-weight:600;font-size:.85rem}'
. '.cr-form input,.cr-form textarea{font:inherit;padding:.5rem .6rem;min-height:44px;box-sizing:border-box}'
. '.cr-form input,.cr-form textarea,.cr-form select{font:inherit;padding:.5rem .6rem;min-height:44px;box-sizing:border-box}'
. '.cr-form textarea{min-height:5rem}'
. '.cr-search{display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;margin:0 0 1rem}'
. '.cr-search input{font:inherit;padding:.5rem .6rem;min-height:44px;flex:1 1 16rem}'
Expand Down
49 changes: 44 additions & 5 deletions src/CrmPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* token can never reach contact data. All on its **own tables** (ADR 0005),
* touching no core data; no public/site surface at all.
*
* Slice 1: contacts (CRUD over the admin + MCP).
* Slice 1: contacts. Slice 2: organizations + the contact→org link.
*/
final class CrmPlugin implements Plugin
{
Expand All @@ -28,17 +28,19 @@ final class CrmPlugin implements Plugin
public function register(PluginContext $context): void
{
$context->migrations()->register('001_contacts', Schema::contacts());
$context->migrations()->register('002_organizations', Schema::organizations());

// Grantable, wildcard-immune: nimbuscms.crm:read / :write. Contact data is
// PII — a content *:write token can never read or change it.
$context->capabilities()->declare('CRM', ['read', 'write']);

// Storage is taken lazily, so register() runs no query and loads without a database.
$storage = static fn (): PluginStorage => $context->storage();
$contacts = new Contacts($storage);
$storage = static fn (): PluginStorage => $context->storage();
$contacts = new Contacts($storage);
$organizations = new Organizations($storage);

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

// 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 @@ -47,7 +49,7 @@ public function register(PluginContext $context): void
'crm',
'Contacts',
'👥',
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ContactsAdmin($contacts))->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))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce),
self::ID . ':write',
);

Expand All @@ -60,6 +62,7 @@ public function register(PluginContext $context): void
'email' => (string) ($r->input('email') ?? ''),
'phone' => (string) ($r->input('phone') ?? ''),
'notes' => (string) ($r->input('notes') ?? ''),
'org_id' => (string) ($r->input('org_id') ?? ''),
];
$idIn = trim((string) ($r->input('id') ?? ''));
$id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null;
Expand All @@ -83,6 +86,42 @@ public function register(PluginContext $context): void
return Response::redirect('/admin/crm?ok=deleted');
});

// Organizations: the companies contacts belong to. Same crm:write gate.
$context->adminPages()->register(
'crm-organizations',
'Organizations',
'🏢',
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new OrganizationsAdmin($organizations))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('q'), $nonce),
self::ID . ':write',
);

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

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

// Teach an MCP agent how to drive the CRM (ADR 0013).
$context->skills()->register('CRM', Guide::text());
}
Expand Down
Loading
Loading