@molecule/api-resource-message 1.0.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/LICENSE +115 -0
- package/dist/authorizers/index.d.ts +12 -0
- package/dist/authorizers/index.d.ts.map +1 -0
- package/dist/authorizers/index.js +11 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +18 -0
- package/dist/handlers/createThread.d.ts +15 -0
- package/dist/handlers/createThread.d.ts.map +1 -0
- package/dist/handlers/createThread.js +54 -0
- package/dist/handlers/deleteMessage.d.ts +14 -0
- package/dist/handlers/deleteMessage.d.ts.map +1 -0
- package/dist/handlers/deleteMessage.js +56 -0
- package/dist/handlers/editMessage.d.ts +14 -0
- package/dist/handlers/editMessage.d.ts.map +1 -0
- package/dist/handlers/editMessage.js +63 -0
- package/dist/handlers/index.d.ts +9 -0
- package/dist/handlers/index.d.ts.map +1 -0
- package/dist/handlers/index.js +8 -0
- package/dist/handlers/listThreads.d.ts +14 -0
- package/dist/handlers/listThreads.d.ts.map +1 -0
- package/dist/handlers/listThreads.js +39 -0
- package/dist/handlers/markRead.d.ts +16 -0
- package/dist/handlers/markRead.d.ts.map +1 -0
- package/dist/handlers/markRead.js +68 -0
- package/dist/handlers/messages.d.ts +23 -0
- package/dist/handlers/messages.d.ts.map +1 -0
- package/dist/handlers/messages.js +135 -0
- package/dist/handlers/readThread.d.ts +15 -0
- package/dist/handlers/readThread.d.ts.map +1 -0
- package/dist/handlers/readThread.js +66 -0
- package/dist/handlers/unreadCount.d.ts +14 -0
- package/dist/handlers/unreadCount.d.ts.map +1 -0
- package/dist/handlers/unreadCount.js +37 -0
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +17 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/requestHandlerMap.d.ts +28 -0
- package/dist/requestHandlerMap.d.ts.map +1 -0
- package/dist/requestHandlerMap.js +27 -0
- package/dist/routes.d.ts +58 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +67 -0
- package/dist/service.d.ts +117 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +301 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +32 -0
- package/dist/validation.d.ts +36 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +38 -0
- package/package.json +74 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send + list messages within a thread.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { t } from '@molecule/api-i18n';
|
|
7
|
+
import { logger } from '@molecule/api-logger';
|
|
8
|
+
import { getThreadById, listMessages, sendMessage } from '../service.js';
|
|
9
|
+
import { sendMessageSchema } from '../validation.js';
|
|
10
|
+
/**
|
|
11
|
+
* Lists messages in a thread, newest first.
|
|
12
|
+
*
|
|
13
|
+
* @param req - The request with `threadId` URL param and optional
|
|
14
|
+
* `before` / `limit` query params.
|
|
15
|
+
* @param res - The response object.
|
|
16
|
+
*/
|
|
17
|
+
export async function listMessagesHandler(req, res) {
|
|
18
|
+
const userId = res.locals.session?.userId;
|
|
19
|
+
if (!userId) {
|
|
20
|
+
res.status(401).json({
|
|
21
|
+
error: t('resource.error.unauthorized', undefined, { defaultValue: 'Unauthorized' }),
|
|
22
|
+
errorKey: 'resource.error.unauthorized',
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const { threadId } = req.params;
|
|
27
|
+
if (!threadId) {
|
|
28
|
+
res.status(400).json({
|
|
29
|
+
error: t('message.error.missingThreadId', undefined, {
|
|
30
|
+
defaultValue: 'Thread ID is required',
|
|
31
|
+
}),
|
|
32
|
+
errorKey: 'message.error.missingThreadId',
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const thread = await getThreadById(threadId);
|
|
37
|
+
if (!thread) {
|
|
38
|
+
res.status(404).json({
|
|
39
|
+
error: t('message.error.threadNotFound', undefined, {
|
|
40
|
+
defaultValue: 'Thread not found',
|
|
41
|
+
}),
|
|
42
|
+
errorKey: 'message.error.threadNotFound',
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (thread.participantAId !== userId && thread.participantBId !== userId) {
|
|
47
|
+
res.status(403).json({
|
|
48
|
+
error: t('message.error.notParticipant', undefined, {
|
|
49
|
+
defaultValue: 'You are not a participant in this thread',
|
|
50
|
+
}),
|
|
51
|
+
errorKey: 'message.error.notParticipant',
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
|
|
56
|
+
const limit = parseInt(req.query.limit, 10) || 50;
|
|
57
|
+
try {
|
|
58
|
+
const messages = await listMessages(threadId, { before, limit });
|
|
59
|
+
res.json({ data: messages, limit });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
logger.error('Failed to list messages', { threadId, error });
|
|
63
|
+
res.status(500).json({
|
|
64
|
+
error: t('message.error.listMessagesFailed', undefined, {
|
|
65
|
+
defaultValue: 'Failed to list messages',
|
|
66
|
+
}),
|
|
67
|
+
errorKey: 'message.error.listMessagesFailed',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Sends a new message in a thread. Authenticated user must be a
|
|
73
|
+
* participant. Broadcasts a `message:sent` event when realtime is bonded.
|
|
74
|
+
*
|
|
75
|
+
* @param req - The request with `threadId` URL param and message body.
|
|
76
|
+
* @param res - The response object.
|
|
77
|
+
*/
|
|
78
|
+
export async function sendMessageHandler(req, res) {
|
|
79
|
+
const userId = res.locals.session?.userId;
|
|
80
|
+
if (!userId) {
|
|
81
|
+
res.status(401).json({
|
|
82
|
+
error: t('resource.error.unauthorized', undefined, { defaultValue: 'Unauthorized' }),
|
|
83
|
+
errorKey: 'resource.error.unauthorized',
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const { threadId } = req.params;
|
|
88
|
+
if (!threadId) {
|
|
89
|
+
res.status(400).json({
|
|
90
|
+
error: t('message.error.missingThreadId', undefined, {
|
|
91
|
+
defaultValue: 'Thread ID is required',
|
|
92
|
+
}),
|
|
93
|
+
errorKey: 'message.error.missingThreadId',
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const parsed = sendMessageSchema.safeParse(req.body);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
const errors = parsed.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
|
100
|
+
res.status(400).json({ error: errors, errorKey: 'message.error.validationFailed' });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const message = await sendMessage(threadId, userId, parsed.data.body, parsed.data.attachments);
|
|
105
|
+
res.status(201).json(message);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
109
|
+
if (errorMessage.includes('not found')) {
|
|
110
|
+
res.status(404).json({
|
|
111
|
+
error: t('message.error.threadNotFound', undefined, {
|
|
112
|
+
defaultValue: 'Thread not found',
|
|
113
|
+
}),
|
|
114
|
+
errorKey: 'message.error.threadNotFound',
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (errorMessage.includes('not a participant')) {
|
|
119
|
+
res.status(403).json({
|
|
120
|
+
error: t('message.error.notParticipant', undefined, {
|
|
121
|
+
defaultValue: 'You are not a participant in this thread',
|
|
122
|
+
}),
|
|
123
|
+
errorKey: 'message.error.notParticipant',
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
logger.error('Failed to send message', { threadId, userId, error });
|
|
128
|
+
res.status(500).json({
|
|
129
|
+
error: t('message.error.sendFailed', undefined, {
|
|
130
|
+
defaultValue: 'Failed to send message',
|
|
131
|
+
}),
|
|
132
|
+
errorKey: 'message.error.sendFailed',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a single thread by ID.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import type { MoleculeRequest, MoleculeResponse } from '@molecule/api-resource';
|
|
7
|
+
/**
|
|
8
|
+
* Returns the thread identified by `:threadId`. The authenticated user
|
|
9
|
+
* must be a participant.
|
|
10
|
+
*
|
|
11
|
+
* @param req - The request with `threadId` URL param.
|
|
12
|
+
* @param res - The response object.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readThread(req: MoleculeRequest, res: MoleculeResponse): Promise<void>;
|
|
15
|
+
//# sourceMappingURL=readThread.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"readThread.d.ts","sourceRoot":"","sources":["../../src/handlers/readThread.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAI/E;;;;;;GAMG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmD3F"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a single thread by ID.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { t } from '@molecule/api-i18n';
|
|
7
|
+
import { logger } from '@molecule/api-logger';
|
|
8
|
+
import { getThreadById } from '../service.js';
|
|
9
|
+
/**
|
|
10
|
+
* Returns the thread identified by `:threadId`. The authenticated user
|
|
11
|
+
* must be a participant.
|
|
12
|
+
*
|
|
13
|
+
* @param req - The request with `threadId` URL param.
|
|
14
|
+
* @param res - The response object.
|
|
15
|
+
*/
|
|
16
|
+
export async function readThread(req, res) {
|
|
17
|
+
const userId = res.locals.session?.userId;
|
|
18
|
+
if (!userId) {
|
|
19
|
+
res.status(401).json({
|
|
20
|
+
error: t('resource.error.unauthorized', undefined, { defaultValue: 'Unauthorized' }),
|
|
21
|
+
errorKey: 'resource.error.unauthorized',
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const { threadId } = req.params;
|
|
26
|
+
if (!threadId) {
|
|
27
|
+
res.status(400).json({
|
|
28
|
+
error: t('message.error.missingThreadId', undefined, {
|
|
29
|
+
defaultValue: 'Thread ID is required',
|
|
30
|
+
}),
|
|
31
|
+
errorKey: 'message.error.missingThreadId',
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const thread = await getThreadById(threadId);
|
|
37
|
+
if (!thread) {
|
|
38
|
+
res.status(404).json({
|
|
39
|
+
error: t('message.error.threadNotFound', undefined, {
|
|
40
|
+
defaultValue: 'Thread not found',
|
|
41
|
+
}),
|
|
42
|
+
errorKey: 'message.error.threadNotFound',
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (thread.participantAId !== userId && thread.participantBId !== userId) {
|
|
47
|
+
res.status(403).json({
|
|
48
|
+
error: t('message.error.notParticipant', undefined, {
|
|
49
|
+
defaultValue: 'You are not a participant in this thread',
|
|
50
|
+
}),
|
|
51
|
+
errorKey: 'message.error.notParticipant',
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
res.json(thread);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
logger.error('Failed to read thread', { threadId, error });
|
|
59
|
+
res.status(500).json({
|
|
60
|
+
error: t('message.error.readThreadFailed', undefined, {
|
|
61
|
+
defaultValue: 'Failed to read thread',
|
|
62
|
+
}),
|
|
63
|
+
errorKey: 'message.error.readThreadFailed',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Total unread count across all the authenticated user's threads.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import type { MoleculeRequest, MoleculeResponse } from '@molecule/api-resource';
|
|
7
|
+
/**
|
|
8
|
+
* Returns the authenticated user's total unread message count.
|
|
9
|
+
*
|
|
10
|
+
* @param req - The request.
|
|
11
|
+
* @param res - The response object.
|
|
12
|
+
*/
|
|
13
|
+
export declare function unreadCount(req: MoleculeRequest, res: MoleculeResponse): Promise<void>;
|
|
14
|
+
//# sourceMappingURL=unreadCount.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unreadCount.d.ts","sourceRoot":"","sources":["../../src/handlers/unreadCount.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAI/E;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsB5F"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Total unread count across all the authenticated user's threads.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { t } from '@molecule/api-i18n';
|
|
7
|
+
import { logger } from '@molecule/api-logger';
|
|
8
|
+
import { getTotalUnreadCount } from '../service.js';
|
|
9
|
+
/**
|
|
10
|
+
* Returns the authenticated user's total unread message count.
|
|
11
|
+
*
|
|
12
|
+
* @param req - The request.
|
|
13
|
+
* @param res - The response object.
|
|
14
|
+
*/
|
|
15
|
+
export async function unreadCount(req, res) {
|
|
16
|
+
const userId = res.locals.session?.userId;
|
|
17
|
+
if (!userId) {
|
|
18
|
+
res.status(401).json({
|
|
19
|
+
error: t('resource.error.unauthorized', undefined, { defaultValue: 'Unauthorized' }),
|
|
20
|
+
errorKey: 'resource.error.unauthorized',
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const total = await getTotalUnreadCount(userId);
|
|
26
|
+
res.json({ unreadCount: total });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
logger.error('Failed to get unread count', { userId, error });
|
|
30
|
+
res.status(500).json({
|
|
31
|
+
error: t('message.error.unreadCountFailed', undefined, {
|
|
32
|
+
defaultValue: 'Failed to get unread count',
|
|
33
|
+
}),
|
|
34
|
+
errorKey: 'message.error.unreadCountFailed',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAWH;;GAEG;AACH,eAAO,MAAM,cAAc,OAAO,CAAA"}
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale registration for the message resource.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { registerLocaleModule } from '@molecule/api-i18n';
|
|
7
|
+
try {
|
|
8
|
+
const locales = await import('@molecule/api-locales-resource-message');
|
|
9
|
+
registerLocaleModule(locales);
|
|
10
|
+
}
|
|
11
|
+
catch (_error) {
|
|
12
|
+
// Locale package not available — translations fall back to inline defaults.
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The i18n registered.
|
|
16
|
+
*/
|
|
17
|
+
export const i18nRegistered = true;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1:1 messaging resource for molecule.dev.
|
|
3
|
+
*
|
|
4
|
+
* Direct-message threads between two participants with read-tracking,
|
|
5
|
+
* unread counters, optional attachments, and realtime broadcast over
|
|
6
|
+
* `@molecule/api-realtime` when bonded.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { routes, requestHandlerMap } from '@molecule/api-resource-message'
|
|
12
|
+
*
|
|
13
|
+
* // Wire routes into your Express app via mlcl inject
|
|
14
|
+
* // POST /message-threads
|
|
15
|
+
* // GET /message-threads
|
|
16
|
+
* // GET /message-threads/unread-count
|
|
17
|
+
* // GET /message-threads/:threadId
|
|
18
|
+
* // GET /message-threads/:threadId/messages
|
|
19
|
+
* // POST /message-threads/:threadId/messages
|
|
20
|
+
* // POST /message-threads/:threadId/read
|
|
21
|
+
* // PATCH /message-threads/messages/:messageId
|
|
22
|
+
* // DELETE /message-threads/messages/:messageId
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* Tables: `src/__setup__/messages.sql` creates `message_threads` + `messages`.
|
|
27
|
+
* An mlcl-scaffolded API replays `__setup__/*.sql` automatically on migrate;
|
|
28
|
+
* anywhere else run it once — nothing at runtime creates them.
|
|
29
|
+
*
|
|
30
|
+
* Threads are strictly 1:1 — a canonicalised participant pair (creating a
|
|
31
|
+
* thread with yourself throws) — and PARTICIPANT-SCOPED: every handler reads
|
|
32
|
+
* the authenticated user from `res.locals.session` (401 without a session;
|
|
33
|
+
* mount behind your global auth middleware) and rejects non-participants with
|
|
34
|
+
* 403/404. The sender is always the session user — never accept a sender id
|
|
35
|
+
* from the request body.
|
|
36
|
+
*
|
|
37
|
+
* Realtime delivery is best-effort — messages are persisted before any
|
|
38
|
+
* broadcast attempt and a missing realtime bond is silently a no-op.
|
|
39
|
+
* Subscribers should listen on `threadRoomId(threadId)` for
|
|
40
|
+
* {@link MESSAGE_REALTIME_EVENTS} payloads.
|
|
41
|
+
*
|
|
42
|
+
* User-facing strings go through `t(key, …, { defaultValue })`; translations
|
|
43
|
+
* ship in the companion `@molecule/api-locales-resource-message` bond.
|
|
44
|
+
*
|
|
45
|
+
* @e2e
|
|
46
|
+
* Integration checklist — drive the real messaging UI (live preview, no
|
|
47
|
+
* mocks), adapt each item to this app's actual threads/screens, and check
|
|
48
|
+
* every box off one by one. A box you can't check is an integration bug to
|
|
49
|
+
* fix — not a skip. Messaging PRIVACY is the point here, so the last item is
|
|
50
|
+
* not optional:
|
|
51
|
+
* - [ ] Sending a message in a thread persists it and it appears in that
|
|
52
|
+
* thread for BOTH participants in chronological (`createdAt`) order, stamped
|
|
53
|
+
* with the sender and a timestamp. Send from each side and confirm both see
|
|
54
|
+
* the same ordered transcript.
|
|
55
|
+
* - [ ] Read / unread works: a message you send is unread for the recipient —
|
|
56
|
+
* their thread badge and `GET /message-threads/unread-count` increment;
|
|
57
|
+
* opening the thread and marking it read (`POST /message-threads/:threadId/read`)
|
|
58
|
+
* clears that side's unread count to zero and the total badge drops to match.
|
|
59
|
+
* - [ ] Editing a message shows an edited state (an "edited" marker /
|
|
60
|
+
* `editedAt`) and deleting it removes it or renders a "message was deleted"
|
|
61
|
+
* tombstone (`deletedAt`) — and ONLY the author can edit or delete their OWN
|
|
62
|
+
* message: the other participant gets no edit/delete affordance and a forged
|
|
63
|
+
* PATCH/DELETE on someone else's message is rejected, never applied.
|
|
64
|
+
* - [ ] Delivery to the other participant: with `@molecule/api-realtime`
|
|
65
|
+
* bonded, a new message appears in their already-open thread WITHOUT a
|
|
66
|
+
* reload; with no realtime bond it appears on their next load/refresh.
|
|
67
|
+
* - [ ] PRIVACY / AUTHORIZATION — a thread and its messages are visible ONLY
|
|
68
|
+
* to its two participants. Sign in as a THIRD user and confirm they cannot
|
|
69
|
+
* read the thread or any message by guessing its id (403/404), cannot post
|
|
70
|
+
* into a thread they are not part of (403), and cannot spoof the sender —
|
|
71
|
+
* the sender is always the session user, never a request-body field.
|
|
72
|
+
*/
|
|
73
|
+
export * from './authorizers/index.js';
|
|
74
|
+
export * from './browser-guard.js';
|
|
75
|
+
export * from './handlers/index.js';
|
|
76
|
+
export * from './i18n.js';
|
|
77
|
+
export * from './requestHandlerMap.js';
|
|
78
|
+
export * from './routes.js';
|
|
79
|
+
export * from './service.js';
|
|
80
|
+
export * from './types.js';
|
|
81
|
+
export * from './validation.js';
|
|
82
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AAEH,cAAc,wBAAwB,CAAA;AACtC,cAAc,oBAAoB,CAAA;AAClC,cAAc,qBAAqB,CAAA;AACnC,cAAc,WAAW,CAAA;AACzB,cAAc,wBAAwB,CAAA;AACtC,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,YAAY,CAAA;AAC1B,cAAc,iBAAiB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1:1 messaging resource for molecule.dev.
|
|
3
|
+
*
|
|
4
|
+
* Direct-message threads between two participants with read-tracking,
|
|
5
|
+
* unread counters, optional attachments, and realtime broadcast over
|
|
6
|
+
* `@molecule/api-realtime` when bonded.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { routes, requestHandlerMap } from '@molecule/api-resource-message'
|
|
12
|
+
*
|
|
13
|
+
* // Wire routes into your Express app via mlcl inject
|
|
14
|
+
* // POST /message-threads
|
|
15
|
+
* // GET /message-threads
|
|
16
|
+
* // GET /message-threads/unread-count
|
|
17
|
+
* // GET /message-threads/:threadId
|
|
18
|
+
* // GET /message-threads/:threadId/messages
|
|
19
|
+
* // POST /message-threads/:threadId/messages
|
|
20
|
+
* // POST /message-threads/:threadId/read
|
|
21
|
+
* // PATCH /message-threads/messages/:messageId
|
|
22
|
+
* // DELETE /message-threads/messages/:messageId
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* Tables: `src/__setup__/messages.sql` creates `message_threads` + `messages`.
|
|
27
|
+
* An mlcl-scaffolded API replays `__setup__/*.sql` automatically on migrate;
|
|
28
|
+
* anywhere else run it once — nothing at runtime creates them.
|
|
29
|
+
*
|
|
30
|
+
* Threads are strictly 1:1 — a canonicalised participant pair (creating a
|
|
31
|
+
* thread with yourself throws) — and PARTICIPANT-SCOPED: every handler reads
|
|
32
|
+
* the authenticated user from `res.locals.session` (401 without a session;
|
|
33
|
+
* mount behind your global auth middleware) and rejects non-participants with
|
|
34
|
+
* 403/404. The sender is always the session user — never accept a sender id
|
|
35
|
+
* from the request body.
|
|
36
|
+
*
|
|
37
|
+
* Realtime delivery is best-effort — messages are persisted before any
|
|
38
|
+
* broadcast attempt and a missing realtime bond is silently a no-op.
|
|
39
|
+
* Subscribers should listen on `threadRoomId(threadId)` for
|
|
40
|
+
* {@link MESSAGE_REALTIME_EVENTS} payloads.
|
|
41
|
+
*
|
|
42
|
+
* User-facing strings go through `t(key, …, { defaultValue })`; translations
|
|
43
|
+
* ship in the companion `@molecule/api-locales-resource-message` bond.
|
|
44
|
+
*
|
|
45
|
+
* @e2e
|
|
46
|
+
* Integration checklist — drive the real messaging UI (live preview, no
|
|
47
|
+
* mocks), adapt each item to this app's actual threads/screens, and check
|
|
48
|
+
* every box off one by one. A box you can't check is an integration bug to
|
|
49
|
+
* fix — not a skip. Messaging PRIVACY is the point here, so the last item is
|
|
50
|
+
* not optional:
|
|
51
|
+
* - [ ] Sending a message in a thread persists it and it appears in that
|
|
52
|
+
* thread for BOTH participants in chronological (`createdAt`) order, stamped
|
|
53
|
+
* with the sender and a timestamp. Send from each side and confirm both see
|
|
54
|
+
* the same ordered transcript.
|
|
55
|
+
* - [ ] Read / unread works: a message you send is unread for the recipient —
|
|
56
|
+
* their thread badge and `GET /message-threads/unread-count` increment;
|
|
57
|
+
* opening the thread and marking it read (`POST /message-threads/:threadId/read`)
|
|
58
|
+
* clears that side's unread count to zero and the total badge drops to match.
|
|
59
|
+
* - [ ] Editing a message shows an edited state (an "edited" marker /
|
|
60
|
+
* `editedAt`) and deleting it removes it or renders a "message was deleted"
|
|
61
|
+
* tombstone (`deletedAt`) — and ONLY the author can edit or delete their OWN
|
|
62
|
+
* message: the other participant gets no edit/delete affordance and a forged
|
|
63
|
+
* PATCH/DELETE on someone else's message is rejected, never applied.
|
|
64
|
+
* - [ ] Delivery to the other participant: with `@molecule/api-realtime`
|
|
65
|
+
* bonded, a new message appears in their already-open thread WITHOUT a
|
|
66
|
+
* reload; with no realtime bond it appears on their next load/refresh.
|
|
67
|
+
* - [ ] PRIVACY / AUTHORIZATION — a thread and its messages are visible ONLY
|
|
68
|
+
* to its two participants. Sign in as a THIRD user and confirm they cannot
|
|
69
|
+
* read the thread or any message by guessing its id (403/404), cannot post
|
|
70
|
+
* into a thread they are not part of (403), and cannot spoof the sender —
|
|
71
|
+
* the sender is always the session user, never a request-body field.
|
|
72
|
+
*/
|
|
73
|
+
export * from './authorizers/index.js';
|
|
74
|
+
export * from './browser-guard.js';
|
|
75
|
+
export * from './handlers/index.js';
|
|
76
|
+
export * from './i18n.js';
|
|
77
|
+
export * from './requestHandlerMap.js';
|
|
78
|
+
export * from './routes.js';
|
|
79
|
+
export * from './service.js';
|
|
80
|
+
export * from './types.js';
|
|
81
|
+
export * from './validation.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps route handler names to their implementations.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { createThread } from './handlers/createThread.js';
|
|
7
|
+
import { deleteMessageHandler } from './handlers/deleteMessage.js';
|
|
8
|
+
import { editMessageHandler } from './handlers/editMessage.js';
|
|
9
|
+
import { listThreads } from './handlers/listThreads.js';
|
|
10
|
+
import { markReadHandler } from './handlers/markRead.js';
|
|
11
|
+
import { listMessagesHandler, sendMessageHandler } from './handlers/messages.js';
|
|
12
|
+
import { readThread } from './handlers/readThread.js';
|
|
13
|
+
import { unreadCount } from './handlers/unreadCount.js';
|
|
14
|
+
/**
|
|
15
|
+
* Handler map for message resource routes.
|
|
16
|
+
*/
|
|
17
|
+
export declare const requestHandlerMap: {
|
|
18
|
+
readonly createThread: typeof createThread;
|
|
19
|
+
readonly listThreads: typeof listThreads;
|
|
20
|
+
readonly readThread: typeof readThread;
|
|
21
|
+
readonly listMessages: typeof listMessagesHandler;
|
|
22
|
+
readonly sendMessage: typeof sendMessageHandler;
|
|
23
|
+
readonly markRead: typeof markReadHandler;
|
|
24
|
+
readonly editMessage: typeof editMessageHandler;
|
|
25
|
+
readonly deleteMessage: typeof deleteMessageHandler;
|
|
26
|
+
readonly unreadCount: typeof unreadCount;
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=requestHandlerMap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"requestHandlerMap.d.ts","sourceRoot":"","sources":["../src/requestHandlerMap.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAA;AACzD,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAA;AAClE,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAC9D,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxD,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAChF,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AAEvD;;GAEG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;;;CAUpB,CAAA"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps route handler names to their implementations.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { createThread } from './handlers/createThread.js';
|
|
7
|
+
import { deleteMessageHandler } from './handlers/deleteMessage.js';
|
|
8
|
+
import { editMessageHandler } from './handlers/editMessage.js';
|
|
9
|
+
import { listThreads } from './handlers/listThreads.js';
|
|
10
|
+
import { markReadHandler } from './handlers/markRead.js';
|
|
11
|
+
import { listMessagesHandler, sendMessageHandler } from './handlers/messages.js';
|
|
12
|
+
import { readThread } from './handlers/readThread.js';
|
|
13
|
+
import { unreadCount } from './handlers/unreadCount.js';
|
|
14
|
+
/**
|
|
15
|
+
* Handler map for message resource routes.
|
|
16
|
+
*/
|
|
17
|
+
export const requestHandlerMap = {
|
|
18
|
+
createThread,
|
|
19
|
+
listThreads,
|
|
20
|
+
readThread,
|
|
21
|
+
listMessages: listMessagesHandler,
|
|
22
|
+
sendMessage: sendMessageHandler,
|
|
23
|
+
markRead: markReadHandler,
|
|
24
|
+
editMessage: editMessageHandler,
|
|
25
|
+
deleteMessage: deleteMessageHandler,
|
|
26
|
+
unreadCount,
|
|
27
|
+
};
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message resource route definitions.
|
|
3
|
+
*
|
|
4
|
+
* Declarative routing — `mlcl inject` reads these to generate the
|
|
5
|
+
* Express router.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Routes for 1:1 message threads, messages, and read-tracking.
|
|
11
|
+
*/
|
|
12
|
+
export declare const routes: readonly [{
|
|
13
|
+
readonly method: "post";
|
|
14
|
+
readonly path: "/message-threads";
|
|
15
|
+
readonly handler: "createThread";
|
|
16
|
+
readonly middlewares: readonly ["authenticate"];
|
|
17
|
+
}, {
|
|
18
|
+
readonly method: "get";
|
|
19
|
+
readonly path: "/message-threads";
|
|
20
|
+
readonly handler: "listThreads";
|
|
21
|
+
readonly middlewares: readonly ["authenticate"];
|
|
22
|
+
}, {
|
|
23
|
+
readonly method: "get";
|
|
24
|
+
readonly path: "/message-threads/unread-count";
|
|
25
|
+
readonly handler: "unreadCount";
|
|
26
|
+
readonly middlewares: readonly ["authenticate"];
|
|
27
|
+
}, {
|
|
28
|
+
readonly method: "get";
|
|
29
|
+
readonly path: "/message-threads/:threadId";
|
|
30
|
+
readonly handler: "readThread";
|
|
31
|
+
readonly middlewares: readonly ["authenticate"];
|
|
32
|
+
}, {
|
|
33
|
+
readonly method: "get";
|
|
34
|
+
readonly path: "/message-threads/:threadId/messages";
|
|
35
|
+
readonly handler: "listMessages";
|
|
36
|
+
readonly middlewares: readonly ["authenticate"];
|
|
37
|
+
}, {
|
|
38
|
+
readonly method: "post";
|
|
39
|
+
readonly path: "/message-threads/:threadId/messages";
|
|
40
|
+
readonly handler: "sendMessage";
|
|
41
|
+
readonly middlewares: readonly ["authenticate"];
|
|
42
|
+
}, {
|
|
43
|
+
readonly method: "post";
|
|
44
|
+
readonly path: "/message-threads/:threadId/read";
|
|
45
|
+
readonly handler: "markRead";
|
|
46
|
+
readonly middlewares: readonly ["authenticate"];
|
|
47
|
+
}, {
|
|
48
|
+
readonly method: "patch";
|
|
49
|
+
readonly path: "/message-threads/messages/:messageId";
|
|
50
|
+
readonly handler: "editMessage";
|
|
51
|
+
readonly middlewares: readonly ["authenticate"];
|
|
52
|
+
}, {
|
|
53
|
+
readonly method: "delete";
|
|
54
|
+
readonly path: "/message-threads/messages/:messageId";
|
|
55
|
+
readonly handler: "deleteMessage";
|
|
56
|
+
readonly middlewares: readonly ["authenticate"];
|
|
57
|
+
}];
|
|
58
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;GAEG;AACH,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAuDT,CAAA"}
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message resource route definitions.
|
|
3
|
+
*
|
|
4
|
+
* Declarative routing — `mlcl inject` reads these to generate the
|
|
5
|
+
* Express router.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Routes for 1:1 message threads, messages, and read-tracking.
|
|
11
|
+
*/
|
|
12
|
+
export const routes = [
|
|
13
|
+
{
|
|
14
|
+
method: 'post',
|
|
15
|
+
path: '/message-threads',
|
|
16
|
+
handler: 'createThread',
|
|
17
|
+
middlewares: ['authenticate'],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
method: 'get',
|
|
21
|
+
path: '/message-threads',
|
|
22
|
+
handler: 'listThreads',
|
|
23
|
+
middlewares: ['authenticate'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
method: 'get',
|
|
27
|
+
path: '/message-threads/unread-count',
|
|
28
|
+
handler: 'unreadCount',
|
|
29
|
+
middlewares: ['authenticate'],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
method: 'get',
|
|
33
|
+
path: '/message-threads/:threadId',
|
|
34
|
+
handler: 'readThread',
|
|
35
|
+
middlewares: ['authenticate'],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
method: 'get',
|
|
39
|
+
path: '/message-threads/:threadId/messages',
|
|
40
|
+
handler: 'listMessages',
|
|
41
|
+
middlewares: ['authenticate'],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
method: 'post',
|
|
45
|
+
path: '/message-threads/:threadId/messages',
|
|
46
|
+
handler: 'sendMessage',
|
|
47
|
+
middlewares: ['authenticate'],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
method: 'post',
|
|
51
|
+
path: '/message-threads/:threadId/read',
|
|
52
|
+
handler: 'markRead',
|
|
53
|
+
middlewares: ['authenticate'],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
method: 'patch',
|
|
57
|
+
path: '/message-threads/messages/:messageId',
|
|
58
|
+
handler: 'editMessage',
|
|
59
|
+
middlewares: ['authenticate'],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
method: 'delete',
|
|
63
|
+
path: '/message-threads/messages/:messageId',
|
|
64
|
+
handler: 'deleteMessage',
|
|
65
|
+
middlewares: ['authenticate'],
|
|
66
|
+
},
|
|
67
|
+
];
|