gymmonk-schema 0.43.0 → 0.45.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.
@@ -22,7 +22,7 @@
22
22
  import { z } from 'zod';
23
23
  import type { CreateMemberBody, UpdateMemberBody } from './member.js';
24
24
  import type { AssignMembershipBody } from './membership.js';
25
- import { type Address, bloodGroupSchema, genderSchema } from './shared.js';
25
+ import { bloodGroupSchema, genderSchema } from './shared.js';
26
26
  import type { CreateStaffBody } from './staff.js';
27
27
  import { staffRoleSchema } from './staff.js';
28
28
  /**
@@ -109,11 +109,10 @@ export declare const addMemberFormSchema: z.ZodObject<{
109
109
  /**
110
110
  * Adding a member asks for the person, not their paperwork.
111
111
  *
112
- * No address and no ID proof: neither is something a front desk has when
113
- * somebody walks in, both were optional and therefore skipped, and the member's
114
- * own screens never collected them either. They live on the EDIT form, where an
115
- * owner fills them in once they actually have them, and on the staff form,
116
- * where an employer legitimately does.
112
+ * No address, no Aadhaar and no ID proof on this form or on any other. A
113
+ * front desk does not have them when somebody walks in, the member's own
114
+ * screens never asked for them, and nothing in the product ever read one back.
115
+ * Every surface that collected them was filling a drawer nobody opens.
117
116
  */
118
117
  export type AddMemberFormValues = {
119
118
  name: string;
@@ -130,13 +129,13 @@ export type AddMemberFormValues = {
130
129
  trainerId: string;
131
130
  };
132
131
  /**
133
- * Staff keep the address a member is no longer asked for but not an ID proof.
132
+ * Staff are asked exactly what a member is asked, plus the job they do.
134
133
  *
135
- * The address stays because this is an employment record: a gym holds one for
136
- * the people it EMPLOYS for reasons that have nothing to do with why it holds a
137
- * member's phone number. The ID upload went for the opposite reason nothing
138
- * in the product ever showed it back, so it was a document collected and then
139
- * never looked at, which is the worst thing to ask anyone for.
134
+ * The home address and the ID upload both went for the same reason: nothing in
135
+ * the product ever showed either one back. A gym that needs an employee's
136
+ * address for a contract has somewhere else to keep it; what it had here was a
137
+ * field collected once and never looked at again, which is the worst thing to
138
+ * ask anyone for.
140
139
  */
141
140
  export declare const addStaffFormSchema: z.ZodObject<{
142
141
  role: z.ZodNullable<z.ZodEnum<{
@@ -145,43 +144,6 @@ export declare const addStaffFormSchema: z.ZodObject<{
145
144
  cleaning: "cleaning";
146
145
  reception: "reception";
147
146
  }>> & z.ZodType<{} | undefined, "trainer" | "manager" | "cleaning" | "reception" | null, z.core.$ZodTypeInternals<{} | undefined, "trainer" | "manager" | "cleaning" | "reception" | null>>;
148
- address: z.ZodNullable<z.ZodObject<{
149
- country: z.ZodObject<{
150
- id: z.ZodString;
151
- name: z.ZodString;
152
- }, z.core.$strip>;
153
- state: z.ZodObject<{
154
- id: z.ZodString;
155
- name: z.ZodString;
156
- }, z.core.$strip>;
157
- district: z.ZodOptional<z.ZodObject<{
158
- id: z.ZodString;
159
- name: z.ZodString;
160
- }, z.core.$strip>>;
161
- locality: z.ZodOptional<z.ZodObject<{
162
- id: z.ZodString;
163
- name: z.ZodString;
164
- type: z.ZodEnum<{
165
- SUB_DISTRICT: "SUB_DISTRICT";
166
- BLOCK: "BLOCK";
167
- GRAM_PANCHAYAT: "GRAM_PANCHAYAT";
168
- VILLAGE: "VILLAGE";
169
- HAMLET: "HAMLET";
170
- URBAN_BODY: "URBAN_BODY";
171
- URBAN_WARD: "URBAN_WARD";
172
- LOCALITY: "LOCALITY";
173
- }>;
174
- }, z.core.$strip>>;
175
- line1: z.ZodString;
176
- landmark: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
177
- pincode: z.ZodString;
178
- ancestry: z.ZodDefault<z.ZodArray<z.ZodString>>;
179
- coords: z.ZodOptional<z.ZodObject<{
180
- lat: z.ZodNumber;
181
- lng: z.ZodNumber;
182
- source: z.ZodString;
183
- }, z.core.$strip>>;
184
- }, z.core.$strip>>;
185
147
  name: z.ZodString;
186
148
  email: z.ZodEmail;
187
149
  phone: z.ZodString;
@@ -212,7 +174,6 @@ export declare const addStaffFormSchema: z.ZodObject<{
212
174
  }, z.core.$strip>;
213
175
  export type AddStaffFormValues = Omit<AddMemberFormValues, 'trainerId'> & {
214
176
  role: z.infer<typeof staffRoleSchema> | null;
215
- address: Address | null;
216
177
  };
217
178
  /** One blank row so the section renders with a contact to fill. */
218
179
  export declare const EMPTY_EMERGENCY_CONTACT: EmergencyContactRow;
@@ -226,55 +187,17 @@ export declare function toCreateMemberBody(values: AddMemberFormValues, initialP
226
187
  /** Validated staff form → `POST /staff` body. */
227
188
  export declare function toCreateStaffBody(values: AddStaffFormValues): CreateStaffBody;
228
189
  /**
229
- * Editing a member reuses the add form's every rule, plus the Aadhaar last-four
230
- * the desk may not have taken at signup.
190
+ * Editing a member asks for exactly what adding one asks for.
231
191
  *
232
- * It is a separate schema rather than the same one because the two forms differ
233
- * in what "blank" means. On the add form a blank optional is simply not
234
- * provided; on the edit form it is an instruction to CLEAR a value that is
235
- * already stored, and the mapper has to send `null` rather than omit the field.
192
+ * It stays a separate schema and a separate mapper because the two differ in
193
+ * what "blank" means. On the add form a blank optional is simply not provided;
194
+ * on the edit form it is an instruction to CLEAR a value that is already
195
+ * stored, so `toUpdateMemberBody` sends `null` where `toCreateMemberBody`
196
+ * omits the field.
236
197
  */
237
198
  export declare const editMemberFormSchema: z.ZodObject<{
238
199
  trainerId: z.ZodString;
239
- aadhaarLast4: z.ZodString;
240
- address: z.ZodNullable<z.ZodObject<{
241
- country: z.ZodObject<{
242
- id: z.ZodString;
243
- name: z.ZodString;
244
- }, z.core.$strip>;
245
- state: z.ZodObject<{
246
- id: z.ZodString;
247
- name: z.ZodString;
248
- }, z.core.$strip>;
249
- district: z.ZodOptional<z.ZodObject<{
250
- id: z.ZodString;
251
- name: z.ZodString;
252
- }, z.core.$strip>>;
253
- locality: z.ZodOptional<z.ZodObject<{
254
- id: z.ZodString;
255
- name: z.ZodString;
256
- type: z.ZodEnum<{
257
- SUB_DISTRICT: "SUB_DISTRICT";
258
- BLOCK: "BLOCK";
259
- GRAM_PANCHAYAT: "GRAM_PANCHAYAT";
260
- VILLAGE: "VILLAGE";
261
- HAMLET: "HAMLET";
262
- URBAN_BODY: "URBAN_BODY";
263
- URBAN_WARD: "URBAN_WARD";
264
- LOCALITY: "LOCALITY";
265
- }>;
266
- }, z.core.$strip>>;
267
- line1: z.ZodString;
268
- landmark: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
269
- pincode: z.ZodString;
270
- ancestry: z.ZodDefault<z.ZodArray<z.ZodString>>;
271
- coords: z.ZodOptional<z.ZodObject<{
272
- lat: z.ZodNumber;
273
- lng: z.ZodNumber;
274
- source: z.ZodString;
275
- }, z.core.$strip>>;
276
- }, z.core.$strip>>;
277
- idProofUrl: z.ZodString;
200
+ joiningDate: z.ZodString;
278
201
  name: z.ZodString;
279
202
  email: z.ZodEmail;
280
203
  phone: z.ZodString;
@@ -304,16 +227,14 @@ export declare const editMemberFormSchema: z.ZodObject<{
304
227
  }, z.core.$strip>>;
305
228
  }, z.core.$strip>;
306
229
  export type EditMemberFormValues = AddMemberFormValues & {
307
- aadhaarLast4: string;
308
- address: Address | null;
309
- idProofUrl: string;
230
+ joiningDate: string;
310
231
  };
311
232
  /**
312
233
  * Validated edit form → `PATCH /members/:id` body.
313
234
  *
314
235
  * Sends every field, including the cleared ones as `null`, because the owner
315
- * emptying a field is an edit like any other. The four identity fields go
316
- * along too; the server drops them once the member has claimed their account.
236
+ * emptying a field is an edit like any other. The identity fields go along too;
237
+ * the server drops them once the member has claimed their account.
317
238
  */
318
239
  export declare function toUpdateMemberBody(values: EditMemberFormValues): UpdateMemberBody;
319
240
  //# sourceMappingURL=add-person-form.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"add-person-form.d.ts","sourceRoot":"","sources":["../src/add-person-form.ts"],"names":[],"mappings":"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"}
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,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEtE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC7D,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;AAI5C;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAG5F;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG;IAC/D,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB,CAIA;AASD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,yBAAyB;;;;iBAuBlC,CAAC;AAEL,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;AA4ED,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAY5B,CAAC;AAEL;;;;;;;GAOG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,GAAG,IAAI,CAAC;IAC5C,UAAU,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GAAG,EAAE,CAAC;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAIF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAO3B,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;AAgBF,mEAAmE;AACnE,eAAO,MAAM,uBAAuB,EAAE,mBAIrC,CAAC;AAEF,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;AAmDD;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAsB7B,CAAC;AAEL,MAAM,MAAM,oBAAoB,GAAG,mBAAmB,GAAG;IACvD,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,oBAAoB,GAAG,gBAAgB,CAYjF"}
@@ -21,8 +21,9 @@
21
21
  */
22
22
  import { z } from 'zod';
23
23
  import { ageInYears, isRealDate, PHONE_REGEX } from './common.js';
24
+ import { DEFAULT_TIMEZONE } from './location.js';
24
25
  import { emergencyContactSchema, MAX_EMERGENCY_CONTACTS } from './member-profile.js';
25
- import { addressSchema, bloodGroupSchema, genderSchema } from './shared.js';
26
+ import { bloodGroupSchema, genderSchema } from './shared.js';
26
27
  import { staffRoleSchema } from './staff.js';
27
28
  // ─── Bounds ──────────────────────────────────────────────────────────────────
28
29
  /**
@@ -178,24 +179,18 @@ export const addMemberFormSchema = z
178
179
  });
179
180
  // ─── Staff form ──────────────────────────────────────────────────────────────
180
181
  /**
181
- * Staff keep the address a member is no longer asked for but not an ID proof.
182
+ * Staff are asked exactly what a member is asked, plus the job they do.
182
183
  *
183
- * The address stays because this is an employment record: a gym holds one for
184
- * the people it EMPLOYS for reasons that have nothing to do with why it holds a
185
- * member's phone number. The ID upload went for the opposite reason nothing
186
- * in the product ever showed it back, so it was a document collected and then
187
- * never looked at, which is the worst thing to ask anyone for.
184
+ * The home address and the ID upload both went for the same reason: nothing in
185
+ * the product ever showed either one back. A gym that needs an employee's
186
+ * address for a contract has somewhere else to keep it; what it had here was a
187
+ * field collected once and never looked at again, which is the worst thing to
188
+ * ask anyone for.
188
189
  */
189
190
  export const addStaffFormSchema = z
190
191
  .object({
191
192
  ...addPersonBaseShape,
192
193
  role: requiredChoice(staffRoleSchema, 'Select a staff role'),
193
- /**
194
- * The whole address, or null. Picked through the location drawer, which
195
- * hands back a complete `Address` — area chain, ids, names, ancestry,
196
- * coordinates — or nothing.
197
- */
198
- address: addressSchema.nullable(),
199
194
  })
200
195
  .superRefine((v, ctx) => {
201
196
  refineDob(v, ctx);
@@ -227,7 +222,6 @@ export const EMPTY_ADD_STAFF_FORM = {
227
222
  ...EMPTY_BASE,
228
223
  contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],
229
224
  role: null,
230
- address: null,
231
225
  };
232
226
  // ─── Form → API body ─────────────────────────────────────────────────────────
233
227
  /** One "Name" field on screen, `firstName` + `lastName` on the wire. */
@@ -246,8 +240,12 @@ function asPhone(value) {
246
240
  return value.replace(/\D/g, '').slice(-10);
247
241
  }
248
242
  /**
249
- * The fields EVERY desk form sends. Address and ID proof are not among them —
250
- * only two of the three forms ask for those, so each adds its own.
243
+ * The fields EVERY desk form sends.
244
+ *
245
+ * All three now ask exactly the same things about the PERSON; what differs is
246
+ * what the record is FOR, so each mapper adds its own role / trainer / plan on
247
+ * top. Address and ID proof used to be the exception here, asked by two of the
248
+ * three; they are gone from all of them.
251
249
  */
252
250
  function sharedBody(v) {
253
251
  // A row with nothing in it is not a contact. Anything else has already been
@@ -292,50 +290,91 @@ export function toCreateStaffBody(values) {
292
290
  ...sharedBody(values),
293
291
  // The form schema guarantees a role was chosen.
294
292
  role: values.role ?? 'trainer',
295
- // Staff-only — see `addStaffFormSchema`. Already a complete `Address` or
296
- // null; the location drawer cannot produce anything in between.
297
- address: values.address ?? undefined,
298
293
  };
299
294
  }
295
+ /**
296
+ * The joining date must be a real day, and must already have happened.
297
+ *
298
+ * Validated here as well as on the server because this one is not a formatting
299
+ * preference: the date anchors the member's whole billing cycle, so a typo
300
+ * that slips through bills them on the wrong day of every month until somebody
301
+ * notices. The server holds the same rule — this is the copy that can say so
302
+ * before the owner presses save.
303
+ */
304
+ function refineJoiningDate(v, ctx) {
305
+ const raw = v.joiningDate.trim();
306
+ // Blank is "leave it as it is", which is always allowed.
307
+ if (!raw)
308
+ return;
309
+ const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
310
+ if (!parts) {
311
+ ctx.addIssue({ code: 'custom', path: ['joiningDate'], message: 'Enter a valid date' });
312
+ return;
313
+ }
314
+ const [year, month, day] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
315
+ if (!isRealDate(year, month, day)) {
316
+ ctx.addIssue({ code: 'custom', path: ['joiningDate'], message: 'That date does not exist' });
317
+ return;
318
+ }
319
+ /**
320
+ * "Today" means today AT THE GYM, and the comparison is between strings.
321
+ *
322
+ * `en-CA` is the one common locale whose short format is already
323
+ * `YYYY-MM-DD`, so the zone conversion and the formatting happen in one step
324
+ * and the result sorts lexicographically — an exact calendar-day comparison
325
+ * with no instant arithmetic in it. This mirrors `gymDayOf` on the server,
326
+ * deliberately: a UTC day here would be one behind IST between midnight and
327
+ * 5:30am, and would tell an owner opening the app early that TODAY is in the
328
+ * future.
329
+ */
330
+ const today = new Intl.DateTimeFormat('en-CA', { timeZone: DEFAULT_TIMEZONE }).format(new Date());
331
+ if (raw > today) {
332
+ ctx.addIssue({
333
+ code: 'custom',
334
+ path: ['joiningDate'],
335
+ message: 'A joining date cannot be in the future',
336
+ });
337
+ }
338
+ }
300
339
  // ─── Edit member ─────────────────────────────────────────────────────────────
301
340
  /**
302
- * Editing a member reuses the add form's every rule, plus the Aadhaar last-four
303
- * the desk may not have taken at signup.
341
+ * Editing a member asks for exactly what adding one asks for.
304
342
  *
305
- * It is a separate schema rather than the same one because the two forms differ
306
- * in what "blank" means. On the add form a blank optional is simply not
307
- * provided; on the edit form it is an instruction to CLEAR a value that is
308
- * already stored, and the mapper has to send `null` rather than omit the field.
343
+ * It stays a separate schema and a separate mapper because the two differ in
344
+ * what "blank" means. On the add form a blank optional is simply not provided;
345
+ * on the edit form it is an instruction to CLEAR a value that is already
346
+ * stored, so `toUpdateMemberBody` sends `null` where `toCreateMemberBody`
347
+ * omits the field.
309
348
  */
310
349
  export const editMemberFormSchema = z
311
350
  .object({
312
351
  ...addPersonBaseShape,
313
352
  trainerId: z.string(),
314
- /** Blank when not recorded; exactly four digits otherwise. */
315
- aadhaarLast4: z
316
- .string()
317
- .trim()
318
- .refine((v) => v === '' || /^\d{4}$/.test(v), 'Enter the last four digits'),
319
353
  /**
320
- * Address and ID proof are asked HERE and not on the add form.
354
+ * When the GYM says this person started training there, `YYYY-MM-DD`.
355
+ *
356
+ * Asked only on EDIT. At the desk the answer is always "today" and one
357
+ * more field in a queue is one more thing to get wrong; the correction is
358
+ * something the owner makes later, once they realise the app's record and
359
+ * the gym's disagree.
321
360
  *
322
- * Adding somebody happens at a desk with a queue behind them; editing
323
- * happens later, once the gym actually holds the document. Dropping them
324
- * from EDIT as well would leave three fields the API stores, the detail
325
- * screen renders, and no screen anywhere can write.
361
+ * Blank means "leave it alone", never "clear it" unlike the other
362
+ * optional fields on this form. Every enrolment has a joining date because
363
+ * the billing cycle is measured from one, so there is no empty state to
364
+ * write back.
326
365
  */
327
- address: addressSchema.nullable(),
328
- idProofUrl: z.string(),
366
+ joiningDate: z.string(),
329
367
  })
330
368
  .superRefine((v, ctx) => {
331
369
  refineDob(v, ctx);
370
+ refineJoiningDate(v, ctx);
332
371
  });
333
372
  /**
334
373
  * Validated edit form → `PATCH /members/:id` body.
335
374
  *
336
375
  * Sends every field, including the cleared ones as `null`, because the owner
337
- * emptying a field is an edit like any other. The four identity fields go
338
- * along too; the server drops them once the member has claimed their account.
376
+ * emptying a field is an edit like any other. The identity fields go along too;
377
+ * the server drops them once the member has claimed their account.
339
378
  */
340
379
  export function toUpdateMemberBody(values) {
341
380
  const shared = sharedBody(values);
@@ -343,11 +382,11 @@ export function toUpdateMemberBody(values) {
343
382
  ...shared,
344
383
  dob: shared.dob ?? null,
345
384
  bloodGroup: shared.bloodGroup ?? null,
346
- address: values.address,
347
- idProofUrl: values.idProofUrl.trim(),
348
385
  avatarUrl: values.avatarUrl.trim(),
349
386
  assignedTrainerId: orUndefined(values.trainerId) ?? null,
350
- aadhaarLast4: values.aadhaarLast4.trim() === '' ? null : values.aadhaarLast4.trim(),
387
+ // OMITTED when blank rather than sent as null: every enrolment has a
388
+ // joining date, so "no answer" means leave the stored one alone.
389
+ joinedAt: orUndefined(values.joiningDate.trim()),
351
390
  };
352
391
  }
353
392
  //# sourceMappingURL=add-person-form.js.map
@@ -1 +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, and `''` for an optional value left blank. Those are legitimate mid-edit\n * states that the API body must never see. Expressing the same rules over the\n * 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 } from './common.js';\nimport type { CreateMemberBody, UpdateMemberBody } from './member.js';\nimport { emergencyContactSchema, MAX_EMERGENCY_CONTACTS } from './member-profile.js';\nimport type { AssignMembershipBody } from './membership.js';\nimport { type Address, addressSchema, bloodGroupSchema, genderSchema } 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// ─── Date of birth, as three cells ───────────────────────────────────────────\n\n/**\n * The three cells joined into the `YYYY-MM-DD` the API stores, or `undefined`\n * when nothing was entered.\n *\n * Desk entry does not require a date of birth — a gym adds people with\n * whatever it has to hand — so \"blank\" is a real answer and has to survive the\n * whole way to the body as an ABSENT field rather than an empty string the\n * API would reject.\n */\nexport function isoFromDobCells(day: string, month: string, year: string): string | undefined {\n if (!day || !month || !year) return undefined;\n return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;\n}\n\n/** A stored `YYYY-MM-DD` split back into the cells, for seeding an edit form. */\nexport function dobCellsFromIso(iso: string | null | undefined): {\n dobDay: string;\n dobMonth: string;\n dobYear: string;\n} {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(iso ?? '');\n if (!match) return { dobDay: '', dobMonth: '', dobYear: '' };\n return { dobYear: match[1] as string, dobMonth: match[2] as string, dobDay: match[3] as string };\n}\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// ─── One emergency-contact row ───────────────────────────────────────────────\n\n/**\n * One emergency-contact row: WHO to call, on what number, and how they are\n * related. A row is either wholly blank (ignored) or names a person.\n *\n * ── Why `name` exists now ───────────────────────────────────────────────────\n * This form used to collect a relationship and a phone with no name at all,\n * and the mapper wrote the relationship INTO `name` because\n * `emergencyContactSchema` requires one. The member's own surfaces store a real\n * name, so the two halves of the product disagreed about what the field held,\n * and the disagreement destroyed data: a member onboards with \"Priya\", the\n * owner opens Edit Member and sees Relationship prefilled \"Priya\" (the reader\n * fell back to `name`), corrects it to \"Sister\", saves — and the contact is now\n * called Sister. Whoever has to ring that number in an emergency is left with\n * a word instead of a person.\n *\n * `relationship` stays OPTIONAL, matching `emergencyContactSchema`. It is\n * useful context, not something to block a desk entry over.\n */\nexport const emergencyContactRowSchema = z\n .object({\n name: z.string().trim().max(120),\n relationship: z.string().trim().max(60),\n phone: z.string().trim(),\n })\n .superRefine((v, ctx) => {\n const blank = v.name === '' && v.relationship === '' && v.phone === '';\n if (blank) return;\n if (v.name === '') {\n ctx.addIssue({\n code: 'custom',\n path: ['name'],\n message: \"Add the contact's name\",\n });\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 name: string;\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 /**\n * Three cells, not a native date input.\n *\n * `<input type=\"date\">` renders in the BROWSER'S locale, so the same field\n * read \"mm/dd/yyyy\" at an Indian gym's front desk — and a member's own\n * onboarding asked for the very same fact through `DobInput`'s DD/MM/YYYY\n * cells. Two surfaces collecting one fact in two formats is how a birthday\n * gets typed the wrong way round.\n *\n * All three or none: desk entry does not require a date of birth, so blank\n * is a real answer, but half a date is not.\n */\n dobDay: z.string(),\n dobMonth: z.string(),\n dobYear: z.string(),\n gender: requiredChoice(genderSchema, 'Select a gender'),\n\n /** Blank when not recorded. */\n bloodGroup: bloodGroupSchema.or(z.literal('')),\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(\n v: { dobDay: string; dobMonth: string; dobYear: string },\n ctx: z.RefinementCtx,\n): void {\n const touched = Boolean(v.dobDay || v.dobMonth || v.dobYear);\n // Nothing entered is fine — the desk records what it has.\n if (!touched) return;\n\n // Every message lands on `dobDay`, the first cell, because the three are one\n // control on screen: an error parked on `dobYear` would sit under a field\n // the form renders as a single row and reads back from `dobDay`.\n if (!v.dobDay || !v.dobMonth || v.dobYear.length !== 4) {\n ctx.addIssue({ code: 'custom', path: ['dobDay'], message: 'Enter the full date of birth' });\n return;\n }\n\n const day = Number(v.dobDay);\n const month = Number(v.dobMonth);\n const year = Number(v.dobYear);\n\n if (!isRealDate(year, month, day)) {\n ctx.addIssue({ code: 'custom', path: ['dobDay'], message: 'That date does not exist' });\n return;\n }\n\n const age = ageInYears(year, month, day);\n if (age < 0) {\n ctx.addIssue({\n code: 'custom',\n path: ['dobDay'],\n message: 'Date of birth cannot be in the future',\n });\n } else if (age < ADD_PERSON_MIN_AGE_YEARS || age > ADD_PERSON_MAX_AGE_YEARS) {\n ctx.addIssue({ code: 'custom', path: ['dobDay'], message: 'Check the year of birth' });\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 });\n\n/**\n * Adding a member asks for the person, not their paperwork.\n *\n * No address and no ID proof: neither is something a front desk has when\n * somebody walks in, both were optional and therefore skipped, and the member's\n * own screens never collected them either. They live on the EDIT form, where an\n * owner fills them in once they actually have them, and on the staff form,\n * where an employer legitimately does.\n */\nexport type AddMemberFormValues = {\n name: string;\n email: string;\n phone: string;\n dobDay: string;\n dobMonth: string;\n dobYear: string;\n gender: z.infer<typeof genderSchema> | null;\n bloodGroup: z.infer<typeof bloodGroupSchema> | '';\n avatarUrl: string;\n contacts: EmergencyContactRow[];\n /** Optional — a gym with no trainers yet, or none chosen. `''` for none. */\n trainerId: string;\n};\n\n// ─── Staff form ──────────────────────────────────────────────────────────────\n\n/**\n * Staff keep the address a member is no longer asked for — but not an ID proof.\n *\n * The address stays because this is an employment record: a gym holds one for\n * the people it EMPLOYS for reasons that have nothing to do with why it holds a\n * member's phone number. The ID upload went for the opposite reason — nothing\n * in the product ever showed it back, so it was a document collected and then\n * never looked at, which is the worst thing to ask anyone for.\n */\nexport const addStaffFormSchema = z\n .object({\n ...addPersonBaseShape,\n role: requiredChoice(staffRoleSchema, 'Select a staff role'),\n /**\n * The whole address, or null. Picked through the location drawer, which\n * hands back a complete `Address` — area chain, ids, names, ancestry,\n * coordinates — or nothing.\n */\n address: addressSchema.nullable(),\n })\n .superRefine((v, ctx) => {\n refineDob(v, ctx);\n });\n\nexport type AddStaffFormValues = Omit<AddMemberFormValues, 'trainerId'> & {\n role: z.infer<typeof staffRoleSchema> | null;\n address: Address | null;\n};\n\n// ─── Empty states ────────────────────────────────────────────────────────────\n\nconst EMPTY_BASE = {\n name: '',\n email: '',\n phone: '',\n dobDay: '',\n dobMonth: '',\n dobYear: '',\n gender: null,\n bloodGroup: '' as const,\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 = {\n name: '',\n relationship: '',\n phone: '',\n};\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 address: 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\n/**\n * The fields EVERY desk form sends. Address and ID proof are not among them —\n * only two of the three forms ask for those, so each adds its own.\n */\nfunction sharedBody(v: Omit<AddMemberFormValues, 'trainerId'>) {\n // A row with nothing in it is not a contact. Anything else has already been\n // through `emergencyContactRowSchema`, which requires a name and a valid\n // phone the moment a row is touched.\n const emergencyContacts = v.contacts\n .filter((c) => c.name !== '' || c.relationship !== '' || c.phone !== '')\n .map((c) =>\n emergencyContactSchema.parse({\n name: c.name.trim(),\n phone: asPhone(c.phone),\n relationship: orUndefined(c.relationship),\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 // Absent rather than empty when the desk did not record one — see\n // `isoFromDobCells`.\n dob: isoFromDobCells(v.dobDay, v.dobMonth, v.dobYear),\n bloodGroup: v.bloodGroup === '' ? undefined : v.bloodGroup,\n emergencyContacts,\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 // Staff-only — see `addStaffFormSchema`. Already a complete `Address` or\n // null; the location drawer cannot produce anything in between.\n address: values.address ?? undefined,\n };\n}\n\n// ─── Edit member ─────────────────────────────────────────────────────────────\n\n/**\n * Editing a member reuses the add form's every rule, plus the Aadhaar last-four\n * the desk may not have taken at signup.\n *\n * It is a separate schema rather than the same one because the two forms differ\n * in what \"blank\" means. On the add form a blank optional is simply not\n * provided; on the edit form it is an instruction to CLEAR a value that is\n * already stored, and the mapper has to send `null` rather than omit the field.\n */\nexport const editMemberFormSchema = z\n .object({\n ...addPersonBaseShape,\n trainerId: z.string(),\n /** Blank when not recorded; exactly four digits otherwise. */\n aadhaarLast4: z\n .string()\n .trim()\n .refine((v) => v === '' || /^\\d{4}$/.test(v), 'Enter the last four digits'),\n /**\n * Address and ID proof are asked HERE and not on the add form.\n *\n * Adding somebody happens at a desk with a queue behind them; editing\n * happens later, once the gym actually holds the document. Dropping them\n * from EDIT as well would leave three fields the API stores, the detail\n * screen renders, and no screen anywhere can write.\n */\n address: addressSchema.nullable(),\n idProofUrl: z.string(),\n })\n .superRefine((v, ctx) => {\n refineDob(v, ctx);\n });\n\nexport type EditMemberFormValues = AddMemberFormValues & {\n aadhaarLast4: string;\n address: Address | null;\n idProofUrl: string;\n};\n\n/**\n * Validated edit form → `PATCH /members/:id` body.\n *\n * Sends every field, including the cleared ones as `null`, because the owner\n * emptying a field is an edit like any other. The four identity fields go\n * along too; the server drops them once the member has claimed their account.\n */\nexport function toUpdateMemberBody(values: EditMemberFormValues): UpdateMemberBody {\n const shared = sharedBody(values);\n return {\n ...shared,\n dob: shared.dob ?? null,\n bloodGroup: shared.bloodGroup ?? null,\n address: values.address,\n idProofUrl: values.idProofUrl.trim(),\n avatarUrl: values.avatarUrl.trim(),\n assignedTrainerId: orUndefined(values.trainerId) ?? null,\n aadhaarLast4: values.aadhaarLast4.trim() === '' ? null : values.aadhaarLast4.trim(),\n };\n}\n"]}
1
+ {"version":3,"file":"add-person-form.js","sourceRoot":"","sources":["../src/add-person-form.ts"],"names":[],"mappings":"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","sourcesContent":["/**\r\n * gymmonk-schema — \"Add member\" / \"Add staff\" FORM schemas\r\n * ========================================================\r\n * The client-side validation contract for the owner's desk-entry forms, and the\r\n * mappers that turn a filled form into the `createMember` / `createStaff` API\r\n * bodies.\r\n *\r\n * Why these live here rather than in the client, and why they are distinct from\r\n * `createMemberBodySchema`: the form's working shape is not the wire shape. It\r\n * holds ONE \"name\" field the API splits in two, `null` for a choice not yet\r\n * made, and `''` for an optional value left blank. Those are legitimate mid-edit\r\n * states that the API body must never see. Expressing the same rules over the\r\n * form shape here keeps\r\n * every message, bound and regex defined once — the client cannot drift from\r\n * what the server will accept, and the coercion from one shape to the other is\r\n * part of the contract instead of ad-hoc code in a component.\r\n *\r\n * Mirrors `onboarding-form.ts`, which does the same job for the wizards.\r\n *\r\n * @module gymmonk-schema/add-person-form\r\n */\r\n\r\nimport { z } from 'zod';\r\nimport { ageInYears, isRealDate, PHONE_REGEX } from './common.js';\r\nimport { DEFAULT_TIMEZONE } from './location.js';\r\nimport type { CreateMemberBody, UpdateMemberBody } from './member.js';\r\nimport { emergencyContactSchema, MAX_EMERGENCY_CONTACTS } from './member-profile.js';\r\nimport type { AssignMembershipBody } from './membership.js';\r\nimport { bloodGroupSchema, genderSchema } from './shared.js';\r\nimport type { CreateStaffBody } from './staff.js';\r\nimport { staffRoleSchema } from './staff.js';\r\n\r\n// ─── Bounds ──────────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * Age bounds for someone signed up at the desk. Wider than the member wizard's\r\n * because a gym also enrols juniors with a guardian present, and the date is\r\n * typed by staff rather than by the person themselves.\r\n */\r\nexport const ADD_PERSON_MIN_AGE_YEARS = 5;\r\nexport const ADD_PERSON_MAX_AGE_YEARS = 100;\r\n\r\n// ─── Date of birth, as three cells ───────────────────────────────────────────\r\n\r\n/**\r\n * The three cells joined into the `YYYY-MM-DD` the API stores, or `undefined`\r\n * when nothing was entered.\r\n *\r\n * Desk entry does not require a date of birth — a gym adds people with\r\n * whatever it has to hand — so \"blank\" is a real answer and has to survive the\r\n * whole way to the body as an ABSENT field rather than an empty string the\r\n * API would reject.\r\n */\r\nexport function isoFromDobCells(day: string, month: string, year: string): string | undefined {\r\n if (!day || !month || !year) return undefined;\r\n return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;\r\n}\r\n\r\n/** A stored `YYYY-MM-DD` split back into the cells, for seeding an edit form. */\r\nexport function dobCellsFromIso(iso: string | null | undefined): {\r\n dobDay: string;\r\n dobMonth: string;\r\n dobYear: string;\r\n} {\r\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(iso ?? '');\r\n if (!match) return { dobDay: '', dobMonth: '', dobYear: '' };\r\n return { dobYear: match[1] as string, dobMonth: match[2] as string, dobDay: match[3] as string };\r\n}\r\n\r\n// ─── Field-level rules ───────────────────────────────────────────────────────\r\n\r\nconst requiredChoice = <T extends z.ZodTypeAny>(inner: T, message: string) =>\r\n inner.nullable().refine((v: unknown) => v !== null, { message });\r\n\r\n// ─── One emergency-contact row ───────────────────────────────────────────────\r\n\r\n/**\r\n * One emergency-contact row: WHO to call, on what number, and how they are\r\n * related. A row is either wholly blank (ignored) or names a person.\r\n *\r\n * ── Why `name` exists now ───────────────────────────────────────────────────\r\n * This form used to collect a relationship and a phone with no name at all,\r\n * and the mapper wrote the relationship INTO `name` because\r\n * `emergencyContactSchema` requires one. The member's own surfaces store a real\r\n * name, so the two halves of the product disagreed about what the field held,\r\n * and the disagreement destroyed data: a member onboards with \"Priya\", the\r\n * owner opens Edit Member and sees Relationship prefilled \"Priya\" (the reader\r\n * fell back to `name`), corrects it to \"Sister\", saves — and the contact is now\r\n * called Sister. Whoever has to ring that number in an emergency is left with\r\n * a word instead of a person.\r\n *\r\n * `relationship` stays OPTIONAL, matching `emergencyContactSchema`. It is\r\n * useful context, not something to block a desk entry over.\r\n */\r\nexport const emergencyContactRowSchema = z\r\n .object({\r\n name: z.string().trim().max(120),\r\n relationship: z.string().trim().max(60),\r\n phone: z.string().trim(),\r\n })\r\n .superRefine((v, ctx) => {\r\n const blank = v.name === '' && v.relationship === '' && v.phone === '';\r\n if (blank) return;\r\n if (v.name === '') {\r\n ctx.addIssue({\r\n code: 'custom',\r\n path: ['name'],\r\n message: \"Add the contact's name\",\r\n });\r\n }\r\n if (!PHONE_REGEX.test(v.phone)) {\r\n ctx.addIssue({\r\n code: 'custom',\r\n path: ['phone'],\r\n message: 'Enter a valid 10-digit mobile number',\r\n });\r\n }\r\n });\r\n\r\nexport interface EmergencyContactRow {\r\n name: string;\r\n relationship: string;\r\n phone: string;\r\n}\r\n\r\n// ─── Shared base ─────────────────────────────────────────────────────────────\r\n\r\nconst addPersonBaseShape = {\r\n /** One field on screen; split into `firstName` / `lastName` on the wire. */\r\n name: z.string().trim().min(1, 'Name is required').max(160),\r\n email: z.email('Enter a valid email'),\r\n phone: z.string().regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number'),\r\n /**\r\n * Three cells, not a native date input.\r\n *\r\n * `<input type=\"date\">` renders in the BROWSER'S locale, so the same field\r\n * read \"mm/dd/yyyy\" at an Indian gym's front desk — and a member's own\r\n * onboarding asked for the very same fact through `DobInput`'s DD/MM/YYYY\r\n * cells. Two surfaces collecting one fact in two formats is how a birthday\r\n * gets typed the wrong way round.\r\n *\r\n * All three or none: desk entry does not require a date of birth, so blank\r\n * is a real answer, but half a date is not.\r\n */\r\n dobDay: z.string(),\r\n dobMonth: z.string(),\r\n dobYear: z.string(),\r\n gender: requiredChoice(genderSchema, 'Select a gender'),\r\n\r\n /** Blank when not recorded. */\r\n bloodGroup: bloodGroupSchema.or(z.literal('')),\r\n avatarUrl: z.string(),\r\n\r\n contacts: z.array(emergencyContactRowSchema).max(MAX_EMERGENCY_CONTACTS),\r\n};\r\n\r\n/**\r\n * Date of birth is optional, but a typed one has to be real and plausible —\r\n * a mis-keyed year is the single most common desk-entry slip.\r\n */\r\nfunction refineDob(\r\n v: { dobDay: string; dobMonth: string; dobYear: string },\r\n ctx: z.RefinementCtx,\r\n): void {\r\n const touched = Boolean(v.dobDay || v.dobMonth || v.dobYear);\r\n // Nothing entered is fine — the desk records what it has.\r\n if (!touched) return;\r\n\r\n // Every message lands on `dobDay`, the first cell, because the three are one\r\n // control on screen: an error parked on `dobYear` would sit under a field\r\n // the form renders as a single row and reads back from `dobDay`.\r\n if (!v.dobDay || !v.dobMonth || v.dobYear.length !== 4) {\r\n ctx.addIssue({ code: 'custom', path: ['dobDay'], message: 'Enter the full date of birth' });\r\n return;\r\n }\r\n\r\n const day = Number(v.dobDay);\r\n const month = Number(v.dobMonth);\r\n const year = Number(v.dobYear);\r\n\r\n if (!isRealDate(year, month, day)) {\r\n ctx.addIssue({ code: 'custom', path: ['dobDay'], message: 'That date does not exist' });\r\n return;\r\n }\r\n\r\n const age = ageInYears(year, month, day);\r\n if (age < 0) {\r\n ctx.addIssue({\r\n code: 'custom',\r\n path: ['dobDay'],\r\n message: 'Date of birth cannot be in the future',\r\n });\r\n } else if (age < ADD_PERSON_MIN_AGE_YEARS || age > ADD_PERSON_MAX_AGE_YEARS) {\r\n ctx.addIssue({ code: 'custom', path: ['dobDay'], message: 'Check the year of birth' });\r\n }\r\n}\r\n\r\n// ─── Member form ─────────────────────────────────────────────────────────────\r\n\r\nexport const addMemberFormSchema = z\r\n .object({\r\n ...addPersonBaseShape,\r\n /**\r\n * Central-auth user id of the trainer, or `''` for none. Only trainers who\r\n * have CLAIMED their invite can be assigned — an unclaimed staff record has\r\n * no user to point at (`MemberProfile.assignedTrainerId` refs `User`).\r\n */\r\n trainerId: z.string(),\r\n })\r\n .superRefine((v, ctx) => {\r\n refineDob(v, ctx);\r\n });\r\n\r\n/**\r\n * Adding a member asks for the person, not their paperwork.\r\n *\r\n * No address, no Aadhaar and no ID proof — on this form or on any other. A\r\n * front desk does not have them when somebody walks in, the member's own\r\n * screens never asked for them, and nothing in the product ever read one back.\r\n * Every surface that collected them was filling a drawer nobody opens.\r\n */\r\nexport type AddMemberFormValues = {\r\n name: string;\r\n email: string;\r\n phone: string;\r\n dobDay: string;\r\n dobMonth: string;\r\n dobYear: string;\r\n gender: z.infer<typeof genderSchema> | null;\r\n bloodGroup: z.infer<typeof bloodGroupSchema> | '';\r\n avatarUrl: string;\r\n contacts: EmergencyContactRow[];\r\n /** Optional — a gym with no trainers yet, or none chosen. `''` for none. */\r\n trainerId: string;\r\n};\r\n\r\n// ─── Staff form ──────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * Staff are asked exactly what a member is asked, plus the job they do.\r\n *\r\n * The home address and the ID upload both went for the same reason: nothing in\r\n * the product ever showed either one back. A gym that needs an employee's\r\n * address for a contract has somewhere else to keep it; what it had here was a\r\n * field collected once and never looked at again, which is the worst thing to\r\n * ask anyone for.\r\n */\r\nexport const addStaffFormSchema = z\r\n .object({\r\n ...addPersonBaseShape,\r\n role: requiredChoice(staffRoleSchema, 'Select a staff role'),\r\n })\r\n .superRefine((v, ctx) => {\r\n refineDob(v, ctx);\r\n });\r\n\r\nexport type AddStaffFormValues = Omit<AddMemberFormValues, 'trainerId'> & {\r\n role: z.infer<typeof staffRoleSchema> | null;\r\n};\r\n\r\n// ─── Empty states ────────────────────────────────────────────────────────────\r\n\r\nconst EMPTY_BASE = {\r\n name: '',\r\n email: '',\r\n phone: '',\r\n dobDay: '',\r\n dobMonth: '',\r\n dobYear: '',\r\n gender: null,\r\n bloodGroup: '' as const,\r\n avatarUrl: '',\r\n} satisfies Omit<AddMemberFormValues, 'contacts' | 'trainerId'>;\r\n\r\n/** One blank row so the section renders with a contact to fill. */\r\nexport const EMPTY_EMERGENCY_CONTACT: EmergencyContactRow = {\r\n name: '',\r\n relationship: '',\r\n phone: '',\r\n};\r\n\r\nexport const EMPTY_ADD_MEMBER_FORM: AddMemberFormValues = {\r\n ...EMPTY_BASE,\r\n contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],\r\n trainerId: '',\r\n};\r\n\r\nexport const EMPTY_ADD_STAFF_FORM: AddStaffFormValues = {\r\n ...EMPTY_BASE,\r\n contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],\r\n role: null,\r\n};\r\n\r\n// ─── Form → API body ─────────────────────────────────────────────────────────\r\n\r\n/** One \"Name\" field on screen, `firstName` + `lastName` on the wire. */\r\nfunction splitName(value: string): { firstName: string; lastName?: string } {\r\n const parts = value.trim().split(/\\s+/).filter(Boolean);\r\n const lastName = parts.slice(1).join(' ');\r\n return { firstName: parts[0] ?? '', ...(lastName ? { lastName } : {}) };\r\n}\r\n\r\n/** Drop a blank optional so it reads as \"not provided\" rather than `''`. */\r\nfunction orUndefined(value: string): string | undefined {\r\n const trimmed = value.trim();\r\n return trimmed === '' ? undefined : trimmed;\r\n}\r\n\r\n/** Phones are STORED raw — strip any display formatting back to 10 digits. */\r\nfunction asPhone(value: string): string {\r\n return value.replace(/\\D/g, '').slice(-10);\r\n}\r\n\r\n/**\r\n * The fields EVERY desk form sends.\r\n *\r\n * All three now ask exactly the same things about the PERSON; what differs is\r\n * what the record is FOR, so each mapper adds its own role / trainer / plan on\r\n * top. Address and ID proof used to be the exception here, asked by two of the\r\n * three; they are gone from all of them.\r\n */\r\nfunction sharedBody(v: Omit<AddMemberFormValues, 'trainerId'>) {\r\n // A row with nothing in it is not a contact. Anything else has already been\r\n // through `emergencyContactRowSchema`, which requires a name and a valid\r\n // phone the moment a row is touched.\r\n const emergencyContacts = v.contacts\r\n .filter((c) => c.name !== '' || c.relationship !== '' || c.phone !== '')\r\n .map((c) =>\r\n emergencyContactSchema.parse({\r\n name: c.name.trim(),\r\n phone: asPhone(c.phone),\r\n relationship: orUndefined(c.relationship),\r\n }),\r\n )\r\n .slice(0, MAX_EMERGENCY_CONTACTS);\r\n\r\n return {\r\n ...splitName(v.name),\r\n email: v.email.trim(),\r\n phone: asPhone(v.phone),\r\n // The form schema guarantees a choice was made before we get here.\r\n gender: v.gender ?? 'other',\r\n // Absent rather than empty when the desk did not record one — see\r\n // `isoFromDobCells`.\r\n dob: isoFromDobCells(v.dobDay, v.dobMonth, v.dobYear),\r\n bloodGroup: v.bloodGroup === '' ? undefined : v.bloodGroup,\r\n emergencyContacts,\r\n avatarUrl: orUndefined(v.avatarUrl),\r\n };\r\n}\r\n\r\n/**\r\n * Validated member form → `POST /members` body. `initialPlan` comes from the\r\n * membership step, which the form itself knows nothing about.\r\n */\r\nexport function toCreateMemberBody(\r\n values: AddMemberFormValues,\r\n initialPlan?: AssignMembershipBody,\r\n): CreateMemberBody {\r\n return {\r\n ...sharedBody(values),\r\n assignedTrainerId: orUndefined(values.trainerId),\r\n ...(initialPlan ? { initialPlan } : {}),\r\n };\r\n}\r\n\r\n/** Validated staff form → `POST /staff` body. */\r\nexport function toCreateStaffBody(values: AddStaffFormValues): CreateStaffBody {\r\n return {\r\n ...sharedBody(values),\r\n // The form schema guarantees a role was chosen.\r\n role: values.role ?? 'trainer',\r\n };\r\n}\r\n\r\n/**\r\n * The joining date must be a real day, and must already have happened.\r\n *\r\n * Validated here as well as on the server because this one is not a formatting\r\n * preference: the date anchors the member's whole billing cycle, so a typo\r\n * that slips through bills them on the wrong day of every month until somebody\r\n * notices. The server holds the same rule — this is the copy that can say so\r\n * before the owner presses save.\r\n */\r\nfunction refineJoiningDate(v: { joiningDate: string }, ctx: z.RefinementCtx): void {\r\n const raw = v.joiningDate.trim();\r\n // Blank is \"leave it as it is\", which is always allowed.\r\n if (!raw) return;\r\n\r\n const parts = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(raw);\r\n if (!parts) {\r\n ctx.addIssue({ code: 'custom', path: ['joiningDate'], message: 'Enter a valid date' });\r\n return;\r\n }\r\n\r\n const [year, month, day] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];\r\n if (!isRealDate(year, month, day)) {\r\n ctx.addIssue({ code: 'custom', path: ['joiningDate'], message: 'That date does not exist' });\r\n return;\r\n }\r\n\r\n /**\r\n * \"Today\" means today AT THE GYM, and the comparison is between strings.\r\n *\r\n * `en-CA` is the one common locale whose short format is already\r\n * `YYYY-MM-DD`, so the zone conversion and the formatting happen in one step\r\n * and the result sorts lexicographically — an exact calendar-day comparison\r\n * with no instant arithmetic in it. This mirrors `gymDayOf` on the server,\r\n * deliberately: a UTC day here would be one behind IST between midnight and\r\n * 5:30am, and would tell an owner opening the app early that TODAY is in the\r\n * future.\r\n */\r\n const today = new Intl.DateTimeFormat('en-CA', { timeZone: DEFAULT_TIMEZONE }).format(new Date());\r\n if (raw > today) {\r\n ctx.addIssue({\r\n code: 'custom',\r\n path: ['joiningDate'],\r\n message: 'A joining date cannot be in the future',\r\n });\r\n }\r\n}\r\n\r\n// ─── Edit member ─────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * Editing a member asks for exactly what adding one asks for.\r\n *\r\n * It stays a separate schema and a separate mapper because the two differ in\r\n * what \"blank\" means. On the add form a blank optional is simply not provided;\r\n * on the edit form it is an instruction to CLEAR a value that is already\r\n * stored, so `toUpdateMemberBody` sends `null` where `toCreateMemberBody`\r\n * omits the field.\r\n */\r\nexport const editMemberFormSchema = z\r\n .object({\r\n ...addPersonBaseShape,\r\n trainerId: z.string(),\r\n /**\r\n * When the GYM says this person started training there, `YYYY-MM-DD`.\r\n *\r\n * Asked only on EDIT. At the desk the answer is always \"today\" and one\r\n * more field in a queue is one more thing to get wrong; the correction is\r\n * something the owner makes later, once they realise the app's record and\r\n * the gym's disagree.\r\n *\r\n * Blank means \"leave it alone\", never \"clear it\" — unlike the other\r\n * optional fields on this form. Every enrolment has a joining date because\r\n * the billing cycle is measured from one, so there is no empty state to\r\n * write back.\r\n */\r\n joiningDate: z.string(),\r\n })\r\n .superRefine((v, ctx) => {\r\n refineDob(v, ctx);\r\n refineJoiningDate(v, ctx);\r\n });\r\n\r\nexport type EditMemberFormValues = AddMemberFormValues & {\r\n joiningDate: string;\r\n};\r\n\r\n/**\r\n * Validated edit form → `PATCH /members/:id` body.\r\n *\r\n * Sends every field, including the cleared ones as `null`, because the owner\r\n * emptying a field is an edit like any other. The identity fields go along too;\r\n * the server drops them once the member has claimed their account.\r\n */\r\nexport function toUpdateMemberBody(values: EditMemberFormValues): UpdateMemberBody {\r\n const shared = sharedBody(values);\r\n return {\r\n ...shared,\r\n dob: shared.dob ?? null,\r\n bloodGroup: shared.bloodGroup ?? null,\r\n avatarUrl: values.avatarUrl.trim(),\r\n assignedTrainerId: orUndefined(values.trainerId) ?? null,\r\n // OMITTED when blank rather than sent as null: every enrolment has a\r\n // joining date, so \"no answer\" means leave the stored one alone.\r\n joinedAt: orUndefined(values.joiningDate.trim()),\r\n };\r\n}\r\n"]}
package/dist/file.d.ts CHANGED
@@ -25,12 +25,11 @@ import { z } from 'zod';
25
25
  * `<folder>/<userId>/<timestamp>-<random><ext>`. That per-user prefix is what
26
26
  * makes ownership checkable on cleanup.
27
27
  */
28
- export declare const UPLOAD_FOLDERS: readonly ["profile-photos", "center-photos", "logos", "member-documents", "equipment-photos", "announcement-media", "exercise-media", "progress-photos", "payment-proofs", "feedback-media"];
28
+ export declare const UPLOAD_FOLDERS: readonly ["profile-photos", "center-photos", "logos", "equipment-photos", "announcement-media", "exercise-media", "progress-photos", "payment-proofs", "feedback-media"];
29
29
  export declare const uploadFolderSchema: z.ZodEnum<{
30
30
  "profile-photos": "profile-photos";
31
31
  "center-photos": "center-photos";
32
32
  logos: "logos";
33
- "member-documents": "member-documents";
34
33
  "equipment-photos": "equipment-photos";
35
34
  "announcement-media": "announcement-media";
36
35
  "exercise-media": "exercise-media";
@@ -56,7 +55,16 @@ export type UploadMimeType = z.infer<typeof uploadMimeTypeSchema>;
56
55
  * perfectly ordinary photos.
57
56
  */
58
57
  export declare const MAX_UPLOAD_SIZE_MB = 10;
59
- /** Per-folder overrides. Documents may be larger scans than a photo. */
58
+ /**
59
+ * Per-folder overrides, for a folder that legitimately needs more room than a
60
+ * photo.
61
+ *
62
+ * Empty today. It held one entry, `member-documents` at 20MB, for the ID scans
63
+ * the front desk used to take; that folder went when the ID-proof upload did,
64
+ * because nothing in the product ever showed a scan back. Kept as the seam
65
+ * rather than deleted, so raising a single folder's ceiling stays a one-line
66
+ * change instead of a refactor.
67
+ */
60
68
  export declare const FOLDER_MAX_SIZE_MB: Partial<Record<UploadFolder, number>>;
61
69
  /**
62
70
  * Request body for `POST /api/v1/files/signed-url`.
@@ -78,7 +86,6 @@ export declare const signedUploadUrlBodySchema: z.ZodObject<{
78
86
  "profile-photos": "profile-photos";
79
87
  "center-photos": "center-photos";
80
88
  logos: "logos";
81
- "member-documents": "member-documents";
82
89
  "equipment-photos": "equipment-photos";
83
90
  "announcement-media": "announcement-media";
84
91
  "exercise-media": "exercise-media";
@@ -1 +1 @@
1
- {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,8LAqBjB,CAAC;AAEX,eAAO,MAAM,kBAAkB;;;;;;;;;;;EAAyB,CAAC;AACzD,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wDAAwD;AACxD,eAAO,MAAM,yBAAyB,oFAM5B,CAAC;AAEX,eAAO,MAAM,oBAAoB;;;;;;EAAoC,CAAC;AACtE,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC,wEAAwE;AACxE,eAAO,MAAM,kBAAkB,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,CAEpE,CAAC;AAMF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;iBA0BlC,CAAC;AAEL,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAE5E,mDAAmD;AACnD,eAAO,MAAM,6BAA6B;;;;;iBAUxC,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAIpF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB;;iBAEnC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,eAAO,MAAM,4BAA4B;;;iBAGvC,CAAC;AAEH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC"}
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,0KAmBjB,CAAC;AAEX,eAAO,MAAM,kBAAkB;;;;;;;;;;EAAyB,CAAC;AACzD,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wDAAwD;AACxD,eAAO,MAAM,yBAAyB,oFAM5B,CAAC;AAEX,eAAO,MAAM,oBAAoB;;;;;;EAAoC,CAAC;AACtE,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,CAAM,CAAC;AAM5E;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;iBA0BlC,CAAC;AAEL,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAE5E,mDAAmD;AACnD,eAAO,MAAM,6BAA6B;;;;;iBAUxC,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAIpF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB;;iBAEnC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,eAAO,MAAM,4BAA4B;;;iBAGvC,CAAC;AAEH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC"}
package/dist/file.js CHANGED
@@ -33,8 +33,6 @@ export const UPLOAD_FOLDERS = [
33
33
  'center-photos',
34
34
  /** Organisation and center logos. */
35
35
  'logos',
36
- /** Member ID proof / address proof captured at the front desk. */
37
- 'member-documents',
38
36
  /** Equipment photos in the gym inventory. */
39
37
  'equipment-photos',
40
38
  /** Images attached to an announcement. */
@@ -67,10 +65,17 @@ export const uploadMimeTypeSchema = z.enum(ALLOWED_UPLOAD_MIME_TYPES);
67
65
  * perfectly ordinary photos.
68
66
  */
69
67
  export const MAX_UPLOAD_SIZE_MB = 10;
70
- /** Per-folder overrides. Documents may be larger scans than a photo. */
71
- export const FOLDER_MAX_SIZE_MB = {
72
- 'member-documents': 20,
73
- };
68
+ /**
69
+ * Per-folder overrides, for a folder that legitimately needs more room than a
70
+ * photo.
71
+ *
72
+ * Empty today. It held one entry, `member-documents` at 20MB, for the ID scans
73
+ * the front desk used to take; that folder went when the ID-proof upload did,
74
+ * because nothing in the product ever showed a scan back. Kept as the seam
75
+ * rather than deleted, so raising a single folder's ceiling stays a one-line
76
+ * change instead of a refactor.
77
+ */
78
+ export const FOLDER_MAX_SIZE_MB = {};
74
79
  const HARD_CAP_MB = Math.max(MAX_UPLOAD_SIZE_MB, ...Object.values(FOLDER_MAX_SIZE_MB));
75
80
  // ─── Signed upload URL ───────────────────────────────────────────────────────
76
81
  /**