@open-mercato/core 0.6.7-develop.6773.1.f9b7fbae4f → 0.6.7-develop.6775.1.c2313bb8a3

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.
@@ -1,5 +1,14 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { z } from "zod";
2
3
  import { sql } from "kysely";
4
+ import { runWithCacheTenant } from "@open-mercato/cache";
5
+ import {
6
+ buildCollectionTags,
7
+ debugCrudCache,
8
+ isCrudCacheEnabled,
9
+ normalizeTagSegment,
10
+ resolveCrudCache
11
+ } from "@open-mercato/shared/lib/crud/cache";
3
12
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
4
13
  import { lookupHashCandidates } from "@open-mercato/shared/lib/encryption/aes";
5
14
  import { User } from "../../auth/data/entities.js";
@@ -20,19 +29,62 @@ import {
20
29
  messageListItemSchema
21
30
  } from "./openapi.js";
22
31
  const NO_MATCH_ID = "00000000-0000-0000-0000-000000000000";
32
+ const MESSAGE_LIST_CACHE_TTL_MS = 3e4;
33
+ const MESSAGE_LIST_RESOURCE = "messages.message";
23
34
  function getDb(em) {
24
35
  return em.getKysely();
25
36
  }
37
+ function normalizeCacheFilterValue(value) {
38
+ if (value === void 0) return null;
39
+ if (value instanceof Date) return value.toISOString();
40
+ if (Array.isArray(value)) return value.map(normalizeCacheFilterValue);
41
+ if (value && typeof value === "object") {
42
+ return Object.entries(value).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)).map(([key, entryValue]) => [key, normalizeCacheFilterValue(entryValue)]);
43
+ }
44
+ return value;
45
+ }
46
+ function buildMessageListFilterSignature(input) {
47
+ const canonicalInput = Object.entries(input).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)).map(([key, value]) => [key, normalizeCacheFilterValue(value)]);
48
+ return createHash("sha256").update(JSON.stringify(canonicalInput)).digest("hex").slice(0, 32);
49
+ }
50
+ function buildMessageListCacheKey(scope, input) {
51
+ return [
52
+ "messages:list:v1",
53
+ `tenant:${normalizeTagSegment(scope.tenantId)}`,
54
+ `org:${normalizeTagSegment(scope.organizationId)}`,
55
+ `user:${normalizeTagSegment(scope.userId)}`,
56
+ `filters:${buildMessageListFilterSignature(input)}`
57
+ ].join("|");
58
+ }
59
+ function isMessageListPayload(value) {
60
+ if (!value || typeof value !== "object") return false;
61
+ const payload = value;
62
+ return Array.isArray(payload.items) && typeof payload.page === "number" && typeof payload.pageSize === "number" && typeof payload.total === "number" && typeof payload.totalPages === "number";
63
+ }
26
64
  const metadata = {
27
65
  GET: { requireAuth: true },
28
66
  POST: { requireAuth: true, requireFeatures: ["messages.compose"] }
29
67
  };
30
68
  async function GET(req) {
31
69
  const { ctx, scope } = await resolveMessageContext(req);
32
- const em = ctx.container.resolve("em");
33
70
  const url = new URL(req.url);
34
71
  const params = Object.fromEntries(url.searchParams);
35
72
  const input = listMessagesSchema.parse(params);
73
+ const cache = isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null;
74
+ const cacheKey = cache ? buildMessageListCacheKey(scope, input) : null;
75
+ if (cache && cacheKey) {
76
+ try {
77
+ const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey));
78
+ if (isMessageListPayload(cached)) {
79
+ return Response.json(cached);
80
+ }
81
+ } catch (error) {
82
+ debugCrudCache("messages-list-cache-read-failed", {
83
+ error: error instanceof Error ? error.message : String(error)
84
+ });
85
+ }
86
+ }
87
+ const em = ctx.container.resolve("em");
36
88
  const db = getDb(em);
37
89
  const searchIds = input.search ? await findMessageIdsBySearchTokens({
38
90
  em,
@@ -165,7 +217,7 @@ async function GET(req) {
165
217
  const name = typeof user.name === "string" && user.name.trim().length ? user.name.trim() : null;
166
218
  senderMetaById.set(user.id, { name, email: user.email ?? null });
167
219
  });
168
- return Response.json({
220
+ const payload = {
169
221
  items: typedRows.map((row) => {
170
222
  const message = messagesById.get(row.id);
171
223
  if (!message) return null;
@@ -205,7 +257,27 @@ async function GET(req) {
205
257
  pageSize: input.pageSize,
206
258
  total,
207
259
  totalPages: Math.ceil(total / input.pageSize)
208
- });
260
+ };
261
+ if (cache && cacheKey) {
262
+ try {
263
+ await runWithCacheTenant(
264
+ scope.tenantId,
265
+ () => cache.set(cacheKey, payload, {
266
+ ttl: MESSAGE_LIST_CACHE_TTL_MS,
267
+ tags: buildCollectionTags(
268
+ MESSAGE_LIST_RESOURCE,
269
+ scope.tenantId,
270
+ [scope.organizationId]
271
+ )
272
+ })
273
+ );
274
+ } catch (error) {
275
+ debugCrudCache("messages-list-cache-write-failed", {
276
+ error: error instanceof Error ? error.message : String(error)
277
+ });
278
+ }
279
+ }
280
+ return Response.json(payload);
209
281
  }
210
282
  async function POST(req) {
211
283
  const { ctx, scope } = await resolveMessageContext(req);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/messages/api/route.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { User } from '../../auth/data/entities'\nimport { Message, MessageObject } from '../data/entities'\nimport { composeMessageSchema, listMessagesSchema } from '../data/validators'\nimport { MESSAGE_ATTACHMENT_ENTITY_ID } from '../lib/constants'\nimport { getMessageType } from '../lib/message-types-registry'\nimport { validateMessageObjectsForType } from '../lib/object-validation'\nimport { attachOperationMetadataHeader } from '../lib/operationMetadata'\nimport { canUseMessageEmailFeature, resolveMessageContext } from '../lib/routeHelpers'\nimport { applyMessageParticipantScope } from '../lib/participantScope'\nimport { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from './guards'\nimport { findMessageIdsBySearchTokens } from '../lib/searchLookup'\nimport { MessageCommandExecuteResult } from '../commands/shared'\nimport {\n composeMessageSchema as composeSchema,\n composeResponseSchema,\n listMessagesSchema as listSchema,\n messageListItemSchema,\n} from './openapi'\n\ntype MessageCommandExecuteResultWithThreadId = MessageCommandExecuteResult & {\n threadId: string\n}\n\nconst NO_MATCH_ID = '00000000-0000-0000-0000-000000000000'\n\nfunction getDb(em: EntityManager): Kysely<any> {\n return em.getKysely<any>()\n}\n\ntype MessageListScopeRow = {\n id: string\n sender_user_id: string\n is_draft: boolean\n recipient_status: string | null\n read_at: string | null\n}\n\ntype AttachmentCountRow = {\n record_id: string\n count: string | number\n}\n\ntype RecipientCountRow = {\n message_id: string\n count: string | number\n}\n\nexport const metadata = {\n GET: { requireAuth: true },\n POST: { requireAuth: true, requireFeatures: ['messages.compose'] },\n}\n\nexport async function GET(req: Request) {\n const { ctx, scope } = await resolveMessageContext(req)\n const em = ctx.container.resolve('em') as EntityManager\n const url = new URL(req.url)\n const params = Object.fromEntries(url.searchParams)\n const input = listMessagesSchema.parse(params)\n const db = getDb(em) as any\n\n const searchIds = input.search\n ? await findMessageIdsBySearchTokens({\n em,\n query: input.search,\n tenantId: scope.tenantId ?? null,\n organizationId: scope.organizationId,\n })\n : undefined\n\n const buildBaseQuery = () => {\n let q: any = db\n .selectFrom('messages as m')\n .where('m.tenant_id', '=', scope.tenantId)\n .where('m.deleted_at', 'is', null)\n\n if (scope.organizationId) {\n q = q.where('m.organization_id', '=', scope.organizationId)\n } else {\n q = q.where('m.organization_id', 'is', null)\n }\n\n const joinRecipient = () => {\n q = q.leftJoin('message_recipients as r', (jb: any) => jb\n .onRef('m.id', '=', 'r.message_id')\n .on('r.recipient_user_id', '=', scope.userId))\n }\n\n switch (input.folder) {\n case 'inbox':\n joinRecipient()\n q = q\n .where('r.message_id', 'is not', null)\n .where('r.deleted_at', 'is', null)\n .where('r.archived_at', 'is', null)\n .where('m.is_draft', '=', false)\n break\n case 'archived':\n joinRecipient()\n q = q\n .where('r.message_id', 'is not', null)\n .where('r.deleted_at', 'is', null)\n .where('r.archived_at', 'is not', null)\n break\n case 'sent':\n q = q\n .where('m.sender_user_id', '=', scope.userId)\n .where('m.is_draft', '=', false)\n joinRecipient()\n break\n case 'drafts':\n q = q\n .where('m.sender_user_id', '=', scope.userId)\n .where('m.is_draft', '=', true)\n joinRecipient()\n break\n case 'all':\n // Sender-OR-recipient participant scope shared with the\n // communication_channels message enricher \u2014 see participantScope.ts (#4133).\n q = applyMessageParticipantScope(q, scope.userId)\n break\n default: {\n const unsupportedFolder: never = input.folder\n throw new Error(`Unsupported folder: ${String(unsupportedFolder)}`)\n }\n }\n\n if (input.status) q = q.where('r.status', '=', input.status)\n if (input.type) q = q.where('m.type', '=', input.type)\n if (input.visibility) q = q.where('m.visibility', '=', input.visibility)\n if (input.sourceEntityType) q = q.where('m.source_entity_type', '=', input.sourceEntityType)\n if (input.sourceEntityId) q = q.where('m.source_entity_id', '=', input.sourceEntityId)\n if (input.externalEmail) q = q.where('m.external_email_hash', 'in', lookupHashCandidates(input.externalEmail))\n if (input.senderId) q = q.where('m.sender_user_id', '=', input.senderId)\n\n if (input.search) {\n if (!searchIds || searchIds.length === 0) {\n q = q.where('m.id', '=', NO_MATCH_ID)\n } else {\n q = q.where('m.id', 'in', searchIds)\n }\n }\n\n if (input.since) q = q.where('m.sent_at', '>', new Date(input.since))\n\n if (input.hasObjects !== undefined) {\n const existsFn = (eb: any) => eb.exists(\n eb.selectFrom('message_objects')\n .select(sql<number>`1`.as('one'))\n .whereRef('message_objects.message_id', '=', 'm.id')\n )\n const notExistsFn = (eb: any) => eb.not(eb.exists(\n eb.selectFrom('message_objects')\n .select(sql<number>`1`.as('one'))\n .whereRef('message_objects.message_id', '=', 'm.id')\n ))\n q = input.hasObjects ? q.where(existsFn) : q.where(notExistsFn)\n }\n\n if (input.hasAttachments !== undefined) {\n const existsFn = (eb: any) => eb.exists(\n eb.selectFrom('attachments')\n .select(sql<number>`1`.as('one'))\n .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where(sql<boolean>`attachments.record_id = m.id::text`)\n )\n const notExistsFn = (eb: any) => eb.not(eb.exists(\n eb.selectFrom('attachments')\n .select(sql<number>`1`.as('one'))\n .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where(sql<boolean>`attachments.record_id = m.id::text`)\n ))\n q = input.hasAttachments ? q.where(existsFn) : q.where(notExistsFn)\n }\n\n if (input.hasActions !== undefined) {\n q = input.hasActions\n ? q.where('m.action_data', 'is not', null)\n : q.where('m.action_data', 'is', null)\n }\n\n return q\n }\n\n // Audited for #3386 rollout (P3): sort is on m.sent_at (a plain timestamp \u2014\n // not in the messages:message encryption map whose encrypted fields are:\n // subject, body, external_email, external_name, action_data, action_result).\n // The handler already uses the correct two-phase shape: Kysely SQL\n // ORDER BY + LIMIT/OFFSET produces a bounded page of IDs, then\n // findWithDecryption is called only for those IDs \u2014 never for the full\n // result set. The #3278 unbounded-decrypt hazard does not apply here.\n // Covered by __tests__/list.test.ts.\n const countResult = await buildBaseQuery()\n .select(sql<number>`count(*)`.as('count'))\n .executeTakeFirst() as { count: string | number } | undefined\n const total = Number(countResult?.count ?? 0)\n\n const offset = (input.page - 1) * input.pageSize\n const scopeRows = await buildBaseQuery()\n .select([\n 'm.id',\n 'm.sender_user_id',\n 'm.is_draft',\n 'r.status as recipient_status',\n 'r.read_at',\n ])\n .orderBy('m.sent_at', 'desc')\n .offset(offset)\n .limit(input.pageSize)\n .execute()\n\n const typedRows = scopeRows as MessageListScopeRow[]\n const messageIds = typedRows.map((row) => row.id)\n\n const messageEntities = messageIds.length > 0\n ? await findWithDecryption(\n em,\n Message,\n { id: { $in: messageIds } },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId }\n )\n : []\n\n const messagesById = new Map<string, Message>()\n for (const message of messageEntities) {\n messagesById.set(message.id, message)\n }\n\n const objects = messageIds.length > 0\n ? await em.find(MessageObject, { messageId: { $in: messageIds } })\n : []\n\n const objectsByMessage = objects.reduce((acc, obj) => {\n if (!acc[obj.messageId]) acc[obj.messageId] = []\n acc[obj.messageId].push(obj)\n return acc\n }, {} as Record<string, MessageObject[]>)\n\n const attachmentCounts: AttachmentCountRow[] = messageIds.length > 0\n ? await (getDb(em) as any)\n .selectFrom('attachments')\n .select(['record_id', sql<string>`count(*)`.as('count')])\n .where('entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where('record_id', 'in', messageIds)\n .groupBy('record_id')\n .execute()\n : []\n\n const attachmentCountByMessage = attachmentCounts.reduce((acc: Record<string, number>, row) => {\n acc[row.record_id] = Number(row.count)\n return acc\n }, {})\n\n const recipientCounts: RecipientCountRow[] = messageIds.length > 0\n ? await (getDb(em) as any)\n .selectFrom('message_recipients')\n .select(['message_id', sql<string>`count(*)`.as('count')])\n .where('message_id', 'in', messageIds)\n .where('deleted_at', 'is', null)\n .groupBy('message_id')\n .execute()\n : []\n\n const recipientCountByMessage = recipientCounts.reduce((acc: Record<string, number>, row) => {\n acc[row.message_id] = Number(row.count)\n return acc\n }, {})\n\n const senderUserIds = Array.from(new Set(typedRows.map((row) => row.sender_user_id).filter(Boolean)))\n const senderUsers = senderUserIds.length > 0\n ? await findWithDecryption(\n em,\n User,\n { id: { $in: senderUserIds } },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId }\n )\n : []\n\n const senderMetaById = new Map<string, { name: string | null; email: string | null }>()\n senderUsers.forEach((user) => {\n const name = typeof user.name === 'string' && user.name.trim().length ? user.name.trim() : null\n senderMetaById.set(user.id, { name, email: user.email ?? null })\n })\n\n return Response.json({\n items: typedRows\n .map((row) => {\n const message = messagesById.get(row.id)\n if (!message) return null\n const body = typeof message.body === 'string' ? message.body : ''\n const bodyPreview = body.substring(0, 150) + (body.length > 150 ? '...' : '')\n const actionData = message.actionData ?? null\n return {\n ...(senderMetaById.get(row.sender_user_id)\n ? {\n senderName: senderMetaById.get(row.sender_user_id)?.name ?? null,\n senderEmail: senderMetaById.get(row.sender_user_id)?.email ?? null,\n }\n : { senderName: null, senderEmail: null }),\n id: message.id,\n type: message.type,\n visibility: message.visibility ?? null,\n sourceEntityType: message.sourceEntityType ?? null,\n sourceEntityId: message.sourceEntityId ?? null,\n externalEmail: message.externalEmail ?? null,\n externalName: message.externalName ?? null,\n subject: message.subject,\n bodyPreview,\n senderUserId: message.senderUserId,\n priority: message.priority,\n status: row.recipient_status ?? (row.is_draft ? 'draft' : 'sent'),\n hasObjects: (objectsByMessage[message.id] || []).length > 0,\n objectCount: (objectsByMessage[message.id] || []).length,\n hasAttachments: (attachmentCountByMessage[message.id] || 0) > 0,\n attachmentCount: attachmentCountByMessage[message.id] || 0,\n recipientCount: recipientCountByMessage[message.id] || 0,\n hasActions:\n Boolean(actionData?.actions?.length)\n || Boolean(getMessageType(message.type)?.defaultActions?.length)\n || (objectsByMessage[message.id] || []).some((item) => item.actionRequired && Boolean(item.actionType)),\n actionTaken: message.actionTaken ?? null,\n sentAt: message.sentAt ? message.sentAt.toISOString() : null,\n readAt: row.read_at,\n threadId: message.threadId ?? null,\n }\n })\n .filter((item): item is NonNullable<typeof item> => item !== null),\n page: input.page,\n pageSize: input.pageSize,\n total,\n totalPages: Math.ceil(total / input.pageSize),\n })\n}\n\nexport async function POST(req: Request) {\n const { ctx, scope } = await resolveMessageContext(req)\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const body = await req.json().catch(() => ({}))\n const input = composeMessageSchema.parse(body)\n\n const isPublicVisibility = input.visibility === 'public'\n const sendViaEmail = isPublicVisibility ? true : input.sendViaEmail\n if (sendViaEmail && !(await canUseMessageEmailFeature(ctx, scope))) {\n return Response.json({ error: 'Missing feature: messages.email' }, { status: 403 })\n }\n\n if (input.objects?.length) {\n const objectValidationError = validateMessageObjectsForType(input.type, input.objects)\n if (objectValidationError) {\n return Response.json({ error: objectValidationError }, { status: 400 })\n }\n }\n\n const guardResult = await runMessageMutationGuards(\n ctx.container,\n {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n resourceKind: 'messages.message',\n resourceId: null,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: input as Record<string, unknown>,\n },\n resolveUserFeatures(ctx.auth),\n )\n if (!guardResult.ok) {\n return Response.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n const { result, logEntry } = await commandBus.execute('messages.messages.compose', {\n input: {\n ...input,\n sendViaEmail,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n },\n ctx: {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n request: req,\n },\n })\n const { id: messageId, threadId: responseThreadId } = result as unknown as MessageCommandExecuteResultWithThreadId\n\n const response = Response.json({ id: messageId, threadId: responseThreadId }, { status: 201 })\n attachOperationMetadataHeader(response, logEntry, {\n resourceKind: 'messages.message',\n resourceId: messageId,\n })\n await runMessageMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n resourceKind: 'messages.message',\n resourceId: messageId,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n return response\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Messages',\n methods: {\n GET: {\n summary: 'List messages',\n query: listSchema,\n responses: [\n {\n status: 200,\n description: 'Message list',\n schema: z.object({\n items: z.array(messageListItemSchema),\n page: z.number(),\n pageSize: z.number(),\n total: z.number(),\n totalPages: z.number(),\n }),\n },\n ],\n },\n POST: {\n summary: 'Compose a message',\n requestBody: {\n schema: composeSchema,\n },\n responses: [\n {\n status: 201,\n description: 'Message created',\n schema: composeResponseSchema,\n },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,SAAS;AAElB,SAAsB,WAAW;AAGjC,SAAS,0BAA0B;AACnC,SAAS,4BAA4B;AACrC,SAAS,YAAY;AACrB,SAAS,SAAS,qBAAqB;AACvC,SAAS,sBAAsB,0BAA0B;AACzD,SAAS,oCAAoC;AAC7C,SAAS,sBAAsB;AAC/B,SAAS,qCAAqC;AAC9C,SAAS,qCAAqC;AAC9C,SAAS,2BAA2B,6BAA6B;AACjE,SAAS,oCAAoC;AAC7C,SAAS,qBAAqB,qCAAqC,gCAAgC;AACnG,SAAS,oCAAoC;AAE7C;AAAA,EACE,wBAAwB;AAAA,EACxB;AAAA,EACA,sBAAsB;AAAA,EACtB;AAAA,OACK;AAMP,MAAM,cAAc;AAEpB,SAAS,MAAM,IAAgC;AAC7C,SAAO,GAAG,UAAe;AAC3B;AAoBO,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,KAAK;AAAA,EACzB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,kBAAkB,EAAE;AACnE;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,KAAK,MAAM,IAAI,MAAM,sBAAsB,GAAG;AACtD,QAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AACrC,QAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAM,SAAS,OAAO,YAAY,IAAI,YAAY;AAClD,QAAM,QAAQ,mBAAmB,MAAM,MAAM;AAC7C,QAAM,KAAK,MAAM,EAAE;AAEnB,QAAM,YAAY,MAAM,SACpB,MAAM,6BAA6B;AAAA,IACjC;AAAA,IACA,OAAO,MAAM;AAAA,IACb,UAAU,MAAM,YAAY;AAAA,IAC5B,gBAAgB,MAAM;AAAA,EACxB,CAAC,IACD;AAEJ,QAAM,iBAAiB,MAAM;AAC3B,QAAI,IAAS,GACV,WAAW,eAAe,EAC1B,MAAM,eAAe,KAAK,MAAM,QAAQ,EACxC,MAAM,gBAAgB,MAAM,IAAI;AAEnC,QAAI,MAAM,gBAAgB;AACxB,UAAI,EAAE,MAAM,qBAAqB,KAAK,MAAM,cAAc;AAAA,IAC5D,OAAO;AACL,UAAI,EAAE,MAAM,qBAAqB,MAAM,IAAI;AAAA,IAC7C;AAEA,UAAM,gBAAgB,MAAM;AAC1B,UAAI,EAAE,SAAS,2BAA2B,CAAC,OAAY,GACpD,MAAM,QAAQ,KAAK,cAAc,EACjC,GAAG,uBAAuB,KAAK,MAAM,MAAM,CAAC;AAAA,IACjD;AAEA,YAAQ,MAAM,QAAQ;AAAA,MACpB,KAAK;AACH,sBAAc;AACd,YAAI,EACD,MAAM,gBAAgB,UAAU,IAAI,EACpC,MAAM,gBAAgB,MAAM,IAAI,EAChC,MAAM,iBAAiB,MAAM,IAAI,EACjC,MAAM,cAAc,KAAK,KAAK;AACjC;AAAA,MACF,KAAK;AACH,sBAAc;AACd,YAAI,EACD,MAAM,gBAAgB,UAAU,IAAI,EACpC,MAAM,gBAAgB,MAAM,IAAI,EAChC,MAAM,iBAAiB,UAAU,IAAI;AACxC;AAAA,MACF,KAAK;AACH,YAAI,EACD,MAAM,oBAAoB,KAAK,MAAM,MAAM,EAC3C,MAAM,cAAc,KAAK,KAAK;AACjC,sBAAc;AACd;AAAA,MACF,KAAK;AACH,YAAI,EACD,MAAM,oBAAoB,KAAK,MAAM,MAAM,EAC3C,MAAM,cAAc,KAAK,IAAI;AAChC,sBAAc;AACd;AAAA,MACF,KAAK;AAGH,YAAI,6BAA6B,GAAG,MAAM,MAAM;AAChD;AAAA,MACF,SAAS;AACP,cAAM,oBAA2B,MAAM;AACvC,cAAM,IAAI,MAAM,uBAAuB,OAAO,iBAAiB,CAAC,EAAE;AAAA,MACpE;AAAA,IACF;AAEA,QAAI,MAAM,OAAQ,KAAI,EAAE,MAAM,YAAY,KAAK,MAAM,MAAM;AAC3D,QAAI,MAAM,KAAM,KAAI,EAAE,MAAM,UAAU,KAAK,MAAM,IAAI;AACrD,QAAI,MAAM,WAAY,KAAI,EAAE,MAAM,gBAAgB,KAAK,MAAM,UAAU;AACvE,QAAI,MAAM,iBAAkB,KAAI,EAAE,MAAM,wBAAwB,KAAK,MAAM,gBAAgB;AAC3F,QAAI,MAAM,eAAgB,KAAI,EAAE,MAAM,sBAAsB,KAAK,MAAM,cAAc;AACrF,QAAI,MAAM,cAAe,KAAI,EAAE,MAAM,yBAAyB,MAAM,qBAAqB,MAAM,aAAa,CAAC;AAC7G,QAAI,MAAM,SAAU,KAAI,EAAE,MAAM,oBAAoB,KAAK,MAAM,QAAQ;AAEvE,QAAI,MAAM,QAAQ;AAChB,UAAI,CAAC,aAAa,UAAU,WAAW,GAAG;AACxC,YAAI,EAAE,MAAM,QAAQ,KAAK,WAAW;AAAA,MACtC,OAAO;AACL,YAAI,EAAE,MAAM,QAAQ,MAAM,SAAS;AAAA,MACrC;AAAA,IACF;AAEA,QAAI,MAAM,MAAO,KAAI,EAAE,MAAM,aAAa,KAAK,IAAI,KAAK,MAAM,KAAK,CAAC;AAEpE,QAAI,MAAM,eAAe,QAAW;AAClC,YAAM,WAAW,CAAC,OAAY,GAAG;AAAA,QAC/B,GAAG,WAAW,iBAAiB,EAC5B,OAAO,OAAe,GAAG,KAAK,CAAC,EAC/B,SAAS,8BAA8B,KAAK,MAAM;AAAA,MACvD;AACA,YAAM,cAAc,CAAC,OAAY,GAAG,IAAI,GAAG;AAAA,QACzC,GAAG,WAAW,iBAAiB,EAC5B,OAAO,OAAe,GAAG,KAAK,CAAC,EAC/B,SAAS,8BAA8B,KAAK,MAAM;AAAA,MACvD,CAAC;AACD,UAAI,MAAM,aAAa,EAAE,MAAM,QAAQ,IAAI,EAAE,MAAM,WAAW;AAAA,IAChE;AAEA,QAAI,MAAM,mBAAmB,QAAW;AACtC,YAAM,WAAW,CAAC,OAAY,GAAG;AAAA,QAC/B,GAAG,WAAW,aAAa,EACxB,OAAO,OAAe,GAAG,KAAK,CAAC,EAC/B,MAAM,yBAAyB,KAAK,4BAA4B,EAChE,MAAM,uCAAgD;AAAA,MAC3D;AACA,YAAM,cAAc,CAAC,OAAY,GAAG,IAAI,GAAG;AAAA,QACzC,GAAG,WAAW,aAAa,EACxB,OAAO,OAAe,GAAG,KAAK,CAAC,EAC/B,MAAM,yBAAyB,KAAK,4BAA4B,EAChE,MAAM,uCAAgD;AAAA,MAC3D,CAAC;AACD,UAAI,MAAM,iBAAiB,EAAE,MAAM,QAAQ,IAAI,EAAE,MAAM,WAAW;AAAA,IACpE;AAEA,QAAI,MAAM,eAAe,QAAW;AAClC,UAAI,MAAM,aACN,EAAE,MAAM,iBAAiB,UAAU,IAAI,IACvC,EAAE,MAAM,iBAAiB,MAAM,IAAI;AAAA,IACzC;AAEA,WAAO;AAAA,EACT;AAUA,QAAM,cAAc,MAAM,eAAe,EACtC,OAAO,cAAsB,GAAG,OAAO,CAAC,EACxC,iBAAiB;AACpB,QAAM,QAAQ,OAAO,aAAa,SAAS,CAAC;AAE5C,QAAM,UAAU,MAAM,OAAO,KAAK,MAAM;AACxC,QAAM,YAAY,MAAM,eAAe,EACpC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ,aAAa,MAAM,EAC3B,OAAO,MAAM,EACb,MAAM,MAAM,QAAQ,EACpB,QAAQ;AAEX,QAAM,YAAY;AAClB,QAAM,aAAa,UAAU,IAAI,CAAC,QAAQ,IAAI,EAAE;AAEhD,QAAM,kBAAkB,WAAW,SAAS,IACxC,MAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,EAAE,IAAI,EAAE,KAAK,WAAW,EAAE;AAAA,IAC1B;AAAA,IACA,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,EACnE,IACA,CAAC;AAEL,QAAM,eAAe,oBAAI,IAAqB;AAC9C,aAAW,WAAW,iBAAiB;AACrC,iBAAa,IAAI,QAAQ,IAAI,OAAO;AAAA,EACtC;AAEA,QAAM,UAAU,WAAW,SAAS,IAChC,MAAM,GAAG,KAAK,eAAe,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,CAAC,IAC/D,CAAC;AAEL,QAAM,mBAAmB,QAAQ,OAAO,CAAC,KAAK,QAAQ;AACpD,QAAI,CAAC,IAAI,IAAI,SAAS,EAAG,KAAI,IAAI,SAAS,IAAI,CAAC;AAC/C,QAAI,IAAI,SAAS,EAAE,KAAK,GAAG;AAC3B,WAAO;AAAA,EACT,GAAG,CAAC,CAAoC;AAExC,QAAM,mBAAyC,WAAW,SAAS,IAC/D,MAAO,MAAM,EAAE,EACZ,WAAW,aAAa,EACxB,OAAO,CAAC,aAAa,cAAsB,GAAG,OAAO,CAAC,CAAC,EACvD,MAAM,aAAa,KAAK,4BAA4B,EACpD,MAAM,aAAa,MAAM,UAAU,EACnC,QAAQ,WAAW,EACnB,QAAQ,IACX,CAAC;AAEL,QAAM,2BAA2B,iBAAiB,OAAO,CAAC,KAA6B,QAAQ;AAC7F,QAAI,IAAI,SAAS,IAAI,OAAO,IAAI,KAAK;AACrC,WAAO;AAAA,EACT,GAAG,CAAC,CAAC;AAEL,QAAM,kBAAuC,WAAW,SAAS,IAC7D,MAAO,MAAM,EAAE,EACZ,WAAW,oBAAoB,EAC/B,OAAO,CAAC,cAAc,cAAsB,GAAG,OAAO,CAAC,CAAC,EACxD,MAAM,cAAc,MAAM,UAAU,EACpC,MAAM,cAAc,MAAM,IAAI,EAC9B,QAAQ,YAAY,EACpB,QAAQ,IACX,CAAC;AAEL,QAAM,0BAA0B,gBAAgB,OAAO,CAAC,KAA6B,QAAQ;AAC3F,QAAI,IAAI,UAAU,IAAI,OAAO,IAAI,KAAK;AACtC,WAAO;AAAA,EACT,GAAG,CAAC,CAAC;AAEL,QAAM,gBAAgB,MAAM,KAAK,IAAI,IAAI,UAAU,IAAI,CAAC,QAAQ,IAAI,cAAc,EAAE,OAAO,OAAO,CAAC,CAAC;AACpG,QAAM,cAAc,cAAc,SAAS,IACvC,MAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,EAAE,IAAI,EAAE,KAAK,cAAc,EAAE;AAAA,IAC7B;AAAA,IACA,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,EACnE,IACA,CAAC;AAEL,QAAM,iBAAiB,oBAAI,IAA2D;AACtF,cAAY,QAAQ,CAAC,SAAS;AAC5B,UAAM,OAAO,OAAO,KAAK,SAAS,YAAY,KAAK,KAAK,KAAK,EAAE,SAAS,KAAK,KAAK,KAAK,IAAI;AAC3F,mBAAe,IAAI,KAAK,IAAI,EAAE,MAAM,OAAO,KAAK,SAAS,KAAK,CAAC;AAAA,EACjE,CAAC;AAED,SAAO,SAAS,KAAK;AAAA,IACnB,OAAO,UACJ,IAAI,CAAC,QAAQ;AACZ,YAAM,UAAU,aAAa,IAAI,IAAI,EAAE;AACvC,UAAI,CAAC,QAAS,QAAO;AACrB,YAAM,OAAO,OAAO,QAAQ,SAAS,WAAW,QAAQ,OAAO;AAC/D,YAAM,cAAc,KAAK,UAAU,GAAG,GAAG,KAAK,KAAK,SAAS,MAAM,QAAQ;AAC1E,YAAM,aAAa,QAAQ,cAAc;AACzC,aAAO;AAAA,QACL,GAAI,eAAe,IAAI,IAAI,cAAc,IACrC;AAAA,UACE,YAAY,eAAe,IAAI,IAAI,cAAc,GAAG,QAAQ;AAAA,UAC5D,aAAa,eAAe,IAAI,IAAI,cAAc,GAAG,SAAS;AAAA,QAChE,IACA,EAAE,YAAY,MAAM,aAAa,KAAK;AAAA,QAC1C,IAAI,QAAQ;AAAA,QACZ,MAAM,QAAQ;AAAA,QACd,YAAY,QAAQ,cAAc;AAAA,QAClC,kBAAkB,QAAQ,oBAAoB;AAAA,QAC9C,gBAAgB,QAAQ,kBAAkB;AAAA,QAC1C,eAAe,QAAQ,iBAAiB;AAAA,QACxC,cAAc,QAAQ,gBAAgB;AAAA,QACtC,SAAS,QAAQ;AAAA,QACjB;AAAA,QACA,cAAc,QAAQ;AAAA,QACtB,UAAU,QAAQ;AAAA,QAClB,QAAQ,IAAI,qBAAqB,IAAI,WAAW,UAAU;AAAA,QAC1D,aAAa,iBAAiB,QAAQ,EAAE,KAAK,CAAC,GAAG,SAAS;AAAA,QAC1D,cAAc,iBAAiB,QAAQ,EAAE,KAAK,CAAC,GAAG;AAAA,QAClD,iBAAiB,yBAAyB,QAAQ,EAAE,KAAK,KAAK;AAAA,QAC9D,iBAAiB,yBAAyB,QAAQ,EAAE,KAAK;AAAA,QACzD,gBAAgB,wBAAwB,QAAQ,EAAE,KAAK;AAAA,QACvD,YACE,QAAQ,YAAY,SAAS,MAAM,KAChC,QAAQ,eAAe,QAAQ,IAAI,GAAG,gBAAgB,MAAM,MAC3D,iBAAiB,QAAQ,EAAE,KAAK,CAAC,GAAG,KAAK,CAAC,SAAS,KAAK,kBAAkB,QAAQ,KAAK,UAAU,CAAC;AAAA,QACxG,aAAa,QAAQ,eAAe;AAAA,QACpC,QAAQ,QAAQ,SAAS,QAAQ,OAAO,YAAY,IAAI;AAAA,QACxD,QAAQ,IAAI;AAAA,QACZ,UAAU,QAAQ,YAAY;AAAA,MAChC;AAAA,IACF,CAAC,EACA,OAAO,CAAC,SAA2C,SAAS,IAAI;AAAA,IACnE,MAAM,MAAM;AAAA,IACZ,UAAU,MAAM;AAAA,IAChB;AAAA,IACA,YAAY,KAAK,KAAK,QAAQ,MAAM,QAAQ;AAAA,EAC9C,CAAC;AACH;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,KAAK,MAAM,IAAI,MAAM,sBAAsB,GAAG;AACtD,QAAM,aAAa,IAAI,UAAU,QAAQ,YAAY;AACrD,QAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AAC9C,QAAM,QAAQ,qBAAqB,MAAM,IAAI;AAE7C,QAAM,qBAAqB,MAAM,eAAe;AAChD,QAAM,eAAe,qBAAqB,OAAO,MAAM;AACvD,MAAI,gBAAgB,CAAE,MAAM,0BAA0B,KAAK,KAAK,GAAI;AAClE,WAAO,SAAS,KAAK,EAAE,OAAO,kCAAkC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACpF;AAEA,MAAI,MAAM,SAAS,QAAQ;AACzB,UAAM,wBAAwB,8BAA8B,MAAM,MAAM,MAAM,OAAO;AACrF,QAAI,uBAAuB;AACzB,aAAO,SAAS,KAAK,EAAE,OAAO,sBAAsB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACxE;AAAA,EACF;AAEA,QAAM,cAAc,MAAM;AAAA,IACxB,IAAI;AAAA,IACJ;AAAA,MACE,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,QAAQ,MAAM;AAAA,MACd,cAAc;AAAA,MACd,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,eAAe,IAAI;AAAA,MACnB,gBAAgB,IAAI;AAAA,MACpB,iBAAiB;AAAA,IACnB;AAAA,IACA,oBAAoB,IAAI,IAAI;AAAA,EAC9B;AACA,MAAI,CAAC,YAAY,IAAI;AACnB,WAAO,SAAS;AAAA,MACd,YAAY,aAAa,EAAE,OAAO,6BAA6B;AAAA,MAC/D,EAAE,QAAQ,YAAY,eAAe,IAAI;AAAA,IAC3C;AAAA,EACF;AAEA,QAAM,EAAE,QAAQ,SAAS,IAAI,MAAM,WAAW,QAAQ,6BAA6B;AAAA,IACjF,OAAO;AAAA,MACL,GAAG;AAAA,MACH;AAAA,MACA,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,QAAQ,MAAM;AAAA,IAChB;AAAA,IACA,KAAK;AAAA,MACH,WAAW,IAAI;AAAA,MACf,MAAM,IAAI,QAAQ;AAAA,MAClB,mBAAmB;AAAA,MACnB,wBAAwB,MAAM;AAAA,MAC9B,iBAAiB,MAAM,iBAAiB,CAAC,MAAM,cAAc,IAAI;AAAA,MACjE,SAAS;AAAA,IACX;AAAA,EACF,CAAC;AACD,QAAM,EAAE,IAAI,WAAW,UAAU,iBAAiB,IAAI;AAEtD,QAAM,WAAW,SAAS,KAAK,EAAE,IAAI,WAAW,UAAU,iBAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAC7F,gCAA8B,UAAU,UAAU;AAAA,IAChD,cAAc;AAAA,IACd,YAAY;AAAA,EACd,CAAC;AACD,QAAM,oCAAoC,YAAY,uBAAuB;AAAA,IAC3E,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,IACtB,QAAQ,MAAM;AAAA,IACd,cAAc;AAAA,IACd,YAAY;AAAA,IACZ,WAAW;AAAA,IACX,eAAe,IAAI;AAAA,IACnB,gBAAgB,IAAI;AAAA,EACtB,CAAC;AACD,SAAO;AACT;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,OAAO;AAAA,MACP,WAAW;AAAA,QACT;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,QAAQ,EAAE,OAAO;AAAA,YACf,OAAO,EAAE,MAAM,qBAAqB;AAAA,YACpC,MAAM,EAAE,OAAO;AAAA,YACf,UAAU,EAAE,OAAO;AAAA,YACnB,OAAO,EAAE,OAAO;AAAA,YAChB,YAAY,EAAE,OAAO;AAAA,UACvB,CAAC;AAAA,QACH;AAAA,MACF;AAAA,IACF;AAAA,IACA,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,QACX,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,QAAQ;AAAA,QACV;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { createHash } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n normalizeTagSegment,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { User } from '../../auth/data/entities'\nimport { Message, MessageObject } from '../data/entities'\nimport { composeMessageSchema, listMessagesSchema, type ListMessagesInput } from '../data/validators'\nimport { MESSAGE_ATTACHMENT_ENTITY_ID } from '../lib/constants'\nimport { getMessageType } from '../lib/message-types-registry'\nimport { validateMessageObjectsForType } from '../lib/object-validation'\nimport { attachOperationMetadataHeader } from '../lib/operationMetadata'\nimport { canUseMessageEmailFeature, resolveMessageContext } from '../lib/routeHelpers'\nimport { applyMessageParticipantScope } from '../lib/participantScope'\nimport { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from './guards'\nimport { findMessageIdsBySearchTokens } from '../lib/searchLookup'\nimport { MessageCommandExecuteResult } from '../commands/shared'\nimport {\n composeMessageSchema as composeSchema,\n composeResponseSchema,\n listMessagesSchema as listSchema,\n messageListItemSchema,\n} from './openapi'\n\ntype MessageCommandExecuteResultWithThreadId = MessageCommandExecuteResult & {\n threadId: string\n}\n\nconst NO_MATCH_ID = '00000000-0000-0000-0000-000000000000'\nconst MESSAGE_LIST_CACHE_TTL_MS = 30_000\nconst MESSAGE_LIST_RESOURCE = 'messages.message'\n\nfunction getDb(em: EntityManager): Kysely<any> {\n return em.getKysely<any>()\n}\n\ntype MessageListScopeRow = {\n id: string\n sender_user_id: string\n is_draft: boolean\n recipient_status: string | null\n read_at: string | null\n}\n\ntype AttachmentCountRow = {\n record_id: string\n count: string | number\n}\n\ntype RecipientCountRow = {\n message_id: string\n count: string | number\n}\n\ntype MessageListPayload = {\n items: Array<Record<string, unknown>>\n page: number\n pageSize: number\n total: number\n totalPages: number\n}\n\ntype MessageListScope = Awaited<ReturnType<typeof resolveMessageContext>>['scope']\n\nfunction normalizeCacheFilterValue(value: unknown): unknown {\n if (value === undefined) return null\n if (value instanceof Date) return value.toISOString()\n if (Array.isArray(value)) return value.map(normalizeCacheFilterValue)\n if (value && typeof value === 'object') {\n return Object.entries(value as Record<string, unknown>)\n .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))\n .map(([key, entryValue]) => [key, normalizeCacheFilterValue(entryValue)])\n }\n return value\n}\n\nfunction buildMessageListFilterSignature(input: ListMessagesInput): string {\n const canonicalInput = Object.entries(input)\n .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))\n .map(([key, value]) => [key, normalizeCacheFilterValue(value)])\n\n return createHash('sha256')\n .update(JSON.stringify(canonicalInput))\n .digest('hex')\n .slice(0, 32)\n}\n\nfunction buildMessageListCacheKey(scope: MessageListScope, input: ListMessagesInput): string {\n return [\n 'messages:list:v1',\n `tenant:${normalizeTagSegment(scope.tenantId)}`,\n `org:${normalizeTagSegment(scope.organizationId)}`,\n `user:${normalizeTagSegment(scope.userId)}`,\n `filters:${buildMessageListFilterSignature(input)}`,\n ].join('|')\n}\n\nfunction isMessageListPayload(value: unknown): value is MessageListPayload {\n if (!value || typeof value !== 'object') return false\n const payload = value as Partial<MessageListPayload>\n return Array.isArray(payload.items)\n && typeof payload.page === 'number'\n && typeof payload.pageSize === 'number'\n && typeof payload.total === 'number'\n && typeof payload.totalPages === 'number'\n}\n\nexport const metadata = {\n GET: { requireAuth: true },\n POST: { requireAuth: true, requireFeatures: ['messages.compose'] },\n}\n\nexport async function GET(req: Request) {\n const { ctx, scope } = await resolveMessageContext(req)\n const url = new URL(req.url)\n const params = Object.fromEntries(url.searchParams)\n const input = listMessagesSchema.parse(params)\n\n const cache = isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null\n const cacheKey = cache ? buildMessageListCacheKey(scope, input) : null\n if (cache && cacheKey) {\n try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (isMessageListPayload(cached)) {\n return Response.json(cached)\n }\n } catch (error) {\n debugCrudCache('messages-list-cache-read-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n const em = ctx.container.resolve('em') as EntityManager\n const db = getDb(em) as any\n\n const searchIds = input.search\n ? await findMessageIdsBySearchTokens({\n em,\n query: input.search,\n tenantId: scope.tenantId ?? null,\n organizationId: scope.organizationId,\n })\n : undefined\n\n const buildBaseQuery = () => {\n let q: any = db\n .selectFrom('messages as m')\n .where('m.tenant_id', '=', scope.tenantId)\n .where('m.deleted_at', 'is', null)\n\n if (scope.organizationId) {\n q = q.where('m.organization_id', '=', scope.organizationId)\n } else {\n q = q.where('m.organization_id', 'is', null)\n }\n\n const joinRecipient = () => {\n q = q.leftJoin('message_recipients as r', (jb: any) => jb\n .onRef('m.id', '=', 'r.message_id')\n .on('r.recipient_user_id', '=', scope.userId))\n }\n\n switch (input.folder) {\n case 'inbox':\n joinRecipient()\n q = q\n .where('r.message_id', 'is not', null)\n .where('r.deleted_at', 'is', null)\n .where('r.archived_at', 'is', null)\n .where('m.is_draft', '=', false)\n break\n case 'archived':\n joinRecipient()\n q = q\n .where('r.message_id', 'is not', null)\n .where('r.deleted_at', 'is', null)\n .where('r.archived_at', 'is not', null)\n break\n case 'sent':\n q = q\n .where('m.sender_user_id', '=', scope.userId)\n .where('m.is_draft', '=', false)\n joinRecipient()\n break\n case 'drafts':\n q = q\n .where('m.sender_user_id', '=', scope.userId)\n .where('m.is_draft', '=', true)\n joinRecipient()\n break\n case 'all':\n // Sender-OR-recipient participant scope shared with the\n // communication_channels message enricher \u2014 see participantScope.ts (#4133).\n q = applyMessageParticipantScope(q, scope.userId)\n break\n default: {\n const unsupportedFolder: never = input.folder\n throw new Error(`Unsupported folder: ${String(unsupportedFolder)}`)\n }\n }\n\n if (input.status) q = q.where('r.status', '=', input.status)\n if (input.type) q = q.where('m.type', '=', input.type)\n if (input.visibility) q = q.where('m.visibility', '=', input.visibility)\n if (input.sourceEntityType) q = q.where('m.source_entity_type', '=', input.sourceEntityType)\n if (input.sourceEntityId) q = q.where('m.source_entity_id', '=', input.sourceEntityId)\n if (input.externalEmail) q = q.where('m.external_email_hash', 'in', lookupHashCandidates(input.externalEmail))\n if (input.senderId) q = q.where('m.sender_user_id', '=', input.senderId)\n\n if (input.search) {\n if (!searchIds || searchIds.length === 0) {\n q = q.where('m.id', '=', NO_MATCH_ID)\n } else {\n q = q.where('m.id', 'in', searchIds)\n }\n }\n\n if (input.since) q = q.where('m.sent_at', '>', new Date(input.since))\n\n if (input.hasObjects !== undefined) {\n const existsFn = (eb: any) => eb.exists(\n eb.selectFrom('message_objects')\n .select(sql<number>`1`.as('one'))\n .whereRef('message_objects.message_id', '=', 'm.id')\n )\n const notExistsFn = (eb: any) => eb.not(eb.exists(\n eb.selectFrom('message_objects')\n .select(sql<number>`1`.as('one'))\n .whereRef('message_objects.message_id', '=', 'm.id')\n ))\n q = input.hasObjects ? q.where(existsFn) : q.where(notExistsFn)\n }\n\n if (input.hasAttachments !== undefined) {\n const existsFn = (eb: any) => eb.exists(\n eb.selectFrom('attachments')\n .select(sql<number>`1`.as('one'))\n .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where(sql<boolean>`attachments.record_id = m.id::text`)\n )\n const notExistsFn = (eb: any) => eb.not(eb.exists(\n eb.selectFrom('attachments')\n .select(sql<number>`1`.as('one'))\n .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where(sql<boolean>`attachments.record_id = m.id::text`)\n ))\n q = input.hasAttachments ? q.where(existsFn) : q.where(notExistsFn)\n }\n\n if (input.hasActions !== undefined) {\n q = input.hasActions\n ? q.where('m.action_data', 'is not', null)\n : q.where('m.action_data', 'is', null)\n }\n\n return q\n }\n\n // Audited for #3386 rollout (P3): sort is on m.sent_at (a plain timestamp \u2014\n // not in the messages:message encryption map whose encrypted fields are:\n // subject, body, external_email, external_name, action_data, action_result).\n // The handler already uses the correct two-phase shape: Kysely SQL\n // ORDER BY + LIMIT/OFFSET produces a bounded page of IDs, then\n // findWithDecryption is called only for those IDs \u2014 never for the full\n // result set. The #3278 unbounded-decrypt hazard does not apply here.\n // Covered by __tests__/list.test.ts.\n const countResult = await buildBaseQuery()\n .select(sql<number>`count(*)`.as('count'))\n .executeTakeFirst() as { count: string | number } | undefined\n const total = Number(countResult?.count ?? 0)\n\n const offset = (input.page - 1) * input.pageSize\n const scopeRows = await buildBaseQuery()\n .select([\n 'm.id',\n 'm.sender_user_id',\n 'm.is_draft',\n 'r.status as recipient_status',\n 'r.read_at',\n ])\n .orderBy('m.sent_at', 'desc')\n .offset(offset)\n .limit(input.pageSize)\n .execute()\n\n const typedRows = scopeRows as MessageListScopeRow[]\n const messageIds = typedRows.map((row) => row.id)\n\n const messageEntities = messageIds.length > 0\n ? await findWithDecryption(\n em,\n Message,\n { id: { $in: messageIds } },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId }\n )\n : []\n\n const messagesById = new Map<string, Message>()\n for (const message of messageEntities) {\n messagesById.set(message.id, message)\n }\n\n const objects = messageIds.length > 0\n ? await em.find(MessageObject, { messageId: { $in: messageIds } })\n : []\n\n const objectsByMessage = objects.reduce((acc, obj) => {\n if (!acc[obj.messageId]) acc[obj.messageId] = []\n acc[obj.messageId].push(obj)\n return acc\n }, {} as Record<string, MessageObject[]>)\n\n const attachmentCounts: AttachmentCountRow[] = messageIds.length > 0\n ? await (getDb(em) as any)\n .selectFrom('attachments')\n .select(['record_id', sql<string>`count(*)`.as('count')])\n .where('entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where('record_id', 'in', messageIds)\n .groupBy('record_id')\n .execute()\n : []\n\n const attachmentCountByMessage = attachmentCounts.reduce((acc: Record<string, number>, row) => {\n acc[row.record_id] = Number(row.count)\n return acc\n }, {})\n\n const recipientCounts: RecipientCountRow[] = messageIds.length > 0\n ? await (getDb(em) as any)\n .selectFrom('message_recipients')\n .select(['message_id', sql<string>`count(*)`.as('count')])\n .where('message_id', 'in', messageIds)\n .where('deleted_at', 'is', null)\n .groupBy('message_id')\n .execute()\n : []\n\n const recipientCountByMessage = recipientCounts.reduce((acc: Record<string, number>, row) => {\n acc[row.message_id] = Number(row.count)\n return acc\n }, {})\n\n const senderUserIds = Array.from(new Set(typedRows.map((row) => row.sender_user_id).filter(Boolean)))\n const senderUsers = senderUserIds.length > 0\n ? await findWithDecryption(\n em,\n User,\n { id: { $in: senderUserIds } },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId }\n )\n : []\n\n const senderMetaById = new Map<string, { name: string | null; email: string | null }>()\n senderUsers.forEach((user) => {\n const name = typeof user.name === 'string' && user.name.trim().length ? user.name.trim() : null\n senderMetaById.set(user.id, { name, email: user.email ?? null })\n })\n\n const payload: MessageListPayload = {\n items: typedRows\n .map((row) => {\n const message = messagesById.get(row.id)\n if (!message) return null\n const body = typeof message.body === 'string' ? message.body : ''\n const bodyPreview = body.substring(0, 150) + (body.length > 150 ? '...' : '')\n const actionData = message.actionData ?? null\n return {\n ...(senderMetaById.get(row.sender_user_id)\n ? {\n senderName: senderMetaById.get(row.sender_user_id)?.name ?? null,\n senderEmail: senderMetaById.get(row.sender_user_id)?.email ?? null,\n }\n : { senderName: null, senderEmail: null }),\n id: message.id,\n type: message.type,\n visibility: message.visibility ?? null,\n sourceEntityType: message.sourceEntityType ?? null,\n sourceEntityId: message.sourceEntityId ?? null,\n externalEmail: message.externalEmail ?? null,\n externalName: message.externalName ?? null,\n subject: message.subject,\n bodyPreview,\n senderUserId: message.senderUserId,\n priority: message.priority,\n status: row.recipient_status ?? (row.is_draft ? 'draft' : 'sent'),\n hasObjects: (objectsByMessage[message.id] || []).length > 0,\n objectCount: (objectsByMessage[message.id] || []).length,\n hasAttachments: (attachmentCountByMessage[message.id] || 0) > 0,\n attachmentCount: attachmentCountByMessage[message.id] || 0,\n recipientCount: recipientCountByMessage[message.id] || 0,\n hasActions:\n Boolean(actionData?.actions?.length)\n || Boolean(getMessageType(message.type)?.defaultActions?.length)\n || (objectsByMessage[message.id] || []).some((item) => item.actionRequired && Boolean(item.actionType)),\n actionTaken: message.actionTaken ?? null,\n sentAt: message.sentAt ? message.sentAt.toISOString() : null,\n readAt: row.read_at,\n threadId: message.threadId ?? null,\n }\n })\n .filter((item): item is NonNullable<typeof item> => item !== null),\n page: input.page,\n pageSize: input.pageSize,\n total,\n totalPages: Math.ceil(total / input.pageSize),\n }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: MESSAGE_LIST_CACHE_TTL_MS,\n tags: buildCollectionTags(\n MESSAGE_LIST_RESOURCE,\n scope.tenantId,\n [scope.organizationId],\n ),\n }),\n )\n } catch (error) {\n debugCrudCache('messages-list-cache-write-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n return Response.json(payload)\n}\n\nexport async function POST(req: Request) {\n const { ctx, scope } = await resolveMessageContext(req)\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const body = await req.json().catch(() => ({}))\n const input = composeMessageSchema.parse(body)\n\n const isPublicVisibility = input.visibility === 'public'\n const sendViaEmail = isPublicVisibility ? true : input.sendViaEmail\n if (sendViaEmail && !(await canUseMessageEmailFeature(ctx, scope))) {\n return Response.json({ error: 'Missing feature: messages.email' }, { status: 403 })\n }\n\n if (input.objects?.length) {\n const objectValidationError = validateMessageObjectsForType(input.type, input.objects)\n if (objectValidationError) {\n return Response.json({ error: objectValidationError }, { status: 400 })\n }\n }\n\n const guardResult = await runMessageMutationGuards(\n ctx.container,\n {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n resourceKind: 'messages.message',\n resourceId: null,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: input as Record<string, unknown>,\n },\n resolveUserFeatures(ctx.auth),\n )\n if (!guardResult.ok) {\n return Response.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n const { result, logEntry } = await commandBus.execute('messages.messages.compose', {\n input: {\n ...input,\n sendViaEmail,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n },\n ctx: {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n request: req,\n },\n })\n const { id: messageId, threadId: responseThreadId } = result as unknown as MessageCommandExecuteResultWithThreadId\n\n const response = Response.json({ id: messageId, threadId: responseThreadId }, { status: 201 })\n attachOperationMetadataHeader(response, logEntry, {\n resourceKind: 'messages.message',\n resourceId: messageId,\n })\n await runMessageMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n resourceKind: 'messages.message',\n resourceId: messageId,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n return response\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Messages',\n methods: {\n GET: {\n summary: 'List messages',\n query: listSchema,\n responses: [\n {\n status: 200,\n description: 'Message list',\n schema: z.object({\n items: z.array(messageListItemSchema),\n page: z.number(),\n pageSize: z.number(),\n total: z.number(),\n totalPages: z.number(),\n }),\n },\n ],\n },\n POST: {\n summary: 'Compose a message',\n requestBody: {\n schema: composeSchema,\n },\n responses: [\n {\n status: 201,\n description: 'Message created',\n schema: composeResponseSchema,\n },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { z } from "zod";
2
3
  import { runWithCacheTenant } from "@open-mercato/cache";
3
4
  import {
@@ -9,7 +10,10 @@ import {
9
10
  import { Notification } from "../data/entities.js";
10
11
  import { listNotificationsSchema, createNotificationSchema } from "../data/validators.js";
11
12
  import { toNotificationDto } from "../lib/notificationMapper.js";
12
- import { buildNotificationReadScopeWhere } from "../lib/notificationScope.js";
13
+ import {
14
+ buildNotificationReadScopeWhere,
15
+ getNotificationReadScopeTagOrganizationIds
16
+ } from "../lib/notificationScope.js";
13
17
  import {
14
18
  NOTIFICATION_RESOURCE_KIND,
15
19
  notificationCrudErrorResponse,
@@ -27,9 +31,13 @@ const metadata = {
27
31
  POST: { requireAuth: true, requireFeatures: ["notifications.create"] }
28
32
  };
29
33
  const NOTIFICATIONS_LIST_TTL_MS = 1e4;
30
- const NOTIFICATIONS_LIST_CACHE_VERSION = 1;
34
+ const NOTIFICATIONS_LIST_CACHE_VERSION = 2;
31
35
  const NOTIFICATIONS_LIST_RESOURCE = "notifications.notification";
32
- function buildNotificationsListCacheKey(userId, input) {
36
+ function buildNotificationsListCacheKey(userId, organizationScope, input) {
37
+ const normalizedIds = Array.from(new Set(
38
+ organizationScope.organizationIds.filter((value) => value.trim().length > 0)
39
+ )).sort((left, right) => left.localeCompare(right));
40
+ const scopeKey = normalizedIds.length === 0 ? "no-access" : `${organizationScope.organizationId ?? "none"}:scope=${createHash("sha256").update(normalizedIds.join("\0")).digest("hex").slice(0, 16)}`;
33
41
  const filterSignature = JSON.stringify({
34
42
  status: Array.isArray(input.status) ? [...input.status].sort((left, right) => left.localeCompare(right)) : input.status ?? null,
35
43
  type: input.type ?? null,
@@ -40,7 +48,7 @@ function buildNotificationsListCacheKey(userId, input) {
40
48
  page: input.page,
41
49
  pageSize: input.pageSize
42
50
  });
43
- return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:filters=${filterSignature}`;
51
+ return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:org=${scopeKey}:filters=${filterSignature}`;
44
52
  }
45
53
  function isNotificationsListPayload(value) {
46
54
  if (!value || typeof value !== "object") return false;
@@ -54,8 +62,13 @@ async function GET(req) {
54
62
  const queryParams = Object.fromEntries(url.searchParams.entries());
55
63
  const input = listNotificationsSchema.parse(queryParams);
56
64
  const userId = scope.userId;
57
- const cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null;
58
- const cacheKey = cache && userId ? buildNotificationsListCacheKey(userId, input) : null;
65
+ const cacheableOrganizationIds = Array.isArray(scope.organizationIds) ? scope.organizationIds : null;
66
+ const cache = userId && cacheableOrganizationIds && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null;
67
+ const cacheKey = cache && userId && cacheableOrganizationIds ? buildNotificationsListCacheKey(
68
+ userId,
69
+ { organizationId: scope.organizationId, organizationIds: cacheableOrganizationIds },
70
+ input
71
+ ) : null;
59
72
  if (cache && cacheKey) {
60
73
  try {
61
74
  const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey));
@@ -115,7 +128,11 @@ async function GET(req) {
115
128
  scope.tenantId,
116
129
  () => cache.set(cacheKey, payload, {
117
130
  ttl: NOTIFICATIONS_LIST_TTL_MS,
118
- tags: buildCollectionTags(NOTIFICATIONS_LIST_RESOURCE, scope.tenantId, [null])
131
+ tags: buildCollectionTags(
132
+ NOTIFICATIONS_LIST_RESOURCE,
133
+ scope.tenantId,
134
+ getNotificationReadScopeTagOrganizationIds(scope)
135
+ )
119
136
  })
120
137
  );
121
138
  } catch (error) {
@@ -166,6 +183,7 @@ const openApi = buildNotificationsCrudOpenApi({
166
183
  export {
167
184
  GET,
168
185
  POST,
186
+ buildNotificationsListCacheKey,
169
187
  metadata,
170
188
  openApi
171
189
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/notifications/api/route.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { Notification } from '../data/entities'\nimport { listNotificationsSchema, createNotificationSchema } from '../data/validators'\nimport { toNotificationDto } from '../lib/notificationMapper'\nimport { buildNotificationReadScopeWhere } from '../lib/notificationScope'\nimport {\n NOTIFICATION_RESOURCE_KIND,\n notificationCrudErrorResponse,\n notificationValidationErrorResponse,\n resolveNotificationContext,\n runGuardedNotificationWrite,\n} from '../lib/routeHelpers'\nimport {\n buildNotificationsCrudOpenApi,\n createPagedListResponseSchema,\n notificationItemSchema,\n} from './openapi'\n\nexport const metadata = {\n GET: { requireAuth: true },\n POST: { requireAuth: true, requireFeatures: ['notifications.create'] },\n}\n\nconst NOTIFICATIONS_LIST_TTL_MS = 10_000\nconst NOTIFICATIONS_LIST_CACHE_VERSION = 1\nconst NOTIFICATIONS_LIST_RESOURCE = 'notifications.notification'\n\ntype NotificationsListPayload = {\n items: ReturnType<typeof toNotificationDto>[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\nfunction buildNotificationsListCacheKey(\n userId: string,\n input: z.infer<typeof listNotificationsSchema>,\n): string {\n const filterSignature = JSON.stringify({\n status: Array.isArray(input.status)\n ? [...input.status].sort((left, right) => left.localeCompare(right))\n : input.status ?? null,\n type: input.type ?? null,\n severity: input.severity ?? null,\n sourceEntityType: input.sourceEntityType ?? null,\n sourceEntityId: input.sourceEntityId ?? null,\n since: input.since ?? null,\n page: input.page,\n pageSize: input.pageSize,\n })\n return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:filters=${filterSignature}`\n}\n\nfunction isNotificationsListPayload(value: unknown): value is NotificationsListPayload {\n if (!value || typeof value !== 'object') return false\n const payload = value as Partial<NotificationsListPayload>\n return (\n Array.isArray(payload.items)\n && typeof payload.total === 'number'\n && typeof payload.page === 'number'\n && typeof payload.pageSize === 'number'\n && typeof payload.totalPages === 'number'\n )\n}\n\nexport async function GET(req: Request) {\n const { ctx, scope } = await resolveNotificationContext(req)\n const em = ctx.container.resolve('em') as EntityManager\n\n const url = new URL(req.url)\n const queryParams = Object.fromEntries(url.searchParams.entries())\n const input = listNotificationsSchema.parse(queryParams)\n const userId = scope.userId\n const cache = userId && isCrudCacheEnabled()\n ? resolveCrudCache(ctx.container)\n : null\n const cacheKey = cache && userId ? buildNotificationsListCacheKey(userId, input) : null\n\n if (cache && cacheKey) {\n try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (isNotificationsListPayload(cached)) {\n return Response.json(cached)\n }\n } catch (error) {\n debugCrudCache('notifications-list-cache-read-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n const filters: Record<string, unknown> = {\n recipientUserId: userId,\n tenantId: scope.tenantId,\n ...buildNotificationReadScopeWhere(scope),\n }\n\n if (input.status) {\n filters.status = Array.isArray(input.status) ? { $in: input.status } : input.status\n } else {\n filters.status = { $ne: 'dismissed' }\n }\n if (input.type) {\n filters.type = input.type\n }\n if (input.severity) {\n filters.severity = input.severity\n }\n if (input.sourceEntityType) {\n filters.sourceEntityType = input.sourceEntityType\n }\n if (input.sourceEntityId) {\n filters.sourceEntityId = input.sourceEntityId\n }\n if (input.since) {\n filters.createdAt = { $gt: new Date(input.since) }\n }\n\n const [notifications, total] = await Promise.all([\n em.find(Notification, filters, {\n orderBy: { createdAt: 'desc' },\n limit: input.pageSize,\n offset: (input.page - 1) * input.pageSize,\n }),\n em.count(Notification, filters),\n ])\n\n const items = notifications.map(toNotificationDto)\n\n const payload: NotificationsListPayload = {\n items,\n total,\n page: input.page,\n pageSize: input.pageSize,\n totalPages: Math.ceil(total / input.pageSize),\n }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: NOTIFICATIONS_LIST_TTL_MS,\n tags: buildCollectionTags(NOTIFICATIONS_LIST_RESOURCE, scope.tenantId, [null]),\n }),\n )\n } catch (error) {\n debugCrudCache('notifications-list-cache-write-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n return Response.json(payload)\n}\n\nexport async function POST(req: Request) {\n const { service, scope, ctx } = await resolveNotificationContext(req)\n\n const body = await req.json().catch(() => ({}))\n const parsed = createNotificationSchema.safeParse(body)\n if (!parsed.success) {\n return notificationValidationErrorResponse(parsed.error)\n }\n\n try {\n const guarded = await runGuardedNotificationWrite(\n ctx.container,\n scope,\n req,\n {\n resourceKind: NOTIFICATION_RESOURCE_KIND,\n operation: 'create',\n payload: parsed.data as Record<string, unknown>,\n },\n () => service.create(parsed.data, scope),\n )\n if (!guarded.ok) return guarded.response\n\n return Response.json({ id: guarded.result.id }, { status: 201 })\n } catch (error) {\n const errorResponse = notificationCrudErrorResponse(error)\n if (errorResponse) return errorResponse\n throw error\n }\n}\n\nexport const openApi = buildNotificationsCrudOpenApi({\n resourceName: 'Notification',\n querySchema: listNotificationsSchema,\n listResponseSchema: createPagedListResponseSchema(notificationItemSchema),\n create: {\n schema: createNotificationSchema,\n responseSchema: z.object({ id: z.string().uuid() }),\n description: 'Creates a notification for a user.',\n },\n})\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createHash } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { Notification } from '../data/entities'\nimport { listNotificationsSchema, createNotificationSchema } from '../data/validators'\nimport { toNotificationDto } from '../lib/notificationMapper'\nimport {\n buildNotificationReadScopeWhere,\n getNotificationReadScopeTagOrganizationIds,\n} from '../lib/notificationScope'\nimport {\n NOTIFICATION_RESOURCE_KIND,\n notificationCrudErrorResponse,\n notificationValidationErrorResponse,\n resolveNotificationContext,\n runGuardedNotificationWrite,\n} from '../lib/routeHelpers'\nimport {\n buildNotificationsCrudOpenApi,\n createPagedListResponseSchema,\n notificationItemSchema,\n} from './openapi'\n\nexport const metadata = {\n GET: { requireAuth: true },\n POST: { requireAuth: true, requireFeatures: ['notifications.create'] },\n}\n\nconst NOTIFICATIONS_LIST_TTL_MS = 10_000\n// v2: the key gained the selected-organization scope (#4840). Bumping the version\n// retires v1 entries, which were shared across organizations for the same user.\nconst NOTIFICATIONS_LIST_CACHE_VERSION = 2\nconst NOTIFICATIONS_LIST_RESOURCE = 'notifications.notification'\n\ntype NotificationsListPayload = {\n items: ReturnType<typeof toNotificationDto>[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\nexport function buildNotificationsListCacheKey(\n userId: string,\n organizationScope: { organizationId: string | null; organizationIds: string[] },\n input: z.infer<typeof listNotificationsSchema>,\n): string {\n const normalizedIds = Array.from(new Set(\n organizationScope.organizationIds.filter((value) => value.trim().length > 0),\n )).sort((left, right) => left.localeCompare(right))\n const scopeKey = normalizedIds.length === 0\n ? 'no-access'\n : `${organizationScope.organizationId ?? 'none'}:scope=${createHash('sha256')\n .update(normalizedIds.join('\\0'))\n .digest('hex')\n .slice(0, 16)}`\n const filterSignature = JSON.stringify({\n status: Array.isArray(input.status)\n ? [...input.status].sort((left, right) => left.localeCompare(right))\n : input.status ?? null,\n type: input.type ?? null,\n severity: input.severity ?? null,\n sourceEntityType: input.sourceEntityType ?? null,\n sourceEntityId: input.sourceEntityId ?? null,\n since: input.since ?? null,\n page: input.page,\n pageSize: input.pageSize,\n })\n return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:org=${scopeKey}:filters=${filterSignature}`\n}\n\nfunction isNotificationsListPayload(value: unknown): value is NotificationsListPayload {\n if (!value || typeof value !== 'object') return false\n const payload = value as Partial<NotificationsListPayload>\n return (\n Array.isArray(payload.items)\n && typeof payload.total === 'number'\n && typeof payload.page === 'number'\n && typeof payload.pageSize === 'number'\n && typeof payload.totalPages === 'number'\n )\n}\n\nexport async function GET(req: Request) {\n const { ctx, scope } = await resolveNotificationContext(req)\n const em = ctx.container.resolve('em') as EntityManager\n\n const url = new URL(req.url)\n const queryParams = Object.fromEntries(url.searchParams.entries())\n const input = listNotificationsSchema.parse(queryParams)\n const userId = scope.userId\n // Mirrors api/unread-count/route.ts: unrestricted and omitted legacy organization\n // scopes cannot be safely tagged for invalidation, because organization-specific\n // writes only invalidate their own collection tag. Leave those scopes uncached\n // rather than serving a stale tenant-wide list until the TTL expires.\n const cacheableOrganizationIds = Array.isArray(scope.organizationIds)\n ? scope.organizationIds\n : null\n const cache = userId && cacheableOrganizationIds && isCrudCacheEnabled()\n ? resolveCrudCache(ctx.container)\n : null\n const cacheKey = cache && userId && cacheableOrganizationIds\n ? buildNotificationsListCacheKey(\n userId,\n { organizationId: scope.organizationId, organizationIds: cacheableOrganizationIds },\n input,\n )\n : null\n\n if (cache && cacheKey) {\n try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (isNotificationsListPayload(cached)) {\n return Response.json(cached)\n }\n } catch (error) {\n debugCrudCache('notifications-list-cache-read-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n const filters: Record<string, unknown> = {\n recipientUserId: userId,\n tenantId: scope.tenantId,\n ...buildNotificationReadScopeWhere(scope),\n }\n\n if (input.status) {\n filters.status = Array.isArray(input.status) ? { $in: input.status } : input.status\n } else {\n filters.status = { $ne: 'dismissed' }\n }\n if (input.type) {\n filters.type = input.type\n }\n if (input.severity) {\n filters.severity = input.severity\n }\n if (input.sourceEntityType) {\n filters.sourceEntityType = input.sourceEntityType\n }\n if (input.sourceEntityId) {\n filters.sourceEntityId = input.sourceEntityId\n }\n if (input.since) {\n filters.createdAt = { $gt: new Date(input.since) }\n }\n\n const [notifications, total] = await Promise.all([\n em.find(Notification, filters, {\n orderBy: { createdAt: 'desc' },\n limit: input.pageSize,\n offset: (input.page - 1) * input.pageSize,\n }),\n em.count(Notification, filters),\n ])\n\n const items = notifications.map(toNotificationDto)\n\n const payload: NotificationsListPayload = {\n items,\n total,\n page: input.page,\n pageSize: input.pageSize,\n totalPages: Math.ceil(total / input.pageSize),\n }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: NOTIFICATIONS_LIST_TTL_MS,\n tags: buildCollectionTags(\n NOTIFICATIONS_LIST_RESOURCE,\n scope.tenantId,\n getNotificationReadScopeTagOrganizationIds(scope),\n ),\n }),\n )\n } catch (error) {\n debugCrudCache('notifications-list-cache-write-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n return Response.json(payload)\n}\n\nexport async function POST(req: Request) {\n const { service, scope, ctx } = await resolveNotificationContext(req)\n\n const body = await req.json().catch(() => ({}))\n const parsed = createNotificationSchema.safeParse(body)\n if (!parsed.success) {\n return notificationValidationErrorResponse(parsed.error)\n }\n\n try {\n const guarded = await runGuardedNotificationWrite(\n ctx.container,\n scope,\n req,\n {\n resourceKind: NOTIFICATION_RESOURCE_KIND,\n operation: 'create',\n payload: parsed.data as Record<string, unknown>,\n },\n () => service.create(parsed.data, scope),\n )\n if (!guarded.ok) return guarded.response\n\n return Response.json({ id: guarded.result.id }, { status: 201 })\n } catch (error) {\n const errorResponse = notificationCrudErrorResponse(error)\n if (errorResponse) return errorResponse\n throw error\n }\n}\n\nexport const openApi = buildNotificationsCrudOpenApi({\n resourceName: 'Notification',\n querySchema: listNotificationsSchema,\n listResponseSchema: createPagedListResponseSchema(notificationItemSchema),\n create: {\n schema: createNotificationSchema,\n responseSchema: z.object({ id: z.string().uuid() }),\n description: 'Creates a notification for a user.',\n },\n})\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -3,7 +3,7 @@ import { createNotificationService } from "./lib/notificationService.js";
3
3
  function register(container) {
4
4
  container.register({
5
5
  notificationService: asFunction(
6
- ({ em, eventBus, commandBus }) => createNotificationService({ em, eventBus, commandBus })
6
+ ({ em, eventBus, commandBus }) => createNotificationService({ em, eventBus, commandBus, container })
7
7
  ).scoped().proxy()
8
8
  });
9
9
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/notifications/di.ts"],
4
- "sourcesContent": ["import type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { asFunction } from 'awilix'\nimport { createNotificationService } from './lib/notificationService'\n\nexport function register(container: AppContainer): void {\n container.register({\n notificationService: asFunction(({ em, eventBus, commandBus }) =>\n createNotificationService({ em, eventBus, commandBus })\n ).scoped().proxy(),\n })\n}\n"],
5
- "mappings": "AACA,SAAS,kBAAkB;AAC3B,SAAS,iCAAiC;AAEnC,SAAS,SAAS,WAA+B;AACtD,YAAU,SAAS;AAAA,IACjB,qBAAqB;AAAA,MAAW,CAAC,EAAE,IAAI,UAAU,WAAW,MAC1D,0BAA0B,EAAE,IAAI,UAAU,WAAW,CAAC;AAAA,IACxD,EAAE,OAAO,EAAE,MAAM;AAAA,EACnB,CAAC;AACH;",
4
+ "sourcesContent": ["import type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { asFunction } from 'awilix'\nimport { createNotificationService } from './lib/notificationService'\n\nexport function register(container: AppContainer): void {\n container.register({\n notificationService: asFunction(({ em, eventBus, commandBus }) =>\n createNotificationService({ em, eventBus, commandBus, container })\n ).scoped().proxy(),\n })\n}\n"],
5
+ "mappings": "AACA,SAAS,kBAAkB;AAC3B,SAAS,iCAAiC;AAEnC,SAAS,SAAS,WAA+B;AACtD,YAAU,SAAS;AAAA,IACjB,qBAAqB;AAAA,MAAW,CAAC,EAAE,IAAI,UAAU,WAAW,MAC1D,0BAA0B,EAAE,IAAI,UAAU,YAAY,UAAU,CAAC;AAAA,IACnE,EAAE,OAAO,EAAE,MAAM;AAAA,EACnB,CAAC;AACH;",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,6 @@
1
1
  import { sql } from "kysely";
2
2
  import { CrudHttpError, conflict } from "@open-mercato/shared/lib/crud/errors";
3
+ import { invalidateCrudCache } from "@open-mercato/shared/lib/crud/cache";
3
4
  import { Notification } from "../data/entities.js";
4
5
  import { NOTIFICATION_EVENTS, NOTIFICATION_SSE_EVENTS } from "./events.js";
5
6
  import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
@@ -25,9 +26,24 @@ function getDb(em) {
25
26
  return em.getKysely();
26
27
  }
27
28
  const UNIQUE_NOTIFICATION_ACTIVE_STATUSES = ["unread", "read", "actioned"];
29
+ const NOTIFICATION_RESOURCE_KIND = "notifications.notification";
28
30
  function normalizeOrgScope(organizationId) {
29
31
  return organizationId ?? null;
30
32
  }
33
+ async function invalidateNotificationCache(container, ctx, reason, notificationId) {
34
+ if (!container) return;
35
+ await invalidateCrudCache(
36
+ container,
37
+ NOTIFICATION_RESOURCE_KIND,
38
+ {
39
+ id: notificationId,
40
+ tenantId: ctx.tenantId,
41
+ organizationId: normalizeOrgScope(ctx.organizationId)
42
+ },
43
+ ctx.tenantId,
44
+ reason
45
+ );
46
+ }
31
47
  async function assertNotificationRecipientsInScope(em, recipientUserIds, ctx) {
32
48
  const scopedRecipientUserIds = await getScopedNotificationRecipientUserIds(
33
49
  getDb(em),
@@ -146,6 +162,7 @@ function createNotificationService(deps) {
146
162
  await tx.flush();
147
163
  return entity;
148
164
  });
165
+ await invalidateNotificationCache(container, ctx, "created");
149
166
  await emitNotificationCreated(eventBus, notification, ctx);
150
167
  await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {
151
168
  tenantId: notification.tenantId,
@@ -168,6 +185,7 @@ function createNotificationService(deps) {
168
185
  }
169
186
  await tx.flush();
170
187
  });
188
+ await invalidateNotificationCache(container, ctx, "created");
171
189
  await emitNotificationCreatedBatch(eventBus, notifications, ctx);
172
190
  await emitNotificationSseEvents(eventBus, notifications, ctx, recipientUserIds);
173
191
  return notifications;
@@ -190,6 +208,7 @@ function createNotificationService(deps) {
190
208
  }
191
209
  await tx.flush();
192
210
  });
211
+ await invalidateNotificationCache(container, ctx, "created");
193
212
  await emitNotificationCreatedBatch(eventBus, notifications, ctx);
194
213
  await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds);
195
214
  return notifications;
@@ -214,6 +233,7 @@ function createNotificationService(deps) {
214
233
  }
215
234
  await tx.flush();
216
235
  });
236
+ await invalidateNotificationCache(container, ctx, "created");
217
237
  await emitNotificationCreatedBatch(eventBus, notifications, ctx);
218
238
  await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds);
219
239
  return notifications;
@@ -225,6 +245,7 @@ function createNotificationService(deps) {
225
245
  notification.status = "read";
226
246
  notification.readAt = /* @__PURE__ */ new Date();
227
247
  await em.flush();
248
+ await invalidateNotificationCache(container, ctx, "updated", notification.id);
228
249
  await eventBus.emit(NOTIFICATION_EVENTS.READ, {
229
250
  notificationId: notification.id,
230
251
  userId: ctx.userId,
@@ -260,6 +281,7 @@ function createNotificationService(deps) {
260
281
  })
261
282
  ).executeTakeFirst();
262
283
  const result = Number(updateResult?.numUpdatedRows ?? targetRows.length);
284
+ await invalidateNotificationCache(container, ctx, "updated");
263
285
  const notifications = await findWithDecryption(em, Notification, {
264
286
  id: { $in: targetRows.map((row) => row.id) }
265
287
  }, void 0, {
@@ -287,6 +309,7 @@ function createNotificationService(deps) {
287
309
  notification.status = "dismissed";
288
310
  notification.dismissedAt = /* @__PURE__ */ new Date();
289
311
  await em.flush();
312
+ await invalidateNotificationCache(container, ctx, "updated", notification.id);
290
313
  await eventBus.emit(NOTIFICATION_EVENTS.DISMISSED, {
291
314
  notificationId: notification.id,
292
315
  userId: ctx.userId,
@@ -309,6 +332,7 @@ function createNotificationService(deps) {
309
332
  notification.readAt = /* @__PURE__ */ new Date();
310
333
  }
311
334
  await em.flush();
335
+ await invalidateNotificationCache(container, ctx, "updated", notification.id);
312
336
  await eventBus.emit(NOTIFICATION_EVENTS.RESTORED, {
313
337
  notificationId: notification.id,
314
338
  userId: ctx.userId,
@@ -393,6 +417,7 @@ function createNotificationService(deps) {
393
417
  notification.readAt = /* @__PURE__ */ new Date();
394
418
  }
395
419
  await em.flush();
420
+ await invalidateNotificationCache(container, ctx, "updated", notification.id);
396
421
  await eventBus.emit(NOTIFICATION_EVENTS.ACTIONED, {
397
422
  notificationId: notification.id,
398
423
  actionId: input.actionId,
@@ -444,17 +469,32 @@ function createNotificationService(deps) {
444
469
  async cleanupExpired() {
445
470
  const em = rootEm.fork();
446
471
  const db = getDb(em);
472
+ const affectedScopes = await db.selectFrom("notifications").select([
473
+ "tenant_id",
474
+ "organization_id"
475
+ ]).where("expires_at", "<", sql`now()`).where("status", "not in", ["actioned", "dismissed"]).distinct().execute();
476
+ if (!affectedScopes.length) return 0;
447
477
  const updateResult = await db.updateTable("notifications").set({
448
478
  status: "dismissed",
449
479
  dismissed_at: sql`now()`
450
480
  }).where("expires_at", "<", sql`now()`).where("status", "not in", ["actioned", "dismissed"]).executeTakeFirst();
481
+ for (const scope of affectedScopes) {
482
+ await invalidateNotificationCache(container, {
483
+ tenantId: scope.tenant_id,
484
+ organizationId: scope.organization_id
485
+ }, "updated");
486
+ }
451
487
  return Number(updateResult?.numUpdatedRows ?? 0);
452
488
  },
453
489
  async deleteBySource(sourceEntityType, sourceEntityId, ctx) {
454
490
  const em = rootEm.fork();
455
491
  const db = getDb(em);
456
492
  const deleteResult = await db.deleteFrom("notifications").where("source_entity_type", "=", sourceEntityType).where("source_entity_id", "=", sourceEntityId).where("tenant_id", "=", ctx.tenantId).executeTakeFirst();
457
- return Number(deleteResult?.numDeletedRows ?? 0);
493
+ const deletedCount = Number(deleteResult?.numDeletedRows ?? 0);
494
+ if (deletedCount > 0) {
495
+ await invalidateNotificationCache(container, ctx, "deleted");
496
+ }
497
+ return deletedCount;
458
498
  }
459
499
  };
460
500
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/notifications/lib/notificationService.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport { CrudHttpError, conflict } from '@open-mercato/shared/lib/crud/errors'\nimport { Notification, type NotificationStatus } from '../data/entities'\nimport type { CreateNotificationInput, CreateBatchNotificationInput, CreateRoleNotificationInput, CreateFeatureNotificationInput, ExecuteActionInput } from '../data/validators'\nimport type { NotificationPollData } from '@open-mercato/shared/modules/notifications/types'\nimport { NOTIFICATION_EVENTS, NOTIFICATION_SSE_EVENTS } from './events'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n buildNotificationEntity,\n emitNotificationCreated,\n emitNotificationCreatedBatch,\n type NotificationContentInput,\n type NotificationTenantContext,\n} from './notificationFactory'\nimport { toNotificationDto } from './notificationMapper'\nimport { buildNotificationReadScopeWhere } from './notificationScope'\nimport {\n getRecipientUserIdsForFeature,\n getRecipientUserIdsForRole,\n getScopedNotificationRecipientUserIds,\n} from './notificationRecipients'\nimport { assertSafeNotificationHref, sanitizeNotificationActions } from './safeHref'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('notifications').child({ component: 'service' })\n\nfunction debug(message: string, ...details: unknown[]): void {\n logger.debug(message, details.length ? { details } : undefined)\n}\n\nfunction getDb(em: EntityManager): Kysely<any> {\n return em.getKysely<any>()\n}\n\nconst UNIQUE_NOTIFICATION_ACTIVE_STATUSES: NotificationStatus[] = ['unread', 'read', 'actioned']\n\nfunction normalizeOrgScope(organizationId: string | null | undefined): string | null {\n return organizationId ?? null\n}\n\nasync function assertNotificationRecipientsInScope(\n em: EntityManager,\n recipientUserIds: string[],\n ctx: NotificationServiceContext,\n): Promise<void> {\n const scopedRecipientUserIds = await getScopedNotificationRecipientUserIds(\n getDb(em),\n ctx.tenantId,\n normalizeOrgScope(ctx.organizationId),\n recipientUserIds,\n )\n\n if (scopedRecipientUserIds.length !== recipientUserIds.length) {\n throw new CrudHttpError(404, { error: 'Notification recipient not found' })\n }\n}\n\nfunction applyNotificationContent(\n notification: Notification,\n input: NotificationContentInput,\n recipientUserId: string,\n ctx: NotificationTenantContext,\n) {\n const actions = sanitizeNotificationActions(input.actions)\n const linkHref = assertSafeNotificationHref(input.linkHref)\n\n notification.recipientUserId = recipientUserId\n notification.type = input.type\n notification.titleKey = input.titleKey\n notification.bodyKey = input.bodyKey\n notification.titleVariables = input.titleVariables\n notification.bodyVariables = input.bodyVariables\n notification.title = input.title || input.titleKey || ''\n notification.body = input.body\n notification.icon = input.icon\n notification.severity = input.severity ?? 'info'\n notification.actionData = actions\n ? {\n actions,\n primaryActionId: input.primaryActionId,\n }\n : null\n notification.sourceModule = input.sourceModule\n notification.sourceEntityType = input.sourceEntityType\n notification.sourceEntityId = input.sourceEntityId\n notification.linkHref = linkHref\n notification.groupKey = input.groupKey\n notification.expiresAt = input.expiresAt ? new Date(input.expiresAt) : null\n notification.tenantId = ctx.tenantId\n notification.organizationId = normalizeOrgScope(ctx.organizationId)\n notification.status = 'unread'\n notification.readAt = null\n notification.actionedAt = null\n notification.dismissedAt = null\n notification.actionTaken = null\n notification.actionResult = null\n notification.createdAt = new Date()\n}\n\nasync function findScopedNotificationOrThrow(\n em: EntityManager,\n notificationId: string,\n ctx: NotificationServiceContext,\n): Promise<Notification> {\n const notification = await findOneWithDecryption(\n em,\n Notification,\n {\n id: notificationId,\n recipientUserId: ctx.userId,\n tenantId: ctx.tenantId,\n },\n undefined,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (!notification) {\n throw new CrudHttpError(404, { error: 'Notification not found' })\n }\n return notification\n}\n\nasync function emitNotificationSseEvents(\n eventBus: { emit: (event: string, payload: unknown) => Promise<void> },\n notifications: Notification[],\n ctx: NotificationServiceContext,\n recipientUserIds: string[],\n): Promise<void> {\n await eventBus.emit(NOTIFICATION_SSE_EVENTS.BATCH_CREATED, {\n tenantId: ctx.tenantId,\n organizationId: normalizeOrgScope(ctx.organizationId),\n recipientUserIds,\n count: notifications.length,\n })\n\n for (const notification of notifications) {\n await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {\n tenantId: notification.tenantId,\n organizationId: notification.organizationId ?? null,\n recipientUserId: notification.recipientUserId,\n notification: toNotificationDto(notification),\n })\n }\n}\n\nasync function createOrRefreshNotification(\n em: EntityManager,\n input: NotificationContentInput,\n recipientUserId: string,\n ctx: NotificationTenantContext,\n): Promise<Notification> {\n if (input.groupKey && input.groupKey.trim().length > 0) {\n const orgScope = normalizeOrgScope(ctx.organizationId) ?? 'global'\n const lockKey = `notifications:${ctx.tenantId}:${orgScope}:${recipientUserId}:${input.type}:${input.groupKey}`\n try {\n const db = getDb(em)\n await sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`.execute(db)\n } catch {\n // If advisory locks are unavailable, continue with best-effort dedupe.\n }\n\n const existing = await em.findOne(Notification, {\n recipientUserId,\n tenantId: ctx.tenantId,\n organizationId: normalizeOrgScope(ctx.organizationId),\n type: input.type,\n groupKey: input.groupKey,\n status: { $in: UNIQUE_NOTIFICATION_ACTIVE_STATUSES },\n }, {\n orderBy: { createdAt: 'desc' },\n })\n\n if (existing) {\n applyNotificationContent(existing, input, recipientUserId, ctx)\n return existing\n }\n }\n\n return buildNotificationEntity(em, input, recipientUserId, ctx)\n}\n\nexport interface NotificationServiceContext {\n tenantId: string\n organizationId?: string | null\n organizationIds?: string[] | null\n userId?: string | null\n}\n\nexport interface NotificationService {\n create(input: CreateNotificationInput, ctx: NotificationServiceContext): Promise<Notification>\n createBatch(input: CreateBatchNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>\n createForRole(input: CreateRoleNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>\n createForFeature(input: CreateFeatureNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>\n markAsRead(notificationId: string, ctx: NotificationServiceContext): Promise<Notification>\n markAllAsRead(ctx: NotificationServiceContext): Promise<number>\n dismiss(notificationId: string, ctx: NotificationServiceContext): Promise<Notification>\n restoreDismissed(\n notificationId: string,\n status: 'read' | 'unread' | undefined,\n ctx: NotificationServiceContext\n ): Promise<Notification>\n executeAction(\n notificationId: string,\n input: ExecuteActionInput,\n ctx: NotificationServiceContext\n ): Promise<{ notification: Notification; result: unknown }>\n getUnreadCount(ctx: NotificationServiceContext): Promise<number>\n getPollData(ctx: NotificationServiceContext, since?: string): Promise<NotificationPollData>\n cleanupExpired(): Promise<number>\n deleteBySource(\n sourceEntityType: string,\n sourceEntityId: string,\n ctx: NotificationServiceContext\n ): Promise<number>\n}\n\nexport interface NotificationServiceDeps {\n em: EntityManager\n eventBus: { emit: (event: string, payload: unknown) => Promise<void> }\n commandBus?: {\n execute: (\n commandId: string,\n options: { input: unknown; ctx: unknown; metadata?: unknown }\n ) => Promise<{ result: unknown }>\n }\n container?: { resolve: (name: string) => unknown }\n}\n\nexport function createNotificationService(deps: NotificationServiceDeps): NotificationService {\n const { em: rootEm, eventBus, commandBus, container } = deps\n\n return {\n async create(input, ctx) {\n const { recipientUserId, ...content } = input\n const writeEm = rootEm.fork()\n const notification = await writeEm.transactional(async (tx) => {\n await assertNotificationRecipientsInScope(tx, [recipientUserId], ctx)\n const entity = await createOrRefreshNotification(tx, content, recipientUserId, ctx)\n await tx.flush()\n return entity\n })\n\n await emitNotificationCreated(eventBus, notification, ctx)\n await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {\n tenantId: notification.tenantId,\n organizationId: notification.organizationId ?? null,\n recipientUserId: notification.recipientUserId,\n notification: toNotificationDto(notification),\n })\n\n return notification\n },\n\n async createBatch(input, ctx) {\n const recipientUserIds = Array.from(new Set(input.recipientUserIds))\n const { recipientUserIds: _recipientUserIds, ...content } = input\n const notifications: Notification[] = []\n const writeEm = rootEm.fork()\n\n await writeEm.transactional(async (tx) => {\n await assertNotificationRecipientsInScope(tx, recipientUserIds, ctx)\n for (const recipientUserId of recipientUserIds) {\n const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx)\n notifications.push(notification)\n }\n await tx.flush()\n })\n\n await emitNotificationCreatedBatch(eventBus, notifications, ctx)\n await emitNotificationSseEvents(eventBus, notifications, ctx, recipientUserIds)\n\n return notifications\n },\n\n async createForRole(input, ctx) {\n const em = rootEm.fork()\n\n const db = getDb(em)\n const recipientUserIds = await getRecipientUserIdsForRole(db, ctx.tenantId, input.roleId)\n if (recipientUserIds.length === 0) {\n return []\n }\n\n const { roleId: _roleId, ...content } = input\n const notifications: Notification[] = []\n const uniqueRecipientUserIds = Array.from(new Set(recipientUserIds))\n const writeEm = rootEm.fork()\n\n await writeEm.transactional(async (tx) => {\n for (const recipientUserId of uniqueRecipientUserIds) {\n const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx)\n notifications.push(notification)\n }\n await tx.flush()\n })\n\n await emitNotificationCreatedBatch(eventBus, notifications, ctx)\n await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds)\n\n return notifications\n },\n\n async createForFeature(input, ctx) {\n const em = rootEm.fork()\n const db = getDb(em)\n const recipientUserIds = await getRecipientUserIdsForFeature(db, ctx.tenantId, input.requiredFeature)\n\n if (recipientUserIds.length === 0) {\n debug('No users found with feature:', input.requiredFeature, 'in tenant:', ctx.tenantId)\n return []\n }\n\n debug('Creating notifications for', recipientUserIds.length, 'user(s) with feature:', input.requiredFeature)\n\n const { requiredFeature: _requiredFeature, ...content } = input\n const notifications: Notification[] = []\n const uniqueRecipientUserIds = Array.from(new Set(recipientUserIds))\n const writeEm = rootEm.fork()\n\n await writeEm.transactional(async (tx) => {\n for (const recipientUserId of uniqueRecipientUserIds) {\n const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx)\n notifications.push(notification)\n }\n await tx.flush()\n })\n\n await emitNotificationCreatedBatch(eventBus, notifications, ctx)\n await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds)\n\n return notifications\n },\n\n async markAsRead(notificationId, ctx) {\n const em = rootEm.fork()\n const notification = await findScopedNotificationOrThrow(em, notificationId, ctx)\n\n if (notification.status === 'unread') {\n notification.status = 'read'\n notification.readAt = new Date()\n await em.flush()\n\n await eventBus.emit(NOTIFICATION_EVENTS.READ, {\n notificationId: notification.id,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n })\n }\n\n return notification\n },\n\n async markAllAsRead(ctx) {\n const em = rootEm.fork()\n const db = getDb(em)\n const applyScope = <QB extends { where: (...args: any[]) => QB }>(q: QB): QB => {\n let chain = q\n .where('recipient_user_id' as any, '=', ctx.userId as any)\n .where('tenant_id' as any, '=', ctx.tenantId)\n .where('status' as any, '=', 'unread')\n if (ctx.organizationId) {\n chain = chain.where('organization_id' as any, '=', ctx.organizationId)\n }\n return chain\n }\n\n const targetRows = await applyScope(\n db\n .selectFrom('notifications' as any)\n .select([\n 'id' as any,\n 'organization_id' as any,\n 'recipient_user_id' as any,\n ]),\n ).execute() as Array<{ id: string }>\n\n if (!targetRows.length) {\n return 0\n }\n\n const updateResult = await applyScope(\n db.updateTable('notifications' as any).set({\n status: 'read',\n read_at: sql`now()`,\n } as any) as any,\n ).executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined\n const result = Number(updateResult?.numUpdatedRows ?? targetRows.length)\n\n const notifications = await findWithDecryption(em, Notification, {\n id: { $in: targetRows.map((row) => row.id) },\n }, undefined, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n })\n\n for (const notification of notifications) {\n await eventBus.emit(NOTIFICATION_EVENTS.READ, {\n notificationId: notification.id,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n })\n\n await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {\n tenantId: notification.tenantId,\n organizationId: notification.organizationId ?? null,\n recipientUserId: notification.recipientUserId,\n notification: toNotificationDto(notification),\n })\n }\n\n return result\n },\n\n async dismiss(notificationId, ctx) {\n const em = rootEm.fork()\n const notification = await findScopedNotificationOrThrow(em, notificationId, ctx)\n\n notification.status = 'dismissed'\n notification.dismissedAt = new Date()\n await em.flush()\n\n await eventBus.emit(NOTIFICATION_EVENTS.DISMISSED, {\n notificationId: notification.id,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n })\n\n return notification\n },\n\n async restoreDismissed(notificationId, status, ctx) {\n const em = rootEm.fork()\n const notification = await findScopedNotificationOrThrow(em, notificationId, ctx)\n\n if (notification.status !== 'dismissed') {\n return notification\n }\n\n const targetStatus = status ?? 'read'\n notification.status = targetStatus\n notification.dismissedAt = null\n\n if (targetStatus === 'unread') {\n notification.readAt = null\n } else if (!notification.readAt) {\n notification.readAt = new Date()\n }\n\n await em.flush()\n\n await eventBus.emit(NOTIFICATION_EVENTS.RESTORED, {\n notificationId: notification.id,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n status: targetStatus,\n })\n\n return notification\n },\n\n async executeAction(notificationId, input, ctx) {\n const em = rootEm.fork()\n const notification = await findScopedNotificationOrThrow(em, notificationId, ctx)\n\n const actionData = notification.actionData\n const action = actionData?.actions?.find((a) => a.id === input.actionId)\n\n if (!action) {\n throw new Error('Action not found')\n }\n\n // Reject an already-actioned notification before dispatching the command,\n // so a retry or double-click cannot re-run the side effect.\n if (notification.status === 'actioned') {\n throw conflict('Notification action already executed')\n }\n\n const actionedAt = new Date()\n const previousStatus = notification.status\n const previousActionedAt = notification.actionedAt ?? null\n const previousActionTaken = notification.actionTaken ?? null\n\n // Atomically claim the notification so only one concurrent request can run\n // the side-effecting command. The conditional UPDATE matches only while the\n // notification has not been actioned yet; a losing request updates 0 rows.\n const claimResult = (await getDb(em)\n .updateTable('notifications' as any)\n .set({\n status: 'actioned',\n actioned_at: actionedAt,\n action_taken: input.actionId,\n } as any)\n .where('id' as any, '=', notification.id)\n .where('recipient_user_id' as any, '=', ctx.userId as any)\n .where('tenant_id' as any, '=', ctx.tenantId)\n .where('status' as any, '!=', 'actioned')\n .executeTakeFirst()) as { numUpdatedRows?: bigint | number } | undefined\n\n if (Number(claimResult?.numUpdatedRows ?? 0) === 0) {\n throw conflict('Notification action already executed')\n }\n\n // The claim is provisional: if the side-effecting command fails, the action\n // never actually completed, so release the claim to its prior state. This\n // lets the user retry the action instead of the notification being locked as\n // `actioned` forever. Only release while we still own the claim\n // (status = 'actioned'), so a concurrent winner's state is never clobbered.\n const releaseClaim = async () => {\n await getDb(em)\n .updateTable('notifications' as any)\n .set({\n status: previousStatus,\n actioned_at: previousActionedAt,\n action_taken: previousActionTaken,\n } as any)\n .where('id' as any, '=', notification.id)\n .where('recipient_user_id' as any, '=', ctx.userId as any)\n .where('tenant_id' as any, '=', ctx.tenantId)\n .where('status' as any, '=', 'actioned')\n .executeTakeFirst()\n }\n\n let result: unknown = null\n\n if (action.commandId && commandBus && container) {\n const commandInput = {\n id: notification.sourceEntityId,\n ...input.payload,\n }\n\n // Build a CommandRuntimeContext from the notification service context\n const commandCtx = {\n container,\n auth: {\n sub: ctx.userId,\n tenantId: ctx.tenantId,\n orgId: ctx.organizationId,\n },\n organizationScope: null,\n selectedOrganizationId: ctx.organizationId ?? null,\n organizationIds: ctx.organizationId ? [ctx.organizationId] : null,\n }\n\n let commandResult: { result: unknown }\n try {\n commandResult = await commandBus.execute(action.commandId, {\n input: commandInput,\n ctx: commandCtx,\n metadata: {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n resourceKind: 'notifications',\n },\n })\n } catch (err) {\n // Never let a rollback failure mask the original command error \u2014 the\n // caller needs the real failure to decide whether to retry.\n try {\n await releaseClaim()\n } catch (releaseErr) {\n debug('failed to release notification action claim', releaseErr)\n }\n throw err\n }\n\n result = commandResult.result\n }\n\n notification.status = 'actioned'\n notification.actionedAt = actionedAt\n notification.actionTaken = input.actionId\n notification.actionResult = result as Record<string, unknown>\n\n if (!notification.readAt) {\n notification.readAt = new Date()\n }\n\n await em.flush()\n\n await eventBus.emit(NOTIFICATION_EVENTS.ACTIONED, {\n notificationId: notification.id,\n actionId: input.actionId,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n })\n\n return { notification, result }\n },\n\n async getUnreadCount(ctx) {\n const em = rootEm.fork()\n return em.count(Notification, {\n recipientUserId: ctx.userId,\n tenantId: ctx.tenantId,\n status: 'unread',\n ...buildNotificationReadScopeWhere(ctx),\n })\n },\n\n async getPollData(ctx, since) {\n const em = rootEm.fork()\n const filters: Record<string, unknown> = {\n recipientUserId: ctx.userId,\n tenantId: ctx.tenantId,\n ...buildNotificationReadScopeWhere(ctx),\n }\n\n if (since) {\n filters.createdAt = { $gt: new Date(since) }\n }\n\n const [notifications, unreadCount] = await Promise.all([\n em.find(Notification, filters, {\n orderBy: { createdAt: 'desc' },\n limit: 50,\n }),\n em.count(Notification, {\n recipientUserId: ctx.userId,\n tenantId: ctx.tenantId,\n status: 'unread',\n ...buildNotificationReadScopeWhere(ctx),\n }),\n ])\n\n const recent = notifications.map(toNotificationDto)\n const hasNew = since ? recent.length > 0 : false\n\n return {\n unreadCount,\n recent,\n hasNew,\n lastId: recent[0]?.id,\n }\n },\n\n async cleanupExpired() {\n const em = rootEm.fork()\n const db = getDb(em)\n\n const updateResult = await db\n .updateTable('notifications' as any)\n .set({\n status: 'dismissed',\n dismissed_at: sql`now()`,\n } as any)\n .where('expires_at' as any, '<', sql`now()`)\n .where('status' as any, 'not in', ['actioned', 'dismissed'])\n .executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined\n\n return Number(updateResult?.numUpdatedRows ?? 0)\n },\n\n async deleteBySource(sourceEntityType, sourceEntityId, ctx) {\n const em = rootEm.fork()\n const db = getDb(em)\n\n const deleteResult = await db\n .deleteFrom('notifications' as any)\n .where('source_entity_type' as any, '=', sourceEntityType)\n .where('source_entity_id' as any, '=', sourceEntityId)\n .where('tenant_id' as any, '=', ctx.tenantId)\n .executeTakeFirst() as { numDeletedRows?: bigint | number } | undefined\n\n return Number(deleteResult?.numDeletedRows ?? 0)\n },\n }\n}\n\n/**\n * Helper to create notification service from a DI container.\n * Use this in API routes and commands to avoid DI resolution issues.\n */\nexport function resolveNotificationService(container: {\n resolve: (name: string) => unknown\n}): NotificationService {\n const em = container.resolve('em') as EntityManager\n const eventBus = container.resolve('eventBus') as { emit: (event: string, payload: unknown) => Promise<void> }\n\n // commandBus may not be registered in all contexts, so resolve it safely\n let commandBus: NotificationServiceDeps['commandBus']\n try {\n commandBus = container.resolve('commandBus') as typeof commandBus\n } catch {\n // commandBus not available - actions with commandId won't work\n commandBus = undefined\n }\n\n return createNotificationService({ em, eventBus, commandBus, container })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport { CrudHttpError, conflict } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { Notification, type NotificationStatus } from '../data/entities'\nimport type { CreateNotificationInput, CreateBatchNotificationInput, CreateRoleNotificationInput, CreateFeatureNotificationInput, ExecuteActionInput } from '../data/validators'\nimport type { NotificationPollData } from '@open-mercato/shared/modules/notifications/types'\nimport { NOTIFICATION_EVENTS, NOTIFICATION_SSE_EVENTS } from './events'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n buildNotificationEntity,\n emitNotificationCreated,\n emitNotificationCreatedBatch,\n type NotificationContentInput,\n type NotificationTenantContext,\n} from './notificationFactory'\nimport { toNotificationDto } from './notificationMapper'\nimport { buildNotificationReadScopeWhere } from './notificationScope'\nimport {\n getRecipientUserIdsForFeature,\n getRecipientUserIdsForRole,\n getScopedNotificationRecipientUserIds,\n} from './notificationRecipients'\nimport { assertSafeNotificationHref, sanitizeNotificationActions } from './safeHref'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('notifications').child({ component: 'service' })\n\nfunction debug(message: string, ...details: unknown[]): void {\n logger.debug(message, details.length ? { details } : undefined)\n}\n\nfunction getDb(em: EntityManager): Kysely<any> {\n return em.getKysely<any>()\n}\n\nconst UNIQUE_NOTIFICATION_ACTIVE_STATUSES: NotificationStatus[] = ['unread', 'read', 'actioned']\nconst NOTIFICATION_RESOURCE_KIND = 'notifications.notification'\n\nfunction normalizeOrgScope(organizationId: string | null | undefined): string | null {\n return organizationId ?? null\n}\n\nasync function invalidateNotificationCache(\n container: NotificationServiceDeps['container'],\n ctx: Pick<NotificationServiceContext, 'tenantId' | 'organizationId'>,\n reason: string,\n notificationId?: string,\n): Promise<void> {\n if (!container) return\n await invalidateCrudCache(\n container as Parameters<typeof invalidateCrudCache>[0],\n NOTIFICATION_RESOURCE_KIND,\n {\n id: notificationId,\n tenantId: ctx.tenantId,\n organizationId: normalizeOrgScope(ctx.organizationId),\n },\n ctx.tenantId,\n reason,\n )\n}\n\nasync function assertNotificationRecipientsInScope(\n em: EntityManager,\n recipientUserIds: string[],\n ctx: NotificationServiceContext,\n): Promise<void> {\n const scopedRecipientUserIds = await getScopedNotificationRecipientUserIds(\n getDb(em),\n ctx.tenantId,\n normalizeOrgScope(ctx.organizationId),\n recipientUserIds,\n )\n\n if (scopedRecipientUserIds.length !== recipientUserIds.length) {\n throw new CrudHttpError(404, { error: 'Notification recipient not found' })\n }\n}\n\nfunction applyNotificationContent(\n notification: Notification,\n input: NotificationContentInput,\n recipientUserId: string,\n ctx: NotificationTenantContext,\n) {\n const actions = sanitizeNotificationActions(input.actions)\n const linkHref = assertSafeNotificationHref(input.linkHref)\n\n notification.recipientUserId = recipientUserId\n notification.type = input.type\n notification.titleKey = input.titleKey\n notification.bodyKey = input.bodyKey\n notification.titleVariables = input.titleVariables\n notification.bodyVariables = input.bodyVariables\n notification.title = input.title || input.titleKey || ''\n notification.body = input.body\n notification.icon = input.icon\n notification.severity = input.severity ?? 'info'\n notification.actionData = actions\n ? {\n actions,\n primaryActionId: input.primaryActionId,\n }\n : null\n notification.sourceModule = input.sourceModule\n notification.sourceEntityType = input.sourceEntityType\n notification.sourceEntityId = input.sourceEntityId\n notification.linkHref = linkHref\n notification.groupKey = input.groupKey\n notification.expiresAt = input.expiresAt ? new Date(input.expiresAt) : null\n notification.tenantId = ctx.tenantId\n notification.organizationId = normalizeOrgScope(ctx.organizationId)\n notification.status = 'unread'\n notification.readAt = null\n notification.actionedAt = null\n notification.dismissedAt = null\n notification.actionTaken = null\n notification.actionResult = null\n notification.createdAt = new Date()\n}\n\nasync function findScopedNotificationOrThrow(\n em: EntityManager,\n notificationId: string,\n ctx: NotificationServiceContext,\n): Promise<Notification> {\n const notification = await findOneWithDecryption(\n em,\n Notification,\n {\n id: notificationId,\n recipientUserId: ctx.userId,\n tenantId: ctx.tenantId,\n },\n undefined,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (!notification) {\n throw new CrudHttpError(404, { error: 'Notification not found' })\n }\n return notification\n}\n\nasync function emitNotificationSseEvents(\n eventBus: { emit: (event: string, payload: unknown) => Promise<void> },\n notifications: Notification[],\n ctx: NotificationServiceContext,\n recipientUserIds: string[],\n): Promise<void> {\n await eventBus.emit(NOTIFICATION_SSE_EVENTS.BATCH_CREATED, {\n tenantId: ctx.tenantId,\n organizationId: normalizeOrgScope(ctx.organizationId),\n recipientUserIds,\n count: notifications.length,\n })\n\n for (const notification of notifications) {\n await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {\n tenantId: notification.tenantId,\n organizationId: notification.organizationId ?? null,\n recipientUserId: notification.recipientUserId,\n notification: toNotificationDto(notification),\n })\n }\n}\n\nasync function createOrRefreshNotification(\n em: EntityManager,\n input: NotificationContentInput,\n recipientUserId: string,\n ctx: NotificationTenantContext,\n): Promise<Notification> {\n if (input.groupKey && input.groupKey.trim().length > 0) {\n const orgScope = normalizeOrgScope(ctx.organizationId) ?? 'global'\n const lockKey = `notifications:${ctx.tenantId}:${orgScope}:${recipientUserId}:${input.type}:${input.groupKey}`\n try {\n const db = getDb(em)\n await sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`.execute(db)\n } catch {\n // If advisory locks are unavailable, continue with best-effort dedupe.\n }\n\n const existing = await em.findOne(Notification, {\n recipientUserId,\n tenantId: ctx.tenantId,\n organizationId: normalizeOrgScope(ctx.organizationId),\n type: input.type,\n groupKey: input.groupKey,\n status: { $in: UNIQUE_NOTIFICATION_ACTIVE_STATUSES },\n }, {\n orderBy: { createdAt: 'desc' },\n })\n\n if (existing) {\n applyNotificationContent(existing, input, recipientUserId, ctx)\n return existing\n }\n }\n\n return buildNotificationEntity(em, input, recipientUserId, ctx)\n}\n\nexport interface NotificationServiceContext {\n tenantId: string\n organizationId?: string | null\n organizationIds?: string[] | null\n userId?: string | null\n}\n\nexport interface NotificationService {\n create(input: CreateNotificationInput, ctx: NotificationServiceContext): Promise<Notification>\n createBatch(input: CreateBatchNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>\n createForRole(input: CreateRoleNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>\n createForFeature(input: CreateFeatureNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>\n markAsRead(notificationId: string, ctx: NotificationServiceContext): Promise<Notification>\n markAllAsRead(ctx: NotificationServiceContext): Promise<number>\n dismiss(notificationId: string, ctx: NotificationServiceContext): Promise<Notification>\n restoreDismissed(\n notificationId: string,\n status: 'read' | 'unread' | undefined,\n ctx: NotificationServiceContext\n ): Promise<Notification>\n executeAction(\n notificationId: string,\n input: ExecuteActionInput,\n ctx: NotificationServiceContext\n ): Promise<{ notification: Notification; result: unknown }>\n getUnreadCount(ctx: NotificationServiceContext): Promise<number>\n getPollData(ctx: NotificationServiceContext, since?: string): Promise<NotificationPollData>\n cleanupExpired(): Promise<number>\n deleteBySource(\n sourceEntityType: string,\n sourceEntityId: string,\n ctx: NotificationServiceContext\n ): Promise<number>\n}\n\nexport interface NotificationServiceDeps {\n em: EntityManager\n eventBus: { emit: (event: string, payload: unknown) => Promise<void> }\n commandBus?: {\n execute: (\n commandId: string,\n options: { input: unknown; ctx: unknown; metadata?: unknown }\n ) => Promise<{ result: unknown }>\n }\n container?: { resolve: (name: string) => unknown }\n}\n\nexport function createNotificationService(deps: NotificationServiceDeps): NotificationService {\n const { em: rootEm, eventBus, commandBus, container } = deps\n\n return {\n async create(input, ctx) {\n const { recipientUserId, ...content } = input\n const writeEm = rootEm.fork()\n const notification = await writeEm.transactional(async (tx) => {\n await assertNotificationRecipientsInScope(tx, [recipientUserId], ctx)\n const entity = await createOrRefreshNotification(tx, content, recipientUserId, ctx)\n await tx.flush()\n return entity\n })\n\n await invalidateNotificationCache(container, ctx, 'created')\n await emitNotificationCreated(eventBus, notification, ctx)\n await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {\n tenantId: notification.tenantId,\n organizationId: notification.organizationId ?? null,\n recipientUserId: notification.recipientUserId,\n notification: toNotificationDto(notification),\n })\n\n return notification\n },\n\n async createBatch(input, ctx) {\n const recipientUserIds = Array.from(new Set(input.recipientUserIds))\n const { recipientUserIds: _recipientUserIds, ...content } = input\n const notifications: Notification[] = []\n const writeEm = rootEm.fork()\n\n await writeEm.transactional(async (tx) => {\n await assertNotificationRecipientsInScope(tx, recipientUserIds, ctx)\n for (const recipientUserId of recipientUserIds) {\n const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx)\n notifications.push(notification)\n }\n await tx.flush()\n })\n\n await invalidateNotificationCache(container, ctx, 'created')\n await emitNotificationCreatedBatch(eventBus, notifications, ctx)\n await emitNotificationSseEvents(eventBus, notifications, ctx, recipientUserIds)\n\n return notifications\n },\n\n async createForRole(input, ctx) {\n const em = rootEm.fork()\n\n const db = getDb(em)\n const recipientUserIds = await getRecipientUserIdsForRole(db, ctx.tenantId, input.roleId)\n if (recipientUserIds.length === 0) {\n return []\n }\n\n const { roleId: _roleId, ...content } = input\n const notifications: Notification[] = []\n const uniqueRecipientUserIds = Array.from(new Set(recipientUserIds))\n const writeEm = rootEm.fork()\n\n await writeEm.transactional(async (tx) => {\n for (const recipientUserId of uniqueRecipientUserIds) {\n const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx)\n notifications.push(notification)\n }\n await tx.flush()\n })\n\n await invalidateNotificationCache(container, ctx, 'created')\n await emitNotificationCreatedBatch(eventBus, notifications, ctx)\n await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds)\n\n return notifications\n },\n\n async createForFeature(input, ctx) {\n const em = rootEm.fork()\n const db = getDb(em)\n const recipientUserIds = await getRecipientUserIdsForFeature(db, ctx.tenantId, input.requiredFeature)\n\n if (recipientUserIds.length === 0) {\n debug('No users found with feature:', input.requiredFeature, 'in tenant:', ctx.tenantId)\n return []\n }\n\n debug('Creating notifications for', recipientUserIds.length, 'user(s) with feature:', input.requiredFeature)\n\n const { requiredFeature: _requiredFeature, ...content } = input\n const notifications: Notification[] = []\n const uniqueRecipientUserIds = Array.from(new Set(recipientUserIds))\n const writeEm = rootEm.fork()\n\n await writeEm.transactional(async (tx) => {\n for (const recipientUserId of uniqueRecipientUserIds) {\n const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx)\n notifications.push(notification)\n }\n await tx.flush()\n })\n\n await invalidateNotificationCache(container, ctx, 'created')\n await emitNotificationCreatedBatch(eventBus, notifications, ctx)\n await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds)\n\n return notifications\n },\n\n async markAsRead(notificationId, ctx) {\n const em = rootEm.fork()\n const notification = await findScopedNotificationOrThrow(em, notificationId, ctx)\n\n if (notification.status === 'unread') {\n notification.status = 'read'\n notification.readAt = new Date()\n await em.flush()\n\n await invalidateNotificationCache(container, ctx, 'updated', notification.id)\n await eventBus.emit(NOTIFICATION_EVENTS.READ, {\n notificationId: notification.id,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n })\n }\n\n return notification\n },\n\n async markAllAsRead(ctx) {\n const em = rootEm.fork()\n const db = getDb(em)\n const applyScope = <QB extends { where: (...args: any[]) => QB }>(q: QB): QB => {\n let chain = q\n .where('recipient_user_id' as any, '=', ctx.userId as any)\n .where('tenant_id' as any, '=', ctx.tenantId)\n .where('status' as any, '=', 'unread')\n if (ctx.organizationId) {\n chain = chain.where('organization_id' as any, '=', ctx.organizationId)\n }\n return chain\n }\n\n const targetRows = await applyScope(\n db\n .selectFrom('notifications' as any)\n .select([\n 'id' as any,\n 'organization_id' as any,\n 'recipient_user_id' as any,\n ]),\n ).execute() as Array<{ id: string }>\n\n if (!targetRows.length) {\n return 0\n }\n\n const updateResult = await applyScope(\n db.updateTable('notifications' as any).set({\n status: 'read',\n read_at: sql`now()`,\n } as any) as any,\n ).executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined\n const result = Number(updateResult?.numUpdatedRows ?? targetRows.length)\n\n await invalidateNotificationCache(container, ctx, 'updated')\n\n const notifications = await findWithDecryption(em, Notification, {\n id: { $in: targetRows.map((row) => row.id) },\n }, undefined, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n })\n\n for (const notification of notifications) {\n await eventBus.emit(NOTIFICATION_EVENTS.READ, {\n notificationId: notification.id,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n })\n\n await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {\n tenantId: notification.tenantId,\n organizationId: notification.organizationId ?? null,\n recipientUserId: notification.recipientUserId,\n notification: toNotificationDto(notification),\n })\n }\n\n return result\n },\n\n async dismiss(notificationId, ctx) {\n const em = rootEm.fork()\n const notification = await findScopedNotificationOrThrow(em, notificationId, ctx)\n\n notification.status = 'dismissed'\n notification.dismissedAt = new Date()\n await em.flush()\n\n await invalidateNotificationCache(container, ctx, 'updated', notification.id)\n await eventBus.emit(NOTIFICATION_EVENTS.DISMISSED, {\n notificationId: notification.id,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n })\n\n return notification\n },\n\n async restoreDismissed(notificationId, status, ctx) {\n const em = rootEm.fork()\n const notification = await findScopedNotificationOrThrow(em, notificationId, ctx)\n\n if (notification.status !== 'dismissed') {\n return notification\n }\n\n const targetStatus = status ?? 'read'\n notification.status = targetStatus\n notification.dismissedAt = null\n\n if (targetStatus === 'unread') {\n notification.readAt = null\n } else if (!notification.readAt) {\n notification.readAt = new Date()\n }\n\n await em.flush()\n\n await invalidateNotificationCache(container, ctx, 'updated', notification.id)\n await eventBus.emit(NOTIFICATION_EVENTS.RESTORED, {\n notificationId: notification.id,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n status: targetStatus,\n })\n\n return notification\n },\n\n async executeAction(notificationId, input, ctx) {\n const em = rootEm.fork()\n const notification = await findScopedNotificationOrThrow(em, notificationId, ctx)\n\n const actionData = notification.actionData\n const action = actionData?.actions?.find((a) => a.id === input.actionId)\n\n if (!action) {\n throw new Error('Action not found')\n }\n\n // Reject an already-actioned notification before dispatching the command,\n // so a retry or double-click cannot re-run the side effect.\n if (notification.status === 'actioned') {\n throw conflict('Notification action already executed')\n }\n\n const actionedAt = new Date()\n const previousStatus = notification.status\n const previousActionedAt = notification.actionedAt ?? null\n const previousActionTaken = notification.actionTaken ?? null\n\n // Atomically claim the notification so only one concurrent request can run\n // the side-effecting command. The conditional UPDATE matches only while the\n // notification has not been actioned yet; a losing request updates 0 rows.\n const claimResult = (await getDb(em)\n .updateTable('notifications' as any)\n .set({\n status: 'actioned',\n actioned_at: actionedAt,\n action_taken: input.actionId,\n } as any)\n .where('id' as any, '=', notification.id)\n .where('recipient_user_id' as any, '=', ctx.userId as any)\n .where('tenant_id' as any, '=', ctx.tenantId)\n .where('status' as any, '!=', 'actioned')\n .executeTakeFirst()) as { numUpdatedRows?: bigint | number } | undefined\n\n if (Number(claimResult?.numUpdatedRows ?? 0) === 0) {\n throw conflict('Notification action already executed')\n }\n\n // The claim is provisional: if the side-effecting command fails, the action\n // never actually completed, so release the claim to its prior state. This\n // lets the user retry the action instead of the notification being locked as\n // `actioned` forever. Only release while we still own the claim\n // (status = 'actioned'), so a concurrent winner's state is never clobbered.\n const releaseClaim = async () => {\n await getDb(em)\n .updateTable('notifications' as any)\n .set({\n status: previousStatus,\n actioned_at: previousActionedAt,\n action_taken: previousActionTaken,\n } as any)\n .where('id' as any, '=', notification.id)\n .where('recipient_user_id' as any, '=', ctx.userId as any)\n .where('tenant_id' as any, '=', ctx.tenantId)\n .where('status' as any, '=', 'actioned')\n .executeTakeFirst()\n }\n\n let result: unknown = null\n\n if (action.commandId && commandBus && container) {\n const commandInput = {\n id: notification.sourceEntityId,\n ...input.payload,\n }\n\n // Build a CommandRuntimeContext from the notification service context\n const commandCtx = {\n container,\n auth: {\n sub: ctx.userId,\n tenantId: ctx.tenantId,\n orgId: ctx.organizationId,\n },\n organizationScope: null,\n selectedOrganizationId: ctx.organizationId ?? null,\n organizationIds: ctx.organizationId ? [ctx.organizationId] : null,\n }\n\n let commandResult: { result: unknown }\n try {\n commandResult = await commandBus.execute(action.commandId, {\n input: commandInput,\n ctx: commandCtx,\n metadata: {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n resourceKind: 'notifications',\n },\n })\n } catch (err) {\n // Never let a rollback failure mask the original command error \u2014 the\n // caller needs the real failure to decide whether to retry.\n try {\n await releaseClaim()\n } catch (releaseErr) {\n debug('failed to release notification action claim', releaseErr)\n }\n throw err\n }\n\n result = commandResult.result\n }\n\n notification.status = 'actioned'\n notification.actionedAt = actionedAt\n notification.actionTaken = input.actionId\n notification.actionResult = result as Record<string, unknown>\n\n if (!notification.readAt) {\n notification.readAt = new Date()\n }\n\n await em.flush()\n\n await invalidateNotificationCache(container, ctx, 'updated', notification.id)\n await eventBus.emit(NOTIFICATION_EVENTS.ACTIONED, {\n notificationId: notification.id,\n actionId: input.actionId,\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n })\n\n return { notification, result }\n },\n\n async getUnreadCount(ctx) {\n const em = rootEm.fork()\n return em.count(Notification, {\n recipientUserId: ctx.userId,\n tenantId: ctx.tenantId,\n status: 'unread',\n ...buildNotificationReadScopeWhere(ctx),\n })\n },\n\n async getPollData(ctx, since) {\n const em = rootEm.fork()\n const filters: Record<string, unknown> = {\n recipientUserId: ctx.userId,\n tenantId: ctx.tenantId,\n ...buildNotificationReadScopeWhere(ctx),\n }\n\n if (since) {\n filters.createdAt = { $gt: new Date(since) }\n }\n\n const [notifications, unreadCount] = await Promise.all([\n em.find(Notification, filters, {\n orderBy: { createdAt: 'desc' },\n limit: 50,\n }),\n em.count(Notification, {\n recipientUserId: ctx.userId,\n tenantId: ctx.tenantId,\n status: 'unread',\n ...buildNotificationReadScopeWhere(ctx),\n }),\n ])\n\n const recent = notifications.map(toNotificationDto)\n const hasNew = since ? recent.length > 0 : false\n\n return {\n unreadCount,\n recent,\n hasNew,\n lastId: recent[0]?.id,\n }\n },\n\n async cleanupExpired() {\n const em = rootEm.fork()\n const db = getDb(em)\n\n const affectedScopes = await db\n .selectFrom('notifications' as any)\n .select([\n 'tenant_id' as any,\n 'organization_id' as any,\n ])\n .where('expires_at' as any, '<', sql`now()`)\n .where('status' as any, 'not in', ['actioned', 'dismissed'])\n .distinct()\n .execute() as Array<{ tenant_id: string; organization_id: string | null }>\n\n if (!affectedScopes.length) return 0\n\n const updateResult = await db\n .updateTable('notifications' as any)\n .set({\n status: 'dismissed',\n dismissed_at: sql`now()`,\n } as any)\n .where('expires_at' as any, '<', sql`now()`)\n .where('status' as any, 'not in', ['actioned', 'dismissed'])\n .executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined\n\n for (const scope of affectedScopes) {\n await invalidateNotificationCache(container, {\n tenantId: scope.tenant_id,\n organizationId: scope.organization_id,\n }, 'updated')\n }\n\n return Number(updateResult?.numUpdatedRows ?? 0)\n },\n\n async deleteBySource(sourceEntityType, sourceEntityId, ctx) {\n const em = rootEm.fork()\n const db = getDb(em)\n\n const deleteResult = await db\n .deleteFrom('notifications' as any)\n .where('source_entity_type' as any, '=', sourceEntityType)\n .where('source_entity_id' as any, '=', sourceEntityId)\n .where('tenant_id' as any, '=', ctx.tenantId)\n .executeTakeFirst() as { numDeletedRows?: bigint | number } | undefined\n\n const deletedCount = Number(deleteResult?.numDeletedRows ?? 0)\n if (deletedCount > 0) {\n await invalidateNotificationCache(container, ctx, 'deleted')\n }\n\n return deletedCount\n },\n }\n}\n\n/**\n * Helper to create notification service from a DI container.\n * Use this in API routes and commands to avoid DI resolution issues.\n */\nexport function resolveNotificationService(container: {\n resolve: (name: string) => unknown\n}): NotificationService {\n const em = container.resolve('em') as EntityManager\n const eventBus = container.resolve('eventBus') as { emit: (event: string, payload: unknown) => Promise<void> }\n\n // commandBus may not be registered in all contexts, so resolve it safely\n let commandBus: NotificationServiceDeps['commandBus']\n try {\n commandBus = container.resolve('commandBus') as typeof commandBus\n } catch {\n // commandBus not available - actions with commandId won't work\n commandBus = undefined\n }\n\n return createNotificationService({ em, eventBus, commandBus, container })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.7-develop.6773.1.f9b7fbae4f",
3
+ "version": "0.6.7-develop.6775.1.c2313bb8a3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.7-develop.6773.1.f9b7fbae4f",
258
- "@open-mercato/shared": "0.6.7-develop.6773.1.f9b7fbae4f",
259
- "@open-mercato/ui": "0.6.7-develop.6773.1.f9b7fbae4f",
257
+ "@open-mercato/ai-assistant": "0.6.7-develop.6775.1.c2313bb8a3",
258
+ "@open-mercato/shared": "0.6.7-develop.6775.1.c2313bb8a3",
259
+ "@open-mercato/ui": "0.6.7-develop.6775.1.c2313bb8a3",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.7-develop.6773.1.f9b7fbae4f",
265
- "@open-mercato/shared": "0.6.7-develop.6773.1.f9b7fbae4f",
266
- "@open-mercato/ui": "0.6.7-develop.6773.1.f9b7fbae4f",
264
+ "@open-mercato/ai-assistant": "0.6.7-develop.6775.1.c2313bb8a3",
265
+ "@open-mercato/shared": "0.6.7-develop.6775.1.c2313bb8a3",
266
+ "@open-mercato/ui": "0.6.7-develop.6775.1.c2313bb8a3",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^6.9.1",
269
269
  "@testing-library/react": "^16.3.1",
@@ -1,13 +1,22 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { z } from 'zod'
2
3
  import type { EntityManager } from '@mikro-orm/postgresql'
3
4
  import { type Kysely, sql } from 'kysely'
5
+ import { runWithCacheTenant } from '@open-mercato/cache'
4
6
  import type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
7
+ import {
8
+ buildCollectionTags,
9
+ debugCrudCache,
10
+ isCrudCacheEnabled,
11
+ normalizeTagSegment,
12
+ resolveCrudCache,
13
+ } from '@open-mercato/shared/lib/crud/cache'
5
14
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
6
15
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
16
  import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
8
17
  import { User } from '../../auth/data/entities'
9
18
  import { Message, MessageObject } from '../data/entities'
10
- import { composeMessageSchema, listMessagesSchema } from '../data/validators'
19
+ import { composeMessageSchema, listMessagesSchema, type ListMessagesInput } from '../data/validators'
11
20
  import { MESSAGE_ATTACHMENT_ENTITY_ID } from '../lib/constants'
12
21
  import { getMessageType } from '../lib/message-types-registry'
13
22
  import { validateMessageObjectsForType } from '../lib/object-validation'
@@ -29,6 +38,8 @@ type MessageCommandExecuteResultWithThreadId = MessageCommandExecuteResult & {
29
38
  }
30
39
 
31
40
  const NO_MATCH_ID = '00000000-0000-0000-0000-000000000000'
41
+ const MESSAGE_LIST_CACHE_TTL_MS = 30_000
42
+ const MESSAGE_LIST_RESOURCE = 'messages.message'
32
43
 
33
44
  function getDb(em: EntityManager): Kysely<any> {
34
45
  return em.getKysely<any>()
@@ -52,6 +63,59 @@ type RecipientCountRow = {
52
63
  count: string | number
53
64
  }
54
65
 
66
+ type MessageListPayload = {
67
+ items: Array<Record<string, unknown>>
68
+ page: number
69
+ pageSize: number
70
+ total: number
71
+ totalPages: number
72
+ }
73
+
74
+ type MessageListScope = Awaited<ReturnType<typeof resolveMessageContext>>['scope']
75
+
76
+ function normalizeCacheFilterValue(value: unknown): unknown {
77
+ if (value === undefined) return null
78
+ if (value instanceof Date) return value.toISOString()
79
+ if (Array.isArray(value)) return value.map(normalizeCacheFilterValue)
80
+ if (value && typeof value === 'object') {
81
+ return Object.entries(value as Record<string, unknown>)
82
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
83
+ .map(([key, entryValue]) => [key, normalizeCacheFilterValue(entryValue)])
84
+ }
85
+ return value
86
+ }
87
+
88
+ function buildMessageListFilterSignature(input: ListMessagesInput): string {
89
+ const canonicalInput = Object.entries(input)
90
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
91
+ .map(([key, value]) => [key, normalizeCacheFilterValue(value)])
92
+
93
+ return createHash('sha256')
94
+ .update(JSON.stringify(canonicalInput))
95
+ .digest('hex')
96
+ .slice(0, 32)
97
+ }
98
+
99
+ function buildMessageListCacheKey(scope: MessageListScope, input: ListMessagesInput): string {
100
+ return [
101
+ 'messages:list:v1',
102
+ `tenant:${normalizeTagSegment(scope.tenantId)}`,
103
+ `org:${normalizeTagSegment(scope.organizationId)}`,
104
+ `user:${normalizeTagSegment(scope.userId)}`,
105
+ `filters:${buildMessageListFilterSignature(input)}`,
106
+ ].join('|')
107
+ }
108
+
109
+ function isMessageListPayload(value: unknown): value is MessageListPayload {
110
+ if (!value || typeof value !== 'object') return false
111
+ const payload = value as Partial<MessageListPayload>
112
+ return Array.isArray(payload.items)
113
+ && typeof payload.page === 'number'
114
+ && typeof payload.pageSize === 'number'
115
+ && typeof payload.total === 'number'
116
+ && typeof payload.totalPages === 'number'
117
+ }
118
+
55
119
  export const metadata = {
56
120
  GET: { requireAuth: true },
57
121
  POST: { requireAuth: true, requireFeatures: ['messages.compose'] },
@@ -59,10 +123,26 @@ export const metadata = {
59
123
 
60
124
  export async function GET(req: Request) {
61
125
  const { ctx, scope } = await resolveMessageContext(req)
62
- const em = ctx.container.resolve('em') as EntityManager
63
126
  const url = new URL(req.url)
64
127
  const params = Object.fromEntries(url.searchParams)
65
128
  const input = listMessagesSchema.parse(params)
129
+
130
+ const cache = isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null
131
+ const cacheKey = cache ? buildMessageListCacheKey(scope, input) : null
132
+ if (cache && cacheKey) {
133
+ try {
134
+ const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))
135
+ if (isMessageListPayload(cached)) {
136
+ return Response.json(cached)
137
+ }
138
+ } catch (error) {
139
+ debugCrudCache('messages-list-cache-read-failed', {
140
+ error: error instanceof Error ? error.message : String(error),
141
+ })
142
+ }
143
+ }
144
+
145
+ const em = ctx.container.resolve('em') as EntityManager
66
146
  const db = getDb(em) as any
67
147
 
68
148
  const searchIds = input.search
@@ -290,7 +370,7 @@ export async function GET(req: Request) {
290
370
  senderMetaById.set(user.id, { name, email: user.email ?? null })
291
371
  })
292
372
 
293
- return Response.json({
373
+ const payload: MessageListPayload = {
294
374
  items: typedRows
295
375
  .map((row) => {
296
376
  const message = messagesById.get(row.id)
@@ -337,7 +417,28 @@ export async function GET(req: Request) {
337
417
  pageSize: input.pageSize,
338
418
  total,
339
419
  totalPages: Math.ceil(total / input.pageSize),
340
- })
420
+ }
421
+
422
+ if (cache && cacheKey) {
423
+ try {
424
+ await runWithCacheTenant(scope.tenantId, () =>
425
+ cache.set(cacheKey, payload, {
426
+ ttl: MESSAGE_LIST_CACHE_TTL_MS,
427
+ tags: buildCollectionTags(
428
+ MESSAGE_LIST_RESOURCE,
429
+ scope.tenantId,
430
+ [scope.organizationId],
431
+ ),
432
+ }),
433
+ )
434
+ } catch (error) {
435
+ debugCrudCache('messages-list-cache-write-failed', {
436
+ error: error instanceof Error ? error.message : String(error),
437
+ })
438
+ }
439
+ }
440
+
441
+ return Response.json(payload)
341
442
  }
342
443
 
343
444
  export async function POST(req: Request) {
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { z } from 'zod'
2
3
  import type { EntityManager } from '@mikro-orm/core'
3
4
  import { runWithCacheTenant } from '@open-mercato/cache'
@@ -10,7 +11,10 @@ import {
10
11
  import { Notification } from '../data/entities'
11
12
  import { listNotificationsSchema, createNotificationSchema } from '../data/validators'
12
13
  import { toNotificationDto } from '../lib/notificationMapper'
13
- import { buildNotificationReadScopeWhere } from '../lib/notificationScope'
14
+ import {
15
+ buildNotificationReadScopeWhere,
16
+ getNotificationReadScopeTagOrganizationIds,
17
+ } from '../lib/notificationScope'
14
18
  import {
15
19
  NOTIFICATION_RESOURCE_KIND,
16
20
  notificationCrudErrorResponse,
@@ -30,7 +34,9 @@ export const metadata = {
30
34
  }
31
35
 
32
36
  const NOTIFICATIONS_LIST_TTL_MS = 10_000
33
- const NOTIFICATIONS_LIST_CACHE_VERSION = 1
37
+ // v2: the key gained the selected-organization scope (#4840). Bumping the version
38
+ // retires v1 entries, which were shared across organizations for the same user.
39
+ const NOTIFICATIONS_LIST_CACHE_VERSION = 2
34
40
  const NOTIFICATIONS_LIST_RESOURCE = 'notifications.notification'
35
41
 
36
42
  type NotificationsListPayload = {
@@ -41,10 +47,20 @@ type NotificationsListPayload = {
41
47
  totalPages: number
42
48
  }
43
49
 
44
- function buildNotificationsListCacheKey(
50
+ export function buildNotificationsListCacheKey(
45
51
  userId: string,
52
+ organizationScope: { organizationId: string | null; organizationIds: string[] },
46
53
  input: z.infer<typeof listNotificationsSchema>,
47
54
  ): string {
55
+ const normalizedIds = Array.from(new Set(
56
+ organizationScope.organizationIds.filter((value) => value.trim().length > 0),
57
+ )).sort((left, right) => left.localeCompare(right))
58
+ const scopeKey = normalizedIds.length === 0
59
+ ? 'no-access'
60
+ : `${organizationScope.organizationId ?? 'none'}:scope=${createHash('sha256')
61
+ .update(normalizedIds.join('\0'))
62
+ .digest('hex')
63
+ .slice(0, 16)}`
48
64
  const filterSignature = JSON.stringify({
49
65
  status: Array.isArray(input.status)
50
66
  ? [...input.status].sort((left, right) => left.localeCompare(right))
@@ -57,7 +73,7 @@ function buildNotificationsListCacheKey(
57
73
  page: input.page,
58
74
  pageSize: input.pageSize,
59
75
  })
60
- return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:filters=${filterSignature}`
76
+ return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:org=${scopeKey}:filters=${filterSignature}`
61
77
  }
62
78
 
63
79
  function isNotificationsListPayload(value: unknown): value is NotificationsListPayload {
@@ -80,10 +96,23 @@ export async function GET(req: Request) {
80
96
  const queryParams = Object.fromEntries(url.searchParams.entries())
81
97
  const input = listNotificationsSchema.parse(queryParams)
82
98
  const userId = scope.userId
83
- const cache = userId && isCrudCacheEnabled()
99
+ // Mirrors api/unread-count/route.ts: unrestricted and omitted legacy organization
100
+ // scopes cannot be safely tagged for invalidation, because organization-specific
101
+ // writes only invalidate their own collection tag. Leave those scopes uncached
102
+ // rather than serving a stale tenant-wide list until the TTL expires.
103
+ const cacheableOrganizationIds = Array.isArray(scope.organizationIds)
104
+ ? scope.organizationIds
105
+ : null
106
+ const cache = userId && cacheableOrganizationIds && isCrudCacheEnabled()
84
107
  ? resolveCrudCache(ctx.container)
85
108
  : null
86
- const cacheKey = cache && userId ? buildNotificationsListCacheKey(userId, input) : null
109
+ const cacheKey = cache && userId && cacheableOrganizationIds
110
+ ? buildNotificationsListCacheKey(
111
+ userId,
112
+ { organizationId: scope.organizationId, organizationIds: cacheableOrganizationIds },
113
+ input,
114
+ )
115
+ : null
87
116
 
88
117
  if (cache && cacheKey) {
89
118
  try {
@@ -149,7 +178,11 @@ export async function GET(req: Request) {
149
178
  await runWithCacheTenant(scope.tenantId, () =>
150
179
  cache.set(cacheKey, payload, {
151
180
  ttl: NOTIFICATIONS_LIST_TTL_MS,
152
- tags: buildCollectionTags(NOTIFICATIONS_LIST_RESOURCE, scope.tenantId, [null]),
181
+ tags: buildCollectionTags(
182
+ NOTIFICATIONS_LIST_RESOURCE,
183
+ scope.tenantId,
184
+ getNotificationReadScopeTagOrganizationIds(scope),
185
+ ),
153
186
  }),
154
187
  )
155
188
  } catch (error) {
@@ -5,7 +5,7 @@ import { createNotificationService } from './lib/notificationService'
5
5
  export function register(container: AppContainer): void {
6
6
  container.register({
7
7
  notificationService: asFunction(({ em, eventBus, commandBus }) =>
8
- createNotificationService({ em, eventBus, commandBus })
8
+ createNotificationService({ em, eventBus, commandBus, container })
9
9
  ).scoped().proxy(),
10
10
  })
11
11
  }
@@ -1,6 +1,7 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { type Kysely, sql } from 'kysely'
3
3
  import { CrudHttpError, conflict } from '@open-mercato/shared/lib/crud/errors'
4
+ import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
4
5
  import { Notification, type NotificationStatus } from '../data/entities'
5
6
  import type { CreateNotificationInput, CreateBatchNotificationInput, CreateRoleNotificationInput, CreateFeatureNotificationInput, ExecuteActionInput } from '../data/validators'
6
7
  import type { NotificationPollData } from '@open-mercato/shared/modules/notifications/types'
@@ -34,11 +35,32 @@ function getDb(em: EntityManager): Kysely<any> {
34
35
  }
35
36
 
36
37
  const UNIQUE_NOTIFICATION_ACTIVE_STATUSES: NotificationStatus[] = ['unread', 'read', 'actioned']
38
+ const NOTIFICATION_RESOURCE_KIND = 'notifications.notification'
37
39
 
38
40
  function normalizeOrgScope(organizationId: string | null | undefined): string | null {
39
41
  return organizationId ?? null
40
42
  }
41
43
 
44
+ async function invalidateNotificationCache(
45
+ container: NotificationServiceDeps['container'],
46
+ ctx: Pick<NotificationServiceContext, 'tenantId' | 'organizationId'>,
47
+ reason: string,
48
+ notificationId?: string,
49
+ ): Promise<void> {
50
+ if (!container) return
51
+ await invalidateCrudCache(
52
+ container as Parameters<typeof invalidateCrudCache>[0],
53
+ NOTIFICATION_RESOURCE_KIND,
54
+ {
55
+ id: notificationId,
56
+ tenantId: ctx.tenantId,
57
+ organizationId: normalizeOrgScope(ctx.organizationId),
58
+ },
59
+ ctx.tenantId,
60
+ reason,
61
+ )
62
+ }
63
+
42
64
  async function assertNotificationRecipientsInScope(
43
65
  em: EntityManager,
44
66
  recipientUserIds: string[],
@@ -243,6 +265,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
243
265
  return entity
244
266
  })
245
267
 
268
+ await invalidateNotificationCache(container, ctx, 'created')
246
269
  await emitNotificationCreated(eventBus, notification, ctx)
247
270
  await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {
248
271
  tenantId: notification.tenantId,
@@ -269,6 +292,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
269
292
  await tx.flush()
270
293
  })
271
294
 
295
+ await invalidateNotificationCache(container, ctx, 'created')
272
296
  await emitNotificationCreatedBatch(eventBus, notifications, ctx)
273
297
  await emitNotificationSseEvents(eventBus, notifications, ctx, recipientUserIds)
274
298
 
@@ -297,6 +321,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
297
321
  await tx.flush()
298
322
  })
299
323
 
324
+ await invalidateNotificationCache(container, ctx, 'created')
300
325
  await emitNotificationCreatedBatch(eventBus, notifications, ctx)
301
326
  await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds)
302
327
 
@@ -328,6 +353,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
328
353
  await tx.flush()
329
354
  })
330
355
 
356
+ await invalidateNotificationCache(container, ctx, 'created')
331
357
  await emitNotificationCreatedBatch(eventBus, notifications, ctx)
332
358
  await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds)
333
359
 
@@ -343,6 +369,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
343
369
  notification.readAt = new Date()
344
370
  await em.flush()
345
371
 
372
+ await invalidateNotificationCache(container, ctx, 'updated', notification.id)
346
373
  await eventBus.emit(NOTIFICATION_EVENTS.READ, {
347
374
  notificationId: notification.id,
348
375
  userId: ctx.userId,
@@ -389,6 +416,8 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
389
416
  ).executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined
390
417
  const result = Number(updateResult?.numUpdatedRows ?? targetRows.length)
391
418
 
419
+ await invalidateNotificationCache(container, ctx, 'updated')
420
+
392
421
  const notifications = await findWithDecryption(em, Notification, {
393
422
  id: { $in: targetRows.map((row) => row.id) },
394
423
  }, undefined, {
@@ -422,6 +451,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
422
451
  notification.dismissedAt = new Date()
423
452
  await em.flush()
424
453
 
454
+ await invalidateNotificationCache(container, ctx, 'updated', notification.id)
425
455
  await eventBus.emit(NOTIFICATION_EVENTS.DISMISSED, {
426
456
  notificationId: notification.id,
427
457
  userId: ctx.userId,
@@ -451,6 +481,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
451
481
 
452
482
  await em.flush()
453
483
 
484
+ await invalidateNotificationCache(container, ctx, 'updated', notification.id)
454
485
  await eventBus.emit(NOTIFICATION_EVENTS.RESTORED, {
455
486
  notificationId: notification.id,
456
487
  userId: ctx.userId,
@@ -580,6 +611,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
580
611
 
581
612
  await em.flush()
582
613
 
614
+ await invalidateNotificationCache(container, ctx, 'updated', notification.id)
583
615
  await eventBus.emit(NOTIFICATION_EVENTS.ACTIONED, {
584
616
  notificationId: notification.id,
585
617
  actionId: input.actionId,
@@ -640,6 +672,19 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
640
672
  const em = rootEm.fork()
641
673
  const db = getDb(em)
642
674
 
675
+ const affectedScopes = await db
676
+ .selectFrom('notifications' as any)
677
+ .select([
678
+ 'tenant_id' as any,
679
+ 'organization_id' as any,
680
+ ])
681
+ .where('expires_at' as any, '<', sql`now()`)
682
+ .where('status' as any, 'not in', ['actioned', 'dismissed'])
683
+ .distinct()
684
+ .execute() as Array<{ tenant_id: string; organization_id: string | null }>
685
+
686
+ if (!affectedScopes.length) return 0
687
+
643
688
  const updateResult = await db
644
689
  .updateTable('notifications' as any)
645
690
  .set({
@@ -650,6 +695,13 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
650
695
  .where('status' as any, 'not in', ['actioned', 'dismissed'])
651
696
  .executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined
652
697
 
698
+ for (const scope of affectedScopes) {
699
+ await invalidateNotificationCache(container, {
700
+ tenantId: scope.tenant_id,
701
+ organizationId: scope.organization_id,
702
+ }, 'updated')
703
+ }
704
+
653
705
  return Number(updateResult?.numUpdatedRows ?? 0)
654
706
  },
655
707
 
@@ -664,7 +716,12 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
664
716
  .where('tenant_id' as any, '=', ctx.tenantId)
665
717
  .executeTakeFirst() as { numDeletedRows?: bigint | number } | undefined
666
718
 
667
- return Number(deleteResult?.numDeletedRows ?? 0)
719
+ const deletedCount = Number(deleteResult?.numDeletedRows ?? 0)
720
+ if (deletedCount > 0) {
721
+ await invalidateNotificationCache(container, ctx, 'deleted')
722
+ }
723
+
724
+ return deletedCount
668
725
  },
669
726
  }
670
727
  }