gymmonk-schema 0.5.0 → 0.7.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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * gymmonk-schema — "Add member" / "Add staff" FORM schemas
3
+ * ========================================================
4
+ * The client-side validation contract for the owner's desk-entry forms, and the
5
+ * mappers that turn a filled form into the `createMember` / `createStaff` API
6
+ * bodies.
7
+ *
8
+ * Why these live here rather than in the client, and why they are distinct from
9
+ * `createMemberBodySchema`: the form's working shape is not the wire shape. It
10
+ * holds ONE "name" field the API splits in two, `null` for a choice not yet
11
+ * made, `''` for an optional value left blank, and an address whose four cells
12
+ * are filled one at a time. Those are legitimate mid-edit states that the API
13
+ * body must never see. Expressing the same rules over the form shape here keeps
14
+ * every message, bound and regex defined once — the client cannot drift from
15
+ * what the server will accept, and the coercion from one shape to the other is
16
+ * part of the contract instead of ad-hoc code in a component.
17
+ *
18
+ * Mirrors `onboarding-form.ts`, which does the same job for the wizards.
19
+ *
20
+ * @module gymmonk-schema/add-person-form
21
+ */
22
+ import { z } from 'zod';
23
+ import type { CreateMemberBody } from './member.js';
24
+ import type { AssignMembershipBody } from './membership.js';
25
+ import { bloodGroupSchema, genderSchema, INDIAN_STATES } from './shared.js';
26
+ import type { CreateStaffBody } from './staff.js';
27
+ import { staffRoleSchema } from './staff.js';
28
+ /**
29
+ * Age bounds for someone signed up at the desk. Wider than the member wizard's
30
+ * because a gym also enrols juniors with a guardian present, and the date is
31
+ * typed by staff rather than by the person themselves.
32
+ */
33
+ export declare const ADD_PERSON_MIN_AGE_YEARS = 5;
34
+ export declare const ADD_PERSON_MAX_AGE_YEARS = 100;
35
+ /** Matches `createMemberBodySchema.emergencyContacts`. */
36
+ export declare const MAX_EMERGENCY_CONTACTS = 5;
37
+ /**
38
+ * The design collects a RELATIONSHIP and a phone, not a name — so a row is
39
+ * either wholly blank (ignored) or has both. `name` is synthesised from the
40
+ * relationship in the mapper, since `emergencyContactSchema` requires one.
41
+ */
42
+ export declare const emergencyContactRowSchema: z.ZodObject<{
43
+ relationship: z.ZodString;
44
+ phone: z.ZodString;
45
+ }, z.core.$strip>;
46
+ export interface EmergencyContactRow {
47
+ relationship: string;
48
+ phone: string;
49
+ }
50
+ export declare const addMemberFormSchema: z.ZodObject<{
51
+ trainerId: z.ZodString;
52
+ name: z.ZodString;
53
+ email: z.ZodEmail;
54
+ phone: z.ZodString;
55
+ dob: z.ZodString;
56
+ gender: z.ZodNullable<z.ZodEnum<{
57
+ male: "male";
58
+ female: "female";
59
+ other: "other";
60
+ }>> & z.ZodType<{} | undefined, "male" | "female" | "other" | null, z.core.$ZodTypeInternals<{} | undefined, "male" | "female" | "other" | null>>;
61
+ street: z.ZodString;
62
+ city: z.ZodString;
63
+ state: z.ZodNullable<z.ZodEnum<{
64
+ "Andhra Pradesh": "Andhra Pradesh";
65
+ "Arunachal Pradesh": "Arunachal Pradesh";
66
+ Assam: "Assam";
67
+ Bihar: "Bihar";
68
+ Chhattisgarh: "Chhattisgarh";
69
+ Goa: "Goa";
70
+ Gujarat: "Gujarat";
71
+ Haryana: "Haryana";
72
+ "Himachal Pradesh": "Himachal Pradesh";
73
+ Jharkhand: "Jharkhand";
74
+ Karnataka: "Karnataka";
75
+ Kerala: "Kerala";
76
+ "Madhya Pradesh": "Madhya Pradesh";
77
+ Maharashtra: "Maharashtra";
78
+ Manipur: "Manipur";
79
+ Meghalaya: "Meghalaya";
80
+ Mizoram: "Mizoram";
81
+ Nagaland: "Nagaland";
82
+ Odisha: "Odisha";
83
+ Punjab: "Punjab";
84
+ Rajasthan: "Rajasthan";
85
+ Sikkim: "Sikkim";
86
+ "Tamil Nadu": "Tamil Nadu";
87
+ Telangana: "Telangana";
88
+ Tripura: "Tripura";
89
+ "Uttar Pradesh": "Uttar Pradesh";
90
+ Uttarakhand: "Uttarakhand";
91
+ "West Bengal": "West Bengal";
92
+ "Andaman and Nicobar Islands": "Andaman and Nicobar Islands";
93
+ Chandigarh: "Chandigarh";
94
+ "Dadra and Nagar Haveli and Daman and Diu": "Dadra and Nagar Haveli and Daman and Diu";
95
+ Delhi: "Delhi";
96
+ "Jammu and Kashmir": "Jammu and Kashmir";
97
+ Ladakh: "Ladakh";
98
+ Lakshadweep: "Lakshadweep";
99
+ Puducherry: "Puducherry";
100
+ }>>;
101
+ pincode: z.ZodString;
102
+ bloodGroup: z.ZodUnion<[z.ZodEnum<{
103
+ "A+": "A+";
104
+ "A-": "A-";
105
+ "B+": "B+";
106
+ "B-": "B-";
107
+ "O+": "O+";
108
+ "O-": "O-";
109
+ "AB+": "AB+";
110
+ "AB-": "AB-";
111
+ }>, z.ZodLiteral<"">]>;
112
+ idProofUrl: z.ZodString;
113
+ avatarUrl: z.ZodString;
114
+ contacts: z.ZodArray<z.ZodObject<{
115
+ relationship: z.ZodString;
116
+ phone: z.ZodString;
117
+ }, z.core.$strip>>;
118
+ }, z.core.$strip>;
119
+ export type AddMemberFormValues = {
120
+ name: string;
121
+ email: string;
122
+ phone: string;
123
+ dob: string;
124
+ gender: z.infer<typeof genderSchema> | null;
125
+ street: string;
126
+ city: string;
127
+ state: (typeof INDIAN_STATES)[number] | null;
128
+ pincode: string;
129
+ bloodGroup: z.infer<typeof bloodGroupSchema> | '';
130
+ idProofUrl: string;
131
+ avatarUrl: string;
132
+ contacts: EmergencyContactRow[];
133
+ trainerId: string;
134
+ };
135
+ export declare const addStaffFormSchema: z.ZodObject<{
136
+ role: z.ZodNullable<z.ZodEnum<{
137
+ trainer: "trainer";
138
+ manager: "manager";
139
+ cleaning: "cleaning";
140
+ reception: "reception";
141
+ }>> & z.ZodType<{} | undefined, "trainer" | "manager" | "cleaning" | "reception" | null, z.core.$ZodTypeInternals<{} | undefined, "trainer" | "manager" | "cleaning" | "reception" | null>>;
142
+ name: z.ZodString;
143
+ email: z.ZodEmail;
144
+ phone: z.ZodString;
145
+ dob: z.ZodString;
146
+ gender: z.ZodNullable<z.ZodEnum<{
147
+ male: "male";
148
+ female: "female";
149
+ other: "other";
150
+ }>> & z.ZodType<{} | undefined, "male" | "female" | "other" | null, z.core.$ZodTypeInternals<{} | undefined, "male" | "female" | "other" | null>>;
151
+ street: z.ZodString;
152
+ city: z.ZodString;
153
+ state: z.ZodNullable<z.ZodEnum<{
154
+ "Andhra Pradesh": "Andhra Pradesh";
155
+ "Arunachal Pradesh": "Arunachal Pradesh";
156
+ Assam: "Assam";
157
+ Bihar: "Bihar";
158
+ Chhattisgarh: "Chhattisgarh";
159
+ Goa: "Goa";
160
+ Gujarat: "Gujarat";
161
+ Haryana: "Haryana";
162
+ "Himachal Pradesh": "Himachal Pradesh";
163
+ Jharkhand: "Jharkhand";
164
+ Karnataka: "Karnataka";
165
+ Kerala: "Kerala";
166
+ "Madhya Pradesh": "Madhya Pradesh";
167
+ Maharashtra: "Maharashtra";
168
+ Manipur: "Manipur";
169
+ Meghalaya: "Meghalaya";
170
+ Mizoram: "Mizoram";
171
+ Nagaland: "Nagaland";
172
+ Odisha: "Odisha";
173
+ Punjab: "Punjab";
174
+ Rajasthan: "Rajasthan";
175
+ Sikkim: "Sikkim";
176
+ "Tamil Nadu": "Tamil Nadu";
177
+ Telangana: "Telangana";
178
+ Tripura: "Tripura";
179
+ "Uttar Pradesh": "Uttar Pradesh";
180
+ Uttarakhand: "Uttarakhand";
181
+ "West Bengal": "West Bengal";
182
+ "Andaman and Nicobar Islands": "Andaman and Nicobar Islands";
183
+ Chandigarh: "Chandigarh";
184
+ "Dadra and Nagar Haveli and Daman and Diu": "Dadra and Nagar Haveli and Daman and Diu";
185
+ Delhi: "Delhi";
186
+ "Jammu and Kashmir": "Jammu and Kashmir";
187
+ Ladakh: "Ladakh";
188
+ Lakshadweep: "Lakshadweep";
189
+ Puducherry: "Puducherry";
190
+ }>>;
191
+ pincode: z.ZodString;
192
+ bloodGroup: z.ZodUnion<[z.ZodEnum<{
193
+ "A+": "A+";
194
+ "A-": "A-";
195
+ "B+": "B+";
196
+ "B-": "B-";
197
+ "O+": "O+";
198
+ "O-": "O-";
199
+ "AB+": "AB+";
200
+ "AB-": "AB-";
201
+ }>, z.ZodLiteral<"">]>;
202
+ idProofUrl: z.ZodString;
203
+ avatarUrl: z.ZodString;
204
+ contacts: z.ZodArray<z.ZodObject<{
205
+ relationship: z.ZodString;
206
+ phone: z.ZodString;
207
+ }, z.core.$strip>>;
208
+ }, z.core.$strip>;
209
+ export type AddStaffFormValues = Omit<AddMemberFormValues, 'trainerId'> & {
210
+ role: z.infer<typeof staffRoleSchema> | null;
211
+ };
212
+ /** One blank row so the section renders with a contact to fill. */
213
+ export declare const EMPTY_EMERGENCY_CONTACT: EmergencyContactRow;
214
+ export declare const EMPTY_ADD_MEMBER_FORM: AddMemberFormValues;
215
+ export declare const EMPTY_ADD_STAFF_FORM: AddStaffFormValues;
216
+ /**
217
+ * Validated member form → `POST /members` body. `initialPlan` comes from the
218
+ * membership step, which the form itself knows nothing about.
219
+ */
220
+ export declare function toCreateMemberBody(values: AddMemberFormValues, initialPlan?: AssignMembershipBody): CreateMemberBody;
221
+ /** Validated staff form → `POST /staff` body. */
222
+ export declare function toCreateStaffBody(values: AddStaffFormValues): CreateStaffBody;
223
+ //# sourceMappingURL=add-person-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-person-form.d.ts","sourceRoot":"","sources":["../src/add-person-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAAiB,gBAAgB,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC3F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAI7C;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAC1C,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C,0DAA0D;AAC1D,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAcxC;;;;GAIG;AACH,eAAO,MAAM,yBAAyB;;;iBAkBlC,CAAC;AAEL,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;AA0FD,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAa5B,CAAC;AAEL,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,GAAG,IAAI,CAAC;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GAAG,EAAE,CAAC;IAClD,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAIF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAQ3B,CAAC;AAEL,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,mBAAmB,EAAE,WAAW,CAAC,GAAG;IACxE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,GAAG,IAAI,CAAC;CAC9C,CAAC;AAmBF,mEAAmE;AACnE,eAAO,MAAM,uBAAuB,EAAE,mBAAqD,CAAC;AAE5F,eAAO,MAAM,qBAAqB,EAAE,mBAInC,CAAC;AAEF,eAAO,MAAM,oBAAoB,EAAE,kBAIlC,CAAC;AA4DF;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,mBAAmB,EAC3B,WAAW,CAAC,EAAE,oBAAoB,GACjC,gBAAgB,CAMlB;AAED,iDAAiD;AACjD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,kBAAkB,GAAG,eAAe,CAM7E"}
@@ -0,0 +1,264 @@
1
+ /**
2
+ * gymmonk-schema — "Add member" / "Add staff" FORM schemas
3
+ * ========================================================
4
+ * The client-side validation contract for the owner's desk-entry forms, and the
5
+ * mappers that turn a filled form into the `createMember` / `createStaff` API
6
+ * bodies.
7
+ *
8
+ * Why these live here rather than in the client, and why they are distinct from
9
+ * `createMemberBodySchema`: the form's working shape is not the wire shape. It
10
+ * holds ONE "name" field the API splits in two, `null` for a choice not yet
11
+ * made, `''` for an optional value left blank, and an address whose four cells
12
+ * are filled one at a time. Those are legitimate mid-edit states that the API
13
+ * body must never see. Expressing the same rules over the form shape here keeps
14
+ * every message, bound and regex defined once — the client cannot drift from
15
+ * what the server will accept, and the coercion from one shape to the other is
16
+ * part of the contract instead of ad-hoc code in a component.
17
+ *
18
+ * Mirrors `onboarding-form.ts`, which does the same job for the wizards.
19
+ *
20
+ * @module gymmonk-schema/add-person-form
21
+ */
22
+ import { z } from 'zod';
23
+ import { ageInYears, isRealDate, PHONE_REGEX, PINCODE_REGEX } from './common.js';
24
+ import { emergencyContactSchema } from './member-profile.js';
25
+ import { addressSchema, bloodGroupSchema, genderSchema, INDIAN_STATES } from './shared.js';
26
+ import { staffRoleSchema } from './staff.js';
27
+ // ─── Bounds ──────────────────────────────────────────────────────────────────
28
+ /**
29
+ * Age bounds for someone signed up at the desk. Wider than the member wizard's
30
+ * because a gym also enrols juniors with a guardian present, and the date is
31
+ * typed by staff rather than by the person themselves.
32
+ */
33
+ export const ADD_PERSON_MIN_AGE_YEARS = 5;
34
+ export const ADD_PERSON_MAX_AGE_YEARS = 100;
35
+ /** Matches `createMemberBodySchema.emergencyContacts`. */
36
+ export const MAX_EMERGENCY_CONTACTS = 5;
37
+ // ─── Field-level rules ───────────────────────────────────────────────────────
38
+ const requiredChoice = (inner, message) => inner.nullable().refine((v) => v !== null, { message });
39
+ /** Blank is allowed; anything else must be a valid 10-digit mobile. */
40
+ const optionalFormPhone = z
41
+ .string()
42
+ .refine((v) => v === '' || PHONE_REGEX.test(v), 'Enter a valid 10-digit mobile number');
43
+ // ─── One emergency-contact row ───────────────────────────────────────────────
44
+ /**
45
+ * The design collects a RELATIONSHIP and a phone, not a name — so a row is
46
+ * either wholly blank (ignored) or has both. `name` is synthesised from the
47
+ * relationship in the mapper, since `emergencyContactSchema` requires one.
48
+ */
49
+ export const emergencyContactRowSchema = z
50
+ .object({
51
+ relationship: z.string().trim().max(60),
52
+ phone: z.string().trim(),
53
+ })
54
+ .superRefine((v, ctx) => {
55
+ const blank = v.relationship === '' && v.phone === '';
56
+ if (blank)
57
+ return;
58
+ if (v.relationship === '') {
59
+ ctx.addIssue({ code: 'custom', path: ['relationship'], message: 'Add the relationship' });
60
+ }
61
+ if (!PHONE_REGEX.test(v.phone)) {
62
+ ctx.addIssue({
63
+ code: 'custom',
64
+ path: ['phone'],
65
+ message: 'Enter a valid 10-digit mobile number',
66
+ });
67
+ }
68
+ });
69
+ // ─── Shared base ─────────────────────────────────────────────────────────────
70
+ const addPersonBaseShape = {
71
+ /** One field on screen; split into `firstName` / `lastName` on the wire. */
72
+ name: z.string().trim().min(1, 'Name is required').max(160),
73
+ email: z.email('Enter a valid email'),
74
+ phone: z.string().regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number'),
75
+ /** `YYYY-MM-DD` from a native date input; blank when not recorded. */
76
+ dob: z.string(),
77
+ gender: requiredChoice(genderSchema, 'Select a gender'),
78
+ // Address — optional as a whole, all-or-nothing when started. See the refine.
79
+ street: z.string().trim().max(200),
80
+ city: z.string().trim().max(100),
81
+ /**
82
+ * Null until picked in the area drawer. Carries its OWN message: the bare
83
+ * enum failure renders all 36 states into the field error, which is unusable
84
+ * on a phone. The value can only miss when the reference data hands back a
85
+ * name outside the canonical set, and "pick from the list" is the right
86
+ * instruction either way.
87
+ */
88
+ state: z.enum(INDIAN_STATES, { error: 'Select a state from the list' }).nullable(),
89
+ pincode: z.string().trim(),
90
+ /** Blank when not recorded. */
91
+ bloodGroup: bloodGroupSchema.or(z.literal('')),
92
+ /** GCS URLs — both upload on pick, so these are never files. */
93
+ idProofUrl: z.string(),
94
+ avatarUrl: z.string(),
95
+ contacts: z.array(emergencyContactRowSchema).max(MAX_EMERGENCY_CONTACTS),
96
+ };
97
+ /**
98
+ * Date of birth is optional, but a typed one has to be real and plausible —
99
+ * a mis-keyed year is the single most common desk-entry slip.
100
+ */
101
+ function refineDob(v, ctx) {
102
+ if (v.dob === '')
103
+ return;
104
+ const parts = v.dob.split('-');
105
+ const year = Number(parts[0]);
106
+ const month = Number(parts[1]);
107
+ const day = Number(parts[2]);
108
+ if (parts.length !== 3 || !isRealDate(year, month, day)) {
109
+ ctx.addIssue({ code: 'custom', path: ['dob'], message: 'That date does not exist' });
110
+ return;
111
+ }
112
+ const age = ageInYears(year, month, day);
113
+ if (age < 0) {
114
+ ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Date of birth cannot be in the future' });
115
+ }
116
+ else if (age < ADD_PERSON_MIN_AGE_YEARS) {
117
+ ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Check the year of birth' });
118
+ }
119
+ else if (age > ADD_PERSON_MAX_AGE_YEARS) {
120
+ ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Check the year of birth' });
121
+ }
122
+ }
123
+ /**
124
+ * The address is optional, but a HALF-filled one is not — silently dropping it
125
+ * (which is what the client used to do) loses data the owner believed they had
126
+ * saved. Once any cell is touched, all four must be valid.
127
+ */
128
+ function refineAddress(v, ctx) {
129
+ const started = Boolean(v.street || v.city || v.state || v.pincode);
130
+ if (!started)
131
+ return;
132
+ if (!v.street) {
133
+ ctx.addIssue({ code: 'custom', path: ['street'], message: 'Street address is required' });
134
+ }
135
+ if (!v.city) {
136
+ ctx.addIssue({ code: 'custom', path: ['city'], message: 'City is required' });
137
+ }
138
+ if (!v.state) {
139
+ ctx.addIssue({ code: 'custom', path: ['state'], message: 'Select a state' });
140
+ }
141
+ if (!PINCODE_REGEX.test(v.pincode)) {
142
+ ctx.addIssue({ code: 'custom', path: ['pincode'], message: 'Enter a valid 6-digit pincode' });
143
+ }
144
+ }
145
+ // ─── Member form ─────────────────────────────────────────────────────────────
146
+ export const addMemberFormSchema = z
147
+ .object({
148
+ ...addPersonBaseShape,
149
+ /**
150
+ * Central-auth user id of the trainer, or `''` for none. Only trainers who
151
+ * have CLAIMED their invite can be assigned — an unclaimed staff record has
152
+ * no user to point at (`MemberProfile.assignedTrainerId` refs `User`).
153
+ */
154
+ trainerId: z.string(),
155
+ })
156
+ .superRefine((v, ctx) => {
157
+ refineDob(v, ctx);
158
+ refineAddress(v, ctx);
159
+ });
160
+ // ─── Staff form ──────────────────────────────────────────────────────────────
161
+ export const addStaffFormSchema = z
162
+ .object({
163
+ ...addPersonBaseShape,
164
+ role: requiredChoice(staffRoleSchema, 'Select a staff role'),
165
+ })
166
+ .superRefine((v, ctx) => {
167
+ refineDob(v, ctx);
168
+ refineAddress(v, ctx);
169
+ });
170
+ // ─── Empty states ────────────────────────────────────────────────────────────
171
+ const EMPTY_BASE = {
172
+ name: '',
173
+ email: '',
174
+ phone: '',
175
+ dob: '',
176
+ gender: null,
177
+ street: '',
178
+ city: '',
179
+ state: null,
180
+ pincode: '',
181
+ bloodGroup: '',
182
+ idProofUrl: '',
183
+ avatarUrl: '',
184
+ };
185
+ /** One blank row so the section renders with a contact to fill. */
186
+ export const EMPTY_EMERGENCY_CONTACT = { relationship: '', phone: '' };
187
+ export const EMPTY_ADD_MEMBER_FORM = {
188
+ ...EMPTY_BASE,
189
+ contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],
190
+ trainerId: '',
191
+ };
192
+ export const EMPTY_ADD_STAFF_FORM = {
193
+ ...EMPTY_BASE,
194
+ contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],
195
+ role: null,
196
+ };
197
+ // ─── Form → API body ─────────────────────────────────────────────────────────
198
+ /** One "Name" field on screen, `firstName` + `lastName` on the wire. */
199
+ function splitName(value) {
200
+ const parts = value.trim().split(/\s+/).filter(Boolean);
201
+ const lastName = parts.slice(1).join(' ');
202
+ return { firstName: parts[0] ?? '', ...(lastName ? { lastName } : {}) };
203
+ }
204
+ /** Drop a blank optional so it reads as "not provided" rather than `''`. */
205
+ function orUndefined(value) {
206
+ const trimmed = value.trim();
207
+ return trimmed === '' ? undefined : trimmed;
208
+ }
209
+ /** Phones are STORED raw — strip any display formatting back to 10 digits. */
210
+ function asPhone(value) {
211
+ return value.replace(/\D/g, '').slice(-10);
212
+ }
213
+ function sharedBody(v) {
214
+ const address = v.street && v.city && v.state && PINCODE_REGEX.test(v.pincode)
215
+ ? addressSchema.parse({
216
+ street: v.street,
217
+ city: v.city,
218
+ state: v.state,
219
+ pincode: v.pincode.trim(),
220
+ })
221
+ : undefined;
222
+ const emergencyContacts = v.contacts
223
+ .filter((c) => c.relationship !== '' || c.phone !== '')
224
+ .map((c) => emergencyContactSchema.parse({
225
+ // The form captures a relationship, not a name; it doubles as the label.
226
+ name: c.relationship.trim(),
227
+ phone: asPhone(c.phone),
228
+ relationship: c.relationship.trim(),
229
+ }))
230
+ .slice(0, MAX_EMERGENCY_CONTACTS);
231
+ return {
232
+ ...splitName(v.name),
233
+ email: v.email.trim(),
234
+ phone: asPhone(v.phone),
235
+ // The form schema guarantees a choice was made before we get here.
236
+ gender: v.gender ?? 'other',
237
+ dob: orUndefined(v.dob),
238
+ bloodGroup: v.bloodGroup === '' ? undefined : v.bloodGroup,
239
+ address,
240
+ emergencyContacts,
241
+ idProofUrl: orUndefined(v.idProofUrl),
242
+ avatarUrl: orUndefined(v.avatarUrl),
243
+ };
244
+ }
245
+ /**
246
+ * Validated member form → `POST /members` body. `initialPlan` comes from the
247
+ * membership step, which the form itself knows nothing about.
248
+ */
249
+ export function toCreateMemberBody(values, initialPlan) {
250
+ return {
251
+ ...sharedBody(values),
252
+ assignedTrainerId: orUndefined(values.trainerId),
253
+ ...(initialPlan ? { initialPlan } : {}),
254
+ };
255
+ }
256
+ /** Validated staff form → `POST /staff` body. */
257
+ export function toCreateStaffBody(values) {
258
+ return {
259
+ ...sharedBody(values),
260
+ // The form schema guarantees a role was chosen.
261
+ role: values.role ?? 'trainer',
262
+ };
263
+ }
264
+ //# sourceMappingURL=add-person-form.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-person-form.js","sourceRoot":"","sources":["../src/add-person-form.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — \"Add member\" / \"Add staff\" FORM schemas\n * ========================================================\n * The client-side validation contract for the owner's desk-entry forms, and the\n * mappers that turn a filled form into the `createMember` / `createStaff` API\n * bodies.\n *\n * Why these live here rather than in the client, and why they are distinct from\n * `createMemberBodySchema`: the form's working shape is not the wire shape. It\n * holds ONE \"name\" field the API splits in two, `null` for a choice not yet\n * made, `''` for an optional value left blank, and an address whose four cells\n * are filled one at a time. Those are legitimate mid-edit states that the API\n * body must never see. Expressing the same rules over the form shape here keeps\n * every message, bound and regex defined once — the client cannot drift from\n * what the server will accept, and the coercion from one shape to the other is\n * part of the contract instead of ad-hoc code in a component.\n *\n * Mirrors `onboarding-form.ts`, which does the same job for the wizards.\n *\n * @module gymmonk-schema/add-person-form\n */\n\nimport { z } from 'zod';\nimport { ageInYears, isRealDate, PHONE_REGEX, PINCODE_REGEX } from './common.js';\nimport { emergencyContactSchema } from './member-profile.js';\nimport type { CreateMemberBody } from './member.js';\nimport type { AssignMembershipBody } from './membership.js';\nimport { addressSchema, bloodGroupSchema, genderSchema, INDIAN_STATES } from './shared.js';\nimport type { CreateStaffBody } from './staff.js';\nimport { staffRoleSchema } from './staff.js';\n\n// ─── Bounds ──────────────────────────────────────────────────────────────────\n\n/**\n * Age bounds for someone signed up at the desk. Wider than the member wizard's\n * because a gym also enrols juniors with a guardian present, and the date is\n * typed by staff rather than by the person themselves.\n */\nexport const ADD_PERSON_MIN_AGE_YEARS = 5;\nexport const ADD_PERSON_MAX_AGE_YEARS = 100;\n\n/** Matches `createMemberBodySchema.emergencyContacts`. */\nexport const MAX_EMERGENCY_CONTACTS = 5;\n\n// ─── Field-level rules ───────────────────────────────────────────────────────\n\nconst requiredChoice = <T extends z.ZodTypeAny>(inner: T, message: string) =>\n inner.nullable().refine((v: unknown) => v !== null, { message });\n\n/** Blank is allowed; anything else must be a valid 10-digit mobile. */\nconst optionalFormPhone = z\n .string()\n .refine((v) => v === '' || PHONE_REGEX.test(v), 'Enter a valid 10-digit mobile number');\n\n// ─── One emergency-contact row ───────────────────────────────────────────────\n\n/**\n * The design collects a RELATIONSHIP and a phone, not a name — so a row is\n * either wholly blank (ignored) or has both. `name` is synthesised from the\n * relationship in the mapper, since `emergencyContactSchema` requires one.\n */\nexport const emergencyContactRowSchema = z\n .object({\n relationship: z.string().trim().max(60),\n phone: z.string().trim(),\n })\n .superRefine((v, ctx) => {\n const blank = v.relationship === '' && v.phone === '';\n if (blank) return;\n if (v.relationship === '') {\n ctx.addIssue({ code: 'custom', path: ['relationship'], message: 'Add the relationship' });\n }\n if (!PHONE_REGEX.test(v.phone)) {\n ctx.addIssue({\n code: 'custom',\n path: ['phone'],\n message: 'Enter a valid 10-digit mobile number',\n });\n }\n });\n\nexport interface EmergencyContactRow {\n relationship: string;\n phone: string;\n}\n\n// ─── Shared base ─────────────────────────────────────────────────────────────\n\nconst addPersonBaseShape = {\n /** One field on screen; split into `firstName` / `lastName` on the wire. */\n name: z.string().trim().min(1, 'Name is required').max(160),\n email: z.email('Enter a valid email'),\n phone: z.string().regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number'),\n /** `YYYY-MM-DD` from a native date input; blank when not recorded. */\n dob: z.string(),\n gender: requiredChoice(genderSchema, 'Select a gender'),\n\n // Address — optional as a whole, all-or-nothing when started. See the refine.\n street: z.string().trim().max(200),\n city: z.string().trim().max(100),\n /**\n * Null until picked in the area drawer. Carries its OWN message: the bare\n * enum failure renders all 36 states into the field error, which is unusable\n * on a phone. The value can only miss when the reference data hands back a\n * name outside the canonical set, and \"pick from the list\" is the right\n * instruction either way.\n */\n state: z.enum(INDIAN_STATES, { error: 'Select a state from the list' }).nullable(),\n pincode: z.string().trim(),\n\n /** Blank when not recorded. */\n bloodGroup: bloodGroupSchema.or(z.literal('')),\n /** GCS URLs — both upload on pick, so these are never files. */\n idProofUrl: z.string(),\n avatarUrl: z.string(),\n\n contacts: z.array(emergencyContactRowSchema).max(MAX_EMERGENCY_CONTACTS),\n};\n\n/**\n * Date of birth is optional, but a typed one has to be real and plausible —\n * a mis-keyed year is the single most common desk-entry slip.\n */\nfunction refineDob(v: { dob: string }, ctx: z.RefinementCtx): void {\n if (v.dob === '') return;\n\n const parts = v.dob.split('-');\n const year = Number(parts[0]);\n const month = Number(parts[1]);\n const day = Number(parts[2]);\n\n if (parts.length !== 3 || !isRealDate(year, month, day)) {\n ctx.addIssue({ code: 'custom', path: ['dob'], message: 'That date does not exist' });\n return;\n }\n\n const age = ageInYears(year, month, day);\n if (age < 0) {\n ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Date of birth cannot be in the future' });\n } else if (age < ADD_PERSON_MIN_AGE_YEARS) {\n ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Check the year of birth' });\n } else if (age > ADD_PERSON_MAX_AGE_YEARS) {\n ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Check the year of birth' });\n }\n}\n\n/**\n * The address is optional, but a HALF-filled one is not — silently dropping it\n * (which is what the client used to do) loses data the owner believed they had\n * saved. Once any cell is touched, all four must be valid.\n */\nfunction refineAddress(\n v: { street: string; city: string; state: string | null; pincode: string },\n ctx: z.RefinementCtx,\n): void {\n const started = Boolean(v.street || v.city || v.state || v.pincode);\n if (!started) return;\n\n if (!v.street) {\n ctx.addIssue({ code: 'custom', path: ['street'], message: 'Street address is required' });\n }\n if (!v.city) {\n ctx.addIssue({ code: 'custom', path: ['city'], message: 'City is required' });\n }\n if (!v.state) {\n ctx.addIssue({ code: 'custom', path: ['state'], message: 'Select a state' });\n }\n if (!PINCODE_REGEX.test(v.pincode)) {\n ctx.addIssue({ code: 'custom', path: ['pincode'], message: 'Enter a valid 6-digit pincode' });\n }\n}\n\n// ─── Member form ─────────────────────────────────────────────────────────────\n\nexport const addMemberFormSchema = z\n .object({\n ...addPersonBaseShape,\n /**\n * Central-auth user id of the trainer, or `''` for none. Only trainers who\n * have CLAIMED their invite can be assigned — an unclaimed staff record has\n * no user to point at (`MemberProfile.assignedTrainerId` refs `User`).\n */\n trainerId: z.string(),\n })\n .superRefine((v, ctx) => {\n refineDob(v, ctx);\n refineAddress(v, ctx);\n });\n\nexport type AddMemberFormValues = {\n name: string;\n email: string;\n phone: string;\n dob: string;\n gender: z.infer<typeof genderSchema> | null;\n street: string;\n city: string;\n state: (typeof INDIAN_STATES)[number] | null;\n pincode: string;\n bloodGroup: z.infer<typeof bloodGroupSchema> | '';\n idProofUrl: string;\n avatarUrl: string;\n contacts: EmergencyContactRow[];\n trainerId: string;\n};\n\n// ─── Staff form ──────────────────────────────────────────────────────────────\n\nexport const addStaffFormSchema = z\n .object({\n ...addPersonBaseShape,\n role: requiredChoice(staffRoleSchema, 'Select a staff role'),\n })\n .superRefine((v, ctx) => {\n refineDob(v, ctx);\n refineAddress(v, ctx);\n });\n\nexport type AddStaffFormValues = Omit<AddMemberFormValues, 'trainerId'> & {\n role: z.infer<typeof staffRoleSchema> | null;\n};\n\n// ─── Empty states ────────────────────────────────────────────────────────────\n\nconst EMPTY_BASE = {\n name: '',\n email: '',\n phone: '',\n dob: '',\n gender: null,\n street: '',\n city: '',\n state: null,\n pincode: '',\n bloodGroup: '' as const,\n idProofUrl: '',\n avatarUrl: '',\n} satisfies Omit<AddMemberFormValues, 'contacts' | 'trainerId'>;\n\n/** One blank row so the section renders with a contact to fill. */\nexport const EMPTY_EMERGENCY_CONTACT: EmergencyContactRow = { relationship: '', phone: '' };\n\nexport const EMPTY_ADD_MEMBER_FORM: AddMemberFormValues = {\n ...EMPTY_BASE,\n contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],\n trainerId: '',\n};\n\nexport const EMPTY_ADD_STAFF_FORM: AddStaffFormValues = {\n ...EMPTY_BASE,\n contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],\n role: null,\n};\n\n// ─── Form → API body ─────────────────────────────────────────────────────────\n\n/** One \"Name\" field on screen, `firstName` + `lastName` on the wire. */\nfunction splitName(value: string): { firstName: string; lastName?: string } {\n const parts = value.trim().split(/\\s+/).filter(Boolean);\n const lastName = parts.slice(1).join(' ');\n return { firstName: parts[0] ?? '', ...(lastName ? { lastName } : {}) };\n}\n\n/** Drop a blank optional so it reads as \"not provided\" rather than `''`. */\nfunction orUndefined(value: string): string | undefined {\n const trimmed = value.trim();\n return trimmed === '' ? undefined : trimmed;\n}\n\n/** Phones are STORED raw — strip any display formatting back to 10 digits. */\nfunction asPhone(value: string): string {\n return value.replace(/\\D/g, '').slice(-10);\n}\n\nfunction sharedBody(v: Omit<AddMemberFormValues, 'trainerId'>) {\n const address =\n v.street && v.city && v.state && PINCODE_REGEX.test(v.pincode)\n ? addressSchema.parse({\n street: v.street,\n city: v.city,\n state: v.state,\n pincode: v.pincode.trim(),\n })\n : undefined;\n\n const emergencyContacts = v.contacts\n .filter((c) => c.relationship !== '' || c.phone !== '')\n .map((c) =>\n emergencyContactSchema.parse({\n // The form captures a relationship, not a name; it doubles as the label.\n name: c.relationship.trim(),\n phone: asPhone(c.phone),\n relationship: c.relationship.trim(),\n }),\n )\n .slice(0, MAX_EMERGENCY_CONTACTS);\n\n return {\n ...splitName(v.name),\n email: v.email.trim(),\n phone: asPhone(v.phone),\n // The form schema guarantees a choice was made before we get here.\n gender: v.gender ?? 'other',\n dob: orUndefined(v.dob),\n bloodGroup: v.bloodGroup === '' ? undefined : v.bloodGroup,\n address,\n emergencyContacts,\n idProofUrl: orUndefined(v.idProofUrl),\n avatarUrl: orUndefined(v.avatarUrl),\n };\n}\n\n/**\n * Validated member form → `POST /members` body. `initialPlan` comes from the\n * membership step, which the form itself knows nothing about.\n */\nexport function toCreateMemberBody(\n values: AddMemberFormValues,\n initialPlan?: AssignMembershipBody,\n): CreateMemberBody {\n return {\n ...sharedBody(values),\n assignedTrainerId: orUndefined(values.trainerId),\n ...(initialPlan ? { initialPlan } : {}),\n };\n}\n\n/** Validated staff form → `POST /staff` body. */\nexport function toCreateStaffBody(values: AddStaffFormValues): CreateStaffBody {\n return {\n ...sharedBody(values),\n // The form schema guarantees a role was chosen.\n role: values.role ?? 'trainer',\n };\n}\n"]}
package/dist/center.d.ts CHANGED
@@ -11,13 +11,13 @@ import { z } from 'zod';
11
11
  /** The activity types a center offers (multi-select). */
12
12
  export declare const CENTER_TYPES: readonly ["gym", "crossfit", "yoga", "pilates", "zumba", "cardio", "strength", "mma", "boxing", "swimming", "aerobics", "personal-training"];
13
13
  export declare const centerTypeSchema: z.ZodEnum<{
14
- gym: "gym";
15
- crossfit: "crossfit";
14
+ cardio: "cardio";
15
+ strength: "strength";
16
16
  yoga: "yoga";
17
+ crossfit: "crossfit";
18
+ gym: "gym";
17
19
  pilates: "pilates";
18
20
  zumba: "zumba";
19
- cardio: "cardio";
20
- strength: "strength";
21
21
  mma: "mma";
22
22
  boxing: "boxing";
23
23
  swimming: "swimming";
@@ -40,13 +40,13 @@ export declare const createCenterBodySchema: z.ZodObject<{
40
40
  name: z.ZodString;
41
41
  description: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
42
42
  types: z.ZodArray<z.ZodEnum<{
43
- gym: "gym";
44
- crossfit: "crossfit";
43
+ cardio: "cardio";
44
+ strength: "strength";
45
45
  yoga: "yoga";
46
+ crossfit: "crossfit";
47
+ gym: "gym";
46
48
  pilates: "pilates";
47
49
  zumba: "zumba";
48
- cardio: "cardio";
49
- strength: "strength";
50
50
  mma: "mma";
51
51
  boxing: "boxing";
52
52
  swimming: "swimming";
@@ -145,13 +145,13 @@ export declare const updateCenterBodySchema: z.ZodObject<{
145
145
  name: z.ZodOptional<z.ZodString>;
146
146
  description: z.ZodOptional<z.ZodPreprocess<z.ZodOptional<z.ZodString>>>;
147
147
  types: z.ZodOptional<z.ZodArray<z.ZodEnum<{
148
- gym: "gym";
149
- crossfit: "crossfit";
148
+ cardio: "cardio";
149
+ strength: "strength";
150
150
  yoga: "yoga";
151
+ crossfit: "crossfit";
152
+ gym: "gym";
151
153
  pilates: "pilates";
152
154
  zumba: "zumba";
153
- cardio: "cardio";
154
- strength: "strength";
155
155
  mma: "mma";
156
156
  boxing: "boxing";
157
157
  swimming: "swimming";
@@ -253,13 +253,13 @@ export declare const centerSchema: z.ZodObject<{
253
253
  name: z.ZodString;
254
254
  description: z.ZodNullable<z.ZodString>;
255
255
  types: z.ZodArray<z.ZodEnum<{
256
- gym: "gym";
257
- crossfit: "crossfit";
256
+ cardio: "cardio";
257
+ strength: "strength";
258
258
  yoga: "yoga";
259
+ crossfit: "crossfit";
260
+ gym: "gym";
259
261
  pilates: "pilates";
260
262
  zumba: "zumba";
261
- cardio: "cardio";
262
- strength: "strength";
263
263
  mma: "mma";
264
264
  boxing: "boxing";
265
265
  swimming: "swimming";