[Notifications] notification storage and service
>>> [!note] Migrated issue
<!-- Drupal.org comment -->
<!-- Migrated from issue #3580209. -->
Reported by: [justafish](https://www.drupal.org/user/161058)
Related to !762
>>>
<p>Build the core notifications storage layer for Canvas. Create the <code>NotificationService</code> PHP class in the Canvas module that provides the core storage and retrieval layer for notifications. This service uses two custom database tables (<code>canvas_notifications</code> and <code>canvas_notification_reads</code>) with indexed columns for efficient queries.</p>
<p>Run this on top of <a href="https://git.drupalcode.org/issue/canvas-3573776/-/tree/3573776-canvas-needs-a">https://git.drupalcode.org/issue/canvas-3573776/-/tree/3573776-canvas-needs-a</a></p>
<h2>Notes</h2>
<p>A notification can have the following types: <code>processing</code>, <code>success</code>, <code>info</code>, <code>warning</code>, <code>error</code>.</p>
<h2>Acceptance criteria</h2>
<ul>
<li><code>NotificationService</code> is registered in <code>canvas.services.yml</code> with autowire.</li>
<li><code>hook_schema()</code> defines the <code>canvas_notifications</code> and <code>canvas_notification_reads</code> tables.</li>
<li><code>canvas_notifications</code> table stores notification payloads with indexed <code>type</code> and <code>timestamp</code> columns for efficient queries.</li>
<li><code>canvas_notification_reads</code> table stores per-user read state keyed by <code>uid</code> and <code>notification_id</code>.</li>
<li><code>create()</code> always auto-generates a UUID for the <code>id</code> field. Callers do not provide an id.</li>
<li><code>create()</code> auto-generates a millisecond timestamp when not provided.</li>
<li><code>create()</code> accepts an optional <code>key</code> field on all notification types.</li>
<li>When <code>create()</code> is called with a <code>key</code>, all existing notifications with the same key are deleted before storing the new one (regardless of type). This handles state transitions (e.g. <code>processing</code> → <code>success</code>) and retry flows (e.g. <code>error</code>/<code>warning</code> → <code>processing</code>) in a single rule.</li>
<li><code>getRecent($uid, $limit = 25)</code> returns the <code>$limit</code> most recent non-processing notifications plus all active processing notifications.</li>
<li><code>getRecent()</code> includes a <code>hasRead</code> boolean on each notification resolved via a <code>LEFT JOIN</code> to the reads table for the given <code>$uid</code>.</li>
<li><code>getRecent()</code> sorts results with processing notifications first, then by timestamp descending.</li>
<li><code>markRead($uid, $notificationIds)</code> inserts entries in the reads table for each notification ID (using MERGE/upsert to be idempotent).</li>
<li>All public methods have PHPDoc.</li>
<li>Kernel tests cover all public methods and edge cases.</li>
</ul>
<h2>Implementation plan</h2>
<h3>Database schema</h3>
<p>Add <code>hook_schema()</code> in <code>canvas.install</code> (or a dedicated Hook class):</p>
<pre><pre>function canvas_schema(): array {<br> $schema['canvas_notifications'] = [<br> 'description' => 'Stores Canvas notification payloads.',<br> 'fields' => [<br> 'notification_id' => [<br> 'type' => 'varchar_ascii',<br> 'length' => 128,<br> 'not null' => TRUE,<br> 'description' => 'Notification UUID.',<br> ],<br> 'type' => [<br> 'type' => 'varchar_ascii',<br> 'length' => 32,<br> 'not null' => TRUE,<br> 'description' => 'Notification type: processing, success, info, warning, error.',<br> ],<br> 'key' => [<br> 'type' => 'varchar_ascii',<br> 'length' => 255,<br> 'not null' => FALSE,<br> 'default' => NULL,<br> 'description' => 'Optional grouping key for related notifications.',<br> ],<br> 'title' => [<br> 'type' => 'varchar',<br> 'length' => 255,<br> 'not null' => TRUE,<br> 'description' => 'Notification title.',<br> ],<br> 'message' => [<br> 'type' => 'text',<br> 'size' => 'normal',<br> 'not null' => TRUE,<br> 'description' => 'Notification body text.',<br> ],<br> 'timestamp' => [<br> 'type' => 'int',<br> 'size' => 'big',<br> 'not null' => TRUE,<br> 'description' => 'Unix timestamp in milliseconds.',<br> ],<br> 'actions' => [<br> 'type' => 'text',<br> 'size' => 'normal',<br> 'not null' => FALSE,<br> 'default' => NULL,<br> 'description' => 'JSON-encoded array of action objects.',<br> ],<br> ],<br> 'primary key' => ['id'],<br> 'indexes' => [<br> 'idx_type_timestamp' => ['type', 'timestamp'],<br> 'idx_timestamp' => ['timestamp'],<br> ],<br> ];<br><br> $schema['canvas_notification_reads'] = [<br> 'description' => 'Tracks which notifications each user has read.',<br> 'fields' => [<br> 'uid' => [<br> 'type' => 'int',<br> 'unsigned' => TRUE,<br> 'not null' => TRUE,<br> 'description' => 'User ID.',<br> ],<br> 'notification_id' => [<br> 'type' => 'varchar_ascii',<br> 'length' => 128,<br> 'not null' => TRUE,<br> 'description' => 'Notification UUID.',<br> ],<br> 'timestamp' => [<br> 'type' => 'int',<br> 'size' => 'big',<br> 'not null' => TRUE,<br> 'description' => 'When the notification was marked as read (ms).',<br> ],<br> ],<br> 'primary key' => ['uid', 'notification_id'],<br> 'indexes' => [<br> 'idx_timestamp' => ['timestamp'],<br> ],<br> ];<br><br> return $schema;<br>}</pre></pre><p>A <code>hook_update_N()</code> will also be needed to create these tables on existing installations.</p>
<h3>Service class</h3>
<p>Create <code>src/Notification/NotificationService.php</code>:</p>
<pre><pre>&lt;?php<br><br>declare(strict_types=1);<br><br>namespace Drupal\canvas\Notification;<br><br>use Drupal\Component\Serialization\Json;<br>use Drupal\Component\Uuid\UuidInterface;<br>use Drupal\Core\Database\Connection;<br><br>final class NotificationService {<br><br> private const TABLE_NOTIFICATIONS = 'canvas_notifications';<br> private const TABLE_READS = 'canvas_notification_reads';<br> private const RETENTION_MS = 2592000000; // 30 days in milliseconds.<br> private const PROCESSING_TIMEOUT_MS = 1800000; // 30 minutes in milliseconds.<br><br> public function __construct(<br> private readonly Connection $database,<br> private readonly UuidInterface $uuid,<br> ) {}<br><br> public function create(array $notification): void { ... }<br> public function getRecent(int $uid, int $limit = 25): array { ... }<br> public function markRead(int $uid, array $notificationIds): void { ... }<br>}</pre></pre><h3>Service registration</h3>
<p>Add to <code>canvas.services.yml</code>:</p>
<pre>Drupal\canvas\Notification\NotificationService: {}</pre><p>Autowire handles the <code>Connection</code> and <code>UuidInterface</code> constructor arguments.</p>
<h3>Key behaviours</h3>
<h4><code>create()</code> logic</h4>
<ul>
<li>Always auto-generate <code>id</code> (UUID). Callers must not pass <code>id</code>.</li>
<li>Auto-generate <code>timestamp</code> (<code>(int) (microtime(true) * 1000)</code>) if not set.</li>
<li>Validate required fields: <code>type</code>, <code>title</code>, <code>message</code>.</li>
<li>If <code>key</code> is set: delete all existing notifications of type <code>error</code>, <code>warning</code>, or <code>processing</code> with the same key.</li>
<li>Insert into <code>canvas_notifications</code>. The <code>actions</code> field is JSON-encoded.</li>
</ul>
<h4><code>getRecent()</code> logic</h4>
<p>Uses a single efficient query with <code>LEFT JOIN</code> for read state:</p>
<pre><pre>(SELECT n.*, IF(r.uid IS NOT NULL, 1, 0) AS has_read<br> FROM canvas_notifications n<br> LEFT JOIN canvas_notification_reads r<br> ON r.notification_id = n.id AND r.uid = :uid<br> WHERE n.type = 'processing'<br> ORDER BY n.timestamp DESC)<br>UNION ALL<br>(SELECT n.*, IF(r.uid IS NOT NULL, 1, 0) AS has_read<br> FROM canvas_notifications n<br> LEFT JOIN canvas_notification_reads r<br> ON r.notification_id = n.id AND r.uid = :uid<br> WHERE n.type != 'processing'<br> ORDER BY n.timestamp DESC<br> LIMIT :limit)</pre></pre><p>Processing notifications are always returned (no limit). Non-processing are limited to <code>$limit</code>. The <code>idx_type_timestamp</code> index covers both queries. The <code>actions</code> field is JSON-decoded before returning.</p>
<h4><code>markRead()</code> logic</h4>
<p>Uses a MERGE (upsert) to be idempotent:</p>
<pre><pre>INSERT INTO canvas_notification_reads (uid, notification_id, timestamp)<br>VALUES (:uid, :id, :now)<br>ON DUPLICATE KEY UPDATE timestamp = :now</pre></pre><h2>Tests required</h2>
<p>Kernel test: <code>tests/src/Kernel/Notification/NotificationServiceTest.php</code></p>
<p>Follow existing Canvas kernel test patterns (extends <code>KernelTestBase</code>, uses <code>#[CoversClass]</code>, <code>#[Group('canvas')]</code>). The test must install the schema via <code>$this->installSchema('canvas', ['canvas_notifications', 'canvas_notification_reads'])</code>.</p>
<table>
<thead>
<tr>
<th>Test method</th>
<th>Validates</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>testCreateStoresNotification</code></td>
<td>Row exists in <code>canvas_notifications</code> after <code>create</code></td>
</tr>
<tr>
<td><code>testCreateAlwaysGeneratesUuid</code></td>
<td><code>id</code> is always a new UUID, never caller-provided. If a caller attempts to provide it, should throw an error.</td>
</tr>
<tr>
<td><code>testCreateAutoGeneratesTimestamp</code></td>
<td>Missing timestamp is auto-filled</td>
</tr>
<tr>
<td><code>testCreateValidatesRequiredFields</code></td>
<td>Exception on missing <code>type</code>/<code>title</code>/<code>message</code></td>
</tr>
<tr>
<td><code>testCreateErrorWithKeyDeletesAcrossTypes</code></td>
<td>Creating <code>error</code> with key deletes existing <code>processing</code>/<code>error</code>/<code>warning</code> with same key</td>
</tr>
<tr>
<td><code>testCreateWarningWithKeyDeletesAcrossTypes</code></td>
<td>Creating <code>warning</code> with key deletes existing <code>processing</code>/<code>error</code>/<code>warning</code> with same key</td>
</tr>
<tr>
<td><code>testCreateProcessingWithKeyDeletesAcrossTypes</code></td>
<td>Creating <code>processing</code> with key deletes existing <code>processing</code>/<code>error</code>/<code>warning</code> with same key</td>
</tr>
<tr>
<td><code>testCreateSuccessWithKeyDoesNotDeleteAcrossTypes</code></td>
<td>Creating <code>success</code> with key does not delete any existing notifications with same key</td>
</tr>
<tr>
<td><code>testCreateInfoWithKeyDoesNotDeleteAcrossTypes</code></td>
<td>Creating <code>info</code> with key does not delete any existing notifications with same key</td>
</tr>
<tr>
<td><code>testCreateStoresActionsAsJson</code></td>
<td>Actions array is JSON-encoded in DB, decoded on read</td>
</tr>
<tr>
<td><code>testGetRecentReturnsLimitedResults</code></td>
<td>Only returns <code>$limit</code> non-processing notifications</td>
</tr>
<tr>
<td><code>testGetRecentAlwaysIncludesProcessing</code></td>
<td>Processing notifications are included beyond limit</td>
</tr>
<tr>
<td><code>testGetRecentSortsCorrectly</code></td>
<td>Processing first, then newest first</td>
</tr>
<tr>
<td><code>testGetRecentIncludesHasReadState</code></td>
<td><code>hasRead</code> is true/false based on reads table</td>
</tr>
<tr>
<td><code>testGetRecentHasReadIsPerUser</code></td>
<td>Different users see different <code>hasRead</code> values</td>
</tr>
<tr>
<td><code>testMarkReadCreatesReadEntries</code></td>
<td>Rows exist in <code>canvas_notification_reads</code></td>
</tr>
<tr>
<td><code>testMarkReadIdempotent</code></td>
<td>Calling <code>markRead</code> twice does not error (upsert)</td>
</tr>
</tbody>
</table>
<h2>Manual testing steps</h2>
<ol>
<li>Enable <code>canvas_dev_mode</code> module.</li>
<li>
<p>Verify the tables exist:</p>
<pre>ddev drush sqlq "SHOW TABLES LIKE 'canvas_notif%';"</pre><p>Expected output: <code>canvas_notifications</code> and <code>canvas_notification_reads</code>.</p>
</li>
<li>
<p>Create a notification via Drush:</p>
<pre><pre>ddev drush php:eval "<br> \Drupal::service('Drupal\canvas\Notification\NotificationService')->create([<br> 'type' => 'info',<br> 'title' => 'Test notification',<br> 'message' => 'Hello world',<br> ]);<br>"</pre></pre></li>
<li>
<p>Verify the row exists:</p>
<pre>ddev drush sqlq "SELECT * FROM canvas_notifications;"</pre></li>
<li>Create a <code>processing</code> notification, then create a <code>success</code> notification with the same key. Verify the processing row was replaced by the success row. Expected: only the success notification with that key remains.</li>
<li>Create an <code>error</code> notification with a key, then create a <code>processing</code> notification with the same key (simulating a retry). Verify the error row was replaced by the processing row. Expected: only the processing notification with that key remains.</li>
<li>Repeat steps 1–7 starting from an old version of Canvas and running the database update procedure.</li>
</ol>
> Related issue: [Issue #3573776](https://www.drupal.org/node/3573776)
issue