@ttt-productions/notification-core 0.15.9 → 0.15.10

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.
Files changed (55) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/react/components/NotificationHistoryList.d.ts +1 -1
  5. package/dist/react/components/NotificationHistoryList.d.ts.map +1 -1
  6. package/dist/react/components/NotificationHistoryList.js +6 -6
  7. package/dist/react/components/NotificationHistoryList.js.map +1 -1
  8. package/dist/react/components/NotificationList.d.ts +1 -1
  9. package/dist/react/components/NotificationList.d.ts.map +1 -1
  10. package/dist/react/components/NotificationList.js +65 -10
  11. package/dist/react/components/NotificationList.js.map +1 -1
  12. package/dist/react/components/NotificationUnreadBadge.d.ts +1 -1
  13. package/dist/react/components/NotificationUnreadBadge.d.ts.map +1 -1
  14. package/dist/react/components/NotificationUnreadBadge.js +2 -1
  15. package/dist/react/components/NotificationUnreadBadge.js.map +1 -1
  16. package/dist/react/components/notification-type-icon.d.ts +11 -0
  17. package/dist/react/components/notification-type-icon.d.ts.map +1 -0
  18. package/dist/react/components/notification-type-icon.js +12 -0
  19. package/dist/react/components/notification-type-icon.js.map +1 -0
  20. package/dist/react/hooks/useActiveNotifications.d.ts +1 -1
  21. package/dist/react/hooks/useActiveNotifications.d.ts.map +1 -1
  22. package/dist/react/hooks/useActiveNotifications.js +3 -2
  23. package/dist/react/hooks/useActiveNotifications.js.map +1 -1
  24. package/dist/react/hooks/useArchiveAllNotifications.d.ts +1 -1
  25. package/dist/react/hooks/useArchiveAllNotifications.d.ts.map +1 -1
  26. package/dist/react/hooks/useArchiveAllNotifications.js +3 -7
  27. package/dist/react/hooks/useArchiveAllNotifications.js.map +1 -1
  28. package/dist/react/hooks/useArchiveNotification.d.ts +1 -1
  29. package/dist/react/hooks/useArchiveNotification.d.ts.map +1 -1
  30. package/dist/react/hooks/useArchiveNotification.js +3 -7
  31. package/dist/react/hooks/useArchiveNotification.js.map +1 -1
  32. package/dist/react/hooks/useNotificationHistory.d.ts +1 -1
  33. package/dist/react/hooks/useNotificationHistory.d.ts.map +1 -1
  34. package/dist/react/hooks/useNotificationHistory.js +3 -2
  35. package/dist/react/hooks/useNotificationHistory.js.map +1 -1
  36. package/dist/react/hooks/useUnreadCount.d.ts +10 -2
  37. package/dist/react/hooks/useUnreadCount.d.ts.map +1 -1
  38. package/dist/react/hooks/useUnreadCount.js +16 -6
  39. package/dist/react/hooks/useUnreadCount.js.map +1 -1
  40. package/dist/react/query-keys.d.ts +5 -0
  41. package/dist/react/query-keys.d.ts.map +1 -0
  42. package/dist/react/query-keys.js +15 -0
  43. package/dist/react/query-keys.js.map +1 -0
  44. package/dist/server/delivery-ledger.d.ts +34 -5
  45. package/dist/server/delivery-ledger.d.ts.map +1 -1
  46. package/dist/server/delivery-ledger.js +24 -5
  47. package/dist/server/delivery-ledger.js.map +1 -1
  48. package/dist/server/index.d.ts +1 -1
  49. package/dist/server/index.d.ts.map +1 -1
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/types.d.ts +65 -5
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js.map +1 -1
  54. package/package.json +5 -5
  55. package/src/styles/notifications.css +10 -33
@@ -1 +1 @@
1
- {"version":3,"file":"useArchiveAllNotifications.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveAllNotifications.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type {\n ArchiveAllPollResult,\n UseArchiveAllNotificationsOptions,\n} from '../../types.js';\n\nconst DEFAULT_POLL_INTERVAL_MS = 1500;\nconst DEFAULT_MAX_POLLS = 120; // ~3 min at the default interval\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Trigger + poll a SERVER-OWNED archive-all job for one notification category.\n *\n * This is a THIN STATUS POLLER, not a client loop. It:\n * 1. enqueues ONE server job via `enqueueArchiveAllFn(category)` (the category — the tab the tray\n * was invoked on — flows into the job, preserving per-tab scoping: the worker archives ONLY\n * that category's active cards, never all tabs);\n * 2. polls `getArchiveAllStatusFn(jobId)` until the job reaches a terminal state\n * (`complete` | `incomplete` | `failed`);\n * 3. resolves an {@link ArchiveAllPollResult} the caller gates side effects on, and invalidates\n * the read keys on a completed drain.\n *\n * All paging + ownership-checked per-card deletion happen server-side. The hook performs no client\n * Firestore writes and never chains archive calls. The generic package hardcodes no callable name —\n * the app injects both adapters.\n *\n * @example\n * ```tsx\n * const enqueue = httpsCallable(functions, 'enqueueArchiveAll');\n * const getStatus = httpsCallable(functions, 'getArchiveAllStatus');\n * const archiveAll = useArchiveAllNotifications({\n * userId: user.uid,\n * category: 'user',\n * enqueueArchiveAllFn: (category) => enqueue({ category }).then((r) => r.data),\n * getArchiveAllStatusFn: (jobId) => getStatus({ jobId }).then((r) => r.data),\n * });\n *\n * archiveAll.mutate();\n * ```\n */\nexport function useArchiveAllNotifications({\n userId,\n category,\n enqueueArchiveAllFn,\n getArchiveAllStatusFn,\n pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,\n maxPolls = DEFAULT_MAX_POLLS,\n invalidateKeys,\n}: UseArchiveAllNotificationsOptions) {\n const queryClient = useQueryClient();\n\n const defaultInvalidateKeys = [\n ['notifications', 'active', category, userId],\n ['notifications', 'unread-count', category, userId],\n ['notifications', 'history', category, userId],\n ];\n\n return useMutation({\n // Server-owned archive-all: enqueue ONE job scoped to `category`, then poll its status until it\n // reaches a terminal state. No browser loop, no chain of archive transactions. The mutation\n // resolves with an explicit terminal result (never throws for an incomplete drain) — `complete`\n // is the load-bearing flag callers gate post-clear side effects on.\n mutationFn: async (): Promise<ArchiveAllPollResult> => {\n const { jobId } = await enqueueArchiveAllFn(category);\n\n // Poll until terminal or until the guard bound is hit. A job that never terminates resolves\n // `incomplete` (mirrors the old loop's honest \"some remain\" result) rather than hanging. The\n // first poll happens immediately so a fast/already-complete job resolves without a delay.\n let lastArchived = 0;\n for (let poll = 0; poll < maxPolls; poll += 1) {\n const snap = await getArchiveAllStatusFn(jobId);\n lastArchived = snap.archived ?? 0;\n\n if (snap.state === 'complete') {\n return { status: 'complete', category, archived: lastArchived, complete: true };\n }\n if (snap.state === 'incomplete') {\n return { status: 'incomplete', category, archived: lastArchived, complete: false };\n }\n if (snap.state === 'failed') {\n return { status: 'failed', category, archived: lastArchived, complete: false, error: snap.error };\n }\n\n // Still in-progress — wait before the next poll unless this was the last allowed poll.\n if (poll < maxPolls - 1) await sleep(pollIntervalMs);\n }\n\n // Guard: the job never reached a terminal state within the poll budget — report incomplete.\n return { status: 'incomplete', category, archived: lastArchived, complete: false };\n },\n onSuccess: (result) => {\n // Only a completed drain changed the active set; still invalidate on an incomplete drain since\n // SOME cards were archived. A hard enqueue/poll failure rejects the mutation (onError), so this\n // never runs for `failed`.\n const keysToInvalidate = invalidateKeys ?? defaultInvalidateKeys;\n keysToInvalidate.forEach((key) => {\n queryClient.invalidateQueries({ queryKey: [...key], exact: false });\n });\n void result;\n },\n });\n}\n"]}
1
+ {"version":3,"file":"useArchiveAllNotifications.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveAllNotifications.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type {\n ArchiveAllPollResult,\n UseArchiveAllNotificationsOptions,\n} from '../../types.js';\nimport { archiveRefreshKeys } from '../query-keys.js';\n\nconst DEFAULT_POLL_INTERVAL_MS = 1500;\nconst DEFAULT_MAX_POLLS = 120; // ~3 min at the default interval\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Trigger + poll a SERVER-OWNED archive-all job for one notification category.\n *\n * This is a THIN STATUS POLLER, not a client loop. It:\n * 1. enqueues ONE server job via `enqueueArchiveAllFn(category)` (the category — the tab the tray\n * was invoked on — flows into the job, preserving per-tab scoping: the worker archives ONLY\n * that category's active cards, never all tabs);\n * 2. polls `getArchiveAllStatusFn(jobId)` until the job reaches a terminal state\n * (`complete` | `incomplete` | `failed`);\n * 3. resolves an {@link ArchiveAllPollResult} the caller gates side effects on, and invalidates\n * the read keys on a completed drain.\n *\n * All paging + ownership-checked per-card deletion happen server-side. The hook performs no client\n * Firestore writes and never chains archive calls. The generic package hardcodes no callable name —\n * the app injects both adapters.\n *\n * @example\n * ```tsx\n * const enqueue = httpsCallable(functions, 'enqueueArchiveAll');\n * const getStatus = httpsCallable(functions, 'getArchiveAllStatus');\n * const archiveAll = useArchiveAllNotifications({\n * userId: user.uid,\n * category: 'user',\n * enqueueArchiveAllFn: (category) => enqueue({ category }).then((r) => r.data),\n * getArchiveAllStatusFn: (jobId) => getStatus({ jobId }).then((r) => r.data),\n * });\n *\n * archiveAll.mutate();\n * ```\n */\nexport function useArchiveAllNotifications({\n userId,\n category,\n enqueueArchiveAllFn,\n getArchiveAllStatusFn,\n pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,\n maxPolls = DEFAULT_MAX_POLLS,\n queryKeys,\n invalidateKeys,\n}: UseArchiveAllNotificationsOptions) {\n const queryClient = useQueryClient();\n\n return useMutation({\n // Server-owned archive-all: enqueue ONE job scoped to `category`, then poll its status until it\n // reaches a terminal state. No browser loop, no chain of archive transactions. The mutation\n // resolves with an explicit terminal result (never throws for an incomplete drain) — `complete`\n // is the load-bearing flag callers gate post-clear side effects on.\n mutationFn: async (): Promise<ArchiveAllPollResult> => {\n const { jobId } = await enqueueArchiveAllFn(category);\n\n // Poll until terminal or until the guard bound is hit. A job that never terminates resolves\n // `incomplete` (mirrors the old loop's honest \"some remain\" result) rather than hanging. The\n // first poll happens immediately so a fast/already-complete job resolves without a delay.\n let lastArchived = 0;\n for (let poll = 0; poll < maxPolls; poll += 1) {\n const snap = await getArchiveAllStatusFn(jobId);\n lastArchived = snap.archived ?? 0;\n\n if (snap.state === 'complete') {\n return { status: 'complete', category, archived: lastArchived, complete: true };\n }\n if (snap.state === 'incomplete') {\n return { status: 'incomplete', category, archived: lastArchived, complete: false };\n }\n if (snap.state === 'failed') {\n return { status: 'failed', category, archived: lastArchived, complete: false, error: snap.error };\n }\n\n // Still in-progress — wait before the next poll unless this was the last allowed poll.\n if (poll < maxPolls - 1) await sleep(pollIntervalMs);\n }\n\n // Guard: the job never reached a terminal state within the poll budget — report incomplete.\n return { status: 'incomplete', category, archived: lastArchived, complete: false };\n },\n onSuccess: (result) => {\n // Only a completed drain changed the active set; still invalidate on an incomplete drain since\n // SOME cards were archived. A hard enqueue/poll failure rejects the mutation (onError), so this\n // never runs for `failed`.\n const keysToInvalidate = invalidateKeys ?? archiveRefreshKeys(queryKeys, category, userId);\n keysToInvalidate.forEach((key) => {\n queryClient.invalidateQueries({ queryKey: [...key], exact: false });\n });\n void result;\n },\n });\n}\n"]}
@@ -7,5 +7,5 @@ import type { NotificationArchiveResult, NotificationDoc, UseArchiveNotification
7
7
  * row as rendered, resolves the adapter's `{ archived }` answer, and invalidates the
8
8
  * read keys on success.
9
9
  */
10
- export declare function useArchiveNotification({ userId, category, archiveFn, invalidateKeys, }: UseArchiveNotificationOptions): import("@tanstack/react-query").UseMutationResult<NotificationArchiveResult, Error, NotificationDoc, unknown>;
10
+ export declare function useArchiveNotification({ userId, category, archiveFn, queryKeys, invalidateKeys, }: UseArchiveNotificationOptions): import("@tanstack/react-query").UseMutationResult<NotificationArchiveResult, Error, NotificationDoc, unknown>;
11
11
  //# sourceMappingURL=useArchiveNotification.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useArchiveNotification.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,yBAAyB,EACzB,eAAe,EACf,6BAA6B,EAC9B,MAAM,gBAAgB,CAAC;AAExB;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,QAAQ,EACR,SAAS,EACT,cAAc,GACf,EAAE,6BAA6B,iHAkB/B"}
1
+ {"version":3,"file":"useArchiveNotification.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,yBAAyB,EACzB,eAAe,EACf,6BAA6B,EAC9B,MAAM,gBAAgB,CAAC;AAGxB;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,QAAQ,EACR,SAAS,EACT,SAAS,EACT,cAAc,GACf,EAAE,6BAA6B,iHAY/B"}
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import { archiveRefreshKeys } from '../query-keys.js';
3
4
  /**
4
5
  * Archive a single rendered notification through an app-supplied callable adapter.
5
6
  *
@@ -8,17 +9,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
8
9
  * row as rendered, resolves the adapter's `{ archived }` answer, and invalidates the
9
10
  * read keys on success.
10
11
  */
11
- export function useArchiveNotification({ userId, category, archiveFn, invalidateKeys, }) {
12
+ export function useArchiveNotification({ userId, category, archiveFn, queryKeys, invalidateKeys, }) {
12
13
  const queryClient = useQueryClient();
13
- const defaultInvalidateKeys = [
14
- ['notifications', 'active', category, userId],
15
- ['notifications', 'unread-count', category, userId],
16
- ['notifications', 'history', category, userId],
17
- ];
18
14
  return useMutation({
19
15
  mutationFn: (notification) => archiveFn(notification),
20
16
  onSuccess: () => {
21
- const keysToInvalidate = invalidateKeys ?? defaultInvalidateKeys;
17
+ const keysToInvalidate = invalidateKeys ?? archiveRefreshKeys(queryKeys, category, userId);
22
18
  keysToInvalidate.forEach((key) => {
23
19
  queryClient.invalidateQueries({ queryKey: [...key], exact: false });
24
20
  });
@@ -1 +1 @@
1
- {"version":3,"file":"useArchiveNotification.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAOpE;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CAAC,EACrC,MAAM,EACN,QAAQ,EACR,SAAS,EACT,cAAc,GACgB;IAC9B,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IAErC,MAAM,qBAAqB,GAAG;QAC5B,CAAC,eAAe,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,CAAC;QAC7C,CAAC,eAAe,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,CAAC;QACnD,CAAC,eAAe,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC;KAC/C,CAAC;IAEF,OAAO,WAAW,CAAoD;QACpE,UAAU,EAAE,CAAC,YAA6B,EAAE,EAAE,CAAC,SAAS,CAAC,YAAY,CAAC;QACtE,SAAS,EAAE,GAAG,EAAE;YACd,MAAM,gBAAgB,GAAG,cAAc,IAAI,qBAAqB,CAAC;YACjE,gBAAgB,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;gBAC/B,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;YACtE,CAAC,CAAC,CAAC;QACL,CAAC;KACF,CAAC,CAAC;AACL,CAAC","sourcesContent":["'use client';\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type {\n NotificationArchiveResult,\n NotificationDoc,\n UseArchiveNotificationOptions,\n} from '../../types.js';\n\n/**\n * Archive a single rendered notification through an app-supplied callable adapter.\n *\n * The notification system is Cloud-Functions-only: clients never write notification\n * docs. This hook performs no Firestore writes — it delegates to `archiveFn` with the\n * row as rendered, resolves the adapter's `{ archived }` answer, and invalidates the\n * read keys on success.\n */\nexport function useArchiveNotification({\n userId,\n category,\n archiveFn,\n invalidateKeys,\n}: UseArchiveNotificationOptions) {\n const queryClient = useQueryClient();\n\n const defaultInvalidateKeys = [\n ['notifications', 'active', category, userId],\n ['notifications', 'unread-count', category, userId],\n ['notifications', 'history', category, userId],\n ];\n\n return useMutation<NotificationArchiveResult, Error, NotificationDoc>({\n mutationFn: (notification: NotificationDoc) => archiveFn(notification),\n onSuccess: () => {\n const keysToInvalidate = invalidateKeys ?? defaultInvalidateKeys;\n keysToInvalidate.forEach((key) => {\n queryClient.invalidateQueries({ queryKey: [...key], exact: false });\n });\n },\n });\n}\n"]}
1
+ {"version":3,"file":"useArchiveNotification.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAMpE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAEtD;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CAAC,EACrC,MAAM,EACN,QAAQ,EACR,SAAS,EACT,SAAS,EACT,cAAc,GACgB;IAC9B,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IAErC,OAAO,WAAW,CAAoD;QACpE,UAAU,EAAE,CAAC,YAA6B,EAAE,EAAE,CAAC,SAAS,CAAC,YAAY,CAAC;QACtE,SAAS,EAAE,GAAG,EAAE;YACd,MAAM,gBAAgB,GAAG,cAAc,IAAI,kBAAkB,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;YAC3F,gBAAgB,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;gBAC/B,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;YACtE,CAAC,CAAC,CAAC;QACL,CAAC;KACF,CAAC,CAAC;AACL,CAAC","sourcesContent":["'use client';\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type {\n NotificationArchiveResult,\n NotificationDoc,\n UseArchiveNotificationOptions,\n} from '../../types.js';\nimport { archiveRefreshKeys } from '../query-keys.js';\n\n/**\n * Archive a single rendered notification through an app-supplied callable adapter.\n *\n * The notification system is Cloud-Functions-only: clients never write notification\n * docs. This hook performs no Firestore writes — it delegates to `archiveFn` with the\n * row as rendered, resolves the adapter's `{ archived }` answer, and invalidates the\n * read keys on success.\n */\nexport function useArchiveNotification({\n userId,\n category,\n archiveFn,\n queryKeys,\n invalidateKeys,\n}: UseArchiveNotificationOptions) {\n const queryClient = useQueryClient();\n\n return useMutation<NotificationArchiveResult, Error, NotificationDoc>({\n mutationFn: (notification: NotificationDoc) => archiveFn(notification),\n onSuccess: () => {\n const keysToInvalidate = invalidateKeys ?? archiveRefreshKeys(queryKeys, category, userId);\n keysToInvalidate.forEach((key) => {\n queryClient.invalidateQueries({ queryKey: [...key], exact: false });\n });\n },\n });\n}\n"]}
@@ -13,5 +13,5 @@ import type { NotificationHistoryItem, UseNotificationHistoryOptions } from '../
13
13
  * list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore
14
14
  * rules — this hook performs no writes.
15
15
  */
16
- export declare function useNotificationHistory({ config, userId, category, enabled, pageSize, staleTime, }: UseNotificationHistoryOptions): import("@ttt-productions/query-core/react").UseFirestorePaginatedResult<NotificationHistoryItem>;
16
+ export declare function useNotificationHistory({ config, userId, category, enabled, pageSize, staleTime, queryKeys, }: UseNotificationHistoryOptions): import("@ttt-productions/query-core/react").UseFirestorePaginatedResult<NotificationHistoryItem>;
17
17
  //# sourceMappingURL=useNotificationHistory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useNotificationHistory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useNotificationHistory.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAEV,uBAAuB,EACvB,6BAA6B,EAC9B,MAAM,gBAAgB,CAAC;AAKxB;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAc,EACd,QAA4B,EAC5B,SAA8B,GAC/B,EAAE,6BAA6B,oGA4B/B"}
1
+ {"version":3,"file":"useNotificationHistory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useNotificationHistory.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAEV,uBAAuB,EACvB,6BAA6B,EAC9B,MAAM,gBAAgB,CAAC;AAMxB;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAc,EACd,QAA4B,EAC5B,SAA8B,EAC9B,SAAS,GACV,EAAE,6BAA6B,oGA4B/B"}
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { useFirestorePaginated } from '@ttt-productions/query-core/react';
3
3
  import { orderBy } from 'firebase/firestore';
4
+ import { resolveNotificationQueryKeys } from '../query-keys.js';
4
5
  const DEFAULT_PAGE_SIZE = 20;
5
6
  const DEFAULT_STALE_TIME = 60_000;
6
7
  /**
@@ -17,7 +18,7 @@ const DEFAULT_STALE_TIME = 60_000;
17
18
  * list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore
18
19
  * rules — this hook performs no writes.
19
20
  */
20
- export function useNotificationHistory({ config, userId, category, enabled = true, pageSize = DEFAULT_PAGE_SIZE, staleTime = DEFAULT_STALE_TIME, }) {
21
+ export function useNotificationHistory({ config, userId, category, enabled = true, pageSize = DEFAULT_PAGE_SIZE, staleTime = DEFAULT_STALE_TIME, queryKeys, }) {
21
22
  const categoryConfig = config.categories[category];
22
23
  if (!categoryConfig) {
23
24
  throw new Error(`[notification-core] Unknown category: ${category}`);
@@ -26,7 +27,7 @@ export function useNotificationHistory({ config, userId, category, enabled = tru
26
27
  const constraints = [orderBy('archivedAt', 'desc')];
27
28
  return useFirestorePaginated({
28
29
  collectionPath,
29
- queryKey: ['notifications', 'history', category, userId, { pageSize }],
30
+ queryKey: [...resolveNotificationQueryKeys(queryKeys).history(category, userId), { pageSize }],
30
31
  constraints,
31
32
  pageSize,
32
33
  enabled: enabled && !!userId,
@@ -1 +1 @@
1
- {"version":3,"file":"useNotificationHistory.js","sourceRoot":"","sources":["../../../src/react/hooks/useNotificationHistory.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,EAAE,OAAO,EAAwB,MAAM,oBAAoB,CAAC;AAOnE,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAC7B,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAElC;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAO,GAAG,IAAI,EACd,QAAQ,GAAG,iBAAiB,EAC5B,SAAS,GAAG,kBAAkB,GACA;IAC9B,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,yCAAyC,QAAQ,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;IAE1D,MAAM,WAAW,GAAsB,CAAC,OAAO,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;IAEvE,OAAO,qBAAqB,CAA0B;QACpD,cAAc;QACd,QAAQ,EAAE,CAAC,eAAe,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAC;QACtE,WAAW;QACX,QAAQ;QACR,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC,MAAM;QAC5B,SAAS;QACT,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACf,MAAM,QAAQ,GAAG,CAAC,IAAI,CAAC,gBAAgB,IAAI,EAAE,CAAoB,CAAC;YAClE,OAAO;gBACL,GAAG,QAAQ;gBACX,kEAAkE;gBAClE,mCAAmC;gBACnC,mBAAmB,EAAE,IAAI,CAAC,EAAE;gBAC5B,UAAU,EAAG,IAAI,CAAC,UAAiC,IAAI,QAAQ,CAAC,SAAS,IAAI,CAAC;aAC/E,CAAC;QACJ,CAAC;KACF,CAAC,CAAC;AACL,CAAC","sourcesContent":["'use client';\n\nimport { useFirestorePaginated } from '@ttt-productions/query-core/react';\nimport { orderBy, type QueryConstraint } from 'firebase/firestore';\nimport type {\n NotificationDoc,\n NotificationHistoryItem,\n UseNotificationHistoryOptions,\n} from '../../types.js';\n\nconst DEFAULT_PAGE_SIZE = 20;\nconst DEFAULT_STALE_TIME = 60_000;\n\n/**\n * Paginated read of ARCHIVED notification history (the two-tier system's history\n * tier). Personal history lives under a per-user path\n * (`userProfiles/{uid}/notificationHistory`) and shared/admin history under a\n * top-level collection (`adminNotificationHistory`) — both resolved from the\n * category's `historyPath`, so no `where` clause is needed either way. Rows are\n * immutable and ordered newest-first by `archivedAt`.\n *\n * Each history doc is a wrapper (`archivedSnapshot` + archive metadata + a\n * native-TTL `expireAt`); the `select` mapper flattens it into a\n * {@link NotificationHistoryItem} so the read surface renders like the active\n * list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore\n * rules — this hook performs no writes.\n */\nexport function useNotificationHistory({\n config,\n userId,\n category,\n enabled = true,\n pageSize = DEFAULT_PAGE_SIZE,\n staleTime = DEFAULT_STALE_TIME,\n}: UseNotificationHistoryOptions) {\n const categoryConfig = config.categories[category];\n if (!categoryConfig) {\n throw new Error(`[notification-core] Unknown category: ${category}`);\n }\n\n const collectionPath = categoryConfig.historyPath(userId);\n\n const constraints: QueryConstraint[] = [orderBy('archivedAt', 'desc')];\n\n return useFirestorePaginated<NotificationHistoryItem>({\n collectionPath,\n queryKey: ['notifications', 'history', category, userId, { pageSize }],\n constraints,\n pageSize,\n enabled: enabled && !!userId,\n staleTime,\n select: (data) => {\n const snapshot = (data.archivedSnapshot ?? {}) as NotificationDoc;\n return {\n ...snapshot,\n // The archive occurrence id is the stable history-doc key; expose\n // archivedAt for ordering/display.\n archiveOccurrenceId: data.id,\n archivedAt: (data.archivedAt as number | undefined) ?? snapshot.updatedAt ?? 0,\n };\n },\n });\n}\n"]}
1
+ {"version":3,"file":"useNotificationHistory.js","sourceRoot":"","sources":["../../../src/react/hooks/useNotificationHistory.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,EAAE,OAAO,EAAwB,MAAM,oBAAoB,CAAC;AAMnE,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAEhE,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAC7B,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAElC;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAO,GAAG,IAAI,EACd,QAAQ,GAAG,iBAAiB,EAC5B,SAAS,GAAG,kBAAkB,EAC9B,SAAS,GACqB;IAC9B,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,yCAAyC,QAAQ,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;IAE1D,MAAM,WAAW,GAAsB,CAAC,OAAO,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;IAEvE,OAAO,qBAAqB,CAA0B;QACpD,cAAc;QACd,QAAQ,EAAE,CAAC,GAAG,4BAA4B,CAAC,SAAS,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,QAAQ,EAAE,CAAC;QAC9F,WAAW;QACX,QAAQ;QACR,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC,MAAM;QAC5B,SAAS;QACT,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACf,MAAM,QAAQ,GAAG,CAAC,IAAI,CAAC,gBAAgB,IAAI,EAAE,CAAoB,CAAC;YAClE,OAAO;gBACL,GAAG,QAAQ;gBACX,kEAAkE;gBAClE,mCAAmC;gBACnC,mBAAmB,EAAE,IAAI,CAAC,EAAE;gBAC5B,UAAU,EAAG,IAAI,CAAC,UAAiC,IAAI,QAAQ,CAAC,SAAS,IAAI,CAAC;aAC/E,CAAC;QACJ,CAAC;KACF,CAAC,CAAC;AACL,CAAC","sourcesContent":["'use client';\n\nimport { useFirestorePaginated } from '@ttt-productions/query-core/react';\nimport { orderBy, type QueryConstraint } from 'firebase/firestore';\nimport type {\n NotificationDoc,\n NotificationHistoryItem,\n UseNotificationHistoryOptions,\n} from '../../types.js';\nimport { resolveNotificationQueryKeys } from '../query-keys.js';\n\nconst DEFAULT_PAGE_SIZE = 20;\nconst DEFAULT_STALE_TIME = 60_000;\n\n/**\n * Paginated read of ARCHIVED notification history (the two-tier system's history\n * tier). Personal history lives under a per-user path\n * (`userProfiles/{uid}/notificationHistory`) and shared/admin history under a\n * top-level collection (`adminNotificationHistory`) — both resolved from the\n * category's `historyPath`, so no `where` clause is needed either way. Rows are\n * immutable and ordered newest-first by `archivedAt`.\n *\n * Each history doc is a wrapper (`archivedSnapshot` + archive metadata + a\n * native-TTL `expireAt`); the `select` mapper flattens it into a\n * {@link NotificationHistoryItem} so the read surface renders like the active\n * list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore\n * rules — this hook performs no writes.\n */\nexport function useNotificationHistory({\n config,\n userId,\n category,\n enabled = true,\n pageSize = DEFAULT_PAGE_SIZE,\n staleTime = DEFAULT_STALE_TIME,\n queryKeys,\n}: UseNotificationHistoryOptions) {\n const categoryConfig = config.categories[category];\n if (!categoryConfig) {\n throw new Error(`[notification-core] Unknown category: ${category}`);\n }\n\n const collectionPath = categoryConfig.historyPath(userId);\n\n const constraints: QueryConstraint[] = [orderBy('archivedAt', 'desc')];\n\n return useFirestorePaginated<NotificationHistoryItem>({\n collectionPath,\n queryKey: [...resolveNotificationQueryKeys(queryKeys).history(category, userId), { pageSize }],\n constraints,\n pageSize,\n enabled: enabled && !!userId,\n staleTime,\n select: (data) => {\n const snapshot = (data.archivedSnapshot ?? {}) as NotificationDoc;\n return {\n ...snapshot,\n // The archive occurrence id is the stable history-doc key; expose\n // archivedAt for ordering/display.\n archiveOccurrenceId: data.id,\n archivedAt: (data.archivedAt as number | undefined) ?? snapshot.updatedAt ?? 0,\n };\n },\n });\n}\n"]}
@@ -6,7 +6,9 @@ import type { UseUnreadCountOptions } from '../../types.js';
6
6
  *
7
7
  * - **personal** categories count **unseen** active items (`seenAt == 0`),
8
8
  * scoped to the caller (`targetUserId == uid`);
9
- * - **shared** categories have no per-admin seen state, so the indicator is
9
+ * - **shared** categories that declare `sharedSeenState` count the items no
10
+ * member has seen (`seenAt == 0`) — the members share one seen state;
11
+ * - other **shared** categories have no seen state, so the indicator is
10
12
  * existence-based — a count of all active items, with no `seenAt` predicate.
11
13
  *
12
14
  * The personal `seenAt == 0` predicate needs a composite index, captured as an
@@ -21,7 +23,7 @@ import type { UseUnreadCountOptions } from '../../types.js';
21
23
  * });
22
24
  * ```
23
25
  */
24
- export declare function useUnreadCount({ config, userId, category, enabled, refetchInterval, countLimit, }: UseUnreadCountOptions): {
26
+ export declare function useUnreadCount({ config, userId, category, enabled, refetchInterval, countLimit, queryKeys, }: UseUnreadCountOptions): {
25
27
  error: Error;
26
28
  isError: true;
27
29
  isPending: false;
@@ -48,6 +50,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
48
50
  fetchStatus: import("@tanstack/query-core").FetchStatus;
49
51
  promise: Promise<number>;
50
52
  count: number;
53
+ hasAnswer: boolean;
51
54
  hasMore: boolean;
52
55
  } | {
53
56
  error: null;
@@ -76,6 +79,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
76
79
  fetchStatus: import("@tanstack/query-core").FetchStatus;
77
80
  promise: Promise<number>;
78
81
  count: number;
82
+ hasAnswer: boolean;
79
83
  hasMore: boolean;
80
84
  } | {
81
85
  error: Error;
@@ -104,6 +108,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
104
108
  fetchStatus: import("@tanstack/query-core").FetchStatus;
105
109
  promise: Promise<number>;
106
110
  count: number;
111
+ hasAnswer: boolean;
107
112
  hasMore: boolean;
108
113
  } | {
109
114
  error: null;
@@ -132,6 +137,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
132
137
  fetchStatus: import("@tanstack/query-core").FetchStatus;
133
138
  promise: Promise<number>;
134
139
  count: number;
140
+ hasAnswer: boolean;
135
141
  hasMore: boolean;
136
142
  } | {
137
143
  error: null;
@@ -160,6 +166,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
160
166
  fetchStatus: import("@tanstack/query-core").FetchStatus;
161
167
  promise: Promise<number>;
162
168
  count: number;
169
+ hasAnswer: boolean;
163
170
  hasMore: boolean;
164
171
  } | {
165
172
  isError: false;
@@ -188,6 +195,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
188
195
  fetchStatus: import("@tanstack/query-core").FetchStatus;
189
196
  promise: Promise<number>;
190
197
  count: number;
198
+ hasAnswer: boolean;
191
199
  hasMore: boolean;
192
200
  };
193
201
  //# sourceMappingURL=useUnreadCount.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useUnreadCount.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAK5D;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAc,EACd,eAA0C,EAC1C,UAAgC,GACjC,EAAE,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8BvB"}
1
+ {"version":3,"file":"useUnreadCount.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAM5D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAc,EACd,eAA0C,EAC1C,UAAgC,EAChC,SAAS,GACV,EAAE,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoCvB"}
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { where } from 'firebase/firestore';
3
3
  import { useFirestoreCount } from '@ttt-productions/query-core/react';
4
+ import { resolveNotificationQueryKeys } from '../query-keys.js';
4
5
  const DEFAULT_REFETCH_INTERVAL = 30_000;
5
6
  const DEFAULT_COUNT_LIMIT = 99;
6
7
  /**
@@ -10,7 +11,9 @@ const DEFAULT_COUNT_LIMIT = 99;
10
11
  *
11
12
  * - **personal** categories count **unseen** active items (`seenAt == 0`),
12
13
  * scoped to the caller (`targetUserId == uid`);
13
- * - **shared** categories have no per-admin seen state, so the indicator is
14
+ * - **shared** categories that declare `sharedSeenState` count the items no
15
+ * member has seen (`seenAt == 0`) — the members share one seen state;
16
+ * - other **shared** categories have no seen state, so the indicator is
14
17
  * existence-based — a count of all active items, with no `seenAt` predicate.
15
18
  *
16
19
  * The personal `seenAt == 0` predicate needs a composite index, captured as an
@@ -25,27 +28,34 @@ const DEFAULT_COUNT_LIMIT = 99;
25
28
  * });
26
29
  * ```
27
30
  */
28
- export function useUnreadCount({ config, userId, category, enabled = true, refetchInterval = DEFAULT_REFETCH_INTERVAL, countLimit = DEFAULT_COUNT_LIMIT, }) {
31
+ export function useUnreadCount({ config, userId, category, enabled = true, refetchInterval = DEFAULT_REFETCH_INTERVAL, countLimit = DEFAULT_COUNT_LIMIT, queryKeys, }) {
29
32
  const categoryConfig = config.categories[category];
30
33
  if (!categoryConfig) {
31
34
  throw new Error(`[notification-core] Unknown category: ${category}`);
32
35
  }
33
36
  const collectionPath = categoryConfig.activePath;
34
37
  const isPersonal = categoryConfig.audienceType === 'personal';
35
- const constraints = isPersonal && userId
36
- ? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]
37
- : [];
38
+ const constraints = isPersonal
39
+ ? userId
40
+ ? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]
41
+ : []
42
+ : categoryConfig.sharedSeenState
43
+ ? [where('seenAt', '==', 0)]
44
+ : [];
38
45
  const { data, ...rest } = useFirestoreCount({
39
46
  collectionPath,
40
- queryKey: ['notifications', 'unread-count', category, userId],
47
+ queryKey: resolveNotificationQueryKeys(queryKeys).unreadCount(category, userId),
41
48
  constraints,
42
49
  enabled: enabled && !!userId,
43
50
  staleTime: refetchInterval,
44
51
  refetchInterval,
45
52
  });
53
+ // `count` reads 0 until the count has answered; `hasAnswer` says whether it has, so a consumer
54
+ // never shows that 0 as the answer (FRONTEND-206). A failed refresh keeps the last answer.
46
55
  const count = data ?? 0;
47
56
  return {
48
57
  count,
58
+ hasAnswer: data !== undefined,
49
59
  hasMore: count > countLimit,
50
60
  ...rest,
51
61
  };
@@ -1 +1 @@
1
- {"version":3,"file":"useUnreadCount.js","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,KAAK,EAAwB,MAAM,oBAAoB,CAAC;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAGtE,MAAM,wBAAwB,GAAG,MAAM,CAAC;AACxC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAO,GAAG,IAAI,EACd,eAAe,GAAG,wBAAwB,EAC1C,UAAU,GAAG,mBAAmB,GACV;IACtB,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,yCAAyC,QAAQ,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,UAAU,CAAC;IACjD,MAAM,UAAU,GAAG,cAAc,CAAC,YAAY,KAAK,UAAU,CAAC;IAE9D,MAAM,WAAW,GACf,UAAU,IAAI,MAAM;QAClB,CAAC,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;QACjE,CAAC,CAAC,EAAE,CAAC;IAET,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,iBAAiB,CAAC;QAC1C,cAAc;QACd,QAAQ,EAAE,CAAC,eAAe,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,CAAC;QAC7D,WAAW;QACX,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC,MAAM;QAC5B,SAAS,EAAE,eAAe;QAC1B,eAAe;KAChB,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,CAAC;IAExB,OAAO;QACL,KAAK;QACL,OAAO,EAAE,KAAK,GAAG,UAAU;QAC3B,GAAG,IAAI;KACR,CAAC;AACJ,CAAC","sourcesContent":["'use client';\n\nimport { where, type QueryConstraint } from 'firebase/firestore';\nimport { useFirestoreCount } from '@ttt-productions/query-core/react';\nimport type { UseUnreadCountOptions } from '../../types.js';\n\nconst DEFAULT_REFETCH_INTERVAL = 30_000;\nconst DEFAULT_COUNT_LIMIT = 99;\n\n/**\n * Unread-count badge backed by a Firestore `count()` aggregation (a server-side\n * count, not a doc fetch), via query-core's `useFirestoreCount`. Polls for cost\n * control; displays capped (`hasMore` → `99+`).\n *\n * - **personal** categories count **unseen** active items (`seenAt == 0`),\n * scoped to the caller (`targetUserId == uid`);\n * - **shared** categories have no per-admin seen state, so the indicator is\n * existence-based — a count of all active items, with no `seenAt` predicate.\n *\n * The personal `seenAt == 0` predicate needs a composite index, captured as an\n * app-side index step.\n *\n * @example\n * ```tsx\n * const { count, isLoading } = useUnreadCount({\n * config: TTT_NOTIFICATION_CONFIG,\n * userId: user.uid,\n * category: 'user',\n * });\n * ```\n */\nexport function useUnreadCount({\n config,\n userId,\n category,\n enabled = true,\n refetchInterval = DEFAULT_REFETCH_INTERVAL,\n countLimit = DEFAULT_COUNT_LIMIT,\n}: UseUnreadCountOptions) {\n const categoryConfig = config.categories[category];\n if (!categoryConfig) {\n throw new Error(`[notification-core] Unknown category: ${category}`);\n }\n\n const collectionPath = categoryConfig.activePath;\n const isPersonal = categoryConfig.audienceType === 'personal';\n\n const constraints: QueryConstraint[] =\n isPersonal && userId\n ? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]\n : [];\n\n const { data, ...rest } = useFirestoreCount({\n collectionPath,\n queryKey: ['notifications', 'unread-count', category, userId],\n constraints,\n enabled: enabled && !!userId,\n staleTime: refetchInterval,\n refetchInterval,\n });\n\n const count = data ?? 0;\n\n return {\n count,\n hasMore: count > countLimit,\n ...rest,\n };\n}\n"]}
1
+ {"version":3,"file":"useUnreadCount.js","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,KAAK,EAAwB,MAAM,oBAAoB,CAAC;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAEtE,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAEhE,MAAM,wBAAwB,GAAG,MAAM,CAAC;AACxC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAO,GAAG,IAAI,EACd,eAAe,GAAG,wBAAwB,EAC1C,UAAU,GAAG,mBAAmB,EAChC,SAAS,GACa;IACtB,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,yCAAyC,QAAQ,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,UAAU,CAAC;IACjD,MAAM,UAAU,GAAG,cAAc,CAAC,YAAY,KAAK,UAAU,CAAC;IAE9D,MAAM,WAAW,GAAsB,UAAU;QAC/C,CAAC,CAAC,MAAM;YACN,CAAC,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;YACjE,CAAC,CAAC,EAAE;QACN,CAAC,CAAC,cAAc,CAAC,eAAe;YAC9B,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;YAC5B,CAAC,CAAC,EAAE,CAAC;IAET,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,iBAAiB,CAAC;QAC1C,cAAc;QACd,QAAQ,EAAE,4BAA4B,CAAC,SAAS,CAAC,CAAC,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC/E,WAAW;QACX,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC,MAAM;QAC5B,SAAS,EAAE,eAAe;QAC1B,eAAe;KAChB,CAAC,CAAC;IAEH,+FAA+F;IAC/F,2FAA2F;IAC3F,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,CAAC;IAExB,OAAO;QACL,KAAK;QACL,SAAS,EAAE,IAAI,KAAK,SAAS;QAC7B,OAAO,EAAE,KAAK,GAAG,UAAU;QAC3B,GAAG,IAAI;KACR,CAAC;AACJ,CAAC","sourcesContent":["'use client';\n\nimport { where, type QueryConstraint } from 'firebase/firestore';\nimport { useFirestoreCount } from '@ttt-productions/query-core/react';\nimport type { UseUnreadCountOptions } from '../../types.js';\nimport { resolveNotificationQueryKeys } from '../query-keys.js';\n\nconst DEFAULT_REFETCH_INTERVAL = 30_000;\nconst DEFAULT_COUNT_LIMIT = 99;\n\n/**\n * Unread-count badge backed by a Firestore `count()` aggregation (a server-side\n * count, not a doc fetch), via query-core's `useFirestoreCount`. Polls for cost\n * control; displays capped (`hasMore` → `99+`).\n *\n * - **personal** categories count **unseen** active items (`seenAt == 0`),\n * scoped to the caller (`targetUserId == uid`);\n * - **shared** categories that declare `sharedSeenState` count the items no\n * member has seen (`seenAt == 0`) — the members share one seen state;\n * - other **shared** categories have no seen state, so the indicator is\n * existence-based — a count of all active items, with no `seenAt` predicate.\n *\n * The personal `seenAt == 0` predicate needs a composite index, captured as an\n * app-side index step.\n *\n * @example\n * ```tsx\n * const { count, isLoading } = useUnreadCount({\n * config: TTT_NOTIFICATION_CONFIG,\n * userId: user.uid,\n * category: 'user',\n * });\n * ```\n */\nexport function useUnreadCount({\n config,\n userId,\n category,\n enabled = true,\n refetchInterval = DEFAULT_REFETCH_INTERVAL,\n countLimit = DEFAULT_COUNT_LIMIT,\n queryKeys,\n}: UseUnreadCountOptions) {\n const categoryConfig = config.categories[category];\n if (!categoryConfig) {\n throw new Error(`[notification-core] Unknown category: ${category}`);\n }\n\n const collectionPath = categoryConfig.activePath;\n const isPersonal = categoryConfig.audienceType === 'personal';\n\n const constraints: QueryConstraint[] = isPersonal\n ? userId\n ? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]\n : []\n : categoryConfig.sharedSeenState\n ? [where('seenAt', '==', 0)]\n : [];\n\n const { data, ...rest } = useFirestoreCount({\n collectionPath,\n queryKey: resolveNotificationQueryKeys(queryKeys).unreadCount(category, userId),\n constraints,\n enabled: enabled && !!userId,\n staleTime: refetchInterval,\n refetchInterval,\n });\n\n // `count` reads 0 until the count has answered; `hasAnswer` says whether it has, so a consumer\n // never shows that 0 as the answer (FRONTEND-206). A failed refresh keeps the last answer.\n const count = data ?? 0;\n\n return {\n count,\n hasAnswer: data !== undefined,\n hasMore: count > countLimit,\n ...rest,\n };\n}\n"]}
@@ -0,0 +1,5 @@
1
+ import type { NotificationQueryKeys } from '../types.js';
2
+ export declare function resolveNotificationQueryKeys(queryKeys: NotificationQueryKeys | undefined): NotificationQueryKeys;
3
+ /** The three keys an archive or an archive-all refreshes for one category and viewer. */
4
+ export declare function archiveRefreshKeys(queryKeys: NotificationQueryKeys | undefined, category: string, userId: string): readonly (readonly unknown[])[];
5
+ //# sourceMappingURL=query-keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query-keys.d.ts","sourceRoot":"","sources":["../../src/react/query-keys.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AASzD,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,qBAAqB,GAAG,SAAS,GAAG,qBAAqB,CAEhH;AAED,yFAAyF;AACzF,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,qBAAqB,GAAG,SAAS,EAC5C,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,SAAS,CAAC,SAAS,OAAO,EAAE,CAAC,EAAE,CAGjC"}
@@ -0,0 +1,15 @@
1
+ /** The keys the hooks use when the app supplies none. */
2
+ const DEFAULT_NOTIFICATION_QUERY_KEYS = {
3
+ active: (category, userId) => ['notifications', 'active', category, userId],
4
+ history: (category, userId) => ['notifications', 'history', category, userId],
5
+ unreadCount: (category, userId) => ['notifications', 'unread-count', category, userId],
6
+ };
7
+ export function resolveNotificationQueryKeys(queryKeys) {
8
+ return queryKeys ?? DEFAULT_NOTIFICATION_QUERY_KEYS;
9
+ }
10
+ /** The three keys an archive or an archive-all refreshes for one category and viewer. */
11
+ export function archiveRefreshKeys(queryKeys, category, userId) {
12
+ const keys = resolveNotificationQueryKeys(queryKeys);
13
+ return [keys.active(category, userId), keys.unreadCount(category, userId), keys.history(category, userId)];
14
+ }
15
+ //# sourceMappingURL=query-keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query-keys.js","sourceRoot":"","sources":["../../src/react/query-keys.ts"],"names":[],"mappings":"AAEA,yDAAyD;AACzD,MAAM,+BAA+B,GAA0B;IAC7D,MAAM,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,eAAe,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,CAAC;IAC3E,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,eAAe,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC;IAC7E,WAAW,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,eAAe,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,CAAC;CACvF,CAAC;AAEF,MAAM,UAAU,4BAA4B,CAAC,SAA4C;IACvF,OAAO,SAAS,IAAI,+BAA+B,CAAC;AACtD,CAAC;AAED,yFAAyF;AACzF,MAAM,UAAU,kBAAkB,CAChC,SAA4C,EAC5C,QAAgB,EAChB,MAAc;IAEd,MAAM,IAAI,GAAG,4BAA4B,CAAC,SAAS,CAAC,CAAC;IACrD,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;AAC7G,CAAC","sourcesContent":["import type { NotificationQueryKeys } from '../types.js';\n\n/** The keys the hooks use when the app supplies none. */\nconst DEFAULT_NOTIFICATION_QUERY_KEYS: NotificationQueryKeys = {\n active: (category, userId) => ['notifications', 'active', category, userId],\n history: (category, userId) => ['notifications', 'history', category, userId],\n unreadCount: (category, userId) => ['notifications', 'unread-count', category, userId],\n};\n\nexport function resolveNotificationQueryKeys(queryKeys: NotificationQueryKeys | undefined): NotificationQueryKeys {\n return queryKeys ?? DEFAULT_NOTIFICATION_QUERY_KEYS;\n}\n\n/** The three keys an archive or an archive-all refreshes for one category and viewer. */\nexport function archiveRefreshKeys(\n queryKeys: NotificationQueryKeys | undefined,\n category: string,\n userId: string,\n): readonly (readonly unknown[])[] {\n const keys = resolveNotificationQueryKeys(queryKeys);\n return [keys.active(category, userId), keys.unreadCount(category, userId), keys.history(category, userId)];\n}\n"]}
@@ -87,16 +87,45 @@ export interface EnqueueResult {
87
87
  allResolved: boolean;
88
88
  }
89
89
  export type MaterializeOutcome = 'materialized' | 'already-materialized' | 'skipped-ineligible-recipient' | 'missing' | 'skipped-non-queued';
90
+ export interface MaterializeManyOptions {
91
+ /** Rows materialized at once (default 10). */
92
+ concurrency?: number;
93
+ /**
94
+ * Called for a row whose materialize threw and whose failure could not then be recorded either —
95
+ * `error` is the recording's own failure and `details.cause` the materialize failure it was
96
+ * recording. The row stays as it was (queued, attempt count
97
+ * unchanged), so a later run selects it again; the rest of the batch proceeds. The app's capture
98
+ * goes here; without it the ledger writes the failure to `console.error`.
99
+ */
100
+ onUnrecordedFailure?: (deliveryId: string, error: unknown, details: {
101
+ cause: unknown;
102
+ }) => void;
103
+ }
104
+ /** What `replay` did: reset a dead letter to `queued`, or found nothing to replay. */
105
+ export type ReplayOutcome = 'replayed' | 'missing' | 'not-dead-lettered';
106
+ export interface ReplayOptions {
107
+ /**
108
+ * Composes the caller's own writes — its audit event — into the replay's transaction, so they
109
+ * commit with the reset or not at all (BACKEND-202). Called only when the row is reset, after
110
+ * every read and after the reset write, with the row as the transaction read it (before the
111
+ * reset). It writes through `transaction` only and reads nothing. Awaited, so an async audit
112
+ * writer composes; a throw or rejection aborts the transaction and nothing commits.
113
+ */
114
+ auditWrite?: (transaction: ServerTransaction, row: Readonly<Record<string, unknown>>) => void | Promise<void>;
115
+ }
90
116
  export interface DeliveryLedger {
91
117
  enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;
92
118
  materialize(deliveryId: string): Promise<MaterializeOutcome>;
93
- /** Materialize many rows with bounded concurrency; transient throws record a retry/dead-letter outcome. */
94
- materializeMany(deliveryIds: string[], options?: {
95
- concurrency?: number;
96
- }): Promise<Record<MaterializeOutcome, number>>;
119
+ /**
120
+ * Materialize many rows with bounded concurrency. A row whose materialize throws has the failure
121
+ * recorded (a retry or a dead letter); each row is isolated, so one row's failure — even one
122
+ * whose recording also fails — never fails the batch or stops the rows behind it.
123
+ */
124
+ materializeMany(deliveryIds: string[], options?: MaterializeManyOptions): Promise<Record<MaterializeOutcome, number>>;
97
125
  recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;
98
126
  deadLetter(deliveryId: string, error: unknown): Promise<void>;
99
- replay(deliveryId: string): Promise<void>;
127
+ /** Resets a dead letter to `queued` with fresh attempts; any other row is left untouched. */
128
+ replay(deliveryId: string, options?: ReplayOptions): Promise<ReplayOutcome>;
100
129
  }
101
130
  /**
102
131
  * Apply the aggregation strategy to produce the active-doc field delta.
@@ -1 +1 @@
1
- {"version":3,"file":"delivery-ledger.d.ts","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"delivery-ledger.d.ts","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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"}
@@ -295,15 +295,15 @@ export function createDeliveryLedger(db, config, options) {
295
295
  });
296
296
  });
297
297
  }
298
- async function replay(deliveryId) {
299
- await db.runTransaction(async (tx) => {
298
+ async function replay(deliveryId, replayOptions) {
299
+ return db.runTransaction(async (tx) => {
300
300
  const dRef = deliveryRef(deliveryId);
301
301
  const dSnap = await tx.get(dRef);
302
302
  if (!dSnap.exists)
303
- return;
303
+ return 'missing';
304
304
  const d = (dSnap.data() ?? {});
305
305
  if (d.state !== 'deadLetter')
306
- return;
306
+ return 'not-dead-lettered';
307
307
  tx.update(dRef, {
308
308
  state: 'queued',
309
309
  attemptCount: 0,
@@ -312,8 +312,22 @@ export function createDeliveryLedger(db, config, options) {
312
312
  deadLetteredAt: null,
313
313
  expireAt: null, // clear any TTL set on a prior terminal
314
314
  });
315
+ await replayOptions?.auditWrite?.(tx, d);
316
+ return 'replayed';
315
317
  });
316
318
  }
319
+ function reportUnrecordedFailure(options, deliveryId, error, cause) {
320
+ try {
321
+ if (options?.onUnrecordedFailure) {
322
+ options.onUnrecordedFailure(deliveryId, error, { cause });
323
+ return;
324
+ }
325
+ }
326
+ catch (handlerError) {
327
+ console.error('[notification-core] onUnrecordedFailure threw for delivery', deliveryId, handlerError);
328
+ }
329
+ console.error('[notification-core] A delivery failure could not be recorded', deliveryId, error, { cause });
330
+ }
317
331
  async function materializeMany(deliveryIds, options) {
318
332
  const concurrency = Math.max(1, options?.concurrency ?? 10);
319
333
  const tally = {
@@ -332,7 +346,12 @@ export function createDeliveryLedger(db, config, options) {
332
346
  tally[outcome] += 1;
333
347
  }
334
348
  catch (error) {
335
- await recordTransientFailure(id, error);
349
+ try {
350
+ await recordTransientFailure(id, error);
351
+ }
352
+ catch (recordError) {
353
+ reportUnrecordedFailure(options, id, recordError, error);
354
+ }
336
355
  }
337
356
  }
338
357
  }