@cogenta/channels 0.1.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.
- package/dist/adapter.d.ts +111 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/approvals/commands.d.ts +28 -0
- package/dist/approvals/commands.d.ts.map +1 -0
- package/dist/approvals/commands.js +66 -0
- package/dist/approvals/commands.js.map +1 -0
- package/dist/approvals/dispatch.d.ts +22 -0
- package/dist/approvals/dispatch.d.ts.map +1 -0
- package/dist/approvals/dispatch.js +22 -0
- package/dist/approvals/dispatch.js.map +1 -0
- package/dist/approvals/message.d.ts +18 -0
- package/dist/approvals/message.d.ts.map +1 -0
- package/dist/approvals/message.js +40 -0
- package/dist/approvals/message.js.map +1 -0
- package/dist/approvals/signed-link.d.ts +19 -0
- package/dist/approvals/signed-link.d.ts.map +1 -0
- package/dist/approvals/signed-link.js +42 -0
- package/dist/approvals/signed-link.js.map +1 -0
- package/dist/approvals/store.d.ts +41 -0
- package/dist/approvals/store.d.ts.map +1 -0
- package/dist/approvals/store.js +41 -0
- package/dist/approvals/store.js.map +1 -0
- package/dist/approvals/token.d.ts +7 -0
- package/dist/approvals/token.d.ts.map +1 -0
- package/dist/approvals/token.js +40 -0
- package/dist/approvals/token.js.map +1 -0
- package/dist/formats/alert.d.ts +18 -0
- package/dist/formats/alert.d.ts.map +1 -0
- package/dist/formats/alert.js +49 -0
- package/dist/formats/alert.js.map +1 -0
- package/dist/formats/budget.d.ts +14 -0
- package/dist/formats/budget.d.ts.map +1 -0
- package/dist/formats/budget.js +13 -0
- package/dist/formats/budget.js.map +1 -0
- package/dist/formats/notification.d.ts +8 -0
- package/dist/formats/notification.d.ts.map +1 -0
- package/dist/formats/notification.js +26 -0
- package/dist/formats/notification.js.map +1 -0
- package/dist/formats/report.d.ts +18 -0
- package/dist/formats/report.d.ts.map +1 -0
- package/dist/formats/report.js +44 -0
- package/dist/formats/report.js.map +1 -0
- package/dist/inbound/authorize.d.ts +57 -0
- package/dist/inbound/authorize.d.ts.map +1 -0
- package/dist/inbound/authorize.js +32 -0
- package/dist/inbound/authorize.js.map +1 -0
- package/dist/inbound/router.d.ts +67 -0
- package/dist/inbound/router.d.ts.map +1 -0
- package/dist/inbound/router.js +69 -0
- package/dist/inbound/router.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/linking/codes.d.ts +6 -0
- package/dist/linking/codes.d.ts.map +1 -0
- package/dist/linking/codes.js +37 -0
- package/dist/linking/codes.js.map +1 -0
- package/dist/linking/store.d.ts +39 -0
- package/dist/linking/store.d.ts.map +1 -0
- package/dist/linking/store.js +77 -0
- package/dist/linking/store.js.map +1 -0
- package/dist/linking/tables.d.ts +14 -0
- package/dist/linking/tables.d.ts.map +1 -0
- package/dist/linking/tables.js +52 -0
- package/dist/linking/tables.js.map +1 -0
- package/dist/preferences/dispatcher.d.ts +37 -0
- package/dist/preferences/dispatcher.d.ts.map +1 -0
- package/dist/preferences/dispatcher.js +103 -0
- package/dist/preferences/dispatcher.js.map +1 -0
- package/dist/preferences/quiet-hours.d.ts +12 -0
- package/dist/preferences/quiet-hours.d.ts.map +1 -0
- package/dist/preferences/quiet-hours.js +22 -0
- package/dist/preferences/quiet-hours.js.map +1 -0
- package/dist/preferences/store.d.ts +8 -0
- package/dist/preferences/store.d.ts.map +1 -0
- package/dist/preferences/store.js +60 -0
- package/dist/preferences/store.js.map +1 -0
- package/dist/preferences/tables.d.ts +12 -0
- package/dist/preferences/tables.d.ts.map +1 -0
- package/dist/preferences/tables.js +55 -0
- package/dist/preferences/tables.js.map +1 -0
- package/dist/preferences/types.d.ts +37 -0
- package/dist/preferences/types.d.ts.map +1 -0
- package/dist/preferences/types.js +31 -0
- package/dist/preferences/types.js.map +1 -0
- package/dist/providers/discord/adapter.d.ts +38 -0
- package/dist/providers/discord/adapter.d.ts.map +1 -0
- package/dist/providers/discord/adapter.js +97 -0
- package/dist/providers/discord/adapter.js.map +1 -0
- package/dist/providers/discord/client.d.ts +67 -0
- package/dist/providers/discord/client.d.ts.map +1 -0
- package/dist/providers/discord/client.js +65 -0
- package/dist/providers/discord/client.js.map +1 -0
- package/dist/providers/discord/gateway.d.ts +55 -0
- package/dist/providers/discord/gateway.d.ts.map +1 -0
- package/dist/providers/discord/gateway.js +114 -0
- package/dist/providers/discord/gateway.js.map +1 -0
- package/dist/providers/discord/inbound.d.ts +22 -0
- package/dist/providers/discord/inbound.d.ts.map +1 -0
- package/dist/providers/discord/inbound.js +75 -0
- package/dist/providers/discord/inbound.js.map +1 -0
- package/dist/providers/discord/render.d.ts +9 -0
- package/dist/providers/discord/render.d.ts.map +1 -0
- package/dist/providers/discord/render.js +73 -0
- package/dist/providers/discord/render.js.map +1 -0
- package/dist/providers/email/adapter.d.ts +18 -0
- package/dist/providers/email/adapter.d.ts.map +1 -0
- package/dist/providers/email/adapter.js +41 -0
- package/dist/providers/email/adapter.js.map +1 -0
- package/dist/providers/email/file-transport.d.ts +14 -0
- package/dist/providers/email/file-transport.d.ts.map +1 -0
- package/dist/providers/email/file-transport.js +33 -0
- package/dist/providers/email/file-transport.js.map +1 -0
- package/dist/providers/email/render.d.ts +20 -0
- package/dist/providers/email/render.d.ts.map +1 -0
- package/dist/providers/email/render.js +89 -0
- package/dist/providers/email/render.js.map +1 -0
- package/dist/providers/email/transport.d.ts +25 -0
- package/dist/providers/email/transport.d.ts.map +1 -0
- package/dist/providers/email/transport.js +2 -0
- package/dist/providers/email/transport.js.map +1 -0
- package/dist/providers/slack/adapter.d.ts +35 -0
- package/dist/providers/slack/adapter.d.ts.map +1 -0
- package/dist/providers/slack/adapter.js +91 -0
- package/dist/providers/slack/adapter.js.map +1 -0
- package/dist/providers/slack/client.d.ts +45 -0
- package/dist/providers/slack/client.d.ts.map +1 -0
- package/dist/providers/slack/client.js +54 -0
- package/dist/providers/slack/client.js.map +1 -0
- package/dist/providers/slack/inbound.d.ts +23 -0
- package/dist/providers/slack/inbound.d.ts.map +1 -0
- package/dist/providers/slack/inbound.js +76 -0
- package/dist/providers/slack/inbound.js.map +1 -0
- package/dist/providers/slack/render.d.ts +8 -0
- package/dist/providers/slack/render.d.ts.map +1 -0
- package/dist/providers/slack/render.js +72 -0
- package/dist/providers/slack/render.js.map +1 -0
- package/dist/providers/slack/socket.d.ts +34 -0
- package/dist/providers/slack/socket.d.ts.map +1 -0
- package/dist/providers/slack/socket.js +70 -0
- package/dist/providers/slack/socket.js.map +1 -0
- package/dist/providers/telegram/adapter.d.ts +35 -0
- package/dist/providers/telegram/adapter.d.ts.map +1 -0
- package/dist/providers/telegram/adapter.js +109 -0
- package/dist/providers/telegram/adapter.js.map +1 -0
- package/dist/providers/telegram/client.d.ts +78 -0
- package/dist/providers/telegram/client.d.ts.map +1 -0
- package/dist/providers/telegram/client.js +53 -0
- package/dist/providers/telegram/client.js.map +1 -0
- package/dist/providers/telegram/inbound.d.ts +21 -0
- package/dist/providers/telegram/inbound.d.ts.map +1 -0
- package/dist/providers/telegram/inbound.js +72 -0
- package/dist/providers/telegram/inbound.js.map +1 -0
- package/dist/providers/telegram/render.d.ts +15 -0
- package/dist/providers/telegram/render.d.ts.map +1 -0
- package/dist/providers/telegram/render.js +87 -0
- package/dist/providers/telegram/render.js.map +1 -0
- package/dist/providers/webhook/adapter.d.ts +40 -0
- package/dist/providers/webhook/adapter.d.ts.map +1 -0
- package/dist/providers/webhook/adapter.js +71 -0
- package/dist/providers/webhook/adapter.js.map +1 -0
- package/dist/providers/webhook/render.d.ts +26 -0
- package/dist/providers/webhook/render.d.ts.map +1 -0
- package/dist/providers/webhook/render.js +41 -0
- package/dist/providers/webhook/render.js.map +1 -0
- package/dist/providers/webhook/signing.d.ts +59 -0
- package/dist/providers/webhook/signing.d.ts.map +1 -0
- package/dist/providers/webhook/signing.js +108 -0
- package/dist/providers/webhook/signing.js.map +1 -0
- package/dist/registry.d.ts +16 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +39 -0
- package/dist/registry.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Crockford-style alphabet, ambiguous characters (`0`/`O`, `1`/`I`/`L`)
|
|
4
|
+
* removed — a code a person reads on a screen and types into a chat app
|
|
5
|
+
* must not depend on font rendering to tell `0` from `O`. 32 symbols divides
|
|
6
|
+
* 256 evenly, so mapping a random byte to an index via modulo carries no
|
|
7
|
+
* bias (unlike an alphabet size that doesn't divide 256).
|
|
8
|
+
*/
|
|
9
|
+
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
10
|
+
/**
|
|
11
|
+
* 8 symbols x 5 bits = 40 bits of entropy. Single-use and short-TTL (minutes,
|
|
12
|
+
* not hours), so this is judged against "can an attacker brute-force one
|
|
13
|
+
* code before it expires", not against long-term-secret standards (that's
|
|
14
|
+
* what session tokens, 256 bits, are for — see `@cogenta/auth`'s
|
|
15
|
+
* `TOKEN_BYTES`). Even at an implausible 100 guesses/second, 2^40 / 100 is
|
|
16
|
+
* over 100 days — a code generated in the admin and typed into a channel
|
|
17
|
+
* within a 10-minute window is not brute-forceable in that window.
|
|
18
|
+
*/
|
|
19
|
+
const CODE_LENGTH = 8;
|
|
20
|
+
export function generateLinkCode() {
|
|
21
|
+
const bytes = randomBytes(CODE_LENGTH);
|
|
22
|
+
let code = '';
|
|
23
|
+
for (let i = 0; i < CODE_LENGTH; i++) {
|
|
24
|
+
const byte = bytes[i] ?? 0;
|
|
25
|
+
code += CODE_ALPHABET[byte % CODE_ALPHABET.length];
|
|
26
|
+
}
|
|
27
|
+
return code;
|
|
28
|
+
}
|
|
29
|
+
/** Stored hashed, exactly like a session token (`@cogenta/auth/sessions.ts`) — a leaked table hands out nothing usable. */
|
|
30
|
+
export function hashLinkCode(code) {
|
|
31
|
+
return createHash('sha256').update(normalizeCode(code)).digest('base64url');
|
|
32
|
+
}
|
|
33
|
+
/** Case-insensitive, whitespace-tolerant — a code typed by hand into a chat app should not fail on a stray space or lowercase letter. */
|
|
34
|
+
export function normalizeCode(code) {
|
|
35
|
+
return code.trim().toUpperCase();
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=codes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codes.js","sourceRoot":"","sources":["../../src/linking/codes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAErD;;;;;;GAMG;AACH,MAAM,aAAa,GAAG,iCAAiC,CAAA;AAEvD;;;;;;;;GAQG;AACH,MAAM,WAAW,GAAG,CAAC,CAAA;AAErB,MAAM,UAAU,gBAAgB;IAC9B,MAAM,KAAK,GAAG,WAAW,CAAC,WAAW,CAAC,CAAA;IACtC,IAAI,IAAI,GAAG,EAAE,CAAA;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;QAC1B,IAAI,IAAI,aAAa,CAAC,IAAI,GAAG,aAAa,CAAC,MAAM,CAAC,CAAA;IACpD,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED,2HAA2H;AAC3H,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;AAC7E,CAAC;AAED,yIAAyI;AACzI,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;AAClC,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type DatabaseHandle } from '@cogenta/core';
|
|
2
|
+
import type { ChannelIdentity } from '../adapter.js';
|
|
3
|
+
export interface GeneratedLinkCode {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly expiresAt: string;
|
|
6
|
+
}
|
|
7
|
+
export interface LinkedChannel {
|
|
8
|
+
readonly channelName: string;
|
|
9
|
+
readonly channelUserId: string;
|
|
10
|
+
readonly linkedAt: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ChannelLinkStore {
|
|
13
|
+
/** Admin side: a real, already-authenticated Cogenta user requests a code for a channel type. */
|
|
14
|
+
generateCode(userId: string, channelName: string, options?: {
|
|
15
|
+
readonly ttlMs?: number;
|
|
16
|
+
}): Promise<GeneratedLinkCode>;
|
|
17
|
+
/**
|
|
18
|
+
* Channel side: a code typed into the channel, plus the channel's own
|
|
19
|
+
* identifiers. On success, creates the real link — `resolveIdentity` for
|
|
20
|
+
* this `(channelName, channelUserId)` returns a non-null `linkedUserId`
|
|
21
|
+
* from this point on. Rejects with a single, uniform `CHANNEL_LINK_CODE_INVALID`
|
|
22
|
+
* for every failure kind (nonexistent, expired, already used, wrong
|
|
23
|
+
* channel) — the caller (a future channel-facing reply) must never turn
|
|
24
|
+
* this into a "code doesn't exist" vs "code expired" distinction visible
|
|
25
|
+
* to whoever is typing, or an unlinked identity is handed a free oracle to
|
|
26
|
+
* probe with (`## La règle de sécurité centrale`).
|
|
27
|
+
*/
|
|
28
|
+
verifyCode(code: string, channelName: string, channelUserId: string): Promise<{
|
|
29
|
+
readonly userId: string;
|
|
30
|
+
}>;
|
|
31
|
+
/** `linkedUserId: null` for a channel identity with no active link — the exact shape task 1's `ChannelIdentity` was built to represent. */
|
|
32
|
+
resolveIdentity(channelName: string, channelUserId: string): Promise<ChannelIdentity>;
|
|
33
|
+
/** Revoking an already-unlinked (or nonexistent) identity is not an error — it's already in the state being asked for. */
|
|
34
|
+
revoke(channelName: string, channelUserId: string): Promise<void>;
|
|
35
|
+
/** "listée dans les sessions actives de l'utilisateur" — every channel currently linked to this user. */
|
|
36
|
+
listLinkedChannels(userId: string): Promise<readonly LinkedChannel[]>;
|
|
37
|
+
}
|
|
38
|
+
export declare function createChannelLinkStore(db: DatabaseHandle, now?: () => number): ChannelLinkStore;
|
|
39
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/linking/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,cAAc,EAA0B,MAAM,eAAe,CAAA;AACzF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAMpD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,iGAAiG;IACjG,YAAY,CACV,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GACpC,OAAO,CAAC,iBAAiB,CAAC,CAAA;IAE7B;;;;;;;;;;OAUG;IACH,UAAU,CACR,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAEvC,2IAA2I;IAC3I,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAAA;IAErF,0HAA0H;IAC1H,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAEjE,yGAAyG;IACzG,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,aAAa,EAAE,CAAC,CAAA;CACtE;AAgCD,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,cAAc,EAClB,GAAG,GAAE,MAAM,MAAiB,GAC3B,gBAAgB,CAuElB"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { CogentaError, identifier, newId, sql } from '@cogenta/core';
|
|
2
|
+
import { generateLinkCode, hashLinkCode, normalizeCode } from './codes.js';
|
|
3
|
+
import { LINKING_TABLES } from './tables.js';
|
|
4
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000; // "valable quelques minutes" (lot doc, verbatim)
|
|
5
|
+
function invalidCodeError(reason) {
|
|
6
|
+
// `reason` is structured error detail for logs/debugging only — never
|
|
7
|
+
// forward it into a channel-facing reply (see `verifyCode`'s doc comment).
|
|
8
|
+
return new CogentaError({
|
|
9
|
+
code: 'CHANNEL_LINK_CODE_INVALID',
|
|
10
|
+
message: 'This linking code is not valid.',
|
|
11
|
+
hint: 'Generate a new code from the admin and enter it again — codes are single-use and expire after a few minutes.',
|
|
12
|
+
details: { reason },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function createChannelLinkStore(db, now = Date.now) {
|
|
16
|
+
const linkCodes = identifier(LINKING_TABLES.linkCodes, db.dialect);
|
|
17
|
+
const links = identifier(LINKING_TABLES.links, db.dialect);
|
|
18
|
+
return {
|
|
19
|
+
async generateCode(userId, channelName, options) {
|
|
20
|
+
const code = generateLinkCode();
|
|
21
|
+
const created = new Date(now()).toISOString();
|
|
22
|
+
const expires = new Date(now() + (options?.ttlMs ?? DEFAULT_TTL_MS)).toISOString();
|
|
23
|
+
await db.query(sql `
|
|
24
|
+
insert into ${linkCodes} (id, code_hash, channel_name, user_id, created_at, expires_at, used_at)
|
|
25
|
+
values (${newId(now)}, ${hashLinkCode(code)}, ${channelName}, ${userId}, ${created}, ${expires}, ${null})`);
|
|
26
|
+
return { code, expiresAt: expires };
|
|
27
|
+
},
|
|
28
|
+
async verifyCode(code, channelName, channelUserId) {
|
|
29
|
+
const targetHash = hashLinkCode(normalizeCode(code));
|
|
30
|
+
const result = await db.query(sql `select * from ${linkCodes} where code_hash = ${targetHash}`);
|
|
31
|
+
const row = result.rows[0];
|
|
32
|
+
if (row === undefined)
|
|
33
|
+
throw invalidCodeError('nonexistent');
|
|
34
|
+
if (row.used_at !== null)
|
|
35
|
+
throw invalidCodeError('already-used');
|
|
36
|
+
if (row.channel_name !== channelName)
|
|
37
|
+
throw invalidCodeError('wrong-channel');
|
|
38
|
+
if (new Date(row.expires_at).getTime() <= now())
|
|
39
|
+
throw invalidCodeError('expired');
|
|
40
|
+
const linkedAt = new Date(now()).toISOString();
|
|
41
|
+
await db.query(sql `update ${linkCodes} set used_at = ${linkedAt} where id = ${row.id}`);
|
|
42
|
+
// Re-linking the same (channel, channelUserId) replaces any prior link
|
|
43
|
+
// for it — one active user per channel identity, the newest wins.
|
|
44
|
+
await db.query(sql `delete from ${links} where channel_name = ${channelName} and channel_user_id = ${channelUserId}`);
|
|
45
|
+
await db.query(sql `
|
|
46
|
+
insert into ${links} (id, channel_name, channel_user_id, user_id, linked_at, revoked_at)
|
|
47
|
+
values (${newId(now)}, ${channelName}, ${channelUserId}, ${row.user_id}, ${linkedAt}, ${null})`);
|
|
48
|
+
return { userId: row.user_id };
|
|
49
|
+
},
|
|
50
|
+
async resolveIdentity(channelName, channelUserId) {
|
|
51
|
+
const result = await db.query(sql `
|
|
52
|
+
select * from ${links}
|
|
53
|
+
where channel_name = ${channelName} and channel_user_id = ${channelUserId} and revoked_at is null`);
|
|
54
|
+
const row = result.rows[0];
|
|
55
|
+
return {
|
|
56
|
+
channelName,
|
|
57
|
+
channelUserId,
|
|
58
|
+
linkedUserId: row?.user_id ?? null,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
async revoke(channelName, channelUserId) {
|
|
62
|
+
await db.query(sql `
|
|
63
|
+
update ${links} set revoked_at = ${new Date(now()).toISOString()}
|
|
64
|
+
where channel_name = ${channelName} and channel_user_id = ${channelUserId} and revoked_at is null`);
|
|
65
|
+
},
|
|
66
|
+
async listLinkedChannels(userId) {
|
|
67
|
+
const result = await db.query(sql `
|
|
68
|
+
select * from ${links} where user_id = ${userId} and revoked_at is null order by linked_at desc`);
|
|
69
|
+
return result.rows.map((row) => ({
|
|
70
|
+
channelName: row.channel_name,
|
|
71
|
+
channelUserId: row.channel_user_id,
|
|
72
|
+
linkedAt: row.linked_at,
|
|
73
|
+
}));
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/linking/store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type DatabaseHandle } from '@cogenta/core';
|
|
2
|
+
export declare const LINKING_TABLES: {
|
|
3
|
+
readonly linkCodes: 'cogenta_channel_link_codes';
|
|
4
|
+
readonly links: 'cogenta_channel_links';
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Owned by `@cogenta/channels`, following `@cogenta/auth`'s `ensureAuthTables`
|
|
8
|
+
* pattern exactly: `create table if not exists`, run once at startup, so a
|
|
9
|
+
* fresh install and an upgrade take the same path — no separate migration
|
|
10
|
+
* file, same as `cogenta_sessions`/`cogenta_users` are not migration-file-backed
|
|
11
|
+
* either.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureChannelTables(db: DatabaseHandle): Promise<void>;
|
|
14
|
+
//# sourceMappingURL=tables.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tables.d.ts","sourceRoot":"","sources":["../../src/linking/tables.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAKpB,MAAM,eAAe,CAAA;AAEtB,eAAO,MAAM,cAAc;aACzB,SAAS,EAAE,4BAA4B;aACvC,KAAK,EAAE,uBAAuB;CACtB,CAAA;AAMV;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAsC3E"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { identifier, sql, unsafeRaw, } from '@cogenta/core';
|
|
2
|
+
export const LINKING_TABLES = {
|
|
3
|
+
linkCodes: 'cogenta_channel_link_codes',
|
|
4
|
+
links: 'cogenta_channel_links',
|
|
5
|
+
};
|
|
6
|
+
function textColumn(dialect, length) {
|
|
7
|
+
return unsafeRaw(dialect === 'sqlite' ? 'text' : `varchar(${length})`);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Owned by `@cogenta/channels`, following `@cogenta/auth`'s `ensureAuthTables`
|
|
11
|
+
* pattern exactly: `create table if not exists`, run once at startup, so a
|
|
12
|
+
* fresh install and an upgrade take the same path — no separate migration
|
|
13
|
+
* file, same as `cogenta_sessions`/`cogenta_users` are not migration-file-backed
|
|
14
|
+
* either.
|
|
15
|
+
*/
|
|
16
|
+
export async function ensureChannelTables(db) {
|
|
17
|
+
const d = db.dialect;
|
|
18
|
+
const linkCodes = identifier(LINKING_TABLES.linkCodes, d);
|
|
19
|
+
const links = identifier(LINKING_TABLES.links, d);
|
|
20
|
+
const t64 = textColumn(d, 64);
|
|
21
|
+
const t255 = textColumn(d, 255);
|
|
22
|
+
const t512 = textColumn(d, 512);
|
|
23
|
+
await db.query(sql `
|
|
24
|
+
create table if not exists ${linkCodes} (
|
|
25
|
+
id ${t64} not null primary key,
|
|
26
|
+
-- Hashed, never stored plain — same reasoning as a session token
|
|
27
|
+
-- (@cogenta/auth): a leaked table hands out nothing usable.
|
|
28
|
+
code_hash ${t512} not null unique,
|
|
29
|
+
channel_name ${t64} not null,
|
|
30
|
+
user_id ${t64} not null,
|
|
31
|
+
created_at ${t64} not null,
|
|
32
|
+
expires_at ${t64} not null,
|
|
33
|
+
used_at ${t64}
|
|
34
|
+
)`);
|
|
35
|
+
await db.query(sql `
|
|
36
|
+
create table if not exists ${links} (
|
|
37
|
+
id ${t64} not null primary key,
|
|
38
|
+
channel_name ${t64} not null,
|
|
39
|
+
channel_user_id ${t255} not null,
|
|
40
|
+
user_id ${t64} not null,
|
|
41
|
+
linked_at ${t64} not null,
|
|
42
|
+
revoked_at ${t64}
|
|
43
|
+
)`);
|
|
44
|
+
await createIndexIfMissing(db, 'cogenta_channel_links_lookup', links, sql `(channel_name, channel_user_id)`);
|
|
45
|
+
await createIndexIfMissing(db, 'cogenta_channel_links_user', links, sql `(user_id)`);
|
|
46
|
+
}
|
|
47
|
+
async function createIndexIfMissing(db, name, table, columns) {
|
|
48
|
+
await db
|
|
49
|
+
.query(sql `create index ${identifier(name, db.dialect)} on ${table} ${columns}`)
|
|
50
|
+
.catch(() => undefined); // already there — no portable "if not exists" for indexes
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=tables.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tables.js","sourceRoot":"","sources":["../../src/linking/tables.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,UAAU,EAEV,GAAG,EACH,SAAS,GACV,MAAM,eAAe,CAAA;AAEtB,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,SAAS,EAAE,4BAA4B;IACvC,KAAK,EAAE,uBAAuB;CACtB,CAAA;AAEV,SAAS,UAAU,CAAC,OAAwB,EAAE,MAAc;IAC1D,OAAO,SAAS,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,MAAM,GAAG,CAAC,CAAA;AACxE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,EAAkB;IAC1D,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAA;IACpB,MAAM,SAAS,GAAG,UAAU,CAAC,cAAc,CAAC,SAAS,EAAE,CAAC,CAAC,CAAA;IACzD,MAAM,KAAK,GAAG,UAAU,CAAC,cAAc,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;IACjD,MAAM,GAAG,GAAG,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;IAC7B,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAC/B,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAE/B,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,CAAA;iCACa,SAAS;WAC/B,GAAG;;;kBAGI,IAAI;qBACD,GAAG;gBACR,GAAG;mBACA,GAAG;mBACH,GAAG;gBACN,GAAG;MACb,CAAC,CAAA;IAEL,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,CAAA;iCACa,KAAK;WAC3B,GAAG;qBACO,GAAG;wBACA,IAAI;gBACZ,GAAG;kBACD,GAAG;mBACF,GAAG;MAChB,CAAC,CAAA;IAEL,MAAM,oBAAoB,CACxB,EAAE,EACF,8BAA8B,EAC9B,KAAK,EACL,GAAG,CAAA,iCAAiC,CACrC,CAAA;IACD,MAAM,oBAAoB,CAAC,EAAE,EAAE,4BAA4B,EAAE,KAAK,EAAE,GAAG,CAAA,WAAW,CAAC,CAAA;AACrF,CAAC;AAED,KAAK,UAAU,oBAAoB,CACjC,EAAkB,EAClB,IAAY,EACZ,KAAkB,EAClB,OAAoB;IAEpB,MAAM,EAAE;SACL,KAAK,CAAC,GAAG,CAAA,gBAAgB,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,KAAK,IAAI,OAAO,EAAE,CAAC;SAC/E,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA,CAAC,0DAA0D;AACtF,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type DatabaseHandle } from '@cogenta/core';
|
|
2
|
+
import type { ChannelSeverity, MessageId } from '../adapter.js';
|
|
3
|
+
import type { ChannelLinkStore } from '../linking/store.js';
|
|
4
|
+
import type { ChannelRegistry } from '../registry.js';
|
|
5
|
+
import type { PreferenceStore } from './store.js';
|
|
6
|
+
import { type ChannelEventType } from './types.js';
|
|
7
|
+
export interface NotifyInput {
|
|
8
|
+
readonly userId: string;
|
|
9
|
+
readonly channelName: string;
|
|
10
|
+
readonly eventType: ChannelEventType;
|
|
11
|
+
readonly severity: ChannelSeverity;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly summary: string;
|
|
14
|
+
}
|
|
15
|
+
export type NotifyOutcome = {
|
|
16
|
+
readonly dispatched: true;
|
|
17
|
+
readonly messageId: MessageId;
|
|
18
|
+
} | {
|
|
19
|
+
readonly dispatched: false;
|
|
20
|
+
readonly reason: 'not-subscribed' | 'below-severity-threshold' | 'no-linked-channel' | 'deferred-quiet-hours' | 'queued-for-grouping';
|
|
21
|
+
};
|
|
22
|
+
export interface NotificationDispatcher {
|
|
23
|
+
/** Filters against preferences, then either sends immediately or queues for a later `flushDue()`. Never throws on a normal filtered/queued outcome — only on a real infra failure. */
|
|
24
|
+
notify(input: NotifyInput): Promise<NotifyOutcome>;
|
|
25
|
+
/** "sans lui, un scan de dépendances produit quinze messages" — collapses every due group of pending notifications into one message each. Call periodically; scheduling it is the deployer's job (same honest split as `resetPlaygroundData`, L9 task 12). */
|
|
26
|
+
flushDue(): Promise<readonly MessageId[]>;
|
|
27
|
+
}
|
|
28
|
+
export interface CreateNotificationDispatcherOptions {
|
|
29
|
+
readonly db: DatabaseHandle;
|
|
30
|
+
readonly registry: ChannelRegistry;
|
|
31
|
+
readonly linkStore: ChannelLinkStore;
|
|
32
|
+
readonly preferenceStore: PreferenceStore;
|
|
33
|
+
readonly buildAdminUrl: (userId: string, channelName: string) => string;
|
|
34
|
+
readonly now?: () => number;
|
|
35
|
+
}
|
|
36
|
+
export declare function createNotificationDispatcher(options: CreateNotificationDispatcherOptions): NotificationDispatcher;
|
|
37
|
+
//# sourceMappingURL=dispatcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/preferences/dispatcher.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAA0B,MAAM,eAAe,CAAA;AAC3E,OAAO,KAAK,EAAkB,eAAe,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAG/E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAErD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEjD,OAAO,EAAE,KAAK,gBAAgB,EAAiB,MAAM,YAAY,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,SAAS,EAAE,gBAAgB,CAAA;IACpC,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAA;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAA;CAAE,GAC5D;IACE,QAAQ,CAAC,UAAU,EAAE,KAAK,CAAA;IAC1B,QAAQ,CAAC,MAAM,EACX,gBAAgB,GAChB,0BAA0B,GAC1B,mBAAmB,GACnB,sBAAsB,GACtB,qBAAqB,CAAA;CAC1B,CAAA;AAEL,MAAM,WAAW,sBAAsB;IACrC,sLAAsL;IACtL,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;IAClD,8PAA8P;IAC9P,QAAQ,IAAI,OAAO,CAAC,SAAS,SAAS,EAAE,CAAC,CAAA;CAC1C;AAgBD,MAAM,WAAW,mCAAmC;IAClD,QAAQ,CAAC,EAAE,EAAE,cAAc,CAAA;IAC3B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAA;IAClC,QAAQ,CAAC,SAAS,EAAE,gBAAgB,CAAA;IACpC,QAAQ,CAAC,eAAe,EAAE,eAAe,CAAA;IACzC,QAAQ,CAAC,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,KAAK,MAAM,CAAA;IACvE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAC5B;AAED,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,mCAAmC,GAC3C,sBAAsB,CA8GxB"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { identifier, newId, sql } from '@cogenta/core';
|
|
2
|
+
import { buildNotification } from '../formats/notification.js';
|
|
3
|
+
import { buildReport } from '../formats/report.js';
|
|
4
|
+
import { isWithinQuietHours } from './quiet-hours.js';
|
|
5
|
+
import { PREFERENCE_TABLES } from './tables.js';
|
|
6
|
+
import { SEVERITY_RANK } from './types.js';
|
|
7
|
+
const GROUPING_WINDOW_MS = {
|
|
8
|
+
hourly: 60 * 60 * 1000,
|
|
9
|
+
daily: 24 * 60 * 60 * 1000,
|
|
10
|
+
};
|
|
11
|
+
export function createNotificationDispatcher(options) {
|
|
12
|
+
const { db, registry, linkStore, preferenceStore, buildAdminUrl } = options;
|
|
13
|
+
const now = options.now ?? Date.now;
|
|
14
|
+
const pending = identifier(PREFERENCE_TABLES.pending, db.dialect);
|
|
15
|
+
async function resolveTarget(userId, channelName) {
|
|
16
|
+
const linked = await linkStore.listLinkedChannels(userId);
|
|
17
|
+
return linked.find((l) => l.channelName === channelName) ?? null;
|
|
18
|
+
}
|
|
19
|
+
async function enqueue(input) {
|
|
20
|
+
await db.query(sql `
|
|
21
|
+
insert into ${pending} (id, user_id, channel_name, event_type, severity, title, summary, created_at)
|
|
22
|
+
values (${newId(now)}, ${input.userId}, ${input.channelName}, ${input.eventType}, ${input.severity}, ${input.title}, ${input.summary}, ${new Date(now()).toISOString()})`);
|
|
23
|
+
}
|
|
24
|
+
async function sendOne(channelName, targetId, message) {
|
|
25
|
+
return registry.get(channelName).send({ id: targetId }, message);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
async notify(input) {
|
|
29
|
+
const prefs = await preferenceStore.get(input.userId, input.channelName);
|
|
30
|
+
if (!prefs.eventTypes.includes(input.eventType)) {
|
|
31
|
+
return { dispatched: false, reason: 'not-subscribed' };
|
|
32
|
+
}
|
|
33
|
+
if (SEVERITY_RANK[input.severity] < SEVERITY_RANK[prefs.minSeverity]) {
|
|
34
|
+
return { dispatched: false, reason: 'below-severity-threshold' };
|
|
35
|
+
}
|
|
36
|
+
const inQuietHours = prefs.quietHours !== null &&
|
|
37
|
+
input.severity !== 'critical' &&
|
|
38
|
+
isWithinQuietHours(prefs.quietHours, now());
|
|
39
|
+
if (inQuietHours) {
|
|
40
|
+
await enqueue(input);
|
|
41
|
+
return { dispatched: false, reason: 'deferred-quiet-hours' };
|
|
42
|
+
}
|
|
43
|
+
if (prefs.grouping !== 'immediate') {
|
|
44
|
+
await enqueue(input);
|
|
45
|
+
return { dispatched: false, reason: 'queued-for-grouping' };
|
|
46
|
+
}
|
|
47
|
+
const target = await resolveTarget(input.userId, input.channelName);
|
|
48
|
+
if (target === null)
|
|
49
|
+
return { dispatched: false, reason: 'no-linked-channel' };
|
|
50
|
+
// `NotifyInput` only carries a title/summary, not the separate
|
|
51
|
+
// context/expectedAction/adminUrl an `AlertChannelMessage` requires —
|
|
52
|
+
// a single immediate item always renders as a one-line Notification;
|
|
53
|
+
// producers that need the richer Alert shape (e.g. approvals) build
|
|
54
|
+
// and dispatch it themselves via `buildAlert`/`dispatchApproval`.
|
|
55
|
+
const message = buildNotification(`${input.title} — ${input.summary}`);
|
|
56
|
+
const messageId = await sendOne(input.channelName, target.channelUserId, message);
|
|
57
|
+
return { dispatched: true, messageId };
|
|
58
|
+
},
|
|
59
|
+
async flushDue() {
|
|
60
|
+
const groupsResult = await db.query(sql `
|
|
61
|
+
select distinct user_id, channel_name from ${pending}`);
|
|
62
|
+
const sent = [];
|
|
63
|
+
for (const group of groupsResult.rows) {
|
|
64
|
+
const prefs = await preferenceStore.get(group.user_id, group.channel_name);
|
|
65
|
+
const stillInQuietHours = prefs.quietHours !== null && isWithinQuietHours(prefs.quietHours, now());
|
|
66
|
+
if (stillInQuietHours)
|
|
67
|
+
continue;
|
|
68
|
+
const rowsResult = await db.query(sql `
|
|
69
|
+
select * from ${pending}
|
|
70
|
+
where user_id = ${group.user_id} and channel_name = ${group.channel_name}
|
|
71
|
+
order by created_at asc`);
|
|
72
|
+
const rows = rowsResult.rows;
|
|
73
|
+
if (rows.length === 0)
|
|
74
|
+
continue;
|
|
75
|
+
const first = rows[0];
|
|
76
|
+
if (first === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
const oldest = new Date(first.created_at).getTime();
|
|
79
|
+
const windowMs = prefs.grouping === 'immediate' ? 0 : GROUPING_WINDOW_MS[prefs.grouping];
|
|
80
|
+
const due = now() - oldest >= windowMs;
|
|
81
|
+
if (!due)
|
|
82
|
+
continue;
|
|
83
|
+
const target = await resolveTarget(group.user_id, group.channel_name);
|
|
84
|
+
if (target !== null) {
|
|
85
|
+
const message = rows.length === 1 && first !== undefined
|
|
86
|
+
? buildNotification(`${first.title} — ${first.summary}`)
|
|
87
|
+
: buildReport({
|
|
88
|
+
title: 'Notifications groupées',
|
|
89
|
+
keyFigures: [{ label: 'Constats', value: String(rows.length) }],
|
|
90
|
+
sections: rows.map((row) => ({ heading: row.title, body: row.summary })),
|
|
91
|
+
moreUrl: buildAdminUrl(group.user_id, group.channel_name),
|
|
92
|
+
});
|
|
93
|
+
sent.push(await sendOne(group.channel_name, target.channelUserId, message));
|
|
94
|
+
}
|
|
95
|
+
await db.query(sql `
|
|
96
|
+
delete from ${pending}
|
|
97
|
+
where user_id = ${group.user_id} and channel_name = ${group.channel_name}`);
|
|
98
|
+
}
|
|
99
|
+
return sent;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../src/preferences/dispatcher.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { QuietHours } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Minute-of-day, UTC. This package stores no per-user timezone yet (nothing
|
|
4
|
+
* upstream collects one) — quiet hours compare against UTC clock minutes, a
|
|
5
|
+
* documented simplification, not a silent one: a user in a different
|
|
6
|
+
* timezone gets a UTC-anchored window until per-user timezone storage
|
|
7
|
+
* exists somewhere in this codebase.
|
|
8
|
+
*/
|
|
9
|
+
export declare function minuteOfDayUtc(epochMs: number): number;
|
|
10
|
+
/** Handles a window that wraps past midnight (e.g. 22:00 → 06:00). */
|
|
11
|
+
export declare function isWithinQuietHours(quietHours: QuietHours, epochMs: number): boolean;
|
|
12
|
+
//# sourceMappingURL=quiet-hours.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quiet-hours.d.ts","sourceRoot":"","sources":["../../src/preferences/quiet-hours.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGtD;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAMnF"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minute-of-day, UTC. This package stores no per-user timezone yet (nothing
|
|
3
|
+
* upstream collects one) — quiet hours compare against UTC clock minutes, a
|
|
4
|
+
* documented simplification, not a silent one: a user in a different
|
|
5
|
+
* timezone gets a UTC-anchored window until per-user timezone storage
|
|
6
|
+
* exists somewhere in this codebase.
|
|
7
|
+
*/
|
|
8
|
+
export function minuteOfDayUtc(epochMs) {
|
|
9
|
+
const date = new Date(epochMs);
|
|
10
|
+
return date.getUTCHours() * 60 + date.getUTCMinutes();
|
|
11
|
+
}
|
|
12
|
+
/** Handles a window that wraps past midnight (e.g. 22:00 → 06:00). */
|
|
13
|
+
export function isWithinQuietHours(quietHours, epochMs) {
|
|
14
|
+
const minute = minuteOfDayUtc(epochMs);
|
|
15
|
+
const { startMinute, endMinute } = quietHours;
|
|
16
|
+
if (startMinute === endMinute)
|
|
17
|
+
return false; // a zero-width window never applies
|
|
18
|
+
if (startMinute < endMinute)
|
|
19
|
+
return minute >= startMinute && minute < endMinute;
|
|
20
|
+
return minute >= startMinute || minute < endMinute;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=quiet-hours.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quiet-hours.js","sourceRoot":"","sources":["../../src/preferences/quiet-hours.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,OAAe;IAC5C,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,OAAO,CAAC,CAAA;IAC9B,OAAO,IAAI,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,aAAa,EAAE,CAAA;AACvD,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,kBAAkB,CAAC,UAAsB,EAAE,OAAe;IACxE,MAAM,MAAM,GAAG,cAAc,CAAC,OAAO,CAAC,CAAA;IACtC,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,GAAG,UAAU,CAAA;IAC7C,IAAI,WAAW,KAAK,SAAS;QAAE,OAAO,KAAK,CAAA,CAAC,oCAAoC;IAChF,IAAI,WAAW,GAAG,SAAS;QAAE,OAAO,MAAM,IAAI,WAAW,IAAI,MAAM,GAAG,SAAS,CAAA;IAC/E,OAAO,MAAM,IAAI,WAAW,IAAI,MAAM,GAAG,SAAS,CAAA;AACpD,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type DatabaseHandle } from '@cogenta/core';
|
|
2
|
+
import { type ChannelPreferences } from './types.js';
|
|
3
|
+
export interface PreferenceStore {
|
|
4
|
+
get(userId: string, channelName: string): Promise<ChannelPreferences>;
|
|
5
|
+
set(userId: string, channelName: string, preferences: ChannelPreferences): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function createPreferenceStore(db: DatabaseHandle, now?: () => number): PreferenceStore;
|
|
8
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/preferences/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,cAAc,EAA0B,MAAM,eAAe,CAAA;AAGzF,OAAO,EAGL,KAAK,kBAAkB,EAGxB,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAA;IACrE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACzF;AA2DD,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,cAAc,EAClB,GAAG,GAAE,MAAM,MAAiB,GAC3B,eAAe,CA2BjB"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CogentaError, identifier, newId, sql } from '@cogenta/core';
|
|
2
|
+
import { PREFERENCE_TABLES } from './tables.js';
|
|
3
|
+
import { CHANNEL_EVENT_TYPES, DEFAULT_CHANNEL_PREFERENCES, } from './types.js';
|
|
4
|
+
function isEventType(value) {
|
|
5
|
+
return CHANNEL_EVENT_TYPES.includes(value);
|
|
6
|
+
}
|
|
7
|
+
function invalid(message, hint) {
|
|
8
|
+
return new CogentaError({ code: 'CHANNEL_PREFERENCES_INVALID', message, hint });
|
|
9
|
+
}
|
|
10
|
+
function validate(preferences) {
|
|
11
|
+
for (const eventType of preferences.eventTypes) {
|
|
12
|
+
if (!isEventType(eventType)) {
|
|
13
|
+
throw invalid(`"${eventType}" is not a known channel event type.`, `Use one of: ${CHANNEL_EVENT_TYPES.join(', ')}.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const { quietHours } = preferences;
|
|
17
|
+
if (quietHours !== null) {
|
|
18
|
+
for (const [label, minute] of [
|
|
19
|
+
['startMinute', quietHours.startMinute],
|
|
20
|
+
['endMinute', quietHours.endMinute],
|
|
21
|
+
]) {
|
|
22
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 1439) {
|
|
23
|
+
throw invalid(`quietHours.${label} must be an integer between 0 and 1439 (minutes since local midnight), got ${minute}.`, 'Pick a minute-of-day in [0, 1439], or set quietHours to null to disable it.');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function rowToPreferences(row) {
|
|
29
|
+
return {
|
|
30
|
+
eventTypes: row.event_types.split(',').filter((v) => v.length > 0),
|
|
31
|
+
minSeverity: row.min_severity,
|
|
32
|
+
quietHours: row.quiet_start_minute === null || row.quiet_end_minute === null
|
|
33
|
+
? null
|
|
34
|
+
: { startMinute: row.quiet_start_minute, endMinute: row.quiet_end_minute },
|
|
35
|
+
grouping: row.grouping,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function createPreferenceStore(db, now = Date.now) {
|
|
39
|
+
const preferences = identifier(PREFERENCE_TABLES.preferences, db.dialect);
|
|
40
|
+
return {
|
|
41
|
+
async get(userId, channelName) {
|
|
42
|
+
const result = await db.query(sql `select * from ${preferences} where user_id = ${userId} and channel_name = ${channelName}`);
|
|
43
|
+
const row = result.rows[0];
|
|
44
|
+
return row === undefined ? DEFAULT_CHANNEL_PREFERENCES : rowToPreferences(row);
|
|
45
|
+
},
|
|
46
|
+
async set(userId, channelName, next) {
|
|
47
|
+
validate(next);
|
|
48
|
+
const updatedAt = new Date(now()).toISOString();
|
|
49
|
+
await db.query(sql `delete from ${preferences} where user_id = ${userId} and channel_name = ${channelName}`);
|
|
50
|
+
await db.query(sql `
|
|
51
|
+
insert into ${preferences}
|
|
52
|
+
(id, user_id, channel_name, event_types, min_severity, quiet_start_minute, quiet_end_minute, grouping, updated_at)
|
|
53
|
+
values (
|
|
54
|
+
${newId(now)}, ${userId}, ${channelName}, ${next.eventTypes.join(',')}, ${next.minSeverity},
|
|
55
|
+
${next.quietHours?.startMinute ?? null}, ${next.quietHours?.endMinute ?? null}, ${next.grouping}, ${updatedAt}
|
|
56
|
+
)`);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/preferences/store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type DatabaseHandle } from '@cogenta/core';
|
|
2
|
+
export declare const PREFERENCE_TABLES: {
|
|
3
|
+
readonly preferences: 'cogenta_channel_preferences';
|
|
4
|
+
readonly pending: 'cogenta_channel_pending_notifications';
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Owned by `@cogenta/channels`, same `create table if not exists` pattern as
|
|
8
|
+
* `ensureChannelTables` (`../linking/tables.js`) — no separate migration
|
|
9
|
+
* file, a fresh install and an upgrade take the same path.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ensurePreferenceTables(db: DatabaseHandle): Promise<void>;
|
|
12
|
+
//# sourceMappingURL=tables.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tables.d.ts","sourceRoot":"","sources":["../../src/preferences/tables.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAKpB,MAAM,eAAe,CAAA;AAEtB,eAAO,MAAM,iBAAiB;aAC5B,WAAW,EAAE,6BAA6B;aAC1C,OAAO,EAAE,uCAAuC;CACxC,CAAA;AAMV;;;;GAIG;AACH,wBAAsB,sBAAsB,CAAC,EAAE,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAkD9E"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { identifier, sql, unsafeRaw, } from '@cogenta/core';
|
|
2
|
+
export const PREFERENCE_TABLES = {
|
|
3
|
+
preferences: 'cogenta_channel_preferences',
|
|
4
|
+
pending: 'cogenta_channel_pending_notifications',
|
|
5
|
+
};
|
|
6
|
+
function textColumn(dialect, length) {
|
|
7
|
+
return unsafeRaw(dialect === 'sqlite' ? 'text' : `varchar(${length})`);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Owned by `@cogenta/channels`, same `create table if not exists` pattern as
|
|
11
|
+
* `ensureChannelTables` (`../linking/tables.js`) — no separate migration
|
|
12
|
+
* file, a fresh install and an upgrade take the same path.
|
|
13
|
+
*/
|
|
14
|
+
export async function ensurePreferenceTables(db) {
|
|
15
|
+
const d = db.dialect;
|
|
16
|
+
const preferences = identifier(PREFERENCE_TABLES.preferences, d);
|
|
17
|
+
const pending = identifier(PREFERENCE_TABLES.pending, d);
|
|
18
|
+
const t64 = textColumn(d, 64);
|
|
19
|
+
const t255 = textColumn(d, 255);
|
|
20
|
+
const int = unsafeRaw('integer');
|
|
21
|
+
await db.query(sql `
|
|
22
|
+
create table if not exists ${preferences} (
|
|
23
|
+
id ${t64} not null primary key,
|
|
24
|
+
user_id ${t64} not null,
|
|
25
|
+
channel_name ${t64} not null,
|
|
26
|
+
-- Comma-joined ChannelEventTypes (types.ts) — an open, but real,
|
|
27
|
+
-- typed set, not free text.
|
|
28
|
+
event_types ${t255} not null,
|
|
29
|
+
min_severity ${t64} not null,
|
|
30
|
+
quiet_start_minute ${int},
|
|
31
|
+
quiet_end_minute ${int},
|
|
32
|
+
grouping ${t64} not null,
|
|
33
|
+
updated_at ${t64} not null
|
|
34
|
+
)`);
|
|
35
|
+
await createIndexIfMissing(db, 'cogenta_channel_preferences_lookup', preferences, sql `(user_id, channel_name)`, true);
|
|
36
|
+
await db.query(sql `
|
|
37
|
+
create table if not exists ${pending} (
|
|
38
|
+
id ${t64} not null primary key,
|
|
39
|
+
user_id ${t64} not null,
|
|
40
|
+
channel_name ${t64} not null,
|
|
41
|
+
event_type ${t64} not null,
|
|
42
|
+
severity ${t64} not null,
|
|
43
|
+
title ${t255} not null,
|
|
44
|
+
summary ${t255} not null,
|
|
45
|
+
created_at ${t64} not null
|
|
46
|
+
)`);
|
|
47
|
+
await createIndexIfMissing(db, 'cogenta_channel_pending_lookup', pending, sql `(user_id, channel_name)`, false);
|
|
48
|
+
}
|
|
49
|
+
async function createIndexIfMissing(db, name, table, columns, unique) {
|
|
50
|
+
const kind = unique ? unsafeRaw('unique index') : unsafeRaw('index');
|
|
51
|
+
await db
|
|
52
|
+
.query(sql `create ${kind} ${identifier(name, db.dialect)} on ${table} ${columns}`)
|
|
53
|
+
.catch(() => undefined); // already there — no portable "if not exists" for indexes
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=tables.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tables.js","sourceRoot":"","sources":["../../src/preferences/tables.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,UAAU,EAEV,GAAG,EACH,SAAS,GACV,MAAM,eAAe,CAAA;AAEtB,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,WAAW,EAAE,6BAA6B;IAC1C,OAAO,EAAE,uCAAuC;CACxC,CAAA;AAEV,SAAS,UAAU,CAAC,OAAwB,EAAE,MAAc;IAC1D,OAAO,SAAS,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,MAAM,GAAG,CAAC,CAAA;AACxE,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,EAAkB;IAC7D,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAA;IACpB,MAAM,WAAW,GAAG,UAAU,CAAC,iBAAiB,CAAC,WAAW,EAAE,CAAC,CAAC,CAAA;IAChE,MAAM,OAAO,GAAG,UAAU,CAAC,iBAAiB,CAAC,OAAO,EAAE,CAAC,CAAC,CAAA;IACxD,MAAM,GAAG,GAAG,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;IAC7B,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAC/B,MAAM,GAAG,GAAG,SAAS,CAAC,SAAS,CAAC,CAAA;IAEhC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,CAAA;iCACa,WAAW;WACjC,GAAG;gBACE,GAAG;qBACE,GAAG;;;oBAGJ,IAAI;qBACH,GAAG;2BACG,GAAG;yBACL,GAAG;iBACX,GAAG;mBACD,GAAG;MAChB,CAAC,CAAA;IAEL,MAAM,oBAAoB,CACxB,EAAE,EACF,oCAAoC,EACpC,WAAW,EACX,GAAG,CAAA,yBAAyB,EAC5B,IAAI,CACL,CAAA;IAED,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,CAAA;iCACa,OAAO;WAC7B,GAAG;gBACE,GAAG;qBACE,GAAG;mBACL,GAAG;iBACL,GAAG;cACN,IAAI;gBACF,IAAI;mBACD,GAAG;MAChB,CAAC,CAAA;IAEL,MAAM,oBAAoB,CACxB,EAAE,EACF,gCAAgC,EAChC,OAAO,EACP,GAAG,CAAA,yBAAyB,EAC5B,KAAK,CACN,CAAA;AACH,CAAC;AAED,KAAK,UAAU,oBAAoB,CACjC,EAAkB,EAClB,IAAY,EACZ,KAAkB,EAClB,OAAoB,EACpB,MAAe;IAEf,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACpE,MAAM,EAAE;SACL,KAAK,CAAC,GAAG,CAAA,UAAU,IAAI,IAAI,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,KAAK,IAAI,OAAO,EAAE,CAAC;SACjF,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA,CAAC,0DAA0D;AACtF,CAAC"}
|