@mohasinac/appkit 4.47.0 → 4.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
- package/dist/_internal/server/features/messages/actions.d.ts +68 -0
- package/dist/_internal/server/features/messages/actions.js +249 -0
- package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
- package/dist/_internal/server/features/parts/buildable.js +159 -0
- package/dist/_internal/server/features/products/list-public.d.ts +17 -0
- package/dist/_internal/server/features/products/list-public.js +92 -5
- package/dist/_internal/server/features/products/og.d.ts +7 -0
- package/dist/_internal/server/features/products/og.js +38 -0
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +6 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
- package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
- package/dist/_internal/shared/actions/action-registry.js +8 -0
- package/dist/_internal/shared/features/notifications/action-url.js +8 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.js +8 -0
- package/dist/constants/api-endpoints.d.ts +48 -0
- package/dist/constants/api-endpoints.js +30 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/about/components/GstExplainerView.d.ts +28 -0
- package/dist/features/about/components/GstExplainerView.js +59 -0
- package/dist/features/about/components/GuidesHubView.d.ts +28 -0
- package/dist/features/about/components/GuidesHubView.js +58 -0
- package/dist/features/about/components/PublicProfileView.js +5 -0
- package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
- package/dist/features/about/components/SpotAFakeView.js +49 -0
- package/dist/features/admin/actions/notification-actions.js +4 -0
- package/dist/features/admin/schemas/firestore.d.ts +13 -3
- package/dist/features/admin/schemas/firestore.js +8 -0
- package/dist/features/blog/repository/blog.repository.js +21 -7
- package/dist/features/categories/api/route.js +41 -1
- package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
- package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
- package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
- package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
- package/dist/features/categories/components/ContextualTrail.js +81 -0
- package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
- package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
- package/dist/features/categories/repository/categories.repository.d.ts +21 -0
- package/dist/features/categories/repository/categories.repository.js +36 -3
- package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
- package/dist/features/email/notification-templates.js +4 -0
- package/dist/features/events/repository/events.repository.js +10 -3
- package/dist/features/faq/constants/read-caps.d.ts +29 -0
- package/dist/features/faq/constants/read-caps.js +29 -0
- package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
- package/dist/features/faq/repository/faqs.repository.js +19 -16
- package/dist/features/homepage/components/AdSlot.js +10 -4
- package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
- package/dist/features/homepage/hooks/useActiveAd.js +42 -2
- package/dist/features/homepage/lib/section-renderer.js +12 -2
- package/dist/features/layout/AppLayoutShell.d.ts +4 -1
- package/dist/features/layout/AppLayoutShell.js +2 -2
- package/dist/features/layout/TitleBarLayout.d.ts +4 -1
- package/dist/features/layout/TitleBarLayout.js +8 -4
- package/dist/features/media/AvatarUpload.js +8 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.js +3 -1
- package/dist/features/media/MediaLightbox.js +4 -1
- package/dist/features/media/MediaPickerModal.js +6 -1
- package/dist/features/media/MediaVideo.js +3 -1
- package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
- package/dist/features/messages/components/ConversationThreadView.js +75 -0
- package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
- package/dist/features/messages/components/ConversationsInboxView.js +39 -0
- package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
- package/dist/features/messages/components/MessageSellerButton.js +33 -0
- package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
- package/dist/features/messages/hooks/useConversationChannel.js +89 -0
- package/dist/features/messages/hooks/useMessages.d.ts +347 -0
- package/dist/features/messages/hooks/useMessages.js +113 -0
- package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
- package/dist/features/messages/repository/conversations.repository.js +173 -0
- package/dist/features/messages/repository/messages.repository.d.ts +42 -0
- package/dist/features/messages/repository/messages.repository.js +142 -0
- package/dist/features/messages/schemas/firestore.d.ts +99 -0
- package/dist/features/messages/schemas/firestore.js +46 -0
- package/dist/features/orders/repository/orders.repository.js +21 -7
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
- package/dist/features/products/components/CityCollectiblesView.js +82 -0
- package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
- package/dist/features/products/components/MarketPriceAnchor.js +62 -0
- package/dist/features/products/components/ProductDetailPageView.js +71 -5
- package/dist/features/products/constants/meetup-cities.d.ts +47 -0
- package/dist/features/products/constants/meetup-cities.js +49 -0
- package/dist/features/products/repository/category-assignment.d.ts +82 -11
- package/dist/features/products/repository/category-assignment.js +93 -12
- package/dist/features/products/repository/products.repository.d.ts +12 -1
- package/dist/features/products/repository/products.repository.js +66 -34
- package/dist/features/products/schemas/firestore.d.ts +24 -2
- package/dist/features/products/schemas/firestore.js +13 -1
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/repository/coupons.repository.js +11 -4
- package/dist/features/reviews/repository/reviews.repository.js +11 -4
- package/dist/features/scams/repository/scammer.repository.js +21 -7
- package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
- package/dist/features/seller/components/SellerProductShell.js +2 -2
- package/dist/features/seller/repository/offer.repository.js +21 -6
- package/dist/features/seller/schemas/product-form.d.ts +3 -3
- package/dist/features/seller/schemas/product-form.js +1 -1
- package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
- package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
- package/dist/features/server-errors/schemas/firestore.js +2 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
- package/dist/features/store-extensions/schemas/firestore.js +4 -0
- package/dist/features/stores/repository/store.repository.js +11 -3
- package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +1 -1
- package/dist/next/routing/route-map.d.ts +14 -0
- package/dist/next/routing/route-map.js +26 -0
- package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
- package/dist/providers/db-firebase/rtdb-paths.js +8 -0
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +4 -0
- package/dist/schemas/registry.d.ts +3 -3
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-schemas.d.ts +17 -0
- package/dist/security/pii-schemas.js +15 -7
- package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
- package/dist/seed/_helpers/category-content-generated.js +23 -7
- package/dist/seed/_helpers/category-models.d.ts +1 -1
- package/dist/seed/_helpers/category-models.js +25 -1
- package/dist/seed/_helpers/category-names.d.ts +54 -0
- package/dist/seed/_helpers/category-names.js +118 -0
- package/dist/seed/_helpers/character-features.d.ts +1 -1
- package/dist/seed/_helpers/character-features.js +5 -4
- package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
- package/dist/seed/categories-seed-data.js +55 -1
- package/dist/seed/grouped-listings-seed-data.js +2 -2
- package/dist/seed/products-art-seed-data.js +2 -1
- package/dist/seed/products-auctions-seed-data.js +42 -22
- package/dist/seed/products-classifieds-seed-data.js +21 -3
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.js +26 -11
- package/dist/seed/products-prize-draws-seed-data.js +8 -1
- package/dist/seed/products-standard-seed-data.js +26 -25
- package/dist/seed/products-stickers-seed-data.js +12 -11
- package/dist/server.d.ts +8 -0
- package/dist/server.js +15 -0
- package/dist/styles.css +113 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/sunset.js +17 -0
- package/dist/tokens/tokens.css +5 -0
- package/dist/ui/components/Breadcrumb.d.ts +8 -1
- package/dist/ui/components/Breadcrumb.js +2 -2
- package/dist/ui/components/Breadcrumb.style.css +37 -0
- package/dist/ui/components/Details.d.ts +11 -1
- package/dist/ui/components/Details.js +2 -2
- package/dist/ui/components/ImageMosaic.d.ts +43 -0
- package/dist/ui/components/ImageMosaic.js +46 -0
- package/dist/ui/components/ImageMosaic.style.css +69 -0
- package/dist/ui/components/SplitTile.d.ts +34 -0
- package/dist/ui/components/SplitTile.js +28 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +4 -0
- package/dist/utils/search-txt-query.d.ts +77 -0
- package/dist/utils/search-txt-query.js +50 -28
- package/package.json +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { apiClient } from "../../../http/ApiClient.js";
|
|
5
|
+
import { useApiMutation } from "../../../client/api/useApiMutation.js";
|
|
6
|
+
import { MESSAGE_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
7
|
+
import { useConversationChannel } from "./useConversationChannel.js";
|
|
8
|
+
/**
|
|
9
|
+
* The inbox list. Bounded (<=50), newest-first — this IS the query the
|
|
10
|
+
* unread badge also sums over, so there is no second "just for the count"
|
|
11
|
+
* query anywhere (D9 spec: "not a second global poll").
|
|
12
|
+
*/
|
|
13
|
+
export function useConversations(limit = 20, enabled = true) {
|
|
14
|
+
return useQuery({
|
|
15
|
+
queryKey: ["conversations", limit],
|
|
16
|
+
queryFn: () => apiClient.get(MESSAGE_ENDPOINTS.LIST({ limit })),
|
|
17
|
+
staleTime: 30000,
|
|
18
|
+
enabled,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function useOpenConversation() {
|
|
22
|
+
return useApiMutation({
|
|
23
|
+
mutationFn: ({ otherUserId }) => apiClient.post(MESSAGE_ENDPOINTS.OPEN, { otherUserId }),
|
|
24
|
+
errorMessage: "Could not open the conversation.",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** D11 — the entry point a product/store page actually has: a storeId, not
|
|
28
|
+
* the owner's uid. Resolution happens server-side. */
|
|
29
|
+
export function useOpenConversationWithSeller() {
|
|
30
|
+
return useApiMutation({
|
|
31
|
+
mutationFn: ({ storeId }) => apiClient.post(MESSAGE_ENDPOINTS.OPEN_WITH_SELLER, { storeId }),
|
|
32
|
+
errorMessage: "Could not message this seller.",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One thread. Combines the bounded REST page with the RTDB ping channel —
|
|
37
|
+
* a ping triggers `refetch()`, never a client Firestore listener
|
|
38
|
+
* (`firestore.rules` forbids one entirely).
|
|
39
|
+
*/
|
|
40
|
+
export function useConversationThread(conversationId) {
|
|
41
|
+
const queryClient = useQueryClient();
|
|
42
|
+
const queryKey = useMemo(() => ["conversation-messages", conversationId], [conversationId]);
|
|
43
|
+
const query = useQuery({
|
|
44
|
+
queryKey,
|
|
45
|
+
queryFn: () => apiClient.get(MESSAGE_ENDPOINTS.THREAD(conversationId, { limit: 30 })),
|
|
46
|
+
enabled: Boolean(conversationId),
|
|
47
|
+
staleTime: 10000,
|
|
48
|
+
});
|
|
49
|
+
const getToken = useCallback((id) => apiClient.post(MESSAGE_ENDPOINTS.TOKEN(id), {}), []);
|
|
50
|
+
const onPing = useCallback(() => {
|
|
51
|
+
void queryClient.invalidateQueries({ queryKey });
|
|
52
|
+
}, [queryClient, queryKey]);
|
|
53
|
+
const { error: channelError } = useConversationChannel(conversationId, getToken, onPing);
|
|
54
|
+
return { ...query, channelError };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Optimistic send with an explicit pending/failed state and retry — a silent
|
|
58
|
+
* failure is how the old feature hid (D9 spec). `clientMessageId` is a stable
|
|
59
|
+
* uuid generated by the CALLER at compose time (not on each attempt), so a
|
|
60
|
+
* retry reuses the same id and the server resolves it as the same message
|
|
61
|
+
* rather than a duplicate.
|
|
62
|
+
*/
|
|
63
|
+
export function useSendMessage(conversationId) {
|
|
64
|
+
const queryClient = useQueryClient();
|
|
65
|
+
const [pending, setPending] = useState([]);
|
|
66
|
+
const queryKey = useMemo(() => ["conversation-messages", conversationId], [conversationId]);
|
|
67
|
+
const mutation = useApiMutation({
|
|
68
|
+
mutationFn: (vars) => apiClient.post(MESSAGE_ENDPOINTS.THREAD(conversationId), vars),
|
|
69
|
+
onMutate: (vars) => {
|
|
70
|
+
setPending((prev) => [
|
|
71
|
+
...prev.filter((p) => p.clientMessageId !== vars.clientMessageId),
|
|
72
|
+
{ clientMessageId: vars.clientMessageId, body: vars.body, status: "pending" },
|
|
73
|
+
]);
|
|
74
|
+
},
|
|
75
|
+
onSuccess: (_data, vars) => {
|
|
76
|
+
setPending((prev) => prev.filter((p) => p.clientMessageId !== vars.clientMessageId));
|
|
77
|
+
void queryClient.invalidateQueries({ queryKey });
|
|
78
|
+
},
|
|
79
|
+
onError: (_err, vars) => {
|
|
80
|
+
setPending((prev) => prev.map((p) => p.clientMessageId === vars.clientMessageId ? { ...p, status: "failed" } : p));
|
|
81
|
+
},
|
|
82
|
+
errorMessage: "Message not sent. Tap to retry.",
|
|
83
|
+
});
|
|
84
|
+
const retry = useCallback((p) => {
|
|
85
|
+
mutation.mutate({ body: p.body, clientMessageId: p.clientMessageId });
|
|
86
|
+
}, [mutation]);
|
|
87
|
+
return { ...mutation, pending, retry };
|
|
88
|
+
}
|
|
89
|
+
export function useMarkConversationRead() {
|
|
90
|
+
const queryClient = useQueryClient();
|
|
91
|
+
return useApiMutation({
|
|
92
|
+
mutationFn: ({ conversationId }) => apiClient.post(MESSAGE_ENDPOINTS.READ(conversationId), {}),
|
|
93
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["conversations"] }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export function useBlockConversation() {
|
|
97
|
+
const queryClient = useQueryClient();
|
|
98
|
+
return useApiMutation({
|
|
99
|
+
mutationFn: ({ conversationId }) => apiClient.post(MESSAGE_ENDPOINTS.BLOCK(conversationId), {}),
|
|
100
|
+
successMessage: "Blocked. They can no longer message you in this conversation.",
|
|
101
|
+
onSuccess: (_d, vars) => void queryClient.invalidateQueries({ queryKey: ["conversation-messages", vars.conversationId] }),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** The nav badge's source when the inbox itself is not mounted — never a
|
|
105
|
+
* second list query of its own. */
|
|
106
|
+
export function useUnreadMessageCount(enabled) {
|
|
107
|
+
return useQuery({
|
|
108
|
+
queryKey: ["messages-unread-count"],
|
|
109
|
+
queryFn: () => apiClient.get(MESSAGE_ENDPOINTS.UNREAD_COUNT),
|
|
110
|
+
staleTime: 60000,
|
|
111
|
+
enabled,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { BaseRepository, type DocumentSnapshot } from "../../../providers/db-firebase";
|
|
2
|
+
import { type ConversationDocument, type ConversationLastMessage, type ParticipantRole } from "../schemas/firestore";
|
|
3
|
+
/**
|
|
4
|
+
* Top-level `conversations` collection. PII encryption: `lastMessage.body`
|
|
5
|
+
* (dot-path form — see `encryptPiiFields`), applied via `piiFields` so every
|
|
6
|
+
* mutating method inherits it (Root Cause #9 — never bypass repo hooks).
|
|
7
|
+
*/
|
|
8
|
+
export declare class ConversationsRepository extends BaseRepository<ConversationDocument> {
|
|
9
|
+
constructor();
|
|
10
|
+
protected piiFields: readonly ["lastMessage.body"];
|
|
11
|
+
protected mapDoc<D = ConversationDocument>(snap: DocumentSnapshot): D;
|
|
12
|
+
/**
|
|
13
|
+
* Find the (at most one) existing thread between two users. Participant
|
|
14
|
+
* pairs are unordered — `array-contains` on one id, then an in-memory check
|
|
15
|
+
* of the other, since Firestore has no "array contains both" operator and a
|
|
16
|
+
* composite `array-contains-any` would also match threads with only one of
|
|
17
|
+
* the two.
|
|
18
|
+
*/
|
|
19
|
+
findBetween(userIdA: string, userIdB: string): Promise<ConversationDocument | null>;
|
|
20
|
+
createBetween(userIdA: string, userIdB: string, roles: Record<string, ParticipantRole>): Promise<ConversationDocument>;
|
|
21
|
+
/**
|
|
22
|
+
* Cursor-paginated, newest-first. Bounded by construction — the absence of
|
|
23
|
+
* a `.limit()` is literally what turned 20 messages into ~1,600 reads the
|
|
24
|
+
* first time this feature existed.
|
|
25
|
+
*/
|
|
26
|
+
listForUser(userId: string, opts?: {
|
|
27
|
+
limit?: number;
|
|
28
|
+
cursor?: string;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
items: ConversationDocument[];
|
|
31
|
+
nextCursor: string | null;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* After a message write: bump the denormalized preview, the per-recipient
|
|
35
|
+
* unread counter, and `messageCount` — all in one update so a reader never
|
|
36
|
+
* observes `lastMessage` ahead of `unread`.
|
|
37
|
+
*/
|
|
38
|
+
applyNewMessage(conversationId: string, authorId: string, recipientId: string, lastMessage: ConversationLastMessage): Promise<void>;
|
|
39
|
+
markRead(conversationId: string, userId: string): Promise<void>;
|
|
40
|
+
blockParticipant(conversationId: string, blockerId: string): Promise<void>;
|
|
41
|
+
archiveStale(before: Date, limit: number): Promise<ConversationDocument[]>;
|
|
42
|
+
findDeletable(before: Date, limit: number): Promise<ConversationDocument[]>;
|
|
43
|
+
}
|
|
44
|
+
export declare const conversationsRepository: ConversationsRepository;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
2
|
+
import { DatabaseError } from "../../../errors/index.js";
|
|
3
|
+
import { BaseRepository, prepareForFirestore, } from "../../../providers/db-firebase/index.js";
|
|
4
|
+
import { arrayUnion, increment } from "../../../contracts/field-ops.js";
|
|
5
|
+
import { CONVERSATION_PII_FIELDS, decryptPiiFields } from "../../../security/index.js";
|
|
6
|
+
import { CONVERSATIONS_COLLECTION, CONVERSATION_FIELDS, } from "../schemas/firestore.js";
|
|
7
|
+
/**
|
|
8
|
+
* Top-level `conversations` collection. PII encryption: `lastMessage.body`
|
|
9
|
+
* (dot-path form — see `encryptPiiFields`), applied via `piiFields` so every
|
|
10
|
+
* mutating method inherits it (Root Cause #9 — never bypass repo hooks).
|
|
11
|
+
*/
|
|
12
|
+
export class ConversationsRepository extends BaseRepository {
|
|
13
|
+
constructor() {
|
|
14
|
+
super(CONVERSATIONS_COLLECTION);
|
|
15
|
+
this.piiFields = CONVERSATION_PII_FIELDS;
|
|
16
|
+
}
|
|
17
|
+
/*
|
|
18
|
+
* Every caller of findById/findBetween/listForUser is reached ONLY through
|
|
19
|
+
* _internal/server/features/messages/actions.ts, which calls
|
|
20
|
+
* requireConversation()+assertParticipant() before a document ever crosses a
|
|
21
|
+
* route boundary — unlike the 14 staged repos (admin listings, bulk
|
|
22
|
+
* exports) that can hand a decrypted doc to an operator who never needed to
|
|
23
|
+
* see it, a conversation's lastMessage.body preview is BY DESIGN shown only
|
|
24
|
+
* to its two participants, and that gate already lives one layer up. No
|
|
25
|
+
* route, view or admin surface reads this repository directly.
|
|
26
|
+
*/
|
|
27
|
+
// audit-pii-coverage-ok: gated one layer up in actions.ts, see comment above
|
|
28
|
+
mapDoc(snap) {
|
|
29
|
+
const raw = super.mapDoc(snap);
|
|
30
|
+
return decryptPiiFields(raw, [...CONVERSATION_PII_FIELDS]);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Find the (at most one) existing thread between two users. Participant
|
|
34
|
+
* pairs are unordered — `array-contains` on one id, then an in-memory check
|
|
35
|
+
* of the other, since Firestore has no "array contains both" operator and a
|
|
36
|
+
* composite `array-contains-any` would also match threads with only one of
|
|
37
|
+
* the two.
|
|
38
|
+
*/
|
|
39
|
+
async findBetween(userIdA, userIdB) {
|
|
40
|
+
try {
|
|
41
|
+
const snap = await this.getCollection()
|
|
42
|
+
.where(CONVERSATION_FIELDS.PARTICIPANT_IDS, "array-contains", userIdA)
|
|
43
|
+
.get();
|
|
44
|
+
const match = snap.docs.find((d) => {
|
|
45
|
+
const ids = d.data().participantIds ?? [];
|
|
46
|
+
return ids.includes(userIdB);
|
|
47
|
+
});
|
|
48
|
+
return match ? this.mapDoc(match) : null;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
void normalizeError(error);
|
|
52
|
+
throw new DatabaseError(`Failed to look up conversation between ${userIdA} and ${userIdB}`, error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async createBetween(userIdA, userIdB, roles) {
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const data = {
|
|
58
|
+
participantIds: [userIdA, userIdB],
|
|
59
|
+
participantRoles: roles,
|
|
60
|
+
lastReadAt: {},
|
|
61
|
+
unread: { [userIdA]: 0, [userIdB]: 0 },
|
|
62
|
+
status: "active",
|
|
63
|
+
messageCount: 0,
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
};
|
|
67
|
+
const docRef = this.getCollection().doc();
|
|
68
|
+
const hooked = this.applyWriteHooks(data);
|
|
69
|
+
await docRef.set(prepareForFirestore(hooked));
|
|
70
|
+
const created = await this.findById(docRef.id);
|
|
71
|
+
if (!created) {
|
|
72
|
+
throw new DatabaseError(`Conversation ${docRef.id} not readable immediately after create`, null);
|
|
73
|
+
}
|
|
74
|
+
return created;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Cursor-paginated, newest-first. Bounded by construction — the absence of
|
|
78
|
+
* a `.limit()` is literally what turned 20 messages into ~1,600 reads the
|
|
79
|
+
* first time this feature existed.
|
|
80
|
+
*/
|
|
81
|
+
async listForUser(userId, opts = {}) {
|
|
82
|
+
const limit = Math.min(opts.limit ?? 20, 50);
|
|
83
|
+
try {
|
|
84
|
+
let query = this.getCollection()
|
|
85
|
+
.where(CONVERSATION_FIELDS.PARTICIPANT_IDS, "array-contains", userId)
|
|
86
|
+
.orderBy(CONVERSATION_FIELDS.LAST_MESSAGE_AT, "desc")
|
|
87
|
+
.limit(limit + 1);
|
|
88
|
+
if (opts.cursor) {
|
|
89
|
+
const cursorDoc = await this.getCollection().doc(opts.cursor).get();
|
|
90
|
+
if (cursorDoc.exists)
|
|
91
|
+
query = query.startAfter(cursorDoc);
|
|
92
|
+
}
|
|
93
|
+
const snap = await query.get();
|
|
94
|
+
const docs = snap.docs.slice(0, limit);
|
|
95
|
+
const items = docs.map((d) => this.mapDoc(d));
|
|
96
|
+
const nextCursor = snap.docs.length > limit ? docs[docs.length - 1].id : null;
|
|
97
|
+
return { items, nextCursor };
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
void normalizeError(error);
|
|
101
|
+
throw new DatabaseError(`Failed to list conversations for ${userId}`, error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* After a message write: bump the denormalized preview, the per-recipient
|
|
106
|
+
* unread counter, and `messageCount` — all in one update so a reader never
|
|
107
|
+
* observes `lastMessage` ahead of `unread`.
|
|
108
|
+
*/
|
|
109
|
+
async applyNewMessage(conversationId, authorId, recipientId, lastMessage) {
|
|
110
|
+
try {
|
|
111
|
+
const hooked = this.applyWriteHooks({ lastMessage });
|
|
112
|
+
// `messageCount` and `unread.{recipientId}` are real Firestore
|
|
113
|
+
// increments, not a read-modify-write, so two near-simultaneous sends
|
|
114
|
+
// cannot lose a count the way `item-requests/[id]/replies` once did.
|
|
115
|
+
await this.getCollection()
|
|
116
|
+
.doc(conversationId)
|
|
117
|
+
.update(prepareForFirestore({
|
|
118
|
+
lastMessage: hooked.lastMessage,
|
|
119
|
+
lastMessageAt: lastMessage.at,
|
|
120
|
+
[`unread.${recipientId}`]: increment(1),
|
|
121
|
+
messageCount: increment(1),
|
|
122
|
+
updatedAt: new Date(),
|
|
123
|
+
}));
|
|
124
|
+
void authorId;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
void normalizeError(error);
|
|
128
|
+
throw new DatabaseError(`Failed to apply new message to conversation ${conversationId}`, error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async markRead(conversationId, userId) {
|
|
132
|
+
await this.getCollection()
|
|
133
|
+
.doc(conversationId)
|
|
134
|
+
.update({
|
|
135
|
+
[`lastReadAt.${userId}`]: new Date().toISOString(),
|
|
136
|
+
[`unread.${userId}`]: 0,
|
|
137
|
+
updatedAt: new Date(),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async blockParticipant(conversationId, blockerId) {
|
|
141
|
+
await this.getCollection()
|
|
142
|
+
.doc(conversationId)
|
|
143
|
+
.update({ blockedBy: arrayUnion(blockerId), updatedAt: new Date() });
|
|
144
|
+
}
|
|
145
|
+
async archiveStale(before, limit) {
|
|
146
|
+
try {
|
|
147
|
+
const snap = await this.getCollection()
|
|
148
|
+
.where(CONVERSATION_FIELDS.STATUS, "==", "active")
|
|
149
|
+
.where(CONVERSATION_FIELDS.LAST_MESSAGE_AT, "<", before)
|
|
150
|
+
.limit(limit)
|
|
151
|
+
.get();
|
|
152
|
+
return snap.docs.map((d) => this.mapDoc(d));
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
void normalizeError(error);
|
|
156
|
+
throw new DatabaseError("Failed to list conversations due for archive", error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async findDeletable(before, limit) {
|
|
160
|
+
try {
|
|
161
|
+
const snap = await this.getCollection()
|
|
162
|
+
.where(CONVERSATION_FIELDS.LAST_MESSAGE_AT, "<", before)
|
|
163
|
+
.limit(limit)
|
|
164
|
+
.get();
|
|
165
|
+
return snap.docs.map((d) => this.mapDoc(d));
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
void normalizeError(error);
|
|
169
|
+
throw new DatabaseError("Failed to list conversations due for deletion", error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export const conversationsRepository = new ConversationsRepository();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type MessageDocument } from "../schemas/firestore";
|
|
2
|
+
export interface CreateMessageInput {
|
|
3
|
+
conversationId: string;
|
|
4
|
+
authorId: string;
|
|
5
|
+
body: string;
|
|
6
|
+
clientMessageId: string;
|
|
7
|
+
attachments?: string[];
|
|
8
|
+
productRef?: MessageDocument["productRef"];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Idempotent on `(conversationId, clientMessageId)` — a retry on a flaky
|
|
12
|
+
* connection must not double-post. The doc id IS the client message id, so a
|
|
13
|
+
* duplicate `set()` (non-merge) on an existing id throws `ALREADY_EXISTS`
|
|
14
|
+
* rather than silently overwriting a different author's message that happened
|
|
15
|
+
* to reuse an id (which cannot happen across conversations since this is
|
|
16
|
+
* scoped under one, but a malicious resend of someone else's id must not
|
|
17
|
+
* silently replace it either).
|
|
18
|
+
*/
|
|
19
|
+
export declare function createMessage(input: CreateMessageInput): Promise<MessageDocument>;
|
|
20
|
+
/**
|
|
21
|
+
* Cursor-paginated, newest-first, `limit(30)` — the absence of a limit is
|
|
22
|
+
* literally what made the old feature expensive (20 messages -> ~1,600 reads
|
|
23
|
+
* across both participants' list refetches).
|
|
24
|
+
*/
|
|
25
|
+
export declare function listMessages(conversationId: string, opts?: {
|
|
26
|
+
limit?: number;
|
|
27
|
+
cursor?: string;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
items: MessageDocument[];
|
|
30
|
+
nextCursor: string | null;
|
|
31
|
+
}>;
|
|
32
|
+
/** Tombstone, never a hard delete of a single message — see schema comment. */
|
|
33
|
+
export declare function softDeleteMessage(conversationId: string, messageId: string, deletedBy: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Retention — delete every message in a conversation being pruned. Bounded
|
|
36
|
+
* batches, never a whole-subcollection snapshot in one go for a thread that
|
|
37
|
+
* somehow grew very large.
|
|
38
|
+
*/
|
|
39
|
+
export declare function deleteAllMessages(conversationId: string): Promise<number>;
|
|
40
|
+
/** All attachment URLs across a conversation's messages — for retention's
|
|
41
|
+
* "attachments die before the document" step. */
|
|
42
|
+
export declare function listAllAttachmentUrls(conversationId: string): Promise<string[]>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { getAdminDb } from "../../../providers/db-firebase/admin.js";
|
|
2
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
3
|
+
import { DatabaseError } from "../../../errors/index.js";
|
|
4
|
+
import { encryptPiiFields, decryptPiiFields, MESSAGE_PII_FIELDS } from "../../../security/index.js";
|
|
5
|
+
import { CONVERSATIONS_COLLECTION, MESSAGES_SUBCOLLECTION, MESSAGE_FIELDS, } from "../schemas/firestore.js";
|
|
6
|
+
/**
|
|
7
|
+
* `conversations/{id}/messages/{msgId}` — a SUBCOLLECTION, not an embedded
|
|
8
|
+
* array. `supportTickets` embeds `messages[]` and appends with
|
|
9
|
+
* `arrayUnion(prepareForFirestore(msg))`, which is correct for a ticket (a
|
|
10
|
+
* bounded, closable record) and wrong here: a thread has no natural ceiling,
|
|
11
|
+
* and a subcollection is concurrency-safe without `arrayUnion` — two replies
|
|
12
|
+
* at once are two documents, never a lost write (the `item-requests/[id]/
|
|
13
|
+
* replies` read-modify-write bug this avoids by construction).
|
|
14
|
+
*
|
|
15
|
+
* Plain functional module over `getAdminDb()`, matching the digital-code pool
|
|
16
|
+
* precedent (`_internal/server/features/digital-code/pool.ts`) — BaseRepository
|
|
17
|
+
* assumes a top-level collection and has no subcollection form.
|
|
18
|
+
*/
|
|
19
|
+
function messagesRef(conversationId) {
|
|
20
|
+
return getAdminDb()
|
|
21
|
+
.collection(CONVERSATIONS_COLLECTION)
|
|
22
|
+
.doc(conversationId)
|
|
23
|
+
.collection(MESSAGES_SUBCOLLECTION);
|
|
24
|
+
}
|
|
25
|
+
function encryptMessage(data) {
|
|
26
|
+
return encryptPiiFields(data, [...MESSAGE_PII_FIELDS]);
|
|
27
|
+
}
|
|
28
|
+
function decryptMessage(doc) {
|
|
29
|
+
return decryptPiiFields(doc, [...MESSAGE_PII_FIELDS]);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Idempotent on `(conversationId, clientMessageId)` — a retry on a flaky
|
|
33
|
+
* connection must not double-post. The doc id IS the client message id, so a
|
|
34
|
+
* duplicate `set()` (non-merge) on an existing id throws `ALREADY_EXISTS`
|
|
35
|
+
* rather than silently overwriting a different author's message that happened
|
|
36
|
+
* to reuse an id (which cannot happen across conversations since this is
|
|
37
|
+
* scoped under one, but a malicious resend of someone else's id must not
|
|
38
|
+
* silently replace it either).
|
|
39
|
+
*/
|
|
40
|
+
export async function createMessage(input) {
|
|
41
|
+
const ref = messagesRef(input.conversationId).doc(input.clientMessageId);
|
|
42
|
+
try {
|
|
43
|
+
const existing = await ref.get();
|
|
44
|
+
if (existing.exists) {
|
|
45
|
+
// Idempotent replay: the client retried, the server already has it.
|
|
46
|
+
return decryptMessage({ id: existing.id, ...existing.data() });
|
|
47
|
+
}
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const data = {
|
|
50
|
+
conversationId: input.conversationId,
|
|
51
|
+
authorId: input.authorId,
|
|
52
|
+
body: input.body,
|
|
53
|
+
clientMessageId: input.clientMessageId,
|
|
54
|
+
createdAt: now,
|
|
55
|
+
updatedAt: now,
|
|
56
|
+
...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
|
|
57
|
+
...(input.productRef ? { productRef: input.productRef } : {}),
|
|
58
|
+
};
|
|
59
|
+
await ref.create(encryptMessage(data));
|
|
60
|
+
return { id: ref.id, ...data };
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
void normalizeError(error);
|
|
64
|
+
throw new DatabaseError(`Failed to create message in conversation ${input.conversationId}`, error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Cursor-paginated, newest-first, `limit(30)` — the absence of a limit is
|
|
69
|
+
* literally what made the old feature expensive (20 messages -> ~1,600 reads
|
|
70
|
+
* across both participants' list refetches).
|
|
71
|
+
*/
|
|
72
|
+
export async function listMessages(conversationId, opts = {}) {
|
|
73
|
+
const limit = Math.min(opts.limit ?? 30, 50);
|
|
74
|
+
try {
|
|
75
|
+
let query = messagesRef(conversationId)
|
|
76
|
+
.orderBy(MESSAGE_FIELDS.CREATED_AT, "desc")
|
|
77
|
+
.limit(limit + 1);
|
|
78
|
+
if (opts.cursor) {
|
|
79
|
+
const cursorDoc = await messagesRef(conversationId).doc(opts.cursor).get();
|
|
80
|
+
if (cursorDoc.exists)
|
|
81
|
+
query = query.startAfter(cursorDoc);
|
|
82
|
+
}
|
|
83
|
+
const snap = await query.get();
|
|
84
|
+
const docs = snap.docs.slice(0, limit);
|
|
85
|
+
const items = docs.map((d) => decryptMessage({ id: d.id, ...d.data() }));
|
|
86
|
+
const nextCursor = snap.docs.length > limit ? docs[docs.length - 1].id : null;
|
|
87
|
+
return { items, nextCursor };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
void normalizeError(error);
|
|
91
|
+
throw new DatabaseError(`Failed to list messages for conversation ${conversationId}`, error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Tombstone, never a hard delete of a single message — see schema comment. */
|
|
95
|
+
export async function softDeleteMessage(conversationId, messageId, deletedBy) {
|
|
96
|
+
try {
|
|
97
|
+
await messagesRef(conversationId).doc(messageId).update({
|
|
98
|
+
deletedAt: new Date(),
|
|
99
|
+
deletedBy,
|
|
100
|
+
body: "",
|
|
101
|
+
updatedAt: new Date(),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
void normalizeError(error);
|
|
106
|
+
throw new DatabaseError(`Failed to delete message ${messageId}`, error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Retention — delete every message in a conversation being pruned. Bounded
|
|
111
|
+
* batches, never a whole-subcollection snapshot in one go for a thread that
|
|
112
|
+
* somehow grew very large.
|
|
113
|
+
*/
|
|
114
|
+
export async function deleteAllMessages(conversationId) {
|
|
115
|
+
const BATCH_SIZE = 400;
|
|
116
|
+
const db = getAdminDb();
|
|
117
|
+
let deleted = 0;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const snap = await messagesRef(conversationId).limit(BATCH_SIZE).get();
|
|
120
|
+
if (snap.empty)
|
|
121
|
+
break;
|
|
122
|
+
const batch = db.batch();
|
|
123
|
+
snap.docs.forEach((d) => batch.delete(d.ref));
|
|
124
|
+
await batch.commit();
|
|
125
|
+
deleted += snap.size;
|
|
126
|
+
if (snap.size < BATCH_SIZE)
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
return deleted;
|
|
130
|
+
}
|
|
131
|
+
/** All attachment URLs across a conversation's messages — for retention's
|
|
132
|
+
* "attachments die before the document" step. */
|
|
133
|
+
export async function listAllAttachmentUrls(conversationId) {
|
|
134
|
+
const snap = await messagesRef(conversationId).get();
|
|
135
|
+
const urls = [];
|
|
136
|
+
for (const d of snap.docs) {
|
|
137
|
+
const data = d.data();
|
|
138
|
+
if (data.attachments)
|
|
139
|
+
urls.push(...data.attachments);
|
|
140
|
+
}
|
|
141
|
+
return urls;
|
|
142
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
2
|
+
/**
|
|
3
|
+
* D9 — chat, rebuilt properly.
|
|
4
|
+
*
|
|
5
|
+
* One inbox. A conversation is between exactly TWO users; a product is
|
|
6
|
+
* attached to a MESSAGE as a card (the WhatsApp-reply-quoting shape), never
|
|
7
|
+
* to the thread — a buyer with three questions about three items wants one
|
|
8
|
+
* thread, not three. Not listing-scoped.
|
|
9
|
+
*
|
|
10
|
+
* 🛑 `firestore.rules` is `allow read, write: if false` on every document —
|
|
11
|
+
* there is deliberately no client-side Firestore access anywhere in this
|
|
12
|
+
* codebase. Every mutation here goes through a server route using the Admin
|
|
13
|
+
* SDK; the client never reads Firestore directly for chat, only the RTDB
|
|
14
|
+
* ping channel (see `chat_events` in `database.rules.json`) plus the REST
|
|
15
|
+
* routes it triggers a refetch against.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CONVERSATIONS_COLLECTION: "conversations";
|
|
18
|
+
export declare const MESSAGES_SUBCOLLECTION: "messages";
|
|
19
|
+
export type ConversationStatus = "active" | "archived";
|
|
20
|
+
export type ParticipantRole = "buyer" | "seller";
|
|
21
|
+
export interface ConversationLastMessage {
|
|
22
|
+
/** Truncated preview — the full body lives only on the message document. */
|
|
23
|
+
body: string;
|
|
24
|
+
authorId: string;
|
|
25
|
+
at: Date;
|
|
26
|
+
hasAttachments: boolean;
|
|
27
|
+
productId?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ConversationDocument extends BaseDocument {
|
|
30
|
+
/** Exactly two. `array-contains` query, bounded by construction. */
|
|
31
|
+
participantIds: string[];
|
|
32
|
+
participantRoles: Record<string, ParticipantRole>;
|
|
33
|
+
lastMessage?: ConversationLastMessage;
|
|
34
|
+
lastMessageAt?: Date;
|
|
35
|
+
/** Read receipts, NOT per-message flags — one timestamp per participant. */
|
|
36
|
+
lastReadAt: Record<string, string>;
|
|
37
|
+
unread: Record<string, number>;
|
|
38
|
+
status: ConversationStatus;
|
|
39
|
+
/** Uids who have blocked the OTHER participant in this thread. */
|
|
40
|
+
blockedBy?: string[];
|
|
41
|
+
messageCount: number;
|
|
42
|
+
}
|
|
43
|
+
export interface MessageProductRef {
|
|
44
|
+
productId: string;
|
|
45
|
+
title: string;
|
|
46
|
+
image?: string;
|
|
47
|
+
/** Omitted for a guest-gated listing snapshot taken while signed out is not
|
|
48
|
+
* possible here — chat requires auth — but still optional: a product can be
|
|
49
|
+
* archived/deleted between send and render. */
|
|
50
|
+
price?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface MessageDocument extends BaseDocument {
|
|
53
|
+
conversationId: string;
|
|
54
|
+
authorId: string;
|
|
55
|
+
/** <=2000 chars, PII-stripped and encrypted at rest. */
|
|
56
|
+
body: string;
|
|
57
|
+
/**
|
|
58
|
+
* Client-generated idempotency key. A retry on a flaky connection must not
|
|
59
|
+
* double-post — every chat system has this. Unique per (conversationId,
|
|
60
|
+
* clientMessageId); a duplicate send resolves to the existing message.
|
|
61
|
+
*/
|
|
62
|
+
clientMessageId: string;
|
|
63
|
+
/** `/media/` proxy URLs, finalized before the write (never tmp/). */
|
|
64
|
+
attachments?: string[];
|
|
65
|
+
productRef?: MessageProductRef;
|
|
66
|
+
/** Tombstone — removing a message must not renumber the thread under the
|
|
67
|
+
* other participant's read cursor. */
|
|
68
|
+
deletedAt?: Date;
|
|
69
|
+
deletedBy?: string;
|
|
70
|
+
}
|
|
71
|
+
export declare const CONVERSATION_FIELDS: {
|
|
72
|
+
readonly ID: "id";
|
|
73
|
+
readonly PARTICIPANT_IDS: "participantIds";
|
|
74
|
+
readonly PARTICIPANT_ROLES: "participantRoles";
|
|
75
|
+
readonly LAST_MESSAGE: "lastMessage";
|
|
76
|
+
readonly LAST_MESSAGE_AT: "lastMessageAt";
|
|
77
|
+
readonly LAST_READ_AT: "lastReadAt";
|
|
78
|
+
readonly UNREAD: "unread";
|
|
79
|
+
readonly STATUS: "status";
|
|
80
|
+
readonly BLOCKED_BY: "blockedBy";
|
|
81
|
+
readonly MESSAGE_COUNT: "messageCount";
|
|
82
|
+
readonly CREATED_AT: "createdAt";
|
|
83
|
+
readonly UPDATED_AT: "updatedAt";
|
|
84
|
+
};
|
|
85
|
+
export declare const MESSAGE_FIELDS: {
|
|
86
|
+
readonly ID: "id";
|
|
87
|
+
readonly CONVERSATION_ID: "conversationId";
|
|
88
|
+
readonly AUTHOR_ID: "authorId";
|
|
89
|
+
readonly BODY: "body";
|
|
90
|
+
readonly CLIENT_MESSAGE_ID: "clientMessageId";
|
|
91
|
+
readonly ATTACHMENTS: "attachments";
|
|
92
|
+
readonly PRODUCT_REF: "productRef";
|
|
93
|
+
readonly DELETED_AT: "deletedAt";
|
|
94
|
+
readonly DELETED_BY: "deletedBy";
|
|
95
|
+
readonly CREATED_AT: "createdAt";
|
|
96
|
+
};
|
|
97
|
+
/** Retention stages, measured from `lastMessageAt`. Folded into `notificationPrune`. */
|
|
98
|
+
export declare const CONVERSATION_ACTIVE_DAYS = 30;
|
|
99
|
+
export declare const CONVERSATION_ARCHIVE_DAYS: number;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D9 — chat, rebuilt properly.
|
|
3
|
+
*
|
|
4
|
+
* One inbox. A conversation is between exactly TWO users; a product is
|
|
5
|
+
* attached to a MESSAGE as a card (the WhatsApp-reply-quoting shape), never
|
|
6
|
+
* to the thread — a buyer with three questions about three items wants one
|
|
7
|
+
* thread, not three. Not listing-scoped.
|
|
8
|
+
*
|
|
9
|
+
* 🛑 `firestore.rules` is `allow read, write: if false` on every document —
|
|
10
|
+
* there is deliberately no client-side Firestore access anywhere in this
|
|
11
|
+
* codebase. Every mutation here goes through a server route using the Admin
|
|
12
|
+
* SDK; the client never reads Firestore directly for chat, only the RTDB
|
|
13
|
+
* ping channel (see `chat_events` in `database.rules.json`) plus the REST
|
|
14
|
+
* routes it triggers a refetch against.
|
|
15
|
+
*/
|
|
16
|
+
export const CONVERSATIONS_COLLECTION = "conversations";
|
|
17
|
+
export const MESSAGES_SUBCOLLECTION = "messages";
|
|
18
|
+
export const CONVERSATION_FIELDS = {
|
|
19
|
+
ID: "id",
|
|
20
|
+
PARTICIPANT_IDS: "participantIds",
|
|
21
|
+
PARTICIPANT_ROLES: "participantRoles",
|
|
22
|
+
LAST_MESSAGE: "lastMessage",
|
|
23
|
+
LAST_MESSAGE_AT: "lastMessageAt",
|
|
24
|
+
LAST_READ_AT: "lastReadAt",
|
|
25
|
+
UNREAD: "unread",
|
|
26
|
+
STATUS: "status",
|
|
27
|
+
BLOCKED_BY: "blockedBy",
|
|
28
|
+
MESSAGE_COUNT: "messageCount",
|
|
29
|
+
CREATED_AT: "createdAt",
|
|
30
|
+
UPDATED_AT: "updatedAt",
|
|
31
|
+
};
|
|
32
|
+
export const MESSAGE_FIELDS = {
|
|
33
|
+
ID: "id",
|
|
34
|
+
CONVERSATION_ID: "conversationId",
|
|
35
|
+
AUTHOR_ID: "authorId",
|
|
36
|
+
BODY: "body",
|
|
37
|
+
CLIENT_MESSAGE_ID: "clientMessageId",
|
|
38
|
+
ATTACHMENTS: "attachments",
|
|
39
|
+
PRODUCT_REF: "productRef",
|
|
40
|
+
DELETED_AT: "deletedAt",
|
|
41
|
+
DELETED_BY: "deletedBy",
|
|
42
|
+
CREATED_AT: "createdAt",
|
|
43
|
+
};
|
|
44
|
+
/** Retention stages, measured from `lastMessageAt`. Folded into `notificationPrune`. */
|
|
45
|
+
export const CONVERSATION_ACTIVE_DAYS = 30;
|
|
46
|
+
export const CONVERSATION_ARCHIVE_DAYS = 7 * 30; // ~7 months
|