@tbs-fitness/contract 0.2.0 → 0.3.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 CHANGED
@@ -41,6 +41,16 @@ const result = createFlagSchema.safeParse(input); // The same rules as the API.
41
41
 
42
42
  Requests have two types. Clients use `*Input`, where fields with a default are optional. The backend uses `*Body`, which is the result after validation. List queries come from the URL as strings, so they have no `*Input`: use `Partial<PaginationQuery>` or `Partial<AuditLogQuery>`.
43
43
 
44
+ ## Version 0.3.0
45
+
46
+ Time zones and the platform's date and time rules. Additive, but a caret range on 0.x does not cross minor versions, so consumers install `^0.3.0` explicitly.
47
+
48
+ - `/common`: `DEFAULT_TIMEZONE` (`UTC`), `isValidTimezone`, `canonicalTimezone` (the current IANA name for a renamed zone, such as `Asia/Kolkata` for `Asia/Calcutta`, which Node and some browsers still report; both schemas below apply it), `timezoneSchema` (an IANA `Area/Location` name or `UTC`; abbreviations such as `IST` and offsets such as `+05:30` are refused), `detectedTimezoneSchema` (a browser suggestion: unusable values become `undefined`), `utcTimestampSchema` (response instants, `Z` only), `instantInputSchema` (request instants, `Z` or an explicit offset, never a bare local time) and `dateOnlySchema` (`YYYY-MM-DD`, never shifted between zones).
49
+ - `/enums`: `TIMEZONE_SOURCES` (`AUTO`, `MANUAL`).
50
+ - `/auth`: `timezonePreferenceSchema`, `updateTimezoneSchema` (`{ timezone, source? }`, source defaults to `MANUAL`), and `timezone` and `timezoneSource` on `accountResponseSchema`.
51
+ - `/coach`: an optional `timezone` on `coachSignupSchema` and `coachLoginSchema`, and `timezone` and `timezoneSource` on `coachProfileSchema`. Both response fields are required, so deploy the backend before a client built on 0.3.0.
52
+ - Every response timestamp now uses `utcTimestampSchema`, which is the same rule as before. Feature-flag request timestamps use `instantInputSchema`.
53
+
44
54
  ## Version 0.2.0 (breaking)
45
55
 
46
56
  - `ROLES` is now `ADMIN`, `COACH` and `CUSTOMER`. A user has exactly one role.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.auditLogResponseSchema = exports.auditLogQuerySchema = void 0;
4
4
  const zod_1 = require("zod");
5
+ const date_time_schema_js_1 = require("../common/date-time.schema.js");
5
6
  const pagination_schema_js_1 = require("../common/pagination.schema.js");
6
7
  const index_js_1 = require("../enums/index.js");
7
8
  exports.auditLogQuerySchema = pagination_schema_js_1.paginationQuerySchema.extend({
@@ -20,5 +21,5 @@ exports.auditLogResponseSchema = zod_1.z.object({
20
21
  requestId: zod_1.z.string().nullable(),
21
22
  correlationId: zod_1.z.string().nullable(),
22
23
  metadata: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).nullable(),
23
- createdAt: zod_1.z.iso.datetime(),
24
+ createdAt: date_time_schema_js_1.utcTimestampSchema,
24
25
  });
@@ -68,6 +68,32 @@ export declare const tokenResponseSchema: z.ZodObject<{
68
68
  sessionId: z.ZodUUID;
69
69
  }, z.core.$strip>;
70
70
  export type TokenResponse = z.output<typeof tokenResponseSchema>;
71
+ /**
72
+ * A user's time zone preference: an IANA zone, and whether it was detected
73
+ * (`AUTO`) or chosen (`MANUAL`).
74
+ */
75
+ export declare const timezonePreferenceSchema: z.ZodObject<{
76
+ timezone: z.ZodString;
77
+ timezoneSource: z.ZodEnum<{
78
+ AUTO: "AUTO";
79
+ MANUAL: "MANUAL";
80
+ }>;
81
+ }, z.core.$strip>;
82
+ export type TimezonePreference = z.output<typeof timezonePreferenceSchema>;
83
+ /**
84
+ * Sets the signed-in user's own time zone. `MANUAL` (the default) is a choice
85
+ * the user made. `AUTO` records a detected zone that a later sign-in may
86
+ * replace.
87
+ */
88
+ export declare const updateTimezoneSchema: z.ZodObject<{
89
+ timezone: z.ZodPipe<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>, z.ZodTransform<string, string>>;
90
+ source: z.ZodDefault<z.ZodEnum<{
91
+ AUTO: "AUTO";
92
+ MANUAL: "MANUAL";
93
+ }>>;
94
+ }, z.core.$strict>;
95
+ export type UpdateTimezoneInput = z.input<typeof updateTimezoneSchema>;
96
+ export type UpdateTimezoneBody = z.output<typeof updateTimezoneSchema>;
71
97
  export declare const accountResponseSchema: z.ZodObject<{
72
98
  id: z.ZodUUID;
73
99
  email: z.ZodString;
@@ -79,6 +105,11 @@ export declare const accountResponseSchema: z.ZodObject<{
79
105
  roles: z.ZodArray<z.ZodString>;
80
106
  permissions: z.ZodArray<z.ZodString>;
81
107
  lastLoginAt: z.ZodNullable<z.ZodISODateTime>;
108
+ timezone: z.ZodString;
109
+ timezoneSource: z.ZodEnum<{
110
+ AUTO: "AUTO";
111
+ MANUAL: "MANUAL";
112
+ }>;
82
113
  }, z.core.$strip>;
83
114
  export type AccountResponse = z.output<typeof accountResponseSchema>;
84
115
  export declare const loginResponseSchema: z.ZodObject<{
@@ -99,6 +130,11 @@ export declare const loginResponseSchema: z.ZodObject<{
99
130
  roles: z.ZodArray<z.ZodString>;
100
131
  permissions: z.ZodArray<z.ZodString>;
101
132
  lastLoginAt: z.ZodNullable<z.ZodISODateTime>;
133
+ timezone: z.ZodString;
134
+ timezoneSource: z.ZodEnum<{
135
+ AUTO: "AUTO";
136
+ MANUAL: "MANUAL";
137
+ }>;
102
138
  }, z.core.$strip>;
103
139
  }, z.core.$strip>;
104
140
  export type LoginResponse = z.output<typeof loginResponseSchema>;
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.revokedSessionsResponseSchema = exports.adminAccountResponseSchema = exports.passwordResetRequestedResponseSchema = exports.logoutAllResponseSchema = exports.sessionResponseSchema = exports.loginResponseSchema = exports.accountResponseSchema = exports.tokenResponseSchema = exports.idParamSchema = exports.setStatusSchema = exports.setRoleSchema = exports.passwordResetConfirmSchema = exports.passwordResetRequestSchema = exports.changePasswordSchema = exports.refreshSchema = exports.loginSchema = exports.opaqueTokenSchema = exports.submittedPasswordSchema = exports.newPasswordSchema = exports.otpCodeSchema = exports.OTP_LENGTH = exports.MAX_PASSWORD_LENGTH = exports.MIN_PASSWORD_LENGTH = void 0;
3
+ exports.revokedSessionsResponseSchema = exports.adminAccountResponseSchema = exports.passwordResetRequestedResponseSchema = exports.logoutAllResponseSchema = exports.sessionResponseSchema = exports.loginResponseSchema = exports.accountResponseSchema = exports.updateTimezoneSchema = exports.timezonePreferenceSchema = exports.tokenResponseSchema = exports.idParamSchema = exports.setStatusSchema = exports.setRoleSchema = exports.passwordResetConfirmSchema = exports.passwordResetRequestSchema = exports.changePasswordSchema = exports.refreshSchema = exports.loginSchema = exports.opaqueTokenSchema = exports.submittedPasswordSchema = exports.newPasswordSchema = exports.otpCodeSchema = exports.OTP_LENGTH = exports.MAX_PASSWORD_LENGTH = exports.MIN_PASSWORD_LENGTH = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const contact_schema_js_1 = require("../common/contact.schema.js");
6
+ const date_time_schema_js_1 = require("../common/date-time.schema.js");
6
7
  const safe_text_schema_js_1 = require("../common/safe-text.schema.js");
7
8
  const index_js_1 = require("../enums/index.js");
8
9
  // Per OWASP: length is what matters, with no composition rules. The upper limit
@@ -65,15 +66,34 @@ exports.tokenResponseSchema = zod_1.z.object({
65
66
  csrfToken: zod_1.z.string().optional(),
66
67
  sessionId: zod_1.z.uuid(),
67
68
  });
68
- exports.accountResponseSchema = zod_1.z.object({
69
+ /**
70
+ * A user's time zone preference: an IANA zone, and whether it was detected
71
+ * (`AUTO`) or chosen (`MANUAL`).
72
+ */
73
+ exports.timezonePreferenceSchema = zod_1.z.object({
74
+ timezone: zod_1.z.string(),
75
+ timezoneSource: zod_1.z.enum(index_js_1.TIMEZONE_SOURCES),
76
+ });
77
+ /**
78
+ * Sets the signed-in user's own time zone. `MANUAL` (the default) is a choice
79
+ * the user made. `AUTO` records a detected zone that a later sign-in may
80
+ * replace.
81
+ */
82
+ exports.updateTimezoneSchema = zod_1.z.strictObject({
83
+ timezone: date_time_schema_js_1.timezoneSchema,
84
+ source: zod_1.z.enum(index_js_1.TIMEZONE_SOURCES).default('MANUAL'),
85
+ });
86
+ exports.accountResponseSchema = zod_1.z
87
+ .object({
69
88
  id: zod_1.z.uuid(),
70
89
  email: zod_1.z.string(),
71
90
  role: zod_1.z.enum(index_js_1.ROLES),
72
91
  /** @deprecated Always `[role]`. Kept for clients written before `role`. */
73
92
  roles: zod_1.z.array(zod_1.z.string()),
74
93
  permissions: zod_1.z.array(zod_1.z.string()),
75
- lastLoginAt: zod_1.z.iso.datetime().nullable(),
76
- });
94
+ lastLoginAt: date_time_schema_js_1.utcTimestampSchema.nullable(),
95
+ })
96
+ .extend(exports.timezonePreferenceSchema.shape);
77
97
  exports.loginResponseSchema = exports.tokenResponseSchema.extend({
78
98
  account: exports.accountResponseSchema,
79
99
  });
@@ -81,9 +101,9 @@ exports.sessionResponseSchema = zod_1.z.object({
81
101
  id: zod_1.z.uuid(),
82
102
  deviceName: zod_1.z.string().nullable(),
83
103
  userAgent: zod_1.z.string().nullable(),
84
- createdAt: zod_1.z.iso.datetime(),
85
- lastUsedAt: zod_1.z.iso.datetime(),
86
- expiresAt: zod_1.z.iso.datetime(),
104
+ createdAt: date_time_schema_js_1.utcTimestampSchema,
105
+ lastUsedAt: date_time_schema_js_1.utcTimestampSchema,
106
+ expiresAt: date_time_schema_js_1.utcTimestampSchema,
87
107
  current: zod_1.z.boolean(),
88
108
  });
89
109
  exports.logoutAllResponseSchema = zod_1.z.object({
@@ -97,7 +117,7 @@ exports.adminAccountResponseSchema = zod_1.z.object({
97
117
  email: zod_1.z.string(),
98
118
  role: zod_1.z.enum(index_js_1.ROLES),
99
119
  status: zod_1.z.enum(index_js_1.ACCOUNT_STATUSES),
100
- lastLoginAt: zod_1.z.iso.datetime().nullable(),
120
+ lastLoginAt: date_time_schema_js_1.utcTimestampSchema.nullable(),
101
121
  });
102
122
  exports.revokedSessionsResponseSchema = zod_1.z.object({
103
123
  revokedSessions: zod_1.z.number().int(),
@@ -4,6 +4,7 @@ export declare const coachSignupSchema: z.ZodObject<{
4
4
  email: z.ZodPipe<z.ZodString, z.ZodEmail>;
5
5
  phone: z.ZodOptional<z.ZodString>;
6
6
  password: z.ZodString;
7
+ timezone: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<string | undefined, unknown>>>;
7
8
  }, z.core.$strict>;
8
9
  export type CoachSignupInput = z.input<typeof coachSignupSchema>;
9
10
  export type CoachSignupBody = z.output<typeof coachSignupSchema>;
@@ -29,6 +30,7 @@ export declare const coachLoginSchema: z.ZodObject<{
29
30
  password: z.ZodString;
30
31
  deviceName: z.ZodOptional<z.ZodString>;
31
32
  rememberMe: z.ZodDefault<z.ZodBoolean>;
33
+ timezone: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<string | undefined, unknown>>>;
32
34
  }, z.core.$strict>;
33
35
  export type CoachLoginInput = z.input<typeof coachLoginSchema>;
34
36
  export type CoachLoginBody = z.output<typeof coachLoginSchema>;
@@ -74,6 +76,11 @@ export declare const coachProfileSchema: z.ZodObject<{
74
76
  emailVerifiedAt: z.ZodNullable<z.ZodISODateTime>;
75
77
  lastLoginAt: z.ZodNullable<z.ZodISODateTime>;
76
78
  createdAt: z.ZodISODateTime;
79
+ timezone: z.ZodString;
80
+ timezoneSource: z.ZodEnum<{
81
+ AUTO: "AUTO";
82
+ MANUAL: "MANUAL";
83
+ }>;
77
84
  }, z.core.$strip>;
78
85
  export type CoachProfile = z.output<typeof coachProfileSchema>;
79
86
  export declare const coachLoginResponseSchema: z.ZodObject<{
@@ -97,6 +104,11 @@ export declare const coachLoginResponseSchema: z.ZodObject<{
97
104
  emailVerifiedAt: z.ZodNullable<z.ZodISODateTime>;
98
105
  lastLoginAt: z.ZodNullable<z.ZodISODateTime>;
99
106
  createdAt: z.ZodISODateTime;
107
+ timezone: z.ZodString;
108
+ timezoneSource: z.ZodEnum<{
109
+ AUTO: "AUTO";
110
+ MANUAL: "MANUAL";
111
+ }>;
100
112
  }, z.core.$strip>;
101
113
  }, z.core.$strip>;
102
114
  export type CoachLoginResponse = z.output<typeof coachLoginResponseSchema>;
@@ -4,6 +4,7 @@ exports.coachLoginResponseSchema = exports.coachProfileSchema = exports.resetTok
4
4
  const zod_1 = require("zod");
5
5
  const auth_dto_js_1 = require("../auth/auth.dto.js");
6
6
  const contact_schema_js_1 = require("../common/contact.schema.js");
7
+ const date_time_schema_js_1 = require("../common/date-time.schema.js");
7
8
  const safe_text_schema_js_1 = require("../common/safe-text.schema.js");
8
9
  const index_js_1 = require("../enums/index.js");
9
10
  exports.coachSignupSchema = zod_1.z.strictObject({
@@ -11,6 +12,8 @@ exports.coachSignupSchema = zod_1.z.strictObject({
11
12
  email: contact_schema_js_1.emailSchema,
12
13
  phone: contact_schema_js_1.phoneSchema.optional(),
13
14
  password: auth_dto_js_1.newPasswordSchema,
15
+ /** The browser's time zone. Missing or unusable becomes UTC on the server. */
16
+ timezone: date_time_schema_js_1.detectedTimezoneSchema,
14
17
  });
15
18
  /** Used by both resend endpoints and by forgot-password. */
16
19
  exports.otpEmailSchema = zod_1.z.strictObject({ email: contact_schema_js_1.emailSchema });
@@ -25,7 +28,14 @@ exports.verifyOtpSchema = zod_1.z.strictObject({
25
28
  */
26
29
  exports.coachLoginSchema = auth_dto_js_1.loginSchema
27
30
  .omit({ tokenTransport: true })
28
- .extend({ rememberMe: zod_1.z.boolean().default(true) });
31
+ .extend({
32
+ rememberMe: zod_1.z.boolean().default(true),
33
+ /**
34
+ * The browser's time zone. It replaces the saved one only while the
35
+ * coach's preference is `AUTO`; a manual choice is never overwritten.
36
+ */
37
+ timezone: date_time_schema_js_1.detectedTimezoneSchema,
38
+ });
29
39
  exports.resetPasswordSchema = zod_1.z.strictObject({
30
40
  resetToken: auth_dto_js_1.opaqueTokenSchema,
31
41
  newPassword: auth_dto_js_1.newPasswordSchema,
@@ -49,17 +59,19 @@ exports.resetTokenResponseSchema = zod_1.z.object({
49
59
  resetToken: zod_1.z.string(),
50
60
  expiresInSeconds: zod_1.z.number().int(),
51
61
  });
52
- exports.coachProfileSchema = zod_1.z.object({
62
+ exports.coachProfileSchema = zod_1.z
63
+ .object({
53
64
  id: zod_1.z.uuid(),
54
65
  email: zod_1.z.string(),
55
66
  fullName: zod_1.z.string().nullable(),
56
67
  phone: zod_1.z.string().nullable(),
57
68
  role: zod_1.z.literal('COACH'),
58
69
  status: zod_1.z.enum(index_js_1.ACCOUNT_STATUSES),
59
- emailVerifiedAt: zod_1.z.iso.datetime().nullable(),
60
- lastLoginAt: zod_1.z.iso.datetime().nullable(),
61
- createdAt: zod_1.z.iso.datetime(),
62
- });
70
+ emailVerifiedAt: date_time_schema_js_1.utcTimestampSchema.nullable(),
71
+ lastLoginAt: date_time_schema_js_1.utcTimestampSchema.nullable(),
72
+ createdAt: date_time_schema_js_1.utcTimestampSchema,
73
+ })
74
+ .extend(auth_dto_js_1.timezonePreferenceSchema.shape);
63
75
  exports.coachLoginResponseSchema = auth_dto_js_1.tokenResponseSchema.extend({
64
76
  coach: exports.coachProfileSchema,
65
77
  });
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.paginatedResponseSchema = exports.dataResponseSchema = exports.apiErrorResponseSchema = void 0;
4
4
  const zod_1 = require("zod");
5
+ const date_time_schema_js_1 = require("./date-time.schema.js");
5
6
  const pagination_schema_js_1 = require("./pagination.schema.js");
6
7
  exports.apiErrorResponseSchema = zod_1.z.object({
7
8
  error: zod_1.z.object({
@@ -16,7 +17,7 @@ exports.apiErrorResponseSchema = zod_1.z.object({
16
17
  }))
17
18
  .optional(),
18
19
  requestId: zod_1.z.string(),
19
- timestamp: zod_1.z.iso.datetime(),
20
+ timestamp: date_time_schema_js_1.utcTimestampSchema,
20
21
  path: zod_1.z.string(),
21
22
  }),
22
23
  });
@@ -0,0 +1,39 @@
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 declare const DEFAULT_TIMEZONE = "UTC";
16
+ /**
17
+ * True for `UTC` and for an IANA `Area/Location` name the runtime's time zone
18
+ * database knows. Runs anywhere `Intl` does, so the browser and the server
19
+ * agree.
20
+ */
21
+ export declare function isValidTimezone(value: unknown): value is string;
22
+ /** The current IANA name for a zone, such as `Asia/Kolkata` for `Asia/Calcutta`. */
23
+ export declare function canonicalTimezone(value: string): string;
24
+ export declare const timezoneSchema: z.ZodPipe<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>, z.ZodTransform<string, string>>;
25
+ /**
26
+ * A time zone the client detected and suggests, such as at sign-up. A missing
27
+ * or unusable value becomes `undefined` instead of failing the request, and the
28
+ * server then applies its default.
29
+ */
30
+ export declare const detectedTimezoneSchema: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<string | undefined, unknown>>>;
31
+ /** An instant in a response: ISO 8601 in UTC, ending in `Z`. */
32
+ export declare const utcTimestampSchema: z.ZodISODateTime;
33
+ /**
34
+ * An instant in a request: ISO 8601 with `Z` or an explicit offset, such as
35
+ * `2026-11-02T15:00:00+05:30`. A local time without either is refused.
36
+ */
37
+ export declare const instantInputSchema: z.ZodISODateTime;
38
+ /** A calendar date with no time and no zone, such as `2026-11-02`. */
39
+ export declare const dateOnlySchema: z.ZodString;
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dateOnlySchema = exports.instantInputSchema = exports.utcTimestampSchema = exports.detectedTimezoneSchema = exports.timezoneSchema = exports.DEFAULT_TIMEZONE = void 0;
4
+ exports.isValidTimezone = isValidTimezone;
5
+ exports.canonicalTimezone = canonicalTimezone;
6
+ const zod_1 = require("zod");
7
+ /**
8
+ * The platform's date and time rules, shared so the backend and the apps
9
+ * validate them identically:
10
+ *
11
+ * - An instant (something that happened at one moment) travels as ISO 8601.
12
+ * Responses always use UTC with `Z`. Requests may use `Z` or an explicit
13
+ * offset, but never a bare local time, which would be ambiguous.
14
+ * - A date-only value (a birthday, an expiry date) travels as `YYYY-MM-DD` and
15
+ * is never converted between time zones.
16
+ * - A time zone is an IANA identifier such as `Asia/Kolkata`. Abbreviations
17
+ * (`IST`) and fixed offsets (`+05:30`) are refused, because offsets change
18
+ * with daylight saving.
19
+ */
20
+ exports.DEFAULT_TIMEZONE = 'UTC';
21
+ const MAX_TIMEZONE_LENGTH = 64;
22
+ // The IANA areas used for real places. `Etc/*` and the legacy aliases
23
+ // (`US/Eastern`, `EST`) are left out: the first are fixed offsets, and the
24
+ // second have modern names.
25
+ const AREA_LOCATION = /^(?:Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)\/[A-Za-z0-9_+-]+(?:\/[A-Za-z0-9_+-]+)?$/;
26
+ /**
27
+ * True for `UTC` and for an IANA `Area/Location` name the runtime's time zone
28
+ * database knows. Runs anywhere `Intl` does, so the browser and the server
29
+ * agree.
30
+ */
31
+ function isValidTimezone(value) {
32
+ if (typeof value !== 'string' || value.length > MAX_TIMEZONE_LENGTH) {
33
+ return false;
34
+ }
35
+ if (value !== exports.DEFAULT_TIMEZONE && !AREA_LOCATION.test(value)) {
36
+ return false;
37
+ }
38
+ try {
39
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ // Browsers and Node report some zones by names IANA has since replaced, because
47
+ // their Unicode data keeps the old ones. Saving the current name means one
48
+ // place is stored, compared and shown one way everywhere.
49
+ const RENAMED_TIMEZONES = {
50
+ 'Asia/Calcutta': 'Asia/Kolkata',
51
+ 'Asia/Saigon': 'Asia/Ho_Chi_Minh',
52
+ 'Asia/Katmandu': 'Asia/Kathmandu',
53
+ 'Asia/Rangoon': 'Asia/Yangon',
54
+ 'Europe/Kiev': 'Europe/Kyiv',
55
+ 'Europe/Uzhgorod': 'Europe/Kyiv',
56
+ 'Europe/Zaporozhye': 'Europe/Kyiv',
57
+ 'Atlantic/Faeroe': 'Atlantic/Faroe',
58
+ 'America/Godthab': 'America/Nuuk',
59
+ 'Pacific/Enderbury': 'Pacific/Kanton',
60
+ 'Pacific/Truk': 'Pacific/Chuuk',
61
+ 'Pacific/Ponape': 'Pacific/Pohnpei',
62
+ 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires',
63
+ 'America/Catamarca': 'America/Argentina/Catamarca',
64
+ 'America/Cordoba': 'America/Argentina/Cordoba',
65
+ 'America/Jujuy': 'America/Argentina/Jujuy',
66
+ 'America/Mendoza': 'America/Argentina/Mendoza',
67
+ 'America/Indianapolis': 'America/Indiana/Indianapolis',
68
+ 'America/Louisville': 'America/Kentucky/Louisville',
69
+ };
70
+ /** The current IANA name for a zone, such as `Asia/Kolkata` for `Asia/Calcutta`. */
71
+ function canonicalTimezone(value) {
72
+ return RENAMED_TIMEZONES[value] ?? value;
73
+ }
74
+ exports.timezoneSchema = zod_1.z
75
+ .string()
76
+ .trim()
77
+ .refine(isValidTimezone, {
78
+ error: 'Choose a time zone such as Asia/Kolkata',
79
+ })
80
+ .transform(canonicalTimezone);
81
+ /**
82
+ * A time zone the client detected and suggests, such as at sign-up. A missing
83
+ * or unusable value becomes `undefined` instead of failing the request, and the
84
+ * server then applies its default.
85
+ */
86
+ exports.detectedTimezoneSchema = zod_1.z
87
+ .unknown()
88
+ .transform((value) => {
89
+ const trimmed = typeof value === 'string' ? value.trim() : value;
90
+ return isValidTimezone(trimmed) ? canonicalTimezone(trimmed) : undefined;
91
+ })
92
+ .optional();
93
+ /** An instant in a response: ISO 8601 in UTC, ending in `Z`. */
94
+ exports.utcTimestampSchema = zod_1.z.iso.datetime();
95
+ /**
96
+ * An instant in a request: ISO 8601 with `Z` or an explicit offset, such as
97
+ * `2026-11-02T15:00:00+05:30`. A local time without either is refused.
98
+ */
99
+ exports.instantInputSchema = zod_1.z.iso.datetime({ offset: true });
100
+ /** A calendar date with no time and no zone, such as `2026-11-02`. */
101
+ exports.dateOnlySchema = zod_1.z
102
+ .string()
103
+ .regex(/^\d{4}-\d{2}-\d{2}$/, { error: 'Use the format YYYY-MM-DD' })
104
+ .refine(isRealCalendarDate, { error: 'Enter a real calendar date' });
105
+ function isRealCalendarDate(value) {
106
+ const [year, month, day] = value.split('-').map(Number);
107
+ // UTC is used only to check the calendar, so no zone can move the date.
108
+ const date = new Date(Date.UTC(year, month - 1, day));
109
+ return (date.getUTCFullYear() === year &&
110
+ date.getUTCMonth() === month - 1 &&
111
+ date.getUTCDate() === day);
112
+ }
@@ -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';
@@ -16,5 +16,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./api-response.schema.js"), exports);
18
18
  __exportStar(require("./contact.schema.js"), exports);
19
+ __exportStar(require("./date-time.schema.js"), exports);
19
20
  __exportStar(require("./pagination.schema.js"), exports);
20
21
  __exportStar(require("./safe-text.schema.js"), exports);
@@ -12,6 +12,13 @@ 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"];
@@ -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.ADMIN_SETTABLE_ACCOUNT_STATUSES = exports.ACCOUNT_STATUSES = exports.ROLES = exports.APP_ENVIRONMENTS = void 0;
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;
6
6
  exports.APP_ENVIRONMENTS = [
7
7
  'development',
8
8
  'staging',
@@ -21,6 +21,12 @@ exports.ACCOUNT_STATUSES = [
21
21
  ];
22
22
  /** The statuses an administrator may set. Verification is never set by hand. */
23
23
  exports.ADMIN_SETTABLE_ACCOUNT_STATUSES = ['active', 'disabled'];
24
+ /**
25
+ * Where a user's time zone came from. `AUTO` was detected from their browser
26
+ * and may be updated when they sign in elsewhere. `MANUAL` was chosen by the
27
+ * user, and only the user changes it.
28
+ */
29
+ exports.TIMEZONE_SOURCES = ['AUTO', 'MANUAL'];
24
30
  exports.TOKEN_TRANSPORTS = ['body', 'cookie'];
25
31
  exports.AUDIT_OUTCOMES = ['success', 'failure'];
26
32
  exports.FEATURE_FLAG_RULE_TYPES = [
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.killSwitchResponseSchema = exports.auditLogResponseSchema = exports.flagResponseSchema = exports.ruleResponseSchema = exports.flagListQuerySchema = exports.setKillSwitchSchema = exports.updateRuleSchema = exports.createRuleSchema = exports.updateFlagSchema = exports.createFlagSchema = exports.flagIdParamSchema = void 0;
4
4
  const zod_1 = require("zod");
5
+ const date_time_schema_js_1 = require("../common/date-time.schema.js");
5
6
  const pagination_schema_js_1 = require("../common/pagination.schema.js");
6
7
  const safe_text_schema_js_1 = require("../common/safe-text.schema.js");
7
8
  const index_js_1 = require("../enums/index.js");
@@ -17,7 +18,7 @@ const keySchema = zod_1.z
17
18
  const nameSchema = (0, safe_text_schema_js_1.safeTextSchema)(120);
18
19
  const descriptionSchema = (0, safe_text_schema_js_1.safeTextSchema)(500);
19
20
  const percentageSchema = zod_1.z.number().int().min(0).max(100);
20
- const timestampSchema = zod_1.z.iso.datetime({ offset: true });
21
+ const timestampSchema = date_time_schema_js_1.instantInputSchema;
21
22
  /** Lower-cased, so that it matches IDs as they come from the database. */
22
23
  const accountIdSchema = zod_1.z.string().toLowerCase().pipe(zod_1.z.uuid());
23
24
  const scheduleIsOrdered = ({ activationAt, expiresAt, }) => !activationAt || !expiresAt || new Date(activationAt) < new Date(expiresAt);
@@ -95,8 +96,8 @@ exports.ruleResponseSchema = zod_1.z.object({
95
96
  ruleType: zod_1.z.enum(index_js_1.FEATURE_FLAG_RULE_TYPES),
96
97
  ruleValue: zod_1.z.string(),
97
98
  enabled: zod_1.z.boolean(),
98
- createdAt: zod_1.z.iso.datetime(),
99
- updatedAt: zod_1.z.iso.datetime(),
99
+ createdAt: date_time_schema_js_1.utcTimestampSchema,
100
+ updatedAt: date_time_schema_js_1.utcTimestampSchema,
100
101
  });
101
102
  exports.flagResponseSchema = zod_1.z.object({
102
103
  id: zod_1.z.uuid(),
@@ -106,13 +107,13 @@ exports.flagResponseSchema = zod_1.z.object({
106
107
  environment: zod_1.z.enum(index_js_1.APP_ENVIRONMENTS),
107
108
  enabled: zod_1.z.boolean(),
108
109
  rolloutPercentage: zod_1.z.number().int(),
109
- activationAt: zod_1.z.iso.datetime().nullable(),
110
- expiresAt: zod_1.z.iso.datetime().nullable(),
110
+ activationAt: date_time_schema_js_1.utcTimestampSchema.nullable(),
111
+ expiresAt: date_time_schema_js_1.utcTimestampSchema.nullable(),
111
112
  version: zod_1.z.number().int(),
112
113
  createdBy: zod_1.z.uuid(),
113
114
  updatedBy: zod_1.z.uuid(),
114
- createdAt: zod_1.z.iso.datetime(),
115
- updatedAt: zod_1.z.iso.datetime(),
115
+ createdAt: date_time_schema_js_1.utcTimestampSchema,
116
+ updatedAt: date_time_schema_js_1.utcTimestampSchema,
116
117
  rules: zod_1.z.array(exports.ruleResponseSchema),
117
118
  });
118
119
  exports.auditLogResponseSchema = zod_1.z.object({
@@ -124,12 +125,12 @@ exports.auditLogResponseSchema = zod_1.z.object({
124
125
  action: zod_1.z.string(),
125
126
  previousValue: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).nullable(),
126
127
  newValue: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).nullable(),
127
- createdAt: zod_1.z.iso.datetime(),
128
+ createdAt: date_time_schema_js_1.utcTimestampSchema,
128
129
  });
129
130
  exports.killSwitchResponseSchema = zod_1.z.object({
130
131
  environment: zod_1.z.enum(index_js_1.APP_ENVIRONMENTS),
131
132
  engaged: zod_1.z.boolean(),
132
133
  reason: zod_1.z.string().nullable(),
133
134
  updatedBy: zod_1.z.uuid().nullable(),
134
- updatedAt: zod_1.z.iso.datetime().nullable(),
135
+ updatedAt: date_time_schema_js_1.utcTimestampSchema.nullable(),
135
136
  });
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { utcTimestampSchema } from '../common/date-time.schema.js';
2
3
  import { paginationQuerySchema } from '../common/pagination.schema.js';
3
4
  import { AUDIT_OUTCOMES } from '../enums/index.js';
4
5
  export const auditLogQuerySchema = paginationQuerySchema.extend({
@@ -17,5 +18,5 @@ export const auditLogResponseSchema = z.object({
17
18
  requestId: z.string().nullable(),
18
19
  correlationId: z.string().nullable(),
19
20
  metadata: z.record(z.string(), z.unknown()).nullable(),
20
- createdAt: z.iso.datetime(),
21
+ createdAt: utcTimestampSchema,
21
22
  });
@@ -68,6 +68,32 @@ export declare const tokenResponseSchema: z.ZodObject<{
68
68
  sessionId: z.ZodUUID;
69
69
  }, z.core.$strip>;
70
70
  export type TokenResponse = z.output<typeof tokenResponseSchema>;
71
+ /**
72
+ * A user's time zone preference: an IANA zone, and whether it was detected
73
+ * (`AUTO`) or chosen (`MANUAL`).
74
+ */
75
+ export declare const timezonePreferenceSchema: z.ZodObject<{
76
+ timezone: z.ZodString;
77
+ timezoneSource: z.ZodEnum<{
78
+ AUTO: "AUTO";
79
+ MANUAL: "MANUAL";
80
+ }>;
81
+ }, z.core.$strip>;
82
+ export type TimezonePreference = z.output<typeof timezonePreferenceSchema>;
83
+ /**
84
+ * Sets the signed-in user's own time zone. `MANUAL` (the default) is a choice
85
+ * the user made. `AUTO` records a detected zone that a later sign-in may
86
+ * replace.
87
+ */
88
+ export declare const updateTimezoneSchema: z.ZodObject<{
89
+ timezone: z.ZodPipe<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>, z.ZodTransform<string, string>>;
90
+ source: z.ZodDefault<z.ZodEnum<{
91
+ AUTO: "AUTO";
92
+ MANUAL: "MANUAL";
93
+ }>>;
94
+ }, z.core.$strict>;
95
+ export type UpdateTimezoneInput = z.input<typeof updateTimezoneSchema>;
96
+ export type UpdateTimezoneBody = z.output<typeof updateTimezoneSchema>;
71
97
  export declare const accountResponseSchema: z.ZodObject<{
72
98
  id: z.ZodUUID;
73
99
  email: z.ZodString;
@@ -79,6 +105,11 @@ export declare const accountResponseSchema: z.ZodObject<{
79
105
  roles: z.ZodArray<z.ZodString>;
80
106
  permissions: z.ZodArray<z.ZodString>;
81
107
  lastLoginAt: z.ZodNullable<z.ZodISODateTime>;
108
+ timezone: z.ZodString;
109
+ timezoneSource: z.ZodEnum<{
110
+ AUTO: "AUTO";
111
+ MANUAL: "MANUAL";
112
+ }>;
82
113
  }, z.core.$strip>;
83
114
  export type AccountResponse = z.output<typeof accountResponseSchema>;
84
115
  export declare const loginResponseSchema: z.ZodObject<{
@@ -99,6 +130,11 @@ export declare const loginResponseSchema: z.ZodObject<{
99
130
  roles: z.ZodArray<z.ZodString>;
100
131
  permissions: z.ZodArray<z.ZodString>;
101
132
  lastLoginAt: z.ZodNullable<z.ZodISODateTime>;
133
+ timezone: z.ZodString;
134
+ timezoneSource: z.ZodEnum<{
135
+ AUTO: "AUTO";
136
+ MANUAL: "MANUAL";
137
+ }>;
102
138
  }, z.core.$strip>;
103
139
  }, z.core.$strip>;
104
140
  export type LoginResponse = z.output<typeof loginResponseSchema>;
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { emailSchema } from '../common/contact.schema.js';
3
+ import { timezoneSchema, utcTimestampSchema, } from '../common/date-time.schema.js';
3
4
  import { safeTextSchema } from '../common/safe-text.schema.js';
4
- import { ACCOUNT_STATUSES, ADMIN_SETTABLE_ACCOUNT_STATUSES, ROLES, TOKEN_TRANSPORTS, } from '../enums/index.js';
5
+ import { ACCOUNT_STATUSES, ADMIN_SETTABLE_ACCOUNT_STATUSES, ROLES, TIMEZONE_SOURCES, TOKEN_TRANSPORTS, } from '../enums/index.js';
5
6
  // Per OWASP: length is what matters, with no composition rules. The upper limit
6
7
  // stops very long inputs from being used to exhaust the hashing function.
7
8
  export const MIN_PASSWORD_LENGTH = 12;
@@ -62,15 +63,34 @@ export const tokenResponseSchema = z.object({
62
63
  csrfToken: z.string().optional(),
63
64
  sessionId: z.uuid(),
64
65
  });
65
- export const accountResponseSchema = z.object({
66
+ /**
67
+ * A user's time zone preference: an IANA zone, and whether it was detected
68
+ * (`AUTO`) or chosen (`MANUAL`).
69
+ */
70
+ export const timezonePreferenceSchema = z.object({
71
+ timezone: z.string(),
72
+ timezoneSource: z.enum(TIMEZONE_SOURCES),
73
+ });
74
+ /**
75
+ * Sets the signed-in user's own time zone. `MANUAL` (the default) is a choice
76
+ * the user made. `AUTO` records a detected zone that a later sign-in may
77
+ * replace.
78
+ */
79
+ export const updateTimezoneSchema = z.strictObject({
80
+ timezone: timezoneSchema,
81
+ source: z.enum(TIMEZONE_SOURCES).default('MANUAL'),
82
+ });
83
+ export const accountResponseSchema = z
84
+ .object({
66
85
  id: z.uuid(),
67
86
  email: z.string(),
68
87
  role: z.enum(ROLES),
69
88
  /** @deprecated Always `[role]`. Kept for clients written before `role`. */
70
89
  roles: z.array(z.string()),
71
90
  permissions: z.array(z.string()),
72
- lastLoginAt: z.iso.datetime().nullable(),
73
- });
91
+ lastLoginAt: utcTimestampSchema.nullable(),
92
+ })
93
+ .extend(timezonePreferenceSchema.shape);
74
94
  export const loginResponseSchema = tokenResponseSchema.extend({
75
95
  account: accountResponseSchema,
76
96
  });
@@ -78,9 +98,9 @@ export const sessionResponseSchema = z.object({
78
98
  id: z.uuid(),
79
99
  deviceName: z.string().nullable(),
80
100
  userAgent: z.string().nullable(),
81
- createdAt: z.iso.datetime(),
82
- lastUsedAt: z.iso.datetime(),
83
- expiresAt: z.iso.datetime(),
101
+ createdAt: utcTimestampSchema,
102
+ lastUsedAt: utcTimestampSchema,
103
+ expiresAt: utcTimestampSchema,
84
104
  current: z.boolean(),
85
105
  });
86
106
  export const logoutAllResponseSchema = z.object({
@@ -94,7 +114,7 @@ export const adminAccountResponseSchema = z.object({
94
114
  email: z.string(),
95
115
  role: z.enum(ROLES),
96
116
  status: z.enum(ACCOUNT_STATUSES),
97
- lastLoginAt: z.iso.datetime().nullable(),
117
+ lastLoginAt: utcTimestampSchema.nullable(),
98
118
  });
99
119
  export const revokedSessionsResponseSchema = z.object({
100
120
  revokedSessions: z.number().int(),
@@ -4,6 +4,7 @@ export declare const coachSignupSchema: z.ZodObject<{
4
4
  email: z.ZodPipe<z.ZodString, z.ZodEmail>;
5
5
  phone: z.ZodOptional<z.ZodString>;
6
6
  password: z.ZodString;
7
+ timezone: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<string | undefined, unknown>>>;
7
8
  }, z.core.$strict>;
8
9
  export type CoachSignupInput = z.input<typeof coachSignupSchema>;
9
10
  export type CoachSignupBody = z.output<typeof coachSignupSchema>;
@@ -29,6 +30,7 @@ export declare const coachLoginSchema: z.ZodObject<{
29
30
  password: z.ZodString;
30
31
  deviceName: z.ZodOptional<z.ZodString>;
31
32
  rememberMe: z.ZodDefault<z.ZodBoolean>;
33
+ timezone: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<string | undefined, unknown>>>;
32
34
  }, z.core.$strict>;
33
35
  export type CoachLoginInput = z.input<typeof coachLoginSchema>;
34
36
  export type CoachLoginBody = z.output<typeof coachLoginSchema>;
@@ -74,6 +76,11 @@ export declare const coachProfileSchema: z.ZodObject<{
74
76
  emailVerifiedAt: z.ZodNullable<z.ZodISODateTime>;
75
77
  lastLoginAt: z.ZodNullable<z.ZodISODateTime>;
76
78
  createdAt: z.ZodISODateTime;
79
+ timezone: z.ZodString;
80
+ timezoneSource: z.ZodEnum<{
81
+ AUTO: "AUTO";
82
+ MANUAL: "MANUAL";
83
+ }>;
77
84
  }, z.core.$strip>;
78
85
  export type CoachProfile = z.output<typeof coachProfileSchema>;
79
86
  export declare const coachLoginResponseSchema: z.ZodObject<{
@@ -97,6 +104,11 @@ export declare const coachLoginResponseSchema: z.ZodObject<{
97
104
  emailVerifiedAt: z.ZodNullable<z.ZodISODateTime>;
98
105
  lastLoginAt: z.ZodNullable<z.ZodISODateTime>;
99
106
  createdAt: z.ZodISODateTime;
107
+ timezone: z.ZodString;
108
+ timezoneSource: z.ZodEnum<{
109
+ AUTO: "AUTO";
110
+ MANUAL: "MANUAL";
111
+ }>;
100
112
  }, z.core.$strip>;
101
113
  }, z.core.$strip>;
102
114
  export type CoachLoginResponse = z.output<typeof coachLoginResponseSchema>;
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
- import { loginSchema, newPasswordSchema, opaqueTokenSchema, otpCodeSchema, tokenResponseSchema, } from '../auth/auth.dto.js';
2
+ import { loginSchema, newPasswordSchema, opaqueTokenSchema, otpCodeSchema, timezonePreferenceSchema, tokenResponseSchema, } from '../auth/auth.dto.js';
3
3
  import { emailSchema, phoneSchema } from '../common/contact.schema.js';
4
+ import { detectedTimezoneSchema, utcTimestampSchema, } from '../common/date-time.schema.js';
4
5
  import { safeTextSchema } from '../common/safe-text.schema.js';
5
6
  import { ACCOUNT_STATUSES } from '../enums/index.js';
6
7
  export const coachSignupSchema = z.strictObject({
@@ -8,6 +9,8 @@ export const coachSignupSchema = z.strictObject({
8
9
  email: emailSchema,
9
10
  phone: phoneSchema.optional(),
10
11
  password: newPasswordSchema,
12
+ /** The browser's time zone. Missing or unusable becomes UTC on the server. */
13
+ timezone: detectedTimezoneSchema,
11
14
  });
12
15
  /** Used by both resend endpoints and by forgot-password. */
13
16
  export const otpEmailSchema = z.strictObject({ email: emailSchema });
@@ -22,7 +25,14 @@ export const verifyOtpSchema = z.strictObject({
22
25
  */
23
26
  export const coachLoginSchema = loginSchema
24
27
  .omit({ tokenTransport: true })
25
- .extend({ rememberMe: z.boolean().default(true) });
28
+ .extend({
29
+ rememberMe: z.boolean().default(true),
30
+ /**
31
+ * The browser's time zone. It replaces the saved one only while the
32
+ * coach's preference is `AUTO`; a manual choice is never overwritten.
33
+ */
34
+ timezone: detectedTimezoneSchema,
35
+ });
26
36
  export const resetPasswordSchema = z.strictObject({
27
37
  resetToken: opaqueTokenSchema,
28
38
  newPassword: newPasswordSchema,
@@ -46,17 +56,19 @@ export const resetTokenResponseSchema = z.object({
46
56
  resetToken: z.string(),
47
57
  expiresInSeconds: z.number().int(),
48
58
  });
49
- export const coachProfileSchema = z.object({
59
+ export const coachProfileSchema = z
60
+ .object({
50
61
  id: z.uuid(),
51
62
  email: z.string(),
52
63
  fullName: z.string().nullable(),
53
64
  phone: z.string().nullable(),
54
65
  role: z.literal('COACH'),
55
66
  status: z.enum(ACCOUNT_STATUSES),
56
- emailVerifiedAt: z.iso.datetime().nullable(),
57
- lastLoginAt: z.iso.datetime().nullable(),
58
- createdAt: z.iso.datetime(),
59
- });
67
+ emailVerifiedAt: utcTimestampSchema.nullable(),
68
+ lastLoginAt: utcTimestampSchema.nullable(),
69
+ createdAt: utcTimestampSchema,
70
+ })
71
+ .extend(timezonePreferenceSchema.shape);
60
72
  export const coachLoginResponseSchema = tokenResponseSchema.extend({
61
73
  coach: coachProfileSchema,
62
74
  });
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { utcTimestampSchema } from './date-time.schema.js';
2
3
  import { paginationMetaSchema, } from './pagination.schema.js';
3
4
  export const apiErrorResponseSchema = z.object({
4
5
  error: z.object({
@@ -13,7 +14,7 @@ export const apiErrorResponseSchema = z.object({
13
14
  }))
14
15
  .optional(),
15
16
  requestId: z.string(),
16
- timestamp: z.iso.datetime(),
17
+ timestamp: utcTimestampSchema,
17
18
  path: z.string(),
18
19
  }),
19
20
  });
@@ -0,0 +1,39 @@
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 declare const DEFAULT_TIMEZONE = "UTC";
16
+ /**
17
+ * True for `UTC` and for an IANA `Area/Location` name the runtime's time zone
18
+ * database knows. Runs anywhere `Intl` does, so the browser and the server
19
+ * agree.
20
+ */
21
+ export declare function isValidTimezone(value: unknown): value is string;
22
+ /** The current IANA name for a zone, such as `Asia/Kolkata` for `Asia/Calcutta`. */
23
+ export declare function canonicalTimezone(value: string): string;
24
+ export declare const timezoneSchema: z.ZodPipe<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>, z.ZodTransform<string, string>>;
25
+ /**
26
+ * A time zone the client detected and suggests, such as at sign-up. A missing
27
+ * or unusable value becomes `undefined` instead of failing the request, and the
28
+ * server then applies its default.
29
+ */
30
+ export declare const detectedTimezoneSchema: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<string | undefined, unknown>>>;
31
+ /** An instant in a response: ISO 8601 in UTC, ending in `Z`. */
32
+ export declare const utcTimestampSchema: z.ZodISODateTime;
33
+ /**
34
+ * An instant in a request: ISO 8601 with `Z` or an explicit offset, such as
35
+ * `2026-11-02T15:00:00+05:30`. A local time without either is refused.
36
+ */
37
+ export declare const instantInputSchema: z.ZodISODateTime;
38
+ /** A calendar date with no time and no zone, such as `2026-11-02`. */
39
+ export declare const dateOnlySchema: z.ZodString;
@@ -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';
@@ -12,6 +12,13 @@ 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,6 +18,12 @@ export const ACCOUNT_STATUSES = [
18
18
  ];
19
19
  /** The statuses an administrator may set. Verification is never set by hand. */
20
20
  export const ADMIN_SETTABLE_ACCOUNT_STATUSES = ['active', 'disabled'];
21
+ /**
22
+ * Where a user's time zone came from. `AUTO` was detected from their browser
23
+ * and may be updated when they sign in elsewhere. `MANUAL` was chosen by the
24
+ * user, and only the user changes it.
25
+ */
26
+ export const TIMEZONE_SOURCES = ['AUTO', 'MANUAL'];
21
27
  export const TOKEN_TRANSPORTS = ['body', 'cookie'];
22
28
  export const AUDIT_OUTCOMES = ['success', 'failure'];
23
29
  export const FEATURE_FLAG_RULE_TYPES = [
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { instantInputSchema, utcTimestampSchema, } from '../common/date-time.schema.js';
2
3
  import { paginationQuerySchema } from '../common/pagination.schema.js';
3
4
  import { safeTextSchema } from '../common/safe-text.schema.js';
4
5
  import { APP_ENVIRONMENTS, FEATURE_FLAG_RULE_TYPES, ROLES, } from '../enums/index.js';
@@ -14,7 +15,7 @@ const keySchema = z
14
15
  const nameSchema = safeTextSchema(120);
15
16
  const descriptionSchema = safeTextSchema(500);
16
17
  const percentageSchema = z.number().int().min(0).max(100);
17
- const timestampSchema = z.iso.datetime({ offset: true });
18
+ const timestampSchema = instantInputSchema;
18
19
  /** Lower-cased, so that it matches IDs as they come from the database. */
19
20
  const accountIdSchema = z.string().toLowerCase().pipe(z.uuid());
20
21
  const scheduleIsOrdered = ({ activationAt, expiresAt, }) => !activationAt || !expiresAt || new Date(activationAt) < new Date(expiresAt);
@@ -92,8 +93,8 @@ export const ruleResponseSchema = z.object({
92
93
  ruleType: z.enum(FEATURE_FLAG_RULE_TYPES),
93
94
  ruleValue: z.string(),
94
95
  enabled: z.boolean(),
95
- createdAt: z.iso.datetime(),
96
- updatedAt: z.iso.datetime(),
96
+ createdAt: utcTimestampSchema,
97
+ updatedAt: utcTimestampSchema,
97
98
  });
98
99
  export const flagResponseSchema = z.object({
99
100
  id: z.uuid(),
@@ -103,13 +104,13 @@ export const flagResponseSchema = z.object({
103
104
  environment: z.enum(APP_ENVIRONMENTS),
104
105
  enabled: z.boolean(),
105
106
  rolloutPercentage: z.number().int(),
106
- activationAt: z.iso.datetime().nullable(),
107
- expiresAt: z.iso.datetime().nullable(),
107
+ activationAt: utcTimestampSchema.nullable(),
108
+ expiresAt: utcTimestampSchema.nullable(),
108
109
  version: z.number().int(),
109
110
  createdBy: z.uuid(),
110
111
  updatedBy: z.uuid(),
111
- createdAt: z.iso.datetime(),
112
- updatedAt: z.iso.datetime(),
112
+ createdAt: utcTimestampSchema,
113
+ updatedAt: utcTimestampSchema,
113
114
  rules: z.array(ruleResponseSchema),
114
115
  });
115
116
  export const auditLogResponseSchema = z.object({
@@ -121,12 +122,12 @@ export const auditLogResponseSchema = z.object({
121
122
  action: z.string(),
122
123
  previousValue: z.record(z.string(), z.unknown()).nullable(),
123
124
  newValue: z.record(z.string(), z.unknown()).nullable(),
124
- createdAt: z.iso.datetime(),
125
+ createdAt: utcTimestampSchema,
125
126
  });
126
127
  export const killSwitchResponseSchema = z.object({
127
128
  environment: z.enum(APP_ENVIRONMENTS),
128
129
  engaged: z.boolean(),
129
130
  reason: z.string().nullable(),
130
131
  updatedBy: z.uuid().nullable(),
131
- updatedAt: z.iso.datetime().nullable(),
132
+ updatedAt: utcTimestampSchema.nullable(),
132
133
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbs-fitness/contract",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "TBS API contract: Zod schemas, DTO types and enums shared by the backend, web and mobile apps",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",