@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 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`.
@@ -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.0",
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.0",
41
- "@better-forge/contracts": "0.1.0",
42
- "@better-forge/db": "0.1.0",
43
- "@better-forge/vault": "0.1.0"
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
+ }
@@ -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
+ }