gymmonk-schema 0.16.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 +235 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +182 -0
- package/dist/chat.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/social.d.ts +197 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +165 -0
- package/dist/social.js.map +1 -0
- package/package.json +48 -48
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
|
package/dist/chat.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat.js","sourceRoot":"","sources":["../src/chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAEhE,gFAAgF;AAEhF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG;IAChC,uEAAuE;IACvE,KAAK,EAAE,OAAO;IACd,wEAAwE;IACxE,IAAI,EAAE,MAAM;IACZ,gDAAgD;IAChD,OAAO,EAAE,SAAS;IAClB,+EAA+E;IAC/E,KAAK,EAAE,OAAO;IAEd,wEAAwE;IACxE,iBAAiB,EAAE,mBAAmB;IACtC,kDAAkD;IAClD,kBAAkB,EAAE,oBAAoB;IAExC,oEAAoE;IACpE,YAAY,EAAE,cAAc;IAC5B,oEAAoE;IACpE,WAAW,EAAE,aAAa;IAC1B,iEAAiE;IACjE,YAAY,EAAE,cAAc;IAC5B,0DAA0D;IAC1D,YAAY,EAAE,cAAc;IAC5B,+DAA+D;IAC/D,eAAe,EAAE,iBAAiB;IAElC,qDAAqD;IACrD,MAAM,EAAE,QAAQ;IAChB,6EAA6E;IAC7E,aAAa,EAAE,eAAe;IAE9B,mEAAmE;IACnE,eAAe,EAAE,iBAAiB;CAC1B,CAAC;AAIX,gFAAgF;AAEhF,gGAAgG;AAChG,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,YAAY,EAAE,cAAc;IAC5B,WAAW,EAAE,aAAa;IAC1B,iBAAiB,EAAE,mBAAmB;IACtC,iBAAiB,EAAE,mBAAmB;IACtC,sBAAsB,EAAE,wBAAwB;IAChD,eAAe,EAAE,iBAAiB;IAClC,YAAY,EAAE,cAAc;IAC5B,YAAY,EAAE,cAAc;CACpB,CAAC;AAIX,gFAAgF;AAEhF,0EAA0E;AAC1E,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAEvC,oCAAoC;AACpC,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAEpC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAE1C,uEAAuE;AACvE,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,CAAC;AAE3C;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,KAAK,CAAC;AAEvC,wEAAwE;AACxE,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,CAAC;AAExC,gFAAgF;AAEhF,yFAAyF;AACzF,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C,MAAM,EAAE,cAAc;IACtB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;IACvB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CACjC,CAAC,CAAC;AAGH,yCAAyC;AACzC,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC;IACxC,EAAE,EAAE,cAAc;IAClB,cAAc,EAAE,cAAc;IAC9B,QAAQ,EAAE,cAAc;IACxB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,kEAAkE;IAClE,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE;IAC3B,MAAM,EAAE,iBAAiB;IACzB,MAAM,EAAE,iBAAiB,CAAC,QAAQ,EAAE;IACpC,kDAAkD;IAClD,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;CACrB,CAAC,CAAC;AAGH,qDAAqD;AACrD,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,QAAQ,EAAE,cAAc;IACxB,MAAM,EAAE,iBAAiB;CAC1B,CAAC,CAAC;AAGH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7C,EAAE,EAAE,cAAc;IAClB,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,qBAAqB,CAAC;IAC5C,WAAW,EAAE,qBAAqB,CAAC,QAAQ,EAAE;IAC7C,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,WAAW,EAAE;IAC3C,SAAS,EAAE,iBAAiB;IAC5B,4DAA4D;IAC5D,SAAS,EAAE,iBAAiB;CAC7B,CAAC,CAAC;AAmEH,gFAAgF;AAEhF,6FAA6F;AAC7F,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;IACpB,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC1B;;;;;;OAMG;IACH,MAAM,EAAE,cAAc;CACvB,CAAC,CAAC;AAGH,qCAAqC;AACrC,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC1C,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;IACjB,SAAS,EAAE,iBAAiB;IAC5B,0EAA0E;IAC1E,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;CACvC,CAAC,CAAC;AAGH,uFAAuF;AACvF,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/C,MAAM,EAAE,cAAc;CACvB,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE;IAC3B,SAAS,EAAE,iBAAiB;CAC7B,CAAC,CAAC;AAGH,qFAAqF;AACrF,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5C,cAAc,EAAE,cAAc;IAC9B,QAAQ,EAAE,cAAc;IACxB,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;IACtB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;CACjB,CAAC,CAAC;AAGH,uEAAuE;AACvE,MAAM,CAAC,MAAM,mBAAmB,GAAG,qBAAqB,CAAC;AAEzD,2EAA2E;AAC3E,MAAM,CAAC,MAAM,uBAAuB,GAAG,YAAY,CAAC","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/social.d.ts
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|