gymmonk-schema 0.25.0 → 0.27.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.
@@ -18,9 +18,23 @@ export declare const announcementIconSchema: z.ZodEnum<{
18
18
  warning: "warning";
19
19
  }>;
20
20
  export type AnnouncementIcon = z.infer<typeof announcementIconSchema>;
21
+ /**
22
+ * An announcement is a PRESENT EVENT.
23
+ *
24
+ * `scheduled` is gone. A broadcast now goes out the moment it is created, which
25
+ * removes the whole apparatus that existed to make "later" work: the due sweep,
26
+ * the claim protocol, the past-date guard, and the Cloud Scheduler job that had
27
+ * to wake a scale-to-zero service every minute for it to mean anything. That
28
+ * job never existed in production, so every scheduled announcement was in fact
29
+ * delivered "the next time somebody opened the app" — the feature promised a
30
+ * time and could not keep it.
31
+ *
32
+ * `draft` stays, and is not a contradiction: a draft is not a queued
33
+ * announcement, it is an unsent note. Sending it is still a present event, it
34
+ * just happens when the owner presses the button rather than when they save.
35
+ */
21
36
  export declare const announcementStatusSchema: z.ZodEnum<{
22
37
  draft: "draft";
23
- scheduled: "scheduled";
24
38
  sent: "sent";
25
39
  }>;
26
40
  export type AnnouncementStatus = z.infer<typeof announcementStatusSchema>;
@@ -35,30 +49,21 @@ export declare const announcementSchema: z.ZodObject<{
35
49
  body: z.ZodString;
36
50
  status: z.ZodEnum<{
37
51
  draft: "draft";
38
- scheduled: "scheduled";
39
52
  sent: "sent";
40
53
  }>;
41
- scheduledAt: z.ZodNullable<z.ZodISODateTime>;
42
54
  sentAt: z.ZodNullable<z.ZodISODateTime>;
43
55
  deliveredCount: z.ZodNullable<z.ZodNumber>;
44
- deliveryFailedAt: z.ZodNullable<z.ZodISODateTime>;
45
56
  createdAt: z.ZodISODateTime;
46
57
  updatedAt: z.ZodISODateTime;
47
58
  }, z.core.$strip>;
48
59
  export type Announcement = z.infer<typeof announcementSchema>;
49
60
  /**
50
- * How far in the past a `scheduledAt` may sit and still be treated as "now".
61
+ * Write one and it goes out, unless it is explicitly saved as a draft.
51
62
  *
52
- * Not zero. The browser builds the instant from two wall-clock inputs and the
53
- * request then crosses a network, so an owner who picks the next minute and
54
- * taps promptly can arrive a few seconds "late" through no fault of their own.
55
- * Refusing that would be a validation error for doing exactly the right thing.
56
- * Anything older is a genuine mistake: it would be delivered by the very next
57
- * sweep while the screen called it Scheduled.
63
+ * No `scheduledAt`, and no refinements: with delivery immediate there is
64
+ * nothing left to cross-validate. The two that used to be here both existed to
65
+ * police a future time that no longer exists.
58
66
  */
59
- export declare const SCHEDULE_GRACE_MS: number;
60
- export declare const PAST_SCHEDULE_MESSAGE = "Pick a time in the future";
61
- export declare function isScheduleInPast(iso: string, now?: Date): boolean;
62
67
  export declare const createAnnouncementBodySchema: z.ZodObject<{
63
68
  icon: z.ZodDefault<z.ZodEnum<{
64
69
  megaphone: "megaphone";
@@ -66,43 +71,34 @@ export declare const createAnnouncementBodySchema: z.ZodObject<{
66
71
  }>>;
67
72
  title: z.ZodString;
68
73
  body: z.ZodString;
69
- scheduledAt: z.ZodOptional<z.ZodISODateTime>;
70
74
  status: z.ZodDefault<z.ZodEnum<{
71
75
  draft: "draft";
72
- scheduled: "scheduled";
73
76
  sent: "sent";
74
77
  }>>;
75
78
  }, z.core.$strip>;
76
79
  export type CreateAnnouncementBody = z.infer<typeof createAnnouncementBodySchema>;
77
80
  /** The create body plus the center it belongs to, as the route receives it. */
78
- export declare const createAnnouncementWithCenterSchema: z.ZodIntersection<z.ZodObject<{
81
+ export declare const createAnnouncementWithCenterSchema: z.ZodObject<{
79
82
  icon: z.ZodDefault<z.ZodEnum<{
80
83
  megaphone: "megaphone";
81
84
  warning: "warning";
82
85
  }>>;
83
86
  title: z.ZodString;
84
87
  body: z.ZodString;
85
- scheduledAt: z.ZodOptional<z.ZodISODateTime>;
86
88
  status: z.ZodDefault<z.ZodEnum<{
87
89
  draft: "draft";
88
- scheduled: "scheduled";
89
90
  sent: "sent";
90
91
  }>>;
91
- }, z.core.$strip>, z.ZodObject<{
92
92
  centerId: z.ZodString;
93
- }, z.core.$strip>>;
93
+ }, z.core.$strip>;
94
94
  export type CreateAnnouncementWithCenterBody = z.infer<typeof createAnnouncementWithCenterSchema>;
95
95
  /**
96
- * Edit a draft, reschedule a queued one, or send either.
96
+ * Edit a draft, and send it.
97
97
  *
98
98
  * Every field is optional because this is a PATCH: an owner fixing a typo sends
99
- * the title alone. `status` is how a draft finally goes out, which is the whole
100
- * reason this exists — before it there was no update endpoint at all, so "Save
101
- * as Draft" wrote a row that could only ever be deleted.
102
- *
103
- * `scheduledAt` accepts null explicitly, meaning "drop the schedule", which is
104
- * different from omitting it ("leave the schedule alone"). Conflating the two
105
- * would make it impossible to turn a scheduled announcement back into a draft.
99
+ * the title alone. `status: 'sent'` is how a draft finally goes out, which is
100
+ * the whole reason this exists — before it there was no update endpoint at all,
101
+ * so "Save as Draft" wrote a row that could only ever be deleted.
106
102
  */
107
103
  export declare const updateAnnouncementBodySchema: z.ZodObject<{
108
104
  icon: z.ZodOptional<z.ZodEnum<{
@@ -113,31 +109,25 @@ export declare const updateAnnouncementBodySchema: z.ZodObject<{
113
109
  body: z.ZodOptional<z.ZodString>;
114
110
  status: z.ZodOptional<z.ZodEnum<{
115
111
  draft: "draft";
116
- scheduled: "scheduled";
117
112
  sent: "sent";
118
113
  }>>;
119
- scheduledAt: z.ZodOptional<z.ZodNullable<z.ZodISODateTime>>;
120
114
  }, z.core.$strip>;
121
115
  export type UpdateAnnouncementBody = z.infer<typeof updateAnnouncementBodySchema>;
122
116
  /**
123
- * How the list is ordered.
117
+ * How the list is ordered. `newest` by default, because the screen's job is
118
+ * "what did we just send".
124
119
  *
125
- * `newest` is the default because the screen's job is "what did we just send".
126
- * `upcoming` orders by `scheduledAt` ascending and is meaningful only for rows
127
- * that HAVE one: in a mixed list every draft and every already-sent row sorts as
128
- * null and groups at the front, so the UI offers it under the Scheduled filter
129
- * alone.
120
+ * There was a third, `upcoming`, ordering by the scheduled time. It went with
121
+ * scheduling.
130
122
  */
131
123
  export declare const announcementSortSchema: z.ZodEnum<{
132
124
  newest: "newest";
133
125
  oldest: "oldest";
134
- upcoming: "upcoming";
135
126
  }>;
136
127
  export type AnnouncementSort = z.infer<typeof announcementSortSchema>;
137
128
  /** `all` is a real choice, not an absent filter, so the UI can round-trip it. */
138
129
  export declare const announcementStatusFilterSchema: z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodEnum<{
139
130
  draft: "draft";
140
- scheduled: "scheduled";
141
131
  sent: "sent";
142
132
  }>]>;
143
133
  export type AnnouncementStatusFilter = z.infer<typeof announcementStatusFilterSchema>;
@@ -155,13 +145,11 @@ export declare const listAnnouncementsQuerySchema: z.ZodObject<{
155
145
  q: z.ZodOptional<z.ZodString>;
156
146
  status: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodEnum<{
157
147
  draft: "draft";
158
- scheduled: "scheduled";
159
148
  sent: "sent";
160
149
  }>]>>;
161
150
  sort: z.ZodDefault<z.ZodEnum<{
162
151
  newest: "newest";
163
152
  oldest: "oldest";
164
- upcoming: "upcoming";
165
153
  }>>;
166
154
  }, z.core.$strip>;
167
155
  export type ListAnnouncementsQuery = z.infer<typeof listAnnouncementsQuerySchema>;
@@ -1 +1 @@
1
- {"version":3,"file":"announcement.d.ts","sourceRoot":"","sources":["../src/announcement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,sBAAsB;;;EAAmC,CAAC;AACvE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,eAAO,MAAM,wBAAwB;;;;EAAyC,CAAC;AAC/E,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAI1E,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;iBA4B7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,QAAgB,CAAC;AAE/C,eAAO,MAAM,qBAAqB,8BAA8B,CAAC;AAEjE,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,IAAiB,GAAG,OAAO,CAI7E;AAID,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;iBAwBrC,CAAC;AACL,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAElF,+EAA+E;AAC/E,eAAO,MAAM,kCAAkC;;;;;;;;;;;;;;;kBAE9C,CAAC;AACF,MAAM,MAAM,gCAAgC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kCAAkC,CAAC,CAAC;AAIlG;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;iBAMvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAIlF;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB;;;;EAA2C,CAAC;AAC/E,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,iFAAiF;AACjF,eAAO,MAAM,8BAA8B;;;;IAAwD,CAAC;AACpG,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,8BAA8B,CAAC,CAAC;AAEtF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;;;iBAMvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAElF,kFAAkF;AAClF,eAAO,MAAM,sBAAsB;;iBAAyC,CAAC;AAC7E,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC"}
1
+ {"version":3,"file":"announcement.d.ts","sourceRoot":"","sources":["../src/announcement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,sBAAsB;;;EAAmC,CAAC;AACvE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,wBAAwB;;;EAA4B,CAAC;AAClE,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAI1E,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;iBAmB7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;iBAKvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAElF,+EAA+E;AAC/E,eAAO,MAAM,kCAAkC;;;;;;;;;;;;iBAE7C,CAAC;AACH,MAAM,MAAM,gCAAgC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kCAAkC,CAAC,CAAC;AAIlG;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;iBAKvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAIlF;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB;;;EAA+B,CAAC;AACnE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,iFAAiF;AACjF,eAAO,MAAM,8BAA8B;;;IAAwD,CAAC;AACpG,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,8BAA8B,CAAC,CAAC;AAEtF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;iBAMvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAElF,kFAAkF;AAClF,eAAO,MAAM,sBAAsB;;iBAAyC,CAAC;AAC7E,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC"}
@@ -15,7 +15,22 @@
15
15
  import { z } from 'zod';
16
16
  import { isoDateTimeSchema, objectIdSchema, paginationSchema } from './common.js';
17
17
  export const announcementIconSchema = z.enum(['megaphone', 'warning']);
18
- export const announcementStatusSchema = z.enum(['draft', 'scheduled', 'sent']);
18
+ /**
19
+ * An announcement is a PRESENT EVENT.
20
+ *
21
+ * `scheduled` is gone. A broadcast now goes out the moment it is created, which
22
+ * removes the whole apparatus that existed to make "later" work: the due sweep,
23
+ * the claim protocol, the past-date guard, and the Cloud Scheduler job that had
24
+ * to wake a scale-to-zero service every minute for it to mean anything. That
25
+ * job never existed in production, so every scheduled announcement was in fact
26
+ * delivered "the next time somebody opened the app" — the feature promised a
27
+ * time and could not keep it.
28
+ *
29
+ * `draft` stays, and is not a contradiction: a draft is not a queued
30
+ * announcement, it is an unsent note. Sending it is still a present event, it
31
+ * just happens when the owner presses the button rather than when they save.
32
+ */
33
+ export const announcementStatusSchema = z.enum(['draft', 'sent']);
19
34
  // ─── Entity ──────────────────────────────────────────────────────────────────
20
35
  export const announcementSchema = z.object({
21
36
  id: objectIdSchema,
@@ -24,7 +39,6 @@ export const announcementSchema = z.object({
24
39
  title: z.string(),
25
40
  body: z.string(),
26
41
  status: announcementStatusSchema,
27
- scheduledAt: isoDateTimeSchema.nullable(),
28
42
  sentAt: isoDateTimeSchema.nullable(),
29
43
  /**
30
44
  * How many people it actually reached, once delivery has been attempted.
@@ -35,95 +49,51 @@ export const announcementSchema = z.object({
35
49
  * both to zero would leave the screen unable to say which.
36
50
  */
37
51
  deliveredCount: z.number().int().nullable(),
38
- /**
39
- * Set when fan-out threw, cleared when a later attempt succeeds.
40
- *
41
- * A row is marked `sent` BEFORE fan-out (it prevents a duplicate broadcast to
42
- * an entire gym), so without this a failure left no trace but a log line while
43
- * the owner's screen said Sent.
44
- */
45
- deliveryFailedAt: isoDateTimeSchema.nullable(),
46
52
  createdAt: isoDateTimeSchema,
47
53
  updatedAt: isoDateTimeSchema,
48
54
  });
49
- // ─── Scheduling guard ────────────────────────────────────────────────────────
55
+ // ─── Create body ─────────────────────────────────────────────────────────────
50
56
  /**
51
- * How far in the past a `scheduledAt` may sit and still be treated as "now".
57
+ * Write one and it goes out, unless it is explicitly saved as a draft.
52
58
  *
53
- * Not zero. The browser builds the instant from two wall-clock inputs and the
54
- * request then crosses a network, so an owner who picks the next minute and
55
- * taps promptly can arrive a few seconds "late" through no fault of their own.
56
- * Refusing that would be a validation error for doing exactly the right thing.
57
- * Anything older is a genuine mistake: it would be delivered by the very next
58
- * sweep while the screen called it Scheduled.
59
+ * No `scheduledAt`, and no refinements: with delivery immediate there is
60
+ * nothing left to cross-validate. The two that used to be here both existed to
61
+ * police a future time that no longer exists.
59
62
  */
60
- export const SCHEDULE_GRACE_MS = 2 * 60 * 1000;
61
- export const PAST_SCHEDULE_MESSAGE = 'Pick a time in the future';
62
- export function isScheduleInPast(iso, now = new Date()) {
63
- const at = Date.parse(iso);
64
- if (Number.isNaN(at))
65
- return false; // shape is the format check's problem
66
- return at < now.getTime() - SCHEDULE_GRACE_MS;
67
- }
68
- // ─── Create body ─────────────────────────────────────────────────────────────
69
- export const createAnnouncementBodySchema = z
70
- .object({
63
+ export const createAnnouncementBodySchema = z.object({
71
64
  icon: announcementIconSchema.default('megaphone'),
72
65
  title: z.string().trim().min(1, 'Title is required').max(120),
73
66
  body: z.string().trim().min(1, 'Message is required').max(250),
74
- /** Required when `status` is `scheduled`; otherwise sent immediately. */
75
- scheduledAt: isoDateTimeSchema.optional(),
76
67
  status: announcementStatusSchema.default('sent'),
77
- })
78
- .refine((v) => v.status !== 'scheduled' || v.scheduledAt != null, {
79
- message: 'Pick a schedule time',
80
- path: ['scheduledAt'],
81
- })
82
- /**
83
- * A scheduled time must be in the FUTURE. Without this, yesterday 9am
84
- * validated cleanly, was stored as `scheduled`, and went out on the next sweep
85
- * seconds later: the owner saw "Scheduled" and the members got it at once.
86
- *
87
- * Drafts are exempt on purpose. Nothing is going out, so a stale time on one
88
- * is not yet a mistake.
89
- */
90
- .refine((v) => v.status !== 'scheduled' || !v.scheduledAt || !isScheduleInPast(v.scheduledAt), {
91
- message: PAST_SCHEDULE_MESSAGE,
92
- path: ['scheduledAt'],
93
68
  });
94
69
  /** The create body plus the center it belongs to, as the route receives it. */
95
- export const createAnnouncementWithCenterSchema = createAnnouncementBodySchema.and(z.object({ centerId: objectIdSchema }));
70
+ export const createAnnouncementWithCenterSchema = createAnnouncementBodySchema.extend({
71
+ centerId: objectIdSchema,
72
+ });
96
73
  // ─── Update body ─────────────────────────────────────────────────────────────
97
74
  /**
98
- * Edit a draft, reschedule a queued one, or send either.
75
+ * Edit a draft, and send it.
99
76
  *
100
77
  * Every field is optional because this is a PATCH: an owner fixing a typo sends
101
- * the title alone. `status` is how a draft finally goes out, which is the whole
102
- * reason this exists — before it there was no update endpoint at all, so "Save
103
- * as Draft" wrote a row that could only ever be deleted.
104
- *
105
- * `scheduledAt` accepts null explicitly, meaning "drop the schedule", which is
106
- * different from omitting it ("leave the schedule alone"). Conflating the two
107
- * would make it impossible to turn a scheduled announcement back into a draft.
78
+ * the title alone. `status: 'sent'` is how a draft finally goes out, which is
79
+ * the whole reason this exists — before it there was no update endpoint at all,
80
+ * so "Save as Draft" wrote a row that could only ever be deleted.
108
81
  */
109
82
  export const updateAnnouncementBodySchema = z.object({
110
83
  icon: announcementIconSchema.optional(),
111
84
  title: z.string().trim().min(1, 'Title is required').max(120).optional(),
112
85
  body: z.string().trim().min(1, 'Message is required').max(250).optional(),
113
86
  status: announcementStatusSchema.optional(),
114
- scheduledAt: isoDateTimeSchema.nullable().optional(),
115
87
  });
116
88
  // ─── Listing query ───────────────────────────────────────────────────────────
117
89
  /**
118
- * How the list is ordered.
90
+ * How the list is ordered. `newest` by default, because the screen's job is
91
+ * "what did we just send".
119
92
  *
120
- * `newest` is the default because the screen's job is "what did we just send".
121
- * `upcoming` orders by `scheduledAt` ascending and is meaningful only for rows
122
- * that HAVE one: in a mixed list every draft and every already-sent row sorts as
123
- * null and groups at the front, so the UI offers it under the Scheduled filter
124
- * alone.
93
+ * There was a third, `upcoming`, ordering by the scheduled time. It went with
94
+ * scheduling.
125
95
  */
126
- export const announcementSortSchema = z.enum(['newest', 'oldest', 'upcoming']);
96
+ export const announcementSortSchema = z.enum(['newest', 'oldest']);
127
97
  /** `all` is a real choice, not an absent filter, so the UI can round-trip it. */
128
98
  export const announcementStatusFilterSchema = z.union([z.literal('all'), announcementStatusSchema]);
129
99
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"announcement.js","sourceRoot":"","sources":["../src/announcement.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Owner announcements\n * ====================================\n * Broadcasts an owner sends to a center's members and staff (now or scheduled).\n * Delivered as notifications + push.\n *\n * SINGLE SOURCE OF TRUTH. The listing query, the update body and the\n * past-schedule guard lived in gymmonk-backend and gymmonk-web-client as two\n * hand-kept mirrors before this. They are here so a field can only be added\n * once: zod STRIPS unknown keys, so a mirror that drifts does not fail loudly,\n * it silently drops the field somewhere between the browser and the database.\n *\n * @module gymmonk-schema/announcement\n */\n\nimport { z } from 'zod';\nimport { isoDateTimeSchema, objectIdSchema, paginationSchema } from './common.js';\n\nexport const announcementIconSchema = z.enum(['megaphone', 'warning']);\nexport type AnnouncementIcon = z.infer<typeof announcementIconSchema>;\n\nexport const announcementStatusSchema = z.enum(['draft', 'scheduled', 'sent']);\nexport type AnnouncementStatus = z.infer<typeof announcementStatusSchema>;\n\n// ─── Entity ──────────────────────────────────────────────────────────────────\n\nexport const announcementSchema = z.object({\n id: objectIdSchema,\n centerId: objectIdSchema,\n icon: announcementIconSchema,\n title: z.string(),\n body: z.string(),\n status: announcementStatusSchema,\n scheduledAt: isoDateTimeSchema.nullable(),\n sentAt: isoDateTimeSchema.nullable(),\n /**\n * How many people it actually reached, once delivery has been attempted.\n *\n * `null` means \"not attempted yet\", which is NOT the same as `0` (\"attempted,\n * nobody to tell\"). An owner broadcasting to an empty roster and an owner\n * whose broadcast has not run yet are different situations, and collapsing\n * both to zero would leave the screen unable to say which.\n */\n deliveredCount: z.number().int().nullable(),\n /**\n * Set when fan-out threw, cleared when a later attempt succeeds.\n *\n * A row is marked `sent` BEFORE fan-out (it prevents a duplicate broadcast to\n * an entire gym), so without this a failure left no trace but a log line while\n * the owner's screen said Sent.\n */\n deliveryFailedAt: isoDateTimeSchema.nullable(),\n createdAt: isoDateTimeSchema,\n updatedAt: isoDateTimeSchema,\n});\nexport type Announcement = z.infer<typeof announcementSchema>;\n\n// ─── Scheduling guard ────────────────────────────────────────────────────────\n\n/**\n * How far in the past a `scheduledAt` may sit and still be treated as \"now\".\n *\n * Not zero. The browser builds the instant from two wall-clock inputs and the\n * request then crosses a network, so an owner who picks the next minute and\n * taps promptly can arrive a few seconds \"late\" through no fault of their own.\n * Refusing that would be a validation error for doing exactly the right thing.\n * Anything older is a genuine mistake: it would be delivered by the very next\n * sweep while the screen called it Scheduled.\n */\nexport const SCHEDULE_GRACE_MS = 2 * 60 * 1000;\n\nexport const PAST_SCHEDULE_MESSAGE = 'Pick a time in the future';\n\nexport function isScheduleInPast(iso: string, now: Date = new Date()): boolean {\n const at = Date.parse(iso);\n if (Number.isNaN(at)) return false; // shape is the format check's problem\n return at < now.getTime() - SCHEDULE_GRACE_MS;\n}\n\n// ─── Create body ─────────────────────────────────────────────────────────────\n\nexport const createAnnouncementBodySchema = z\n .object({\n icon: announcementIconSchema.default('megaphone'),\n title: z.string().trim().min(1, 'Title is required').max(120),\n body: z.string().trim().min(1, 'Message is required').max(250),\n /** Required when `status` is `scheduled`; otherwise sent immediately. */\n scheduledAt: isoDateTimeSchema.optional(),\n status: announcementStatusSchema.default('sent'),\n })\n .refine((v) => v.status !== 'scheduled' || v.scheduledAt != null, {\n message: 'Pick a schedule time',\n path: ['scheduledAt'],\n })\n /**\n * A scheduled time must be in the FUTURE. Without this, yesterday 9am\n * validated cleanly, was stored as `scheduled`, and went out on the next sweep\n * seconds later: the owner saw \"Scheduled\" and the members got it at once.\n *\n * Drafts are exempt on purpose. Nothing is going out, so a stale time on one\n * is not yet a mistake.\n */\n .refine((v) => v.status !== 'scheduled' || !v.scheduledAt || !isScheduleInPast(v.scheduledAt), {\n message: PAST_SCHEDULE_MESSAGE,\n path: ['scheduledAt'],\n });\nexport type CreateAnnouncementBody = z.infer<typeof createAnnouncementBodySchema>;\n\n/** The create body plus the center it belongs to, as the route receives it. */\nexport const createAnnouncementWithCenterSchema = createAnnouncementBodySchema.and(\n z.object({ centerId: objectIdSchema }),\n);\nexport type CreateAnnouncementWithCenterBody = z.infer<typeof createAnnouncementWithCenterSchema>;\n\n// ─── Update body ─────────────────────────────────────────────────────────────\n\n/**\n * Edit a draft, reschedule a queued one, or send either.\n *\n * Every field is optional because this is a PATCH: an owner fixing a typo sends\n * the title alone. `status` is how a draft finally goes out, which is the whole\n * reason this exists — before it there was no update endpoint at all, so \"Save\n * as Draft\" wrote a row that could only ever be deleted.\n *\n * `scheduledAt` accepts null explicitly, meaning \"drop the schedule\", which is\n * different from omitting it (\"leave the schedule alone\"). Conflating the two\n * would make it impossible to turn a scheduled announcement back into a draft.\n */\nexport const updateAnnouncementBodySchema = z.object({\n icon: announcementIconSchema.optional(),\n title: z.string().trim().min(1, 'Title is required').max(120).optional(),\n body: z.string().trim().min(1, 'Message is required').max(250).optional(),\n status: announcementStatusSchema.optional(),\n scheduledAt: isoDateTimeSchema.nullable().optional(),\n});\nexport type UpdateAnnouncementBody = z.infer<typeof updateAnnouncementBodySchema>;\n\n// ─── Listing query ───────────────────────────────────────────────────────────\n\n/**\n * How the list is ordered.\n *\n * `newest` is the default because the screen's job is \"what did we just send\".\n * `upcoming` orders by `scheduledAt` ascending and is meaningful only for rows\n * that HAVE one: in a mixed list every draft and every already-sent row sorts as\n * null and groups at the front, so the UI offers it under the Scheduled filter\n * alone.\n */\nexport const announcementSortSchema = z.enum(['newest', 'oldest', 'upcoming']);\nexport type AnnouncementSort = z.infer<typeof announcementSortSchema>;\n\n/** `all` is a real choice, not an absent filter, so the UI can round-trip it. */\nexport const announcementStatusFilterSchema = z.union([z.literal('all'), announcementStatusSchema]);\nexport type AnnouncementStatusFilter = z.infer<typeof announcementStatusFilterSchema>;\n\n/**\n * The listing query.\n *\n * Everything arrives as a STRING off the query string, so `paginationSchema`\n * coerces page and limit. Without the coercion `page=2` fails validation and\n * the client gets a 400 for a link it built correctly.\n */\nexport const listAnnouncementsQuerySchema = paginationSchema.extend({\n centerId: objectIdSchema,\n /** Free text over title and body. Trimmed; empty means \"no search\". */\n q: z.string().trim().max(120).optional(),\n status: announcementStatusFilterSchema.default('all'),\n sort: announcementSortSchema.default('newest'),\n});\nexport type ListAnnouncementsQuery = z.infer<typeof listAnnouncementsQuerySchema>;\n\n/** Just the center, for routes that identify the row by id but still scope it. */\nexport const centerScopeQuerySchema = z.object({ centerId: objectIdSchema });\nexport type CenterScopeQuery = z.infer<typeof centerScopeQuerySchema>;\n"]}
1
+ {"version":3,"file":"announcement.js","sourceRoot":"","sources":["../src/announcement.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Owner announcements\n * ====================================\n * Broadcasts an owner sends to a center's members and staff (now or scheduled).\n * Delivered as notifications + push.\n *\n * SINGLE SOURCE OF TRUTH. The listing query, the update body and the\n * past-schedule guard lived in gymmonk-backend and gymmonk-web-client as two\n * hand-kept mirrors before this. They are here so a field can only be added\n * once: zod STRIPS unknown keys, so a mirror that drifts does not fail loudly,\n * it silently drops the field somewhere between the browser and the database.\n *\n * @module gymmonk-schema/announcement\n */\n\nimport { z } from 'zod';\nimport { isoDateTimeSchema, objectIdSchema, paginationSchema } from './common.js';\n\nexport const announcementIconSchema = z.enum(['megaphone', 'warning']);\nexport type AnnouncementIcon = z.infer<typeof announcementIconSchema>;\n\n/**\n * An announcement is a PRESENT EVENT.\n *\n * `scheduled` is gone. A broadcast now goes out the moment it is created, which\n * removes the whole apparatus that existed to make \"later\" work: the due sweep,\n * the claim protocol, the past-date guard, and the Cloud Scheduler job that had\n * to wake a scale-to-zero service every minute for it to mean anything. That\n * job never existed in production, so every scheduled announcement was in fact\n * delivered \"the next time somebody opened the app\" — the feature promised a\n * time and could not keep it.\n *\n * `draft` stays, and is not a contradiction: a draft is not a queued\n * announcement, it is an unsent note. Sending it is still a present event, it\n * just happens when the owner presses the button rather than when they save.\n */\nexport const announcementStatusSchema = z.enum(['draft', 'sent']);\nexport type AnnouncementStatus = z.infer<typeof announcementStatusSchema>;\n\n// ─── Entity ──────────────────────────────────────────────────────────────────\n\nexport const announcementSchema = z.object({\n id: objectIdSchema,\n centerId: objectIdSchema,\n icon: announcementIconSchema,\n title: z.string(),\n body: z.string(),\n status: announcementStatusSchema,\n sentAt: isoDateTimeSchema.nullable(),\n /**\n * How many people it actually reached, once delivery has been attempted.\n *\n * `null` means \"not attempted yet\", which is NOT the same as `0` (\"attempted,\n * nobody to tell\"). An owner broadcasting to an empty roster and an owner\n * whose broadcast has not run yet are different situations, and collapsing\n * both to zero would leave the screen unable to say which.\n */\n deliveredCount: z.number().int().nullable(),\n createdAt: isoDateTimeSchema,\n updatedAt: isoDateTimeSchema,\n});\nexport type Announcement = z.infer<typeof announcementSchema>;\n\n// ─── Create body ─────────────────────────────────────────────────────────────\n\n/**\n * Write one and it goes out, unless it is explicitly saved as a draft.\n *\n * No `scheduledAt`, and no refinements: with delivery immediate there is\n * nothing left to cross-validate. The two that used to be here both existed to\n * police a future time that no longer exists.\n */\nexport const createAnnouncementBodySchema = z.object({\n icon: announcementIconSchema.default('megaphone'),\n title: z.string().trim().min(1, 'Title is required').max(120),\n body: z.string().trim().min(1, 'Message is required').max(250),\n status: announcementStatusSchema.default('sent'),\n});\nexport type CreateAnnouncementBody = z.infer<typeof createAnnouncementBodySchema>;\n\n/** The create body plus the center it belongs to, as the route receives it. */\nexport const createAnnouncementWithCenterSchema = createAnnouncementBodySchema.extend({\n centerId: objectIdSchema,\n});\nexport type CreateAnnouncementWithCenterBody = z.infer<typeof createAnnouncementWithCenterSchema>;\n\n// ─── Update body ─────────────────────────────────────────────────────────────\n\n/**\n * Edit a draft, and send it.\n *\n * Every field is optional because this is a PATCH: an owner fixing a typo sends\n * the title alone. `status: 'sent'` is how a draft finally goes out, which is\n * the whole reason this exists — before it there was no update endpoint at all,\n * so \"Save as Draft\" wrote a row that could only ever be deleted.\n */\nexport const updateAnnouncementBodySchema = z.object({\n icon: announcementIconSchema.optional(),\n title: z.string().trim().min(1, 'Title is required').max(120).optional(),\n body: z.string().trim().min(1, 'Message is required').max(250).optional(),\n status: announcementStatusSchema.optional(),\n});\nexport type UpdateAnnouncementBody = z.infer<typeof updateAnnouncementBodySchema>;\n\n// ─── Listing query ───────────────────────────────────────────────────────────\n\n/**\n * How the list is ordered. `newest` by default, because the screen's job is\n * \"what did we just send\".\n *\n * There was a third, `upcoming`, ordering by the scheduled time. It went with\n * scheduling.\n */\nexport const announcementSortSchema = z.enum(['newest', 'oldest']);\nexport type AnnouncementSort = z.infer<typeof announcementSortSchema>;\n\n/** `all` is a real choice, not an absent filter, so the UI can round-trip it. */\nexport const announcementStatusFilterSchema = z.union([z.literal('all'), announcementStatusSchema]);\nexport type AnnouncementStatusFilter = z.infer<typeof announcementStatusFilterSchema>;\n\n/**\n * The listing query.\n *\n * Everything arrives as a STRING off the query string, so `paginationSchema`\n * coerces page and limit. Without the coercion `page=2` fails validation and\n * the client gets a 400 for a link it built correctly.\n */\nexport const listAnnouncementsQuerySchema = paginationSchema.extend({\n centerId: objectIdSchema,\n /** Free text over title and body. Trimmed; empty means \"no search\". */\n q: z.string().trim().max(120).optional(),\n status: announcementStatusFilterSchema.default('all'),\n sort: announcementSortSchema.default('newest'),\n});\nexport type ListAnnouncementsQuery = z.infer<typeof listAnnouncementsQuerySchema>;\n\n/** Just the center, for routes that identify the row by id but still scope it. */\nexport const centerScopeQuerySchema = z.object({ centerId: objectIdSchema });\nexport type CenterScopeQuery = z.infer<typeof centerScopeQuerySchema>;\n"]}
@@ -1 +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"}
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;AAa5D,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"}
package/dist/area.js CHANGED
@@ -64,7 +64,13 @@ export const areaLineageSchema = z.object({
64
64
  coords: areaCoordsSchema.nullable(),
65
65
  });
66
66
  // ─── Query params ────────────────────────────────────────────────────────────
67
- /** Shared free-text filter. Matched as a case-insensitive PREFIX server-side. */
67
+ /**
68
+ * Shared free-text filter. Matched server-side as a case-insensitive SUBSTRING
69
+ * of the area's name — not a prefix, because master data leads a name with its
70
+ * administrative label as often as not ("99-Nakraunda" is the urban ward a
71
+ * person calls Nakraunda), and a prefix match leaves those rows unreachable no
72
+ * matter what the user types.
73
+ */
68
74
  const areaSearchTerm = z.string().trim().max(120).optional();
69
75
  /** GET /areas/districts */
70
76
  export const districtsQuerySchema = z.object({
package/dist/area.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"area.js","sourceRoot":"","sources":["../src/area.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEnE,gFAAgF;AAEhF,wEAAwE;AACxE,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,EAAE,EAAE,cAAc;IAClB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;CACjB,CAAC,CAAC;AAGH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,gBAAgB,CAAC,MAAM,CAAC;IAC1D,IAAI,EAAE,kBAAkB;CACzB,CAAC,CAAC;AAGH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,oBAAoB,CAAC;IACpC,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;CACrB,CAAC,CAAC;AAGH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC;IACxC,oEAAoE;IACpE,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC;IACjC,MAAM,EAAE,gBAAgB,CAAC,QAAQ,EAAE;CACpC,CAAC,CAAC;AAGH,gFAAgF;AAEhF,iFAAiF;AACjF,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC;AAE7D,2BAA2B;AAC3B,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,OAAO,EAAE,cAAc;IACvB,CAAC,EAAE,cAAc;CAClB,CAAC,CAAC;AAGH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,CAAC;AACrC,mFAAmF;AACnF,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAEtC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C,UAAU,EAAE,cAAc;IAC1B,CAAC,EAAE,cAAc;IACjB,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAC/C,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC;CAC1F,CAAC,CAAC;AAGH,iCAAiC;AACjC,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC9C,MAAM,EAAE,cAAc;CACvB,CAAC,CAAC","sourcesContent":["/**\n * Area lookup API — the contract behind the location picker\n * =========================================================\n * `/api/v1/areas/*` reads reform-owned master data (countries, states,\n * districts, and the eight sub-district types) so a GymMonk address can be\n * PICKED rather than typed.\n *\n * These shapes live here, not in either app, for the reason the whole package\n * exists: the backend and the web client were each declaring their own\n * `AreaOption` and `LocalityOption`, which is two definitions of one wire\n * format and therefore a drift waiting to happen — the backend adding a field\n * or renaming one could not fail the client's build. Declared once, both sides\n * import it and a change breaks loudly at compile time.\n *\n * Everything here is READ-ONLY. Master data is owned by reform-backend;\n * GymMonk never writes to it.\n *\n * @module area\n */\n\nimport { z } from 'zod';\nimport { objectIdSchema } from './common.js';\nimport { areaCoordsSchema, localityTypeSchema } from './shared.js';\n\n// ─── Results ─────────────────────────────────────────────────────────────────\n\n/** One pickable area: the master_db id, and the name to show for it. */\nexport const areaOptionSchema = z.object({\n id: objectIdSchema,\n name: z.string(),\n});\nexport type AreaOption = z.infer<typeof areaOptionSchema>;\n\n/**\n * A locality result also carries its area TYPE.\n *\n * That is not decoration. One district holds rows of eight different types, and\n * names repeat across them — reform's master data has \"Nakraunda\" as both a\n * Village and a Hamlet under Dehradun. Merged into one alphabetical list, the\n * type is the only thing that tells the two apart.\n */\nexport const localityOptionSchema = areaOptionSchema.extend({\n type: localityTypeSchema,\n});\nexport type LocalityOption = z.infer<typeof localityOptionSchema>;\n\n/**\n * One page of localities.\n *\n * `hasMore` rather than a total count: a large district holds thousands of\n * rows, and an infinite-scrolling list only ever needs to know whether to keep\n * going. The server derives it by fetching one row beyond the page, which\n * avoids a second `countDocuments` over the same filter.\n */\nexport const localityPageSchema = z.object({\n items: z.array(localityOptionSchema),\n hasMore: z.boolean(),\n});\nexport type LocalityPage = z.infer<typeof localityPageSchema>;\n\n/**\n * Ancestor ids and coordinates for one area, resolved once when an address is\n * saved and then denormalized onto it — so reading an address never touches\n * master data again.\n *\n * `coords` is nullable, not optional: \"we looked and this chain has no\n * coordinates\" is a real answer, and collapsing it to a missing field would\n * make it indistinguishable from a response that failed to include it.\n */\nexport const areaLineageSchema = z.object({\n /** Every ancestor id, leaf → country, including the area itself. */\n ancestry: z.array(objectIdSchema),\n coords: areaCoordsSchema.nullable(),\n});\nexport type AreaLineage = z.infer<typeof areaLineageSchema>;\n\n// ─── Query params ────────────────────────────────────────────────────────────\n\n/** Shared free-text filter. Matched as a case-insensitive PREFIX server-side. */\nconst areaSearchTerm = z.string().trim().max(120).optional();\n\n/** GET /areas/districts */\nexport const districtsQuerySchema = z.object({\n stateId: objectIdSchema,\n q: areaSearchTerm,\n});\nexport type DistrictsQuery = z.infer<typeof districtsQuerySchema>;\n\n/** Rows per locality page. Mirrored by the client's infinite-scroll hook. */\nexport const LOCALITY_PAGE_SIZE = 50;\n/** Ceiling on a caller-supplied `limit`, so one request cannot pull a district. */\nexport const LOCALITY_MAX_LIMIT = 100;\n\n/**\n * GET /areas/localities\n *\n * `page`/`limit` are coerced because they arrive as query strings, and clamped\n * here rather than in the handler so the frontend sees the same bounds it will\n * be held to.\n */\nexport const localitiesQuerySchema = z.object({\n districtId: objectIdSchema,\n q: areaSearchTerm,\n page: z.coerce.number().int().min(1).default(1),\n limit: z.coerce.number().int().min(1).max(LOCALITY_MAX_LIMIT).default(LOCALITY_PAGE_SIZE),\n});\nexport type LocalitiesQuery = z.infer<typeof localitiesQuerySchema>;\n\n/** GET /areas/lineage/:areaId */\nexport const areaLineageParamsSchema = z.object({\n areaId: objectIdSchema,\n});\nexport type AreaLineageParams = z.infer<typeof areaLineageParamsSchema>;\n"]}
1
+ {"version":3,"file":"area.js","sourceRoot":"","sources":["../src/area.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEnE,gFAAgF;AAEhF,wEAAwE;AACxE,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,EAAE,EAAE,cAAc;IAClB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;CACjB,CAAC,CAAC;AAGH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,gBAAgB,CAAC,MAAM,CAAC;IAC1D,IAAI,EAAE,kBAAkB;CACzB,CAAC,CAAC;AAGH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,oBAAoB,CAAC;IACpC,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;CACrB,CAAC,CAAC;AAGH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC;IACxC,oEAAoE;IACpE,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC;IACjC,MAAM,EAAE,gBAAgB,CAAC,QAAQ,EAAE;CACpC,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;GAMG;AACH,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC;AAE7D,2BAA2B;AAC3B,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,OAAO,EAAE,cAAc;IACvB,CAAC,EAAE,cAAc;CAClB,CAAC,CAAC;AAGH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,CAAC;AACrC,mFAAmF;AACnF,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAEtC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C,UAAU,EAAE,cAAc;IAC1B,CAAC,EAAE,cAAc;IACjB,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAC/C,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC;CAC1F,CAAC,CAAC;AAGH,iCAAiC;AACjC,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC9C,MAAM,EAAE,cAAc;CACvB,CAAC,CAAC","sourcesContent":["/**\n * Area lookup API — the contract behind the location picker\n * =========================================================\n * `/api/v1/areas/*` reads reform-owned master data (countries, states,\n * districts, and the eight sub-district types) so a GymMonk address can be\n * PICKED rather than typed.\n *\n * These shapes live here, not in either app, for the reason the whole package\n * exists: the backend and the web client were each declaring their own\n * `AreaOption` and `LocalityOption`, which is two definitions of one wire\n * format and therefore a drift waiting to happen — the backend adding a field\n * or renaming one could not fail the client's build. Declared once, both sides\n * import it and a change breaks loudly at compile time.\n *\n * Everything here is READ-ONLY. Master data is owned by reform-backend;\n * GymMonk never writes to it.\n *\n * @module area\n */\n\nimport { z } from 'zod';\nimport { objectIdSchema } from './common.js';\nimport { areaCoordsSchema, localityTypeSchema } from './shared.js';\n\n// ─── Results ─────────────────────────────────────────────────────────────────\n\n/** One pickable area: the master_db id, and the name to show for it. */\nexport const areaOptionSchema = z.object({\n id: objectIdSchema,\n name: z.string(),\n});\nexport type AreaOption = z.infer<typeof areaOptionSchema>;\n\n/**\n * A locality result also carries its area TYPE.\n *\n * That is not decoration. One district holds rows of eight different types, and\n * names repeat across them — reform's master data has \"Nakraunda\" as both a\n * Village and a Hamlet under Dehradun. Merged into one alphabetical list, the\n * type is the only thing that tells the two apart.\n */\nexport const localityOptionSchema = areaOptionSchema.extend({\n type: localityTypeSchema,\n});\nexport type LocalityOption = z.infer<typeof localityOptionSchema>;\n\n/**\n * One page of localities.\n *\n * `hasMore` rather than a total count: a large district holds thousands of\n * rows, and an infinite-scrolling list only ever needs to know whether to keep\n * going. The server derives it by fetching one row beyond the page, which\n * avoids a second `countDocuments` over the same filter.\n */\nexport const localityPageSchema = z.object({\n items: z.array(localityOptionSchema),\n hasMore: z.boolean(),\n});\nexport type LocalityPage = z.infer<typeof localityPageSchema>;\n\n/**\n * Ancestor ids and coordinates for one area, resolved once when an address is\n * saved and then denormalized onto it — so reading an address never touches\n * master data again.\n *\n * `coords` is nullable, not optional: \"we looked and this chain has no\n * coordinates\" is a real answer, and collapsing it to a missing field would\n * make it indistinguishable from a response that failed to include it.\n */\nexport const areaLineageSchema = z.object({\n /** Every ancestor id, leaf → country, including the area itself. */\n ancestry: z.array(objectIdSchema),\n coords: areaCoordsSchema.nullable(),\n});\nexport type AreaLineage = z.infer<typeof areaLineageSchema>;\n\n// ─── Query params ────────────────────────────────────────────────────────────\n\n/**\n * Shared free-text filter. Matched server-side as a case-insensitive SUBSTRING\n * of the area's name — not a prefix, because master data leads a name with its\n * administrative label as often as not (\"99-Nakraunda\" is the urban ward a\n * person calls Nakraunda), and a prefix match leaves those rows unreachable no\n * matter what the user types.\n */\nconst areaSearchTerm = z.string().trim().max(120).optional();\n\n/** GET /areas/districts */\nexport const districtsQuerySchema = z.object({\n stateId: objectIdSchema,\n q: areaSearchTerm,\n});\nexport type DistrictsQuery = z.infer<typeof districtsQuerySchema>;\n\n/** Rows per locality page. Mirrored by the client's infinite-scroll hook. */\nexport const LOCALITY_PAGE_SIZE = 50;\n/** Ceiling on a caller-supplied `limit`, so one request cannot pull a district. */\nexport const LOCALITY_MAX_LIMIT = 100;\n\n/**\n * GET /areas/localities\n *\n * `page`/`limit` are coerced because they arrive as query strings, and clamped\n * here rather than in the handler so the frontend sees the same bounds it will\n * be held to.\n */\nexport const localitiesQuerySchema = z.object({\n districtId: objectIdSchema,\n q: areaSearchTerm,\n page: z.coerce.number().int().min(1).default(1),\n limit: z.coerce.number().int().min(1).max(LOCALITY_MAX_LIMIT).default(LOCALITY_PAGE_SIZE),\n});\nexport type LocalitiesQuery = z.infer<typeof localitiesQuerySchema>;\n\n/** GET /areas/lineage/:areaId */\nexport const areaLineageParamsSchema = z.object({\n areaId: objectIdSchema,\n});\nexport type AreaLineageParams = z.infer<typeof areaLineageParamsSchema>;\n"]}
package/dist/session.d.ts CHANGED
@@ -130,6 +130,32 @@ export declare const attendanceStatusSchema: z.ZodEnum<{
130
130
  rest: "rest";
131
131
  }>;
132
132
  export type AttendanceStatus = z.infer<typeof attendanceStatusSchema>;
133
+ /**
134
+ * How an attendance mark came to be recorded.
135
+ *
136
+ * `scan` — the member scanned the gym's QR poster themselves.
137
+ * `manual` — a person put it there: an owner, manager or front desk.
138
+ * `auto` — nobody did. The member never scanned out and the session hit its
139
+ * cap, so the system closed it (see `AUTO_CHECK_OUT_HOURS`).
140
+ *
141
+ * Recorded per SIDE, because the two halves of a visit routinely differ: the
142
+ * common case is a member who scans in and is closed out automatically. An
143
+ * attendance report that cannot tell a scanned departure from an assumed one is
144
+ * reporting a guess as a measurement.
145
+ */
146
+ export declare const attendanceSourceSchema: z.ZodEnum<{
147
+ scan: "scan";
148
+ manual: "manual";
149
+ auto: "auto";
150
+ }>;
151
+ export type AttendanceSource = z.infer<typeof attendanceSourceSchema>;
152
+ /**
153
+ * How long a session may stay open before it is closed for the member.
154
+ *
155
+ * Shared with the client so the member's screen can say when their session will
156
+ * end, rather than having the number live only in the server that enforces it.
157
+ */
158
+ export declare const AUTO_CHECK_OUT_HOURS = 3;
133
159
  export declare const attendanceEntrySchema: z.ZodObject<{
134
160
  date: z.ZodString;
135
161
  checkInAt: z.ZodNullable<z.ZodISODateTime>;
@@ -139,6 +165,18 @@ export declare const attendanceEntrySchema: z.ZodObject<{
139
165
  absent: "absent";
140
166
  rest: "rest";
141
167
  }>;
168
+ checkInSource: z.ZodNullable<z.ZodEnum<{
169
+ scan: "scan";
170
+ manual: "manual";
171
+ auto: "auto";
172
+ }>>;
173
+ checkOutSource: z.ZodNullable<z.ZodEnum<{
174
+ scan: "scan";
175
+ manual: "manual";
176
+ auto: "auto";
177
+ }>>;
178
+ checkInBy: z.ZodNullable<z.ZodString>;
179
+ checkOutBy: z.ZodNullable<z.ZodString>;
142
180
  }, z.core.$strip>;
143
181
  export type AttendanceEntry = z.infer<typeof attendanceEntrySchema>;
144
182
  export declare const attendanceQuerySchema: z.ZodObject<{
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAcxB,eAAO,MAAM,mBAAmB;;;;EAA0C,CAAC;AAC3E,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,sBAAsB;;;;iBAMjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,+CAA+C;AAC/C,eAAO,MAAM,cAAc,EAAE,gBAI5B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,GAAE,IAAiB,GAAG,MAAM,CAO/F;AAID,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAY/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB;;;;;iBAOQ,CAAC;AACvC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;iBAE7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D,eAAO,MAAM,0BAA0B;;;iBAGrC,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAE9E;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB;;;;;;iBAEpC,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAI5E,eAAO,MAAM,sBAAsB;;;;EAAwC,CAAC;AAC5E,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,eAAO,MAAM,qBAAqB;;;;;;;;;iBAKhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,eAAO,MAAM,qBAAqB;;;;;;iBAIhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,sEAAsE;AACtE,eAAO,MAAM,wBAAwB;;;;;;;;;;;iBAOnC,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAcxB,eAAO,MAAM,mBAAmB;;;;EAA0C,CAAC;AAC3E,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,sBAAsB;;;;iBAMjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,+CAA+C;AAC/C,eAAO,MAAM,cAAc,EAAE,gBAI5B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,GAAE,IAAiB,GAAG,MAAM,CAO/F;AAID,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAY/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB;;;;;iBAOQ,CAAC;AACvC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;iBAE7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D,eAAO,MAAM,0BAA0B;;;iBAGrC,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAE9E;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB;;;;;;iBAEpC,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAI5E,eAAO,MAAM,sBAAsB;;;;EAAwC,CAAC;AAC5E,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,sBAAsB;;;;EAAqC,CAAC;AACzE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;iBAkBhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,eAAO,MAAM,qBAAqB;;;;;;iBAIhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,sEAAsE;AACtE,eAAO,MAAM,wBAAwB;;;;;;;;;;;iBAOnC,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
package/dist/session.js CHANGED
@@ -121,11 +121,45 @@ export const stopwatchActionBodySchema = z.object({
121
121
  });
122
122
  // ─── Attendance ──────────────────────────────────────────────────────────────
123
123
  export const attendanceStatusSchema = z.enum(['present', 'absent', 'rest']);
124
+ /**
125
+ * How an attendance mark came to be recorded.
126
+ *
127
+ * `scan` — the member scanned the gym's QR poster themselves.
128
+ * `manual` — a person put it there: an owner, manager or front desk.
129
+ * `auto` — nobody did. The member never scanned out and the session hit its
130
+ * cap, so the system closed it (see `AUTO_CHECK_OUT_HOURS`).
131
+ *
132
+ * Recorded per SIDE, because the two halves of a visit routinely differ: the
133
+ * common case is a member who scans in and is closed out automatically. An
134
+ * attendance report that cannot tell a scanned departure from an assumed one is
135
+ * reporting a guess as a measurement.
136
+ */
137
+ export const attendanceSourceSchema = z.enum(['scan', 'manual', 'auto']);
138
+ /**
139
+ * How long a session may stay open before it is closed for the member.
140
+ *
141
+ * Shared with the client so the member's screen can say when their session will
142
+ * end, rather than having the number live only in the server that enforces it.
143
+ */
144
+ export const AUTO_CHECK_OUT_HOURS = 3;
124
145
  export const attendanceEntrySchema = z.object({
125
146
  date: isoDateSchema,
126
147
  checkInAt: isoDateTimeSchema.nullable(),
127
148
  checkOutAt: isoDateTimeSchema.nullable(),
128
149
  status: attendanceStatusSchema,
150
+ /** How each half was recorded. Null where that half has not happened. */
151
+ checkInSource: attendanceSourceSchema.nullable(),
152
+ checkOutSource: attendanceSourceSchema.nullable(),
153
+ /**
154
+ * WHO marked it, when a person did — a name, snapshotted at the time.
155
+ *
156
+ * Null for `scan` and `auto`, which have no author. A snapshot rather than a
157
+ * live lookup because this is a record of what happened: a manager who later
158
+ * leaves, or changes their name, must not silently rewrite last month's
159
+ * attendance sheet.
160
+ */
161
+ checkInBy: z.string().nullable(),
162
+ checkOutBy: z.string().nullable(),
129
163
  });
130
164
  export const attendanceQuerySchema = paginationSchema.extend({
131
165
  userId: objectIdSchema.optional(),
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Workout sessions, check-in & attendance\n * ========================================================\n * A member checks in to start a live session, marks exercises done, and checks\n * out. Sessions roll up into attendance records the owner can review, and can\n * also be recorded directly by an owner (manual attendance marking).\n *\n * @module gymmonk-schema/session\n */\n\nimport { z } from 'zod';\nimport { checkInCodeSchema } from './check-in.js';\nimport {\n isoDateSchema,\n isoDateTimeSchema,\n objectIdSchema,\n optionalTrimmedString,\n paginationSchema,\n} from './common.js';\nimport { checkInGeoInputSchema } from './location.js';\nimport { weekdaySchema } from './shared.js';\n\n// ─── Live session widget status ──────────────────────────────────────────────\n\nexport const sessionStatusSchema = z.enum(['idle', 'active', 'completed']);\nexport type SessionStatus = z.infer<typeof sessionStatusSchema>;\n\n// ─── The set stopwatch ───────────────────────────────────────────────────────\n\n/**\n * The member's own stopwatch, which is NOT the session clock.\n *\n * Home shows two running times and they measure different things. The session\n * clock is \"how long have you been in the gym\" — it runs from `checkInAt`,\n * cannot be paused, and is derived rather than stored. This one is \"how long\n * has this set taken\", and the member starts, pauses and resets it freely.\n *\n * Stored as ACCUMULATED + STARTED-AT rather than as a running total, because a\n * total would need the server to tick. Elapsed is\n * `accumulatedSec + (running ? now - startedAt : 0)`, so a paused stopwatch is\n * a plain number, a running one survives a refresh or a dead battery, and no\n * job has to write to the database once a second.\n *\n * `startedAt` is null exactly when `running` is false; the pair is a small\n * state machine, and the backend is the only writer.\n */\nexport const sessionStopwatchSchema = z.object({\n running: z.boolean(),\n /** Seconds banked by previous runs, excluding any run in progress. */\n accumulatedSec: z.number().int().nonnegative(),\n /** When the current run began. Null while paused. */\n startedAt: isoDateTimeSchema.nullable(),\n});\nexport type SessionStopwatch = z.infer<typeof sessionStopwatchSchema>;\n\n/** A stopwatch that has never been started. */\nexport const IDLE_STOPWATCH: SessionStopwatch = {\n running: false,\n accumulatedSec: 0,\n startedAt: null,\n};\n\n/**\n * Resolve a stopwatch to whole seconds elapsed.\n *\n * Shared rather than reimplemented on each side: the backend needs it to bank\n * time on pause, and the client needs it to render every tick. Two copies of\n * this arithmetic would drift the moment one of them forgot the running run.\n */\nexport function stopwatchElapsedSec(stopwatch: SessionStopwatch, now: Date = new Date()): number {\n if (!stopwatch.running || !stopwatch.startedAt) return stopwatch.accumulatedSec;\n const started = Date.parse(stopwatch.startedAt);\n if (Number.isNaN(started)) return stopwatch.accumulatedSec;\n // Floored at the banked value: a client clock behind the server's would\n // otherwise make the number run backwards.\n return stopwatch.accumulatedSec + Math.max(0, Math.floor((now.getTime() - started) / 1000));\n}\n\n// ─── Session entity ──────────────────────────────────────────────────────────\n\nexport const workoutSessionSchema = z.object({\n id: objectIdSchema,\n userId: objectIdSchema,\n centerId: objectIdSchema,\n status: sessionStatusSchema,\n checkInAt: isoDateTimeSchema,\n checkOutAt: isoDateTimeSchema.nullable(),\n /** The plan weekday being trained, if the member is on a plan. */\n planWeekday: weekdaySchema.nullable(),\n completedExerciseIds: z.array(objectIdSchema),\n totalExercises: z.number().int().nonnegative(),\n stopwatch: sessionStopwatchSchema,\n});\nexport type WorkoutSession = z.infer<typeof workoutSessionSchema>;\n\n// ─── Session mutations ───────────────────────────────────────────────────────\n\n/**\n * Check in by scanning the gym's QR poster.\n *\n * `code` is REQUIRED and is the whole point: the center is resolved FROM the\n * scanned code, never from a client-supplied `centerId`. An earlier version of\n * this body took an optional `centerId`, which meant any authenticated member\n * could mark themselves present from their sofa. Presence has to be proven by\n * being close enough to the poster to photograph it.\n *\n * Staff marking someone present by hand is a different, authorised path —\n * `markAttendanceBodySchema` below.\n */\nexport const checkInBodySchema = z\n .object({\n code: checkInCodeSchema,\n })\n // Where the scanning device thought it was. All three optional — see\n // `checkInGeoInputSchema`. The server measures this against the gym's pin and\n // treats a missing fix as unverifiable rather than as a failure.\n .extend(checkInGeoInputSchema.shape);\nexport type CheckInBody = z.infer<typeof checkInBodySchema>;\n\n/**\n * Check out — the same poster, scanned again. Verified against the session's\n * OWN center, so a member cannot close a session by scanning a different gym.\n */\nexport const checkOutBodySchema = z.object({\n code: checkInCodeSchema,\n});\nexport type CheckOutBody = z.infer<typeof checkOutBodySchema>;\n\nexport const markExerciseDoneBodySchema = z.object({\n exerciseId: objectIdSchema,\n done: z.boolean(),\n});\nexport type MarkExerciseDoneBody = z.infer<typeof markExerciseDoneBodySchema>;\n\n/**\n * Drive the set stopwatch.\n *\n * An ACTION, not a state patch. The client says what the member pressed and the\n * server computes the resulting `{ running, accumulatedSec, startedAt }` — which\n * keeps the banking arithmetic in one place and means a client cannot post an\n * arbitrary elapsed time to inflate a workout.\n */\nexport const stopwatchActionBodySchema = z.object({\n action: z.enum(['start', 'pause', 'reset']),\n});\nexport type StopwatchActionBody = z.infer<typeof stopwatchActionBodySchema>;\n\n// ─── Attendance ──────────────────────────────────────────────────────────────\n\nexport const attendanceStatusSchema = z.enum(['present', 'absent', 'rest']);\nexport type AttendanceStatus = z.infer<typeof attendanceStatusSchema>;\n\nexport const attendanceEntrySchema = z.object({\n date: isoDateSchema,\n checkInAt: isoDateTimeSchema.nullable(),\n checkOutAt: isoDateTimeSchema.nullable(),\n status: attendanceStatusSchema,\n});\nexport type AttendanceEntry = z.infer<typeof attendanceEntrySchema>;\n\nexport const attendanceQuerySchema = paginationSchema.extend({\n userId: objectIdSchema.optional(),\n from: isoDateSchema.optional(),\n to: isoDateSchema.optional(),\n});\nexport type AttendanceQuery = z.infer<typeof attendanceQuerySchema>;\n\n/** Owner marking attendance for a member or staff member manually. */\nexport const markAttendanceBodySchema = z.object({\n userId: objectIdSchema,\n date: isoDateSchema.optional(),\n status: attendanceStatusSchema.default('present'),\n checkInAt: isoDateTimeSchema.optional(),\n checkOutAt: isoDateTimeSchema.optional(),\n note: optionalTrimmedString(160),\n});\nexport type MarkAttendanceBody = z.infer<typeof markAttendanceBodySchema>;\n"]}
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Workout sessions, check-in & attendance\n * ========================================================\n * A member checks in to start a live session, marks exercises done, and checks\n * out. Sessions roll up into attendance records the owner can review, and can\n * also be recorded directly by an owner (manual attendance marking).\n *\n * @module gymmonk-schema/session\n */\n\nimport { z } from 'zod';\nimport { checkInCodeSchema } from './check-in.js';\nimport {\n isoDateSchema,\n isoDateTimeSchema,\n objectIdSchema,\n optionalTrimmedString,\n paginationSchema,\n} from './common.js';\nimport { checkInGeoInputSchema } from './location.js';\nimport { weekdaySchema } from './shared.js';\n\n// ─── Live session widget status ──────────────────────────────────────────────\n\nexport const sessionStatusSchema = z.enum(['idle', 'active', 'completed']);\nexport type SessionStatus = z.infer<typeof sessionStatusSchema>;\n\n// ─── The set stopwatch ───────────────────────────────────────────────────────\n\n/**\n * The member's own stopwatch, which is NOT the session clock.\n *\n * Home shows two running times and they measure different things. The session\n * clock is \"how long have you been in the gym\" — it runs from `checkInAt`,\n * cannot be paused, and is derived rather than stored. This one is \"how long\n * has this set taken\", and the member starts, pauses and resets it freely.\n *\n * Stored as ACCUMULATED + STARTED-AT rather than as a running total, because a\n * total would need the server to tick. Elapsed is\n * `accumulatedSec + (running ? now - startedAt : 0)`, so a paused stopwatch is\n * a plain number, a running one survives a refresh or a dead battery, and no\n * job has to write to the database once a second.\n *\n * `startedAt` is null exactly when `running` is false; the pair is a small\n * state machine, and the backend is the only writer.\n */\nexport const sessionStopwatchSchema = z.object({\n running: z.boolean(),\n /** Seconds banked by previous runs, excluding any run in progress. */\n accumulatedSec: z.number().int().nonnegative(),\n /** When the current run began. Null while paused. */\n startedAt: isoDateTimeSchema.nullable(),\n});\nexport type SessionStopwatch = z.infer<typeof sessionStopwatchSchema>;\n\n/** A stopwatch that has never been started. */\nexport const IDLE_STOPWATCH: SessionStopwatch = {\n running: false,\n accumulatedSec: 0,\n startedAt: null,\n};\n\n/**\n * Resolve a stopwatch to whole seconds elapsed.\n *\n * Shared rather than reimplemented on each side: the backend needs it to bank\n * time on pause, and the client needs it to render every tick. Two copies of\n * this arithmetic would drift the moment one of them forgot the running run.\n */\nexport function stopwatchElapsedSec(stopwatch: SessionStopwatch, now: Date = new Date()): number {\n if (!stopwatch.running || !stopwatch.startedAt) return stopwatch.accumulatedSec;\n const started = Date.parse(stopwatch.startedAt);\n if (Number.isNaN(started)) return stopwatch.accumulatedSec;\n // Floored at the banked value: a client clock behind the server's would\n // otherwise make the number run backwards.\n return stopwatch.accumulatedSec + Math.max(0, Math.floor((now.getTime() - started) / 1000));\n}\n\n// ─── Session entity ──────────────────────────────────────────────────────────\n\nexport const workoutSessionSchema = z.object({\n id: objectIdSchema,\n userId: objectIdSchema,\n centerId: objectIdSchema,\n status: sessionStatusSchema,\n checkInAt: isoDateTimeSchema,\n checkOutAt: isoDateTimeSchema.nullable(),\n /** The plan weekday being trained, if the member is on a plan. */\n planWeekday: weekdaySchema.nullable(),\n completedExerciseIds: z.array(objectIdSchema),\n totalExercises: z.number().int().nonnegative(),\n stopwatch: sessionStopwatchSchema,\n});\nexport type WorkoutSession = z.infer<typeof workoutSessionSchema>;\n\n// ─── Session mutations ───────────────────────────────────────────────────────\n\n/**\n * Check in by scanning the gym's QR poster.\n *\n * `code` is REQUIRED and is the whole point: the center is resolved FROM the\n * scanned code, never from a client-supplied `centerId`. An earlier version of\n * this body took an optional `centerId`, which meant any authenticated member\n * could mark themselves present from their sofa. Presence has to be proven by\n * being close enough to the poster to photograph it.\n *\n * Staff marking someone present by hand is a different, authorised path —\n * `markAttendanceBodySchema` below.\n */\nexport const checkInBodySchema = z\n .object({\n code: checkInCodeSchema,\n })\n // Where the scanning device thought it was. All three optional — see\n // `checkInGeoInputSchema`. The server measures this against the gym's pin and\n // treats a missing fix as unverifiable rather than as a failure.\n .extend(checkInGeoInputSchema.shape);\nexport type CheckInBody = z.infer<typeof checkInBodySchema>;\n\n/**\n * Check out — the same poster, scanned again. Verified against the session's\n * OWN center, so a member cannot close a session by scanning a different gym.\n */\nexport const checkOutBodySchema = z.object({\n code: checkInCodeSchema,\n});\nexport type CheckOutBody = z.infer<typeof checkOutBodySchema>;\n\nexport const markExerciseDoneBodySchema = z.object({\n exerciseId: objectIdSchema,\n done: z.boolean(),\n});\nexport type MarkExerciseDoneBody = z.infer<typeof markExerciseDoneBodySchema>;\n\n/**\n * Drive the set stopwatch.\n *\n * An ACTION, not a state patch. The client says what the member pressed and the\n * server computes the resulting `{ running, accumulatedSec, startedAt }` — which\n * keeps the banking arithmetic in one place and means a client cannot post an\n * arbitrary elapsed time to inflate a workout.\n */\nexport const stopwatchActionBodySchema = z.object({\n action: z.enum(['start', 'pause', 'reset']),\n});\nexport type StopwatchActionBody = z.infer<typeof stopwatchActionBodySchema>;\n\n// ─── Attendance ──────────────────────────────────────────────────────────────\n\nexport const attendanceStatusSchema = z.enum(['present', 'absent', 'rest']);\nexport type AttendanceStatus = z.infer<typeof attendanceStatusSchema>;\n\n/**\n * How an attendance mark came to be recorded.\n *\n * `scan` — the member scanned the gym's QR poster themselves.\n * `manual` — a person put it there: an owner, manager or front desk.\n * `auto` — nobody did. The member never scanned out and the session hit its\n * cap, so the system closed it (see `AUTO_CHECK_OUT_HOURS`).\n *\n * Recorded per SIDE, because the two halves of a visit routinely differ: the\n * common case is a member who scans in and is closed out automatically. An\n * attendance report that cannot tell a scanned departure from an assumed one is\n * reporting a guess as a measurement.\n */\nexport const attendanceSourceSchema = z.enum(['scan', 'manual', 'auto']);\nexport type AttendanceSource = z.infer<typeof attendanceSourceSchema>;\n\n/**\n * How long a session may stay open before it is closed for the member.\n *\n * Shared with the client so the member's screen can say when their session will\n * end, rather than having the number live only in the server that enforces it.\n */\nexport const AUTO_CHECK_OUT_HOURS = 3;\n\nexport const attendanceEntrySchema = z.object({\n date: isoDateSchema,\n checkInAt: isoDateTimeSchema.nullable(),\n checkOutAt: isoDateTimeSchema.nullable(),\n status: attendanceStatusSchema,\n /** How each half was recorded. Null where that half has not happened. */\n checkInSource: attendanceSourceSchema.nullable(),\n checkOutSource: attendanceSourceSchema.nullable(),\n /**\n * WHO marked it, when a person did — a name, snapshotted at the time.\n *\n * Null for `scan` and `auto`, which have no author. A snapshot rather than a\n * live lookup because this is a record of what happened: a manager who later\n * leaves, or changes their name, must not silently rewrite last month's\n * attendance sheet.\n */\n checkInBy: z.string().nullable(),\n checkOutBy: z.string().nullable(),\n});\nexport type AttendanceEntry = z.infer<typeof attendanceEntrySchema>;\n\nexport const attendanceQuerySchema = paginationSchema.extend({\n userId: objectIdSchema.optional(),\n from: isoDateSchema.optional(),\n to: isoDateSchema.optional(),\n});\nexport type AttendanceQuery = z.infer<typeof attendanceQuerySchema>;\n\n/** Owner marking attendance for a member or staff member manually. */\nexport const markAttendanceBodySchema = z.object({\n userId: objectIdSchema,\n date: isoDateSchema.optional(),\n status: attendanceStatusSchema.default('present'),\n checkInAt: isoDateTimeSchema.optional(),\n checkOutAt: isoDateTimeSchema.optional(),\n note: optionalTrimmedString(160),\n});\nexport type MarkAttendanceBody = z.infer<typeof markAttendanceBodySchema>;\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gymmonk-schema",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Shared Zod schemas, enums and domain types for GymMonk (fitness SaaS) — single source of truth (SSOT) consumed by gymmonk-backend and gymmonk-web-client.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",