@tbs-fitness/contract 0.3.0 → 0.5.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 (56) 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/auth/auth.dto.d.ts +4 -4
  7. package/dist/cjs/auth/index.d.ts +1 -0
  8. package/dist/cjs/auth/index.js +1 -0
  9. package/dist/cjs/auth/otp.dto.d.ts +45 -0
  10. package/dist/cjs/auth/otp.dto.js +39 -0
  11. package/dist/cjs/client/client-auth.dto.d.ts +81 -0
  12. package/dist/cjs/client/client-auth.dto.js +49 -0
  13. package/dist/cjs/client/index.d.ts +2 -0
  14. package/dist/cjs/client/index.js +18 -0
  15. package/dist/cjs/coach/coach-auth.dto.d.ts +0 -40
  16. package/dist/cjs/coach/coach-auth.dto.js +1 -30
  17. package/dist/cjs/coach/index.d.ts +1 -0
  18. package/dist/cjs/coach/index.js +1 -0
  19. package/dist/cjs/communication/index.d.ts +2 -0
  20. package/dist/cjs/communication/index.js +18 -0
  21. package/dist/cjs/communication/message-catalogue.d.ts +26 -0
  22. package/dist/cjs/communication/message-catalogue.js +53 -0
  23. package/dist/cjs/communication/template.dto.d.ts +240 -0
  24. package/dist/cjs/communication/template.dto.js +206 -0
  25. package/dist/cjs/enums/index.d.ts +21 -2
  26. package/dist/cjs/enums/index.js +28 -3
  27. package/dist/cjs/feature-flags/feature-flag.dto.d.ts +4 -4
  28. package/dist/cjs/feature-flags/feature-flag.dto.js +1 -1
  29. package/dist/esm/app-settings/app-settings.dto.d.ts +124 -0
  30. package/dist/esm/app-settings/app-settings.dto.js +90 -0
  31. package/dist/esm/app-settings/index.d.ts +1 -0
  32. package/dist/esm/app-settings/index.js +1 -0
  33. package/dist/esm/auth/auth.dto.d.ts +4 -4
  34. package/dist/esm/auth/index.d.ts +1 -0
  35. package/dist/esm/auth/index.js +1 -0
  36. package/dist/esm/auth/otp.dto.d.ts +45 -0
  37. package/dist/esm/auth/otp.dto.js +36 -0
  38. package/dist/esm/client/client-auth.dto.d.ts +81 -0
  39. package/dist/esm/client/client-auth.dto.js +46 -0
  40. package/dist/esm/client/index.d.ts +2 -0
  41. package/dist/esm/client/index.js +2 -0
  42. package/dist/esm/coach/coach-auth.dto.d.ts +0 -40
  43. package/dist/esm/coach/coach-auth.dto.js +1 -30
  44. package/dist/esm/coach/index.d.ts +1 -0
  45. package/dist/esm/coach/index.js +1 -0
  46. package/dist/esm/communication/index.d.ts +2 -0
  47. package/dist/esm/communication/index.js +2 -0
  48. package/dist/esm/communication/message-catalogue.d.ts +26 -0
  49. package/dist/esm/communication/message-catalogue.js +50 -0
  50. package/dist/esm/communication/template.dto.d.ts +240 -0
  51. package/dist/esm/communication/template.dto.js +200 -0
  52. package/dist/esm/enums/index.d.ts +21 -2
  53. package/dist/esm/enums/index.js +27 -2
  54. package/dist/esm/feature-flags/feature-flag.dto.d.ts +4 -4
  55. package/dist/esm/feature-flags/feature-flag.dto.js +1 -1
  56. package/package.json +31 -1
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { loginSchema, newPasswordSchema, opaqueTokenSchema, otpCodeSchema, timezonePreferenceSchema, tokenResponseSchema, } from '../auth/auth.dto.js';
2
+ import { loginSchema, newPasswordSchema, timezonePreferenceSchema, tokenResponseSchema, } from '../auth/auth.dto.js';
3
3
  import { emailSchema, phoneSchema } from '../common/contact.schema.js';
4
4
  import { detectedTimezoneSchema, utcTimestampSchema, } from '../common/date-time.schema.js';
5
5
  import { safeTextSchema } from '../common/safe-text.schema.js';
@@ -12,12 +12,6 @@ export const coachSignupSchema = z.strictObject({
12
12
  /** The browser's time zone. Missing or unusable becomes UTC on the server. */
13
13
  timezone: detectedTimezoneSchema,
14
14
  });
15
- /** Used by both resend endpoints and by forgot-password. */
16
- export const otpEmailSchema = z.strictObject({ email: emailSchema });
17
- export const verifyOtpSchema = z.strictObject({
18
- email: emailSchema,
19
- code: otpCodeSchema,
20
- });
21
15
  /**
22
16
  * Coaches sign in from the browser, so the refresh token always travels in an
23
17
  * HttpOnly cookie. `rememberMe: false` makes that cookie end with the browser
@@ -33,29 +27,6 @@ export const coachLoginSchema = loginSchema
33
27
  */
34
28
  timezone: detectedTimezoneSchema,
35
29
  });
36
- export const resetPasswordSchema = z.strictObject({
37
- resetToken: opaqueTokenSchema,
38
- newPassword: newPasswordSchema,
39
- });
40
- /**
41
- * Says what the client needs to drive the code screen, and nothing about
42
- * whether the email belongs to an account: the same values are returned either
43
- * way.
44
- */
45
- export const otpDispatchResponseSchema = z.object({
46
- message: z.string(),
47
- otpLength: z.number().int(),
48
- expiresInSeconds: z.number().int(),
49
- resendAvailableInSeconds: z.number().int(),
50
- });
51
- export const signupVerifiedResponseSchema = z.object({
52
- message: z.string(),
53
- });
54
- /** Issued only after a recovery code is verified. It can reset a password once. */
55
- export const resetTokenResponseSchema = z.object({
56
- resetToken: z.string(),
57
- expiresInSeconds: z.number().int(),
58
- });
59
30
  export const coachProfileSchema = z
60
31
  .object({
61
32
  id: z.uuid(),
@@ -1 +1,2 @@
1
+ export * from '../auth/otp.dto.js';
1
2
  export * from './coach-auth.dto.js';
@@ -1 +1,2 @@
1
+ export * from '../auth/otp.dto.js';
1
2
  export * from './coach-auth.dto.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);
@@ -1,7 +1,7 @@
1
1
  export declare const APP_ENVIRONMENTS: readonly ["development", "staging", "production"];
2
2
  export type AppEnvironment = (typeof APP_ENVIRONMENTS)[number];
3
3
  /** Each user has exactly one role. A new role is added here and nowhere else. */
4
- export declare const ROLES: readonly ["ADMIN", "COACH", "CUSTOMER"];
4
+ export declare const ROLES: readonly ["ADMIN", "COACH", "CLIENT"];
5
5
  export type Role = (typeof ROLES)[number];
6
6
  /**
7
7
  * `pending_verification` is a self-registered user who has not yet proved they
@@ -23,5 +23,24 @@ export declare const TOKEN_TRANSPORTS: readonly ["body", "cookie"];
23
23
  export type TokenTransport = (typeof TOKEN_TRANSPORTS)[number];
24
24
  export declare const AUDIT_OUTCOMES: readonly ["success", "failure"];
25
25
  export type AuditOutcome = (typeof AUDIT_OUTCOMES)[number];
26
- export declare const FEATURE_FLAG_RULE_TYPES: readonly ["user", "customer", "trainer", "role"];
26
+ export declare const FEATURE_FLAG_RULE_TYPES: readonly ["user", "client", "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];
@@ -6,7 +6,7 @@ export const APP_ENVIRONMENTS = [
6
6
  'production',
7
7
  ];
8
8
  /** Each user has exactly one role. A new role is added here and nowhere else. */
9
- export const ROLES = ['ADMIN', 'COACH', 'CUSTOMER'];
9
+ export const ROLES = ['ADMIN', 'COACH', 'CLIENT'];
10
10
  /**
11
11
  * `pending_verification` is a self-registered user who has not yet proved they
12
12
  * own their email address. Such a user cannot sign in.
@@ -28,7 +28,32 @@ export const TOKEN_TRANSPORTS = ['body', 'cookie'];
28
28
  export const AUDIT_OUTCOMES = ['success', 'failure'];
29
29
  export const FEATURE_FLAG_RULE_TYPES = [
30
30
  'user',
31
- 'customer',
31
+ 'client',
32
32
  'trainer',
33
33
  'role',
34
34
  ];
35
+ /**
36
+ * The ways a message can reach someone. WhatsApp and push are added here when
37
+ * their channel modules are built.
38
+ */
39
+ export const COMMUNICATION_CHANNELS = ['EMAIL', 'SMS'];
40
+ /**
41
+ * Every message the platform sends. Each has a catalogue entry in
42
+ * `@tbs-fitness/contract/communication` saying which channels may carry it and
43
+ * which variables its sender supplies.
44
+ */
45
+ export const MESSAGE_TYPES = [
46
+ 'auth.otp.signup_verification',
47
+ 'auth.otp.password_reset',
48
+ 'auth.password_reset_link',
49
+ ];
50
+ /** What happened to one message on one channel. */
51
+ export const DELIVERY_STATUSES = [
52
+ 'sent',
53
+ 'skipped_disabled',
54
+ 'skipped_unreachable',
55
+ 'failed',
56
+ 'failed_missing_variables',
57
+ ];
58
+ /** Only one version of a template per message type and channel is active. */
59
+ export const TEMPLATE_STATUSES = ['draft', 'active', 'archived'];