gymmonk-schema 0.15.0 → 0.17.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.
package/dist/chat.d.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * gymmonk-schema — Chat
3
+ * =====================
4
+ * THE wire contract between `gymmonk-web-client` and `gymmonk-chat-server`, and
5
+ * the only place either is allowed to name a socket event.
6
+ *
7
+ * It lives here rather than in the chat server because three codebases need it
8
+ * and none of them owns the other two: the client emits, the chat server
9
+ * handles, and gymmonk-backend mints the tokens that let the two meet. An event
10
+ * name that differs by one character between any pair of them produces a
11
+ * message nobody receives and no error anywhere, which is exactly the class of
12
+ * bug a shared package exists to make impossible.
13
+ *
14
+ * @module gymmonk-schema/chat
15
+ */
16
+ import { z } from 'zod';
17
+ /**
18
+ * Every socket event name.
19
+ *
20
+ * String literals rather than an enum, so both sides put the identical bytes on
21
+ * the wire without either importing the other's runtime.
22
+ */
23
+ export declare const CHAT_SOCKET_EVENTS: {
24
+ /** Server → client, on connect. The socket is up and awaiting auth. */
25
+ readonly HELLO: "hello";
26
+ /** Client → server. `{ token }`. Must arrive inside the auth window. */
27
+ readonly AUTH: "auth";
28
+ /** Server → client. `{ userId }` on success. */
29
+ readonly AUTH_OK: "auth:ok";
30
+ /** Server → client. `{ code, message }` for any failure, auth or otherwise. */
31
+ readonly ERROR: "error";
32
+ /** Client → server. Open a conversation and start receiving it live. */
33
+ readonly CONVERSATION_JOIN: "conversation:join";
34
+ /** Client → server. Leave it (navigated away). */
35
+ readonly CONVERSATION_LEAVE: "conversation:leave";
36
+ /** Client → server. `{ conversationId, clientMessageId, text }`. */
37
+ readonly MESSAGE_SEND: "message:send";
38
+ /** Server → everyone in the room, and the recipients' own rooms. */
39
+ readonly MESSAGE_NEW: "message:new";
40
+ /** Server → sender only. Their send, acknowledged and stored. */
41
+ readonly MESSAGE_SENT: "message:sent";
42
+ /** Client → server. Mark everything up to now as read. */
43
+ readonly MESSAGE_READ: "message:read";
44
+ /** Server → the room. `{ conversationId, userId, readAt }`. */
45
+ readonly MESSAGE_READ_BY: "message:read-by";
46
+ /** Client → server. `{ conversationId, typing }`. */
47
+ readonly TYPING: "typing";
48
+ /** Server → the other participants. `{ conversationId, userId, typing }`. */
49
+ readonly TYPING_UPDATE: "typing:update";
50
+ /** Server → the room. Who currently has this conversation open. */
51
+ readonly PRESENCE_UPDATE: "presence:update";
52
+ };
53
+ export type ChatSocketEvent = (typeof CHAT_SOCKET_EVENTS)[keyof typeof CHAT_SOCKET_EVENTS];
54
+ /** Stable codes on the `error` event. The client branches on these; the message is for logs. */
55
+ export declare const CHAT_ERROR: {
56
+ readonly AUTH_TIMEOUT: "AUTH_TIMEOUT";
57
+ readonly AUTH_FAILED: "AUTH_FAILED";
58
+ readonly NOT_AUTHENTICATED: "NOT_AUTHENTICATED";
59
+ readonly NOT_A_PARTICIPANT: "NOT_A_PARTICIPANT";
60
+ readonly CONVERSATION_NOT_FOUND: "CONVERSATION_NOT_FOUND";
61
+ readonly INVALID_PAYLOAD: "INVALID_PAYLOAD";
62
+ readonly RATE_LIMITED: "RATE_LIMITED";
63
+ readonly SERVER_ERROR: "SERVER_ERROR";
64
+ };
65
+ export type ChatErrorCode = (typeof CHAT_ERROR)[keyof typeof CHAT_ERROR];
66
+ /** Longest message body accepted. Matches the column cap in the model. */
67
+ export declare const MAX_MESSAGE_LENGTH = 4000;
68
+ /** Messages per page of history. */
69
+ export declare const MESSAGE_PAGE_SIZE = 50;
70
+ /**
71
+ * Messages one socket may send per minute.
72
+ *
73
+ * Not an anti-abuse boundary on its own: a member can only ever write to a
74
+ * conversation their own backend authorised. This is what stops a broken client
75
+ * looping a send from filling a collection.
76
+ */
77
+ export declare const MESSAGE_RATE_PER_MINUTE = 60;
78
+ /** How long an unauthenticated socket may hold a connection, in ms. */
79
+ export declare const CHAT_AUTH_TIMEOUT_MS = 10000;
80
+ /**
81
+ * How long a typing indicator survives without a refresh, in ms.
82
+ *
83
+ * Someone who closes the app mid-sentence never sends `typing: false`, so the
84
+ * indicator has to expire on its own or it stays on screen forever. The sender
85
+ * re-emits well inside this window while they are still typing.
86
+ */
87
+ export declare const TYPING_TIMEOUT_MS = 4000;
88
+ /** How often the sender re-emits `typing: true` while typing, in ms. */
89
+ export declare const TYPING_THROTTLE_MS = 2000;
90
+ /** A participant, denormalised onto the conversation so a list renders without joins. */
91
+ export declare const chatParticipantSchema: z.ZodObject<{
92
+ userId: z.ZodString;
93
+ displayName: z.ZodString;
94
+ avatarUrl: z.ZodNullable<z.ZodString>;
95
+ }, z.core.$strip>;
96
+ export type ChatParticipant = z.infer<typeof chatParticipantSchema>;
97
+ /** A message as every client sees it. */
98
+ export declare const chatMessageSchema: z.ZodObject<{
99
+ id: z.ZodString;
100
+ conversationId: z.ZodString;
101
+ senderId: z.ZodString;
102
+ text: z.ZodString;
103
+ clientMessageId: z.ZodString;
104
+ sentAt: z.ZodISODateTime;
105
+ readAt: z.ZodNullable<z.ZodISODateTime>;
106
+ deleted: z.ZodBoolean;
107
+ }, z.core.$strip>;
108
+ export type ChatMessage = z.infer<typeof chatMessageSchema>;
109
+ /** The one-line preview a conversation row shows. */
110
+ export declare const chatLastMessageSchema: z.ZodObject<{
111
+ text: z.ZodString;
112
+ senderId: z.ZodString;
113
+ sentAt: z.ZodISODateTime;
114
+ }, z.core.$strip>;
115
+ export type ChatLastMessage = z.infer<typeof chatLastMessageSchema>;
116
+ /**
117
+ * A conversation as the list screen sees it.
118
+ *
119
+ * `unreadCount` is the CALLER's, not a global figure: the same row means
120
+ * different things to the two people in it.
121
+ */
122
+ export declare const chatConversationSchema: z.ZodObject<{
123
+ id: z.ZodString;
124
+ participants: z.ZodArray<z.ZodObject<{
125
+ userId: z.ZodString;
126
+ displayName: z.ZodString;
127
+ avatarUrl: z.ZodNullable<z.ZodString>;
128
+ }, z.core.$strip>>;
129
+ lastMessage: z.ZodNullable<z.ZodObject<{
130
+ text: z.ZodString;
131
+ senderId: z.ZodString;
132
+ sentAt: z.ZodISODateTime;
133
+ }, z.core.$strip>>;
134
+ unreadCount: z.ZodNumber;
135
+ createdAt: z.ZodISODateTime;
136
+ updatedAt: z.ZodISODateTime;
137
+ frozen: z.ZodBoolean;
138
+ }, z.core.$strip>;
139
+ export type ChatConversation = z.infer<typeof chatConversationSchema>;
140
+ export interface ChatAuthPayload {
141
+ token: string;
142
+ }
143
+ export interface ChatConversationRoomPayload {
144
+ conversationId: string;
145
+ }
146
+ export interface ChatMessageSendPayload {
147
+ conversationId: string;
148
+ /** Client-generated idempotency key. A retry with the same value is not a second message. */
149
+ clientMessageId: string;
150
+ text: string;
151
+ }
152
+ export interface ChatMessageReadPayload {
153
+ conversationId: string;
154
+ }
155
+ export interface ChatTypingPayload {
156
+ conversationId: string;
157
+ typing: boolean;
158
+ }
159
+ export interface ChatServerToClientEvents {
160
+ [CHAT_SOCKET_EVENTS.HELLO]: (payload: {
161
+ message: string;
162
+ }) => void;
163
+ [CHAT_SOCKET_EVENTS.AUTH_OK]: (payload: {
164
+ userId: string;
165
+ }) => void;
166
+ [CHAT_SOCKET_EVENTS.ERROR]: (payload: {
167
+ code: string;
168
+ message: string;
169
+ }) => void;
170
+ [CHAT_SOCKET_EVENTS.MESSAGE_NEW]: (message: ChatMessage) => void;
171
+ [CHAT_SOCKET_EVENTS.MESSAGE_SENT]: (payload: {
172
+ clientMessageId: string;
173
+ message: ChatMessage;
174
+ }) => void;
175
+ [CHAT_SOCKET_EVENTS.MESSAGE_READ_BY]: (payload: {
176
+ conversationId: string;
177
+ userId: string;
178
+ readAt: string;
179
+ }) => void;
180
+ [CHAT_SOCKET_EVENTS.TYPING_UPDATE]: (payload: {
181
+ conversationId: string;
182
+ userId: string;
183
+ typing: boolean;
184
+ }) => void;
185
+ [CHAT_SOCKET_EVENTS.PRESENCE_UPDATE]: (payload: {
186
+ conversationId: string;
187
+ userIds: string[];
188
+ }) => void;
189
+ }
190
+ export interface ChatClientToServerEvents {
191
+ [CHAT_SOCKET_EVENTS.AUTH]: (payload: ChatAuthPayload) => void;
192
+ [CHAT_SOCKET_EVENTS.CONVERSATION_JOIN]: (payload: ChatConversationRoomPayload) => void;
193
+ [CHAT_SOCKET_EVENTS.CONVERSATION_LEAVE]: (payload: ChatConversationRoomPayload) => void;
194
+ [CHAT_SOCKET_EVENTS.MESSAGE_SEND]: (payload: ChatMessageSendPayload) => void;
195
+ [CHAT_SOCKET_EVENTS.MESSAGE_READ]: (payload: ChatMessageReadPayload) => void;
196
+ [CHAT_SOCKET_EVENTS.TYPING]: (payload: ChatTypingPayload) => void;
197
+ }
198
+ /** `GET /api/v1/chat/config` — where chat lives, whether it is on, and who the caller is. */
199
+ export declare const chatConfigSchema: z.ZodObject<{
200
+ enabled: z.ZodBoolean;
201
+ url: z.ZodNullable<z.ZodString>;
202
+ userId: z.ZodString;
203
+ }, z.core.$strip>;
204
+ export type ChatConfig = z.infer<typeof chatConfigSchema>;
205
+ /** `POST /api/v1/chat/auth-token` */
206
+ export declare const chatAuthTokenSchema: z.ZodObject<{
207
+ token: z.ZodString;
208
+ expiresAt: z.ZodISODateTime;
209
+ expiresIn: z.ZodNumber;
210
+ }, z.core.$strip>;
211
+ export type ChatAuthToken = z.infer<typeof chatAuthTokenSchema>;
212
+ /** `POST /api/v1/chat/invitation` — permission to open a conversation with someone. */
213
+ export declare const chatInvitationBodySchema: z.ZodObject<{
214
+ userId: z.ZodString;
215
+ }, z.core.$strip>;
216
+ export type ChatInvitationBody = z.infer<typeof chatInvitationBodySchema>;
217
+ export declare const chatInvitationSchema: z.ZodObject<{
218
+ invitationToken: z.ZodString;
219
+ expiresAt: z.ZodISODateTime;
220
+ }, z.core.$strip>;
221
+ export type ChatInvitation = z.infer<typeof chatInvitationSchema>;
222
+ /** `POST /api/v1/chat/notify` — chat-server telling the backend to notify people. */
223
+ export declare const chatNotifyBodySchema: z.ZodObject<{
224
+ recipientIds: z.ZodArray<z.ZodString>;
225
+ conversationId: z.ZodString;
226
+ senderId: z.ZodString;
227
+ senderName: z.ZodString;
228
+ text: z.ZodString;
229
+ }, z.core.$strip>;
230
+ export type ChatNotifyBody = z.infer<typeof chatNotifyBodySchema>;
231
+ /** JWT `aud` on every chat token. chat-server accepts exactly this. */
232
+ export declare const CHAT_TOKEN_AUDIENCE = "gymmonk-chat-server";
233
+ /** `purpose` claim marking an invitation. Auth tokens carry no purpose. */
234
+ export declare const CHAT_INVITATION_PURPOSE = "invitation";
235
+ //# sourceMappingURL=chat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../src/chat.ts"],"names":[],"mappings":"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"}
package/dist/chat.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * gymmonk-schema — Chat
3
+ * =====================
4
+ * THE wire contract between `gymmonk-web-client` and `gymmonk-chat-server`, and
5
+ * the only place either is allowed to name a socket event.
6
+ *
7
+ * It lives here rather than in the chat server because three codebases need it
8
+ * and none of them owns the other two: the client emits, the chat server
9
+ * handles, and gymmonk-backend mints the tokens that let the two meet. An event
10
+ * name that differs by one character between any pair of them produces a
11
+ * message nobody receives and no error anywhere, which is exactly the class of
12
+ * bug a shared package exists to make impossible.
13
+ *
14
+ * @module gymmonk-schema/chat
15
+ */
16
+ import { z } from 'zod';
17
+ import { isoDateTimeSchema, objectIdSchema } from './common.js';
18
+ // ─── Socket events ───────────────────────────────────────────────────────────
19
+ /**
20
+ * Every socket event name.
21
+ *
22
+ * String literals rather than an enum, so both sides put the identical bytes on
23
+ * the wire without either importing the other's runtime.
24
+ */
25
+ export const CHAT_SOCKET_EVENTS = {
26
+ /** Server → client, on connect. The socket is up and awaiting auth. */
27
+ HELLO: 'hello',
28
+ /** Client → server. `{ token }`. Must arrive inside the auth window. */
29
+ AUTH: 'auth',
30
+ /** Server → client. `{ userId }` on success. */
31
+ AUTH_OK: 'auth:ok',
32
+ /** Server → client. `{ code, message }` for any failure, auth or otherwise. */
33
+ ERROR: 'error',
34
+ /** Client → server. Open a conversation and start receiving it live. */
35
+ CONVERSATION_JOIN: 'conversation:join',
36
+ /** Client → server. Leave it (navigated away). */
37
+ CONVERSATION_LEAVE: 'conversation:leave',
38
+ /** Client → server. `{ conversationId, clientMessageId, text }`. */
39
+ MESSAGE_SEND: 'message:send',
40
+ /** Server → everyone in the room, and the recipients' own rooms. */
41
+ MESSAGE_NEW: 'message:new',
42
+ /** Server → sender only. Their send, acknowledged and stored. */
43
+ MESSAGE_SENT: 'message:sent',
44
+ /** Client → server. Mark everything up to now as read. */
45
+ MESSAGE_READ: 'message:read',
46
+ /** Server → the room. `{ conversationId, userId, readAt }`. */
47
+ MESSAGE_READ_BY: 'message:read-by',
48
+ /** Client → server. `{ conversationId, typing }`. */
49
+ TYPING: 'typing',
50
+ /** Server → the other participants. `{ conversationId, userId, typing }`. */
51
+ TYPING_UPDATE: 'typing:update',
52
+ /** Server → the room. Who currently has this conversation open. */
53
+ PRESENCE_UPDATE: 'presence:update',
54
+ };
55
+ // ─── Error codes ─────────────────────────────────────────────────────────────
56
+ /** Stable codes on the `error` event. The client branches on these; the message is for logs. */
57
+ export const CHAT_ERROR = {
58
+ AUTH_TIMEOUT: 'AUTH_TIMEOUT',
59
+ AUTH_FAILED: 'AUTH_FAILED',
60
+ NOT_AUTHENTICATED: 'NOT_AUTHENTICATED',
61
+ NOT_A_PARTICIPANT: 'NOT_A_PARTICIPANT',
62
+ CONVERSATION_NOT_FOUND: 'CONVERSATION_NOT_FOUND',
63
+ INVALID_PAYLOAD: 'INVALID_PAYLOAD',
64
+ RATE_LIMITED: 'RATE_LIMITED',
65
+ SERVER_ERROR: 'SERVER_ERROR',
66
+ };
67
+ // ─── Limits ──────────────────────────────────────────────────────────────────
68
+ /** Longest message body accepted. Matches the column cap in the model. */
69
+ export const MAX_MESSAGE_LENGTH = 4000;
70
+ /** Messages per page of history. */
71
+ export const MESSAGE_PAGE_SIZE = 50;
72
+ /**
73
+ * Messages one socket may send per minute.
74
+ *
75
+ * Not an anti-abuse boundary on its own: a member can only ever write to a
76
+ * conversation their own backend authorised. This is what stops a broken client
77
+ * looping a send from filling a collection.
78
+ */
79
+ export const MESSAGE_RATE_PER_MINUTE = 60;
80
+ /** How long an unauthenticated socket may hold a connection, in ms. */
81
+ export const CHAT_AUTH_TIMEOUT_MS = 10_000;
82
+ /**
83
+ * How long a typing indicator survives without a refresh, in ms.
84
+ *
85
+ * Someone who closes the app mid-sentence never sends `typing: false`, so the
86
+ * indicator has to expire on its own or it stays on screen forever. The sender
87
+ * re-emits well inside this window while they are still typing.
88
+ */
89
+ export const TYPING_TIMEOUT_MS = 4_000;
90
+ /** How often the sender re-emits `typing: true` while typing, in ms. */
91
+ export const TYPING_THROTTLE_MS = 2_000;
92
+ // ─── Wire shapes ─────────────────────────────────────────────────────────────
93
+ /** A participant, denormalised onto the conversation so a list renders without joins. */
94
+ export const chatParticipantSchema = z.object({
95
+ userId: objectIdSchema,
96
+ displayName: z.string(),
97
+ avatarUrl: z.string().nullable(),
98
+ });
99
+ /** A message as every client sees it. */
100
+ export const chatMessageSchema = z.object({
101
+ id: objectIdSchema,
102
+ conversationId: objectIdSchema,
103
+ senderId: objectIdSchema,
104
+ text: z.string(),
105
+ /** Echoed back so the sender can reconcile its optimistic row. */
106
+ clientMessageId: z.string(),
107
+ sentAt: isoDateTimeSchema,
108
+ readAt: isoDateTimeSchema.nullable(),
109
+ /** Deleted for everyone. `text` is then empty. */
110
+ deleted: z.boolean(),
111
+ });
112
+ /** The one-line preview a conversation row shows. */
113
+ export const chatLastMessageSchema = z.object({
114
+ text: z.string(),
115
+ senderId: objectIdSchema,
116
+ sentAt: isoDateTimeSchema,
117
+ });
118
+ /**
119
+ * A conversation as the list screen sees it.
120
+ *
121
+ * `unreadCount` is the CALLER's, not a global figure: the same row means
122
+ * different things to the two people in it.
123
+ */
124
+ export const chatConversationSchema = z.object({
125
+ id: objectIdSchema,
126
+ participants: z.array(chatParticipantSchema),
127
+ lastMessage: chatLastMessageSchema.nullable(),
128
+ unreadCount: z.number().int().nonnegative(),
129
+ createdAt: isoDateTimeSchema,
130
+ /** Last activity. The inbox sorts on this, newest first. */
131
+ updatedAt: isoDateTimeSchema,
132
+ /**
133
+ * Closed to new messages. True once one participant blocks the other.
134
+ *
135
+ * History still reads: freezing stops the next message, and deleting what was
136
+ * already said is a different and much larger decision. The client uses this
137
+ * to disable its composer rather than letting a send fail at the server.
138
+ */
139
+ frozen: z.boolean(),
140
+ });
141
+ // ─── Backend bridge ──────────────────────────────────────────────────────────
142
+ /** `GET /api/v1/chat/config` — where chat lives, whether it is on, and who the caller is. */
143
+ export const chatConfigSchema = z.object({
144
+ enabled: z.boolean(),
145
+ url: z.string().nullable(),
146
+ /**
147
+ * The caller's id AS CHAT-SERVER WILL SEE IT.
148
+ *
149
+ * Sourced from the same place that mints the token rather than from the
150
+ * client session, because the two must agree or "is this message mine" is
151
+ * wrong for somebody.
152
+ */
153
+ userId: objectIdSchema,
154
+ });
155
+ /** `POST /api/v1/chat/auth-token` */
156
+ export const chatAuthTokenSchema = z.object({
157
+ token: z.string(),
158
+ expiresAt: isoDateTimeSchema,
159
+ /** Seconds, so a client can schedule a refresh without parsing a date. */
160
+ expiresIn: z.number().int().positive(),
161
+ });
162
+ /** `POST /api/v1/chat/invitation` — permission to open a conversation with someone. */
163
+ export const chatInvitationBodySchema = z.object({
164
+ userId: objectIdSchema,
165
+ });
166
+ export const chatInvitationSchema = z.object({
167
+ invitationToken: z.string(),
168
+ expiresAt: isoDateTimeSchema,
169
+ });
170
+ /** `POST /api/v1/chat/notify` — chat-server telling the backend to notify people. */
171
+ export const chatNotifyBodySchema = z.object({
172
+ recipientIds: z.array(objectIdSchema).min(1),
173
+ conversationId: objectIdSchema,
174
+ senderId: objectIdSchema,
175
+ senderName: z.string(),
176
+ text: z.string(),
177
+ });
178
+ /** JWT `aud` on every chat token. chat-server accepts exactly this. */
179
+ export const CHAT_TOKEN_AUDIENCE = 'gymmonk-chat-server';
180
+ /** `purpose` claim marking an invitation. Auth tokens carry no purpose. */
181
+ export const CHAT_INVITATION_PURPOSE = 'invitation';
182
+ //# sourceMappingURL=chat.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat.js","sourceRoot":"","sources":["../src/chat.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Chat\n * =====================\n * THE wire contract between `gymmonk-web-client` and `gymmonk-chat-server`, and\n * the only place either is allowed to name a socket event.\n *\n * It lives here rather than in the chat server because three codebases need it\n * and none of them owns the other two: the client emits, the chat server\n * handles, and gymmonk-backend mints the tokens that let the two meet. An event\n * name that differs by one character between any pair of them produces a\n * message nobody receives and no error anywhere, which is exactly the class of\n * bug a shared package exists to make impossible.\n *\n * @module gymmonk-schema/chat\n */\n\nimport { z } from 'zod';\nimport { isoDateTimeSchema, objectIdSchema } from './common.js';\n\n// ─── Socket events ───────────────────────────────────────────────────────────\n\n/**\n * Every socket event name.\n *\n * String literals rather than an enum, so both sides put the identical bytes on\n * the wire without either importing the other's runtime.\n */\nexport const CHAT_SOCKET_EVENTS = {\n /** Server → client, on connect. The socket is up and awaiting auth. */\n HELLO: 'hello',\n /** Client → server. `{ token }`. Must arrive inside the auth window. */\n AUTH: 'auth',\n /** Server → client. `{ userId }` on success. */\n AUTH_OK: 'auth:ok',\n /** Server → client. `{ code, message }` for any failure, auth or otherwise. */\n ERROR: 'error',\n\n /** Client → server. Open a conversation and start receiving it live. */\n CONVERSATION_JOIN: 'conversation:join',\n /** Client → server. Leave it (navigated away). */\n CONVERSATION_LEAVE: 'conversation:leave',\n\n /** Client → server. `{ conversationId, clientMessageId, text }`. */\n MESSAGE_SEND: 'message:send',\n /** Server → everyone in the room, and the recipients' own rooms. */\n MESSAGE_NEW: 'message:new',\n /** Server → sender only. Their send, acknowledged and stored. */\n MESSAGE_SENT: 'message:sent',\n /** Client → server. Mark everything up to now as read. */\n MESSAGE_READ: 'message:read',\n /** Server → the room. `{ conversationId, userId, readAt }`. */\n MESSAGE_READ_BY: 'message:read-by',\n\n /** Client → server. `{ conversationId, typing }`. */\n TYPING: 'typing',\n /** Server → the other participants. `{ conversationId, userId, typing }`. */\n TYPING_UPDATE: 'typing:update',\n\n /** Server → the room. Who currently has this conversation open. */\n PRESENCE_UPDATE: 'presence:update',\n} as const;\n\nexport type ChatSocketEvent = (typeof CHAT_SOCKET_EVENTS)[keyof typeof CHAT_SOCKET_EVENTS];\n\n// ─── Error codes ─────────────────────────────────────────────────────────────\n\n/** Stable codes on the `error` event. The client branches on these; the message is for logs. */\nexport const CHAT_ERROR = {\n AUTH_TIMEOUT: 'AUTH_TIMEOUT',\n AUTH_FAILED: 'AUTH_FAILED',\n NOT_AUTHENTICATED: 'NOT_AUTHENTICATED',\n NOT_A_PARTICIPANT: 'NOT_A_PARTICIPANT',\n CONVERSATION_NOT_FOUND: 'CONVERSATION_NOT_FOUND',\n INVALID_PAYLOAD: 'INVALID_PAYLOAD',\n RATE_LIMITED: 'RATE_LIMITED',\n SERVER_ERROR: 'SERVER_ERROR',\n} as const;\n\nexport type ChatErrorCode = (typeof CHAT_ERROR)[keyof typeof CHAT_ERROR];\n\n// ─── Limits ──────────────────────────────────────────────────────────────────\n\n/** Longest message body accepted. Matches the column cap in the model. */\nexport const MAX_MESSAGE_LENGTH = 4000;\n\n/** Messages per page of history. */\nexport const MESSAGE_PAGE_SIZE = 50;\n\n/**\n * Messages one socket may send per minute.\n *\n * Not an anti-abuse boundary on its own: a member can only ever write to a\n * conversation their own backend authorised. This is what stops a broken client\n * looping a send from filling a collection.\n */\nexport const MESSAGE_RATE_PER_MINUTE = 60;\n\n/** How long an unauthenticated socket may hold a connection, in ms. */\nexport const CHAT_AUTH_TIMEOUT_MS = 10_000;\n\n/**\n * How long a typing indicator survives without a refresh, in ms.\n *\n * Someone who closes the app mid-sentence never sends `typing: false`, so the\n * indicator has to expire on its own or it stays on screen forever. The sender\n * re-emits well inside this window while they are still typing.\n */\nexport const TYPING_TIMEOUT_MS = 4_000;\n\n/** How often the sender re-emits `typing: true` while typing, in ms. */\nexport const TYPING_THROTTLE_MS = 2_000;\n\n// ─── Wire shapes ─────────────────────────────────────────────────────────────\n\n/** A participant, denormalised onto the conversation so a list renders without joins. */\nexport const chatParticipantSchema = z.object({\n userId: objectIdSchema,\n displayName: z.string(),\n avatarUrl: z.string().nullable(),\n});\nexport type ChatParticipant = z.infer<typeof chatParticipantSchema>;\n\n/** A message as every client sees it. */\nexport const chatMessageSchema = z.object({\n id: objectIdSchema,\n conversationId: objectIdSchema,\n senderId: objectIdSchema,\n text: z.string(),\n /** Echoed back so the sender can reconcile its optimistic row. */\n clientMessageId: z.string(),\n sentAt: isoDateTimeSchema,\n readAt: isoDateTimeSchema.nullable(),\n /** Deleted for everyone. `text` is then empty. */\n deleted: z.boolean(),\n});\nexport type ChatMessage = z.infer<typeof chatMessageSchema>;\n\n/** The one-line preview a conversation row shows. */\nexport const chatLastMessageSchema = z.object({\n text: z.string(),\n senderId: objectIdSchema,\n sentAt: isoDateTimeSchema,\n});\nexport type ChatLastMessage = z.infer<typeof chatLastMessageSchema>;\n\n/**\n * A conversation as the list screen sees it.\n *\n * `unreadCount` is the CALLER's, not a global figure: the same row means\n * different things to the two people in it.\n */\nexport const chatConversationSchema = z.object({\n id: objectIdSchema,\n participants: z.array(chatParticipantSchema),\n lastMessage: chatLastMessageSchema.nullable(),\n unreadCount: z.number().int().nonnegative(),\n createdAt: isoDateTimeSchema,\n /** Last activity. The inbox sorts on this, newest first. */\n updatedAt: isoDateTimeSchema,\n});\nexport type ChatConversation = z.infer<typeof chatConversationSchema>;\n\n// ─── Event payloads ──────────────────────────────────────────────────────────\n\nexport interface ChatAuthPayload {\n token: string;\n}\n\nexport interface ChatConversationRoomPayload {\n conversationId: string;\n}\n\nexport interface ChatMessageSendPayload {\n conversationId: string;\n /** Client-generated idempotency key. A retry with the same value is not a second message. */\n clientMessageId: string;\n text: string;\n}\n\nexport interface ChatMessageReadPayload {\n conversationId: string;\n}\n\nexport interface ChatTypingPayload {\n conversationId: string;\n typing: boolean;\n}\n\n// ─── Typed event maps ────────────────────────────────────────────────────────\n// Socket.io takes these as generics on both ends, so an event emitted with the\n// wrong payload is a compile error rather than a message nobody receives.\n\nexport interface ChatServerToClientEvents {\n [CHAT_SOCKET_EVENTS.HELLO]: (payload: { message: string }) => void;\n [CHAT_SOCKET_EVENTS.AUTH_OK]: (payload: { userId: string }) => void;\n [CHAT_SOCKET_EVENTS.ERROR]: (payload: { code: string; message: string }) => void;\n [CHAT_SOCKET_EVENTS.MESSAGE_NEW]: (message: ChatMessage) => void;\n [CHAT_SOCKET_EVENTS.MESSAGE_SENT]: (payload: {\n clientMessageId: string;\n message: ChatMessage;\n }) => void;\n [CHAT_SOCKET_EVENTS.MESSAGE_READ_BY]: (payload: {\n conversationId: string;\n userId: string;\n readAt: string;\n }) => void;\n [CHAT_SOCKET_EVENTS.TYPING_UPDATE]: (payload: {\n conversationId: string;\n userId: string;\n typing: boolean;\n }) => void;\n [CHAT_SOCKET_EVENTS.PRESENCE_UPDATE]: (payload: {\n conversationId: string;\n userIds: string[];\n }) => void;\n}\n\nexport interface ChatClientToServerEvents {\n [CHAT_SOCKET_EVENTS.AUTH]: (payload: ChatAuthPayload) => void;\n [CHAT_SOCKET_EVENTS.CONVERSATION_JOIN]: (payload: ChatConversationRoomPayload) => void;\n [CHAT_SOCKET_EVENTS.CONVERSATION_LEAVE]: (payload: ChatConversationRoomPayload) => void;\n [CHAT_SOCKET_EVENTS.MESSAGE_SEND]: (payload: ChatMessageSendPayload) => void;\n [CHAT_SOCKET_EVENTS.MESSAGE_READ]: (payload: ChatMessageReadPayload) => void;\n [CHAT_SOCKET_EVENTS.TYPING]: (payload: ChatTypingPayload) => void;\n}\n\n// ─── Backend bridge ──────────────────────────────────────────────────────────\n\n/** `GET /api/v1/chat/config` — where chat lives, whether it is on, and who the caller is. */\nexport const chatConfigSchema = z.object({\n enabled: z.boolean(),\n url: z.string().nullable(),\n /**\n * The caller's id AS CHAT-SERVER WILL SEE IT.\n *\n * Sourced from the same place that mints the token rather than from the\n * client session, because the two must agree or \"is this message mine\" is\n * wrong for somebody.\n */\n userId: objectIdSchema,\n});\nexport type ChatConfig = z.infer<typeof chatConfigSchema>;\n\n/** `POST /api/v1/chat/auth-token` */\nexport const chatAuthTokenSchema = z.object({\n token: z.string(),\n expiresAt: isoDateTimeSchema,\n /** Seconds, so a client can schedule a refresh without parsing a date. */\n expiresIn: z.number().int().positive(),\n});\nexport type ChatAuthToken = z.infer<typeof chatAuthTokenSchema>;\n\n/** `POST /api/v1/chat/invitation` — permission to open a conversation with someone. */\nexport const chatInvitationBodySchema = z.object({\n userId: objectIdSchema,\n});\nexport type ChatInvitationBody = z.infer<typeof chatInvitationBodySchema>;\n\nexport const chatInvitationSchema = z.object({\n invitationToken: z.string(),\n expiresAt: isoDateTimeSchema,\n});\nexport type ChatInvitation = z.infer<typeof chatInvitationSchema>;\n\n/** `POST /api/v1/chat/notify` — chat-server telling the backend to notify people. */\nexport const chatNotifyBodySchema = z.object({\n recipientIds: z.array(objectIdSchema).min(1),\n conversationId: objectIdSchema,\n senderId: objectIdSchema,\n senderName: z.string(),\n text: z.string(),\n});\nexport type ChatNotifyBody = z.infer<typeof chatNotifyBodySchema>;\n\n/** JWT `aud` on every chat token. chat-server accepts exactly this. */\nexport const CHAT_TOKEN_AUDIENCE = 'gymmonk-chat-server';\n\n/** `purpose` claim marking an invitation. Auth tokens carry no purpose. */\nexport const CHAT_INVITATION_PURPOSE = 'invitation';\n"]}
package/dist/index.d.ts CHANGED
@@ -40,4 +40,6 @@ export * from './staff.js';
40
40
  export * from './subscription.js';
41
41
  export * from './user.js';
42
42
  export * from './workout-plan.js';
43
- //# sourceMappingURL=index.d.ts.map
43
+ //# sourceMappingURL=index.d.ts.map
44
+ export * from './chat.js';
45
+ export * from './social.js';
package/dist/index.js CHANGED
@@ -54,4 +54,6 @@ export * from './staff.js';
54
54
  export * from './subscription.js';
55
55
  export * from './user.js';
56
56
  export * from './workout-plan.js';
57
- //# sourceMappingURL=index.js.map
57
+ //# sourceMappingURL=index.js.map
58
+ export * from './chat.js';
59
+ export * from './social.js';
package/dist/session.d.ts CHANGED
@@ -14,6 +14,39 @@ export declare const sessionStatusSchema: z.ZodEnum<{
14
14
  completed: "completed";
15
15
  }>;
16
16
  export type SessionStatus = z.infer<typeof sessionStatusSchema>;
17
+ /**
18
+ * The member's own stopwatch, which is NOT the session clock.
19
+ *
20
+ * Home shows two running times and they measure different things. The session
21
+ * clock is "how long have you been in the gym" — it runs from `checkInAt`,
22
+ * cannot be paused, and is derived rather than stored. This one is "how long
23
+ * has this set taken", and the member starts, pauses and resets it freely.
24
+ *
25
+ * Stored as ACCUMULATED + STARTED-AT rather than as a running total, because a
26
+ * total would need the server to tick. Elapsed is
27
+ * `accumulatedSec + (running ? now - startedAt : 0)`, so a paused stopwatch is
28
+ * a plain number, a running one survives a refresh or a dead battery, and no
29
+ * job has to write to the database once a second.
30
+ *
31
+ * `startedAt` is null exactly when `running` is false; the pair is a small
32
+ * state machine, and the backend is the only writer.
33
+ */
34
+ export declare const sessionStopwatchSchema: z.ZodObject<{
35
+ running: z.ZodBoolean;
36
+ accumulatedSec: z.ZodNumber;
37
+ startedAt: z.ZodNullable<z.ZodISODateTime>;
38
+ }, z.core.$strip>;
39
+ export type SessionStopwatch = z.infer<typeof sessionStopwatchSchema>;
40
+ /** A stopwatch that has never been started. */
41
+ export declare const IDLE_STOPWATCH: SessionStopwatch;
42
+ /**
43
+ * Resolve a stopwatch to whole seconds elapsed.
44
+ *
45
+ * Shared rather than reimplemented on each side: the backend needs it to bank
46
+ * time on pause, and the client needs it to render every tick. Two copies of
47
+ * this arithmetic would drift the moment one of them forgot the running run.
48
+ */
49
+ export declare function stopwatchElapsedSec(stopwatch: SessionStopwatch, now?: Date): number;
17
50
  export declare const workoutSessionSchema: z.ZodObject<{
18
51
  id: z.ZodString;
19
52
  userId: z.ZodString;
@@ -36,6 +69,11 @@ export declare const workoutSessionSchema: z.ZodObject<{
36
69
  }>>;
37
70
  completedExerciseIds: z.ZodArray<z.ZodString>;
38
71
  totalExercises: z.ZodNumber;
72
+ stopwatch: z.ZodObject<{
73
+ running: z.ZodBoolean;
74
+ accumulatedSec: z.ZodNumber;
75
+ startedAt: z.ZodNullable<z.ZodISODateTime>;
76
+ }, z.core.$strip>;
39
77
  }, z.core.$strip>;
40
78
  export type WorkoutSession = z.infer<typeof workoutSessionSchema>;
41
79
  /**
@@ -67,6 +105,22 @@ export declare const markExerciseDoneBodySchema: z.ZodObject<{
67
105
  done: z.ZodBoolean;
68
106
  }, z.core.$strip>;
69
107
  export type MarkExerciseDoneBody = z.infer<typeof markExerciseDoneBodySchema>;
108
+ /**
109
+ * Drive the set stopwatch.
110
+ *
111
+ * An ACTION, not a state patch. The client says what the member pressed and the
112
+ * server computes the resulting `{ running, accumulatedSec, startedAt }` — which
113
+ * keeps the banking arithmetic in one place and means a client cannot post an
114
+ * arbitrary elapsed time to inflate a workout.
115
+ */
116
+ export declare const stopwatchActionBodySchema: z.ZodObject<{
117
+ action: z.ZodEnum<{
118
+ start: "start";
119
+ pause: "pause";
120
+ reset: "reset";
121
+ }>;
122
+ }, z.core.$strip>;
123
+ export type StopwatchActionBody = z.infer<typeof stopwatchActionBodySchema>;
70
124
  export declare const attendanceStatusSchema: z.ZodEnum<{
71
125
  present: "present";
72
126
  absent: "absent";
@@ -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;AAaxB,eAAO,MAAM,mBAAmB;;;;EAA0C,CAAC;AAC3E,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;iBAW/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB;;iBAE5B,CAAC;AACH,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;AAI9E,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;AAaxB,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;;iBAE5B,CAAC;AACH,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"}
package/dist/session.js CHANGED
@@ -13,6 +13,54 @@ import { isoDateSchema, isoDateTimeSchema, objectIdSchema, optionalTrimmedString
13
13
  import { weekdaySchema } from './shared.js';
14
14
  // ─── Live session widget status ──────────────────────────────────────────────
15
15
  export const sessionStatusSchema = z.enum(['idle', 'active', 'completed']);
16
+ // ─── The set stopwatch ───────────────────────────────────────────────────────
17
+ /**
18
+ * The member's own stopwatch, which is NOT the session clock.
19
+ *
20
+ * Home shows two running times and they measure different things. The session
21
+ * clock is "how long have you been in the gym" — it runs from `checkInAt`,
22
+ * cannot be paused, and is derived rather than stored. This one is "how long
23
+ * has this set taken", and the member starts, pauses and resets it freely.
24
+ *
25
+ * Stored as ACCUMULATED + STARTED-AT rather than as a running total, because a
26
+ * total would need the server to tick. Elapsed is
27
+ * `accumulatedSec + (running ? now - startedAt : 0)`, so a paused stopwatch is
28
+ * a plain number, a running one survives a refresh or a dead battery, and no
29
+ * job has to write to the database once a second.
30
+ *
31
+ * `startedAt` is null exactly when `running` is false; the pair is a small
32
+ * state machine, and the backend is the only writer.
33
+ */
34
+ export const sessionStopwatchSchema = z.object({
35
+ running: z.boolean(),
36
+ /** Seconds banked by previous runs, excluding any run in progress. */
37
+ accumulatedSec: z.number().int().nonnegative(),
38
+ /** When the current run began. Null while paused. */
39
+ startedAt: isoDateTimeSchema.nullable(),
40
+ });
41
+ /** A stopwatch that has never been started. */
42
+ export const IDLE_STOPWATCH = {
43
+ running: false,
44
+ accumulatedSec: 0,
45
+ startedAt: null,
46
+ };
47
+ /**
48
+ * Resolve a stopwatch to whole seconds elapsed.
49
+ *
50
+ * Shared rather than reimplemented on each side: the backend needs it to bank
51
+ * time on pause, and the client needs it to render every tick. Two copies of
52
+ * this arithmetic would drift the moment one of them forgot the running run.
53
+ */
54
+ export function stopwatchElapsedSec(stopwatch, now = new Date()) {
55
+ if (!stopwatch.running || !stopwatch.startedAt)
56
+ return stopwatch.accumulatedSec;
57
+ const started = Date.parse(stopwatch.startedAt);
58
+ if (Number.isNaN(started))
59
+ return stopwatch.accumulatedSec;
60
+ // Floored at the banked value: a client clock behind the server's would
61
+ // otherwise make the number run backwards.
62
+ return stopwatch.accumulatedSec + Math.max(0, Math.floor((now.getTime() - started) / 1000));
63
+ }
16
64
  // ─── Session entity ──────────────────────────────────────────────────────────
17
65
  export const workoutSessionSchema = z.object({
18
66
  id: objectIdSchema,
@@ -25,6 +73,7 @@ export const workoutSessionSchema = z.object({
25
73
  planWeekday: weekdaySchema.nullable(),
26
74
  completedExerciseIds: z.array(objectIdSchema),
27
75
  totalExercises: z.number().int().nonnegative(),
76
+ stopwatch: sessionStopwatchSchema,
28
77
  });
29
78
  // ─── Session mutations ───────────────────────────────────────────────────────
30
79
  /**
@@ -53,6 +102,17 @@ export const markExerciseDoneBodySchema = z.object({
53
102
  exerciseId: objectIdSchema,
54
103
  done: z.boolean(),
55
104
  });
105
+ /**
106
+ * Drive the set stopwatch.
107
+ *
108
+ * An ACTION, not a state patch. The client says what the member pressed and the
109
+ * server computes the resulting `{ running, accumulatedSec, startedAt }` — which
110
+ * keeps the banking arithmetic in one place and means a client cannot post an
111
+ * arbitrary elapsed time to inflate a workout.
112
+ */
113
+ export const stopwatchActionBodySchema = z.object({
114
+ action: z.enum(['start', 'pause', 'reset']),
115
+ });
56
116
  // ─── Attendance ──────────────────────────────────────────────────────────────
57
117
  export const attendanceStatusSchema = z.enum(['present', 'absent', 'rest']);
58
118
  export const attendanceEntrySchema = z.object({
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,gFAAgF;AAEhF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC;AAG3E,gFAAgF;AAEhF,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,EAAE,EAAE,cAAc;IAClB,MAAM,EAAE,cAAc;IACtB,QAAQ,EAAE,cAAc;IACxB,MAAM,EAAE,mBAAmB;IAC3B,SAAS,EAAE,iBAAiB;IAC5B,UAAU,EAAE,iBAAiB,CAAC,QAAQ,EAAE;IACxC,kEAAkE;IAClE,WAAW,EAAE,aAAa,CAAC,QAAQ,EAAE;IACrC,oBAAoB,EAAE,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC;IAC7C,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,WAAW,EAAE;CAC/C,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC;IACxC,IAAI,EAAE,iBAAiB;CACxB,CAAC,CAAC;AAGH;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,IAAI,EAAE,iBAAiB;CACxB,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC,MAAM,CAAC;IACjD,UAAU,EAAE,cAAc;IAC1B,IAAI,EAAE,CAAC,CAAC,OAAO,EAAE;CAClB,CAAC,CAAC;AAGH,gFAAgF;AAEhF,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;AAG5E,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C,IAAI,EAAE,aAAa;IACnB,SAAS,EAAE,iBAAiB,CAAC,QAAQ,EAAE;IACvC,UAAU,EAAE,iBAAiB,CAAC,QAAQ,EAAE;IACxC,MAAM,EAAE,sBAAsB;CAC/B,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,qBAAqB,GAAG,gBAAgB,CAAC,MAAM,CAAC;IAC3D,MAAM,EAAE,cAAc,CAAC,QAAQ,EAAE;IACjC,IAAI,EAAE,aAAa,CAAC,QAAQ,EAAE;IAC9B,EAAE,EAAE,aAAa,CAAC,QAAQ,EAAE;CAC7B,CAAC,CAAC;AAGH,sEAAsE;AACtE,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/C,MAAM,EAAE,cAAc;IACtB,IAAI,EAAE,aAAa,CAAC,QAAQ,EAAE;IAC9B,MAAM,EAAE,sBAAsB,CAAC,OAAO,CAAC,SAAS,CAAC;IACjD,SAAS,EAAE,iBAAiB,CAAC,QAAQ,EAAE;IACvC,UAAU,EAAE,iBAAiB,CAAC,QAAQ,EAAE;IACxC,IAAI,EAAE,qBAAqB,CAAC,GAAG,CAAC;CACjC,CAAC,CAAC","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 { 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// ─── 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});\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.object({\n code: checkInCodeSchema,\n});\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// ─── 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 { 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.object({\n code: checkInCodeSchema,\n});\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"]}
@@ -0,0 +1,197 @@
1
+ /**
2
+ * gymmonk-schema — Social graph (follow)
3
+ * ======================================
4
+ * The follow relationship and the people list it feeds.
5
+ *
6
+ * A follow is UNILATERAL: one row, one direction, no acceptance step. A mutual
7
+ * follow is two independent rows. That is what makes "do I follow them" and
8
+ * "do they follow me" separately answerable, and therefore what makes
9
+ * "Follow back" a state the UI can offer at all.
10
+ *
11
+ * @module gymmonk-schema/social
12
+ */
13
+ import { z } from 'zod';
14
+ /**
15
+ * How the viewer stands with one other person.
16
+ *
17
+ * Both directions are carried, and both timestamps with them. `followedYouAt`
18
+ * is what the Followers list shows as "· 2w", and `followsViewer` is the only
19
+ * thing that distinguishes a "Follow" button from a "Follow back" button.
20
+ */
21
+ export declare const viewerSocialStateSchema: z.ZodObject<{
22
+ following: z.ZodBoolean;
23
+ followsViewer: z.ZodBoolean;
24
+ followedAt: z.ZodNullable<z.ZodISODateTime>;
25
+ followedYouAt: z.ZodNullable<z.ZodISODateTime>;
26
+ }, z.core.$strip>;
27
+ export type ViewerSocialState = z.infer<typeof viewerSocialStateSchema>;
28
+ /** Nobody follows anybody. The shape a missing lookup falls back to. */
29
+ export declare const EMPTY_VIEWER_SOCIAL_STATE: ViewerSocialState;
30
+ /**
31
+ * "Followed by Priya, Rahul + 3 more" — this person's MUTUAL FOLLOWERS.
32
+ *
33
+ * The direction is easy to get backwards and the label depends on it: a mutual
34
+ * is someone the VIEWER FOLLOWS who ALSO FOLLOWS this person. "Followed by
35
+ * Priya" says Priya follows them, and Priya is worth naming because the viewer
36
+ * follows Priya and will recognise her. The inverse set (people they both
37
+ * follow) is a different thing and reads as a lie under this label.
38
+ *
39
+ * `count` is the TOTAL; `avatars` and `sampleNames` carry only the handful
40
+ * actually rendered. Sending every mutual so the client can count them would
41
+ * put an unbounded array on a list row for one line of secondary text.
42
+ */
43
+ export declare const mutualsSchema: z.ZodObject<{
44
+ count: z.ZodNumber;
45
+ avatars: z.ZodArray<z.ZodNullable<z.ZodString>>;
46
+ sampleNames: z.ZodArray<z.ZodString>;
47
+ }, z.core.$strip>;
48
+ export type Mutuals = z.infer<typeof mutualsSchema>;
49
+ /** How many faces and names a mutuals line carries. */
50
+ export declare const MUTUALS_SAMPLE_SIZE = 3;
51
+ /**
52
+ * Why this person is in the viewer's list.
53
+ *
54
+ * `gym` at the viewer's gym, not followed
55
+ * `following` followed by the viewer, and NOT at their gym any more
56
+ * `both` at the gym AND followed
57
+ *
58
+ * The distinction is load-bearing rather than cosmetic. Messaging is gated on
59
+ * sharing a gym, so a `following` row must not offer a Message button the
60
+ * backend will refuse. jansathi has no equivalent because its People tab is the
61
+ * whole community and there is no second axis to be on the wrong side of.
62
+ */
63
+ export declare const buddyRelationSchema: z.ZodEnum<{
64
+ gym: "gym";
65
+ following: "following";
66
+ both: "both";
67
+ }>;
68
+ export type BuddyRelation = z.infer<typeof buddyRelationSchema>;
69
+ /** Which slice of the list the caller asked for. */
70
+ export declare const buddyFilterSchema: z.ZodEnum<{
71
+ all: "all";
72
+ gym: "gym";
73
+ following: "following";
74
+ followers: "followers";
75
+ }>;
76
+ export type BuddyFilter = z.infer<typeof buddyFilterSchema>;
77
+ export declare const BUDDY_FILTER_VALUES: readonly ["all", "gym", "following", "followers"];
78
+ /**
79
+ * One person as a peer is allowed to see them.
80
+ *
81
+ * Deliberately NOT derived from the owner's member projection. That one carries
82
+ * phone numbers, addresses, emergency contacts, dues and attendance, and the
83
+ * only reliable way to guarantee none of it reaches a peer is for this to be a
84
+ * separate shape rather than the same one with fields removed.
85
+ */
86
+ export declare const buddySchema: z.ZodObject<{
87
+ userId: z.ZodString;
88
+ name: z.ZodString;
89
+ avatarUrl: z.ZodNullable<z.ZodString>;
90
+ memberSince: z.ZodNullable<z.ZodString>;
91
+ lastActiveAt: z.ZodNullable<z.ZodISODateTime>;
92
+ relation: z.ZodEnum<{
93
+ gym: "gym";
94
+ following: "following";
95
+ both: "both";
96
+ }>;
97
+ viewer: z.ZodObject<{
98
+ following: z.ZodBoolean;
99
+ followsViewer: z.ZodBoolean;
100
+ followedAt: z.ZodNullable<z.ZodISODateTime>;
101
+ followedYouAt: z.ZodNullable<z.ZodISODateTime>;
102
+ }, z.core.$strip>;
103
+ mutuals: z.ZodNullable<z.ZodObject<{
104
+ count: z.ZodNumber;
105
+ avatars: z.ZodArray<z.ZodNullable<z.ZodString>>;
106
+ sampleNames: z.ZodArray<z.ZodString>;
107
+ }, z.core.$strip>>;
108
+ }, z.core.$strip>;
109
+ export type Buddy = z.infer<typeof buddySchema>;
110
+ /** `GET /api/v1/members/at-my-gym?filter=` */
111
+ export declare const buddyListQuerySchema: z.ZodObject<{
112
+ filter: z.ZodDefault<z.ZodEnum<{
113
+ all: "all";
114
+ gym: "gym";
115
+ following: "following";
116
+ followers: "followers";
117
+ }>>;
118
+ }, z.core.$strip>;
119
+ export type BuddyListQuery = z.infer<typeof buddyListQuerySchema>;
120
+ /**
121
+ * The state AFTER a toggle.
122
+ *
123
+ * A toggle rather than separate follow and unfollow endpoints: a button acting
124
+ * on stale state can send the wrong one, and then the server has to decide
125
+ * whether a double unfollow is an error. A toggle has one meaning whatever the
126
+ * client believed, and this response says what is now true.
127
+ */
128
+ export declare const toggleFollowResultSchema: z.ZodObject<{
129
+ targetUserId: z.ZodString;
130
+ following: z.ZodBoolean;
131
+ followerCount: z.ZodNumber;
132
+ }, z.core.$strip>;
133
+ export type ToggleFollowResult = z.infer<typeof toggleFollowResultSchema>;
134
+ /**
135
+ * `GET /api/v1/users/:id/social` — who this person is, and where the viewer
136
+ * stands with them.
137
+ *
138
+ * `person` is the SAME shape as a row in the people list, deliberately. The
139
+ * profile screen and the list row show the same facts and offer the same
140
+ * actions, so giving them two shapes would mean two places to add a field and
141
+ * one of them being forgotten.
142
+ */
143
+ export declare const socialSnapshotSchema: z.ZodObject<{
144
+ person: z.ZodObject<{
145
+ userId: z.ZodString;
146
+ name: z.ZodString;
147
+ avatarUrl: z.ZodNullable<z.ZodString>;
148
+ memberSince: z.ZodNullable<z.ZodString>;
149
+ lastActiveAt: z.ZodNullable<z.ZodISODateTime>;
150
+ relation: z.ZodEnum<{
151
+ gym: "gym";
152
+ following: "following";
153
+ both: "both";
154
+ }>;
155
+ viewer: z.ZodObject<{
156
+ following: z.ZodBoolean;
157
+ followsViewer: z.ZodBoolean;
158
+ followedAt: z.ZodNullable<z.ZodISODateTime>;
159
+ followedYouAt: z.ZodNullable<z.ZodISODateTime>;
160
+ }, z.core.$strip>;
161
+ mutuals: z.ZodNullable<z.ZodObject<{
162
+ count: z.ZodNumber;
163
+ avatars: z.ZodArray<z.ZodNullable<z.ZodString>>;
164
+ sampleNames: z.ZodArray<z.ZodString>;
165
+ }, z.core.$strip>>;
166
+ }, z.core.$strip>;
167
+ followerCount: z.ZodNumber;
168
+ followingCount: z.ZodNumber;
169
+ }, z.core.$strip>;
170
+ export type SocialSnapshot = z.infer<typeof socialSnapshotSchema>;
171
+ /**
172
+ * Blocking is ONE-WAY in storage and BIDIRECTIONAL in effect.
173
+ *
174
+ * One row records that A blocked B. Nothing records the reverse, because B has
175
+ * not blocked anybody. But every gate that consults it looks BOTH ways: B must
176
+ * not be able to follow, message or even see A, or the block would only work
177
+ * against the person who asked for it.
178
+ *
179
+ * Blocking is also a reset, not a mute: it tears down any follow in either
180
+ * direction and freezes any conversation the two had. Leaving a follow in place
181
+ * would keep the blocked person in the blocker's follower count, which is
182
+ * exactly the connection they just severed.
183
+ */
184
+ export declare const blockedUserSchema: z.ZodObject<{
185
+ userId: z.ZodString;
186
+ name: z.ZodString;
187
+ avatarUrl: z.ZodNullable<z.ZodString>;
188
+ blockedAt: z.ZodISODateTime;
189
+ }, z.core.$strip>;
190
+ export type BlockedUser = z.infer<typeof blockedUserSchema>;
191
+ /** `POST /api/v1/users/:id/block` — the state AFTER the toggle. */
192
+ export declare const toggleBlockResultSchema: z.ZodObject<{
193
+ targetUserId: z.ZodString;
194
+ blocked: z.ZodBoolean;
195
+ }, z.core.$strip>;
196
+ export type ToggleBlockResult = z.infer<typeof toggleBlockResultSchema>;
197
+ //# sourceMappingURL=social.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"social.d.ts","sourceRoot":"","sources":["../src/social.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB;;;;;iBASlC,CAAC;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAExE,wEAAwE;AACxE,eAAO,MAAM,yBAAyB,EAAE,iBAKvC,CAAC;AAIF;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;iBAMxB,CAAC;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,uDAAuD;AACvD,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAIrC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB;;;;EAAuC,CAAC;AACxE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,oDAAoD;AACpD,eAAO,MAAM,iBAAiB;;;;;EAAmD,CAAC;AAClF,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,eAAO,MAAM,mBAAmB,mDAAoD,CAAC;AAErF;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;iBAmBtB,CAAC;AACH,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEhD,8CAA8C;AAC9C,eAAO,MAAM,oBAAoB;;;;;;;iBAE/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB;;;;iBAInC,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,oFAAoF;AACpF,eAAO,MAAM,oBAAoB;;;;;;;;;iBAI/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
package/dist/social.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * gymmonk-schema — Social graph (follow)
3
+ * ======================================
4
+ * The follow relationship and the people list it feeds.
5
+ *
6
+ * A follow is UNILATERAL: one row, one direction, no acceptance step. A mutual
7
+ * follow is two independent rows. That is what makes "do I follow them" and
8
+ * "do they follow me" separately answerable, and therefore what makes
9
+ * "Follow back" a state the UI can offer at all.
10
+ *
11
+ * @module gymmonk-schema/social
12
+ */
13
+ import { z } from 'zod';
14
+ import { isoDateTimeSchema, objectIdSchema } from './common.js';
15
+ // ─── Viewer state ────────────────────────────────────────────────────────────
16
+ /**
17
+ * How the viewer stands with one other person.
18
+ *
19
+ * Both directions are carried, and both timestamps with them. `followedYouAt`
20
+ * is what the Followers list shows as "· 2w", and `followsViewer` is the only
21
+ * thing that distinguishes a "Follow" button from a "Follow back" button.
22
+ */
23
+ export const viewerSocialStateSchema = z.object({
24
+ /** The viewer follows them. */
25
+ following: z.boolean(),
26
+ /** They follow the viewer. */
27
+ followsViewer: z.boolean(),
28
+ /** When the viewer started following them. Null when they do not. */
29
+ followedAt: isoDateTimeSchema.nullable(),
30
+ /** When they started following the viewer. Null when they do not. */
31
+ followedYouAt: isoDateTimeSchema.nullable(),
32
+ });
33
+ /** Nobody follows anybody. The shape a missing lookup falls back to. */
34
+ export const EMPTY_VIEWER_SOCIAL_STATE = {
35
+ following: false,
36
+ followsViewer: false,
37
+ followedAt: null,
38
+ followedYouAt: null,
39
+ };
40
+ // ─── Mutuals ─────────────────────────────────────────────────────────────────
41
+ /**
42
+ * "Followed by Priya, Rahul + 3 more" — this person's MUTUAL FOLLOWERS.
43
+ *
44
+ * The direction is easy to get backwards and the label depends on it: a mutual
45
+ * is someone the VIEWER FOLLOWS who ALSO FOLLOWS this person. "Followed by
46
+ * Priya" says Priya follows them, and Priya is worth naming because the viewer
47
+ * follows Priya and will recognise her. The inverse set (people they both
48
+ * follow) is a different thing and reads as a lie under this label.
49
+ *
50
+ * `count` is the TOTAL; `avatars` and `sampleNames` carry only the handful
51
+ * actually rendered. Sending every mutual so the client can count them would
52
+ * put an unbounded array on a list row for one line of secondary text.
53
+ */
54
+ export const mutualsSchema = z.object({
55
+ count: z.number().int().nonnegative(),
56
+ /** Up to three, for the stacked faces. An entry is null when that person has no photo. */
57
+ avatars: z.array(z.string().nullable()),
58
+ /** Up to three names, for the sentence. */
59
+ sampleNames: z.array(z.string()),
60
+ });
61
+ /** How many faces and names a mutuals line carries. */
62
+ export const MUTUALS_SAMPLE_SIZE = 3;
63
+ // ─── People list ─────────────────────────────────────────────────────────────
64
+ /**
65
+ * Why this person is in the viewer's list.
66
+ *
67
+ * `gym` at the viewer's gym, not followed
68
+ * `following` followed by the viewer, and NOT at their gym any more
69
+ * `both` at the gym AND followed
70
+ *
71
+ * The distinction is load-bearing rather than cosmetic. Messaging is gated on
72
+ * sharing a gym, so a `following` row must not offer a Message button the
73
+ * backend will refuse. jansathi has no equivalent because its People tab is the
74
+ * whole community and there is no second axis to be on the wrong side of.
75
+ */
76
+ export const buddyRelationSchema = z.enum(['gym', 'following', 'both']);
77
+ /** Which slice of the list the caller asked for. */
78
+ export const buddyFilterSchema = z.enum(['all', 'gym', 'following', 'followers']);
79
+ export const BUDDY_FILTER_VALUES = ['all', 'gym', 'following', 'followers'];
80
+ /**
81
+ * One person as a peer is allowed to see them.
82
+ *
83
+ * Deliberately NOT derived from the owner's member projection. That one carries
84
+ * phone numbers, addresses, emergency contacts, dues and attendance, and the
85
+ * only reliable way to guarantee none of it reaches a peer is for this to be a
86
+ * separate shape rather than the same one with fields removed.
87
+ */
88
+ export const buddySchema = z.object({
89
+ /** The USER id. This is who you follow and message, not a profile row. */
90
+ userId: objectIdSchema,
91
+ name: z.string(),
92
+ avatarUrl: z.string().nullable(),
93
+ /** "Member since Mar 2025", or null for someone with no membership history here. */
94
+ memberSince: z.string().nullable(),
95
+ /**
96
+ * When this person was last seen in the app. Null if never.
97
+ *
98
+ * Carried so the list can offer a "Recently active" sort. It is deliberately
99
+ * a raw instant rather than a rendered "2h ago": the client is the only side
100
+ * that knows what time it is where the reader is sitting, and a string
101
+ * formatted on the server goes stale the moment it is cached.
102
+ */
103
+ lastActiveAt: isoDateTimeSchema.nullable(),
104
+ relation: buddyRelationSchema,
105
+ viewer: viewerSocialStateSchema,
106
+ mutuals: mutualsSchema.nullable(),
107
+ });
108
+ /** `GET /api/v1/members/at-my-gym?filter=` */
109
+ export const buddyListQuerySchema = z.object({
110
+ filter: buddyFilterSchema.default('all'),
111
+ });
112
+ // ─── Toggle ──────────────────────────────────────────────────────────────────
113
+ /**
114
+ * The state AFTER a toggle.
115
+ *
116
+ * A toggle rather than separate follow and unfollow endpoints: a button acting
117
+ * on stale state can send the wrong one, and then the server has to decide
118
+ * whether a double unfollow is an error. A toggle has one meaning whatever the
119
+ * client believed, and this response says what is now true.
120
+ */
121
+ export const toggleFollowResultSchema = z.object({
122
+ targetUserId: objectIdSchema,
123
+ following: z.boolean(),
124
+ followerCount: z.number().int().nonnegative(),
125
+ });
126
+ /**
127
+ * `GET /api/v1/users/:id/social` — who this person is, and where the viewer
128
+ * stands with them.
129
+ *
130
+ * `person` is the SAME shape as a row in the people list, deliberately. The
131
+ * profile screen and the list row show the same facts and offer the same
132
+ * actions, so giving them two shapes would mean two places to add a field and
133
+ * one of them being forgotten.
134
+ */
135
+ export const socialSnapshotSchema = z.object({
136
+ person: buddySchema,
137
+ followerCount: z.number().int().nonnegative(),
138
+ followingCount: z.number().int().nonnegative(),
139
+ });
140
+ // ─── Block ───────────────────────────────────────────────────────────────────
141
+ /**
142
+ * Blocking is ONE-WAY in storage and BIDIRECTIONAL in effect.
143
+ *
144
+ * One row records that A blocked B. Nothing records the reverse, because B has
145
+ * not blocked anybody. But every gate that consults it looks BOTH ways: B must
146
+ * not be able to follow, message or even see A, or the block would only work
147
+ * against the person who asked for it.
148
+ *
149
+ * Blocking is also a reset, not a mute: it tears down any follow in either
150
+ * direction and freezes any conversation the two had. Leaving a follow in place
151
+ * would keep the blocked person in the blocker's follower count, which is
152
+ * exactly the connection they just severed.
153
+ */
154
+ export const blockedUserSchema = z.object({
155
+ userId: objectIdSchema,
156
+ name: z.string(),
157
+ avatarUrl: z.string().nullable(),
158
+ blockedAt: isoDateTimeSchema,
159
+ });
160
+ /** `POST /api/v1/users/:id/block` — the state AFTER the toggle. */
161
+ export const toggleBlockResultSchema = z.object({
162
+ targetUserId: objectIdSchema,
163
+ blocked: z.boolean(),
164
+ });
165
+ //# sourceMappingURL=social.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"social.js","sourceRoot":"","sources":["../src/social.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Social graph (follow)\n * ======================================\n * The follow relationship and the people list it feeds.\n *\n * A follow is UNILATERAL: one row, one direction, no acceptance step. A mutual\n * follow is two independent rows. That is what makes \"do I follow them\" and\n * \"do they follow me\" separately answerable, and therefore what makes\n * \"Follow back\" a state the UI can offer at all.\n *\n * @module gymmonk-schema/social\n */\n\nimport { z } from 'zod';\nimport { isoDateTimeSchema, objectIdSchema } from './common.js';\n\n// ─── Viewer state ────────────────────────────────────────────────────────────\n\n/**\n * How the viewer stands with one other person.\n *\n * Both directions are carried, and both timestamps with them. `followedYouAt`\n * is what the Followers list shows as \"· 2w\", and `followsViewer` is the only\n * thing that distinguishes a \"Follow\" button from a \"Follow back\" button.\n */\nexport const viewerSocialStateSchema = z.object({\n /** The viewer follows them. */\n following: z.boolean(),\n /** They follow the viewer. */\n followsViewer: z.boolean(),\n /** When the viewer started following them. Null when they do not. */\n followedAt: isoDateTimeSchema.nullable(),\n /** When they started following the viewer. Null when they do not. */\n followedYouAt: isoDateTimeSchema.nullable(),\n});\nexport type ViewerSocialState = z.infer<typeof viewerSocialStateSchema>;\n\n/** Nobody follows anybody. The shape a missing lookup falls back to. */\nexport const EMPTY_VIEWER_SOCIAL_STATE: ViewerSocialState = {\n following: false,\n followsViewer: false,\n followedAt: null,\n followedYouAt: null,\n};\n\n// ─── Mutuals ─────────────────────────────────────────────────────────────────\n\n/**\n * \"Followed by Priya, Rahul and 3 more\" — the people the viewer and this person\n * both follow.\n *\n * `count` is the TOTAL; `avatars` and `sampleNames` carry only the handful\n * actually rendered. Sending every mutual so the client can count them would\n * put an unbounded array on a list row for one line of secondary text.\n */\nexport const mutualsSchema = z.object({\n count: z.number().int().nonnegative(),\n /** Up to three, for the stacked faces. An entry is null when that person has no photo. */\n avatars: z.array(z.string().nullable()),\n /** Up to three names, for the sentence. */\n sampleNames: z.array(z.string()),\n});\nexport type Mutuals = z.infer<typeof mutualsSchema>;\n\n/** How many faces and names a mutuals line carries. */\nexport const MUTUALS_SAMPLE_SIZE = 3;\n\n// ─── People list ─────────────────────────────────────────────────────────────\n\n/**\n * Why this person is in the viewer's list.\n *\n * `gym` at the viewer's gym, not followed\n * `following` followed by the viewer, and NOT at their gym any more\n * `both` at the gym AND followed\n *\n * The distinction is load-bearing rather than cosmetic. Messaging is gated on\n * sharing a gym, so a `following` row must not offer a Message button the\n * backend will refuse. jansathi has no equivalent because its People tab is the\n * whole community and there is no second axis to be on the wrong side of.\n */\nexport const buddyRelationSchema = z.enum(['gym', 'following', 'both']);\nexport type BuddyRelation = z.infer<typeof buddyRelationSchema>;\n\n/** Which slice of the list the caller asked for. */\nexport const buddyFilterSchema = z.enum(['all', 'gym', 'following', 'followers']);\nexport type BuddyFilter = z.infer<typeof buddyFilterSchema>;\n\nexport const BUDDY_FILTER_VALUES = ['all', 'gym', 'following', 'followers'] as const;\n\n/**\n * One person as a peer is allowed to see them.\n *\n * Deliberately NOT derived from the owner's member projection. That one carries\n * phone numbers, addresses, emergency contacts, dues and attendance, and the\n * only reliable way to guarantee none of it reaches a peer is for this to be a\n * separate shape rather than the same one with fields removed.\n */\nexport const buddySchema = z.object({\n /** The USER id. This is who you follow and message, not a profile row. */\n userId: objectIdSchema,\n name: z.string(),\n avatarUrl: z.string().nullable(),\n /** \"Member since Mar 2025\", or null for someone with no membership history here. */\n memberSince: z.string().nullable(),\n /**\n * When this person was last seen in the app. Null if never.\n *\n * Carried so the list can offer a \"Recently active\" sort. It is deliberately\n * a raw instant rather than a rendered \"2h ago\": the client is the only side\n * that knows what time it is where the reader is sitting, and a string\n * formatted on the server goes stale the moment it is cached.\n */\n lastActiveAt: isoDateTimeSchema.nullable(),\n relation: buddyRelationSchema,\n viewer: viewerSocialStateSchema,\n mutuals: mutualsSchema.nullable(),\n});\nexport type Buddy = z.infer<typeof buddySchema>;\n\n/** `GET /api/v1/members/at-my-gym?filter=` */\nexport const buddyListQuerySchema = z.object({\n filter: buddyFilterSchema.default('all'),\n});\nexport type BuddyListQuery = z.infer<typeof buddyListQuerySchema>;\n\n// ─── Toggle ──────────────────────────────────────────────────────────────────\n\n/**\n * The state AFTER a toggle.\n *\n * A toggle rather than separate follow and unfollow endpoints: a button acting\n * on stale state can send the wrong one, and then the server has to decide\n * whether a double unfollow is an error. A toggle has one meaning whatever the\n * client believed, and this response says what is now true.\n */\nexport const toggleFollowResultSchema = z.object({\n targetUserId: objectIdSchema,\n following: z.boolean(),\n followerCount: z.number().int().nonnegative(),\n});\nexport type ToggleFollowResult = z.infer<typeof toggleFollowResultSchema>;\n\n/** `GET /api/v1/users/:id/social` — one person's relationship plus their totals. */\nexport const socialSnapshotSchema = z.object({\n viewer: viewerSocialStateSchema,\n followerCount: z.number().int().nonnegative(),\n followingCount: z.number().int().nonnegative(),\n});\nexport type SocialSnapshot = z.infer<typeof socialSnapshotSchema>;\n"]}
package/package.json CHANGED
@@ -1,49 +1,49 @@
1
1
  {
2
- "name": "gymmonk-schema",
3
- "version": "0.15.0",
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
- "type": "module",
6
- "main": "./dist/index.js",
7
- "module": "./dist/index.js",
8
- "types": "./dist/index.d.ts",
9
- "exports": {
10
- ".": {
11
- "types": "./dist/index.d.ts",
12
- "import": "./dist/index.js",
13
- "require": "./dist/index.js"
14
- }
15
- },
16
- "files": [
17
- "dist",
18
- "README.md"
19
- ],
20
- "sideEffects": false,
21
- "scripts": {
22
- "build": "tsc -p tsconfig.json",
23
- "type-check": "tsc --noEmit",
24
- "lint": "biome lint .",
25
- "format": "biome format --write .",
26
- "check": "npm run type-check && biome check --write .",
27
- "check:staged": "biome check --staged --write --no-errors-on-unmatched",
28
- "prepublishOnly": "npm run build && npm run check"
29
- },
30
- "keywords": [
31
- "zod",
32
- "schema",
33
- "gymmonk",
34
- "gym",
35
- "fitness",
36
- "typescript"
37
- ],
38
- "author": "Hari",
39
- "license": "MIT",
40
- "peerDependencies": {
41
- "zod": "^4.4.0"
42
- },
43
- "devDependencies": {
44
- "@biomejs/biome": "^2.4.2",
45
- "@types/node": "^24.10.1",
46
- "typescript": "^5.9.3",
47
- "zod": "^4.4.3"
48
- }
49
- }
2
+ "name": "gymmonk-schema",
3
+ "version": "0.17.0",
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
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "type-check": "tsc --noEmit",
24
+ "lint": "biome lint .",
25
+ "format": "biome format --write .",
26
+ "check": "npm run type-check && biome check --write .",
27
+ "check:staged": "biome check --staged --write --no-errors-on-unmatched",
28
+ "prepublishOnly": "npm run build && npm run check"
29
+ },
30
+ "keywords": [
31
+ "zod",
32
+ "schema",
33
+ "gymmonk",
34
+ "gym",
35
+ "fitness",
36
+ "typescript"
37
+ ],
38
+ "author": "Hari",
39
+ "license": "MIT",
40
+ "peerDependencies": {
41
+ "zod": "^4.4.0"
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "^2.4.2",
45
+ "@types/node": "^24.10.1",
46
+ "typescript": "^5.9.3",
47
+ "zod": "^4.4.3"
48
+ }
49
+ }