@mohasinac/appkit 4.46.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.
Files changed (175) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +58 -13
  38. package/dist/features/categories/components/BreadcrumbTrail.js +63 -26
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  103. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  104. package/dist/features/seller/components/SellerProductShell.d.ts +27 -2
  105. package/dist/features/seller/components/SellerProductShell.js +32 -3
  106. package/dist/features/seller/repository/offer.repository.js +21 -6
  107. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  108. package/dist/features/seller/schemas/product-form.js +1 -1
  109. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  110. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  111. package/dist/features/server-errors/schemas/firestore.js +2 -0
  112. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  113. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  114. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  115. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  116. package/dist/features/stores/repository/store.repository.js +11 -3
  117. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  118. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  119. package/dist/features/tester/seed-data/authored/index.js +2 -0
  120. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  121. package/dist/index.d.ts +8 -2
  122. package/dist/index.js +1 -1
  123. package/dist/next/routing/route-map.d.ts +14 -0
  124. package/dist/next/routing/route-map.js +26 -0
  125. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  126. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  127. package/dist/repositories/index.d.ts +1 -0
  128. package/dist/repositories/index.js +4 -0
  129. package/dist/schemas/registry.d.ts +3 -3
  130. package/dist/security/index.d.ts +1 -1
  131. package/dist/security/index.js +1 -1
  132. package/dist/security/pii-schemas.d.ts +17 -0
  133. package/dist/security/pii-schemas.js +15 -7
  134. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  135. package/dist/seed/_helpers/category-content-generated.js +1853 -0
  136. package/dist/seed/_helpers/category-models.d.ts +1 -1
  137. package/dist/seed/_helpers/category-models.js +25 -1
  138. package/dist/seed/_helpers/category-names.d.ts +54 -0
  139. package/dist/seed/_helpers/category-names.js +118 -0
  140. package/dist/seed/_helpers/character-features.d.ts +1 -1
  141. package/dist/seed/_helpers/character-features.js +5 -4
  142. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  143. package/dist/seed/categories-seed-data.js +55 -1
  144. package/dist/seed/grouped-listings-seed-data.js +2 -2
  145. package/dist/seed/products-art-seed-data.js +2 -1
  146. package/dist/seed/products-auctions-seed-data.js +42 -22
  147. package/dist/seed/products-classifieds-seed-data.js +21 -3
  148. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  149. package/dist/seed/products-live-items-seed-data.js +2 -1
  150. package/dist/seed/products-preorders-seed-data.js +26 -11
  151. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  152. package/dist/seed/products-standard-seed-data.js +26 -25
  153. package/dist/seed/products-stickers-seed-data.js +12 -11
  154. package/dist/server.d.ts +8 -0
  155. package/dist/server.js +15 -0
  156. package/dist/styles.css +113 -1
  157. package/dist/tailwind-utilities.css +1 -1
  158. package/dist/tokens/themes/sunset.js +17 -0
  159. package/dist/tokens/tokens.css +5 -0
  160. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  161. package/dist/ui/components/Breadcrumb.js +2 -2
  162. package/dist/ui/components/Breadcrumb.style.css +37 -0
  163. package/dist/ui/components/Details.d.ts +11 -1
  164. package/dist/ui/components/Details.js +2 -2
  165. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  166. package/dist/ui/components/ImageMosaic.js +46 -0
  167. package/dist/ui/components/ImageMosaic.style.css +69 -0
  168. package/dist/ui/components/SplitTile.d.ts +34 -0
  169. package/dist/ui/components/SplitTile.js +28 -0
  170. package/dist/ui/components/index.style.css +1 -0
  171. package/dist/ui/index.d.ts +4 -0
  172. package/dist/ui/index.js +4 -0
  173. package/dist/utils/search-txt-query.d.ts +77 -0
  174. package/dist/utils/search-txt-query.js +50 -28
  175. 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