[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> &rarr; <code>success</code>) and retry flows (e.g. <code>error</code>/<code>warning</code> &rarr; <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>&nbsp; $schema['canvas_notifications'] = [<br>&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Stores Canvas notification payloads.',<br>&nbsp;&nbsp;&nbsp; 'fields' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'notification_id' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'varchar_ascii',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'length' =&gt; 128,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Notification UUID.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'varchar_ascii',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'length' =&gt; 32,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Notification type: processing, success, info, warning, error.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'key' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'varchar_ascii',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'length' =&gt; 255,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; FALSE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'default' =&gt; NULL,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Optional grouping key for related notifications.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'title' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'varchar',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'length' =&gt; 255,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Notification title.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'message' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'text',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'size' =&gt; 'normal',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Notification body text.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'timestamp' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'int',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'size' =&gt; 'big',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Unix timestamp in milliseconds.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'actions' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'text',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'size' =&gt; 'normal',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; FALSE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'default' =&gt; NULL,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'JSON-encoded array of action objects.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp; 'primary key' =&gt; ['id'],<br>&nbsp;&nbsp;&nbsp; 'indexes' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'idx_type_timestamp' =&gt; ['type', 'timestamp'],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'idx_timestamp' =&gt; ['timestamp'],<br>&nbsp;&nbsp;&nbsp; ],<br>&nbsp; ];<br><br>&nbsp; $schema['canvas_notification_reads'] = [<br>&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Tracks which notifications each user has read.',<br>&nbsp;&nbsp;&nbsp; 'fields' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'uid' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'int',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'unsigned' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'User ID.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'notification_id' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'varchar_ascii',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'length' =&gt; 128,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'Notification UUID.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'timestamp' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'type' =&gt; 'int',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'size' =&gt; 'big',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'not null' =&gt; TRUE,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'description' =&gt; 'When the notification was marked as read (ms).',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp; 'primary key' =&gt; ['uid', 'notification_id'],<br>&nbsp;&nbsp;&nbsp; 'indexes' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'idx_timestamp' =&gt; ['timestamp'],<br>&nbsp;&nbsp;&nbsp; ],<br>&nbsp; ];<br><br>&nbsp; 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>&amp;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>&nbsp; private const TABLE_NOTIFICATIONS = 'canvas_notifications';<br>&nbsp; private const TABLE_READS = 'canvas_notification_reads';<br>&nbsp; private const RETENTION_MS = 2592000000; // 30 days in milliseconds.<br>&nbsp; private const PROCESSING_TIMEOUT_MS = 1800000; // 30 minutes in milliseconds.<br><br>&nbsp; public function __construct(<br>&nbsp;&nbsp;&nbsp; private readonly Connection $database,<br>&nbsp;&nbsp;&nbsp; private readonly UuidInterface $uuid,<br>&nbsp; ) {}<br><br>&nbsp; public function create(array $notification): void { ... }<br>&nbsp; public function getRecent(int $uid, int $limit = 25): array { ... }<br>&nbsp; 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>&nbsp;&nbsp; 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>&nbsp;&nbsp; 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-&gt;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>&nbsp; \Drupal::service('Drupal\canvas\Notification\NotificationService')-&gt;create([<br>&nbsp;&nbsp;&nbsp; 'type' =&gt; 'info',<br>&nbsp;&nbsp;&nbsp; 'title' =&gt; 'Test notification',<br>&nbsp;&nbsp;&nbsp; 'message' =&gt; 'Hello world',<br>&nbsp; ]);<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&ndash;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