@things-factory/notification 10.1.218 → 10.1.223
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-client/bootstrap.js +8 -3
- package/dist-client/bootstrap.js.map +1 -1
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +1 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/store-once.d.ts +14 -0
- package/dist-client/store-once.js +32 -0
- package/dist-client/store-once.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/viewparts/notice-channel-setting-let.d.ts +32 -0
- package/dist-client/viewparts/notice-channel-setting-let.js +175 -0
- package/dist-client/viewparts/notice-channel-setting-let.js.map +1 -0
- package/dist-server/controllers/index.d.ts +1 -0
- package/dist-server/controllers/index.js +1 -0
- package/dist-server/controllers/index.js.map +1 -1
- package/dist-server/controllers/notice/deliver-notice.d.ts +78 -0
- package/dist-server/controllers/notice/deliver-notice.js +101 -0
- package/dist-server/controllers/notice/deliver-notice.js.map +1 -0
- package/dist-server/controllers/notice/index.d.ts +4 -0
- package/dist-server/controllers/notice/index.js +8 -0
- package/dist-server/controllers/notice/index.js.map +1 -0
- package/dist-server/controllers/notice/notice-channels.d.ts +67 -0
- package/dist-server/controllers/notice/notice-channels.js +62 -0
- package/dist-server/controllers/notice/notice-channels.js.map +1 -0
- package/dist-server/controllers/notice/notice-email.d.ts +28 -0
- package/dist-server/controllers/notice/notice-email.js +125 -0
- package/dist-server/controllers/notice/notice-email.js.map +1 -0
- package/dist-server/controllers/notice/send-notice.d.ts +16 -0
- package/dist-server/controllers/notice/send-notice.js +72 -0
- package/dist-server/controllers/notice/send-notice.js.map +1 -0
- package/dist-server/middlewares/index.d.ts +1 -0
- package/dist-server/middlewares/index.js +3 -0
- package/dist-server/middlewares/index.js.map +1 -1
- package/dist-server/middlewares/notification-middleware.d.ts +19 -0
- package/dist-server/middlewares/notification-middleware.js +9 -3
- package/dist-server/middlewares/notification-middleware.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/index.js +3 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/notice-channel/index.d.ts +2 -0
- package/dist-server/service/notice-channel/index.js +6 -0
- package/dist-server/service/notice-channel/index.js.map +1 -0
- package/dist-server/service/notice-channel/notice-channel-resolver.d.ts +23 -0
- package/dist-server/service/notice-channel/notice-channel-resolver.js +118 -0
- package/dist-server/service/notice-channel/notice-channel-resolver.js.map +1 -0
- package/dist-server/service/notice-channel/notice-channel-rules.d.ts +25 -0
- package/dist-server/service/notice-channel/notice-channel-rules.js +60 -0
- package/dist-server/service/notice-channel/notice-channel-rules.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/tests/notice-channel-setting.test.ts +107 -0
- package/tests/notice-delivery.test.ts +328 -0
- package/tests/open-doors.test.ts +5 -2
- package/tests/store-once.test.ts +88 -0
- package/translations/en.json +10 -1
- package/translations/es.json +10 -1
- package/translations/ja.json +10 -1
- package/translations/ko.json +10 -1
- package/translations/ms.json +10 -1
- package/translations/zh.json +10 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import '@material/web/button/outlined-button.js';
|
|
2
|
+
import '@material/web/button/text-button.js';
|
|
3
|
+
import '@material/web/switch/switch.js';
|
|
4
|
+
import '@things-factory/setting-base';
|
|
5
|
+
import { LitElement, nothing } from 'lit';
|
|
6
|
+
type SecretState = {
|
|
7
|
+
set: boolean;
|
|
8
|
+
host?: string;
|
|
9
|
+
};
|
|
10
|
+
type NoticeChannelSetting = {
|
|
11
|
+
email: boolean;
|
|
12
|
+
teamsChannel: SecretState;
|
|
13
|
+
};
|
|
14
|
+
declare const NoticeChannelSettingLet_base: (new (...args: any[]) => LitElement) & typeof LitElement;
|
|
15
|
+
/**
|
|
16
|
+
* Where notices go beyond the app and web push in this domain (ADR-0164): mail, and the Teams channel that takes a
|
|
17
|
+
* notice to everyone. Shown to those who may change the domain's attributes; for anyone else the card stays empty. The
|
|
18
|
+
* webhook address goes in and never comes back - the card shows only that it is set, and its host.
|
|
19
|
+
*/
|
|
20
|
+
export declare class NoticeChannelSettingLet extends NoticeChannelSettingLet_base {
|
|
21
|
+
static styles: import("lit").CSSResult[];
|
|
22
|
+
setting?: NoticeChannelSetting;
|
|
23
|
+
entries: {
|
|
24
|
+
teamsChannelWebhook: string;
|
|
25
|
+
};
|
|
26
|
+
busy: boolean;
|
|
27
|
+
render(): import("lit-html").TemplateResult<1> | typeof nothing;
|
|
28
|
+
private webhookRow;
|
|
29
|
+
connectedCallback(): Promise<void>;
|
|
30
|
+
private save;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { __decorate, __metadata } from "tslib";
|
|
2
|
+
import '@material/web/button/outlined-button.js';
|
|
3
|
+
import '@material/web/button/text-button.js';
|
|
4
|
+
import '@material/web/switch/switch.js';
|
|
5
|
+
import '@things-factory/setting-base';
|
|
6
|
+
import gql from 'graphql-tag';
|
|
7
|
+
import { css, html, LitElement, nothing } from 'lit';
|
|
8
|
+
import { customElement, state } from 'lit/decorators.js';
|
|
9
|
+
import { client } from '@operato/graphql';
|
|
10
|
+
import { i18next, localize } from '@operato/i18n';
|
|
11
|
+
import { notify } from '@operato/layout';
|
|
12
|
+
const SETTING = `email teamsChannel { set host }`;
|
|
13
|
+
/**
|
|
14
|
+
* Where notices go beyond the app and web push in this domain (ADR-0164): mail, and the Teams channel that takes a
|
|
15
|
+
* notice to everyone. Shown to those who may change the domain's attributes; for anyone else the card stays empty. The
|
|
16
|
+
* webhook address goes in and never comes back - the card shows only that it is set, and its host.
|
|
17
|
+
*/
|
|
18
|
+
let NoticeChannelSettingLet = class NoticeChannelSettingLet extends localize(i18next)(LitElement) {
|
|
19
|
+
constructor() {
|
|
20
|
+
super(...arguments);
|
|
21
|
+
this.entries = { teamsChannelWebhook: '' };
|
|
22
|
+
this.busy = false;
|
|
23
|
+
}
|
|
24
|
+
static { this.styles = [
|
|
25
|
+
css `
|
|
26
|
+
div[row] {
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
gap: var(--spacing-small, 4px);
|
|
30
|
+
border-bottom: var(--border-dim-color);
|
|
31
|
+
margin-bottom: var(--spacing-medium);
|
|
32
|
+
padding-bottom: var(--spacing-medium);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
div[row]:last-child {
|
|
36
|
+
border-bottom: none;
|
|
37
|
+
margin-bottom: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
label {
|
|
41
|
+
display: flex;
|
|
42
|
+
gap: 10px;
|
|
43
|
+
align-items: center;
|
|
44
|
+
|
|
45
|
+
font: var(--label-font);
|
|
46
|
+
color: var(--md-sys-color-on-surface);
|
|
47
|
+
text-transform: var(--label-text-transform);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
[state] {
|
|
51
|
+
font: var(--input-font);
|
|
52
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
53
|
+
/* a host is data - the card's capitalized labels must not reach it */
|
|
54
|
+
text-transform: none;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
div[entry] {
|
|
58
|
+
display: flex;
|
|
59
|
+
gap: var(--spacing-small, 4px);
|
|
60
|
+
align-items: center;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
input {
|
|
64
|
+
background-color: var(--input-field-background, var(--md-sys-color-surface-container-highest));
|
|
65
|
+
color: var(--input-field-color, var(--md-sys-color-on-surface));
|
|
66
|
+
border: var(--border-dim-color);
|
|
67
|
+
border-radius: var(--border-radius);
|
|
68
|
+
margin: var(--input-margin);
|
|
69
|
+
padding: var(--input-padding);
|
|
70
|
+
font: var(--input-font);
|
|
71
|
+
|
|
72
|
+
flex: 1;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
}
|
|
75
|
+
`
|
|
76
|
+
]; }
|
|
77
|
+
render() {
|
|
78
|
+
const setting = this.setting;
|
|
79
|
+
if (!setting)
|
|
80
|
+
return nothing;
|
|
81
|
+
return html `
|
|
82
|
+
<setting-let>
|
|
83
|
+
<span slot="title">${i18next.t('title.notice channels')}</span>
|
|
84
|
+
|
|
85
|
+
<div slot="content" row>
|
|
86
|
+
<label>
|
|
87
|
+
<md-switch ?selected=${setting.email} ?disabled=${this.busy} @change=${e => this.save({ email: e.target.selected })}></md-switch>
|
|
88
|
+
${i18next.t('label.notice by email')}
|
|
89
|
+
</label>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
${this.webhookRow('teamsChannelWebhook', i18next.t('label.teams channel webhook'), setting.teamsChannel)}
|
|
93
|
+
</setting-let>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
webhookRow(field, label, secret) {
|
|
97
|
+
const entry = this.entries[field];
|
|
98
|
+
return html `
|
|
99
|
+
<div slot="content" row>
|
|
100
|
+
<label>${label}</label>
|
|
101
|
+
<span state>
|
|
102
|
+
${secret.set ? i18next.t('text.notice webhook set', { host: secret.host || '-' }) : i18next.t('text.notice webhook not set')}
|
|
103
|
+
</span>
|
|
104
|
+
<div entry>
|
|
105
|
+
<input
|
|
106
|
+
type="url"
|
|
107
|
+
placeholder="https://"
|
|
108
|
+
autocomplete="off"
|
|
109
|
+
.value=${entry}
|
|
110
|
+
@input=${e => (this.entries = { ...this.entries, [field]: e.target.value })}
|
|
111
|
+
/>
|
|
112
|
+
<md-outlined-button ?disabled=${this.busy || !entry.trim()} @click=${() => this.save({ [field]: entry.trim() })}
|
|
113
|
+
>${i18next.t('button.notice webhook save')}</md-outlined-button
|
|
114
|
+
>
|
|
115
|
+
${secret.set
|
|
116
|
+
? html `<md-text-button ?disabled=${this.busy} @click=${() => this.save({ [field]: '' })}
|
|
117
|
+
>${i18next.t('button.notice webhook clear')}</md-text-button
|
|
118
|
+
>`
|
|
119
|
+
: nothing}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
async connectedCallback() {
|
|
125
|
+
super.connectedCallback();
|
|
126
|
+
const response = await client
|
|
127
|
+
.query({ query: gql `query { noticeChannelSetting { ${SETTING} } }`, fetchPolicy: 'no-cache', errorPolicy: 'all' })
|
|
128
|
+
.catch(() => undefined);
|
|
129
|
+
/* refused - this person may not change the domain's attributes, so the card stays empty */
|
|
130
|
+
this.setting = response?.data?.noticeChannelSetting || undefined;
|
|
131
|
+
}
|
|
132
|
+
async save(patch) {
|
|
133
|
+
this.busy = true;
|
|
134
|
+
try {
|
|
135
|
+
const response = await client.mutate({
|
|
136
|
+
mutation: gql `mutation ($patch: NoticeChannelSettingPatch!) { updateNoticeChannelSetting(patch: $patch) { ${SETTING} } }`,
|
|
137
|
+
variables: { patch },
|
|
138
|
+
errorPolicy: 'all'
|
|
139
|
+
});
|
|
140
|
+
if (response.errors?.length) {
|
|
141
|
+
notify({ level: 'error', message: response.errors[0].message });
|
|
142
|
+
this.requestUpdate();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.setting = response.data.updateNoticeChannelSetting;
|
|
146
|
+
const cleared = Object.keys(patch).filter(field => field in this.entries);
|
|
147
|
+
if (cleared.length)
|
|
148
|
+
this.entries = { ...this.entries, ...Object.fromEntries(cleared.map(field => [field, ''])) };
|
|
149
|
+
notify({ level: 'info', message: i18next.t('text.notice channels saved') });
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
notify({ level: 'error', message: String(error?.message || error) });
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
this.busy = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
__decorate([
|
|
160
|
+
state(),
|
|
161
|
+
__metadata("design:type", Object)
|
|
162
|
+
], NoticeChannelSettingLet.prototype, "setting", void 0);
|
|
163
|
+
__decorate([
|
|
164
|
+
state(),
|
|
165
|
+
__metadata("design:type", Object)
|
|
166
|
+
], NoticeChannelSettingLet.prototype, "entries", void 0);
|
|
167
|
+
__decorate([
|
|
168
|
+
state(),
|
|
169
|
+
__metadata("design:type", Object)
|
|
170
|
+
], NoticeChannelSettingLet.prototype, "busy", void 0);
|
|
171
|
+
NoticeChannelSettingLet = __decorate([
|
|
172
|
+
customElement('notice-channel-setting-let')
|
|
173
|
+
], NoticeChannelSettingLet);
|
|
174
|
+
export { NoticeChannelSettingLet };
|
|
175
|
+
//# sourceMappingURL=notice-channel-setting-let.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notice-channel-setting-let.js","sourceRoot":"","sources":["../../client/viewparts/notice-channel-setting-let.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/button/outlined-button.js'\nimport '@material/web/button/text-button.js'\nimport '@material/web/switch/switch.js'\nimport '@things-factory/setting-base'\n\nimport gql from 'graphql-tag'\nimport { css, html, LitElement, nothing } from 'lit'\nimport { customElement, state } from 'lit/decorators.js'\n\nimport { client } from '@operato/graphql'\nimport { i18next, localize } from '@operato/i18n'\nimport { notify } from '@operato/layout'\n\ntype SecretState = { set: boolean; host?: string }\ntype NoticeChannelSetting = { email: boolean; teamsChannel: SecretState }\n\nconst SETTING = `email teamsChannel { set host }`\n\n/**\n * Where notices go beyond the app and web push in this domain (ADR-0164): mail, and the Teams channel that takes a\n * notice to everyone. Shown to those who may change the domain's attributes; for anyone else the card stays empty. The\n * webhook address goes in and never comes back - the card shows only that it is set, and its host.\n */\n@customElement('notice-channel-setting-let')\nexport class NoticeChannelSettingLet extends localize(i18next)(LitElement) {\n static styles = [\n css`\n div[row] {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-small, 4px);\n border-bottom: var(--border-dim-color);\n margin-bottom: var(--spacing-medium);\n padding-bottom: var(--spacing-medium);\n }\n\n div[row]:last-child {\n border-bottom: none;\n margin-bottom: 0;\n }\n\n label {\n display: flex;\n gap: 10px;\n align-items: center;\n\n font: var(--label-font);\n color: var(--md-sys-color-on-surface);\n text-transform: var(--label-text-transform);\n }\n\n [state] {\n font: var(--input-font);\n color: var(--md-sys-color-on-surface-variant);\n /* a host is data - the card's capitalized labels must not reach it */\n text-transform: none;\n }\n\n div[entry] {\n display: flex;\n gap: var(--spacing-small, 4px);\n align-items: center;\n }\n\n input {\n background-color: var(--input-field-background, var(--md-sys-color-surface-container-highest));\n color: var(--input-field-color, var(--md-sys-color-on-surface));\n border: var(--border-dim-color);\n border-radius: var(--border-radius);\n margin: var(--input-margin);\n padding: var(--input-padding);\n font: var(--input-font);\n\n flex: 1;\n min-width: 0;\n }\n `\n ]\n\n @state() setting?: NoticeChannelSetting\n @state() entries: { teamsChannelWebhook: string } = { teamsChannelWebhook: '' }\n @state() busy = false\n\n render() {\n const setting = this.setting\n if (!setting) return nothing\n\n return html`\n <setting-let>\n <span slot=\"title\">${i18next.t('title.notice channels')}</span>\n\n <div slot=\"content\" row>\n <label>\n <md-switch ?selected=${setting.email} ?disabled=${this.busy} @change=${e => this.save({ email: e.target.selected })}></md-switch>\n ${i18next.t('label.notice by email')}\n </label>\n </div>\n\n ${this.webhookRow('teamsChannelWebhook', i18next.t('label.teams channel webhook'), setting.teamsChannel)}\n </setting-let>\n `\n }\n\n private webhookRow(field: 'teamsChannelWebhook', label: string, secret: SecretState) {\n const entry = this.entries[field]\n\n return html`\n <div slot=\"content\" row>\n <label>${label}</label>\n <span state>\n ${secret.set ? i18next.t('text.notice webhook set', { host: secret.host || '-' }) : i18next.t('text.notice webhook not set')}\n </span>\n <div entry>\n <input\n type=\"url\"\n placeholder=\"https://\"\n autocomplete=\"off\"\n .value=${entry}\n @input=${e => (this.entries = { ...this.entries, [field]: e.target.value })}\n />\n <md-outlined-button ?disabled=${this.busy || !entry.trim()} @click=${() => this.save({ [field]: entry.trim() })}\n >${i18next.t('button.notice webhook save')}</md-outlined-button\n >\n ${secret.set\n ? html`<md-text-button ?disabled=${this.busy} @click=${() => this.save({ [field]: '' })}\n >${i18next.t('button.notice webhook clear')}</md-text-button\n >`\n : nothing}\n </div>\n </div>\n `\n }\n\n async connectedCallback() {\n super.connectedCallback()\n\n const response = await client\n .query({ query: gql`query { noticeChannelSetting { ${SETTING} } }`, fetchPolicy: 'no-cache', errorPolicy: 'all' })\n .catch(() => undefined)\n\n /* refused - this person may not change the domain's attributes, so the card stays empty */\n this.setting = response?.data?.noticeChannelSetting || undefined\n }\n\n private async save(patch: Record<string, unknown>) {\n this.busy = true\n try {\n const response = await client.mutate({\n mutation: gql`mutation ($patch: NoticeChannelSettingPatch!) { updateNoticeChannelSetting(patch: $patch) { ${SETTING} } }`,\n variables: { patch },\n errorPolicy: 'all'\n })\n\n if (response.errors?.length) {\n notify({ level: 'error', message: response.errors[0].message })\n this.requestUpdate()\n return\n }\n\n this.setting = response.data.updateNoticeChannelSetting\n const cleared = Object.keys(patch).filter(field => field in this.entries)\n if (cleared.length) this.entries = { ...this.entries, ...Object.fromEntries(cleared.map(field => [field, ''])) }\n notify({ level: 'info', message: i18next.t('text.notice channels saved') })\n } catch (error) {\n notify({ level: 'error', message: String((error as any)?.message || error) })\n } finally {\n this.busy = false\n }\n }\n}\n"]}
|
|
@@ -3,4 +3,5 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
const tslib_1 = require("tslib");
|
|
4
4
|
tslib_1.__exportStar(require("./fcm"), exports);
|
|
5
5
|
tslib_1.__exportStar(require("./receivers"), exports);
|
|
6
|
+
tslib_1.__exportStar(require("./notice"), exports);
|
|
6
7
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/controllers/index.ts"],"names":[],"mappings":";;;AAAA,gDAAqB;AACrB,sDAA2B","sourcesContent":["export * from './fcm'\nexport * from './receivers'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/controllers/index.ts"],"names":[],"mappings":";;;AAAA,gDAAqB;AACrB,sDAA2B;AAC3B,mDAAwB","sourcesContent":["export * from './fcm'\nexport * from './receivers'\nexport * from './notice'\n"]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type NoticeWords } from './notice-channels.js';
|
|
2
|
+
export type NoticeReceiver = {
|
|
3
|
+
id: string;
|
|
4
|
+
email?: string | null;
|
|
5
|
+
locale?: string | null;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* A notice, settled by the module whose event it tells of - who, what and where. The words are asked per language,
|
|
9
|
+
* so each person reads their own.
|
|
10
|
+
*/
|
|
11
|
+
export type Notice = {
|
|
12
|
+
/** What kind of thing it tells of - `approval`, `announcement`. The in-app notice carries it. */
|
|
13
|
+
type: string;
|
|
14
|
+
level?: 'info' | 'warn';
|
|
15
|
+
receivers: NoticeReceiver[];
|
|
16
|
+
words: (lng: string) => NoticeWords;
|
|
17
|
+
/** The page, relative to the domain - what the in-app notice opens. */
|
|
18
|
+
address: string;
|
|
19
|
+
/** The same page as a full address for mail and Teams; absent when no request said where we are. */
|
|
20
|
+
link?: string;
|
|
21
|
+
/** The language of a person who has not chosen one. */
|
|
22
|
+
defaultLanguage?: string;
|
|
23
|
+
/** Names the notice in the log. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** How `words().body` is written. Markdown is rendered in the mail; Teams and push take it as text. */
|
|
26
|
+
format?: 'text' | 'markdown';
|
|
27
|
+
/**
|
|
28
|
+
* The channels this notice may take beyond the app, when its sender chooses per notice (an announcement's
|
|
29
|
+
* "send by mail too"). Left out, it takes every channel the domain turned on; a channel the domain has not turned on
|
|
30
|
+
* is never taken.
|
|
31
|
+
*/
|
|
32
|
+
channels?: {
|
|
33
|
+
email?: boolean;
|
|
34
|
+
teams?: boolean;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Said to everyone in the domain (an announcement to the whole domain). The app, push and mail still go to each
|
|
38
|
+
* person; Teams posts once to the domain's channel webhook, in the default language. Only a broadcast goes to Teams -
|
|
39
|
+
* a notice to some people never lands where everyone reads.
|
|
40
|
+
*/
|
|
41
|
+
broadcast?: boolean;
|
|
42
|
+
};
|
|
43
|
+
/** What went out - mail counted per person, the Teams channel post once. A sender that keeps a record stores this. */
|
|
44
|
+
export type NoticeResult = {
|
|
45
|
+
receivers: number;
|
|
46
|
+
email: {
|
|
47
|
+
sent: number;
|
|
48
|
+
failed: number;
|
|
49
|
+
};
|
|
50
|
+
teams: {
|
|
51
|
+
sent: number;
|
|
52
|
+
failed: number;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
export type NoticeChannelFunctions = {
|
|
56
|
+
/** The domain's attributes (EnvVar) - read when the notice goes. */
|
|
57
|
+
attributes: () => Promise<Record<string, string | undefined>>;
|
|
58
|
+
/** notification's `notify` for the domain; absent, nothing goes in the app. */
|
|
59
|
+
push?: (payload: any) => unknown;
|
|
60
|
+
/** email-base's `sendEmail` - answers the transport's receipt, nothing when it failed. */
|
|
61
|
+
mail: (mail: {
|
|
62
|
+
receiver: string;
|
|
63
|
+
subject: string;
|
|
64
|
+
content: string;
|
|
65
|
+
}) => Promise<unknown>;
|
|
66
|
+
/** Posts JSON to the Teams webhook and answers whether it took it. */
|
|
67
|
+
teams: (url: string, body: unknown) => Promise<{
|
|
68
|
+
ok: boolean;
|
|
69
|
+
status?: number;
|
|
70
|
+
}>;
|
|
71
|
+
report: (message: string, error?: unknown) => void;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Tells each receiver in the app and by web push always, and by mail when the domain turned it on; a broadcast is
|
|
75
|
+
* also posted once to the domain's Teams channel. A channel that fails is reported with its name and the others still
|
|
76
|
+
* go - nothing here throws, because what it tells of has already happened.
|
|
77
|
+
*/
|
|
78
|
+
export declare function deliverNotice(notice: Notice, channels: NoticeChannelFunctions): Promise<NoticeResult>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deliverNotice = deliverNotice;
|
|
4
|
+
/*
|
|
5
|
+
* Sending one notice down each channel (ADR-0164). The channels come in as functions, so a test hands in fakes and
|
|
6
|
+
* sees what each one was given; send-notice.ts hands in the real ones.
|
|
7
|
+
*/
|
|
8
|
+
const notice_channels_js_1 = require("./notice-channels.js");
|
|
9
|
+
const notice_email_js_1 = require("./notice-email.js");
|
|
10
|
+
/** The domain's own address - the notice's full link without its page. */
|
|
11
|
+
function baseOf(notice) {
|
|
12
|
+
return notice.link && notice.link.endsWith(notice.address) ? notice.link.slice(0, notice.link.length - notice.address.length) : undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Tells each receiver in the app and by web push always, and by mail when the domain turned it on; a broadcast is
|
|
16
|
+
* also posted once to the domain's Teams channel. A channel that fails is reported with its name and the others still
|
|
17
|
+
* go - nothing here throws, because what it tells of has already happened.
|
|
18
|
+
*/
|
|
19
|
+
async function deliverNotice(notice, channels) {
|
|
20
|
+
const result = { receivers: notice.receivers.length, email: { sent: 0, failed: 0 }, teams: { sent: 0, failed: 0 } };
|
|
21
|
+
if (!notice.receivers.length)
|
|
22
|
+
return result;
|
|
23
|
+
let turnedOn = { email: false };
|
|
24
|
+
try {
|
|
25
|
+
turnedOn = (0, notice_channels_js_1.noticeChannels)(await channels.attributes());
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
channels.report(`${notice.label}: the domain's notice channels could not be read - in the app only`, error);
|
|
29
|
+
}
|
|
30
|
+
const email = turnedOn.email && notice.channels?.email !== false;
|
|
31
|
+
const teamsChannelWebhook = notice.broadcast && notice.channels?.teams !== false ? turnedOn.teamsChannelWebhook : undefined;
|
|
32
|
+
for (const receiver of notice.receivers) {
|
|
33
|
+
const lng = (0, notice_channels_js_1.languageOf)(receiver.locale, notice.defaultLanguage);
|
|
34
|
+
let words;
|
|
35
|
+
try {
|
|
36
|
+
words = notice.words(lng);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
channels.report(`${notice.label}: the words for ${receiver.id} could not be made`, error);
|
|
40
|
+
if (email && receiver.email)
|
|
41
|
+
result.email.failed++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (channels.push) {
|
|
45
|
+
for (const mode of ['foreground', 'background']) {
|
|
46
|
+
try {
|
|
47
|
+
await channels.push({
|
|
48
|
+
receivers: [receiver.id],
|
|
49
|
+
type: notice.type,
|
|
50
|
+
subject: notice.level || 'info',
|
|
51
|
+
title: words.subject,
|
|
52
|
+
body: words.summary ?? words.body,
|
|
53
|
+
url: notice.address,
|
|
54
|
+
mode
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
channels.report(`${notice.label}: the ${mode} notice to ${receiver.id} failed`, error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (email && receiver.email) {
|
|
63
|
+
try {
|
|
64
|
+
const sent = await channels.mail({
|
|
65
|
+
receiver: receiver.email,
|
|
66
|
+
subject: words.subject,
|
|
67
|
+
content: (0, notice_email_js_1.noticeEmailForm)({ lang: lng, words, link: notice.link, format: notice.format, base: baseOf(notice) })
|
|
68
|
+
});
|
|
69
|
+
if (sent) {
|
|
70
|
+
result.email.sent++;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
result.email.failed++;
|
|
74
|
+
channels.report(`${notice.label}: the mail to ${receiver.email} did not go out`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
result.email.failed++;
|
|
79
|
+
channels.report(`${notice.label}: the mail to ${receiver.email} failed`, error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (teamsChannelWebhook) {
|
|
84
|
+
try {
|
|
85
|
+
const answer = await channels.teams(teamsChannelWebhook, (0, notice_channels_js_1.teamsMessage)({ words: notice.words((0, notice_channels_js_1.languageOf)(undefined, notice.defaultLanguage)), link: notice.link }));
|
|
86
|
+
if (answer.ok) {
|
|
87
|
+
result.teams.sent++;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
result.teams.failed++;
|
|
91
|
+
channels.report(`${notice.label}: Teams refused the channel post (${answer.status ?? '-'})`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
result.teams.failed++;
|
|
96
|
+
channels.report(`${notice.label}: the Teams channel post failed`, error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=deliver-notice.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deliver-notice.js","sourceRoot":"","sources":["../../../server/controllers/notice/deliver-notice.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Sending one notice down each channel (ADR-0164). The channels come in as functions, so a test hands in fakes and\n * sees what each one was given; send-notice.ts hands in the real ones.\n */\nimport { languageOf, noticeChannels, teamsMessage, type NoticeWords } from './notice-channels.js'\nimport { noticeEmailForm } from './notice-email.js'\n\nexport type NoticeReceiver = { id: string; email?: string | null; locale?: string | null }\n\n/**\n * A notice, settled by the module whose event it tells of - who, what and where. The words are asked per language,\n * so each person reads their own.\n */\nexport type Notice = {\n /** What kind of thing it tells of - `approval`, `announcement`. The in-app notice carries it. */\n type: string\n level?: 'info' | 'warn'\n receivers: NoticeReceiver[]\n words: (lng: string) => NoticeWords\n /** The page, relative to the domain - what the in-app notice opens. */\n address: string\n /** The same page as a full address for mail and Teams; absent when no request said where we are. */\n link?: string\n /** The language of a person who has not chosen one. */\n defaultLanguage?: string\n /** Names the notice in the log. */\n label: string\n /** How `words().body` is written. Markdown is rendered in the mail; Teams and push take it as text. */\n format?: 'text' | 'markdown'\n /**\n * The channels this notice may take beyond the app, when its sender chooses per notice (an announcement's\n * \"send by mail too\"). Left out, it takes every channel the domain turned on; a channel the domain has not turned on\n * is never taken.\n */\n channels?: { email?: boolean; teams?: boolean }\n /**\n * Said to everyone in the domain (an announcement to the whole domain). The app, push and mail still go to each\n * person; Teams posts once to the domain's channel webhook, in the default language. Only a broadcast goes to Teams -\n * a notice to some people never lands where everyone reads.\n */\n broadcast?: boolean\n}\n\n/** What went out - mail counted per person, the Teams channel post once. A sender that keeps a record stores this. */\nexport type NoticeResult = {\n receivers: number\n email: { sent: number; failed: number }\n teams: { sent: number; failed: number }\n}\n\nexport type NoticeChannelFunctions = {\n /** The domain's attributes (EnvVar) - read when the notice goes. */\n attributes: () => Promise<Record<string, string | undefined>>\n /** notification's `notify` for the domain; absent, nothing goes in the app. */\n push?: (payload: any) => unknown\n /** email-base's `sendEmail` - answers the transport's receipt, nothing when it failed. */\n mail: (mail: { receiver: string; subject: string; content: string }) => Promise<unknown>\n /** Posts JSON to the Teams webhook and answers whether it took it. */\n teams: (url: string, body: unknown) => Promise<{ ok: boolean; status?: number }>\n report: (message: string, error?: unknown) => void\n}\n\n/** The domain's own address - the notice's full link without its page. */\nfunction baseOf(notice: Notice): string | undefined {\n return notice.link && notice.link.endsWith(notice.address) ? notice.link.slice(0, notice.link.length - notice.address.length) : undefined\n}\n\n/**\n * Tells each receiver in the app and by web push always, and by mail when the domain turned it on; a broadcast is\n * also posted once to the domain's Teams channel. A channel that fails is reported with its name and the others still\n * go - nothing here throws, because what it tells of has already happened.\n */\nexport async function deliverNotice(notice: Notice, channels: NoticeChannelFunctions): Promise<NoticeResult> {\n const result: NoticeResult = { receivers: notice.receivers.length, email: { sent: 0, failed: 0 }, teams: { sent: 0, failed: 0 } }\n if (!notice.receivers.length) return result\n\n let turnedOn: ReturnType<typeof noticeChannels> = { email: false }\n try {\n turnedOn = noticeChannels(await channels.attributes())\n } catch (error) {\n channels.report(`${notice.label}: the domain's notice channels could not be read - in the app only`, error)\n }\n\n const email = turnedOn.email && notice.channels?.email !== false\n const teamsChannelWebhook = notice.broadcast && notice.channels?.teams !== false ? turnedOn.teamsChannelWebhook : undefined\n\n for (const receiver of notice.receivers) {\n const lng = languageOf(receiver.locale, notice.defaultLanguage)\n let words: NoticeWords\n try {\n words = notice.words(lng)\n } catch (error) {\n channels.report(`${notice.label}: the words for ${receiver.id} could not be made`, error)\n if (email && receiver.email) result.email.failed++\n continue\n }\n\n if (channels.push) {\n for (const mode of ['foreground', 'background'] as const) {\n try {\n await channels.push({\n receivers: [receiver.id],\n type: notice.type,\n subject: notice.level || 'info',\n title: words.subject,\n body: words.summary ?? words.body,\n url: notice.address,\n mode\n })\n } catch (error) {\n channels.report(`${notice.label}: the ${mode} notice to ${receiver.id} failed`, error)\n }\n }\n }\n\n if (email && receiver.email) {\n try {\n const sent = await channels.mail({\n receiver: receiver.email,\n subject: words.subject,\n content: noticeEmailForm({ lang: lng, words, link: notice.link, format: notice.format, base: baseOf(notice) })\n })\n if (sent) {\n result.email.sent++\n } else {\n result.email.failed++\n channels.report(`${notice.label}: the mail to ${receiver.email} did not go out`)\n }\n } catch (error) {\n result.email.failed++\n channels.report(`${notice.label}: the mail to ${receiver.email} failed`, error)\n }\n }\n }\n\n if (teamsChannelWebhook) {\n try {\n const answer = await channels.teams(teamsChannelWebhook, teamsMessage({ words: notice.words(languageOf(undefined, notice.defaultLanguage)), link: notice.link }))\n if (answer.ok) {\n result.teams.sent++\n } else {\n result.teams.failed++\n channels.report(`${notice.label}: Teams refused the channel post (${answer.status ?? '-'})`)\n }\n } catch (error) {\n result.teams.failed++\n channels.report(`${notice.label}: the Teams channel post failed`, error)\n }\n }\n\n return result\n}\n"]}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
tslib_1.__exportStar(require("./notice-channels.js"), exports);
|
|
5
|
+
tslib_1.__exportStar(require("./notice-email.js"), exports);
|
|
6
|
+
tslib_1.__exportStar(require("./deliver-notice.js"), exports);
|
|
7
|
+
tslib_1.__exportStar(require("./send-notice.js"), exports);
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/controllers/notice/index.ts"],"names":[],"mappings":";;;AAAA,+DAAoC;AACpC,4DAAiC;AACjC,8DAAmC;AACnC,2DAAgC","sourcesContent":["export * from './notice-channels.js'\nexport * from './notice-email.js'\nexport * from './deliver-notice.js'\nexport * from './send-notice.js'\n"]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** The domain attributes (EnvVar) that turn the channels on. Web push needs none. */
|
|
2
|
+
export declare const NOTICE_EMAIL = "Notice::email";
|
|
3
|
+
/** The Workflows webhook that posts to one Teams channel - where a notice to everyone in the domain goes. */
|
|
4
|
+
export declare const NOTICE_TEAMS_CHANNEL = "Notice::teamsChannelWebhook";
|
|
5
|
+
export declare const NOTICE_ATTRIBUTE_PREFIX = "Notice::";
|
|
6
|
+
/**
|
|
7
|
+
* Which channels a domain has turned on. Mail goes when the domain says `on` - a server carries several domains,
|
|
8
|
+
* and a development server carries real accounts. Teams goes to one channel when the domain holds the address of the
|
|
9
|
+
* channel's Workflows webhook; anything but an https address is not one. There is no Teams to each person: its flow
|
|
10
|
+
* needed expressions typed into the Workflows designer, and the user dropped it (10-26) - a person is told by mail.
|
|
11
|
+
*/
|
|
12
|
+
export declare function noticeChannels(attributes: Record<string, string | undefined> | null | undefined): {
|
|
13
|
+
email: boolean;
|
|
14
|
+
teamsChannelWebhook?: string;
|
|
15
|
+
};
|
|
16
|
+
/** `ko-KR` → `ko`: the server's translations are kept by language, not by region. */
|
|
17
|
+
export declare function languageOf(locale: string | null | undefined, fallback: string | null | undefined): string;
|
|
18
|
+
/**
|
|
19
|
+
* What a notice says to one person, in their language. `note` stands in for the button when there is no link.
|
|
20
|
+
* `summary` is what the in-app notice and web push show when the body is long (an announcement's whole text goes by
|
|
21
|
+
* mail); left out, they show the body.
|
|
22
|
+
*/
|
|
23
|
+
export type NoticeWords = {
|
|
24
|
+
subject: string;
|
|
25
|
+
body: string;
|
|
26
|
+
action: string;
|
|
27
|
+
note: string;
|
|
28
|
+
summary?: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The body posted to the Teams channel's Workflows webhook: a Teams message with one Adaptive Card, which the channel
|
|
32
|
+
* template posts as it is given.
|
|
33
|
+
*/
|
|
34
|
+
export declare function teamsMessage({ words, link }: {
|
|
35
|
+
words: NoticeWords;
|
|
36
|
+
link?: string;
|
|
37
|
+
}): {
|
|
38
|
+
type: string;
|
|
39
|
+
summary: string;
|
|
40
|
+
attachments: {
|
|
41
|
+
contentType: string;
|
|
42
|
+
contentUrl: any;
|
|
43
|
+
content: {
|
|
44
|
+
$schema: string;
|
|
45
|
+
type: string;
|
|
46
|
+
version: string;
|
|
47
|
+
body: ({
|
|
48
|
+
type: string;
|
|
49
|
+
text: string;
|
|
50
|
+
weight: string;
|
|
51
|
+
size: string;
|
|
52
|
+
wrap: boolean;
|
|
53
|
+
} | {
|
|
54
|
+
type: string;
|
|
55
|
+
text: string;
|
|
56
|
+
wrap: boolean;
|
|
57
|
+
weight?: undefined;
|
|
58
|
+
size?: undefined;
|
|
59
|
+
})[];
|
|
60
|
+
actions: {
|
|
61
|
+
type: string;
|
|
62
|
+
title: string;
|
|
63
|
+
url: string;
|
|
64
|
+
}[];
|
|
65
|
+
};
|
|
66
|
+
}[];
|
|
67
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* The channels a notice goes down (ADR-0164) - which ones a domain has turned on, the language a person reads, and
|
|
4
|
+
* the Teams message. Plain functions over plain values, so a test holds them without a database.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.NOTICE_ATTRIBUTE_PREFIX = exports.NOTICE_TEAMS_CHANNEL = exports.NOTICE_EMAIL = void 0;
|
|
8
|
+
exports.noticeChannels = noticeChannels;
|
|
9
|
+
exports.languageOf = languageOf;
|
|
10
|
+
exports.teamsMessage = teamsMessage;
|
|
11
|
+
/** The domain attributes (EnvVar) that turn the channels on. Web push needs none. */
|
|
12
|
+
exports.NOTICE_EMAIL = 'Notice::email';
|
|
13
|
+
/** The Workflows webhook that posts to one Teams channel - where a notice to everyone in the domain goes. */
|
|
14
|
+
exports.NOTICE_TEAMS_CHANNEL = 'Notice::teamsChannelWebhook';
|
|
15
|
+
exports.NOTICE_ATTRIBUTE_PREFIX = 'Notice::';
|
|
16
|
+
/**
|
|
17
|
+
* Which channels a domain has turned on. Mail goes when the domain says `on` - a server carries several domains,
|
|
18
|
+
* and a development server carries real accounts. Teams goes to one channel when the domain holds the address of the
|
|
19
|
+
* channel's Workflows webhook; anything but an https address is not one. There is no Teams to each person: its flow
|
|
20
|
+
* needed expressions typed into the Workflows designer, and the user dropped it (10-26) - a person is told by mail.
|
|
21
|
+
*/
|
|
22
|
+
function noticeChannels(attributes) {
|
|
23
|
+
const email = String(attributes?.[exports.NOTICE_EMAIL] ?? '').trim().toLowerCase() === 'on';
|
|
24
|
+
const https = (name) => {
|
|
25
|
+
const webhook = String(attributes?.[name] ?? '').trim();
|
|
26
|
+
return /^https:\/\//i.test(webhook) ? webhook : undefined;
|
|
27
|
+
};
|
|
28
|
+
return { email, teamsChannelWebhook: https(exports.NOTICE_TEAMS_CHANNEL) };
|
|
29
|
+
}
|
|
30
|
+
/** `ko-KR` → `ko`: the server's translations are kept by language, not by region. */
|
|
31
|
+
function languageOf(locale, fallback) {
|
|
32
|
+
return String(locale || fallback || 'en')
|
|
33
|
+
.split(/[-_]/)[0]
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The body posted to the Teams channel's Workflows webhook: a Teams message with one Adaptive Card, which the channel
|
|
38
|
+
* template posts as it is given.
|
|
39
|
+
*/
|
|
40
|
+
function teamsMessage({ words, link }) {
|
|
41
|
+
return {
|
|
42
|
+
type: 'message',
|
|
43
|
+
summary: words.subject,
|
|
44
|
+
attachments: [
|
|
45
|
+
{
|
|
46
|
+
contentType: 'application/vnd.microsoft.card.adaptive',
|
|
47
|
+
contentUrl: null,
|
|
48
|
+
content: {
|
|
49
|
+
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
50
|
+
type: 'AdaptiveCard',
|
|
51
|
+
version: '1.4',
|
|
52
|
+
body: [
|
|
53
|
+
{ type: 'TextBlock', text: words.subject, weight: 'Bolder', size: 'Medium', wrap: true },
|
|
54
|
+
{ type: 'TextBlock', text: link ? words.body : `${words.body}\n\n${words.note}`, wrap: true }
|
|
55
|
+
],
|
|
56
|
+
actions: link ? [{ type: 'Action.OpenUrl', title: words.action, url: link }] : []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=notice-channels.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notice-channels.js","sourceRoot":"","sources":["../../../server/controllers/notice/notice-channels.ts"],"names":[],"mappings":";AAAA;;;GAGG;;;AAcH,wCAWC;AAGD,gCAIC;AAaD,oCAqBC;AAhED,qFAAqF;AACxE,QAAA,YAAY,GAAG,eAAe,CAAA;AAC3C,6GAA6G;AAChG,QAAA,oBAAoB,GAAG,6BAA6B,CAAA;AACpD,QAAA,uBAAuB,GAAG,UAAU,CAAA;AAEjD;;;;;GAKG;AACH,SAAgB,cAAc,CAAC,UAAiE;IAI9F,MAAM,KAAK,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,oBAAY,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,IAAI,CAAA;IACpF,MAAM,KAAK,GAAG,CAAC,IAAY,EAAE,EAAE;QAC7B,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;QACvD,OAAO,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;IAC3D,CAAC,CAAA;IAED,OAAO,EAAE,KAAK,EAAE,mBAAmB,EAAE,KAAK,CAAC,4BAAoB,CAAC,EAAE,CAAA;AACpE,CAAC;AAED,qFAAqF;AACrF,SAAgB,UAAU,CAAC,MAAiC,EAAE,QAAmC;IAC/F,OAAO,MAAM,CAAC,MAAM,IAAI,QAAQ,IAAI,IAAI,CAAC;SACtC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;SAChB,WAAW,EAAE,CAAA;AAClB,CAAC;AASD;;;GAGG;AACH,SAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,IAAI,EAAyC;IACjF,OAAO;QACL,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,WAAW,EAAE;YACX;gBACE,WAAW,EAAE,yCAAyC;gBACtD,UAAU,EAAE,IAAI;gBAChB,OAAO,EAAE;oBACP,OAAO,EAAE,oDAAoD;oBAC7D,IAAI,EAAE,cAAc;oBACpB,OAAO,EAAE,KAAK;oBACd,IAAI,EAAE;wBACJ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE;wBACxF,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,OAAO,KAAK,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;qBAC9F;oBACD,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE;iBAClF;aACF;SACF;KACF,CAAA;AACH,CAAC","sourcesContent":["/*\n * The channels a notice goes down (ADR-0164) - which ones a domain has turned on, the language a person reads, and\n * the Teams message. Plain functions over plain values, so a test holds them without a database.\n */\n\n/** The domain attributes (EnvVar) that turn the channels on. Web push needs none. */\nexport const NOTICE_EMAIL = 'Notice::email'\n/** The Workflows webhook that posts to one Teams channel - where a notice to everyone in the domain goes. */\nexport const NOTICE_TEAMS_CHANNEL = 'Notice::teamsChannelWebhook'\nexport const NOTICE_ATTRIBUTE_PREFIX = 'Notice::'\n\n/**\n * Which channels a domain has turned on. Mail goes when the domain says `on` - a server carries several domains,\n * and a development server carries real accounts. Teams goes to one channel when the domain holds the address of the\n * channel's Workflows webhook; anything but an https address is not one. There is no Teams to each person: its flow\n * needed expressions typed into the Workflows designer, and the user dropped it (10-26) - a person is told by mail.\n */\nexport function noticeChannels(attributes: Record<string, string | undefined> | null | undefined): {\n email: boolean\n teamsChannelWebhook?: string\n} {\n const email = String(attributes?.[NOTICE_EMAIL] ?? '').trim().toLowerCase() === 'on'\n const https = (name: string) => {\n const webhook = String(attributes?.[name] ?? '').trim()\n return /^https:\\/\\//i.test(webhook) ? webhook : undefined\n }\n\n return { email, teamsChannelWebhook: https(NOTICE_TEAMS_CHANNEL) }\n}\n\n/** `ko-KR` → `ko`: the server's translations are kept by language, not by region. */\nexport function languageOf(locale: string | null | undefined, fallback: string | null | undefined): string {\n return String(locale || fallback || 'en')\n .split(/[-_]/)[0]\n .toLowerCase()\n}\n\n/**\n * What a notice says to one person, in their language. `note` stands in for the button when there is no link.\n * `summary` is what the in-app notice and web push show when the body is long (an announcement's whole text goes by\n * mail); left out, they show the body.\n */\nexport type NoticeWords = { subject: string; body: string; action: string; note: string; summary?: string }\n\n/**\n * The body posted to the Teams channel's Workflows webhook: a Teams message with one Adaptive Card, which the channel\n * template posts as it is given.\n */\nexport function teamsMessage({ words, link }: { words: NoticeWords; link?: string }) {\n return {\n type: 'message',\n summary: words.subject,\n attachments: [\n {\n contentType: 'application/vnd.microsoft.card.adaptive',\n contentUrl: null,\n content: {\n $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',\n type: 'AdaptiveCard',\n version: '1.4',\n body: [\n { type: 'TextBlock', text: words.subject, weight: 'Bolder', size: 'Medium', wrap: true },\n { type: 'TextBlock', text: link ? words.body : `${words.body}\\n\\n${words.note}`, wrap: true }\n ],\n actions: link ? [{ type: 'Action.OpenUrl', title: words.action, url: link }] : []\n }\n }\n ]\n }\n}\n"]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { NoticeWords } from './notice-channels.js';
|
|
2
|
+
/** Every value goes into the HTML through this - a subject like `<b>` or `A&B` stays text. */
|
|
3
|
+
export declare function escapeHtml(value: unknown): string;
|
|
4
|
+
/**
|
|
5
|
+
* Where a link in a notice may go: http(s) and mailto as written, and an address of the app (no scheme) under `base`,
|
|
6
|
+
* the domain's own address. With no base an app address has nowhere to go and is dropped, as is every other scheme -
|
|
7
|
+
* `javascript:` in any spelling the URL parser reads as one.
|
|
8
|
+
*/
|
|
9
|
+
export declare function safeNoticeHref(href: string | null | undefined, base?: string): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Markdown as mail HTML, rendered here on the server. Raw HTML in the text is shown as text, never as markup; a link or
|
|
12
|
+
* an image becomes a link only to an address `safeNoticeHref` allows, and an image is not loaded - it is shown as a
|
|
13
|
+
* link with its alt text, so a mail does not fetch from wherever its writer pointed.
|
|
14
|
+
*/
|
|
15
|
+
export declare function renderNoticeMarkdown(markdown: string, base?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* The notice mail (ADR-0164) - the head, band, button and foot of the auth mails, so a person knows the sender.
|
|
18
|
+
* Without a link (a notice sent with no request to take the address from) the button gives way to the note.
|
|
19
|
+
*/
|
|
20
|
+
export declare function noticeEmailForm({ lang, words, link, format, base }: {
|
|
21
|
+
lang: string;
|
|
22
|
+
words: NoticeWords;
|
|
23
|
+
link?: string;
|
|
24
|
+
/** How the body is written - an announcement's is Markdown. */
|
|
25
|
+
format?: 'text' | 'markdown';
|
|
26
|
+
/** The domain's address, under which an app address in a Markdown body is resolved. */
|
|
27
|
+
base?: string;
|
|
28
|
+
}): string;
|