@astram/guard 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/auth/auth.module.d.ts +7 -0
  2. package/dist/auth/auth.module.d.ts.map +1 -1
  3. package/dist/auth/auth.module.js +43 -1
  4. package/dist/auth/tokens.d.ts +13 -0
  5. package/dist/auth/tokens.d.ts.map +1 -1
  6. package/dist/auth/tokens.js +14 -1
  7. package/dist/config/notification-config.d.ts +20 -0
  8. package/dist/config/notification-config.d.ts.map +1 -0
  9. package/dist/config/notification-config.js +33 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +49 -36
  13. package/dist/module/shield.module.d.ts +12 -0
  14. package/dist/module/shield.module.d.ts.map +1 -1
  15. package/dist/module/shield.module.js +11 -0
  16. package/dist/notifications/mariadb-notification-store.d.ts +38 -0
  17. package/dist/notifications/mariadb-notification-store.d.ts.map +1 -0
  18. package/dist/notifications/mariadb-notification-store.js +151 -0
  19. package/dist/notifications/notification-retention.job.d.ts +52 -0
  20. package/dist/notifications/notification-retention.job.d.ts.map +1 -0
  21. package/dist/notifications/notification-retention.job.js +97 -0
  22. package/dist/notifications/notification.service.d.ts +68 -0
  23. package/dist/notifications/notification.service.d.ts.map +1 -0
  24. package/dist/notifications/notification.service.js +247 -0
  25. package/dist/notifications/notifications.controller.d.ts +57 -0
  26. package/dist/notifications/notifications.controller.d.ts.map +1 -0
  27. package/dist/notifications/notifications.controller.js +257 -0
  28. package/dist/notifications/ports.d.ts +112 -0
  29. package/dist/notifications/ports.d.ts.map +1 -0
  30. package/dist/notifications/ports.js +63 -0
  31. package/package.json +5 -5
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `MariaDbNotificationStore` — the framework-default {@link NotificationStore} (notification-store
3
+ * SPEC §5A) over `<ns>_identity.user_notification` (identity migration 108), on guard's core pool
4
+ * (`<ns>_core`, DML on `<ns>_identity`). Database names are resolved at construction, never per
5
+ * request; every statement is parameterized.
6
+ *
7
+ * Tenant wall (core 50 #4, #7; Shield #4): every per-user statement filters `un_user_uuid = ?
8
+ * AND un_tenant_id = ?`, including the post-insert count. `purge` is the one statement that is
9
+ * not per-user: it is the retention sweep over the whole table, bounded by `LIMIT`.
10
+ *
11
+ * `insertMany` is ONE multi-row `INSERT … ON DUPLICATE KEY UPDATE un_dedupe_key = un_dedupe_key`:
12
+ * a repeated `(user, tenant, dedupe key)` is a no-op on `uq_un_dedupe`; never `INSERT IGNORE`
13
+ * (that would also hide FK and data errors). The inserted count is NOT read from `affectedRows`:
14
+ * the `mariadb` driver connects with `CLIENT_FOUND_ROWS` by default (`foundRows` defaults to true,
15
+ * `lib/config/connection-options.js`), so an ODKU row left unchanged reports 1, the same as an
16
+ * insert. Instead the store counts its own freshly generated `un_uuid`s right after the insert, on
17
+ * the same `query` (the caller's transaction when given, so it sees its own uncommitted rows).
18
+ */
19
+ import type { Pool } from 'mariadb';
20
+ import { type FrameworkSchemaNames } from '../config/framework-schema-names.js';
21
+ import { type NotificationCategory, type NotificationInsert, type NotificationListQuery, type NotificationListResult, type NotificationRetentionPolicy, type NotificationStore, type NotificationUnreadCounts, type NotificationWriteOptions } from './ports.js';
22
+ /** Rows per `insertMany` statement (the `post` recipient cap, SPEC §10). */
23
+ export declare const NOTIFICATION_INSERT_MAX_ROWS = 1000;
24
+ export declare class MariaDbNotificationStore implements NotificationStore {
25
+ private readonly pool;
26
+ /** `<identity>`.`user_notification`, resolved at construction. */
27
+ private readonly table;
28
+ constructor(pool: Pick<Pool, 'query'>,
29
+ /** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
30
+ schemas?: FrameworkSchemaNames);
31
+ list(userId: string, tenantId: string, query: NotificationListQuery): Promise<NotificationListResult>;
32
+ countUnread(userId: string, tenantId: string): Promise<NotificationUnreadCounts>;
33
+ markRead(userId: string, tenantId: string, uuid: string, nowMs: number): Promise<boolean>;
34
+ markAllRead(userId: string, tenantId: string, category: NotificationCategory | undefined, nowMs: number): Promise<number>;
35
+ insertMany(rows: readonly NotificationInsert[], opts?: NotificationWriteOptions): Promise<number>;
36
+ purge(nowMs: number, policy: NotificationRetentionPolicy, maxRows: number): Promise<number>;
37
+ }
38
+ //# sourceMappingURL=mariadb-notification-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mariadb-notification-store.d.ts","sourceRoot":"","sources":["../../src/notifications/mariadb-notification-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EAEL,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAE3B,KAAK,2BAA2B,EAEhC,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC7B,KAAK,wBAAwB,EAC9B,MAAM,YAAY,CAAC;AAIpB,4EAA4E;AAC5E,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAgDjD,qBAAa,wBAAyB,YAAW,iBAAiB;IAK9D,OAAO,CAAC,QAAQ,CAAC,IAAI;IAJvB,kEAAkE;IAClE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;gBAGZ,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC;IAC1C,yFAAyF;IACzF,OAAO,CAAC,EAAE,oBAAoB;IAQ1B,IAAI,CACR,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,qBAAqB,GAC3B,OAAO,CAAC,sBAAsB,CAAC;IA2B5B,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,wBAAwB,CAAC;IAchF,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAgBzF,WAAW,CACf,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,oBAAoB,GAAG,SAAS,EAC1C,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,CAAC;IAWZ,UAAU,CACd,IAAI,EAAE,SAAS,kBAAkB,EAAE,EACnC,IAAI,GAAE,wBAA6B,GAClC,OAAO,CAAC,MAAM,CAAC;IAuDZ,KAAK,CACT,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,2BAA2B,EACnC,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC;CAiBnB"}
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MariaDbNotificationStore = exports.NOTIFICATION_INSERT_MAX_ROWS = void 0;
4
+ const framework_schema_names_js_1 = require("../config/framework-schema-names.js");
5
+ const ports_js_1 = require("./ports.js");
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ /** Rows per `insertMany` statement (the `post` recipient cap, SPEC §10). */
8
+ exports.NOTIFICATION_INSERT_MAX_ROWS = 1000;
9
+ const SELECT_COLUMNS = 'un_uuid, un_category, un_kind, un_title, un_body, un_link, un_created_at_ms, un_read_at_ms';
10
+ function isCategory(v) {
11
+ return ports_js_1.NOTIFICATION_CATEGORIES.includes(v);
12
+ }
13
+ /** BIGINT columns may arrive as bigint; the epoch-ms range fits a safe integer. */
14
+ const toRow = (r) => ({
15
+ uuid: String(r.un_uuid),
16
+ // The column carries a CHECK constraint; an unexpected value is still never passed on raw.
17
+ category: isCategory(r.un_category) ? r.un_category : 'personal',
18
+ kind: r.un_kind,
19
+ title: r.un_title,
20
+ body: r.un_body ?? null,
21
+ link: r.un_link ?? null,
22
+ createdAtMs: Number(r.un_created_at_ms),
23
+ readAtMs: r.un_read_at_ms == null ? null : Number(r.un_read_at_ms),
24
+ });
25
+ function affected(res) {
26
+ return Number(res?.affectedRows ?? 0);
27
+ }
28
+ function countOf(rows) {
29
+ const first = Array.isArray(rows) ? rows[0] : undefined;
30
+ return Number(first?.c ?? 0);
31
+ }
32
+ function assertPositiveInteger(name, v, max) {
33
+ if (!Number.isSafeInteger(v) || v < 1 || (max !== undefined && v > max)) {
34
+ throw new RangeError(`MariaDbNotificationStore: ${name} is out of range`);
35
+ }
36
+ }
37
+ class MariaDbNotificationStore {
38
+ pool;
39
+ /** `<identity>`.`user_notification`, resolved at construction. */
40
+ table;
41
+ constructor(pool,
42
+ /** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
43
+ schemas) {
44
+ this.pool = pool;
45
+ this.table = (0, framework_schema_names_js_1.qualifiedTableName)((0, framework_schema_names_js_1.schemaNamesAtConstruction)(schemas).identity, 'user_notification');
46
+ }
47
+ async list(userId, tenantId, query) {
48
+ assertPositiveInteger('page', query.page);
49
+ assertPositiveInteger('limit', query.limit, 100);
50
+ const where = ['un_user_uuid = ?', 'un_tenant_id = ?'];
51
+ const params = [userId, tenantId];
52
+ if (query.category !== undefined) {
53
+ where.push('un_category = ?');
54
+ params.push(query.category);
55
+ }
56
+ if (query.unreadOnly)
57
+ where.push('un_read_at_ms IS NULL');
58
+ const clause = where.join(' AND ');
59
+ const total = countOf(await this.pool.query(`SELECT COUNT(*) AS c FROM ${this.table} WHERE ${clause}`, params));
60
+ if (total === 0)
61
+ return { total: 0, rows: [] };
62
+ const offset = (query.page - 1) * query.limit;
63
+ if (!Number.isSafeInteger(offset)) {
64
+ throw new RangeError('MariaDbNotificationStore: page is out of range');
65
+ }
66
+ const rows = (await this.pool.query(`SELECT ${SELECT_COLUMNS} FROM ${this.table} WHERE ${clause}
67
+ ORDER BY un_created_at_ms DESC, un_uuid DESC LIMIT ? OFFSET ?`, [...params, query.limit, offset]));
68
+ return { total, rows: rows.map(toRow) };
69
+ }
70
+ async countUnread(userId, tenantId) {
71
+ const rows = (await this.pool.query(`SELECT un_category, COUNT(*) AS c FROM ${this.table}
72
+ WHERE un_user_uuid = ? AND un_tenant_id = ? AND un_read_at_ms IS NULL
73
+ GROUP BY un_category`, [userId, tenantId]));
74
+ const counts = { announcement: 0, personal: 0 };
75
+ for (const r of rows) {
76
+ if (isCategory(r.un_category))
77
+ counts[r.un_category] = Number(r.c);
78
+ }
79
+ return counts;
80
+ }
81
+ async markRead(userId, tenantId, uuid, nowMs) {
82
+ const res = await this.pool.query(`UPDATE ${this.table} SET un_read_at_ms = ?
83
+ WHERE un_uuid = ? AND un_user_uuid = ? AND un_tenant_id = ? AND un_read_at_ms IS NULL`, [nowMs, uuid, userId, tenantId]);
84
+ if (affected(res) > 0)
85
+ return true;
86
+ // Already read (the first read time is kept) or not this caller's notice in this tenant.
87
+ const rows = (await this.pool.query(`SELECT 1 AS found FROM ${this.table}
88
+ WHERE un_uuid = ? AND un_user_uuid = ? AND un_tenant_id = ? LIMIT 1`, [uuid, userId, tenantId]));
89
+ return rows.length > 0;
90
+ }
91
+ async markAllRead(userId, tenantId, category, nowMs) {
92
+ const params = [nowMs, userId, tenantId];
93
+ let sql = `UPDATE ${this.table} SET un_read_at_ms = ?
94
+ WHERE un_user_uuid = ? AND un_tenant_id = ? AND un_read_at_ms IS NULL`;
95
+ if (category !== undefined) {
96
+ sql += ' AND un_category = ?';
97
+ params.push(category);
98
+ }
99
+ return affected(await this.pool.query(sql, params));
100
+ }
101
+ async insertMany(rows, opts = {}) {
102
+ if (rows.length === 0)
103
+ return 0;
104
+ if (rows.length > exports.NOTIFICATION_INSERT_MAX_ROWS) {
105
+ throw new RangeError('MariaDbNotificationStore: too many rows for one insert');
106
+ }
107
+ const run = opts.query ?? ((sql, params) => this.pool.query(sql, params));
108
+ const params = [];
109
+ for (const r of rows) {
110
+ params.push(r.uuid, r.tenantId, r.userId, r.category, r.kind, r.title, r.body, r.link, r.dedupeKey, r.createdAtMs);
111
+ }
112
+ const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)').join(', ');
113
+ await run(`INSERT INTO ${this.table}
114
+ (un_uuid, un_tenant_id, un_user_uuid, un_category, un_kind, un_title, un_body, un_link,
115
+ un_dedupe_key, un_created_at_ms, un_read_at_ms)
116
+ VALUES ${values}
117
+ ON DUPLICATE KEY UPDATE un_dedupe_key = un_dedupe_key`, params);
118
+ // Count the rows that landed (see the file header: affectedRows cannot tell an ODKU no-op from
119
+ // an insert). Grouped by tenant so every statement stays tenant- and user-scoped.
120
+ const byTenant = new Map();
121
+ for (const r of rows) {
122
+ const group = byTenant.get(r.tenantId);
123
+ if (group)
124
+ group.push(r);
125
+ else
126
+ byTenant.set(r.tenantId, [r]);
127
+ }
128
+ let inserted = 0;
129
+ for (const [tenantId, group] of byTenant) {
130
+ const users = [...new Set(group.map((r) => r.userId))];
131
+ const marks = (n) => new Array(n).fill('?').join(', ');
132
+ inserted += countOf(await run(`SELECT COUNT(*) AS c FROM ${this.table}
133
+ WHERE un_tenant_id = ? AND un_user_uuid IN (${marks(users.length)})
134
+ AND un_uuid IN (${marks(group.length)})`, [tenantId, ...users, ...group.map((r) => r.uuid)]));
135
+ }
136
+ return inserted;
137
+ }
138
+ async purge(nowMs, policy, maxRows) {
139
+ assertPositiveInteger('maxRows', maxRows);
140
+ const readCutoff = nowMs - policy.readDays * DAY_MS;
141
+ const maxCutoff = nowMs - policy.maxDays * DAY_MS;
142
+ // `maxDays ≥ readDays` (loader rule) ⇒ maxCutoff ≤ readCutoff, so the leading range keeps the
143
+ // statement on `idx_un_created`: read notices older than the read retention, and every notice
144
+ // older than the max retention.
145
+ const lead = Math.max(readCutoff, maxCutoff);
146
+ return affected(await this.pool.query(`DELETE FROM ${this.table}
147
+ WHERE un_created_at_ms < ? AND (un_read_at_ms IS NOT NULL OR un_created_at_ms < ?)
148
+ ORDER BY un_created_at_ms LIMIT ?`, [lead, maxCutoff, maxRows]));
149
+ }
150
+ }
151
+ exports.MariaDbNotificationStore = MariaDbNotificationStore;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `NotificationRetentionJob` — deletes expired notices (notification-store SPEC §5E, §10, §11).
3
+ *
4
+ * Registered with `ShieldModule.forRoot({ auth: true })` beside the store, over guard's core pool
5
+ * `SHIELD_DB_POOL` (it does not own the pool: `ConnectionLifecycle` ends it in
6
+ * `onApplicationShutdown`, after this job stopped).
7
+ *
8
+ * - First pass {@link NOTIFICATION_RETENTION_FIRST_RUN_MS} after bootstrap, then one pass every
9
+ * {@link NOTIFICATION_RETENTION_INTERVAL_MS} (hourly), scheduled only after the previous pass
10
+ * settled, so passes never overlap.
11
+ * - One pass = one `purge` of at most {@link NOTIFICATION_RETENTION_BATCH} rows: read notices older
12
+ * than `NOTIFICATION_READ_RETENTION_DAYS`, any notice older than `NOTIFICATION_MAX_RETENTION_DAYS`.
13
+ * - A failed pass is logged by error class only (never the driver message) and retried at the next
14
+ * interval; nothing here throws into Nest, so it never blocks boot (a missing table, migration
15
+ * 108 not applied, is just a logged pass).
16
+ * - Every timer is `unref()`'d. `onModuleDestroy` (Nest `close()` step 1) clears the timer and
17
+ * waits for a running pass. Off the request path (Shield #12).
18
+ */
19
+ import { type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
20
+ import type { NotificationRetentionPolicy, NotificationStore } from './ports.js';
21
+ export declare const NOTIFICATION_RETENTION_FIRST_RUN_MS = 5000;
22
+ export declare const NOTIFICATION_RETENTION_INTERVAL_MS: number;
23
+ export declare const NOTIFICATION_RETENTION_BATCH = 5000;
24
+ export interface NotificationRetentionLogger {
25
+ log(message: string): void;
26
+ error(message: string): void;
27
+ }
28
+ export interface NotificationRetentionJobOptions {
29
+ /** Default `Date.now`. */
30
+ readonly now?: () => number;
31
+ /** Default `new Logger('Shield')`. */
32
+ readonly logger?: NotificationRetentionLogger;
33
+ }
34
+ export declare class NotificationRetentionJob implements OnApplicationBootstrap, OnModuleDestroy {
35
+ private readonly store;
36
+ private readonly policy;
37
+ private readonly now;
38
+ private readonly logger;
39
+ private stopped;
40
+ private timer;
41
+ private inFlight;
42
+ constructor(store: Pick<NotificationStore, 'purge'>, policy: NotificationRetentionPolicy, options?: NotificationRetentionJobOptions);
43
+ onApplicationBootstrap(): void;
44
+ onModuleDestroy(): Promise<void>;
45
+ /**
46
+ * One retention pass; resolves to the deleted count, or `null` when the pass failed (logged).
47
+ * Exposed for tests and operators; the timer calls it hourly.
48
+ */
49
+ runOnce(): Promise<number | null>;
50
+ private schedule;
51
+ }
52
+ //# sourceMappingURL=notification-retention.job.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-retention.job.d.ts","sourceRoot":"","sources":["../../src/notifications/notification-retention.job.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAGL,KAAK,sBAAsB,EAC3B,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAExB,OAAO,KAAK,EAAE,2BAA2B,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEjF,eAAO,MAAM,mCAAmC,OAAQ,CAAC;AACzD,eAAO,MAAM,kCAAkC,QAAiB,CAAC;AACjE,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,MAAM,WAAW,2BAA2B;IAC1C,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,+BAA+B;IAC9C,0BAA0B;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,sCAAsC;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,2BAA2B,CAAC;CAC/C;AAED,qBACa,wBAAyB,YAAW,sBAAsB,EAAE,eAAe;IAQpF,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IARzB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA8B;IACrD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,QAAQ,CAA4B;gBAGzB,KAAK,EAAE,IAAI,CAAC,iBAAiB,EAAE,OAAO,CAAC,EACvC,MAAM,EAAE,2BAA2B,EACpD,OAAO,GAAE,+BAAoC;IAM/C,sBAAsB,IAAI,IAAI;IAIxB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAOtC;;;OAGG;IACG,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAavC,OAAO,CAAC,QAAQ;CAcjB"}
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.NotificationRetentionJob = exports.NOTIFICATION_RETENTION_BATCH = exports.NOTIFICATION_RETENTION_INTERVAL_MS = exports.NOTIFICATION_RETENTION_FIRST_RUN_MS = void 0;
13
+ /**
14
+ * `NotificationRetentionJob` — deletes expired notices (notification-store SPEC §5E, §10, §11).
15
+ *
16
+ * Registered with `ShieldModule.forRoot({ auth: true })` beside the store, over guard's core pool
17
+ * `SHIELD_DB_POOL` (it does not own the pool: `ConnectionLifecycle` ends it in
18
+ * `onApplicationShutdown`, after this job stopped).
19
+ *
20
+ * - First pass {@link NOTIFICATION_RETENTION_FIRST_RUN_MS} after bootstrap, then one pass every
21
+ * {@link NOTIFICATION_RETENTION_INTERVAL_MS} (hourly), scheduled only after the previous pass
22
+ * settled, so passes never overlap.
23
+ * - One pass = one `purge` of at most {@link NOTIFICATION_RETENTION_BATCH} rows: read notices older
24
+ * than `NOTIFICATION_READ_RETENTION_DAYS`, any notice older than `NOTIFICATION_MAX_RETENTION_DAYS`.
25
+ * - A failed pass is logged by error class only (never the driver message) and retried at the next
26
+ * interval; nothing here throws into Nest, so it never blocks boot (a missing table, migration
27
+ * 108 not applied, is just a logged pass).
28
+ * - Every timer is `unref()`'d. `onModuleDestroy` (Nest `close()` step 1) clears the timer and
29
+ * waits for a running pass. Off the request path (Shield #12).
30
+ */
31
+ const common_1 = require("@nestjs/common");
32
+ const audit_chain_sealer_js_1 = require("../audit/audit-chain-sealer.js");
33
+ exports.NOTIFICATION_RETENTION_FIRST_RUN_MS = 5_000;
34
+ exports.NOTIFICATION_RETENTION_INTERVAL_MS = 60 * 60 * 1000;
35
+ exports.NOTIFICATION_RETENTION_BATCH = 5000;
36
+ let NotificationRetentionJob = class NotificationRetentionJob {
37
+ store;
38
+ policy;
39
+ now;
40
+ logger;
41
+ stopped = false;
42
+ timer;
43
+ inFlight;
44
+ constructor(store, policy, options = {}) {
45
+ this.store = store;
46
+ this.policy = policy;
47
+ this.now = options.now ?? Date.now;
48
+ this.logger = options.logger ?? new common_1.Logger('Shield');
49
+ }
50
+ onApplicationBootstrap() {
51
+ this.schedule(exports.NOTIFICATION_RETENTION_FIRST_RUN_MS);
52
+ }
53
+ async onModuleDestroy() {
54
+ this.stopped = true;
55
+ if (this.timer !== undefined)
56
+ clearTimeout(this.timer);
57
+ this.timer = undefined;
58
+ await this.inFlight; // never rejects (runOnce catches)
59
+ }
60
+ /**
61
+ * One retention pass; resolves to the deleted count, or `null` when the pass failed (logged).
62
+ * Exposed for tests and operators; the timer calls it hourly.
63
+ */
64
+ async runOnce() {
65
+ try {
66
+ const deleted = await this.store.purge(this.now(), this.policy, exports.NOTIFICATION_RETENTION_BATCH);
67
+ if (deleted > 0)
68
+ this.logger.log(`[notifications] retention removed ${deleted} notices`);
69
+ return deleted;
70
+ }
71
+ catch (err) {
72
+ this.logger.error(`[notifications] retention pass failed (${(0, audit_chain_sealer_js_1.auditSealErrorClass)(err)}); retried next hour`);
73
+ return null;
74
+ }
75
+ }
76
+ schedule(delayMs) {
77
+ if (this.stopped || this.timer !== undefined)
78
+ return;
79
+ this.timer = setTimeout(() => {
80
+ this.timer = undefined;
81
+ if (this.stopped)
82
+ return;
83
+ this.inFlight = this.runOnce()
84
+ .then(() => undefined)
85
+ .finally(() => {
86
+ this.inFlight = undefined;
87
+ this.schedule(exports.NOTIFICATION_RETENTION_INTERVAL_MS);
88
+ });
89
+ }, delayMs);
90
+ this.timer.unref();
91
+ }
92
+ };
93
+ exports.NotificationRetentionJob = NotificationRetentionJob;
94
+ exports.NotificationRetentionJob = NotificationRetentionJob = __decorate([
95
+ (0, common_1.Injectable)(),
96
+ __metadata("design:paramtypes", [Object, Object, Object])
97
+ ], NotificationRetentionJob);
@@ -0,0 +1,68 @@
1
+ import type { Pool } from 'mariadb';
2
+ import { type FrameworkSchemaNames } from '../config/framework-schema-names.js';
3
+ import { type NotificationCategory, type NotificationKindRegistration, type NotificationStore, type NotificationWriteOptions } from './ports.js';
4
+ /** SPEC §10 limits. */
5
+ export declare const NOTIFICATION_TITLE_MAX = 160;
6
+ export declare const NOTIFICATION_BODY_MAX = 1000;
7
+ export declare const NOTIFICATION_LINK_MAX = 512;
8
+ export declare const NOTIFICATION_RECIPIENTS_MAX = 1000;
9
+ /** The prefix reserved for the framework's own kinds. */
10
+ export declare const FRAMEWORK_KIND_PREFIX = "shield.";
11
+ /** The framework's own kinds. Step 2 registers none (step 3 adds `shield.access_changed`). */
12
+ export declare const FRAMEWORK_NOTIFICATION_KINDS: readonly NotificationKindRegistration[];
13
+ export interface NotificationPostInput {
14
+ /** The company the notice belongs to; recipients must be active members of it. */
15
+ readonly tenantId: string;
16
+ /** User uuids, 1–1000. Service accounts are not users and never receive notices. */
17
+ readonly recipients: readonly string[];
18
+ readonly kind: string;
19
+ readonly title: string;
20
+ readonly body?: string | null;
21
+ /** App-relative path only (`/…`, not `//`, no `\`, no control characters). */
22
+ readonly link?: string | null;
23
+ /** Idempotency key per recipient and tenant, 1–64 of `[A-Za-z0-9._:-]`. */
24
+ readonly dedupeKey?: string | null;
25
+ }
26
+ export interface NotificationPostResult {
27
+ readonly created: number;
28
+ readonly skipped: number;
29
+ }
30
+ /** Validated kind registry: kind ⇒ category. */
31
+ export type NotificationKindRegistry = ReadonlyMap<string, NotificationCategory>;
32
+ /**
33
+ * Build the kind registry from the app's `notificationKinds` (validated at boot, SPEC §5C) and the
34
+ * framework's own kinds. Refuses a malformed or over-long kind, an unknown category, a duplicate,
35
+ * and an app kind under the reserved `shield.` prefix; each error names the kind.
36
+ */
37
+ export declare function resolveNotificationKinds(appKinds: readonly NotificationKindRegistration[] | undefined, frameworkKinds?: readonly NotificationKindRegistration[]): NotificationKindRegistry;
38
+ /** Logs counts only (Nest `Logger` shape). */
39
+ export interface NotificationServiceLogger {
40
+ debug(message: string): void;
41
+ }
42
+ export interface NotificationServiceOptions {
43
+ /** Default `Date.now`. */
44
+ readonly now?: () => number;
45
+ /** Default `new Logger('Shield')`. */
46
+ readonly logger?: NotificationServiceLogger;
47
+ }
48
+ export declare class NotificationService {
49
+ private readonly store;
50
+ /** Guard's core pool: the recipient filter runs here when no caller `query` is given. */
51
+ private readonly pool;
52
+ private readonly kinds;
53
+ private readonly usersTable;
54
+ private readonly membershipTable;
55
+ private readonly now;
56
+ private readonly logger;
57
+ constructor(store: NotificationStore,
58
+ /** Guard's core pool: the recipient filter runs here when no caller `query` is given. */
59
+ pool: Pick<Pool, 'query'>, kinds: NotificationKindRegistry,
60
+ /** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
61
+ schemas?: FrameworkSchemaNames, options?: NotificationServiceOptions);
62
+ /** Whether `kind` is registered (framework or app). */
63
+ hasKind(kind: string): boolean;
64
+ post(input: NotificationPostInput, opts?: NotificationWriteOptions): Promise<NotificationPostResult>;
65
+ /** ONE parameterized SELECT: active users with an active membership in `tenantId`. */
66
+ private activeRecipients;
67
+ }
68
+ //# sourceMappingURL=notification.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification.service.d.ts","sourceRoot":"","sources":["../../src/notifications/notification.service.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EAOL,KAAK,oBAAoB,EAEzB,KAAK,4BAA4B,EAEjC,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC9B,MAAM,YAAY,CAAC;AAEpB,uBAAuB;AACvB,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAC1C,eAAO,MAAM,qBAAqB,MAAM,CAAC;AACzC,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAOhD,yDAAyD;AACzD,eAAO,MAAM,qBAAqB,YAAY,CAAC;AAE/C,8FAA8F;AAC9F,eAAO,MAAM,4BAA4B,EAAE,SAAS,4BAA4B,EAAO,CAAC;AAExF,MAAM,WAAW,qBAAqB;IACpC,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oFAAoF;IACpF,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,gDAAgD;AAChD,MAAM,MAAM,wBAAwB,GAAG,WAAW,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC;AAWjF;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,SAAS,4BAA4B,EAAE,GAAG,SAAS,EAC7D,cAAc,GAAE,SAAS,4BAA4B,EAAiC,GACrF,wBAAwB,CAqC1B;AAED,8CAA8C;AAC9C,MAAM,WAAW,yBAAyB;IACxC,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,0BAA0B;IACzC,0BAA0B;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,sCAAsC;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,yBAAyB,CAAC;CAC7C;AAED,qBAAa,mBAAmB;IAO5B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,yFAAyF;IACzF,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK;IATxB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;gBAGhC,KAAK,EAAE,iBAAiB;IACzC,yFAAyF;IACxE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,EACzB,KAAK,EAAE,wBAAwB;IAChD,yFAAyF;IACzF,OAAO,CAAC,EAAE,oBAAoB,EAC9B,OAAO,GAAE,0BAA+B;IAS1C,uDAAuD;IACvD,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIxB,IAAI,CACR,KAAK,EAAE,qBAAqB,EAC5B,IAAI,GAAE,wBAA6B,GAClC,OAAO,CAAC,sBAAsB,CAAC;IA6ClC,sFAAsF;YACxE,gBAAgB;CAwB/B"}