@tbs-fitness/contract 0.2.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.
Files changed (48) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/app-settings/app-settings.dto.d.ts +124 -0
  3. package/dist/cjs/app-settings/app-settings.dto.js +94 -0
  4. package/dist/cjs/app-settings/index.d.ts +1 -0
  5. package/dist/cjs/app-settings/index.js +17 -0
  6. package/dist/cjs/audit/audit-log.dto.js +2 -1
  7. package/dist/cjs/auth/auth.dto.d.ts +36 -0
  8. package/dist/cjs/auth/auth.dto.js +28 -8
  9. package/dist/cjs/coach/coach-auth.dto.d.ts +12 -0
  10. package/dist/cjs/coach/coach-auth.dto.js +18 -6
  11. package/dist/cjs/common/api-response.schema.js +2 -1
  12. package/dist/cjs/common/date-time.schema.d.ts +39 -0
  13. package/dist/cjs/common/date-time.schema.js +112 -0
  14. package/dist/cjs/common/index.d.ts +1 -0
  15. package/dist/cjs/common/index.js +1 -0
  16. package/dist/cjs/communication/index.d.ts +2 -0
  17. package/dist/cjs/communication/index.js +18 -0
  18. package/dist/cjs/communication/message-catalogue.d.ts +26 -0
  19. package/dist/cjs/communication/message-catalogue.js +53 -0
  20. package/dist/cjs/communication/template.dto.d.ts +240 -0
  21. package/dist/cjs/communication/template.dto.js +206 -0
  22. package/dist/cjs/enums/index.d.ts +26 -0
  23. package/dist/cjs/enums/index.js +32 -1
  24. package/dist/cjs/feature-flags/feature-flag.dto.js +10 -9
  25. package/dist/esm/app-settings/app-settings.dto.d.ts +124 -0
  26. package/dist/esm/app-settings/app-settings.dto.js +90 -0
  27. package/dist/esm/app-settings/index.d.ts +1 -0
  28. package/dist/esm/app-settings/index.js +1 -0
  29. package/dist/esm/audit/audit-log.dto.js +2 -1
  30. package/dist/esm/auth/auth.dto.d.ts +36 -0
  31. package/dist/esm/auth/auth.dto.js +28 -8
  32. package/dist/esm/coach/coach-auth.dto.d.ts +12 -0
  33. package/dist/esm/coach/coach-auth.dto.js +19 -7
  34. package/dist/esm/common/api-response.schema.js +2 -1
  35. package/dist/esm/common/date-time.schema.d.ts +39 -0
  36. package/dist/esm/common/date-time.schema.js +107 -0
  37. package/dist/esm/common/index.d.ts +1 -0
  38. package/dist/esm/common/index.js +1 -0
  39. package/dist/esm/communication/index.d.ts +2 -0
  40. package/dist/esm/communication/index.js +2 -0
  41. package/dist/esm/communication/message-catalogue.d.ts +26 -0
  42. package/dist/esm/communication/message-catalogue.js +50 -0
  43. package/dist/esm/communication/template.dto.d.ts +240 -0
  44. package/dist/esm/communication/template.dto.js +200 -0
  45. package/dist/esm/enums/index.d.ts +26 -0
  46. package/dist/esm/enums/index.js +31 -0
  47. package/dist/esm/feature-flags/feature-flag.dto.js +10 -9
  48. package/package.json +21 -1
@@ -0,0 +1,107 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The platform's date and time rules, shared so the backend and the apps
4
+ * validate them identically:
5
+ *
6
+ * - An instant (something that happened at one moment) travels as ISO 8601.
7
+ * Responses always use UTC with `Z`. Requests may use `Z` or an explicit
8
+ * offset, but never a bare local time, which would be ambiguous.
9
+ * - A date-only value (a birthday, an expiry date) travels as `YYYY-MM-DD` and
10
+ * is never converted between time zones.
11
+ * - A time zone is an IANA identifier such as `Asia/Kolkata`. Abbreviations
12
+ * (`IST`) and fixed offsets (`+05:30`) are refused, because offsets change
13
+ * with daylight saving.
14
+ */
15
+ export const DEFAULT_TIMEZONE = 'UTC';
16
+ const MAX_TIMEZONE_LENGTH = 64;
17
+ // The IANA areas used for real places. `Etc/*` and the legacy aliases
18
+ // (`US/Eastern`, `EST`) are left out: the first are fixed offsets, and the
19
+ // second have modern names.
20
+ const AREA_LOCATION = /^(?:Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)\/[A-Za-z0-9_+-]+(?:\/[A-Za-z0-9_+-]+)?$/;
21
+ /**
22
+ * True for `UTC` and for an IANA `Area/Location` name the runtime's time zone
23
+ * database knows. Runs anywhere `Intl` does, so the browser and the server
24
+ * agree.
25
+ */
26
+ export function isValidTimezone(value) {
27
+ if (typeof value !== 'string' || value.length > MAX_TIMEZONE_LENGTH) {
28
+ return false;
29
+ }
30
+ if (value !== DEFAULT_TIMEZONE && !AREA_LOCATION.test(value)) {
31
+ return false;
32
+ }
33
+ try {
34
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ // Browsers and Node report some zones by names IANA has since replaced, because
42
+ // their Unicode data keeps the old ones. Saving the current name means one
43
+ // place is stored, compared and shown one way everywhere.
44
+ const RENAMED_TIMEZONES = {
45
+ 'Asia/Calcutta': 'Asia/Kolkata',
46
+ 'Asia/Saigon': 'Asia/Ho_Chi_Minh',
47
+ 'Asia/Katmandu': 'Asia/Kathmandu',
48
+ 'Asia/Rangoon': 'Asia/Yangon',
49
+ 'Europe/Kiev': 'Europe/Kyiv',
50
+ 'Europe/Uzhgorod': 'Europe/Kyiv',
51
+ 'Europe/Zaporozhye': 'Europe/Kyiv',
52
+ 'Atlantic/Faeroe': 'Atlantic/Faroe',
53
+ 'America/Godthab': 'America/Nuuk',
54
+ 'Pacific/Enderbury': 'Pacific/Kanton',
55
+ 'Pacific/Truk': 'Pacific/Chuuk',
56
+ 'Pacific/Ponape': 'Pacific/Pohnpei',
57
+ 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires',
58
+ 'America/Catamarca': 'America/Argentina/Catamarca',
59
+ 'America/Cordoba': 'America/Argentina/Cordoba',
60
+ 'America/Jujuy': 'America/Argentina/Jujuy',
61
+ 'America/Mendoza': 'America/Argentina/Mendoza',
62
+ 'America/Indianapolis': 'America/Indiana/Indianapolis',
63
+ 'America/Louisville': 'America/Kentucky/Louisville',
64
+ };
65
+ /** The current IANA name for a zone, such as `Asia/Kolkata` for `Asia/Calcutta`. */
66
+ export function canonicalTimezone(value) {
67
+ return RENAMED_TIMEZONES[value] ?? value;
68
+ }
69
+ export const timezoneSchema = z
70
+ .string()
71
+ .trim()
72
+ .refine(isValidTimezone, {
73
+ error: 'Choose a time zone such as Asia/Kolkata',
74
+ })
75
+ .transform(canonicalTimezone);
76
+ /**
77
+ * A time zone the client detected and suggests, such as at sign-up. A missing
78
+ * or unusable value becomes `undefined` instead of failing the request, and the
79
+ * server then applies its default.
80
+ */
81
+ export const detectedTimezoneSchema = z
82
+ .unknown()
83
+ .transform((value) => {
84
+ const trimmed = typeof value === 'string' ? value.trim() : value;
85
+ return isValidTimezone(trimmed) ? canonicalTimezone(trimmed) : undefined;
86
+ })
87
+ .optional();
88
+ /** An instant in a response: ISO 8601 in UTC, ending in `Z`. */
89
+ export const utcTimestampSchema = z.iso.datetime();
90
+ /**
91
+ * An instant in a request: ISO 8601 with `Z` or an explicit offset, such as
92
+ * `2026-11-02T15:00:00+05:30`. A local time without either is refused.
93
+ */
94
+ export const instantInputSchema = z.iso.datetime({ offset: true });
95
+ /** A calendar date with no time and no zone, such as `2026-11-02`. */
96
+ export const dateOnlySchema = z
97
+ .string()
98
+ .regex(/^\d{4}-\d{2}-\d{2}$/, { error: 'Use the format YYYY-MM-DD' })
99
+ .refine(isRealCalendarDate, { error: 'Enter a real calendar date' });
100
+ function isRealCalendarDate(value) {
101
+ const [year, month, day] = value.split('-').map(Number);
102
+ // UTC is used only to check the calendar, so no zone can move the date.
103
+ const date = new Date(Date.UTC(year, month - 1, day));
104
+ return (date.getUTCFullYear() === year &&
105
+ date.getUTCMonth() === month - 1 &&
106
+ date.getUTCDate() === day);
107
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './api-response.schema.js';
2
2
  export * from './contact.schema.js';
3
+ export * from './date-time.schema.js';
3
4
  export * from './pagination.schema.js';
4
5
  export * from './safe-text.schema.js';
@@ -1,4 +1,5 @@
1
1
  export * from './api-response.schema.js';
2
2
  export * from './contact.schema.js';
3
+ export * from './date-time.schema.js';
3
4
  export * from './pagination.schema.js';
4
5
  export * from './safe-text.schema.js';
@@ -0,0 +1,2 @@
1
+ export * from './message-catalogue.js';
2
+ export * from './template.dto.js';
@@ -0,0 +1,2 @@
1
+ export * from './message-catalogue.js';
2
+ export * from './template.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;
@@ -0,0 +1,240 @@
1
+ import { z } from 'zod';
2
+ import { type CommunicationChannel, type MessageType } from '../enums/index.js';
3
+ /** The variable names used as `{{name}}` in a piece of template text, in order, without repeats. */
4
+ export declare function templatePlaceholders(text: string): string[];
5
+ /** True when the text has a `{{` or `}}` that is not part of a valid placeholder. */
6
+ export declare function hasMalformedPlaceholder(text: string): boolean;
7
+ /** A variable a template declares. Its value is supplied when the message is sent. */
8
+ export declare const templateVariableSchema: z.ZodObject<{
9
+ name: z.ZodString;
10
+ description: z.ZodOptional<z.ZodString>;
11
+ example: z.ZodOptional<z.ZodString>;
12
+ }, z.core.$strict>;
13
+ export type TemplateVariable = z.output<typeof templateVariableSchema>;
14
+ interface TemplateShape {
15
+ messageType: MessageType;
16
+ channel: CommunicationChannel;
17
+ subject?: string | null | undefined;
18
+ bodyText: string;
19
+ bodyHtml?: string | null | undefined;
20
+ variables: {
21
+ name: string;
22
+ }[];
23
+ }
24
+ /**
25
+ * The rules every template must meet before it is saved: the declared
26
+ * variables match the placeholders exactly, every one is supplied by the
27
+ * message type, and the type's required variables are used. Shared by the
28
+ * server and the admin editor, so both report the same problems.
29
+ */
30
+ export declare function templateProblems(template: TemplateShape): {
31
+ path: string;
32
+ message: string;
33
+ }[];
34
+ /** A new draft. Saved templates are never edited; a change is a new version. */
35
+ export declare const createTemplateSchema: z.ZodObject<{
36
+ messageType: z.ZodEnum<{
37
+ "auth.otp.signup_verification": "auth.otp.signup_verification";
38
+ "auth.otp.password_reset": "auth.otp.password_reset";
39
+ "auth.password_reset_link": "auth.password_reset_link";
40
+ }>;
41
+ channel: z.ZodEnum<{
42
+ EMAIL: "EMAIL";
43
+ SMS: "SMS";
44
+ }>;
45
+ name: z.ZodPipe<z.ZodString, z.ZodString>;
46
+ subject: z.ZodOptional<z.ZodNullable<z.ZodString>>;
47
+ bodyText: z.ZodString;
48
+ bodyHtml: z.ZodOptional<z.ZodNullable<z.ZodString>>;
49
+ variables: z.ZodArray<z.ZodObject<{
50
+ name: z.ZodString;
51
+ description: z.ZodOptional<z.ZodString>;
52
+ example: z.ZodOptional<z.ZodString>;
53
+ }, z.core.$strict>>;
54
+ }, z.core.$strict>;
55
+ export type CreateTemplateInput = z.input<typeof createTemplateSchema>;
56
+ export type CreateTemplateBody = z.output<typeof createTemplateSchema>;
57
+ export declare const templateListQuerySchema: z.ZodObject<{
58
+ messageType: z.ZodOptional<z.ZodEnum<{
59
+ "auth.otp.signup_verification": "auth.otp.signup_verification";
60
+ "auth.otp.password_reset": "auth.otp.password_reset";
61
+ "auth.password_reset_link": "auth.password_reset_link";
62
+ }>>;
63
+ channel: z.ZodOptional<z.ZodEnum<{
64
+ EMAIL: "EMAIL";
65
+ SMS: "SMS";
66
+ }>>;
67
+ }, z.core.$strict>;
68
+ export type TemplateListQuery = z.output<typeof templateListQuerySchema>;
69
+ export declare const templateResponseSchema: z.ZodObject<{
70
+ id: z.ZodUUID;
71
+ messageType: z.ZodEnum<{
72
+ "auth.otp.signup_verification": "auth.otp.signup_verification";
73
+ "auth.otp.password_reset": "auth.otp.password_reset";
74
+ "auth.password_reset_link": "auth.password_reset_link";
75
+ }>;
76
+ channel: z.ZodEnum<{
77
+ EMAIL: "EMAIL";
78
+ SMS: "SMS";
79
+ }>;
80
+ version: z.ZodNumber;
81
+ name: z.ZodString;
82
+ subject: z.ZodNullable<z.ZodString>;
83
+ bodyText: z.ZodString;
84
+ bodyHtml: z.ZodNullable<z.ZodString>;
85
+ variables: z.ZodArray<z.ZodObject<{
86
+ name: z.ZodString;
87
+ description: z.ZodOptional<z.ZodString>;
88
+ example: z.ZodOptional<z.ZodString>;
89
+ }, z.core.$strip>>;
90
+ status: z.ZodEnum<{
91
+ active: "active";
92
+ draft: "draft";
93
+ archived: "archived";
94
+ }>;
95
+ createdBy: z.ZodNullable<z.ZodUUID>;
96
+ createdAt: z.ZodISODateTime;
97
+ activatedBy: z.ZodNullable<z.ZodUUID>;
98
+ activatedAt: z.ZodNullable<z.ZodISODateTime>;
99
+ }, z.core.$strip>;
100
+ export type TemplateResponse = z.output<typeof templateResponseSchema>;
101
+ export declare const templateListResponseSchema: z.ZodArray<z.ZodObject<{
102
+ id: z.ZodUUID;
103
+ messageType: z.ZodEnum<{
104
+ "auth.otp.signup_verification": "auth.otp.signup_verification";
105
+ "auth.otp.password_reset": "auth.otp.password_reset";
106
+ "auth.password_reset_link": "auth.password_reset_link";
107
+ }>;
108
+ channel: z.ZodEnum<{
109
+ EMAIL: "EMAIL";
110
+ SMS: "SMS";
111
+ }>;
112
+ version: z.ZodNumber;
113
+ name: z.ZodString;
114
+ subject: z.ZodNullable<z.ZodString>;
115
+ bodyText: z.ZodString;
116
+ bodyHtml: z.ZodNullable<z.ZodString>;
117
+ variables: z.ZodArray<z.ZodObject<{
118
+ name: z.ZodString;
119
+ description: z.ZodOptional<z.ZodString>;
120
+ example: z.ZodOptional<z.ZodString>;
121
+ }, z.core.$strip>>;
122
+ status: z.ZodEnum<{
123
+ active: "active";
124
+ draft: "draft";
125
+ archived: "archived";
126
+ }>;
127
+ createdBy: z.ZodNullable<z.ZodUUID>;
128
+ createdAt: z.ZodISODateTime;
129
+ activatedBy: z.ZodNullable<z.ZodUUID>;
130
+ activatedAt: z.ZodNullable<z.ZodISODateTime>;
131
+ }, z.core.$strip>>;
132
+ /** Renders unsaved text, or a saved version, with the catalogue's sample values. */
133
+ export declare const previewTemplateSchema: z.ZodUnion<readonly [z.ZodObject<{
134
+ templateId: z.ZodUUID;
135
+ }, z.core.$strict>, z.ZodObject<{
136
+ messageType: z.ZodEnum<{
137
+ "auth.otp.signup_verification": "auth.otp.signup_verification";
138
+ "auth.otp.password_reset": "auth.otp.password_reset";
139
+ "auth.password_reset_link": "auth.password_reset_link";
140
+ }>;
141
+ channel: z.ZodEnum<{
142
+ EMAIL: "EMAIL";
143
+ SMS: "SMS";
144
+ }>;
145
+ name: z.ZodPipe<z.ZodString, z.ZodString>;
146
+ subject: z.ZodOptional<z.ZodNullable<z.ZodString>>;
147
+ bodyText: z.ZodString;
148
+ bodyHtml: z.ZodOptional<z.ZodNullable<z.ZodString>>;
149
+ variables: z.ZodArray<z.ZodObject<{
150
+ name: z.ZodString;
151
+ description: z.ZodOptional<z.ZodString>;
152
+ example: z.ZodOptional<z.ZodString>;
153
+ }, z.core.$strict>>;
154
+ }, z.core.$strict>]>;
155
+ export type PreviewTemplateInput = z.input<typeof previewTemplateSchema>;
156
+ export type PreviewTemplateBody = z.output<typeof previewTemplateSchema>;
157
+ export declare const renderedMessageSchema: z.ZodObject<{
158
+ channel: z.ZodEnum<{
159
+ EMAIL: "EMAIL";
160
+ SMS: "SMS";
161
+ }>;
162
+ subject: z.ZodNullable<z.ZodString>;
163
+ bodyText: z.ZodString;
164
+ bodyHtml: z.ZodNullable<z.ZodString>;
165
+ smsSegments: z.ZodNullable<z.ZodNumber>;
166
+ }, z.core.$strip>;
167
+ export type RenderedMessage = z.output<typeof renderedMessageSchema>;
168
+ export declare const testSendResponseSchema: z.ZodObject<{
169
+ channel: z.ZodEnum<{
170
+ EMAIL: "EMAIL";
171
+ SMS: "SMS";
172
+ }>;
173
+ sentTo: z.ZodString;
174
+ }, z.core.$strip>;
175
+ export type TestSendResponse = z.output<typeof testSendResponseSchema>;
176
+ /** A catalogue entry, with the built-in default template for each channel. */
177
+ export declare const messageTypeResponseSchema: z.ZodObject<{
178
+ type: z.ZodEnum<{
179
+ "auth.otp.signup_verification": "auth.otp.signup_verification";
180
+ "auth.otp.password_reset": "auth.otp.password_reset";
181
+ "auth.password_reset_link": "auth.password_reset_link";
182
+ }>;
183
+ label: z.ZodString;
184
+ description: z.ZodString;
185
+ channels: z.ZodArray<z.ZodEnum<{
186
+ EMAIL: "EMAIL";
187
+ SMS: "SMS";
188
+ }>>;
189
+ variables: z.ZodArray<z.ZodObject<{
190
+ name: z.ZodString;
191
+ description: z.ZodString;
192
+ example: z.ZodString;
193
+ }, z.core.$strip>>;
194
+ requiredVariables: z.ZodArray<z.ZodString>;
195
+ defaults: z.ZodArray<z.ZodObject<{
196
+ channel: z.ZodEnum<{
197
+ EMAIL: "EMAIL";
198
+ SMS: "SMS";
199
+ }>;
200
+ subject: z.ZodNullable<z.ZodString>;
201
+ bodyText: z.ZodString;
202
+ bodyHtml: z.ZodNullable<z.ZodString>;
203
+ variables: z.ZodArray<z.ZodObject<{
204
+ name: z.ZodString;
205
+ }, z.core.$strip>>;
206
+ }, z.core.$strip>>;
207
+ }, z.core.$strip>;
208
+ export type MessageTypeResponse = z.output<typeof messageTypeResponseSchema>;
209
+ export declare const messageTypeListResponseSchema: z.ZodArray<z.ZodObject<{
210
+ type: z.ZodEnum<{
211
+ "auth.otp.signup_verification": "auth.otp.signup_verification";
212
+ "auth.otp.password_reset": "auth.otp.password_reset";
213
+ "auth.password_reset_link": "auth.password_reset_link";
214
+ }>;
215
+ label: z.ZodString;
216
+ description: z.ZodString;
217
+ channels: z.ZodArray<z.ZodEnum<{
218
+ EMAIL: "EMAIL";
219
+ SMS: "SMS";
220
+ }>>;
221
+ variables: z.ZodArray<z.ZodObject<{
222
+ name: z.ZodString;
223
+ description: z.ZodString;
224
+ example: z.ZodString;
225
+ }, z.core.$strip>>;
226
+ requiredVariables: z.ZodArray<z.ZodString>;
227
+ defaults: z.ZodArray<z.ZodObject<{
228
+ channel: z.ZodEnum<{
229
+ EMAIL: "EMAIL";
230
+ SMS: "SMS";
231
+ }>;
232
+ subject: z.ZodNullable<z.ZodString>;
233
+ bodyText: z.ZodString;
234
+ bodyHtml: z.ZodNullable<z.ZodString>;
235
+ variables: z.ZodArray<z.ZodObject<{
236
+ name: z.ZodString;
237
+ }, z.core.$strip>>;
238
+ }, z.core.$strip>>;
239
+ }, z.core.$strip>>;
240
+ export {};
@@ -0,0 +1,200 @@
1
+ import { z } from 'zod';
2
+ import { utcTimestampSchema } from '../common/date-time.schema.js';
3
+ import { safeTextSchema } from '../common/safe-text.schema.js';
4
+ import { COMMUNICATION_CHANNELS, MESSAGE_TYPES, TEMPLATE_STATUSES, } from '../enums/index.js';
5
+ import { MAX_SMS_LENGTH, MESSAGE_CATALOGUE } from './message-catalogue.js';
6
+ const PLACEHOLDER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
7
+ /** The variable names used as `{{name}}` in a piece of template text, in order, without repeats. */
8
+ export function templatePlaceholders(text) {
9
+ const names = new Set();
10
+ for (const match of text.matchAll(PLACEHOLDER)) {
11
+ names.add(match[1]);
12
+ }
13
+ return [...names];
14
+ }
15
+ /** True when the text has a `{{` or `}}` that is not part of a valid placeholder. */
16
+ export function hasMalformedPlaceholder(text) {
17
+ const rest = text.replace(PLACEHOLDER, '');
18
+ return rest.includes('{{') || rest.includes('}}');
19
+ }
20
+ const MAX_SUBJECT_LENGTH = 200;
21
+ const MAX_BODY_LENGTH = 20000;
22
+ /** A variable a template declares. Its value is supplied when the message is sent. */
23
+ export const templateVariableSchema = z.strictObject({
24
+ name: z
25
+ .string()
26
+ .regex(/^[A-Za-z][A-Za-z0-9_]*$/, {
27
+ error: 'Use letters, digits and underscores, starting with a letter',
28
+ })
29
+ .max(64),
30
+ description: safeTextSchema(200).optional(),
31
+ example: z.string().max(500).optional(),
32
+ });
33
+ const templateFields = {
34
+ messageType: z.enum(MESSAGE_TYPES),
35
+ channel: z.enum(COMMUNICATION_CHANNELS),
36
+ name: safeTextSchema(100).pipe(z.string().min(1, { error: 'Name it' })),
37
+ subject: z.string().max(MAX_SUBJECT_LENGTH).nullish(),
38
+ bodyText: z
39
+ .string()
40
+ .min(1, { error: 'Write the message' })
41
+ .max(MAX_BODY_LENGTH),
42
+ bodyHtml: z.string().max(MAX_BODY_LENGTH).nullish(),
43
+ variables: z.array(templateVariableSchema).max(20),
44
+ };
45
+ /**
46
+ * The rules every template must meet before it is saved: the declared
47
+ * variables match the placeholders exactly, every one is supplied by the
48
+ * message type, and the type's required variables are used. Shared by the
49
+ * server and the admin editor, so both report the same problems.
50
+ */
51
+ export function templateProblems(template) {
52
+ const problems = [];
53
+ const definition = MESSAGE_CATALOGUE[template.messageType];
54
+ const isEmail = template.channel === 'EMAIL';
55
+ if (!definition.channels.includes(template.channel)) {
56
+ problems.push({
57
+ path: 'channel',
58
+ message: `${definition.label} cannot be sent by ${template.channel}`,
59
+ });
60
+ }
61
+ if (isEmail && !template.subject?.trim()) {
62
+ problems.push({ path: 'subject', message: 'An email needs a subject' });
63
+ }
64
+ if (!isEmail && (template.subject || template.bodyHtml)) {
65
+ problems.push({
66
+ path: 'bodyHtml',
67
+ message: 'Only email templates have a subject or HTML',
68
+ });
69
+ }
70
+ if (template.channel === 'SMS' && template.bodyText.length > MAX_SMS_LENGTH) {
71
+ problems.push({
72
+ path: 'bodyText',
73
+ message: `An SMS can be at most ${String(MAX_SMS_LENGTH)} characters`,
74
+ });
75
+ }
76
+ const texts = [
77
+ template.subject ?? '',
78
+ template.bodyText,
79
+ template.bodyHtml ?? '',
80
+ ];
81
+ if (texts.some(hasMalformedPlaceholder)) {
82
+ problems.push({
83
+ path: 'bodyText',
84
+ message: 'Write placeholders as {{name}}, with matching braces',
85
+ });
86
+ }
87
+ const used = new Set(texts.flatMap(templatePlaceholders));
88
+ const declared = template.variables.map((variable) => variable.name);
89
+ const declaredSet = new Set(declared);
90
+ if (declaredSet.size !== declared.length) {
91
+ problems.push({ path: 'variables', message: 'Declare each variable once' });
92
+ }
93
+ for (const name of used) {
94
+ if (!declaredSet.has(name)) {
95
+ problems.push({
96
+ path: 'variables',
97
+ message: `{{${name}}} is used but not declared`,
98
+ });
99
+ }
100
+ }
101
+ for (const name of declaredSet) {
102
+ if (!used.has(name)) {
103
+ problems.push({
104
+ path: 'variables',
105
+ message: `${name} is declared but not used`,
106
+ });
107
+ }
108
+ if (!(name in definition.variables)) {
109
+ problems.push({
110
+ path: 'variables',
111
+ message: `${definition.label} does not supply ${name}`,
112
+ });
113
+ }
114
+ }
115
+ for (const name of definition.requiredVariables) {
116
+ if (!declaredSet.has(name)) {
117
+ problems.push({
118
+ path: 'variables',
119
+ message: `${definition.label} must include {{${name}}}`,
120
+ });
121
+ }
122
+ }
123
+ return problems;
124
+ }
125
+ /** A new draft. Saved templates are never edited; a change is a new version. */
126
+ export const createTemplateSchema = z
127
+ .strictObject(templateFields)
128
+ .superRefine((template, ctx) => {
129
+ for (const problem of templateProblems(template)) {
130
+ ctx.addIssue({
131
+ code: 'custom',
132
+ path: [problem.path],
133
+ message: problem.message,
134
+ });
135
+ }
136
+ });
137
+ export const templateListQuerySchema = z.strictObject({
138
+ messageType: z.enum(MESSAGE_TYPES).optional(),
139
+ channel: z.enum(COMMUNICATION_CHANNELS).optional(),
140
+ });
141
+ export const templateResponseSchema = z.object({
142
+ id: z.uuid(),
143
+ messageType: z.enum(MESSAGE_TYPES),
144
+ channel: z.enum(COMMUNICATION_CHANNELS),
145
+ version: z.number().int(),
146
+ name: z.string(),
147
+ subject: z.string().nullable(),
148
+ bodyText: z.string(),
149
+ bodyHtml: z.string().nullable(),
150
+ variables: z.array(z.object({
151
+ name: z.string(),
152
+ description: z.string().optional(),
153
+ example: z.string().optional(),
154
+ })),
155
+ status: z.enum(TEMPLATE_STATUSES),
156
+ createdBy: z.uuid().nullable(),
157
+ createdAt: utcTimestampSchema,
158
+ activatedBy: z.uuid().nullable(),
159
+ activatedAt: utcTimestampSchema.nullable(),
160
+ });
161
+ export const templateListResponseSchema = z.array(templateResponseSchema);
162
+ /** Renders unsaved text, or a saved version, with the catalogue's sample values. */
163
+ export const previewTemplateSchema = z.union([
164
+ z.strictObject({ templateId: z.uuid() }),
165
+ z.strictObject(templateFields),
166
+ ]);
167
+ export const renderedMessageSchema = z.object({
168
+ channel: z.enum(COMMUNICATION_CHANNELS),
169
+ subject: z.string().nullable(),
170
+ bodyText: z.string(),
171
+ bodyHtml: z.string().nullable(),
172
+ /** SMS only: how many 160-character segments it takes. */
173
+ smsSegments: z.number().int().nullable(),
174
+ });
175
+ export const testSendResponseSchema = z.object({
176
+ channel: z.enum(COMMUNICATION_CHANNELS),
177
+ /** The signed-in admin's own address, masked, such as `b***@tbs.coach`. */
178
+ sentTo: z.string(),
179
+ });
180
+ /** A catalogue entry, with the built-in default template for each channel. */
181
+ export const messageTypeResponseSchema = z.object({
182
+ type: z.enum(MESSAGE_TYPES),
183
+ label: z.string(),
184
+ description: z.string(),
185
+ channels: z.array(z.enum(COMMUNICATION_CHANNELS)),
186
+ variables: z.array(z.object({
187
+ name: z.string(),
188
+ description: z.string(),
189
+ example: z.string(),
190
+ })),
191
+ requiredVariables: z.array(z.string()),
192
+ defaults: z.array(z.object({
193
+ channel: z.enum(COMMUNICATION_CHANNELS),
194
+ subject: z.string().nullable(),
195
+ bodyText: z.string(),
196
+ bodyHtml: z.string().nullable(),
197
+ variables: z.array(z.object({ name: z.string() })),
198
+ })),
199
+ });
200
+ export const messageTypeListResponseSchema = z.array(messageTypeResponseSchema);
@@ -12,9 +12,35 @@ export type AccountStatus = (typeof ACCOUNT_STATUSES)[number];
12
12
  /** The statuses an administrator may set. Verification is never set by hand. */
13
13
  export declare const ADMIN_SETTABLE_ACCOUNT_STATUSES: readonly ["active", "disabled"];
14
14
  export type AdminSettableAccountStatus = (typeof ADMIN_SETTABLE_ACCOUNT_STATUSES)[number];
15
+ /**
16
+ * Where a user's time zone came from. `AUTO` was detected from their browser
17
+ * and may be updated when they sign in elsewhere. `MANUAL` was chosen by the
18
+ * user, and only the user changes it.
19
+ */
20
+ export declare const TIMEZONE_SOURCES: readonly ["AUTO", "MANUAL"];
21
+ export type TimezoneSource = (typeof TIMEZONE_SOURCES)[number];
15
22
  export declare const TOKEN_TRANSPORTS: readonly ["body", "cookie"];
16
23
  export type TokenTransport = (typeof TOKEN_TRANSPORTS)[number];
17
24
  export declare const AUDIT_OUTCOMES: readonly ["success", "failure"];
18
25
  export type AuditOutcome = (typeof AUDIT_OUTCOMES)[number];
19
26
  export declare const FEATURE_FLAG_RULE_TYPES: readonly ["user", "customer", "trainer", "role"];
20
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];