@ttt-productions/notification-core 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/dist/react/components/NotificationList.d.ts +1 -1
- package/dist/react/components/NotificationList.d.ts.map +1 -1
- package/dist/react/components/NotificationList.js +7 -20
- package/dist/react/components/NotificationList.js.map +1 -1
- package/dist/react/hooks/useArchiveAllNotifications.d.ts +10 -12
- package/dist/react/hooks/useArchiveAllNotifications.d.ts.map +1 -1
- package/dist/react/hooks/useArchiveAllNotifications.js +10 -58
- package/dist/react/hooks/useArchiveAllNotifications.js.map +1 -1
- package/dist/react/hooks/useArchiveNotification.d.ts +12 -11
- package/dist/react/hooks/useArchiveNotification.d.ts.map +1 -1
- package/dist/react/hooks/useArchiveNotification.js +12 -39
- package/dist/react/hooks/useArchiveNotification.js.map +1 -1
- package/dist/react/hooks/useUnreadCount.d.ts +30 -21
- package/dist/react/hooks/useUnreadCount.d.ts.map +1 -1
- package/dist/react/hooks/useUnreadCount.js +29 -15
- package/dist/react/hooks/useUnreadCount.js.map +1 -1
- package/dist/server/archiveNotificationHelper.d.ts +14 -3
- package/dist/server/archiveNotificationHelper.d.ts.map +1 -1
- package/dist/server/archiveNotificationHelper.js +36 -7
- package/dist/server/archiveNotificationHelper.js.map +1 -1
- package/dist/server/createNotificationHelper.d.ts.map +1 -1
- package/dist/server/createNotificationHelper.js +41 -9
- package/dist/server/createNotificationHelper.js.map +1 -1
- package/dist/server/errors.d.ts +13 -0
- package/dist/server/errors.d.ts.map +1 -0
- package/dist/server/errors.js +19 -0
- package/dist/server/errors.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/markSeenHelper.d.ts +17 -0
- package/dist/server/markSeenHelper.d.ts.map +1 -0
- package/dist/server/markSeenHelper.js +29 -0
- package/dist/server/markSeenHelper.js.map +1 -0
- package/dist/server/processBatchHelper.d.ts.map +1 -1
- package/dist/server/processBatchHelper.js +23 -6
- package/dist/server/processBatchHelper.js.map +1 -1
- package/dist/server/types.d.ts +6 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/types.d.ts +39 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,10 +13,27 @@ Backend/Functions code should avoid the `/react` subpath.
|
|
|
13
13
|
|
|
14
14
|
## Model
|
|
15
15
|
|
|
16
|
-
There is **no `isRead` flag
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
A two-tier **active → history** model. There is **no `isRead` flag**; instead,
|
|
17
|
+
active docs carry a `seenAt` field (`0` = unseen). Opening the tray marks items
|
|
18
|
+
seen (the consuming app stamps `seenAt` via the `markSeenHelper`), which clears
|
|
19
|
+
the unread badge **without** archiving — so "seen" and "dismissed" are distinct
|
|
20
|
+
states. Archiving is the explicit dismiss: it moves a notification from the
|
|
21
|
+
active collection to history (carrying an `ArchivalInfo` audit trail, and an
|
|
22
|
+
app-supplied `expireAt` on the history doc to back native TTL). History docs
|
|
23
|
+
extend the active shape with `expireAt`.
|
|
24
|
+
|
|
25
|
+
Duplicate triggers for the same `dedupKey` increment a single active doc's
|
|
26
|
+
`count`, append the actor, and reset `seenAt` to `0` so new activity re-lights
|
|
27
|
+
the badge.
|
|
28
|
+
|
|
29
|
+
**Notifications are Cloud-Functions-only — clients never write notification
|
|
30
|
+
docs.** The archive React hooks (`useArchiveNotification` /
|
|
31
|
+
`useArchiveAllNotifications`) perform **no Firestore writes**; they take an
|
|
32
|
+
app-supplied `archiveFn` / `archiveAllFn` adapter wired to the app's callable
|
|
33
|
+
(e.g. `httpsCallable(functions, 'archiveNotification')`) and invalidate the
|
|
34
|
+
read keys on success. Server helpers (`archiveNotificationHelper` /
|
|
35
|
+
`archiveAllNotificationsHelper`) verify ownership before the move (personal:
|
|
36
|
+
`targetUserId === callerUid`; shared: caller must be admin).
|
|
20
37
|
|
|
21
38
|
### Identity is id-only
|
|
22
39
|
|
|
@@ -2,5 +2,5 @@ import type { NotificationListProps } from '../../types.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Scrollable list of active notifications with click-to-archive and clear-all.
|
|
4
4
|
*/
|
|
5
|
-
export declare function NotificationList({ config, userId, category, onNotificationClick, onClearAll, refetchInterval,
|
|
5
|
+
export declare function NotificationList({ config, userId, category, onNotificationClick, archiveFn, archiveAllFn, onClearAll, refetchInterval, emptyText, }: NotificationListProps): import("react/jsx-runtime").JSX.Element;
|
|
6
6
|
//# sourceMappingURL=NotificationList.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NotificationList.d.ts","sourceRoot":"","sources":["../../../src/react/components/NotificationList.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAE7E;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,mBAAmB,EACnB,UAAU,EACV,eAAe,EACf,
|
|
1
|
+
{"version":3,"file":"NotificationList.d.ts","sourceRoot":"","sources":["../../../src/react/components/NotificationList.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAE7E;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,mBAAmB,EACnB,SAAS,EACT,YAAY,EACZ,UAAU,EACV,eAAe,EACf,SAAS,GACV,EAAE,qBAAqB,2CA8HvB"}
|
|
@@ -11,7 +11,7 @@ import { formatRelativeTime } from './relative-time.js';
|
|
|
11
11
|
/**
|
|
12
12
|
* Scrollable list of active notifications with click-to-archive and clear-all.
|
|
13
13
|
*/
|
|
14
|
-
export function NotificationList({ config, userId, category, onNotificationClick,
|
|
14
|
+
export function NotificationList({ config, userId, category, onNotificationClick, archiveFn, archiveAllFn, onClearAll, refetchInterval, emptyText, }) {
|
|
15
15
|
const { data: notifications, isLoading, hasNextPage, nextPage, } = useActiveNotifications({
|
|
16
16
|
config,
|
|
17
17
|
userId,
|
|
@@ -25,46 +25,33 @@ export function NotificationList({ config, userId, category, onNotificationClick
|
|
|
25
25
|
refetchInterval,
|
|
26
26
|
});
|
|
27
27
|
const archiveMutation = useArchiveNotification({
|
|
28
|
-
config,
|
|
29
28
|
userId,
|
|
30
29
|
category,
|
|
30
|
+
archiveFn,
|
|
31
31
|
});
|
|
32
32
|
const archiveAllMutation = useArchiveAllNotifications({
|
|
33
|
-
config,
|
|
34
33
|
userId,
|
|
35
34
|
category,
|
|
35
|
+
archiveAllFn,
|
|
36
36
|
});
|
|
37
37
|
const handleNotificationClick = useCallback(async (notification) => {
|
|
38
38
|
try {
|
|
39
|
-
await archiveMutation.mutateAsync(
|
|
40
|
-
notificationId: notification.id,
|
|
41
|
-
archivalInfo: {
|
|
42
|
-
archivedBy: userId,
|
|
43
|
-
archivedAt: Date.now(),
|
|
44
|
-
device,
|
|
45
|
-
},
|
|
46
|
-
});
|
|
39
|
+
await archiveMutation.mutateAsync(notification.id);
|
|
47
40
|
}
|
|
48
41
|
catch {
|
|
49
42
|
// Still navigate even if archive fails
|
|
50
43
|
}
|
|
51
44
|
onNotificationClick(notification);
|
|
52
|
-
}, [archiveMutation,
|
|
45
|
+
}, [archiveMutation, onNotificationClick]);
|
|
53
46
|
const handleClearAll = useCallback(async () => {
|
|
54
47
|
try {
|
|
55
|
-
await archiveAllMutation.mutateAsync(
|
|
56
|
-
archivalInfo: {
|
|
57
|
-
archivedBy: userId,
|
|
58
|
-
archivedAt: Date.now(),
|
|
59
|
-
device,
|
|
60
|
-
},
|
|
61
|
-
});
|
|
48
|
+
await archiveAllMutation.mutateAsync();
|
|
62
49
|
}
|
|
63
50
|
catch {
|
|
64
51
|
// Silently fail
|
|
65
52
|
}
|
|
66
53
|
onClearAll?.();
|
|
67
|
-
}, [archiveAllMutation,
|
|
54
|
+
}, [archiveAllMutation, onClearAll]);
|
|
68
55
|
const getTypeIcon = useCallback((type) => {
|
|
69
56
|
const typeConfig = config.types[type];
|
|
70
57
|
return typeConfig?.icon ?? '🔔';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NotificationList.js","sourceRoot":"","sources":["../../../src/react/components/NotificationList.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AACpC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAC1E,OAAO,EAAE,sBAAsB,EAAE,MAAM,oCAAoC,CAAC;AAC5E,OAAO,EAAE,sBAAsB,EAAE,MAAM,oCAAoC,CAAC;AAC5E,OAAO,EAAE,0BAA0B,EAAE,MAAM,wCAAwC,CAAC;AACpF,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAGxD;;GAEG;AACH,MAAM,UAAU,gBAAgB,CAAC,EAC/B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,mBAAmB,EACnB,UAAU,EACV,eAAe,EACf,
|
|
1
|
+
{"version":3,"file":"NotificationList.js","sourceRoot":"","sources":["../../../src/react/components/NotificationList.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { UseArchiveAllNotificationsOptions } from '../../types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Archive
|
|
4
|
-
*
|
|
3
|
+
* Archive the caller's whole category through an app-supplied callable adapter.
|
|
4
|
+
*
|
|
5
|
+
* Like {@link useArchiveNotification}, this performs no client Firestore writes
|
|
6
|
+
* — paging + ownership-checked deletion happen server-side via the callable the
|
|
7
|
+
* app wires into `archiveAllFn`. On success it invalidates the read keys.
|
|
5
8
|
*
|
|
6
9
|
* @example
|
|
7
10
|
* ```tsx
|
|
11
|
+
* const archiveNotification = httpsCallable(functions, 'archiveNotification');
|
|
8
12
|
* const archiveAll = useArchiveAllNotifications({
|
|
9
|
-
* config: TTT_NOTIFICATION_CONFIG,
|
|
10
13
|
* userId: user.uid,
|
|
11
14
|
* category: 'user',
|
|
15
|
+
* archiveAllFn: () => archiveNotification({ category: 'user', scope: { kind: 'all' } }),
|
|
12
16
|
* });
|
|
13
17
|
*
|
|
14
|
-
* archiveAll.mutate(
|
|
15
|
-
* archivalInfo: { archivedBy: user.uid, archivedAt: Date.now(), device: 'web' },
|
|
16
|
-
* });
|
|
18
|
+
* archiveAll.mutate();
|
|
17
19
|
* ```
|
|
18
20
|
*/
|
|
19
|
-
export declare function useArchiveAllNotifications({
|
|
20
|
-
totalArchived: number;
|
|
21
|
-
}, Error, {
|
|
22
|
-
archivalInfo: ArchivalInfo;
|
|
23
|
-
}, unknown>;
|
|
21
|
+
export declare function useArchiveAllNotifications({ userId, category, archiveAllFn, invalidateKeys, }: UseArchiveAllNotificationsOptions): import("@tanstack/react-query").UseMutationResult<unknown, Error, void, unknown>;
|
|
24
22
|
//# sourceMappingURL=useArchiveAllNotifications.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useArchiveAllNotifications.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveAllNotifications.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useArchiveAllNotifications.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveAllNotifications.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iCAAiC,EAAE,MAAM,gBAAgB,CAAC;AAExE;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,0BAA0B,CAAC,EACzC,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,cAAc,GACf,EAAE,iCAAiC,oFAkBnC"}
|
|
@@ -1,81 +1,33 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import { collection, query, where, orderBy, limit, getDocs, doc, writeBatch, } from 'firebase/firestore';
|
|
4
|
-
import { useFirestoreDb } from '@ttt-productions/query-core/react';
|
|
5
|
-
const BATCH_SIZE = 50;
|
|
6
3
|
/**
|
|
7
|
-
* Archive
|
|
8
|
-
*
|
|
4
|
+
* Archive the caller's whole category through an app-supplied callable adapter.
|
|
5
|
+
*
|
|
6
|
+
* Like {@link useArchiveNotification}, this performs no client Firestore writes
|
|
7
|
+
* — paging + ownership-checked deletion happen server-side via the callable the
|
|
8
|
+
* app wires into `archiveAllFn`. On success it invalidates the read keys.
|
|
9
9
|
*
|
|
10
10
|
* @example
|
|
11
11
|
* ```tsx
|
|
12
|
+
* const archiveNotification = httpsCallable(functions, 'archiveNotification');
|
|
12
13
|
* const archiveAll = useArchiveAllNotifications({
|
|
13
|
-
* config: TTT_NOTIFICATION_CONFIG,
|
|
14
14
|
* userId: user.uid,
|
|
15
15
|
* category: 'user',
|
|
16
|
+
* archiveAllFn: () => archiveNotification({ category: 'user', scope: { kind: 'all' } }),
|
|
16
17
|
* });
|
|
17
18
|
*
|
|
18
|
-
* archiveAll.mutate(
|
|
19
|
-
* archivalInfo: { archivedBy: user.uid, archivedAt: Date.now(), device: 'web' },
|
|
20
|
-
* });
|
|
19
|
+
* archiveAll.mutate();
|
|
21
20
|
* ```
|
|
22
21
|
*/
|
|
23
|
-
export function useArchiveAllNotifications({
|
|
24
|
-
const db = useFirestoreDb();
|
|
22
|
+
export function useArchiveAllNotifications({ userId, category, archiveAllFn, invalidateKeys, }) {
|
|
25
23
|
const queryClient = useQueryClient();
|
|
26
|
-
const categoryConfig = config.categories[category];
|
|
27
24
|
const defaultInvalidateKeys = [
|
|
28
25
|
['notifications', 'active', category, userId],
|
|
29
26
|
['notifications', 'unread-count', category, userId],
|
|
30
27
|
['notifications', 'history', category, userId],
|
|
31
28
|
];
|
|
32
29
|
return useMutation({
|
|
33
|
-
mutationFn: async (
|
|
34
|
-
if (!categoryConfig) {
|
|
35
|
-
throw new Error(`[notification-core] Unknown category: ${category}`);
|
|
36
|
-
}
|
|
37
|
-
const activePath = categoryConfig.activePath;
|
|
38
|
-
const historyPath = categoryConfig.historyPath(userId);
|
|
39
|
-
const isPersonal = categoryConfig.audienceType === 'personal';
|
|
40
|
-
let totalArchived = 0;
|
|
41
|
-
let hasMore = true;
|
|
42
|
-
while (hasMore) {
|
|
43
|
-
const constraints = [
|
|
44
|
-
...(isPersonal ? [where('targetUserId', '==', userId)] : []),
|
|
45
|
-
orderBy('updatedAt', 'desc'),
|
|
46
|
-
limit(BATCH_SIZE),
|
|
47
|
-
];
|
|
48
|
-
const q = query(collection(db, activePath), ...constraints);
|
|
49
|
-
const snapshot = await getDocs(q);
|
|
50
|
-
if (snapshot.empty) {
|
|
51
|
-
hasMore = false;
|
|
52
|
-
break;
|
|
53
|
-
}
|
|
54
|
-
const batch = writeBatch(db);
|
|
55
|
-
snapshot.docs.forEach((docSnap) => {
|
|
56
|
-
const data = docSnap.data();
|
|
57
|
-
// Write to history
|
|
58
|
-
const historyRef = doc(db, historyPath, docSnap.id);
|
|
59
|
-
batch.set(historyRef, {
|
|
60
|
-
...data,
|
|
61
|
-
id: docSnap.id,
|
|
62
|
-
archival: archivalInfo,
|
|
63
|
-
...(categoryConfig.audienceType === 'shared'
|
|
64
|
-
? { handledBy: archivalInfo.archivedBy }
|
|
65
|
-
: {}),
|
|
66
|
-
});
|
|
67
|
-
// Delete from active
|
|
68
|
-
const activeRef = doc(db, activePath, docSnap.id);
|
|
69
|
-
batch.delete(activeRef);
|
|
70
|
-
});
|
|
71
|
-
await batch.commit();
|
|
72
|
-
totalArchived += snapshot.docs.length;
|
|
73
|
-
if (snapshot.docs.length < BATCH_SIZE) {
|
|
74
|
-
hasMore = false;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return { totalArchived };
|
|
78
|
-
},
|
|
30
|
+
mutationFn: async () => archiveAllFn(),
|
|
79
31
|
onSuccess: () => {
|
|
80
32
|
const keysToInvalidate = invalidateKeys ?? defaultInvalidateKeys;
|
|
81
33
|
keysToInvalidate.forEach((key) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useArchiveAllNotifications.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveAllNotifications.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"useArchiveAllNotifications.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveAllNotifications.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAGpE;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,0BAA0B,CAAC,EACzC,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,cAAc,GACoB;IAClC,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,CAAC;QACjB,UAAU,EAAE,KAAK,IAAI,EAAE,CAAC,YAAY,EAAE;QACtC,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"}
|
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { UseArchiveNotificationOptions } from '../../types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Archive a single notification
|
|
3
|
+
* Archive a single notification through an app-supplied callable adapter.
|
|
4
|
+
*
|
|
5
|
+
* The notification system is Cloud-Functions-only: clients never write
|
|
6
|
+
* notification docs. This hook performs no Firestore writes — it delegates to
|
|
7
|
+
* `archiveFn` (wired by the app to `httpsCallable(functions, 'archiveNotification')`)
|
|
8
|
+
* and invalidates the read keys on success.
|
|
4
9
|
*
|
|
5
10
|
* @example
|
|
6
11
|
* ```tsx
|
|
12
|
+
* const archiveNotification = httpsCallable(functions, 'archiveNotification');
|
|
7
13
|
* const archive = useArchiveNotification({
|
|
8
|
-
* config: TTT_NOTIFICATION_CONFIG,
|
|
9
14
|
* userId: user.uid,
|
|
10
15
|
* category: 'user',
|
|
16
|
+
* archiveFn: (notificationId) =>
|
|
17
|
+
* archiveNotification({ category: 'user', scope: { kind: 'single', notificationId } }),
|
|
11
18
|
* });
|
|
12
19
|
*
|
|
13
|
-
* archive.mutate(
|
|
14
|
-
* notificationId: 'abc123',
|
|
15
|
-
* archivalInfo: { archivedBy: user.uid, archivedAt: Date.now(), device: 'web' },
|
|
16
|
-
* });
|
|
20
|
+
* archive.mutate('abc123');
|
|
17
21
|
* ```
|
|
18
22
|
*/
|
|
19
|
-
export declare function useArchiveNotification({
|
|
20
|
-
notificationId: string;
|
|
21
|
-
archivalInfo: ArchivalInfo;
|
|
22
|
-
}, unknown>;
|
|
23
|
+
export declare function useArchiveNotification({ userId, category, archiveFn, invalidateKeys, }: UseArchiveNotificationOptions): import("@tanstack/react-query").UseMutationResult<unknown, Error, string, unknown>;
|
|
23
24
|
//# sourceMappingURL=useArchiveNotification.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useArchiveNotification.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useArchiveNotification.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,gBAAgB,CAAC;AAEpE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,QAAQ,EACR,SAAS,EACT,cAAc,GACf,EAAE,6BAA6B,sFAkB/B"}
|
|
@@ -1,62 +1,35 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import { doc, getDoc, setDoc, deleteDoc } from 'firebase/firestore';
|
|
4
|
-
import { useFirestoreDb } from '@ttt-productions/query-core/react';
|
|
5
3
|
/**
|
|
6
|
-
* Archive a single notification
|
|
4
|
+
* Archive a single notification through an app-supplied callable adapter.
|
|
5
|
+
*
|
|
6
|
+
* The notification system is Cloud-Functions-only: clients never write
|
|
7
|
+
* notification docs. This hook performs no Firestore writes — it delegates to
|
|
8
|
+
* `archiveFn` (wired by the app to `httpsCallable(functions, 'archiveNotification')`)
|
|
9
|
+
* and invalidates the read keys on success.
|
|
7
10
|
*
|
|
8
11
|
* @example
|
|
9
12
|
* ```tsx
|
|
13
|
+
* const archiveNotification = httpsCallable(functions, 'archiveNotification');
|
|
10
14
|
* const archive = useArchiveNotification({
|
|
11
|
-
* config: TTT_NOTIFICATION_CONFIG,
|
|
12
15
|
* userId: user.uid,
|
|
13
16
|
* category: 'user',
|
|
17
|
+
* archiveFn: (notificationId) =>
|
|
18
|
+
* archiveNotification({ category: 'user', scope: { kind: 'single', notificationId } }),
|
|
14
19
|
* });
|
|
15
20
|
*
|
|
16
|
-
* archive.mutate(
|
|
17
|
-
* notificationId: 'abc123',
|
|
18
|
-
* archivalInfo: { archivedBy: user.uid, archivedAt: Date.now(), device: 'web' },
|
|
19
|
-
* });
|
|
21
|
+
* archive.mutate('abc123');
|
|
20
22
|
* ```
|
|
21
23
|
*/
|
|
22
|
-
export function useArchiveNotification({
|
|
23
|
-
const db = useFirestoreDb();
|
|
24
|
+
export function useArchiveNotification({ userId, category, archiveFn, invalidateKeys, }) {
|
|
24
25
|
const queryClient = useQueryClient();
|
|
25
|
-
const categoryConfig = config.categories[category];
|
|
26
26
|
const defaultInvalidateKeys = [
|
|
27
27
|
['notifications', 'active', category, userId],
|
|
28
28
|
['notifications', 'unread-count', category, userId],
|
|
29
29
|
['notifications', 'history', category, userId],
|
|
30
30
|
];
|
|
31
31
|
return useMutation({
|
|
32
|
-
mutationFn: async (
|
|
33
|
-
if (!categoryConfig) {
|
|
34
|
-
throw new Error(`[notification-core] Unknown category: ${category}`);
|
|
35
|
-
}
|
|
36
|
-
const activePath = categoryConfig.activePath;
|
|
37
|
-
const historyPath = categoryConfig.historyPath(userId);
|
|
38
|
-
// Read active doc
|
|
39
|
-
const activeRef = doc(db, activePath, notificationId);
|
|
40
|
-
const activeSnap = await getDoc(activeRef);
|
|
41
|
-
if (!activeSnap.exists()) {
|
|
42
|
-
// Already archived or deleted — no-op
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
const activeData = { id: activeSnap.id, ...activeSnap.data() };
|
|
46
|
-
// Build history doc
|
|
47
|
-
const historyDoc = {
|
|
48
|
-
...activeData,
|
|
49
|
-
archival: archivalInfo,
|
|
50
|
-
...(categoryConfig.audienceType === 'shared'
|
|
51
|
-
? { handledBy: archivalInfo.archivedBy }
|
|
52
|
-
: {}),
|
|
53
|
-
};
|
|
54
|
-
// Write to history
|
|
55
|
-
const historyRef = doc(db, historyPath, notificationId);
|
|
56
|
-
await setDoc(historyRef, historyDoc);
|
|
57
|
-
// Delete from active
|
|
58
|
-
await deleteDoc(activeRef);
|
|
59
|
-
},
|
|
32
|
+
mutationFn: async (notificationId) => archiveFn(notificationId),
|
|
60
33
|
onSuccess: () => {
|
|
61
34
|
const keysToInvalidate = invalidateKeys ?? defaultInvalidateKeys;
|
|
62
35
|
keysToInvalidate.forEach((key) => {
|
|
@@ -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;
|
|
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;AAGpE;;;;;;;;;;;;;;;;;;;;GAoBG;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,CAAC;QACjB,UAAU,EAAE,KAAK,EAAE,cAAsB,EAAE,EAAE,CAAC,SAAS,CAAC,cAAc,CAAC;QACvE,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"}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { UseUnreadCountOptions } from '../../types.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Unread-count badge backed by a Firestore `count()` aggregation (a server-side
|
|
4
|
+
* count, not a doc fetch). Polls for cost control; displays capped
|
|
5
|
+
* (`hasMore` → `99+`).
|
|
6
|
+
*
|
|
7
|
+
* - **personal** categories count **unseen** active items (`seenAt == 0`),
|
|
8
|
+
* scoped to the caller (`targetUserId == uid`);
|
|
9
|
+
* - **shared** categories have no per-admin seen state, so the indicator is
|
|
10
|
+
* existence-based — a count of all active items, with no `seenAt` predicate.
|
|
11
|
+
*
|
|
12
|
+
* The personal `seenAt == 0` predicate needs a composite index, captured as an
|
|
13
|
+
* app-side index step.
|
|
5
14
|
*
|
|
6
15
|
* @example
|
|
7
16
|
* ```tsx
|
|
@@ -35,9 +44,9 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
35
44
|
isRefetching: boolean;
|
|
36
45
|
isStale: boolean;
|
|
37
46
|
isEnabled: boolean;
|
|
38
|
-
refetch: (options?: import("@tanstack/query
|
|
39
|
-
fetchStatus: import("@tanstack/query
|
|
40
|
-
promise: Promise<
|
|
47
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
|
|
48
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
49
|
+
promise: Promise<number>;
|
|
41
50
|
count: number;
|
|
42
51
|
hasMore: boolean;
|
|
43
52
|
} | {
|
|
@@ -63,9 +72,9 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
63
72
|
isRefetching: boolean;
|
|
64
73
|
isStale: boolean;
|
|
65
74
|
isEnabled: boolean;
|
|
66
|
-
refetch: (options?: import("@tanstack/query
|
|
67
|
-
fetchStatus: import("@tanstack/query
|
|
68
|
-
promise: Promise<
|
|
75
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
|
|
76
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
77
|
+
promise: Promise<number>;
|
|
69
78
|
count: number;
|
|
70
79
|
hasMore: boolean;
|
|
71
80
|
} | {
|
|
@@ -91,9 +100,9 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
91
100
|
isRefetching: boolean;
|
|
92
101
|
isStale: boolean;
|
|
93
102
|
isEnabled: boolean;
|
|
94
|
-
refetch: (options?: import("@tanstack/query
|
|
95
|
-
fetchStatus: import("@tanstack/query
|
|
96
|
-
promise: Promise<
|
|
103
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
|
|
104
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
105
|
+
promise: Promise<number>;
|
|
97
106
|
count: number;
|
|
98
107
|
hasMore: boolean;
|
|
99
108
|
} | {
|
|
@@ -119,9 +128,9 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
119
128
|
isRefetching: boolean;
|
|
120
129
|
isStale: boolean;
|
|
121
130
|
isEnabled: boolean;
|
|
122
|
-
refetch: (options?: import("@tanstack/query
|
|
123
|
-
fetchStatus: import("@tanstack/query
|
|
124
|
-
promise: Promise<
|
|
131
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
|
|
132
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
133
|
+
promise: Promise<number>;
|
|
125
134
|
count: number;
|
|
126
135
|
hasMore: boolean;
|
|
127
136
|
} | {
|
|
@@ -147,9 +156,9 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
147
156
|
isRefetching: boolean;
|
|
148
157
|
isStale: boolean;
|
|
149
158
|
isEnabled: boolean;
|
|
150
|
-
refetch: (options?: import("@tanstack/query
|
|
151
|
-
fetchStatus: import("@tanstack/query
|
|
152
|
-
promise: Promise<
|
|
159
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
|
|
160
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
161
|
+
promise: Promise<number>;
|
|
153
162
|
count: number;
|
|
154
163
|
hasMore: boolean;
|
|
155
164
|
} | {
|
|
@@ -175,9 +184,9 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
175
184
|
isRefetching: boolean;
|
|
176
185
|
isStale: boolean;
|
|
177
186
|
isEnabled: boolean;
|
|
178
|
-
refetch: (options?: import("@tanstack/query
|
|
179
|
-
fetchStatus: import("@tanstack/query
|
|
180
|
-
promise: Promise<
|
|
187
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
|
|
188
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
189
|
+
promise: Promise<number>;
|
|
181
190
|
count: number;
|
|
182
191
|
hasMore: boolean;
|
|
183
192
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useUnreadCount.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useUnreadCount.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAWA,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkCvB"}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import {
|
|
3
|
-
import { where,
|
|
2
|
+
import { useQuery } from '@tanstack/react-query';
|
|
3
|
+
import { collection, query, where, getCountFromServer, } from 'firebase/firestore';
|
|
4
|
+
import { useFirestoreDb } from '@ttt-productions/query-core/react';
|
|
4
5
|
const DEFAULT_REFETCH_INTERVAL = 30_000;
|
|
5
6
|
const DEFAULT_COUNT_LIMIT = 99;
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Unread-count badge backed by a Firestore `count()` aggregation (a server-side
|
|
9
|
+
* count, not a doc fetch). Polls for cost control; displays capped
|
|
10
|
+
* (`hasMore` → `99+`).
|
|
11
|
+
*
|
|
12
|
+
* - **personal** categories count **unseen** active items (`seenAt == 0`),
|
|
13
|
+
* scoped to the caller (`targetUserId == uid`);
|
|
14
|
+
* - **shared** categories have no per-admin seen state, so the indicator is
|
|
15
|
+
* existence-based — a count of all active items, with no `seenAt` predicate.
|
|
16
|
+
*
|
|
17
|
+
* The personal `seenAt == 0` predicate needs a composite index, captured as an
|
|
18
|
+
* app-side index step.
|
|
9
19
|
*
|
|
10
20
|
* @example
|
|
11
21
|
* ```tsx
|
|
@@ -17,28 +27,32 @@ const DEFAULT_COUNT_LIMIT = 99;
|
|
|
17
27
|
* ```
|
|
18
28
|
*/
|
|
19
29
|
export function useUnreadCount({ config, userId, category, enabled = true, refetchInterval = DEFAULT_REFETCH_INTERVAL, countLimit = DEFAULT_COUNT_LIMIT, }) {
|
|
30
|
+
const db = useFirestoreDb();
|
|
20
31
|
const categoryConfig = config.categories[category];
|
|
21
32
|
if (!categoryConfig) {
|
|
22
33
|
throw new Error(`[notification-core] Unknown category: ${category}`);
|
|
23
34
|
}
|
|
24
35
|
const collectionPath = categoryConfig.activePath;
|
|
25
36
|
const isPersonal = categoryConfig.audienceType === 'personal';
|
|
26
|
-
const
|
|
27
|
-
...(isPersonal ? [where('targetUserId', '==', userId)] : []),
|
|
28
|
-
orderBy('updatedAt', 'desc'),
|
|
29
|
-
limit(countLimit),
|
|
30
|
-
];
|
|
31
|
-
const { data: notifications, ...rest } = useFirestoreCollection({
|
|
32
|
-
collectionPath,
|
|
37
|
+
const { data, ...rest } = useQuery({
|
|
33
38
|
queryKey: ['notifications', 'unread-count', category, userId],
|
|
34
|
-
|
|
39
|
+
queryFn: async () => {
|
|
40
|
+
const collectionRef = collection(db, collectionPath);
|
|
41
|
+
const constraints = isPersonal
|
|
42
|
+
? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]
|
|
43
|
+
: [];
|
|
44
|
+
const q = constraints.length > 0 ? query(collectionRef, ...constraints) : collectionRef;
|
|
45
|
+
const snapshot = await getCountFromServer(q);
|
|
46
|
+
return snapshot.data().count;
|
|
47
|
+
},
|
|
35
48
|
enabled: enabled && !!userId,
|
|
36
|
-
subscribe: false,
|
|
37
49
|
staleTime: refetchInterval,
|
|
50
|
+
refetchInterval,
|
|
38
51
|
});
|
|
52
|
+
const count = data ?? 0;
|
|
39
53
|
return {
|
|
40
|
-
count
|
|
41
|
-
hasMore:
|
|
54
|
+
count,
|
|
55
|
+
hasMore: count > countLimit,
|
|
42
56
|
...rest,
|
|
43
57
|
};
|
|
44
58
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useUnreadCount.js","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"useUnreadCount.js","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EACL,UAAU,EACV,KAAK,EACL,KAAK,EACL,kBAAkB,GAEnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAGnE,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,EAAE,GAAG,cAAc,EAAE,CAAC;IAE5B,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,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,QAAQ,CAAC;QACjC,QAAQ,EAAE,CAAC,eAAe,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,CAAC;QAC7D,OAAO,EAAE,KAAK,IAAqB,EAAE;YACnC,MAAM,aAAa,GAAG,UAAU,CAAC,EAAE,EAAE,cAAc,CAAC,CAAC;YACrD,MAAM,WAAW,GAAsB,UAAU;gBAC/C,CAAC,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;gBACjE,CAAC,CAAC,EAAE,CAAC;YACP,MAAM,CAAC,GAAG,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,aAAa,EAAE,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC;YACxF,MAAM,QAAQ,GAAG,MAAM,kBAAkB,CAAC,CAAC,CAAC,CAAC;YAC7C,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC;QAC/B,CAAC;QACD,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"}
|
|
@@ -7,20 +7,31 @@ import type { ServerFirestore } from './types.js';
|
|
|
7
7
|
interface ArchiveInput {
|
|
8
8
|
notificationId: string;
|
|
9
9
|
category: string;
|
|
10
|
-
|
|
10
|
+
/** uid of the caller requesting the archive (ownership is verified against it). */
|
|
11
|
+
callerUid: string;
|
|
12
|
+
/** Whether the caller holds an admin/jrAdmin claim (required for shared notifications). */
|
|
13
|
+
callerIsAdmin: boolean;
|
|
11
14
|
archivalInfo: ArchivalInfo;
|
|
15
|
+
/**
|
|
16
|
+
* App-supplied epoch ms persisted on the history doc to back native TTL.
|
|
17
|
+
* The retention window is app policy; the helper just writes the value.
|
|
18
|
+
*/
|
|
19
|
+
expireAt?: number;
|
|
12
20
|
}
|
|
13
21
|
interface ArchiveAllInput {
|
|
14
22
|
category: string;
|
|
15
|
-
|
|
23
|
+
callerUid: string;
|
|
24
|
+
callerIsAdmin: boolean;
|
|
16
25
|
archivalInfo: ArchivalInfo;
|
|
26
|
+
expireAt?: number;
|
|
17
27
|
}
|
|
18
28
|
interface ArchiveResult {
|
|
19
29
|
success: boolean;
|
|
20
30
|
archived: number;
|
|
21
31
|
}
|
|
22
32
|
/**
|
|
23
|
-
* Archive a single notification on the server side.
|
|
33
|
+
* Archive a single notification on the server side. Verifies ownership before
|
|
34
|
+
* the active → history move and persists the app-supplied `expireAt`.
|
|
24
35
|
*/
|
|
25
36
|
export declare function archiveNotificationHelper(db: ServerFirestore, config: NotificationSystemConfig, input: ArchiveInput): Promise<ArchiveResult>;
|
|
26
37
|
/**
|