@molecule/api-resource-message 1.0.0 → 1.0.2
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/README.md +642 -0
- package/dist/handlers/listThreads.d.ts.map +1 -1
- package/dist/handlers/listThreads.js +3 -1
- package/package.json +11 -10
package/README.md
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
3
|
+
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
|
+
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
|
+
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
+
Generated: 2026-08-04T01:49:16.099Z
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
# @molecule/api-resource-message
|
|
10
|
+
|
|
11
|
+
> **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
|
|
12
|
+
> It is written to be read by coding agents as much as by people, and is generated from this
|
|
13
|
+
> package's source — edit `src/index.ts` JSDoc, not this file.
|
|
14
|
+
|
|
15
|
+
1:1 messaging resource for molecule.dev.
|
|
16
|
+
|
|
17
|
+
Direct-message threads between two participants with read-tracking,
|
|
18
|
+
unread counters, optional attachments, and realtime broadcast over
|
|
19
|
+
`@molecule/api-realtime` when bonded.
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { routes, requestHandlerMap } from '@molecule/api-resource-message'
|
|
25
|
+
|
|
26
|
+
// Wire routes into your Express app via mlcl inject
|
|
27
|
+
// POST /message-threads
|
|
28
|
+
// GET /message-threads
|
|
29
|
+
// GET /message-threads/unread-count
|
|
30
|
+
// GET /message-threads/:threadId
|
|
31
|
+
// GET /message-threads/:threadId/messages
|
|
32
|
+
// POST /message-threads/:threadId/messages
|
|
33
|
+
// POST /message-threads/:threadId/read
|
|
34
|
+
// PATCH /message-threads/messages/:messageId
|
|
35
|
+
// DELETE /message-threads/messages/:messageId
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Type
|
|
39
|
+
|
|
40
|
+
`resource`
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install @molecule/api-resource-message @molecule/api-database @molecule/api-i18n @molecule/api-locales-resource-message @molecule/api-logger @molecule/api-realtime @molecule/api-resource zod
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
### Interfaces
|
|
51
|
+
|
|
52
|
+
#### `ListMessagesOptions`
|
|
53
|
+
|
|
54
|
+
Options for paginating messages within a thread.
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
interface ListMessagesOptions {
|
|
58
|
+
/** Return messages strictly older than this timestamp (ISO 8601). */
|
|
59
|
+
before?: string
|
|
60
|
+
/** Maximum number of messages to return. Defaults to 50, max 200. */
|
|
61
|
+
limit?: number
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
#### `Message`
|
|
66
|
+
|
|
67
|
+
A single message within a {@link Thread}.
|
|
68
|
+
|
|
69
|
+
`editedAt` and `deletedAt` are `null` until the message is mutated. A
|
|
70
|
+
soft-deleted message (`deletedAt !== null`) is preserved for audit /
|
|
71
|
+
thread continuity but should not be rendered as content.
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
interface Message {
|
|
75
|
+
/** Unique message identifier. */
|
|
76
|
+
id: string
|
|
77
|
+
/** Thread this message belongs to. */
|
|
78
|
+
threadId: string
|
|
79
|
+
/** ID of the user who sent the message. */
|
|
80
|
+
senderId: string
|
|
81
|
+
/** Message body text. */
|
|
82
|
+
body: string
|
|
83
|
+
/** Optional file/image attachments. */
|
|
84
|
+
attachments: MessageAttachment[]
|
|
85
|
+
/** When the message was created (ISO 8601). */
|
|
86
|
+
createdAt: string
|
|
87
|
+
/** When the message was last edited, or `null`. */
|
|
88
|
+
editedAt: string | null
|
|
89
|
+
/** When the message was soft-deleted, or `null`. */
|
|
90
|
+
deletedAt: string | null
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
#### `MessageAttachment`
|
|
95
|
+
|
|
96
|
+
A message attachment reference.
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
interface MessageAttachment {
|
|
100
|
+
/** Public or signed URL to the attachment payload. */
|
|
101
|
+
url: string
|
|
102
|
+
/** MIME type of the attachment (e.g. `image/png`). */
|
|
103
|
+
mime: string
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### `Thread`
|
|
108
|
+
|
|
109
|
+
A 1:1 messaging thread between two participants.
|
|
110
|
+
|
|
111
|
+
`participantAId` and `participantBId` are stored canonically — the
|
|
112
|
+
smaller value (lexicographically) is always `participantAId`. Use
|
|
113
|
+
{@link getOrCreateThread} to look up or create a thread between two
|
|
114
|
+
users without worrying about ordering.
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
interface Thread {
|
|
118
|
+
/** Unique thread identifier. */
|
|
119
|
+
id: string
|
|
120
|
+
/** Lexicographically-smaller participant ID. */
|
|
121
|
+
participantAId: string
|
|
122
|
+
/** Lexicographically-larger participant ID. */
|
|
123
|
+
participantBId: string
|
|
124
|
+
/** Timestamp of the most recent message, or `null` if no messages exist. */
|
|
125
|
+
lastMessageAt: string | null
|
|
126
|
+
/** Number of messages participant A has not yet read. */
|
|
127
|
+
unreadCountA: number
|
|
128
|
+
/** Number of messages participant B has not yet read. */
|
|
129
|
+
unreadCountB: number
|
|
130
|
+
/** When the thread was created (ISO 8601). */
|
|
131
|
+
createdAt: string
|
|
132
|
+
/** When the thread was last updated (ISO 8601). */
|
|
133
|
+
updatedAt: string
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Types
|
|
138
|
+
|
|
139
|
+
#### `MessageRealtimeEvent`
|
|
140
|
+
|
|
141
|
+
Union of realtime event names.
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
type MessageRealtimeEvent = (typeof MESSAGE_REALTIME_EVENTS)[keyof typeof MESSAGE_REALTIME_EVENTS]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Functions
|
|
148
|
+
|
|
149
|
+
#### `canonicaliseParticipants(a, b)`
|
|
150
|
+
|
|
151
|
+
Canonicalises a (a, b) participant pair so that the lexicographically
|
|
152
|
+
smaller value is always returned first. This guarantees `(x, y)` and
|
|
153
|
+
`(y, x)` resolve to the same {@link Thread} row.
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
function canonicaliseParticipants(a: string, b: string): [string, string]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- `a` — First participant ID.
|
|
160
|
+
- `b` — Second participant ID.
|
|
161
|
+
|
|
162
|
+
**Returns:** A tuple `[participantAId, participantBId]` in canonical order.
|
|
163
|
+
|
|
164
|
+
#### `countMessages(threadId)`
|
|
165
|
+
|
|
166
|
+
Counts the messages persisted in a thread (including soft-deleted).
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
function countMessages(threadId: string): Promise<number>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- `threadId` — The thread ID.
|
|
173
|
+
|
|
174
|
+
**Returns:** Total stored message count.
|
|
175
|
+
|
|
176
|
+
#### `createThread(req, res)`
|
|
177
|
+
|
|
178
|
+
Resolves (or lazily creates) the canonical thread between the
|
|
179
|
+
authenticated user and a counter-participant.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
function createThread(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- `req` — The request with `participantId` in body.
|
|
186
|
+
- `res` — The response object.
|
|
187
|
+
|
|
188
|
+
#### `deleteMessage(messageId, senderId)`
|
|
189
|
+
|
|
190
|
+
Soft-deletes a message. Only the original sender may delete.
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
function deleteMessage(messageId: string, senderId: string): Promise<boolean>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- `messageId` — The message ID.
|
|
197
|
+
- `senderId` — The user ID claiming to be the message author.
|
|
198
|
+
|
|
199
|
+
**Returns:** `true` if the message was soft-deleted, `false` if not found or not authorised.
|
|
200
|
+
|
|
201
|
+
#### `deleteMessageHandler(req, res)`
|
|
202
|
+
|
|
203
|
+
Soft-deletes a message. Only the original sender may delete.
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
function deleteMessageHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- `req` — The request with `messageId` URL param.
|
|
210
|
+
- `res` — The response object.
|
|
211
|
+
|
|
212
|
+
#### `editMessage(messageId, senderId, body)`
|
|
213
|
+
|
|
214
|
+
Edits an existing message body. Only the original sender may edit.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
function editMessage(messageId: string, senderId: string, body: string): Promise<Message | null>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- `messageId` — The message ID.
|
|
221
|
+
- `senderId` — The user ID claiming to be the message author.
|
|
222
|
+
- `body` — New non-empty message body.
|
|
223
|
+
|
|
224
|
+
**Returns:** The updated message, or `null` if not found / not authorised.
|
|
225
|
+
|
|
226
|
+
#### `editMessageHandler(req, res)`
|
|
227
|
+
|
|
228
|
+
Edits a message body. Only the original sender may edit.
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
function editMessageHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- `req` — The request with `messageId` URL param and `body` in body.
|
|
235
|
+
- `res` — The response object.
|
|
236
|
+
|
|
237
|
+
#### `getOrCreateThread(a, b)`
|
|
238
|
+
|
|
239
|
+
Looks up the canonical thread between two participants, creating it
|
|
240
|
+
lazily if none exists.
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
function getOrCreateThread(a: string, b: string): Promise<Thread>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- `a` — First participant user ID.
|
|
247
|
+
- `b` — Second participant user ID.
|
|
248
|
+
|
|
249
|
+
**Returns:** The existing or newly-created thread.
|
|
250
|
+
|
|
251
|
+
#### `getThreadById(threadId)`
|
|
252
|
+
|
|
253
|
+
Retrieves a thread by ID.
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
function getThreadById(threadId: string): Promise<Thread | null>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- `threadId` — The thread ID.
|
|
260
|
+
|
|
261
|
+
**Returns:** The thread, or `null` if not found.
|
|
262
|
+
|
|
263
|
+
#### `getTotalUnreadCount(participantId)`
|
|
264
|
+
|
|
265
|
+
Counts unread messages for a participant across all of their threads.
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
function getTotalUnreadCount(participantId: string): Promise<number>
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
- `participantId` — The participant user ID.
|
|
272
|
+
|
|
273
|
+
**Returns:** Total unread message count across every thread.
|
|
274
|
+
|
|
275
|
+
#### `listMessages(threadId, options)`
|
|
276
|
+
|
|
277
|
+
Lists messages in a thread, newest first. Soft-deleted messages are
|
|
278
|
+
still returned (with `deletedAt` set) so clients can render
|
|
279
|
+
"this message was deleted" placeholders.
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
function listMessages(threadId: string, options?: ListMessagesOptions): Promise<Message[]>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
- `threadId` — The thread ID.
|
|
286
|
+
- `options` — Pagination options. `before` returns messages strictly older than the given ISO timestamp; `limit` is clamped to 200.
|
|
287
|
+
|
|
288
|
+
**Returns:** Array of messages, newest first.
|
|
289
|
+
|
|
290
|
+
#### `listMessagesHandler(req, res)`
|
|
291
|
+
|
|
292
|
+
Lists messages in a thread, newest first.
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
function listMessagesHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- `req` — The request with `threadId` URL param and optional `before` / `limit` query params.
|
|
299
|
+
- `res` — The response object.
|
|
300
|
+
|
|
301
|
+
#### `listThreads(req, res)`
|
|
302
|
+
|
|
303
|
+
Returns paginated threads for the authenticated user, newest first.
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
function listThreads(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
- `req` — The request with optional `limit` / `offset` query params.
|
|
310
|
+
- `res` — The response object.
|
|
311
|
+
|
|
312
|
+
#### `listThreadsForParticipant(participantId, options)`
|
|
313
|
+
|
|
314
|
+
Lists threads a participant is part of, ordered by most recent activity.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
function listThreadsForParticipant(
|
|
318
|
+
participantId: string,
|
|
319
|
+
options?: { limit?: number; offset?: number },
|
|
320
|
+
): Promise<Thread[]>
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
- `participantId` — The user ID to list threads for.
|
|
324
|
+
- `options` — Pagination options.
|
|
325
|
+
|
|
326
|
+
**Returns:** Array of threads in descending `lastMessageAt` order.
|
|
327
|
+
|
|
328
|
+
#### `markRead(threadId, readerId)`
|
|
329
|
+
|
|
330
|
+
Marks a thread as read for the given participant and resets their
|
|
331
|
+
unread counter to zero. Broadcasts a `message:read` event.
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
function markRead(threadId: string, readerId: string): Promise<void>
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
- `threadId` — The thread ID.
|
|
338
|
+
- `readerId` — The participant user ID marking the thread as read.
|
|
339
|
+
|
|
340
|
+
#### `markReadHandler(req, res)`
|
|
341
|
+
|
|
342
|
+
Marks a thread as read and zeroes the authenticated user's unread
|
|
343
|
+
counter for that thread. Broadcasts a `message:read` event over the
|
|
344
|
+
bonded realtime provider.
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
function markReadHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
- `req` — The request with `threadId` URL param.
|
|
351
|
+
- `res` — The response object.
|
|
352
|
+
|
|
353
|
+
#### `readThread(req, res)`
|
|
354
|
+
|
|
355
|
+
Returns the thread identified by `:threadId`. The authenticated user
|
|
356
|
+
must be a participant.
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
function readThread(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
- `req` — The request with `threadId` URL param.
|
|
363
|
+
- `res` — The response object.
|
|
364
|
+
|
|
365
|
+
#### `sendMessage(threadId, senderId, body, attachments)`
|
|
366
|
+
|
|
367
|
+
Sends a message in a thread. Updates `lastMessageAt` and bumps the
|
|
368
|
+
recipient's `unreadCount*` counter, then broadcasts the new message
|
|
369
|
+
over the bonded realtime provider (if any).
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
function sendMessage(
|
|
373
|
+
threadId: string,
|
|
374
|
+
senderId: string,
|
|
375
|
+
body: string,
|
|
376
|
+
attachments?: MessageAttachment[],
|
|
377
|
+
): Promise<Message>
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
- `threadId` — The target thread ID.
|
|
381
|
+
- `senderId` — The sending participant's user ID.
|
|
382
|
+
- `body` — Message body. Must be non-empty after trimming.
|
|
383
|
+
- `attachments` — Optional attachment list.
|
|
384
|
+
|
|
385
|
+
**Returns:** The persisted message.
|
|
386
|
+
|
|
387
|
+
#### `sendMessageHandler(req, res)`
|
|
388
|
+
|
|
389
|
+
Sends a new message in a thread. Authenticated user must be a
|
|
390
|
+
participant. Broadcasts a `message:sent` event when realtime is bonded.
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
function sendMessageHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
- `req` — The request with `threadId` URL param and message body.
|
|
397
|
+
- `res` — The response object.
|
|
398
|
+
|
|
399
|
+
#### `threadRoomId(threadId)`
|
|
400
|
+
|
|
401
|
+
Returns the realtime room ID for a given thread.
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
function threadRoomId(threadId: string): string
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
- `threadId` — The thread ID.
|
|
408
|
+
|
|
409
|
+
**Returns:** The room ID consumers should subscribe to for thread events.
|
|
410
|
+
|
|
411
|
+
#### `unreadCount(req, res)`
|
|
412
|
+
|
|
413
|
+
Returns the authenticated user's total unread message count.
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
function unreadCount(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
- `req` — The request.
|
|
420
|
+
- `res` — The response object.
|
|
421
|
+
|
|
422
|
+
### Constants
|
|
423
|
+
|
|
424
|
+
#### `editMessageSchema`
|
|
425
|
+
|
|
426
|
+
Schema for `editMessage` request input.
|
|
427
|
+
|
|
428
|
+
```typescript
|
|
429
|
+
const editMessageSchema: z.ZodObject<{ body: z.ZodString }, z.core.$strip>
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
#### `getOrCreateThreadSchema`
|
|
433
|
+
|
|
434
|
+
Schema for `getOrCreateThread` request input.
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
const getOrCreateThreadSchema: z.ZodObject<{ participantId: z.ZodString }, z.core.$strip>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
#### `i18nRegistered`
|
|
441
|
+
|
|
442
|
+
The i18n registered.
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
const i18nRegistered: true
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### `MESSAGE_REALTIME_EVENTS`
|
|
449
|
+
|
|
450
|
+
Realtime event names emitted by this package.
|
|
451
|
+
|
|
452
|
+
Subscribers should listen on the room ID returned from
|
|
453
|
+
{@link threadRoomId} for the thread they care about.
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
const MESSAGE_REALTIME_EVENTS: {
|
|
457
|
+
readonly messageSent: 'message:sent'
|
|
458
|
+
readonly messageRead: 'message:read'
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
#### `messageAttachmentSchema`
|
|
463
|
+
|
|
464
|
+
Schema for an individual message attachment.
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
const messageAttachmentSchema: z.ZodObject<{ url: z.ZodString; mime: z.ZodString }, z.core.$strip>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
#### `requestHandlerMap`
|
|
471
|
+
|
|
472
|
+
Handler map for message resource routes.
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
const requestHandlerMap: {
|
|
476
|
+
readonly createThread: typeof createThread
|
|
477
|
+
readonly listThreads: typeof listThreads
|
|
478
|
+
readonly readThread: typeof readThread
|
|
479
|
+
readonly listMessages: typeof listMessagesHandler
|
|
480
|
+
readonly sendMessage: typeof sendMessageHandler
|
|
481
|
+
readonly markRead: typeof markReadHandler
|
|
482
|
+
readonly editMessage: typeof editMessageHandler
|
|
483
|
+
readonly deleteMessage: typeof deleteMessageHandler
|
|
484
|
+
readonly unreadCount: typeof unreadCount
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
#### `routes`
|
|
489
|
+
|
|
490
|
+
Routes for 1:1 message threads, messages, and read-tracking.
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
const routes: readonly [
|
|
494
|
+
{
|
|
495
|
+
readonly method: 'post'
|
|
496
|
+
readonly path: '/message-threads'
|
|
497
|
+
readonly handler: 'createThread'
|
|
498
|
+
readonly middlewares: readonly ['authenticate']
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
readonly method: 'get'
|
|
502
|
+
readonly path: '/message-threads'
|
|
503
|
+
readonly handler: 'listThreads'
|
|
504
|
+
readonly middlewares: readonly ['authenticate']
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
readonly method: 'get'
|
|
508
|
+
readonly path: '/message-threads/unread-count'
|
|
509
|
+
readonly handler: 'unreadCount'
|
|
510
|
+
readonly middlewares: readonly ['authenticate']
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
readonly method: 'get'
|
|
514
|
+
readonly path: '/message-threads/:threadId'
|
|
515
|
+
readonly handler: 'readThread'
|
|
516
|
+
readonly middlewares: readonly ['authenticate']
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
readonly method: 'get'
|
|
520
|
+
readonly path: '/message-threads/:threadId/messages'
|
|
521
|
+
readonly handler: 'listMessages'
|
|
522
|
+
readonly middlewares: readonly ['authenticate']
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
readonly method: 'post'
|
|
526
|
+
readonly path: '/message-threads/:threadId/messages'
|
|
527
|
+
readonly handler: 'sendMessage'
|
|
528
|
+
readonly middlewares: readonly ['authenticate']
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
readonly method: 'post'
|
|
532
|
+
readonly path: '/message-threads/:threadId/read'
|
|
533
|
+
readonly handler: 'markRead'
|
|
534
|
+
readonly middlewares: readonly ['authenticate']
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
readonly method: 'patch'
|
|
538
|
+
readonly path: '/message-threads/messages/:messageId'
|
|
539
|
+
readonly handler: 'editMessage'
|
|
540
|
+
readonly middlewares: readonly ['authenticate']
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
readonly method: 'delete'
|
|
544
|
+
readonly path: '/message-threads/messages/:messageId'
|
|
545
|
+
readonly handler: 'deleteMessage'
|
|
546
|
+
readonly middlewares: readonly ['authenticate']
|
|
547
|
+
},
|
|
548
|
+
]
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
#### `sendMessageSchema`
|
|
552
|
+
|
|
553
|
+
Schema for `sendMessage` request input.
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
const sendMessageSchema: z.ZodObject<
|
|
557
|
+
{
|
|
558
|
+
body: z.ZodString
|
|
559
|
+
attachments: z.ZodOptional<
|
|
560
|
+
z.ZodArray<z.ZodObject<{ url: z.ZodString; mime: z.ZodString }, z.core.$strip>>
|
|
561
|
+
>
|
|
562
|
+
},
|
|
563
|
+
z.core.$strip
|
|
564
|
+
>
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
## Injection Notes
|
|
568
|
+
|
|
569
|
+
### Requirements
|
|
570
|
+
|
|
571
|
+
Peer dependencies:
|
|
572
|
+
|
|
573
|
+
- `@molecule/api-database` ^1.0.1
|
|
574
|
+
- `@molecule/api-i18n` ^1.0.1
|
|
575
|
+
- `@molecule/api-locales-resource-message` ^1.0.1
|
|
576
|
+
- `@molecule/api-logger` ^1.0.1
|
|
577
|
+
- `@molecule/api-realtime` ^1.0.1
|
|
578
|
+
- `@molecule/api-resource` ^1.0.1
|
|
579
|
+
- `zod` ^4.0.0
|
|
580
|
+
|
|
581
|
+
### Runtime Dependencies
|
|
582
|
+
|
|
583
|
+
- `@molecule/api-database`
|
|
584
|
+
- `@molecule/api-i18n`
|
|
585
|
+
- `@molecule/api-locales-resource-message`
|
|
586
|
+
- `@molecule/api-logger`
|
|
587
|
+
- `@molecule/api-realtime`
|
|
588
|
+
- `@molecule/api-resource`
|
|
589
|
+
- `zod`
|
|
590
|
+
|
|
591
|
+
Tables: `src/__setup__/messages.sql` creates `message_threads` + `messages`.
|
|
592
|
+
An mlcl-scaffolded API replays `__setup__/*.sql` automatically on migrate;
|
|
593
|
+
anywhere else run it once — nothing at runtime creates them.
|
|
594
|
+
|
|
595
|
+
Threads are strictly 1:1 — a canonicalised participant pair (creating a
|
|
596
|
+
thread with yourself throws) — and PARTICIPANT-SCOPED: every handler reads
|
|
597
|
+
the authenticated user from `res.locals.session` (401 without a session;
|
|
598
|
+
mount behind your global auth middleware) and rejects non-participants with
|
|
599
|
+
403/404. The sender is always the session user — never accept a sender id
|
|
600
|
+
from the request body.
|
|
601
|
+
|
|
602
|
+
Realtime delivery is best-effort — messages are persisted before any
|
|
603
|
+
broadcast attempt and a missing realtime bond is silently a no-op.
|
|
604
|
+
Subscribers should listen on `threadRoomId(threadId)` for
|
|
605
|
+
{@link MESSAGE_REALTIME_EVENTS} payloads.
|
|
606
|
+
|
|
607
|
+
User-facing strings go through `t(key, …, { defaultValue })`; translations
|
|
608
|
+
ship in the companion `@molecule/api-locales-resource-message` bond.
|
|
609
|
+
|
|
610
|
+
## E2E Tests
|
|
611
|
+
|
|
612
|
+
Integration checklist — drive the real messaging UI (live preview, no
|
|
613
|
+
mocks), adapt each item to this app's actual threads/screens, and check
|
|
614
|
+
every box off one by one. A box you can't check is an integration bug to
|
|
615
|
+
fix — not a skip. Messaging PRIVACY is the point here, so the last item is
|
|
616
|
+
not optional:
|
|
617
|
+
|
|
618
|
+
- [ ] Sending a message in a thread persists it and it appears in that
|
|
619
|
+
thread for BOTH participants in chronological (`createdAt`) order, stamped
|
|
620
|
+
with the sender and a timestamp. Send from each side and confirm both see
|
|
621
|
+
the same ordered transcript.
|
|
622
|
+
- [ ] Read / unread works: a message you send is unread for the recipient —
|
|
623
|
+
their thread badge and `GET /message-threads/unread-count` increment;
|
|
624
|
+
opening the thread and marking it read (`POST /message-threads/:threadId/read`)
|
|
625
|
+
clears that side's unread count to zero and the total badge drops to match.
|
|
626
|
+
- [ ] Editing a message shows an edited state (an "edited" marker /
|
|
627
|
+
`editedAt`) and deleting it removes it or renders a "message was deleted"
|
|
628
|
+
tombstone (`deletedAt`) — and ONLY the author can edit or delete their OWN
|
|
629
|
+
message: the other participant gets no edit/delete affordance and a forged
|
|
630
|
+
PATCH/DELETE on someone else's message is rejected, never applied.
|
|
631
|
+
- [ ] Delivery to the other participant: with `@molecule/api-realtime`
|
|
632
|
+
bonded, a new message appears in their already-open thread WITHOUT a
|
|
633
|
+
reload; with no realtime bond it appears on their next load/refresh.
|
|
634
|
+
- [ ] PRIVACY / AUTHORIZATION — a thread and its messages are visible ONLY
|
|
635
|
+
to its two participants. Sign in as a THIRD user and confirm they cannot
|
|
636
|
+
read the thread or any message by guessing its id (403/404), cannot post
|
|
637
|
+
into a thread they are not part of (403), and cannot spoof the sender —
|
|
638
|
+
the sender is always the session user, never a request-body field.
|
|
639
|
+
|
|
640
|
+
## Translations
|
|
641
|
+
|
|
642
|
+
Translation strings are provided by `@molecule/api-locales-resource-message`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"listThreads.d.ts","sourceRoot":"","sources":["../../src/handlers/listThreads.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"listThreads.d.ts","sourceRoot":"","sources":["../../src/handlers/listThreads.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAO/E;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB5F"}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { t } from '@molecule/api-i18n';
|
|
7
7
|
import { logger } from '@molecule/api-logger';
|
|
8
8
|
import { listThreadsForParticipant } from '../service.js';
|
|
9
|
+
/** Upper bound for the list page size — an uncapped `limit` is a table-dump primitive (e.g. `?limit=999999999`). */
|
|
10
|
+
const MAX_LIST_LIMIT = 500;
|
|
9
11
|
/**
|
|
10
12
|
* Returns paginated threads for the authenticated user, newest first.
|
|
11
13
|
*
|
|
@@ -21,7 +23,7 @@ export async function listThreads(req, res) {
|
|
|
21
23
|
});
|
|
22
24
|
return;
|
|
23
25
|
}
|
|
24
|
-
const limit = parseInt(req.query.limit, 10) || 50;
|
|
26
|
+
const limit = Math.min(MAX_LIST_LIMIT, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
|
25
27
|
const offset = parseInt(req.query.offset, 10) || 0;
|
|
26
28
|
try {
|
|
27
29
|
const threads = await listThreadsForParticipant(userId, { limit, offset });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@molecule/api-resource-message",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "1:1 messaging threads + messages with read-tracking and realtime broadcast",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
|
-
"dist"
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
21
22
|
],
|
|
22
23
|
"keywords": [
|
|
23
24
|
"molecule",
|
|
@@ -29,19 +30,19 @@
|
|
|
29
30
|
],
|
|
30
31
|
"license": "Apache-2.0",
|
|
31
32
|
"devDependencies": {
|
|
32
|
-
"@molecule/api-locales-resource-message": "1.0.
|
|
33
|
+
"@molecule/api-locales-resource-message": "1.0.1",
|
|
33
34
|
"@types/node": "26.1.2",
|
|
34
35
|
"typescript": "6.0.3",
|
|
35
|
-
"vitest": "4.1.
|
|
36
|
+
"vitest": "4.1.11",
|
|
36
37
|
"zod": "4.4.3"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
|
-
"@molecule/api-database": "^1.0.
|
|
40
|
-
"@molecule/api-i18n": "^1.0.
|
|
41
|
-
"@molecule/api-locales-resource-message": "^1.0.
|
|
42
|
-
"@molecule/api-logger": "^1.0.
|
|
43
|
-
"@molecule/api-realtime": "^1.0.
|
|
44
|
-
"@molecule/api-resource": "^1.0.
|
|
40
|
+
"@molecule/api-database": "^1.0.1",
|
|
41
|
+
"@molecule/api-i18n": "^1.0.1",
|
|
42
|
+
"@molecule/api-locales-resource-message": "^1.0.1",
|
|
43
|
+
"@molecule/api-logger": "^1.0.1",
|
|
44
|
+
"@molecule/api-realtime": "^1.0.1",
|
|
45
|
+
"@molecule/api-resource": "^1.0.1",
|
|
45
46
|
"zod": "^4.0.0"
|
|
46
47
|
},
|
|
47
48
|
"peerDependenciesMeta": {
|