@better-forge/notify 0.1.0 → 0.1.1
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.
- package/LICENSE +21 -21
- package/README.md +11 -11
- package/dist/migrations.js +59 -59
- package/package.json +59 -59
- package/src/alertPolicy.ts +149 -149
- package/src/channelInput.ts +153 -153
- package/src/channelSecrets.ts +20 -20
- package/src/engine.ts +423 -423
- package/src/index.ts +16 -16
- package/src/migrations.ts +88 -88
- package/src/notice.ts +68 -68
- package/src/routes.ts +362 -362
- package/src/tables.ts +96 -96
- package/src/template.ts +76 -76
- package/src/transports.ts +351 -351
- package/src/worker.ts +58 -58
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 BetterStatusPage contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BetterStatusPage contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# @better-forge/notify
|
|
2
|
-
|
|
3
|
-
Notification channels, delivery history, retries and alert hygiene.
|
|
4
|
-
|
|
5
|
-
- `createNotifyEngine` records every notification, applies a channel's quiet hours, rate cap and digest window, sends with retries and sends SMTP mail. The application injects the sender, the digest wording, the names of the events hygiene treats specially and the secret resolver.
|
|
6
|
-
- `createTransports` holds the senders for email, webhook, Discord, Teams, Slack, Telegram, ntfy and Pushover. The rich channels format a neutral `Notice` (severity, headline segments, facts, detail, time) that the application builds from its own event.
|
|
7
|
-
- `createNotifyRoutes` makes the admin routes: delivery history and retry, channel management with secret masking, and SMTP settings. The application injects audit, identity, secret masking, the vault-use guard, the names its API uses for what a notification is about, and the test sends.
|
|
8
|
-
- `createDeliveryWorker` sends the deliveries that came due every 30 seconds and purges old history daily, and runs the extra tasks the application adds to those ticks.
|
|
9
|
-
- `defineNotifyTables` / `createNotifyDbModule` define the tables and their migrations; the application passes the names of the columns that identify what a delivery is about.
|
|
10
|
-
|
|
11
|
-
Depends on: `@better-forge/db`, `@better-forge/contracts`, `@better-forge/vault`, `@better-forge/audit`.
|
|
1
|
+
# @better-forge/notify
|
|
2
|
+
|
|
3
|
+
Notification channels, delivery history, retries and alert hygiene.
|
|
4
|
+
|
|
5
|
+
- `createNotifyEngine` records every notification, applies a channel's quiet hours, rate cap and digest window, sends with retries and sends SMTP mail. The application injects the sender, the digest wording, the names of the events hygiene treats specially and the secret resolver.
|
|
6
|
+
- `createTransports` holds the senders for email, webhook, Discord, Teams, Slack, Telegram, ntfy and Pushover. The rich channels format a neutral `Notice` (severity, headline segments, facts, detail, time) that the application builds from its own event.
|
|
7
|
+
- `createNotifyRoutes` makes the admin routes: delivery history and retry, channel management with secret masking, and SMTP settings. The application injects audit, identity, secret masking, the vault-use guard, the names its API uses for what a notification is about, and the test sends.
|
|
8
|
+
- `createDeliveryWorker` sends the deliveries that came due every 30 seconds and purges old history daily, and runs the extra tasks the application adds to those ticks.
|
|
9
|
+
- `defineNotifyTables` / `createNotifyDbModule` define the tables and their migrations; the application passes the names of the columns that identify what a delivery is about.
|
|
10
|
+
|
|
11
|
+
Depends on: `@better-forge/db`, `@better-forge/contracts`, `@better-forge/vault`, `@better-forge/audit`.
|
package/dist/migrations.js
CHANGED
|
@@ -3,68 +3,68 @@ import { defineNotifyTables } from './tables.js';
|
|
|
3
3
|
export function notifyMigrations(options) {
|
|
4
4
|
const { sourceIdColumn, sourceNameColumn, smtpFromName } = options;
|
|
5
5
|
return [
|
|
6
|
-
{ kind: 'exec', sql: `
|
|
7
|
-
CREATE TABLE IF NOT EXISTS notification_channels (
|
|
8
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
-
name TEXT NOT NULL,
|
|
10
|
-
type TEXT NOT NULL,
|
|
11
|
-
config TEXT NOT NULL DEFAULT '{}',
|
|
12
|
-
enabled INTEGER NOT NULL DEFAULT 1,
|
|
13
|
-
notify_on_recovery INTEGER NOT NULL DEFAULT 0,
|
|
14
|
-
created_at INTEGER NOT NULL,
|
|
15
|
-
updated_at INTEGER NOT NULL
|
|
16
|
-
);
|
|
6
|
+
{ kind: 'exec', sql: `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS notification_channels (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
name TEXT NOT NULL,
|
|
10
|
+
type TEXT NOT NULL,
|
|
11
|
+
config TEXT NOT NULL DEFAULT '{}',
|
|
12
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
13
|
+
notify_on_recovery INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
created_at INTEGER NOT NULL,
|
|
15
|
+
updated_at INTEGER NOT NULL
|
|
16
|
+
);
|
|
17
17
|
` },
|
|
18
|
-
{ kind: 'exec', sql: `
|
|
19
|
-
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
|
20
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
-
channel_id INTEGER NOT NULL,
|
|
22
|
-
channel_name TEXT NOT NULL,
|
|
23
|
-
channel_type TEXT NOT NULL,
|
|
24
|
-
${sourceIdColumn} INTEGER,
|
|
25
|
-
${sourceNameColumn} TEXT NOT NULL,
|
|
26
|
-
event_type TEXT NOT NULL,
|
|
27
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
28
|
-
target_status TEXT NOT NULL,
|
|
29
|
-
previous_status TEXT NOT NULL,
|
|
30
|
-
variables TEXT NOT NULL,
|
|
31
|
-
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
32
|
-
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
33
|
-
next_attempt_at INTEGER,
|
|
34
|
-
last_attempt_at INTEGER,
|
|
35
|
-
delivered_at INTEGER,
|
|
36
|
-
last_error TEXT,
|
|
37
|
-
created_at INTEGER NOT NULL,
|
|
38
|
-
updated_at INTEGER NOT NULL
|
|
39
|
-
);
|
|
40
|
-
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status_next ON notification_deliveries(status, next_attempt_at);
|
|
41
|
-
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_created ON notification_deliveries(created_at);
|
|
18
|
+
{ kind: 'exec', sql: `
|
|
19
|
+
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
channel_id INTEGER NOT NULL,
|
|
22
|
+
channel_name TEXT NOT NULL,
|
|
23
|
+
channel_type TEXT NOT NULL,
|
|
24
|
+
${sourceIdColumn} INTEGER,
|
|
25
|
+
${sourceNameColumn} TEXT NOT NULL,
|
|
26
|
+
event_type TEXT NOT NULL,
|
|
27
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
28
|
+
target_status TEXT NOT NULL,
|
|
29
|
+
previous_status TEXT NOT NULL,
|
|
30
|
+
variables TEXT NOT NULL,
|
|
31
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
33
|
+
next_attempt_at INTEGER,
|
|
34
|
+
last_attempt_at INTEGER,
|
|
35
|
+
delivered_at INTEGER,
|
|
36
|
+
last_error TEXT,
|
|
37
|
+
created_at INTEGER NOT NULL,
|
|
38
|
+
updated_at INTEGER NOT NULL
|
|
39
|
+
);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status_next ON notification_deliveries(status, next_attempt_at);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_created ON notification_deliveries(created_at);
|
|
42
42
|
` },
|
|
43
|
-
{ kind: 'exec', sql: `
|
|
44
|
-
CREATE TABLE IF NOT EXISTS notification_delivery_attempts (
|
|
45
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
-
delivery_id INTEGER NOT NULL,
|
|
47
|
-
attempt_number INTEGER NOT NULL,
|
|
48
|
-
status TEXT NOT NULL,
|
|
49
|
-
error TEXT,
|
|
50
|
-
started_at INTEGER NOT NULL,
|
|
51
|
-
completed_at INTEGER NOT NULL,
|
|
52
|
-
FOREIGN KEY (delivery_id) REFERENCES notification_deliveries(id) ON DELETE CASCADE
|
|
53
|
-
);
|
|
54
|
-
CREATE INDEX IF NOT EXISTS idx_notification_delivery_attempts_delivery ON notification_delivery_attempts(delivery_id, attempt_number);
|
|
43
|
+
{ kind: 'exec', sql: `
|
|
44
|
+
CREATE TABLE IF NOT EXISTS notification_delivery_attempts (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
delivery_id INTEGER NOT NULL,
|
|
47
|
+
attempt_number INTEGER NOT NULL,
|
|
48
|
+
status TEXT NOT NULL,
|
|
49
|
+
error TEXT,
|
|
50
|
+
started_at INTEGER NOT NULL,
|
|
51
|
+
completed_at INTEGER NOT NULL,
|
|
52
|
+
FOREIGN KEY (delivery_id) REFERENCES notification_deliveries(id) ON DELETE CASCADE
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_notification_delivery_attempts_delivery ON notification_delivery_attempts(delivery_id, attempt_number);
|
|
55
55
|
` },
|
|
56
|
-
{ kind: 'exec', sql: `
|
|
57
|
-
CREATE TABLE IF NOT EXISTS smtp_settings (
|
|
58
|
-
id INTEGER PRIMARY KEY,
|
|
59
|
-
host TEXT NOT NULL DEFAULT '',
|
|
60
|
-
port INTEGER NOT NULL DEFAULT 587,
|
|
61
|
-
secure INTEGER NOT NULL DEFAULT 0,
|
|
62
|
-
user TEXT NOT NULL DEFAULT '',
|
|
63
|
-
password TEXT NOT NULL DEFAULT '',
|
|
64
|
-
from_address TEXT NOT NULL DEFAULT '',
|
|
65
|
-
from_name TEXT NOT NULL DEFAULT '${smtpFromName}',
|
|
66
|
-
updated_at INTEGER NOT NULL
|
|
67
|
-
);
|
|
56
|
+
{ kind: 'exec', sql: `
|
|
57
|
+
CREATE TABLE IF NOT EXISTS smtp_settings (
|
|
58
|
+
id INTEGER PRIMARY KEY,
|
|
59
|
+
host TEXT NOT NULL DEFAULT '',
|
|
60
|
+
port INTEGER NOT NULL DEFAULT 587,
|
|
61
|
+
secure INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
user TEXT NOT NULL DEFAULT '',
|
|
63
|
+
password TEXT NOT NULL DEFAULT '',
|
|
64
|
+
from_address TEXT NOT NULL DEFAULT '',
|
|
65
|
+
from_name TEXT NOT NULL DEFAULT '${smtpFromName}',
|
|
66
|
+
updated_at INTEGER NOT NULL
|
|
67
|
+
);
|
|
68
68
|
` },
|
|
69
69
|
{ kind: 'column', sql: `ALTER TABLE smtp_settings ADD COLUMN vault_config TEXT`, desc: 'smtp_settings.vault_config' },
|
|
70
70
|
{ kind: 'column', sql: `ALTER TABLE notification_channels ADD COLUMN alert_policy TEXT NOT NULL DEFAULT '{}'`, desc: 'notification_channels.alert_policy' },
|
package/package.json
CHANGED
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@better-forge/notify",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Notification channels, delivery history, retries and alert hygiene.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
-
"directory": "packages/notify"
|
|
10
|
-
},
|
|
11
|
-
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/notify#readme",
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
-
},
|
|
15
|
-
"publishConfig": {
|
|
16
|
-
"access": "public"
|
|
17
|
-
},
|
|
18
|
-
"type": "module",
|
|
19
|
-
"main": "./dist/index.js",
|
|
20
|
-
"types": "./dist/index.d.ts",
|
|
21
|
-
"exports": {
|
|
22
|
-
".": {
|
|
23
|
-
"types": "./dist/index.d.ts",
|
|
24
|
-
"import": "./dist/index.js"
|
|
25
|
-
},
|
|
26
|
-
"./package.json": "./package.json"
|
|
27
|
-
},
|
|
28
|
-
"files": [
|
|
29
|
-
"dist",
|
|
30
|
-
"src",
|
|
31
|
-
"!src/**/*.test.*"
|
|
32
|
-
],
|
|
33
|
-
"scripts": {
|
|
34
|
-
"build": "tsc -p tsconfig.json",
|
|
35
|
-
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
36
|
-
},
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"nodemailer": "^10.0.13",
|
|
39
|
-
"node-cron": "^4.6.0",
|
|
40
|
-
"@better-forge/audit": "0.1.
|
|
41
|
-
"@better-forge/contracts": "0.1.
|
|
42
|
-
"@better-forge/db": "0.1.
|
|
43
|
-
"@better-forge/vault": "0.1.
|
|
44
|
-
},
|
|
45
|
-
"peerDependencies": {
|
|
46
|
-
"drizzle-orm": "^0.45.2",
|
|
47
|
-
"fastify": "^5.3.2"
|
|
48
|
-
},
|
|
49
|
-
"devDependencies": {
|
|
50
|
-
"drizzle-orm": "^0.45.2",
|
|
51
|
-
"fastify": "^5.3.2",
|
|
52
|
-
"@types/node": "^26.6.2",
|
|
53
|
-
"@types/nodemailer": "^8.0.1",
|
|
54
|
-
"typescript": "^5.7.3"
|
|
55
|
-
},
|
|
56
|
-
"engines": {
|
|
57
|
-
"node": ">=26.10.0"
|
|
58
|
-
}
|
|
59
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-forge/notify",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Notification channels, delivery history, retries and alert hygiene.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
+
"directory": "packages/notify"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/notify#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"!src/**/*.test.*"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"nodemailer": "^10.0.13",
|
|
39
|
+
"node-cron": "^4.6.0",
|
|
40
|
+
"@better-forge/audit": "0.1.1",
|
|
41
|
+
"@better-forge/contracts": "0.1.1",
|
|
42
|
+
"@better-forge/db": "0.1.1",
|
|
43
|
+
"@better-forge/vault": "0.1.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"drizzle-orm": "^0.45.2",
|
|
47
|
+
"fastify": "^5.3.2"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"drizzle-orm": "^0.45.2",
|
|
51
|
+
"fastify": "^5.3.2",
|
|
52
|
+
"@types/node": "^26.6.2",
|
|
53
|
+
"@types/nodemailer": "^8.0.1",
|
|
54
|
+
"typescript": "^5.7.3"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=26.10.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/alertPolicy.ts
CHANGED
|
@@ -1,149 +1,149 @@
|
|
|
1
|
-
import type { QuietHoursPolicy } from '@better-forge/contracts'
|
|
2
|
-
|
|
3
|
-
const MINUTE_MS = 60_000
|
|
4
|
-
const DAY_MINUTES = 24 * 60
|
|
5
|
-
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/
|
|
6
|
-
|
|
7
|
-
const formatters = new Map<string, Intl.DateTimeFormat>()
|
|
8
|
-
|
|
9
|
-
export function isValidTimezone(timezone: string): boolean {
|
|
10
|
-
try {
|
|
11
|
-
new Intl.DateTimeFormat('en-GB', { timeZone: timezone })
|
|
12
|
-
return true
|
|
13
|
-
} catch {
|
|
14
|
-
return false
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function formatter(timezone: string): Intl.DateTimeFormat {
|
|
19
|
-
let cached = formatters.get(timezone)
|
|
20
|
-
if (!cached) {
|
|
21
|
-
cached = new Intl.DateTimeFormat('en-GB', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
22
|
-
formatters.set(timezone, cached)
|
|
23
|
-
}
|
|
24
|
-
return cached
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Minutes elapsed since local midnight in `timezone` at the given instant. */
|
|
28
|
-
export function localMinutes(at: number, timezone: string): number {
|
|
29
|
-
const parts = formatter(isValidTimezone(timezone) ? timezone : 'UTC').formatToParts(new Date(at))
|
|
30
|
-
const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? '0')
|
|
31
|
-
const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? '0')
|
|
32
|
-
return hour * 60 + minute
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function timeToMinutes(value: string): number {
|
|
36
|
-
const match = TIME_PATTERN.exec(value)
|
|
37
|
-
if (!match) return 0
|
|
38
|
-
return Number(match[1]) * 60 + Number(match[2])
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** True when `at` falls inside the channel's quiet window. Windows where end <= start wrap midnight. */
|
|
42
|
-
export function isWithinQuietHours(policy: QuietHoursPolicy, at: number): boolean {
|
|
43
|
-
if (!policy.enabled) return false
|
|
44
|
-
const start = timeToMinutes(policy.start)
|
|
45
|
-
const end = timeToMinutes(policy.end)
|
|
46
|
-
if (start === end) return false // zero-length window — nothing is ever quiet
|
|
47
|
-
const now = localMinutes(at, policy.timezone)
|
|
48
|
-
return start < end ? now >= start && now < end : now >= start || now < end
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** The instant the current quiet window ends. Only meaningful while `isWithinQuietHours` holds. */
|
|
52
|
-
export function quietHoursEndAt(policy: QuietHoursPolicy, at: number): number {
|
|
53
|
-
const end = timeToMinutes(policy.end)
|
|
54
|
-
const now = localMinutes(at, policy.timezone)
|
|
55
|
-
const minutesAhead = (end - now + DAY_MINUTES) % DAY_MINUTES || DAY_MINUTES
|
|
56
|
-
let candidate = at - (at % MINUTE_MS) + minutesAhead * MINUTE_MS
|
|
57
|
-
// A DST shift inside the window moves the wall clock under us; nudge forward until it is over.
|
|
58
|
-
for (let guard = 0; guard < 8 && isWithinQuietHours(policy, candidate); guard++) candidate += 30 * MINUTE_MS
|
|
59
|
-
return candidate
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function asRecord(value: unknown): Record<string, unknown> {
|
|
63
|
-
return value && typeof value === 'object' ? value as Record<string, unknown> : {}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function asBool(value: unknown, fallback: boolean): boolean {
|
|
67
|
-
if (typeof value === 'boolean') return value
|
|
68
|
-
if (value === 1 || value === 0) return value === 1
|
|
69
|
-
return fallback
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function asInt(value: unknown, min: number, max: number, fallback: number): number {
|
|
73
|
-
const parsed = typeof value === 'number' ? value : Number(value)
|
|
74
|
-
if (!Number.isFinite(parsed)) return fallback
|
|
75
|
-
return Math.min(max, Math.max(min, Math.round(parsed)))
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function asTime(value: unknown, fallback: string): string {
|
|
79
|
-
return typeof value === 'string' && TIME_PATTERN.test(value) ? value : fallback
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface ThrottlePolicy { enabled: boolean; maxAlerts: number; windowMinutes: number }
|
|
83
|
-
export interface GroupingPolicy { enabled: boolean; minSources: number; windowSeconds: number }
|
|
84
|
-
export interface AlertPolicy { quietHours: QuietHoursPolicy; throttle: ThrottlePolicy; grouping: GroupingPolicy }
|
|
85
|
-
|
|
86
|
-
export const DEFAULT_ALERT_POLICY: AlertPolicy = {
|
|
87
|
-
quietHours: { enabled: false, start: '22:00', end: '07:00', timezone: 'UTC', mode: 'defer' },
|
|
88
|
-
throttle: { enabled: false, maxAlerts: 3, windowMinutes: 60 },
|
|
89
|
-
grouping: { enabled: false, minSources: 3, windowSeconds: 60 },
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Alert policy parsing for an application that stores and sends the "at least this many sources" setting of the
|
|
94
|
-
* grouping under `minSourcesKey`. The package works on `minSources`; `toWire` gives the stored form back, with the
|
|
95
|
-
* keys in the order they have always been written in.
|
|
96
|
-
*/
|
|
97
|
-
export function createAlertPolicy(options: { minSourcesKey: string }) {
|
|
98
|
-
/** Coerces untrusted input (API body or stored JSON) into a complete, in-range policy. */
|
|
99
|
-
function normalizeAlertPolicy(input: unknown): AlertPolicy {
|
|
100
|
-
const raw = asRecord(input)
|
|
101
|
-
const quiet = asRecord(raw['quietHours'])
|
|
102
|
-
const throttle = asRecord(raw['throttle'])
|
|
103
|
-
const grouping = asRecord(raw['grouping'])
|
|
104
|
-
const timezone = typeof quiet['timezone'] === 'string' && isValidTimezone(quiet['timezone'])
|
|
105
|
-
? quiet['timezone']
|
|
106
|
-
: DEFAULT_ALERT_POLICY.quietHours.timezone
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
quietHours: {
|
|
110
|
-
enabled: asBool(quiet['enabled'], DEFAULT_ALERT_POLICY.quietHours.enabled),
|
|
111
|
-
start: asTime(quiet['start'], DEFAULT_ALERT_POLICY.quietHours.start),
|
|
112
|
-
end: asTime(quiet['end'], DEFAULT_ALERT_POLICY.quietHours.end),
|
|
113
|
-
timezone,
|
|
114
|
-
mode: quiet['mode'] === 'suppress' ? 'suppress' : 'defer',
|
|
115
|
-
},
|
|
116
|
-
throttle: {
|
|
117
|
-
enabled: asBool(throttle['enabled'], DEFAULT_ALERT_POLICY.throttle.enabled),
|
|
118
|
-
maxAlerts: asInt(throttle['maxAlerts'], 1, 100, DEFAULT_ALERT_POLICY.throttle.maxAlerts),
|
|
119
|
-
windowMinutes: asInt(throttle['windowMinutes'], 1, 1440, DEFAULT_ALERT_POLICY.throttle.windowMinutes),
|
|
120
|
-
},
|
|
121
|
-
grouping: {
|
|
122
|
-
enabled: asBool(grouping['enabled'], DEFAULT_ALERT_POLICY.grouping.enabled),
|
|
123
|
-
minSources: asInt(grouping[options.minSourcesKey], 2, 100, DEFAULT_ALERT_POLICY.grouping.minSources),
|
|
124
|
-
windowSeconds: asInt(grouping['windowSeconds'], 10, 900, DEFAULT_ALERT_POLICY.grouping.windowSeconds),
|
|
125
|
-
},
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Reads a channel's stored `alert_policy` column, falling back to defaults on anything unusable. */
|
|
130
|
-
function parseAlertPolicy(stored: string | null | undefined): AlertPolicy {
|
|
131
|
-
if (!stored) return normalizeAlertPolicy({})
|
|
132
|
-
try {
|
|
133
|
-
return normalizeAlertPolicy(JSON.parse(stored))
|
|
134
|
-
} catch {
|
|
135
|
-
return normalizeAlertPolicy({})
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** The form that is stored and sent: `minSources` under the application's key, the other keys in their usual order. */
|
|
140
|
-
function toWire(policy: AlertPolicy): { quietHours: QuietHoursPolicy; throttle: ThrottlePolicy; grouping: Record<string, unknown> } {
|
|
141
|
-
return {
|
|
142
|
-
quietHours: policy.quietHours,
|
|
143
|
-
throttle: policy.throttle,
|
|
144
|
-
grouping: { enabled: policy.grouping.enabled, [options.minSourcesKey]: policy.grouping.minSources, windowSeconds: policy.grouping.windowSeconds },
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return { normalizeAlertPolicy, parseAlertPolicy, toWire }
|
|
149
|
-
}
|
|
1
|
+
import type { QuietHoursPolicy } from '@better-forge/contracts'
|
|
2
|
+
|
|
3
|
+
const MINUTE_MS = 60_000
|
|
4
|
+
const DAY_MINUTES = 24 * 60
|
|
5
|
+
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/
|
|
6
|
+
|
|
7
|
+
const formatters = new Map<string, Intl.DateTimeFormat>()
|
|
8
|
+
|
|
9
|
+
export function isValidTimezone(timezone: string): boolean {
|
|
10
|
+
try {
|
|
11
|
+
new Intl.DateTimeFormat('en-GB', { timeZone: timezone })
|
|
12
|
+
return true
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatter(timezone: string): Intl.DateTimeFormat {
|
|
19
|
+
let cached = formatters.get(timezone)
|
|
20
|
+
if (!cached) {
|
|
21
|
+
cached = new Intl.DateTimeFormat('en-GB', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
22
|
+
formatters.set(timezone, cached)
|
|
23
|
+
}
|
|
24
|
+
return cached
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Minutes elapsed since local midnight in `timezone` at the given instant. */
|
|
28
|
+
export function localMinutes(at: number, timezone: string): number {
|
|
29
|
+
const parts = formatter(isValidTimezone(timezone) ? timezone : 'UTC').formatToParts(new Date(at))
|
|
30
|
+
const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? '0')
|
|
31
|
+
const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? '0')
|
|
32
|
+
return hour * 60 + minute
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function timeToMinutes(value: string): number {
|
|
36
|
+
const match = TIME_PATTERN.exec(value)
|
|
37
|
+
if (!match) return 0
|
|
38
|
+
return Number(match[1]) * 60 + Number(match[2])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when `at` falls inside the channel's quiet window. Windows where end <= start wrap midnight. */
|
|
42
|
+
export function isWithinQuietHours(policy: QuietHoursPolicy, at: number): boolean {
|
|
43
|
+
if (!policy.enabled) return false
|
|
44
|
+
const start = timeToMinutes(policy.start)
|
|
45
|
+
const end = timeToMinutes(policy.end)
|
|
46
|
+
if (start === end) return false // zero-length window — nothing is ever quiet
|
|
47
|
+
const now = localMinutes(at, policy.timezone)
|
|
48
|
+
return start < end ? now >= start && now < end : now >= start || now < end
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The instant the current quiet window ends. Only meaningful while `isWithinQuietHours` holds. */
|
|
52
|
+
export function quietHoursEndAt(policy: QuietHoursPolicy, at: number): number {
|
|
53
|
+
const end = timeToMinutes(policy.end)
|
|
54
|
+
const now = localMinutes(at, policy.timezone)
|
|
55
|
+
const minutesAhead = (end - now + DAY_MINUTES) % DAY_MINUTES || DAY_MINUTES
|
|
56
|
+
let candidate = at - (at % MINUTE_MS) + minutesAhead * MINUTE_MS
|
|
57
|
+
// A DST shift inside the window moves the wall clock under us; nudge forward until it is over.
|
|
58
|
+
for (let guard = 0; guard < 8 && isWithinQuietHours(policy, candidate); guard++) candidate += 30 * MINUTE_MS
|
|
59
|
+
return candidate
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
63
|
+
return value && typeof value === 'object' ? value as Record<string, unknown> : {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function asBool(value: unknown, fallback: boolean): boolean {
|
|
67
|
+
if (typeof value === 'boolean') return value
|
|
68
|
+
if (value === 1 || value === 0) return value === 1
|
|
69
|
+
return fallback
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function asInt(value: unknown, min: number, max: number, fallback: number): number {
|
|
73
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
74
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
75
|
+
return Math.min(max, Math.max(min, Math.round(parsed)))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function asTime(value: unknown, fallback: string): string {
|
|
79
|
+
return typeof value === 'string' && TIME_PATTERN.test(value) ? value : fallback
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ThrottlePolicy { enabled: boolean; maxAlerts: number; windowMinutes: number }
|
|
83
|
+
export interface GroupingPolicy { enabled: boolean; minSources: number; windowSeconds: number }
|
|
84
|
+
export interface AlertPolicy { quietHours: QuietHoursPolicy; throttle: ThrottlePolicy; grouping: GroupingPolicy }
|
|
85
|
+
|
|
86
|
+
export const DEFAULT_ALERT_POLICY: AlertPolicy = {
|
|
87
|
+
quietHours: { enabled: false, start: '22:00', end: '07:00', timezone: 'UTC', mode: 'defer' },
|
|
88
|
+
throttle: { enabled: false, maxAlerts: 3, windowMinutes: 60 },
|
|
89
|
+
grouping: { enabled: false, minSources: 3, windowSeconds: 60 },
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Alert policy parsing for an application that stores and sends the "at least this many sources" setting of the
|
|
94
|
+
* grouping under `minSourcesKey`. The package works on `minSources`; `toWire` gives the stored form back, with the
|
|
95
|
+
* keys in the order they have always been written in.
|
|
96
|
+
*/
|
|
97
|
+
export function createAlertPolicy(options: { minSourcesKey: string }) {
|
|
98
|
+
/** Coerces untrusted input (API body or stored JSON) into a complete, in-range policy. */
|
|
99
|
+
function normalizeAlertPolicy(input: unknown): AlertPolicy {
|
|
100
|
+
const raw = asRecord(input)
|
|
101
|
+
const quiet = asRecord(raw['quietHours'])
|
|
102
|
+
const throttle = asRecord(raw['throttle'])
|
|
103
|
+
const grouping = asRecord(raw['grouping'])
|
|
104
|
+
const timezone = typeof quiet['timezone'] === 'string' && isValidTimezone(quiet['timezone'])
|
|
105
|
+
? quiet['timezone']
|
|
106
|
+
: DEFAULT_ALERT_POLICY.quietHours.timezone
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
quietHours: {
|
|
110
|
+
enabled: asBool(quiet['enabled'], DEFAULT_ALERT_POLICY.quietHours.enabled),
|
|
111
|
+
start: asTime(quiet['start'], DEFAULT_ALERT_POLICY.quietHours.start),
|
|
112
|
+
end: asTime(quiet['end'], DEFAULT_ALERT_POLICY.quietHours.end),
|
|
113
|
+
timezone,
|
|
114
|
+
mode: quiet['mode'] === 'suppress' ? 'suppress' : 'defer',
|
|
115
|
+
},
|
|
116
|
+
throttle: {
|
|
117
|
+
enabled: asBool(throttle['enabled'], DEFAULT_ALERT_POLICY.throttle.enabled),
|
|
118
|
+
maxAlerts: asInt(throttle['maxAlerts'], 1, 100, DEFAULT_ALERT_POLICY.throttle.maxAlerts),
|
|
119
|
+
windowMinutes: asInt(throttle['windowMinutes'], 1, 1440, DEFAULT_ALERT_POLICY.throttle.windowMinutes),
|
|
120
|
+
},
|
|
121
|
+
grouping: {
|
|
122
|
+
enabled: asBool(grouping['enabled'], DEFAULT_ALERT_POLICY.grouping.enabled),
|
|
123
|
+
minSources: asInt(grouping[options.minSourcesKey], 2, 100, DEFAULT_ALERT_POLICY.grouping.minSources),
|
|
124
|
+
windowSeconds: asInt(grouping['windowSeconds'], 10, 900, DEFAULT_ALERT_POLICY.grouping.windowSeconds),
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Reads a channel's stored `alert_policy` column, falling back to defaults on anything unusable. */
|
|
130
|
+
function parseAlertPolicy(stored: string | null | undefined): AlertPolicy {
|
|
131
|
+
if (!stored) return normalizeAlertPolicy({})
|
|
132
|
+
try {
|
|
133
|
+
return normalizeAlertPolicy(JSON.parse(stored))
|
|
134
|
+
} catch {
|
|
135
|
+
return normalizeAlertPolicy({})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The form that is stored and sent: `minSources` under the application's key, the other keys in their usual order. */
|
|
140
|
+
function toWire(policy: AlertPolicy): { quietHours: QuietHoursPolicy; throttle: ThrottlePolicy; grouping: Record<string, unknown> } {
|
|
141
|
+
return {
|
|
142
|
+
quietHours: policy.quietHours,
|
|
143
|
+
throttle: policy.throttle,
|
|
144
|
+
grouping: { enabled: policy.grouping.enabled, [options.minSourcesKey]: policy.grouping.minSources, windowSeconds: policy.grouping.windowSeconds },
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { normalizeAlertPolicy, parseAlertPolicy, toWire }
|
|
149
|
+
}
|