@tbs-fitness/contract 0.3.0 → 0.4.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/README.md +11 -0
- package/dist/cjs/app-settings/app-settings.dto.d.ts +124 -0
- package/dist/cjs/app-settings/app-settings.dto.js +94 -0
- package/dist/cjs/app-settings/index.d.ts +1 -0
- package/dist/cjs/app-settings/index.js +17 -0
- package/dist/cjs/communication/index.d.ts +2 -0
- package/dist/cjs/communication/index.js +18 -0
- package/dist/cjs/communication/message-catalogue.d.ts +26 -0
- package/dist/cjs/communication/message-catalogue.js +53 -0
- package/dist/cjs/communication/template.dto.d.ts +240 -0
- package/dist/cjs/communication/template.dto.js +206 -0
- package/dist/cjs/enums/index.d.ts +19 -0
- package/dist/cjs/enums/index.js +26 -1
- package/dist/esm/app-settings/app-settings.dto.d.ts +124 -0
- package/dist/esm/app-settings/app-settings.dto.js +90 -0
- package/dist/esm/app-settings/index.d.ts +1 -0
- package/dist/esm/app-settings/index.js +1 -0
- package/dist/esm/communication/index.d.ts +2 -0
- package/dist/esm/communication/index.js +2 -0
- package/dist/esm/communication/message-catalogue.d.ts +26 -0
- package/dist/esm/communication/message-catalogue.js +50 -0
- package/dist/esm/communication/template.dto.d.ts +240 -0
- package/dist/esm/communication/template.dto.js +200 -0
- package/dist/esm/enums/index.d.ts +19 -0
- package/dist/esm/enums/index.js +25 -0
- package/package.json +21 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.messageTypeListResponseSchema = exports.messageTypeResponseSchema = exports.testSendResponseSchema = exports.renderedMessageSchema = exports.previewTemplateSchema = exports.templateListResponseSchema = exports.templateResponseSchema = exports.templateListQuerySchema = exports.createTemplateSchema = exports.templateVariableSchema = void 0;
|
|
4
|
+
exports.templatePlaceholders = templatePlaceholders;
|
|
5
|
+
exports.hasMalformedPlaceholder = hasMalformedPlaceholder;
|
|
6
|
+
exports.templateProblems = templateProblems;
|
|
7
|
+
const zod_1 = require("zod");
|
|
8
|
+
const date_time_schema_js_1 = require("../common/date-time.schema.js");
|
|
9
|
+
const safe_text_schema_js_1 = require("../common/safe-text.schema.js");
|
|
10
|
+
const index_js_1 = require("../enums/index.js");
|
|
11
|
+
const message_catalogue_js_1 = require("./message-catalogue.js");
|
|
12
|
+
const PLACEHOLDER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
|
13
|
+
/** The variable names used as `{{name}}` in a piece of template text, in order, without repeats. */
|
|
14
|
+
function templatePlaceholders(text) {
|
|
15
|
+
const names = new Set();
|
|
16
|
+
for (const match of text.matchAll(PLACEHOLDER)) {
|
|
17
|
+
names.add(match[1]);
|
|
18
|
+
}
|
|
19
|
+
return [...names];
|
|
20
|
+
}
|
|
21
|
+
/** True when the text has a `{{` or `}}` that is not part of a valid placeholder. */
|
|
22
|
+
function hasMalformedPlaceholder(text) {
|
|
23
|
+
const rest = text.replace(PLACEHOLDER, '');
|
|
24
|
+
return rest.includes('{{') || rest.includes('}}');
|
|
25
|
+
}
|
|
26
|
+
const MAX_SUBJECT_LENGTH = 200;
|
|
27
|
+
const MAX_BODY_LENGTH = 20000;
|
|
28
|
+
/** A variable a template declares. Its value is supplied when the message is sent. */
|
|
29
|
+
exports.templateVariableSchema = zod_1.z.strictObject({
|
|
30
|
+
name: zod_1.z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^[A-Za-z][A-Za-z0-9_]*$/, {
|
|
33
|
+
error: 'Use letters, digits and underscores, starting with a letter',
|
|
34
|
+
})
|
|
35
|
+
.max(64),
|
|
36
|
+
description: (0, safe_text_schema_js_1.safeTextSchema)(200).optional(),
|
|
37
|
+
example: zod_1.z.string().max(500).optional(),
|
|
38
|
+
});
|
|
39
|
+
const templateFields = {
|
|
40
|
+
messageType: zod_1.z.enum(index_js_1.MESSAGE_TYPES),
|
|
41
|
+
channel: zod_1.z.enum(index_js_1.COMMUNICATION_CHANNELS),
|
|
42
|
+
name: (0, safe_text_schema_js_1.safeTextSchema)(100).pipe(zod_1.z.string().min(1, { error: 'Name it' })),
|
|
43
|
+
subject: zod_1.z.string().max(MAX_SUBJECT_LENGTH).nullish(),
|
|
44
|
+
bodyText: zod_1.z
|
|
45
|
+
.string()
|
|
46
|
+
.min(1, { error: 'Write the message' })
|
|
47
|
+
.max(MAX_BODY_LENGTH),
|
|
48
|
+
bodyHtml: zod_1.z.string().max(MAX_BODY_LENGTH).nullish(),
|
|
49
|
+
variables: zod_1.z.array(exports.templateVariableSchema).max(20),
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The rules every template must meet before it is saved: the declared
|
|
53
|
+
* variables match the placeholders exactly, every one is supplied by the
|
|
54
|
+
* message type, and the type's required variables are used. Shared by the
|
|
55
|
+
* server and the admin editor, so both report the same problems.
|
|
56
|
+
*/
|
|
57
|
+
function templateProblems(template) {
|
|
58
|
+
const problems = [];
|
|
59
|
+
const definition = message_catalogue_js_1.MESSAGE_CATALOGUE[template.messageType];
|
|
60
|
+
const isEmail = template.channel === 'EMAIL';
|
|
61
|
+
if (!definition.channels.includes(template.channel)) {
|
|
62
|
+
problems.push({
|
|
63
|
+
path: 'channel',
|
|
64
|
+
message: `${definition.label} cannot be sent by ${template.channel}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (isEmail && !template.subject?.trim()) {
|
|
68
|
+
problems.push({ path: 'subject', message: 'An email needs a subject' });
|
|
69
|
+
}
|
|
70
|
+
if (!isEmail && (template.subject || template.bodyHtml)) {
|
|
71
|
+
problems.push({
|
|
72
|
+
path: 'bodyHtml',
|
|
73
|
+
message: 'Only email templates have a subject or HTML',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (template.channel === 'SMS' && template.bodyText.length > message_catalogue_js_1.MAX_SMS_LENGTH) {
|
|
77
|
+
problems.push({
|
|
78
|
+
path: 'bodyText',
|
|
79
|
+
message: `An SMS can be at most ${String(message_catalogue_js_1.MAX_SMS_LENGTH)} characters`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const texts = [
|
|
83
|
+
template.subject ?? '',
|
|
84
|
+
template.bodyText,
|
|
85
|
+
template.bodyHtml ?? '',
|
|
86
|
+
];
|
|
87
|
+
if (texts.some(hasMalformedPlaceholder)) {
|
|
88
|
+
problems.push({
|
|
89
|
+
path: 'bodyText',
|
|
90
|
+
message: 'Write placeholders as {{name}}, with matching braces',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const used = new Set(texts.flatMap(templatePlaceholders));
|
|
94
|
+
const declared = template.variables.map((variable) => variable.name);
|
|
95
|
+
const declaredSet = new Set(declared);
|
|
96
|
+
if (declaredSet.size !== declared.length) {
|
|
97
|
+
problems.push({ path: 'variables', message: 'Declare each variable once' });
|
|
98
|
+
}
|
|
99
|
+
for (const name of used) {
|
|
100
|
+
if (!declaredSet.has(name)) {
|
|
101
|
+
problems.push({
|
|
102
|
+
path: 'variables',
|
|
103
|
+
message: `{{${name}}} is used but not declared`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const name of declaredSet) {
|
|
108
|
+
if (!used.has(name)) {
|
|
109
|
+
problems.push({
|
|
110
|
+
path: 'variables',
|
|
111
|
+
message: `${name} is declared but not used`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (!(name in definition.variables)) {
|
|
115
|
+
problems.push({
|
|
116
|
+
path: 'variables',
|
|
117
|
+
message: `${definition.label} does not supply ${name}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const name of definition.requiredVariables) {
|
|
122
|
+
if (!declaredSet.has(name)) {
|
|
123
|
+
problems.push({
|
|
124
|
+
path: 'variables',
|
|
125
|
+
message: `${definition.label} must include {{${name}}}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return problems;
|
|
130
|
+
}
|
|
131
|
+
/** A new draft. Saved templates are never edited; a change is a new version. */
|
|
132
|
+
exports.createTemplateSchema = zod_1.z
|
|
133
|
+
.strictObject(templateFields)
|
|
134
|
+
.superRefine((template, ctx) => {
|
|
135
|
+
for (const problem of templateProblems(template)) {
|
|
136
|
+
ctx.addIssue({
|
|
137
|
+
code: 'custom',
|
|
138
|
+
path: [problem.path],
|
|
139
|
+
message: problem.message,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
exports.templateListQuerySchema = zod_1.z.strictObject({
|
|
144
|
+
messageType: zod_1.z.enum(index_js_1.MESSAGE_TYPES).optional(),
|
|
145
|
+
channel: zod_1.z.enum(index_js_1.COMMUNICATION_CHANNELS).optional(),
|
|
146
|
+
});
|
|
147
|
+
exports.templateResponseSchema = zod_1.z.object({
|
|
148
|
+
id: zod_1.z.uuid(),
|
|
149
|
+
messageType: zod_1.z.enum(index_js_1.MESSAGE_TYPES),
|
|
150
|
+
channel: zod_1.z.enum(index_js_1.COMMUNICATION_CHANNELS),
|
|
151
|
+
version: zod_1.z.number().int(),
|
|
152
|
+
name: zod_1.z.string(),
|
|
153
|
+
subject: zod_1.z.string().nullable(),
|
|
154
|
+
bodyText: zod_1.z.string(),
|
|
155
|
+
bodyHtml: zod_1.z.string().nullable(),
|
|
156
|
+
variables: zod_1.z.array(zod_1.z.object({
|
|
157
|
+
name: zod_1.z.string(),
|
|
158
|
+
description: zod_1.z.string().optional(),
|
|
159
|
+
example: zod_1.z.string().optional(),
|
|
160
|
+
})),
|
|
161
|
+
status: zod_1.z.enum(index_js_1.TEMPLATE_STATUSES),
|
|
162
|
+
createdBy: zod_1.z.uuid().nullable(),
|
|
163
|
+
createdAt: date_time_schema_js_1.utcTimestampSchema,
|
|
164
|
+
activatedBy: zod_1.z.uuid().nullable(),
|
|
165
|
+
activatedAt: date_time_schema_js_1.utcTimestampSchema.nullable(),
|
|
166
|
+
});
|
|
167
|
+
exports.templateListResponseSchema = zod_1.z.array(exports.templateResponseSchema);
|
|
168
|
+
/** Renders unsaved text, or a saved version, with the catalogue's sample values. */
|
|
169
|
+
exports.previewTemplateSchema = zod_1.z.union([
|
|
170
|
+
zod_1.z.strictObject({ templateId: zod_1.z.uuid() }),
|
|
171
|
+
zod_1.z.strictObject(templateFields),
|
|
172
|
+
]);
|
|
173
|
+
exports.renderedMessageSchema = zod_1.z.object({
|
|
174
|
+
channel: zod_1.z.enum(index_js_1.COMMUNICATION_CHANNELS),
|
|
175
|
+
subject: zod_1.z.string().nullable(),
|
|
176
|
+
bodyText: zod_1.z.string(),
|
|
177
|
+
bodyHtml: zod_1.z.string().nullable(),
|
|
178
|
+
/** SMS only: how many 160-character segments it takes. */
|
|
179
|
+
smsSegments: zod_1.z.number().int().nullable(),
|
|
180
|
+
});
|
|
181
|
+
exports.testSendResponseSchema = zod_1.z.object({
|
|
182
|
+
channel: zod_1.z.enum(index_js_1.COMMUNICATION_CHANNELS),
|
|
183
|
+
/** The signed-in admin's own address, masked, such as `b***@tbs.coach`. */
|
|
184
|
+
sentTo: zod_1.z.string(),
|
|
185
|
+
});
|
|
186
|
+
/** A catalogue entry, with the built-in default template for each channel. */
|
|
187
|
+
exports.messageTypeResponseSchema = zod_1.z.object({
|
|
188
|
+
type: zod_1.z.enum(index_js_1.MESSAGE_TYPES),
|
|
189
|
+
label: zod_1.z.string(),
|
|
190
|
+
description: zod_1.z.string(),
|
|
191
|
+
channels: zod_1.z.array(zod_1.z.enum(index_js_1.COMMUNICATION_CHANNELS)),
|
|
192
|
+
variables: zod_1.z.array(zod_1.z.object({
|
|
193
|
+
name: zod_1.z.string(),
|
|
194
|
+
description: zod_1.z.string(),
|
|
195
|
+
example: zod_1.z.string(),
|
|
196
|
+
})),
|
|
197
|
+
requiredVariables: zod_1.z.array(zod_1.z.string()),
|
|
198
|
+
defaults: zod_1.z.array(zod_1.z.object({
|
|
199
|
+
channel: zod_1.z.enum(index_js_1.COMMUNICATION_CHANNELS),
|
|
200
|
+
subject: zod_1.z.string().nullable(),
|
|
201
|
+
bodyText: zod_1.z.string(),
|
|
202
|
+
bodyHtml: zod_1.z.string().nullable(),
|
|
203
|
+
variables: zod_1.z.array(zod_1.z.object({ name: zod_1.z.string() })),
|
|
204
|
+
})),
|
|
205
|
+
});
|
|
206
|
+
exports.messageTypeListResponseSchema = zod_1.z.array(exports.messageTypeResponseSchema);
|
|
@@ -25,3 +25,22 @@ export declare const AUDIT_OUTCOMES: readonly ["success", "failure"];
|
|
|
25
25
|
export type AuditOutcome = (typeof AUDIT_OUTCOMES)[number];
|
|
26
26
|
export declare const FEATURE_FLAG_RULE_TYPES: readonly ["user", "customer", "trainer", "role"];
|
|
27
27
|
export type FeatureFlagRuleType = (typeof FEATURE_FLAG_RULE_TYPES)[number];
|
|
28
|
+
/**
|
|
29
|
+
* The ways a message can reach someone. WhatsApp and push are added here when
|
|
30
|
+
* their channel modules are built.
|
|
31
|
+
*/
|
|
32
|
+
export declare const COMMUNICATION_CHANNELS: readonly ["EMAIL", "SMS"];
|
|
33
|
+
export type CommunicationChannel = (typeof COMMUNICATION_CHANNELS)[number];
|
|
34
|
+
/**
|
|
35
|
+
* Every message the platform sends. Each has a catalogue entry in
|
|
36
|
+
* `@tbs-fitness/contract/communication` saying which channels may carry it and
|
|
37
|
+
* which variables its sender supplies.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MESSAGE_TYPES: readonly ["auth.otp.signup_verification", "auth.otp.password_reset", "auth.password_reset_link"];
|
|
40
|
+
export type MessageType = (typeof MESSAGE_TYPES)[number];
|
|
41
|
+
/** What happened to one message on one channel. */
|
|
42
|
+
export declare const DELIVERY_STATUSES: readonly ["sent", "skipped_disabled", "skipped_unreachable", "failed", "failed_missing_variables"];
|
|
43
|
+
export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number];
|
|
44
|
+
/** Only one version of a template per message type and channel is active. */
|
|
45
|
+
export declare const TEMPLATE_STATUSES: readonly ["draft", "active", "archived"];
|
|
46
|
+
export type TemplateStatus = (typeof TEMPLATE_STATUSES)[number];
|
package/dist/cjs/enums/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// These values are stored in the database and sent over the API. Renaming or
|
|
3
3
|
// removing one is a breaking change, so it needs a major version.
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
-
exports.FEATURE_FLAG_RULE_TYPES = exports.AUDIT_OUTCOMES = exports.TOKEN_TRANSPORTS = exports.TIMEZONE_SOURCES = exports.ADMIN_SETTABLE_ACCOUNT_STATUSES = exports.ACCOUNT_STATUSES = exports.ROLES = exports.APP_ENVIRONMENTS = void 0;
|
|
5
|
+
exports.TEMPLATE_STATUSES = exports.DELIVERY_STATUSES = exports.MESSAGE_TYPES = exports.COMMUNICATION_CHANNELS = exports.FEATURE_FLAG_RULE_TYPES = exports.AUDIT_OUTCOMES = exports.TOKEN_TRANSPORTS = exports.TIMEZONE_SOURCES = exports.ADMIN_SETTABLE_ACCOUNT_STATUSES = exports.ACCOUNT_STATUSES = exports.ROLES = exports.APP_ENVIRONMENTS = void 0;
|
|
6
6
|
exports.APP_ENVIRONMENTS = [
|
|
7
7
|
'development',
|
|
8
8
|
'staging',
|
|
@@ -35,3 +35,28 @@ exports.FEATURE_FLAG_RULE_TYPES = [
|
|
|
35
35
|
'trainer',
|
|
36
36
|
'role',
|
|
37
37
|
];
|
|
38
|
+
/**
|
|
39
|
+
* The ways a message can reach someone. WhatsApp and push are added here when
|
|
40
|
+
* their channel modules are built.
|
|
41
|
+
*/
|
|
42
|
+
exports.COMMUNICATION_CHANNELS = ['EMAIL', 'SMS'];
|
|
43
|
+
/**
|
|
44
|
+
* Every message the platform sends. Each has a catalogue entry in
|
|
45
|
+
* `@tbs-fitness/contract/communication` saying which channels may carry it and
|
|
46
|
+
* which variables its sender supplies.
|
|
47
|
+
*/
|
|
48
|
+
exports.MESSAGE_TYPES = [
|
|
49
|
+
'auth.otp.signup_verification',
|
|
50
|
+
'auth.otp.password_reset',
|
|
51
|
+
'auth.password_reset_link',
|
|
52
|
+
];
|
|
53
|
+
/** What happened to one message on one channel. */
|
|
54
|
+
exports.DELIVERY_STATUSES = [
|
|
55
|
+
'sent',
|
|
56
|
+
'skipped_disabled',
|
|
57
|
+
'skipped_unreachable',
|
|
58
|
+
'failed',
|
|
59
|
+
'failed_missing_variables',
|
|
60
|
+
];
|
|
61
|
+
/** Only one version of a template per message type and channel is active. */
|
|
62
|
+
exports.TEMPLATE_STATUSES = ['draft', 'active', 'archived'];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type MessageType } from '../enums/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Runtime settings an administrator changes without a deploy. Each key has a
|
|
5
|
+
* value schema here, which both the server and the admin screen use. A key
|
|
6
|
+
* with no saved value uses its default.
|
|
7
|
+
*/
|
|
8
|
+
export declare const APP_SETTING_KEYS: readonly ["communication.channels", "communication.messages"];
|
|
9
|
+
export type AppSettingKey = (typeof APP_SETTING_KEYS)[number];
|
|
10
|
+
/** A switch per channel. */
|
|
11
|
+
export declare const channelSwitchesSchema: z.ZodObject<{
|
|
12
|
+
EMAIL: z.ZodBoolean;
|
|
13
|
+
SMS: z.ZodBoolean;
|
|
14
|
+
}, z.core.$strict>;
|
|
15
|
+
export type ChannelSwitches = z.output<typeof channelSwitchesSchema>;
|
|
16
|
+
/**
|
|
17
|
+
* Master switches: a channel switched off here sends nothing, whatever the
|
|
18
|
+
* per-message settings say.
|
|
19
|
+
*/
|
|
20
|
+
export declare const communicationChannelsSettingSchema: z.ZodObject<{
|
|
21
|
+
EMAIL: z.ZodBoolean;
|
|
22
|
+
SMS: z.ZodBoolean;
|
|
23
|
+
}, z.core.$strict>;
|
|
24
|
+
/**
|
|
25
|
+
* Per message type and channel. A type missing from a saved value uses its
|
|
26
|
+
* catalogue defaults, so a newly added message type needs no data change.
|
|
27
|
+
*/
|
|
28
|
+
export declare const communicationMessagesSettingSchema: z.ZodRecord<z.ZodEnum<{
|
|
29
|
+
"auth.otp.signup_verification": "auth.otp.signup_verification";
|
|
30
|
+
"auth.otp.password_reset": "auth.otp.password_reset";
|
|
31
|
+
"auth.password_reset_link": "auth.password_reset_link";
|
|
32
|
+
}> & z.core.$partial, z.ZodObject<{
|
|
33
|
+
EMAIL: z.ZodBoolean;
|
|
34
|
+
SMS: z.ZodBoolean;
|
|
35
|
+
}, z.core.$strict>>;
|
|
36
|
+
export type CommunicationMessagesSetting = z.output<typeof communicationMessagesSettingSchema>;
|
|
37
|
+
export declare const APP_SETTING_VALUE_SCHEMAS: {
|
|
38
|
+
readonly 'communication.channels': z.ZodObject<{
|
|
39
|
+
EMAIL: z.ZodBoolean;
|
|
40
|
+
SMS: z.ZodBoolean;
|
|
41
|
+
}, z.core.$strict>;
|
|
42
|
+
readonly 'communication.messages': z.ZodRecord<z.ZodEnum<{
|
|
43
|
+
"auth.otp.signup_verification": "auth.otp.signup_verification";
|
|
44
|
+
"auth.otp.password_reset": "auth.otp.password_reset";
|
|
45
|
+
"auth.password_reset_link": "auth.password_reset_link";
|
|
46
|
+
}> & z.core.$partial, z.ZodObject<{
|
|
47
|
+
EMAIL: z.ZodBoolean;
|
|
48
|
+
SMS: z.ZodBoolean;
|
|
49
|
+
}, z.core.$strict>>;
|
|
50
|
+
};
|
|
51
|
+
export interface AppSettingValues {
|
|
52
|
+
'communication.channels': ChannelSwitches;
|
|
53
|
+
'communication.messages': CommunicationMessagesSetting;
|
|
54
|
+
}
|
|
55
|
+
/** Every message type, with its catalogue defaults: the effective value with nothing saved. */
|
|
56
|
+
export declare function defaultMessageSettings(): Record<MessageType, ChannelSwitches>;
|
|
57
|
+
export declare const APP_SETTING_DEFAULTS: AppSettingValues;
|
|
58
|
+
export declare const appSettingKeyParamSchema: z.ZodEnum<{
|
|
59
|
+
"communication.channels": "communication.channels";
|
|
60
|
+
"communication.messages": "communication.messages";
|
|
61
|
+
}>;
|
|
62
|
+
export declare const appSettingResponseSchema: z.ZodObject<{
|
|
63
|
+
key: z.ZodEnum<{
|
|
64
|
+
"communication.channels": "communication.channels";
|
|
65
|
+
"communication.messages": "communication.messages";
|
|
66
|
+
}>;
|
|
67
|
+
label: z.ZodString;
|
|
68
|
+
description: z.ZodString;
|
|
69
|
+
value: z.ZodUnknown;
|
|
70
|
+
defaultValue: z.ZodUnknown;
|
|
71
|
+
version: z.ZodNumber;
|
|
72
|
+
updatedBy: z.ZodNullable<z.ZodUUID>;
|
|
73
|
+
updatedAt: z.ZodNullable<z.ZodISODateTime>;
|
|
74
|
+
}, z.core.$strip>;
|
|
75
|
+
export type AppSettingResponse = z.output<typeof appSettingResponseSchema>;
|
|
76
|
+
export declare const appSettingListResponseSchema: z.ZodArray<z.ZodObject<{
|
|
77
|
+
key: z.ZodEnum<{
|
|
78
|
+
"communication.channels": "communication.channels";
|
|
79
|
+
"communication.messages": "communication.messages";
|
|
80
|
+
}>;
|
|
81
|
+
label: z.ZodString;
|
|
82
|
+
description: z.ZodString;
|
|
83
|
+
value: z.ZodUnknown;
|
|
84
|
+
defaultValue: z.ZodUnknown;
|
|
85
|
+
version: z.ZodNumber;
|
|
86
|
+
updatedBy: z.ZodNullable<z.ZodUUID>;
|
|
87
|
+
updatedAt: z.ZodNullable<z.ZodISODateTime>;
|
|
88
|
+
}, z.core.$strip>>;
|
|
89
|
+
/**
|
|
90
|
+
* Replaces a setting's value. `version` must be the one last read, so a
|
|
91
|
+
* change made in between is not silently overwritten. The value is checked
|
|
92
|
+
* against the key's schema on the server.
|
|
93
|
+
*/
|
|
94
|
+
export declare const updateAppSettingSchema: z.ZodObject<{
|
|
95
|
+
value: z.ZodUnknown;
|
|
96
|
+
version: z.ZodNumber;
|
|
97
|
+
}, z.core.$strict>;
|
|
98
|
+
export type UpdateAppSettingInput = z.input<typeof updateAppSettingSchema>;
|
|
99
|
+
export type UpdateAppSettingBody = z.output<typeof updateAppSettingSchema>;
|
|
100
|
+
export declare const appSettingHistoryEntrySchema: z.ZodObject<{
|
|
101
|
+
id: z.ZodUUID;
|
|
102
|
+
key: z.ZodEnum<{
|
|
103
|
+
"communication.channels": "communication.channels";
|
|
104
|
+
"communication.messages": "communication.messages";
|
|
105
|
+
}>;
|
|
106
|
+
previousValue: z.ZodUnknown;
|
|
107
|
+
newValue: z.ZodUnknown;
|
|
108
|
+
version: z.ZodNumber;
|
|
109
|
+
actorUserId: z.ZodUUID;
|
|
110
|
+
createdAt: z.ZodISODateTime;
|
|
111
|
+
}, z.core.$strip>;
|
|
112
|
+
export type AppSettingHistoryEntry = z.output<typeof appSettingHistoryEntrySchema>;
|
|
113
|
+
export declare const appSettingHistoryResponseSchema: z.ZodArray<z.ZodObject<{
|
|
114
|
+
id: z.ZodUUID;
|
|
115
|
+
key: z.ZodEnum<{
|
|
116
|
+
"communication.channels": "communication.channels";
|
|
117
|
+
"communication.messages": "communication.messages";
|
|
118
|
+
}>;
|
|
119
|
+
previousValue: z.ZodUnknown;
|
|
120
|
+
newValue: z.ZodUnknown;
|
|
121
|
+
version: z.ZodNumber;
|
|
122
|
+
actorUserId: z.ZodUUID;
|
|
123
|
+
createdAt: z.ZodISODateTime;
|
|
124
|
+
}, z.core.$strip>>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { utcTimestampSchema } from '../common/date-time.schema.js';
|
|
3
|
+
import { MESSAGE_CATALOGUE } from '../communication/message-catalogue.js';
|
|
4
|
+
import { MESSAGE_TYPES } from '../enums/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* Runtime settings an administrator changes without a deploy. Each key has a
|
|
7
|
+
* value schema here, which both the server and the admin screen use. A key
|
|
8
|
+
* with no saved value uses its default.
|
|
9
|
+
*/
|
|
10
|
+
export const APP_SETTING_KEYS = [
|
|
11
|
+
'communication.channels',
|
|
12
|
+
'communication.messages',
|
|
13
|
+
];
|
|
14
|
+
/** A switch per channel. */
|
|
15
|
+
export const channelSwitchesSchema = z.strictObject({
|
|
16
|
+
EMAIL: z.boolean(),
|
|
17
|
+
SMS: z.boolean(),
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Master switches: a channel switched off here sends nothing, whatever the
|
|
21
|
+
* per-message settings say.
|
|
22
|
+
*/
|
|
23
|
+
export const communicationChannelsSettingSchema = channelSwitchesSchema;
|
|
24
|
+
/**
|
|
25
|
+
* Per message type and channel. A type missing from a saved value uses its
|
|
26
|
+
* catalogue defaults, so a newly added message type needs no data change.
|
|
27
|
+
*/
|
|
28
|
+
export const communicationMessagesSettingSchema = z
|
|
29
|
+
.partialRecord(z.enum(MESSAGE_TYPES), channelSwitchesSchema)
|
|
30
|
+
.superRefine((value, ctx) => {
|
|
31
|
+
for (const [type, switches] of Object.entries(value)) {
|
|
32
|
+
const allowed = MESSAGE_CATALOGUE[type].channels;
|
|
33
|
+
for (const [channel, on] of Object.entries(switches ?? {})) {
|
|
34
|
+
if (on && !allowed.includes(channel)) {
|
|
35
|
+
ctx.addIssue({
|
|
36
|
+
code: 'custom',
|
|
37
|
+
path: [type, channel],
|
|
38
|
+
message: `${MESSAGE_CATALOGUE[type].label} cannot be sent by ${channel}`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
export const APP_SETTING_VALUE_SCHEMAS = {
|
|
45
|
+
'communication.channels': communicationChannelsSettingSchema,
|
|
46
|
+
'communication.messages': communicationMessagesSettingSchema,
|
|
47
|
+
};
|
|
48
|
+
/** Every message type, with its catalogue defaults: the effective value with nothing saved. */
|
|
49
|
+
export function defaultMessageSettings() {
|
|
50
|
+
return Object.fromEntries(MESSAGE_TYPES.map((type) => [
|
|
51
|
+
type,
|
|
52
|
+
{ ...MESSAGE_CATALOGUE[type].defaultChannels },
|
|
53
|
+
]));
|
|
54
|
+
}
|
|
55
|
+
export const APP_SETTING_DEFAULTS = {
|
|
56
|
+
'communication.channels': { EMAIL: true, SMS: true },
|
|
57
|
+
'communication.messages': defaultMessageSettings(),
|
|
58
|
+
};
|
|
59
|
+
export const appSettingKeyParamSchema = z.enum(APP_SETTING_KEYS);
|
|
60
|
+
export const appSettingResponseSchema = z.object({
|
|
61
|
+
key: z.enum(APP_SETTING_KEYS),
|
|
62
|
+
label: z.string(),
|
|
63
|
+
description: z.string(),
|
|
64
|
+
value: z.unknown(),
|
|
65
|
+
defaultValue: z.unknown(),
|
|
66
|
+
/** 0 until the setting is first saved. Send it back to update. */
|
|
67
|
+
version: z.number().int(),
|
|
68
|
+
updatedBy: z.uuid().nullable(),
|
|
69
|
+
updatedAt: utcTimestampSchema.nullable(),
|
|
70
|
+
});
|
|
71
|
+
export const appSettingListResponseSchema = z.array(appSettingResponseSchema);
|
|
72
|
+
/**
|
|
73
|
+
* Replaces a setting's value. `version` must be the one last read, so a
|
|
74
|
+
* change made in between is not silently overwritten. The value is checked
|
|
75
|
+
* against the key's schema on the server.
|
|
76
|
+
*/
|
|
77
|
+
export const updateAppSettingSchema = z.strictObject({
|
|
78
|
+
value: z.unknown(),
|
|
79
|
+
version: z.number().int().min(0),
|
|
80
|
+
});
|
|
81
|
+
export const appSettingHistoryEntrySchema = z.object({
|
|
82
|
+
id: z.uuid(),
|
|
83
|
+
key: z.enum(APP_SETTING_KEYS),
|
|
84
|
+
previousValue: z.unknown(),
|
|
85
|
+
newValue: z.unknown(),
|
|
86
|
+
version: z.number().int(),
|
|
87
|
+
actorUserId: z.uuid(),
|
|
88
|
+
createdAt: utcTimestampSchema,
|
|
89
|
+
});
|
|
90
|
+
export const appSettingHistoryResponseSchema = z.array(appSettingHistoryEntrySchema);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './app-settings.dto.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './app-settings.dto.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CommunicationChannel, MessageType } from '../enums/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the platform knows about each message type: the channels that may carry
|
|
4
|
+
* it, the channels switched on by default, and the variables its sender
|
|
5
|
+
* supplies. A template may only use supplied variables, and must use the
|
|
6
|
+
* required ones, so an admin can never activate a template the system cannot
|
|
7
|
+
* fill.
|
|
8
|
+
*/
|
|
9
|
+
export interface MessageTypeDefinition {
|
|
10
|
+
label: string;
|
|
11
|
+
description: string;
|
|
12
|
+
channels: readonly CommunicationChannel[];
|
|
13
|
+
defaultChannels: Readonly<Record<CommunicationChannel, boolean>>;
|
|
14
|
+
/** Every variable the sender passes, with a sample value for previews. */
|
|
15
|
+
variables: Readonly<Record<string, {
|
|
16
|
+
description: string;
|
|
17
|
+
example: string;
|
|
18
|
+
}>>;
|
|
19
|
+
/** Variables every template for this type must use. */
|
|
20
|
+
requiredVariables: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
export declare const MESSAGE_CATALOGUE: Readonly<Record<MessageType, MessageTypeDefinition>>;
|
|
23
|
+
/** The longest SMS body a template may have: four concatenated GSM-7 segments. */
|
|
24
|
+
export declare const MAX_SMS_LENGTH = 612;
|
|
25
|
+
/** One GSM-7 segment. Longer messages are split and cost more. */
|
|
26
|
+
export declare const SMS_SEGMENT_LENGTH = 160;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const OTP_VARIABLES = {
|
|
2
|
+
code: { description: 'The one-time code', example: '482915' },
|
|
3
|
+
fullName: { description: "The recipient's name", example: 'Biki Singh' },
|
|
4
|
+
expiresInMinutes: {
|
|
5
|
+
description: 'Minutes until the code expires',
|
|
6
|
+
example: '10',
|
|
7
|
+
},
|
|
8
|
+
appName: { description: 'The product name', example: 'TBS' },
|
|
9
|
+
};
|
|
10
|
+
export const MESSAGE_CATALOGUE = {
|
|
11
|
+
'auth.otp.signup_verification': {
|
|
12
|
+
label: 'Sign-up verification code',
|
|
13
|
+
description: 'Sent when a coach signs up, to verify their email address.',
|
|
14
|
+
channels: ['EMAIL', 'SMS'],
|
|
15
|
+
defaultChannels: { EMAIL: true, SMS: false },
|
|
16
|
+
variables: OTP_VARIABLES,
|
|
17
|
+
requiredVariables: ['code'],
|
|
18
|
+
},
|
|
19
|
+
'auth.otp.password_reset': {
|
|
20
|
+
label: 'Password recovery code',
|
|
21
|
+
description: 'Sent when a coach asks to reset a forgotten password.',
|
|
22
|
+
channels: ['EMAIL', 'SMS'],
|
|
23
|
+
defaultChannels: { EMAIL: true, SMS: false },
|
|
24
|
+
variables: OTP_VARIABLES,
|
|
25
|
+
requiredVariables: ['code'],
|
|
26
|
+
},
|
|
27
|
+
'auth.password_reset_link': {
|
|
28
|
+
label: 'Password reset link',
|
|
29
|
+
description: 'Sent when an administrator account requests a reset link.',
|
|
30
|
+
channels: ['EMAIL'],
|
|
31
|
+
defaultChannels: { EMAIL: true, SMS: false },
|
|
32
|
+
variables: {
|
|
33
|
+
resetUrl: {
|
|
34
|
+
description: 'The single-use reset link',
|
|
35
|
+
example: 'https://app.example.com/reset-password?token=…',
|
|
36
|
+
},
|
|
37
|
+
fullName: { description: "The recipient's name", example: 'Biki Singh' },
|
|
38
|
+
expiresInMinutes: {
|
|
39
|
+
description: 'Minutes until the link expires',
|
|
40
|
+
example: '30',
|
|
41
|
+
},
|
|
42
|
+
appName: { description: 'The product name', example: 'TBS' },
|
|
43
|
+
},
|
|
44
|
+
requiredVariables: ['resetUrl'],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
/** The longest SMS body a template may have: four concatenated GSM-7 segments. */
|
|
48
|
+
export const MAX_SMS_LENGTH = 612;
|
|
49
|
+
/** One GSM-7 segment. Longer messages are split and cost more. */
|
|
50
|
+
export const SMS_SEGMENT_LENGTH = 160;
|