gymmonk-schema 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/add-person-form.d.ts +212 -0
  2. package/dist/add-person-form.d.ts.map +1 -0
  3. package/dist/add-person-form.js +227 -0
  4. package/dist/add-person-form.js.map +1 -0
  5. package/dist/area.d.ts +123 -0
  6. package/dist/area.d.ts.map +1 -0
  7. package/dist/area.js +95 -0
  8. package/dist/area.js.map +1 -0
  9. package/dist/center.d.ts +118 -136
  10. package/dist/center.d.ts.map +1 -1
  11. package/dist/check-in.d.ts +74 -0
  12. package/dist/check-in.d.ts.map +1 -0
  13. package/dist/check-in.js +116 -0
  14. package/dist/check-in.js.map +1 -0
  15. package/dist/common.d.ts +11 -0
  16. package/dist/common.d.ts.map +1 -1
  17. package/dist/common.js +17 -0
  18. package/dist/common.js.map +1 -1
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/member-profile.d.ts +104 -122
  24. package/dist/member-profile.d.ts.map +1 -1
  25. package/dist/member.d.ts +108 -85
  26. package/dist/member.d.ts.map +1 -1
  27. package/dist/member.js +11 -1
  28. package/dist/member.js.map +1 -1
  29. package/dist/membership-plan.d.ts +16 -16
  30. package/dist/membership.d.ts +2 -2
  31. package/dist/messages.d.ts +24 -1
  32. package/dist/messages.d.ts.map +1 -1
  33. package/dist/messages.js +25 -1
  34. package/dist/messages.js.map +1 -1
  35. package/dist/notification.d.ts +4 -4
  36. package/dist/onboarding-form.d.ts +373 -27
  37. package/dist/onboarding-form.d.ts.map +1 -1
  38. package/dist/onboarding-form.js +19 -36
  39. package/dist/onboarding-form.js.map +1 -1
  40. package/dist/onboarding.d.ts +140 -164
  41. package/dist/onboarding.d.ts.map +1 -1
  42. package/dist/organisation.d.ts +102 -120
  43. package/dist/organisation.d.ts.map +1 -1
  44. package/dist/session.d.ts +21 -2
  45. package/dist/session.d.ts.map +1 -1
  46. package/dist/session.js +21 -2
  47. package/dist/session.js.map +1 -1
  48. package/dist/shared.d.ts +126 -41
  49. package/dist/shared.d.ts.map +1 -1
  50. package/dist/shared.js +108 -5
  51. package/dist/shared.js.map +1 -1
  52. package/dist/staff.d.ts +68 -80
  53. package/dist/staff.d.ts.map +1 -1
  54. package/package.json +1 -1
@@ -0,0 +1,212 @@
1
+ /**
2
+ * gymmonk-schema — "Add member" / "Add staff" FORM schemas
3
+ * ========================================================
4
+ * The client-side validation contract for the owner's desk-entry forms, and the
5
+ * mappers that turn a filled form into the `createMember` / `createStaff` API
6
+ * bodies.
7
+ *
8
+ * Why these live here rather than in the client, and why they are distinct from
9
+ * `createMemberBodySchema`: the form's working shape is not the wire shape. It
10
+ * holds ONE "name" field the API splits in two, `null` for a choice not yet
11
+ * made, and `''` for an optional value left blank. Those are legitimate mid-edit
12
+ * states that the API body must never see. Expressing the same rules over the
13
+ * form shape here keeps
14
+ * every message, bound and regex defined once — the client cannot drift from
15
+ * what the server will accept, and the coercion from one shape to the other is
16
+ * part of the contract instead of ad-hoc code in a component.
17
+ *
18
+ * Mirrors `onboarding-form.ts`, which does the same job for the wizards.
19
+ *
20
+ * @module gymmonk-schema/add-person-form
21
+ */
22
+ import { z } from 'zod';
23
+ import type { CreateMemberBody } from './member.js';
24
+ import type { AssignMembershipBody } from './membership.js';
25
+ import { type Address, bloodGroupSchema, genderSchema } from './shared.js';
26
+ import type { CreateStaffBody } from './staff.js';
27
+ import { staffRoleSchema } from './staff.js';
28
+ /**
29
+ * Age bounds for someone signed up at the desk. Wider than the member wizard's
30
+ * because a gym also enrols juniors with a guardian present, and the date is
31
+ * typed by staff rather than by the person themselves.
32
+ */
33
+ export declare const ADD_PERSON_MIN_AGE_YEARS = 5;
34
+ export declare const ADD_PERSON_MAX_AGE_YEARS = 100;
35
+ /** Matches `createMemberBodySchema.emergencyContacts`. */
36
+ export declare const MAX_EMERGENCY_CONTACTS = 5;
37
+ /**
38
+ * The design collects a RELATIONSHIP and a phone, not a name — so a row is
39
+ * either wholly blank (ignored) or has both. `name` is synthesised from the
40
+ * relationship in the mapper, since `emergencyContactSchema` requires one.
41
+ */
42
+ export declare const emergencyContactRowSchema: z.ZodObject<{
43
+ relationship: z.ZodString;
44
+ phone: z.ZodString;
45
+ }, z.core.$strip>;
46
+ export interface EmergencyContactRow {
47
+ relationship: string;
48
+ phone: string;
49
+ }
50
+ export declare const addMemberFormSchema: z.ZodObject<{
51
+ trainerId: z.ZodString;
52
+ name: z.ZodString;
53
+ email: z.ZodEmail;
54
+ phone: z.ZodString;
55
+ dob: z.ZodString;
56
+ gender: z.ZodNullable<z.ZodEnum<{
57
+ male: "male";
58
+ female: "female";
59
+ other: "other";
60
+ }>> & z.ZodType<{} | undefined, "male" | "female" | "other" | null, z.core.$ZodTypeInternals<{} | undefined, "male" | "female" | "other" | null>>;
61
+ address: z.ZodNullable<z.ZodObject<{
62
+ country: z.ZodObject<{
63
+ id: z.ZodString;
64
+ name: z.ZodString;
65
+ }, z.core.$strip>;
66
+ state: z.ZodObject<{
67
+ id: z.ZodString;
68
+ name: z.ZodString;
69
+ }, z.core.$strip>;
70
+ district: z.ZodOptional<z.ZodObject<{
71
+ id: z.ZodString;
72
+ name: z.ZodString;
73
+ }, z.core.$strip>>;
74
+ locality: z.ZodOptional<z.ZodObject<{
75
+ id: z.ZodString;
76
+ name: z.ZodString;
77
+ type: z.ZodEnum<{
78
+ SUB_DISTRICT: "SUB_DISTRICT";
79
+ BLOCK: "BLOCK";
80
+ GRAM_PANCHAYAT: "GRAM_PANCHAYAT";
81
+ VILLAGE: "VILLAGE";
82
+ HAMLET: "HAMLET";
83
+ URBAN_BODY: "URBAN_BODY";
84
+ URBAN_WARD: "URBAN_WARD";
85
+ LOCALITY: "LOCALITY";
86
+ }>;
87
+ }, z.core.$strip>>;
88
+ line1: z.ZodString;
89
+ landmark: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
90
+ pincode: z.ZodString;
91
+ ancestry: z.ZodDefault<z.ZodArray<z.ZodString>>;
92
+ coords: z.ZodOptional<z.ZodObject<{
93
+ lat: z.ZodNumber;
94
+ lng: z.ZodNumber;
95
+ source: z.ZodString;
96
+ }, z.core.$strip>>;
97
+ }, z.core.$strip>>;
98
+ bloodGroup: z.ZodUnion<[z.ZodEnum<{
99
+ "A+": "A+";
100
+ "A-": "A-";
101
+ "B+": "B+";
102
+ "B-": "B-";
103
+ "O+": "O+";
104
+ "O-": "O-";
105
+ "AB+": "AB+";
106
+ "AB-": "AB-";
107
+ }>, z.ZodLiteral<"">]>;
108
+ idProofUrl: z.ZodString;
109
+ avatarUrl: z.ZodString;
110
+ contacts: z.ZodArray<z.ZodObject<{
111
+ relationship: z.ZodString;
112
+ phone: z.ZodString;
113
+ }, z.core.$strip>>;
114
+ }, z.core.$strip>;
115
+ export type AddMemberFormValues = {
116
+ name: string;
117
+ email: string;
118
+ phone: string;
119
+ dob: string;
120
+ gender: z.infer<typeof genderSchema> | null;
121
+ address: Address | null;
122
+ bloodGroup: z.infer<typeof bloodGroupSchema> | '';
123
+ idProofUrl: string;
124
+ avatarUrl: string;
125
+ contacts: EmergencyContactRow[];
126
+ trainerId: string;
127
+ };
128
+ export declare const addStaffFormSchema: z.ZodObject<{
129
+ role: z.ZodNullable<z.ZodEnum<{
130
+ trainer: "trainer";
131
+ manager: "manager";
132
+ cleaning: "cleaning";
133
+ reception: "reception";
134
+ }>> & z.ZodType<{} | undefined, "trainer" | "manager" | "cleaning" | "reception" | null, z.core.$ZodTypeInternals<{} | undefined, "trainer" | "manager" | "cleaning" | "reception" | null>>;
135
+ name: z.ZodString;
136
+ email: z.ZodEmail;
137
+ phone: z.ZodString;
138
+ dob: z.ZodString;
139
+ gender: z.ZodNullable<z.ZodEnum<{
140
+ male: "male";
141
+ female: "female";
142
+ other: "other";
143
+ }>> & z.ZodType<{} | undefined, "male" | "female" | "other" | null, z.core.$ZodTypeInternals<{} | undefined, "male" | "female" | "other" | null>>;
144
+ address: z.ZodNullable<z.ZodObject<{
145
+ country: z.ZodObject<{
146
+ id: z.ZodString;
147
+ name: z.ZodString;
148
+ }, z.core.$strip>;
149
+ state: z.ZodObject<{
150
+ id: z.ZodString;
151
+ name: z.ZodString;
152
+ }, z.core.$strip>;
153
+ district: z.ZodOptional<z.ZodObject<{
154
+ id: z.ZodString;
155
+ name: z.ZodString;
156
+ }, z.core.$strip>>;
157
+ locality: z.ZodOptional<z.ZodObject<{
158
+ id: z.ZodString;
159
+ name: z.ZodString;
160
+ type: z.ZodEnum<{
161
+ SUB_DISTRICT: "SUB_DISTRICT";
162
+ BLOCK: "BLOCK";
163
+ GRAM_PANCHAYAT: "GRAM_PANCHAYAT";
164
+ VILLAGE: "VILLAGE";
165
+ HAMLET: "HAMLET";
166
+ URBAN_BODY: "URBAN_BODY";
167
+ URBAN_WARD: "URBAN_WARD";
168
+ LOCALITY: "LOCALITY";
169
+ }>;
170
+ }, z.core.$strip>>;
171
+ line1: z.ZodString;
172
+ landmark: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
173
+ pincode: z.ZodString;
174
+ ancestry: z.ZodDefault<z.ZodArray<z.ZodString>>;
175
+ coords: z.ZodOptional<z.ZodObject<{
176
+ lat: z.ZodNumber;
177
+ lng: z.ZodNumber;
178
+ source: z.ZodString;
179
+ }, z.core.$strip>>;
180
+ }, z.core.$strip>>;
181
+ bloodGroup: z.ZodUnion<[z.ZodEnum<{
182
+ "A+": "A+";
183
+ "A-": "A-";
184
+ "B+": "B+";
185
+ "B-": "B-";
186
+ "O+": "O+";
187
+ "O-": "O-";
188
+ "AB+": "AB+";
189
+ "AB-": "AB-";
190
+ }>, z.ZodLiteral<"">]>;
191
+ idProofUrl: z.ZodString;
192
+ avatarUrl: z.ZodString;
193
+ contacts: z.ZodArray<z.ZodObject<{
194
+ relationship: z.ZodString;
195
+ phone: z.ZodString;
196
+ }, z.core.$strip>>;
197
+ }, z.core.$strip>;
198
+ export type AddStaffFormValues = Omit<AddMemberFormValues, 'trainerId'> & {
199
+ role: z.infer<typeof staffRoleSchema> | null;
200
+ };
201
+ /** One blank row so the section renders with a contact to fill. */
202
+ export declare const EMPTY_EMERGENCY_CONTACT: EmergencyContactRow;
203
+ export declare const EMPTY_ADD_MEMBER_FORM: AddMemberFormValues;
204
+ export declare const EMPTY_ADD_STAFF_FORM: AddStaffFormValues;
205
+ /**
206
+ * Validated member form → `POST /members` body. `initialPlan` comes from the
207
+ * membership step, which the form itself knows nothing about.
208
+ */
209
+ export declare function toCreateMemberBody(values: AddMemberFormValues, initialPlan?: AssignMembershipBody): CreateMemberBody;
210
+ /** Validated staff form → `POST /staff` body. */
211
+ export declare function toCreateStaffBody(values: AddStaffFormValues): CreateStaffBody;
212
+ //# sourceMappingURL=add-person-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-person-form.d.ts","sourceRoot":"","sources":["../src/add-person-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAAE,KAAK,OAAO,EAAiB,gBAAgB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC1F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAI7C;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAC1C,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C,0DAA0D;AAC1D,eAAO,MAAM,sBAAsB,IAAI,CAAC;AASxC;;;;GAIG;AACH,eAAO,MAAM,yBAAyB;;;iBAkBlC,CAAC;AAEL,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;AA+DD,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAY5B,CAAC;AAEL,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,GAAG,IAAI,CAAC;IAC5C,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GAAG,EAAE,CAAC;IAClD,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAIF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,mBAAqD,CAAC;AAE5F,eAAO,MAAM,qBAAqB,EAAE,mBAInC,CAAC;AAEF,eAAO,MAAM,oBAAoB,EAAE,kBAIlC,CAAC;AAsDF;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,mBAAmB,EAC3B,WAAW,CAAC,EAAE,oBAAoB,GACjC,gBAAgB,CAMlB;AAED,iDAAiD;AACjD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,kBAAkB,GAAG,eAAe,CAM7E"}
@@ -0,0 +1,227 @@
1
+ /**
2
+ * gymmonk-schema — "Add member" / "Add staff" FORM schemas
3
+ * ========================================================
4
+ * The client-side validation contract for the owner's desk-entry forms, and the
5
+ * mappers that turn a filled form into the `createMember` / `createStaff` API
6
+ * bodies.
7
+ *
8
+ * Why these live here rather than in the client, and why they are distinct from
9
+ * `createMemberBodySchema`: the form's working shape is not the wire shape. It
10
+ * holds ONE "name" field the API splits in two, `null` for a choice not yet
11
+ * made, and `''` for an optional value left blank. Those are legitimate mid-edit
12
+ * states that the API body must never see. Expressing the same rules over the
13
+ * form shape here keeps
14
+ * every message, bound and regex defined once — the client cannot drift from
15
+ * what the server will accept, and the coercion from one shape to the other is
16
+ * part of the contract instead of ad-hoc code in a component.
17
+ *
18
+ * Mirrors `onboarding-form.ts`, which does the same job for the wizards.
19
+ *
20
+ * @module gymmonk-schema/add-person-form
21
+ */
22
+ import { z } from 'zod';
23
+ import { ageInYears, isRealDate, PHONE_REGEX } from './common.js';
24
+ import { emergencyContactSchema } from './member-profile.js';
25
+ import { addressSchema, bloodGroupSchema, genderSchema } from './shared.js';
26
+ import { staffRoleSchema } from './staff.js';
27
+ // ─── Bounds ──────────────────────────────────────────────────────────────────
28
+ /**
29
+ * Age bounds for someone signed up at the desk. Wider than the member wizard's
30
+ * because a gym also enrols juniors with a guardian present, and the date is
31
+ * typed by staff rather than by the person themselves.
32
+ */
33
+ export const ADD_PERSON_MIN_AGE_YEARS = 5;
34
+ export const ADD_PERSON_MAX_AGE_YEARS = 100;
35
+ /** Matches `createMemberBodySchema.emergencyContacts`. */
36
+ export const MAX_EMERGENCY_CONTACTS = 5;
37
+ // ─── Field-level rules ───────────────────────────────────────────────────────
38
+ const requiredChoice = (inner, message) => inner.nullable().refine((v) => v !== null, { message });
39
+ // ─── One emergency-contact row ───────────────────────────────────────────────
40
+ /**
41
+ * The design collects a RELATIONSHIP and a phone, not a name — so a row is
42
+ * either wholly blank (ignored) or has both. `name` is synthesised from the
43
+ * relationship in the mapper, since `emergencyContactSchema` requires one.
44
+ */
45
+ export const emergencyContactRowSchema = z
46
+ .object({
47
+ relationship: z.string().trim().max(60),
48
+ phone: z.string().trim(),
49
+ })
50
+ .superRefine((v, ctx) => {
51
+ const blank = v.relationship === '' && v.phone === '';
52
+ if (blank)
53
+ return;
54
+ if (v.relationship === '') {
55
+ ctx.addIssue({ code: 'custom', path: ['relationship'], message: 'Add the relationship' });
56
+ }
57
+ if (!PHONE_REGEX.test(v.phone)) {
58
+ ctx.addIssue({
59
+ code: 'custom',
60
+ path: ['phone'],
61
+ message: 'Enter a valid 10-digit mobile number',
62
+ });
63
+ }
64
+ });
65
+ // ─── Shared base ─────────────────────────────────────────────────────────────
66
+ const addPersonBaseShape = {
67
+ /** One field on screen; split into `firstName` / `lastName` on the wire. */
68
+ name: z.string().trim().min(1, 'Name is required').max(160),
69
+ email: z.email('Enter a valid email'),
70
+ phone: z.string().regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number'),
71
+ /** `YYYY-MM-DD` from a native date input; blank when not recorded. */
72
+ dob: z.string(),
73
+ gender: requiredChoice(genderSchema, 'Select a gender'),
74
+ /**
75
+ * The whole address, or null. Picked through the location drawer, which
76
+ * hands back a complete `Address` — area chain, ids, names, ancestry,
77
+ * coordinates — or nothing. It is therefore atomic: the "half-filled
78
+ * address" this schema used to guard against can no longer be expressed.
79
+ */
80
+ address: addressSchema.nullable(),
81
+ /** Blank when not recorded. */
82
+ bloodGroup: bloodGroupSchema.or(z.literal('')),
83
+ /** GCS URLs — both upload on pick, so these are never files. */
84
+ idProofUrl: z.string(),
85
+ avatarUrl: z.string(),
86
+ contacts: z.array(emergencyContactRowSchema).max(MAX_EMERGENCY_CONTACTS),
87
+ };
88
+ /**
89
+ * Date of birth is optional, but a typed one has to be real and plausible —
90
+ * a mis-keyed year is the single most common desk-entry slip.
91
+ */
92
+ function refineDob(v, ctx) {
93
+ if (v.dob === '')
94
+ return;
95
+ const parts = v.dob.split('-');
96
+ const year = Number(parts[0]);
97
+ const month = Number(parts[1]);
98
+ const day = Number(parts[2]);
99
+ if (parts.length !== 3 || !isRealDate(year, month, day)) {
100
+ ctx.addIssue({ code: 'custom', path: ['dob'], message: 'That date does not exist' });
101
+ return;
102
+ }
103
+ const age = ageInYears(year, month, day);
104
+ if (age < 0) {
105
+ ctx.addIssue({
106
+ code: 'custom',
107
+ path: ['dob'],
108
+ message: 'Date of birth cannot be in the future',
109
+ });
110
+ }
111
+ else if (age < ADD_PERSON_MIN_AGE_YEARS) {
112
+ ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Check the year of birth' });
113
+ }
114
+ else if (age > ADD_PERSON_MAX_AGE_YEARS) {
115
+ ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Check the year of birth' });
116
+ }
117
+ }
118
+ // ─── Member form ─────────────────────────────────────────────────────────────
119
+ export const addMemberFormSchema = z
120
+ .object({
121
+ ...addPersonBaseShape,
122
+ /**
123
+ * Central-auth user id of the trainer, or `''` for none. Only trainers who
124
+ * have CLAIMED their invite can be assigned — an unclaimed staff record has
125
+ * no user to point at (`MemberProfile.assignedTrainerId` refs `User`).
126
+ */
127
+ trainerId: z.string(),
128
+ })
129
+ .superRefine((v, ctx) => {
130
+ refineDob(v, ctx);
131
+ });
132
+ // ─── Staff form ──────────────────────────────────────────────────────────────
133
+ export const addStaffFormSchema = z
134
+ .object({
135
+ ...addPersonBaseShape,
136
+ role: requiredChoice(staffRoleSchema, 'Select a staff role'),
137
+ })
138
+ .superRefine((v, ctx) => {
139
+ refineDob(v, ctx);
140
+ });
141
+ // ─── Empty states ────────────────────────────────────────────────────────────
142
+ const EMPTY_BASE = {
143
+ name: '',
144
+ email: '',
145
+ phone: '',
146
+ dob: '',
147
+ gender: null,
148
+ address: null,
149
+ bloodGroup: '',
150
+ idProofUrl: '',
151
+ avatarUrl: '',
152
+ };
153
+ /** One blank row so the section renders with a contact to fill. */
154
+ export const EMPTY_EMERGENCY_CONTACT = { relationship: '', phone: '' };
155
+ export const EMPTY_ADD_MEMBER_FORM = {
156
+ ...EMPTY_BASE,
157
+ contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],
158
+ trainerId: '',
159
+ };
160
+ export const EMPTY_ADD_STAFF_FORM = {
161
+ ...EMPTY_BASE,
162
+ contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],
163
+ role: null,
164
+ };
165
+ // ─── Form → API body ─────────────────────────────────────────────────────────
166
+ /** One "Name" field on screen, `firstName` + `lastName` on the wire. */
167
+ function splitName(value) {
168
+ const parts = value.trim().split(/\s+/).filter(Boolean);
169
+ const lastName = parts.slice(1).join(' ');
170
+ return { firstName: parts[0] ?? '', ...(lastName ? { lastName } : {}) };
171
+ }
172
+ /** Drop a blank optional so it reads as "not provided" rather than `''`. */
173
+ function orUndefined(value) {
174
+ const trimmed = value.trim();
175
+ return trimmed === '' ? undefined : trimmed;
176
+ }
177
+ /** Phones are STORED raw — strip any display formatting back to 10 digits. */
178
+ function asPhone(value) {
179
+ return value.replace(/\D/g, '').slice(-10);
180
+ }
181
+ function sharedBody(v) {
182
+ // Already a complete `Address` or null — the drawer cannot produce anything
183
+ // in between, so there is nothing to assemble or re-check here.
184
+ const address = v.address ?? undefined;
185
+ const emergencyContacts = v.contacts
186
+ .filter((c) => c.relationship !== '' || c.phone !== '')
187
+ .map((c) => emergencyContactSchema.parse({
188
+ // The form captures a relationship, not a name; it doubles as the label.
189
+ name: c.relationship.trim(),
190
+ phone: asPhone(c.phone),
191
+ relationship: c.relationship.trim(),
192
+ }))
193
+ .slice(0, MAX_EMERGENCY_CONTACTS);
194
+ return {
195
+ ...splitName(v.name),
196
+ email: v.email.trim(),
197
+ phone: asPhone(v.phone),
198
+ // The form schema guarantees a choice was made before we get here.
199
+ gender: v.gender ?? 'other',
200
+ dob: orUndefined(v.dob),
201
+ bloodGroup: v.bloodGroup === '' ? undefined : v.bloodGroup,
202
+ address,
203
+ emergencyContacts,
204
+ idProofUrl: orUndefined(v.idProofUrl),
205
+ avatarUrl: orUndefined(v.avatarUrl),
206
+ };
207
+ }
208
+ /**
209
+ * Validated member form → `POST /members` body. `initialPlan` comes from the
210
+ * membership step, which the form itself knows nothing about.
211
+ */
212
+ export function toCreateMemberBody(values, initialPlan) {
213
+ return {
214
+ ...sharedBody(values),
215
+ assignedTrainerId: orUndefined(values.trainerId),
216
+ ...(initialPlan ? { initialPlan } : {}),
217
+ };
218
+ }
219
+ /** Validated staff form → `POST /staff` body. */
220
+ export function toCreateStaffBody(values) {
221
+ return {
222
+ ...sharedBody(values),
223
+ // The form schema guarantees a role was chosen.
224
+ role: values.role ?? 'trainer',
225
+ };
226
+ }
227
+ //# sourceMappingURL=add-person-form.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-person-form.js","sourceRoot":"","sources":["../src/add-person-form.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — \"Add member\" / \"Add staff\" FORM schemas\n * ========================================================\n * The client-side validation contract for the owner's desk-entry forms, and the\n * mappers that turn a filled form into the `createMember` / `createStaff` API\n * bodies.\n *\n * Why these live here rather than in the client, and why they are distinct from\n * `createMemberBodySchema`: the form's working shape is not the wire shape. It\n * holds ONE \"name\" field the API splits in two, `null` for a choice not yet\n * made, 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 } from './member.js';\nimport { emergencyContactSchema } 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/** Matches `createMemberBodySchema.emergencyContacts`. */\nexport const MAX_EMERGENCY_CONTACTS = 5;\n\n// ─── Field-level rules ───────────────────────────────────────────────────────\n\nconst requiredChoice = <T extends z.ZodTypeAny>(inner: T, message: string) =>\n inner.nullable().refine((v: unknown) => v !== null, { message });\n\n// ─── One emergency-contact row ───────────────────────────────────────────────\n\n/**\n * The design collects a RELATIONSHIP and a phone, not a name — so a row is\n * either wholly blank (ignored) or has both. `name` is synthesised from the\n * relationship in the mapper, since `emergencyContactSchema` requires one.\n */\nexport const emergencyContactRowSchema = z\n .object({\n relationship: z.string().trim().max(60),\n phone: z.string().trim(),\n })\n .superRefine((v, ctx) => {\n const blank = v.relationship === '' && v.phone === '';\n if (blank) return;\n if (v.relationship === '') {\n ctx.addIssue({ code: 'custom', path: ['relationship'], message: 'Add the relationship' });\n }\n if (!PHONE_REGEX.test(v.phone)) {\n ctx.addIssue({\n code: 'custom',\n path: ['phone'],\n message: 'Enter a valid 10-digit mobile number',\n });\n }\n });\n\nexport interface EmergencyContactRow {\n relationship: string;\n phone: string;\n}\n\n// ─── Shared base ─────────────────────────────────────────────────────────────\n\nconst addPersonBaseShape = {\n /** One field on screen; split into `firstName` / `lastName` on the wire. */\n name: z.string().trim().min(1, 'Name is required').max(160),\n email: z.email('Enter a valid email'),\n phone: z.string().regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number'),\n /** `YYYY-MM-DD` from a native date input; blank when not recorded. */\n dob: z.string(),\n gender: requiredChoice(genderSchema, 'Select a gender'),\n\n /**\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. It is therefore atomic: the \"half-filled\n * address\" this schema used to guard against can no longer be expressed.\n */\n address: addressSchema.nullable(),\n\n /** Blank when not recorded. */\n bloodGroup: bloodGroupSchema.or(z.literal('')),\n /** GCS URLs — both upload on pick, so these are never files. */\n idProofUrl: z.string(),\n avatarUrl: z.string(),\n\n contacts: z.array(emergencyContactRowSchema).max(MAX_EMERGENCY_CONTACTS),\n};\n\n/**\n * Date of birth is optional, but a typed one has to be real and plausible —\n * a mis-keyed year is the single most common desk-entry slip.\n */\nfunction refineDob(v: { dob: string }, ctx: z.RefinementCtx): void {\n if (v.dob === '') return;\n\n const parts = v.dob.split('-');\n const year = Number(parts[0]);\n const month = Number(parts[1]);\n const day = Number(parts[2]);\n\n if (parts.length !== 3 || !isRealDate(year, month, day)) {\n ctx.addIssue({ code: 'custom', path: ['dob'], message: 'That date does not exist' });\n return;\n }\n\n const age = ageInYears(year, month, day);\n if (age < 0) {\n ctx.addIssue({\n code: 'custom',\n path: ['dob'],\n message: 'Date of birth cannot be in the future',\n });\n } else if (age < ADD_PERSON_MIN_AGE_YEARS) {\n ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Check the year of birth' });\n } else if (age > ADD_PERSON_MAX_AGE_YEARS) {\n ctx.addIssue({ code: 'custom', path: ['dob'], message: 'Check the year of birth' });\n }\n}\n\n// ─── 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\nexport type AddMemberFormValues = {\n name: string;\n email: string;\n phone: string;\n dob: string;\n gender: z.infer<typeof genderSchema> | null;\n address: Address | null;\n bloodGroup: z.infer<typeof bloodGroupSchema> | '';\n idProofUrl: string;\n avatarUrl: string;\n contacts: EmergencyContactRow[];\n trainerId: string;\n};\n\n// ─── Staff form ──────────────────────────────────────────────────────────────\n\nexport const addStaffFormSchema = z\n .object({\n ...addPersonBaseShape,\n role: requiredChoice(staffRoleSchema, 'Select a staff role'),\n })\n .superRefine((v, ctx) => {\n refineDob(v, ctx);\n });\n\nexport type AddStaffFormValues = Omit<AddMemberFormValues, 'trainerId'> & {\n role: z.infer<typeof staffRoleSchema> | null;\n};\n\n// ─── Empty states ────────────────────────────────────────────────────────────\n\nconst EMPTY_BASE = {\n name: '',\n email: '',\n phone: '',\n dob: '',\n gender: null,\n address: null,\n bloodGroup: '' as const,\n idProofUrl: '',\n avatarUrl: '',\n} satisfies Omit<AddMemberFormValues, 'contacts' | 'trainerId'>;\n\n/** One blank row so the section renders with a contact to fill. */\nexport const EMPTY_EMERGENCY_CONTACT: EmergencyContactRow = { relationship: '', phone: '' };\n\nexport const EMPTY_ADD_MEMBER_FORM: AddMemberFormValues = {\n ...EMPTY_BASE,\n contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],\n trainerId: '',\n};\n\nexport const EMPTY_ADD_STAFF_FORM: AddStaffFormValues = {\n ...EMPTY_BASE,\n contacts: [{ ...EMPTY_EMERGENCY_CONTACT }],\n role: null,\n};\n\n// ─── Form → API body ─────────────────────────────────────────────────────────\n\n/** One \"Name\" field on screen, `firstName` + `lastName` on the wire. */\nfunction splitName(value: string): { firstName: string; lastName?: string } {\n const parts = value.trim().split(/\\s+/).filter(Boolean);\n const lastName = parts.slice(1).join(' ');\n return { firstName: parts[0] ?? '', ...(lastName ? { lastName } : {}) };\n}\n\n/** Drop a blank optional so it reads as \"not provided\" rather than `''`. */\nfunction orUndefined(value: string): string | undefined {\n const trimmed = value.trim();\n return trimmed === '' ? undefined : trimmed;\n}\n\n/** Phones are STORED raw — strip any display formatting back to 10 digits. */\nfunction asPhone(value: string): string {\n return value.replace(/\\D/g, '').slice(-10);\n}\n\nfunction sharedBody(v: Omit<AddMemberFormValues, 'trainerId'>) {\n // Already a complete `Address` or null — the drawer cannot produce anything\n // in between, so there is nothing to assemble or re-check here.\n const address = v.address ?? undefined;\n\n const emergencyContacts = v.contacts\n .filter((c) => c.relationship !== '' || c.phone !== '')\n .map((c) =>\n emergencyContactSchema.parse({\n // The form captures a relationship, not a name; it doubles as the label.\n name: c.relationship.trim(),\n phone: asPhone(c.phone),\n relationship: c.relationship.trim(),\n }),\n )\n .slice(0, MAX_EMERGENCY_CONTACTS);\n\n return {\n ...splitName(v.name),\n email: v.email.trim(),\n phone: asPhone(v.phone),\n // The form schema guarantees a choice was made before we get here.\n gender: v.gender ?? 'other',\n dob: orUndefined(v.dob),\n bloodGroup: v.bloodGroup === '' ? undefined : v.bloodGroup,\n address,\n emergencyContacts,\n idProofUrl: orUndefined(v.idProofUrl),\n avatarUrl: orUndefined(v.avatarUrl),\n };\n}\n\n/**\n * Validated member form → `POST /members` body. `initialPlan` comes from the\n * membership step, which the form itself knows nothing about.\n */\nexport function toCreateMemberBody(\n values: AddMemberFormValues,\n initialPlan?: AssignMembershipBody,\n): CreateMemberBody {\n return {\n ...sharedBody(values),\n assignedTrainerId: orUndefined(values.trainerId),\n ...(initialPlan ? { initialPlan } : {}),\n };\n}\n\n/** Validated staff form → `POST /staff` body. */\nexport function toCreateStaffBody(values: AddStaffFormValues): CreateStaffBody {\n return {\n ...sharedBody(values),\n // The form schema guarantees a role was chosen.\n role: values.role ?? 'trainer',\n };\n}\n"]}
package/dist/area.d.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Area lookup API — the contract behind the location picker
3
+ * =========================================================
4
+ * `/api/v1/areas/*` reads reform-owned master data (countries, states,
5
+ * districts, and the eight sub-district types) so a GymMonk address can be
6
+ * PICKED rather than typed.
7
+ *
8
+ * These shapes live here, not in either app, for the reason the whole package
9
+ * exists: the backend and the web client were each declaring their own
10
+ * `AreaOption` and `LocalityOption`, which is two definitions of one wire
11
+ * format and therefore a drift waiting to happen — the backend adding a field
12
+ * or renaming one could not fail the client's build. Declared once, both sides
13
+ * import it and a change breaks loudly at compile time.
14
+ *
15
+ * Everything here is READ-ONLY. Master data is owned by reform-backend;
16
+ * GymMonk never writes to it.
17
+ *
18
+ * @module area
19
+ */
20
+ import { z } from 'zod';
21
+ /** One pickable area: the master_db id, and the name to show for it. */
22
+ export declare const areaOptionSchema: z.ZodObject<{
23
+ id: z.ZodString;
24
+ name: z.ZodString;
25
+ }, z.core.$strip>;
26
+ export type AreaOption = z.infer<typeof areaOptionSchema>;
27
+ /**
28
+ * A locality result also carries its area TYPE.
29
+ *
30
+ * That is not decoration. One district holds rows of eight different types, and
31
+ * names repeat across them — reform's master data has "Nakraunda" as both a
32
+ * Village and a Hamlet under Dehradun. Merged into one alphabetical list, the
33
+ * type is the only thing that tells the two apart.
34
+ */
35
+ export declare const localityOptionSchema: z.ZodObject<{
36
+ id: z.ZodString;
37
+ name: z.ZodString;
38
+ type: z.ZodEnum<{
39
+ SUB_DISTRICT: "SUB_DISTRICT";
40
+ BLOCK: "BLOCK";
41
+ GRAM_PANCHAYAT: "GRAM_PANCHAYAT";
42
+ VILLAGE: "VILLAGE";
43
+ HAMLET: "HAMLET";
44
+ URBAN_BODY: "URBAN_BODY";
45
+ URBAN_WARD: "URBAN_WARD";
46
+ LOCALITY: "LOCALITY";
47
+ }>;
48
+ }, z.core.$strip>;
49
+ export type LocalityOption = z.infer<typeof localityOptionSchema>;
50
+ /**
51
+ * One page of localities.
52
+ *
53
+ * `hasMore` rather than a total count: a large district holds thousands of
54
+ * rows, and an infinite-scrolling list only ever needs to know whether to keep
55
+ * going. The server derives it by fetching one row beyond the page, which
56
+ * avoids a second `countDocuments` over the same filter.
57
+ */
58
+ export declare const localityPageSchema: z.ZodObject<{
59
+ items: z.ZodArray<z.ZodObject<{
60
+ id: z.ZodString;
61
+ name: z.ZodString;
62
+ type: z.ZodEnum<{
63
+ SUB_DISTRICT: "SUB_DISTRICT";
64
+ BLOCK: "BLOCK";
65
+ GRAM_PANCHAYAT: "GRAM_PANCHAYAT";
66
+ VILLAGE: "VILLAGE";
67
+ HAMLET: "HAMLET";
68
+ URBAN_BODY: "URBAN_BODY";
69
+ URBAN_WARD: "URBAN_WARD";
70
+ LOCALITY: "LOCALITY";
71
+ }>;
72
+ }, z.core.$strip>>;
73
+ hasMore: z.ZodBoolean;
74
+ }, z.core.$strip>;
75
+ export type LocalityPage = z.infer<typeof localityPageSchema>;
76
+ /**
77
+ * Ancestor ids and coordinates for one area, resolved once when an address is
78
+ * saved and then denormalized onto it — so reading an address never touches
79
+ * master data again.
80
+ *
81
+ * `coords` is nullable, not optional: "we looked and this chain has no
82
+ * coordinates" is a real answer, and collapsing it to a missing field would
83
+ * make it indistinguishable from a response that failed to include it.
84
+ */
85
+ export declare const areaLineageSchema: z.ZodObject<{
86
+ ancestry: z.ZodArray<z.ZodString>;
87
+ coords: z.ZodNullable<z.ZodObject<{
88
+ lat: z.ZodNumber;
89
+ lng: z.ZodNumber;
90
+ source: z.ZodString;
91
+ }, z.core.$strip>>;
92
+ }, z.core.$strip>;
93
+ export type AreaLineage = z.infer<typeof areaLineageSchema>;
94
+ /** GET /areas/districts */
95
+ export declare const districtsQuerySchema: z.ZodObject<{
96
+ stateId: z.ZodString;
97
+ q: z.ZodOptional<z.ZodString>;
98
+ }, z.core.$strip>;
99
+ export type DistrictsQuery = z.infer<typeof districtsQuerySchema>;
100
+ /** Rows per locality page. Mirrored by the client's infinite-scroll hook. */
101
+ export declare const LOCALITY_PAGE_SIZE = 50;
102
+ /** Ceiling on a caller-supplied `limit`, so one request cannot pull a district. */
103
+ export declare const LOCALITY_MAX_LIMIT = 100;
104
+ /**
105
+ * GET /areas/localities
106
+ *
107
+ * `page`/`limit` are coerced because they arrive as query strings, and clamped
108
+ * here rather than in the handler so the frontend sees the same bounds it will
109
+ * be held to.
110
+ */
111
+ export declare const localitiesQuerySchema: z.ZodObject<{
112
+ districtId: z.ZodString;
113
+ q: z.ZodOptional<z.ZodString>;
114
+ page: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
115
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
116
+ }, z.core.$strip>;
117
+ export type LocalitiesQuery = z.infer<typeof localitiesQuerySchema>;
118
+ /** GET /areas/lineage/:areaId */
119
+ export declare const areaLineageParamsSchema: z.ZodObject<{
120
+ areaId: z.ZodString;
121
+ }, z.core.$strip>;
122
+ export type AreaLineageParams = z.infer<typeof areaLineageParamsSchema>;
123
+ //# sourceMappingURL=area.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"area.d.ts","sourceRoot":"","sources":["../src/area.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB,wEAAwE;AACxE,eAAO,MAAM,gBAAgB;;;iBAG3B,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;iBAE/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;iBAG7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB;;;;;;;iBAI5B,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,2BAA2B;AAC3B,eAAO,MAAM,oBAAoB;;;iBAG/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,KAAK,CAAC;AACrC,mFAAmF;AACnF,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAEtC;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB;;;;;iBAKhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,iCAAiC;AACjC,eAAO,MAAM,uBAAuB;;iBAElC,CAAC;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC"}