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
224 changes: 224 additions & 0 deletions src/Activities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Crm;

use Nimbus\Plugin\PluginStorage;

/**
* Activities — the CRM timeline. A dated, typed entry (a note/call/email/meeting)
* logged against a **subject**: a contact or an organization. The subject link is
* polymorphic and carries the security review's sharp edges, so both are enforced
* here at write:
*
* - **`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.
* - **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.
* - **`kind` is an allow-list** too; **`body` is stored raw** (escaped on render)
* with a length cap; **`author`** is server-set by the caller (the MCP token
* name, or null in the admin) — never a client field, so it can't be spoofed.
*
* Append-only: an entry is added or deleted, not edited. A subject's own delete
* purges its activities (see {@see Contacts::delete()} / {@see Organizations::delete()}),
* so this service holds no orphaned PII.
*/
final class Activities
{
public const SUBJECT_CONTACT = 'contact';
public const SUBJECT_ORGANIZATION = 'organization';
public const SUBJECT_DEAL = 'deal';

/** subject_type → the table its id must exist in. The write-time allow-list. */
private const SUBJECTS = [
self::SUBJECT_CONTACT => Schema::CONTACT,
self::SUBJECT_ORGANIZATION => Schema::ORGANIZATION,
];

/** @var list<string> */
public const KINDS = ['note', 'call', 'email', 'meeting', 'other'];

private const MAX_BODY = 20000;
private const MAX_AUTHOR = 191;

/** @param \Closure():PluginStorage $storage resolved lazily, so construction runs no query */
public function __construct(private \Closure $storage)
{
}

/**
* Log an activity against a subject. `author` is passed by the caller (never
* read from `$fields`) so it cannot be over-posted. Returns the new activity id.
*
* @param array<string,mixed> $fields
*/
public function add(array $fields, string $now, ?string $author = null): int
{
[$type, $id] = $this->subject($fields);
$kind = $this->kind($fields);
$body = $this->body($fields);
$occurredAt = $this->occurredAt($fields, $now);
$who = $this->author($author);

return $this->storage()->insert(
'INSERT INTO ' . Schema::ACTIVITY . ' (subject_type, subject_id, kind, body, occurred_at, author, created_at)
VALUES (:type, :sid, :kind, :body, :occurred, :author, :created)',
['type' => $type, 'sid' => $id, 'kind' => $kind, 'body' => $body, 'occurred' => $occurredAt, 'author' => $who, 'created' => $now],
);
}

/**
* The timeline for one subject, most-recent first. `$type` is validated to the
* allow-list; an unknown type is an empty timeline, not an error.
*
* @return list<array{id:int,subject_type:string,subject_id:int,kind:string,body:?string,occurred_at:string,author:?string,created_at:string}>
*/
public function forSubject(string $type, int $id): array
{
if (!isset(self::SUBJECTS[$type])) {
return [];
}
$rows = $this->storage()->select(
'SELECT id, subject_type, subject_id, kind, body, occurred_at, author, created_at
FROM ' . Schema::ACTIVITY . ' WHERE subject_type = :type AND subject_id = :id
ORDER BY occurred_at DESC, id DESC',
['type' => $type, 'id' => $id],
);
return array_map($this->hydrate(...), $rows);
}

/**
* @return array{id:int,subject_type:string,subject_id:int,kind:string,body:?string,occurred_at:string,author:?string,created_at:string}|null
*/
public function get(int $id): ?array
{
$row = $this->storage()->selectOne(
'SELECT id, subject_type, subject_id, kind, body, occurred_at, author, created_at
FROM ' . Schema::ACTIVITY . ' WHERE id = :id',
['id' => $id],
);
return $row === null ? null : $this->hydrate($row);
}

/** Delete one activity outright by id; returns the number of rows removed (0 if none). */
public function delete(int $id): int
{
return $this->storage()->execute('DELETE FROM ' . Schema::ACTIVITY . ' WHERE id = :id', ['id' => $id]);
}

// --- validation / hydration -----------------------------------------

/**
* Resolve and validate the subject: an allow-listed `subject_type` and a
* `subject_id` that exists in the mapped table.
*
* @param array<string,mixed> $fields
* @return array{0:string,1:int}
*/
private function subject(array $fields): array
{
$type = trim((string) ($fields['subject_type'] ?? ''));
if (!isset(self::SUBJECTS[$type])) {
throw new \InvalidArgumentException('"subject_type" must be one of: ' . implode(', ', array_keys(self::SUBJECTS)) . '.');
}
$raw = trim((string) ($fields['subject_id'] ?? ''));
if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1) {
throw new \InvalidArgumentException('"subject_id" must be a positive whole number.');
}
$id = (int) $raw;
if ($this->storage()->selectOne('SELECT id FROM ' . self::SUBJECTS[$type] . ' WHERE id = :id', ['id' => $id]) === null) {
throw new \InvalidArgumentException("No {$type} with id {$id}.");
}
return [$type, $id];
}

/** @param array<string,mixed> $fields */
private function kind(array $fields): string
{
if (!array_key_exists('kind', $fields)) {
return 'note';
}
$kind = trim((string) $fields['kind']);
if ($kind === '') {
return 'note';
}
if (!in_array($kind, self::KINDS, true)) {
throw new \InvalidArgumentException('"kind" must be one of: ' . implode(', ', self::KINDS) . '.');
}
return $kind;
}

/** @param array<string,mixed> $fields */
private function body(array $fields): ?string
{
$v = trim((string) ($fields['body'] ?? ''));
if ($v === '') {
return null;
}
if (mb_strlen($v) > self::MAX_BODY) {
throw new \InvalidArgumentException('"body" must be ' . self::MAX_BODY . ' characters or fewer.');
}
return $v;
}

/**
* When the activity happened. Absent → now. Accepts a full datetime or an
* `datetime-local` value (`T` separator, no seconds); a sloppy value is
* rejected rather than silently reinterpreted.
*
* @param array<string,mixed> $fields
*/
private function occurredAt(array $fields, string $now): string
{
$raw = str_replace('T', ' ', trim((string) ($fields['occurred_at'] ?? '')));
if ($raw === '') {
return $now;
}
foreach (['Y-m-d H:i:s', 'Y-m-d H:i'] as $fmt) {
$d = \DateTimeImmutable::createFromFormat($fmt, $raw);
if ($d !== false && $d->format($fmt) === $raw) {
return $d->format('Y-m-d H:i:s');
}
}
throw new \InvalidArgumentException('"occurred_at" must be a valid date and time.');
}

private function author(?string $author): ?string
{
if ($author === null) {
return null;
}
$v = trim($author);
if ($v === '') {
return null;
}
return mb_substr($v, 0, self::MAX_AUTHOR);
}

/**
* @param array<string,mixed> $row
* @return array{id:int,subject_type:string,subject_id:int,kind:string,body:?string,occurred_at:string,author:?string,created_at:string}
*/
private function hydrate(array $row): array
{
return [
'id' => (int) $row['id'],
'subject_type' => (string) $row['subject_type'],
'subject_id' => (int) $row['subject_id'],
'kind' => (string) $row['kind'],
'body' => $row['body'] === null ? null : (string) $row['body'],
'occurred_at' => (string) $row['occurred_at'],
'author' => $row['author'] === null ? null : (string) $row['author'],
'created_at' => (string) $row['created_at'],
];
}

private function storage(): PluginStorage
{
return ($this->storage)();
}
}
106 changes: 106 additions & 0 deletions src/ActivitiesAdmin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Crm;

/**
* The activity timeline block, embedded on a record's edit page ({@see ContactsAdmin},
* {@see OrganizationsAdmin}). It renders the subject's timeline, an "add" form, and a
* delete control per entry — all posting to the host page's `activity-add` /
* `activity-delete` actions (crm:write + CSRF gated by core). Every author value —
* the note body and the `author` name — is escaped on the way out; styling is one
* nonce-carrying `<style>` block, since the admin CSP is nonce-only for `style-src`.
*/
final class ActivitiesAdmin
{
/**
* @param string $csrf CSRF token for the forms
* @param string $page host admin slug (`crm` or `crm-organizations`)
* @param string $subjectType `contact` | `organization`
* @param int $subjectId the record the timeline hangs off
* @param list<array<string,mixed>> $activities the subject's timeline (newest first)
* @param string $nonce the request CSP nonce
*/
public static function render(string $csrf, string $page, string $subjectType, int $subjectId, array $activities, string $nonce): string
{
return self::styles($nonce)
. '<section class="cr-timeline">'
. '<h2>Activity</h2>'
. self::form($csrf, $page, $subjectType, $subjectId)
. self::list($csrf, $page, $subjectType, $subjectId, $activities)
. '</section>';
}

private static function form(string $csrf, string $page, string $subjectType, int $subjectId): string
{
$kinds = '';
foreach (Activities::KINDS as $k) {
$kinds .= '<option value="' . self::e($k) . '">' . self::e(ucfirst($k)) . '</option>';
}

return '<form method="post" action="/admin/' . self::e($page) . '/activity-add" class="cr-act-form">'
. '<input type="hidden" name="_csrf" value="' . self::e($csrf) . '">'
. '<input type="hidden" name="subject_type" value="' . self::e($subjectType) . '">'
. '<input type="hidden" name="subject_id" value="' . self::e((string) $subjectId) . '">'
. '<div class="cr-act-row">'
. '<label>Type<select name="kind">' . $kinds . '</select></label>'
. '<label>When<input type="datetime-local" name="occurred_at"></label>'
. '</div>'
. '<label>Note<textarea name="body" rows="2" maxlength="20000" placeholder="What happened?"></textarea></label>'
. '<div class="cr-actions"><button type="submit" class="nb-btn">Log activity</button></div>'
. '</form>';
}

/** @param list<array<string,mixed>> $activities */
private static function list(string $csrf, string $page, string $subjectType, int $subjectId, array $activities): string
{
if ($activities === []) {
return '<p class="nb-muted">No activity logged yet.</p>';
}

$items = '';
foreach ($activities as $a) {
$body = (string) ($a['body'] ?? '');
$meta = self::e(ucfirst((string) $a['kind'])) . ' · ' . self::e((string) $a['occurred_at']);
if (($a['author'] ?? null) !== null && (string) $a['author'] !== '') {
$meta .= ' · ' . self::e((string) $a['author']);
}
$items .= '<li class="cr-act">'
. '<div class="cr-act-head"><span class="cr-act-meta">' . $meta . '</span>'
. '<form method="post" action="/admin/' . self::e($page) . '/activity-delete" data-confirm="Delete this activity?">'
. '<input type="hidden" name="_csrf" value="' . self::e($csrf) . '">'
. '<input type="hidden" name="id" value="' . self::e((string) $a['id']) . '">'
. '<input type="hidden" name="subject_type" value="' . self::e($subjectType) . '">'
. '<input type="hidden" name="subject_id" value="' . self::e((string) $subjectId) . '">'
. '<button type="submit" class="cr-link-danger">Delete</button></form></div>'
. ($body !== '' ? '<p class="cr-act-body">' . self::e($body) . '</p>' : '')
. '</li>';
}

return '<ul class="cr-act-list">' . $items . '</ul>';
}

private static function styles(string $nonce): string
{
return '<style nonce="' . self::e($nonce) . '">'
. '.cr-timeline{max-width:44rem;margin:2rem 0 0;border-top:1px solid rgba(128,128,128,.2);padding-top:1rem}'
. '.cr-act-form{display:flex;flex-direction:column;gap:.6rem;margin:0 0 1.5rem}'
. '.cr-act-row{display:flex;gap:.75rem;flex-wrap:wrap}'
. '.cr-act-form label{display:flex;flex-direction:column;gap:.25rem;flex:1 1 12rem;font-weight:600;font-size:.85rem}'
. '.cr-act-form input,.cr-act-form textarea,.cr-act-form select{font:inherit;padding:.5rem .6rem;min-height:44px;box-sizing:border-box}'
. '.cr-act-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.75rem}'
. '.cr-act{border:1px solid rgba(128,128,128,.2);border-radius:8px;padding:.6rem .75rem}'
. '.cr-act-head{display:flex;justify-content:space-between;align-items:center;gap:.5rem}'
. '.cr-act-meta{font-size:.8rem;font-weight:700;opacity:.75}'
. '.cr-act-body{margin:.4rem 0 0;white-space:pre-wrap}'
. '.cr-link-danger{background:none;border:0;color:#c0392b;font:inherit;cursor:pointer;text-decoration:underline;padding:0;min-height:44px}'
. '</style>';
}

/** 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');
}
}
15 changes: 13 additions & 2 deletions src/Contacts.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,21 @@ public function all(?string $q = null): array
return array_map($this->hydrate(...), $rows);
}

/** Delete a contact outright by id; returns the number of rows removed (0 if none). */
/**
* Delete a contact outright, together with its activity timeline — the GDPR
* "forget" primitive, so nothing about the person is left behind. Atomic:
* the activities and the contact go in one transaction. Returns the number of
* contact rows removed (0 if none).
*/
public function delete(int $id): int
{
return $this->storage()->execute('DELETE FROM ' . Schema::CONTACT . ' WHERE id = :id', ['id' => $id]);
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_CONTACT, 'id' => $id],
);
return $this->storage()->execute('DELETE FROM ' . Schema::CONTACT . ' WHERE id = :id', ['id' => $id]);
});
}

// --- validation / hydration -----------------------------------------
Expand Down
23 changes: 16 additions & 7 deletions src/ContactsAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,21 @@
final class ContactsAdmin
{
private const NOTICES = [
'saved' => ['ok', 'Contact saved.'],
'deleted' => ['ok', 'Contact deleted.'],
'bademail' => ['err', 'That email address is not valid.'],
'noname' => ['err', 'A contact needs a first or last name.'],
'invalid' => ['err', 'Check the details and try again.'],
'saved' => ['ok', 'Contact saved.'],
'deleted' => ['ok', 'Contact deleted.'],
'activity' => ['ok', 'Activity logged.'],
'activitygone' => ['ok', 'Activity deleted.'],
'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.'],
'invalid' => ['err', 'Check the details and try again.'],
];

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

/**
Expand All @@ -45,6 +51,9 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit
. '<p class="nb-muted cr-intro">The people your business keeps track of. This is private data — only roles with the <code>crm</code> capability can see it.</p>';

$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 .= $this->list($csrf, $contacts, $q, $editId);

return $html;
Expand Down
Loading
Loading