gymmonk-schema 0.43.0 → 0.44.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,16 @@ 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;
278
200
  name: z.ZodString;
279
201
  email: z.ZodEmail;
280
202
  phone: z.ZodString;
@@ -303,17 +225,13 @@ export declare const editMemberFormSchema: z.ZodObject<{
303
225
  phone: z.ZodString;
304
226
  }, z.core.$strip>>;
305
227
  }, z.core.$strip>;
306
- export type EditMemberFormValues = AddMemberFormValues & {
307
- aadhaarLast4: string;
308
- address: Address | null;
309
- idProofUrl: string;
310
- };
228
+ export type EditMemberFormValues = AddMemberFormValues;
311
229
  /**
312
230
  * Validated edit form → `PATCH /members/:id` body.
313
231
  *
314
232
  * 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.
233
+ * emptying a field is an edit like any other. The identity fields go along too;
234
+ * the server drops them once the member has claimed their account.
317
235
  */
318
236
  export declare function toUpdateMemberBody(values: EditMemberFormValues): UpdateMemberBody;
319
237
  //# 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":"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"}
@@ -22,7 +22,7 @@
22
22
  import { z } from 'zod';
23
23
  import { ageInYears, isRealDate, PHONE_REGEX } from './common.js';
24
24
  import { emergencyContactSchema, MAX_EMERGENCY_CONTACTS } from './member-profile.js';
25
- import { addressSchema, bloodGroupSchema, genderSchema } from './shared.js';
25
+ import { bloodGroupSchema, genderSchema } from './shared.js';
26
26
  import { staffRoleSchema } from './staff.js';
27
27
  // ─── Bounds ──────────────────────────────────────────────────────────────────
28
28
  /**
@@ -178,24 +178,18 @@ export const addMemberFormSchema = z
178
178
  });
179
179
  // ─── Staff form ──────────────────────────────────────────────────────────────
180
180
  /**
181
- * Staff keep the address a member is no longer asked for but not an ID proof.
181
+ * Staff are asked exactly what a member is asked, plus the job they do.
182
182
  *
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.
183
+ * The home address and the ID upload both went for the same reason: nothing in
184
+ * the product ever showed either one back. A gym that needs an employee's
185
+ * address for a contract has somewhere else to keep it; what it had here was a
186
+ * field collected once and never looked at again, which is the worst thing to
187
+ * ask anyone for.
188
188
  */
189
189
  export const addStaffFormSchema = z
190
190
  .object({
191
191
  ...addPersonBaseShape,
192
192
  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
193
  })
200
194
  .superRefine((v, ctx) => {
201
195
  refineDob(v, ctx);
@@ -227,7 +221,6 @@ export const EMPTY_ADD_STAFF_FORM = {
227
221
  ...EMPTY_BASE,
228
222
  contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],
229
223
  role: null,
230
- address: null,
231
224
  };
232
225
  // ─── Form → API body ─────────────────────────────────────────────────────────
233
226
  /** One "Name" field on screen, `firstName` + `lastName` on the wire. */
@@ -246,8 +239,12 @@ function asPhone(value) {
246
239
  return value.replace(/\D/g, '').slice(-10);
247
240
  }
248
241
  /**
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.
242
+ * The fields EVERY desk form sends.
243
+ *
244
+ * All three now ask exactly the same things about the PERSON; what differs is
245
+ * what the record is FOR, so each mapper adds its own role / trainer / plan on
246
+ * top. Address and ID proof used to be the exception here, asked by two of the
247
+ * three; they are gone from all of them.
251
248
  */
252
249
  function sharedBody(v) {
253
250
  // A row with nothing in it is not a contact. Anything else has already been
@@ -292,40 +289,22 @@ export function toCreateStaffBody(values) {
292
289
  ...sharedBody(values),
293
290
  // The form schema guarantees a role was chosen.
294
291
  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
292
  };
299
293
  }
300
294
  // ─── Edit member ─────────────────────────────────────────────────────────────
301
295
  /**
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.
296
+ * Editing a member asks for exactly what adding one asks for.
304
297
  *
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.
298
+ * It stays a separate schema and a separate mapper because the two differ in
299
+ * what "blank" means. On the add form a blank optional is simply not provided;
300
+ * on the edit form it is an instruction to CLEAR a value that is already
301
+ * stored, so `toUpdateMemberBody` sends `null` where `toCreateMemberBody`
302
+ * omits the field.
309
303
  */
310
304
  export const editMemberFormSchema = z
311
305
  .object({
312
306
  ...addPersonBaseShape,
313
307
  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
- /**
320
- * Address and ID proof are asked HERE and not on the add form.
321
- *
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.
326
- */
327
- address: addressSchema.nullable(),
328
- idProofUrl: z.string(),
329
308
  })
330
309
  .superRefine((v, ctx) => {
331
310
  refineDob(v, ctx);
@@ -334,8 +313,8 @@ export const editMemberFormSchema = z
334
313
  * Validated edit form → `PATCH /members/:id` body.
335
314
  *
336
315
  * 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.
316
+ * emptying a field is an edit like any other. The identity fields go along too;
317
+ * the server drops them once the member has claimed their account.
339
318
  */
340
319
  export function toUpdateMemberBody(values) {
341
320
  const shared = sharedBody(values);
@@ -343,11 +322,8 @@ export function toUpdateMemberBody(values) {
343
322
  ...shared,
344
323
  dob: shared.dob ?? null,
345
324
  bloodGroup: shared.bloodGroup ?? null,
346
- address: values.address,
347
- idProofUrl: values.idProofUrl.trim(),
348
325
  avatarUrl: values.avatarUrl.trim(),
349
326
  assignedTrainerId: orUndefined(values.trainerId) ?? null,
350
- aadhaarLast4: values.aadhaarLast4.trim() === '' ? null : values.aadhaarLast4.trim(),
351
327
  };
352
328
  }
353
329
  //# 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":["/**\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 { 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, no Aadhaar and no ID proof — on this form or on any other. A\n * front desk does not have them when somebody walks in, the member's own\n * screens never asked for them, and nothing in the product ever read one back.\n * Every surface that collected them was filling a drawer nobody opens.\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 are asked exactly what a member is asked, plus the job they do.\n *\n * The home address and the ID upload both went for the same reason: nothing in\n * the product ever showed either one back. A gym that needs an employee's\n * address for a contract has somewhere else to keep it; what it had here was a\n * field collected once and never looked at again, which is the worst thing to\n * ask anyone for.\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 });\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 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};\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.\n *\n * All three now ask exactly the same things about the PERSON; what differs is\n * what the record is FOR, so each mapper adds its own role / trainer / plan on\n * top. Address and ID proof used to be the exception here, asked by two of the\n * three; they are gone from all of them.\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 };\n}\n\n// ─── Edit member ─────────────────────────────────────────────────────────────\n\n/**\n * Editing a member asks for exactly what adding one asks for.\n *\n * It stays a separate schema and a separate mapper because the two differ in\n * what \"blank\" means. On the add form a blank optional is simply not provided;\n * on the edit form it is an instruction to CLEAR a value that is already\n * stored, so `toUpdateMemberBody` sends `null` where `toCreateMemberBody`\n * omits the field.\n */\nexport const editMemberFormSchema = z\n .object({\n ...addPersonBaseShape,\n trainerId: z.string(),\n })\n .superRefine((v, ctx) => {\n refineDob(v, ctx);\n });\n\nexport type EditMemberFormValues = AddMemberFormValues;\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 identity fields go along too;\n * 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 avatarUrl: values.avatarUrl.trim(),\n assignedTrainerId: orUndefined(values.trainerId) ?? null,\n };\n}\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
  /**
package/dist/file.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"file.js","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — File uploads\n * =============================\n * Contracts for the signed-URL upload flow. GymMonk never streams file bytes\n * through the API: the browser asks the backend for a short-lived v4 signed\n * PUT URL, uploads straight to Google Cloud Storage, and stores the returned\n * PERMANENT public URL.\n *\n * The download URL is deliberately NOT signed. A signed read URL expires,\n * which would rot every image reference already written to Mongo; the bucket\n * instead grants `allUsers` object-viewer and we store the plain public URL.\n *\n * Mirrors reform-backend's `shared/schemas/file.schema.ts`.\n *\n * @module gymmonk-schema/file\n */\n\nimport { z } from 'zod';\n\n// ─── Folders ─────────────────────────────────────────────────────────────────\n\n/**\n * Every folder an upload may target. This is an allow-list, not a hint: the\n * backend rejects anything outside it, so a compromised client cannot scatter\n * objects across the bucket. Adding a new upload surface means adding it here\n * FIRST, then publishing the package.\n *\n * `userId` is appended by the backend, so the effective path is\n * `<folder>/<userId>/<timestamp>-<random><ext>`. That per-user prefix is what\n * makes ownership checkable on cleanup.\n */\nexport const UPLOAD_FOLDERS = [\n /** Member + owner + staff avatars. Square, cropped round in the UI. */\n 'profile-photos',\n /** Gym/center gallery photos. Multi-select, uncropped. */\n 'center-photos',\n /** Organisation and center logos. */\n 'logos',\n /** Member ID proof / address proof captured at the front desk. */\n 'member-documents',\n /** Equipment photos in the gym inventory. */\n 'equipment-photos',\n /** Images attached to an announcement. */\n 'announcement-media',\n /** Custom exercise thumbnails added by a gym. */\n 'exercise-media',\n /** Progress photos a member uploads against a measurement entry. */\n 'progress-photos',\n /** Screenshot of a transfer, attached to a membership awaiting confirmation. */\n 'payment-proofs',\n /** Screenshots and photos attached to feedback or a complaint. */\n 'feedback-media',\n] as const;\n\nexport const uploadFolderSchema = z.enum(UPLOAD_FOLDERS);\nexport type UploadFolder = z.infer<typeof uploadFolderSchema>;\n\n// ─── Content types ───────────────────────────────────────────────────────────\n\n/** MIME types the signed-URL endpoint will sign for. */\nexport const ALLOWED_UPLOAD_MIME_TYPES = [\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'application/pdf',\n] as const;\n\nexport const uploadMimeTypeSchema = z.enum(ALLOWED_UPLOAD_MIME_TYPES);\nexport type UploadMimeType = z.infer<typeof uploadMimeTypeSchema>;\n\n// ─── Size limits ─────────────────────────────────────────────────────────────\n\n/**\n * Default per-file ceiling, in MB. Validation runs on the RAW pick, before the\n * client-side optimizer shrinks it — a modern phone camera produces 8-12 MB\n * JPEGs that compress to well under 1 MB, so a tighter cap would reject\n * perfectly ordinary photos.\n */\nexport const MAX_UPLOAD_SIZE_MB = 10;\n\n/** Per-folder overrides. Documents may be larger scans than a photo. */\nexport const FOLDER_MAX_SIZE_MB: Partial<Record<UploadFolder, number>> = {\n 'member-documents': 20,\n};\n\nconst HARD_CAP_MB = Math.max(MAX_UPLOAD_SIZE_MB, ...Object.values(FOLDER_MAX_SIZE_MB));\n\n// ─── Signed upload URL ───────────────────────────────────────────────────────\n\n/**\n * Request body for `POST /api/v1/files/signed-url`.\n *\n * `fileName` is restricted to a safe charset because it becomes part of the GCS\n * object path — the client sanitises before sending, and this rejects anything\n * that slipped through (path separators, control characters, unicode tricks).\n */\nexport const signedUploadUrlBodySchema = z\n .object({\n fileName: z\n .string()\n .min(1)\n .max(255)\n .regex(/^[a-zA-Z0-9_.-]+$/, 'Invalid file name'),\n contentType: uploadMimeTypeSchema,\n folder: uploadFolderSchema,\n fileSize: z\n .number()\n .int()\n .min(1)\n .max(HARD_CAP_MB * 1024 * 1024, `File must be smaller than ${HARD_CAP_MB}MB`),\n })\n .superRefine((data, ctx) => {\n // Tighten to the per-folder cap. The outer `.max` is only the widest\n // ceiling any folder allows; this is the one that actually applies.\n const capMb = FOLDER_MAX_SIZE_MB[data.folder] ?? MAX_UPLOAD_SIZE_MB;\n if (data.fileSize > capMb * 1024 * 1024) {\n ctx.addIssue({\n code: 'custom',\n path: ['fileSize'],\n message: `File must be smaller than ${capMb}MB for ${data.folder}`,\n });\n }\n });\n\nexport type SignedUploadUrlBody = z.infer<typeof signedUploadUrlBodySchema>;\n\n/** Response of `POST /api/v1/files/signed-url`. */\nexport const signedUploadUrlResponseSchema = z.object({\n /** Short-lived v4 signed URL — PUT the bytes here. */\n uploadUrl: z.string(),\n /** Permanent public URL. This is what gets stored in Mongo. */\n downloadUrl: z.string(),\n /** Object path inside the bucket, without the bucket prefix. Used to\n * delete an object the user abandoned before saving. */\n filePath: z.string(),\n /** When `uploadUrl` stops working. */\n expiresAt: z.iso.datetime(),\n});\n\nexport type SignedUploadUrlResponse = z.infer<typeof signedUploadUrlResponseSchema>;\n\n// ─── Cleanup ─────────────────────────────────────────────────────────────────\n\n/**\n * Body for `POST /api/v1/files/cleanup-uploads`. Called when a user removes a\n * photo they uploaded but never saved, so the orphaned object does not sit in\n * the bucket forever.\n *\n * Ownership is enforced per-path from the `userId` segment the backend embedded\n * at signing time. Paths belonging to someone else are skipped silently rather\n * than 403-ing, so one foreign entry cannot void a legitimate batch.\n */\nexport const cleanupUploadsBodySchema = z.object({\n filePaths: z.array(z.string().min(1)).min(1).max(20),\n});\n\nexport type CleanupUploadsBody = z.infer<typeof cleanupUploadsBodySchema>;\n\nexport const cleanupUploadsResponseSchema = z.object({\n deleted: z.number().int().min(0),\n skipped: z.number().int().min(0),\n});\n\nexport type CleanupUploadsResponse = z.infer<typeof cleanupUploadsResponseSchema>;\n"]}
1
+ {"version":3,"file":"file.js","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — File uploads\n * =============================\n * Contracts for the signed-URL upload flow. GymMonk never streams file bytes\n * through the API: the browser asks the backend for a short-lived v4 signed\n * PUT URL, uploads straight to Google Cloud Storage, and stores the returned\n * PERMANENT public URL.\n *\n * The download URL is deliberately NOT signed. A signed read URL expires,\n * which would rot every image reference already written to Mongo; the bucket\n * instead grants `allUsers` object-viewer and we store the plain public URL.\n *\n * Mirrors reform-backend's `shared/schemas/file.schema.ts`.\n *\n * @module gymmonk-schema/file\n */\n\nimport { z } from 'zod';\n\n// ─── Folders ─────────────────────────────────────────────────────────────────\n\n/**\n * Every folder an upload may target. This is an allow-list, not a hint: the\n * backend rejects anything outside it, so a compromised client cannot scatter\n * objects across the bucket. Adding a new upload surface means adding it here\n * FIRST, then publishing the package.\n *\n * `userId` is appended by the backend, so the effective path is\n * `<folder>/<userId>/<timestamp>-<random><ext>`. That per-user prefix is what\n * makes ownership checkable on cleanup.\n */\nexport const UPLOAD_FOLDERS = [\n /** Member + owner + staff avatars. Square, cropped round in the UI. */\n 'profile-photos',\n /** Gym/center gallery photos. Multi-select, uncropped. */\n 'center-photos',\n /** Organisation and center logos. */\n 'logos',\n /** Equipment photos in the gym inventory. */\n 'equipment-photos',\n /** Images attached to an announcement. */\n 'announcement-media',\n /** Custom exercise thumbnails added by a gym. */\n 'exercise-media',\n /** Progress photos a member uploads against a measurement entry. */\n 'progress-photos',\n /** Screenshot of a transfer, attached to a membership awaiting confirmation. */\n 'payment-proofs',\n /** Screenshots and photos attached to feedback or a complaint. */\n 'feedback-media',\n] as const;\n\nexport const uploadFolderSchema = z.enum(UPLOAD_FOLDERS);\nexport type UploadFolder = z.infer<typeof uploadFolderSchema>;\n\n// ─── Content types ───────────────────────────────────────────────────────────\n\n/** MIME types the signed-URL endpoint will sign for. */\nexport const ALLOWED_UPLOAD_MIME_TYPES = [\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'application/pdf',\n] as const;\n\nexport const uploadMimeTypeSchema = z.enum(ALLOWED_UPLOAD_MIME_TYPES);\nexport type UploadMimeType = z.infer<typeof uploadMimeTypeSchema>;\n\n// ─── Size limits ─────────────────────────────────────────────────────────────\n\n/**\n * Default per-file ceiling, in MB. Validation runs on the RAW pick, before the\n * client-side optimizer shrinks it — a modern phone camera produces 8-12 MB\n * JPEGs that compress to well under 1 MB, so a tighter cap would reject\n * perfectly ordinary photos.\n */\nexport const MAX_UPLOAD_SIZE_MB = 10;\n\n/**\n * Per-folder overrides, for a folder that legitimately needs more room than a\n * photo.\n *\n * Empty today. It held one entry, `member-documents` at 20MB, for the ID scans\n * the front desk used to take; that folder went when the ID-proof upload did,\n * because nothing in the product ever showed a scan back. Kept as the seam\n * rather than deleted, so raising a single folder's ceiling stays a one-line\n * change instead of a refactor.\n */\nexport const FOLDER_MAX_SIZE_MB: Partial<Record<UploadFolder, number>> = {};\n\nconst HARD_CAP_MB = Math.max(MAX_UPLOAD_SIZE_MB, ...Object.values(FOLDER_MAX_SIZE_MB));\n\n// ─── Signed upload URL ───────────────────────────────────────────────────────\n\n/**\n * Request body for `POST /api/v1/files/signed-url`.\n *\n * `fileName` is restricted to a safe charset because it becomes part of the GCS\n * object path — the client sanitises before sending, and this rejects anything\n * that slipped through (path separators, control characters, unicode tricks).\n */\nexport const signedUploadUrlBodySchema = z\n .object({\n fileName: z\n .string()\n .min(1)\n .max(255)\n .regex(/^[a-zA-Z0-9_.-]+$/, 'Invalid file name'),\n contentType: uploadMimeTypeSchema,\n folder: uploadFolderSchema,\n fileSize: z\n .number()\n .int()\n .min(1)\n .max(HARD_CAP_MB * 1024 * 1024, `File must be smaller than ${HARD_CAP_MB}MB`),\n })\n .superRefine((data, ctx) => {\n // Tighten to the per-folder cap. The outer `.max` is only the widest\n // ceiling any folder allows; this is the one that actually applies.\n const capMb = FOLDER_MAX_SIZE_MB[data.folder] ?? MAX_UPLOAD_SIZE_MB;\n if (data.fileSize > capMb * 1024 * 1024) {\n ctx.addIssue({\n code: 'custom',\n path: ['fileSize'],\n message: `File must be smaller than ${capMb}MB for ${data.folder}`,\n });\n }\n });\n\nexport type SignedUploadUrlBody = z.infer<typeof signedUploadUrlBodySchema>;\n\n/** Response of `POST /api/v1/files/signed-url`. */\nexport const signedUploadUrlResponseSchema = z.object({\n /** Short-lived v4 signed URL — PUT the bytes here. */\n uploadUrl: z.string(),\n /** Permanent public URL. This is what gets stored in Mongo. */\n downloadUrl: z.string(),\n /** Object path inside the bucket, without the bucket prefix. Used to\n * delete an object the user abandoned before saving. */\n filePath: z.string(),\n /** When `uploadUrl` stops working. */\n expiresAt: z.iso.datetime(),\n});\n\nexport type SignedUploadUrlResponse = z.infer<typeof signedUploadUrlResponseSchema>;\n\n// ─── Cleanup ─────────────────────────────────────────────────────────────────\n\n/**\n * Body for `POST /api/v1/files/cleanup-uploads`. Called when a user removes a\n * photo they uploaded but never saved, so the orphaned object does not sit in\n * the bucket forever.\n *\n * Ownership is enforced per-path from the `userId` segment the backend embedded\n * at signing time. Paths belonging to someone else are skipped silently rather\n * than 403-ing, so one foreign entry cannot void a legitimate batch.\n */\nexport const cleanupUploadsBodySchema = z.object({\n filePaths: z.array(z.string().min(1)).min(1).max(20),\n});\n\nexport type CleanupUploadsBody = z.infer<typeof cleanupUploadsBodySchema>;\n\nexport const cleanupUploadsResponseSchema = z.object({\n deleted: z.number().int().min(0),\n skipped: z.number().int().min(0),\n});\n\nexport type CleanupUploadsResponse = z.infer<typeof cleanupUploadsResponseSchema>;\n"]}