@mohasinac/appkit 4.22.0 → 4.23.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 (71) hide show
  1. package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
  2. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/stores/adapters.js +5 -0
  4. package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
  5. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
  6. package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
  7. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  8. package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
  9. package/dist/_internal/shared/history/index.d.ts +1 -1
  10. package/dist/_internal/shared/history/types.d.ts +16 -0
  11. package/dist/client.d.ts +2 -0
  12. package/dist/client.js +1 -0
  13. package/dist/constants/field-names.d.ts +12 -0
  14. package/dist/constants/field-names.js +12 -0
  15. package/dist/features/admin/actions/admin-actions.js +13 -3
  16. package/dist/features/admin/components/AdminBidsView.js +7 -1
  17. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  18. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  19. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  20. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  21. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  22. package/dist/features/auctions/repository/bid.repository.js +57 -33
  23. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  24. package/dist/features/auctions/schemas/firestore.js +21 -0
  25. package/dist/features/auth/schemas/index.d.ts +2 -2
  26. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  27. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  28. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  29. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  30. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  31. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  32. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  33. package/dist/features/catalogue/schemas/firestore.js +12 -0
  34. package/dist/features/categories/schemas/index.d.ts +2 -2
  35. package/dist/features/messages/schemas/index.d.ts +6 -6
  36. package/dist/features/orders/schemas/index.d.ts +12 -12
  37. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  38. package/dist/features/payments/repository/payout.repository.js +45 -19
  39. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  40. package/dist/features/payments/schemas/firestore.js +39 -0
  41. package/dist/features/payments/schemas/index.d.ts +12 -12
  42. package/dist/features/products/schemas/index.d.ts +2 -2
  43. package/dist/features/promotions/schemas/index.d.ts +12 -12
  44. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  45. package/dist/features/scams/repository/scammer.repository.js +24 -2
  46. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  47. package/dist/features/scams/schemas/firestore.js +30 -0
  48. package/dist/features/shipments/components/AdminShipmentEditorView.js +2 -1
  49. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  50. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  51. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  52. package/dist/features/shipments/schemas/firestore.js +19 -0
  53. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  54. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  55. package/dist/features/status-history/index.d.ts +2 -0
  56. package/dist/features/status-history/index.js +1 -0
  57. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  58. package/dist/features/stores/repository/store.repository.js +43 -4
  59. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  60. package/dist/features/stores/schemas/firestore.js +31 -0
  61. package/dist/features/support/repository/support.repository.d.ts +47 -3
  62. package/dist/features/support/repository/support.repository.js +72 -12
  63. package/dist/features/support/schemas/firestore.d.ts +38 -1
  64. package/dist/features/support/schemas/firestore.js +34 -0
  65. package/dist/features/support/schemas/index.d.ts +2 -2
  66. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +54 -0
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +12 -1
  69. package/dist/schemas/registry.d.ts +32 -32
  70. package/dist/seed/bids-seed-data.js +8 -0
  71. package/package.json +1 -1
@@ -6,7 +6,8 @@ import { normalizeError } from "../../../errors/normalize";
6
6
  */
7
7
  import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
8
8
  import { DatabaseError } from "../../../errors";
9
- import { BID_COLLECTION, createBidId } from "../schemas";
9
+ import { BID_COLLECTION, BID_TRACKED_FIELDS, BID_HISTORY_PII_FIELDS, createBidId } from "../schemas";
10
+ import { withHistory } from "../../../_internal/shared/history/index";
10
11
  import { encryptPiiFields, decryptPiiFields, BID_PII_FIELDS, } from "../../../security";
11
12
  import { BID_FIELDS } from "../../../constants/field-names";
12
13
  export class BidRepository extends BaseRepository {
@@ -115,12 +116,21 @@ export class BidRepository extends BaseRepository {
115
116
  data: this.mapDoc(doc),
116
117
  };
117
118
  }
118
- markWon(batch, ref) {
119
- batch.update(ref, {
120
- status: "won",
121
- isWinning: true,
122
- updatedAt: new Date(),
123
- });
119
+ /*
120
+ * ── Batch mutators ─────────────────────────────────────────────────────
121
+ *
122
+ * These take a `DocumentReference` because settlement writes every bid on
123
+ * an auction in one Firestore batch. `prior` is the document from the SAME
124
+ * query that produced the ref — every caller has it (`activeBids` and
125
+ * `losers` are both `{ ref, data }` pairs), so history costs no extra read
126
+ * even when an auction closes with hundreds of bids (Rule #6).
127
+ *
128
+ * Omitting `prior` is legitimate rather than broken: the entry then records
129
+ * `undefined → "lost"`, which is still true and still stamped with who and
130
+ * when. It just cannot say what the bid was before.
131
+ */
132
+ markWon(batch, ref, ctx, prior) {
133
+ batch.update(ref, this.withBidHistory(prior, { status: "won", isWinning: true, updatedAt: new Date() }, ctx, "markWon"));
124
134
  }
125
135
  /**
126
136
  * Link a won bid to the order it eventually became.
@@ -130,23 +140,21 @@ export class BidRepository extends BaseRepository {
130
140
  * /user/bids could not link anywhere, and nothing could tell a settled win
131
141
  * from an unpaid one.
132
142
  */
133
- async attachOrder(bidId, orderId) {
134
- await this.update(bidId, { orderId, updatedAt: new Date() });
143
+ async attachOrder(bidId, orderId, ctx, prior) {
144
+ const current = prior !== undefined ? prior : await this.findById(bidId);
145
+ await this.update(bidId, this.withBidHistory(current, { orderId, updatedAt: new Date() }, ctx, "attachOrder"));
135
146
  }
136
147
  /**
137
148
  * The winner never paid within their checkout window. Distinct from "lost"
138
149
  * (outbid) — this bidder won and then defaulted, which a seller may want to
139
150
  * act on.
140
151
  */
141
- async markForfeited(bidId) {
142
- await this.update(bidId, { status: "forfeited", isWinning: false, updatedAt: new Date() });
152
+ async markForfeited(bidId, ctx, prior) {
153
+ const current = prior !== undefined ? prior : await this.findById(bidId);
154
+ await this.update(bidId, this.withBidHistory(current, { status: "forfeited", isWinning: false, updatedAt: new Date() }, ctx, "markForfeited"));
143
155
  }
144
- markLost(batch, ref) {
145
- batch.update(ref, {
146
- status: "lost",
147
- isWinning: false,
148
- updatedAt: new Date(),
149
- });
156
+ markLost(batch, ref, ctx, prior) {
157
+ batch.update(ref, this.withBidHistory(prior, { status: "lost", isWinning: false, updatedAt: new Date() }, ctx, "markLost"));
150
158
  }
151
159
  /**
152
160
  * Batched "everyone else lost", for callers that are not already inside a
@@ -154,12 +162,20 @@ export class BidRepository extends BaseRepository {
154
162
  * settlement job, and the losing bids can be any number, so this writes them
155
163
  * in Firestore-batch chunks rather than one round-trip per bid.
156
164
  */
157
- async markManyLost(refs) {
165
+ async markManyLost(entries, ctx) {
158
166
  const CHUNK = 400; // under Firestore's 500-write batch limit, with headroom
159
- for (let i = 0; i < refs.length; i += CHUNK) {
167
+ for (let i = 0; i < entries.length; i += CHUNK) {
160
168
  const batch = this.db.batch();
161
- for (const ref of refs.slice(i, i + CHUNK))
162
- this.markLost(batch, ref);
169
+ for (const entry of entries.slice(i, i + CHUNK)) {
170
+ // Accepts a bare ref OR a {ref, data} pair, so the caller that already
171
+ // holds the documents gets a real before-value on the timeline and the
172
+ // one that does not still works. Widened rather than made required
173
+ // because forcing a read per losing bid would put a large auction's
174
+ // settlement over the Firestore read budget.
175
+ const isPair = typeof entry === "object" && "ref" in entry;
176
+ const ref = isPair ? entry.ref : entry;
177
+ this.markLost(batch, ref, ctx, isPair ? entry.data : undefined);
178
+ }
163
179
  await batch.commit();
164
180
  }
165
181
  }
@@ -168,21 +184,29 @@ export class BidRepository extends BaseRepository {
168
184
  * status means "won, then defaulted", and a lapsed claim never won anything;
169
185
  * the auction it was placed against carried on untouched.
170
186
  */
171
- async markCancelled(bidId) {
172
- await this.update(bidId, { status: "cancelled", isWinning: false, updatedAt: new Date() });
187
+ async markCancelled(bidId, ctx, prior) {
188
+ const current = prior !== undefined ? prior : await this.findById(bidId);
189
+ await this.update(bidId, this.withBidHistory(current, { status: "cancelled", isWinning: false, updatedAt: new Date() }, ctx, "markCancelled"));
173
190
  }
174
- markOutbid(batch, ref) {
175
- batch.update(ref, {
176
- status: "outbid",
177
- isWinning: false,
178
- updatedAt: new Date(),
179
- });
191
+ markOutbid(batch, ref, ctx, prior) {
192
+ batch.update(ref, this.withBidHistory(prior, { status: "outbid", isWinning: false, updatedAt: new Date() }, ctx, "markOutbid"));
180
193
  }
181
- markWinning(batch, ref) {
182
- batch.update(ref, {
183
- isWinning: true,
184
- updatedAt: new Date(),
194
+ markWinning(batch, ref, ctx, prior) {
195
+ batch.update(ref, this.withBidHistory(prior, { isWinning: true, updatedAt: new Date() }, ctx, "markWinning"));
196
+ }
197
+ /** Append a timeline entry when the patch touches a tracked field. */
198
+ withBidHistory(current, patch, ctx, defaultTrigger) {
199
+ const withEntry = withHistory(current, patch, {
200
+ tracked: BID_TRACKED_FIELDS,
201
+ actor: ctx?.actor ?? { role: "system" },
202
+ trigger: ctx?.trigger ?? defaultTrigger,
203
+ reason: ctx?.reason,
204
+ note: ctx?.note,
205
+ // `userName`/`userEmail` are denormalised onto every bid, and
206
+ // `encryptPiiFields` never descends into arrays.
207
+ piiFields: BID_HISTORY_PII_FIELDS,
185
208
  });
209
+ return withEntry ?? patch;
186
210
  }
187
211
  /**
188
212
  * Find current winning bid for a product
@@ -2,6 +2,7 @@
2
2
  * Bids/Auctions Firestore Document Types & Constants
3
3
  */
4
4
  import { type GenerateBidIdInput } from "../../../utils/id-generators";
5
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
5
6
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
6
7
  export type BidStatus = "active" | "outbid" | "won" | "lost" | "cancelled"
7
8
  /** Won, then the buyer let the 48h payment window lapse. */
@@ -52,6 +53,16 @@ export interface BidDocument extends BaseDocument {
52
53
  * `claimAuctionForCheckout`, not by blocking the second buyer up front.
53
54
  */
54
55
  isBuyout?: boolean;
56
+ /**
57
+ * Who changed what, when, and why. See § "Status History" in CLAUDE.md.
58
+ *
59
+ * A bid moves through more states than any other record a buyer owns —
60
+ * active → outbid → active → won → forfeited, or cancelled for a lapsed
61
+ * buyout claim — and every one of those moves happened inside a batch with
62
+ * nothing recording it. "Why does this say forfeited" was unanswerable.
63
+ */
64
+ statusHistory?: StatusChangeEntry[];
65
+ statusHistoryTruncated?: number;
55
66
  }
56
67
  export declare const BID_COLLECTION: "bids";
57
68
  export declare const BID_INDEXED_FIELDS: readonly ["productId", "userId", "status", "isWinning", "bidDate", "createdAt"];
@@ -71,3 +82,19 @@ export declare const bidQueryHelpers: {
71
82
  export declare function createBidId(input: Omit<GenerateBidIdInput, "random"> & {
72
83
  random?: string;
73
84
  }): string;
85
+ /**
86
+ * The fields whose changes earn a timeline entry.
87
+ *
88
+ * `bidAmount` is included because a proxy bid raises it in place, so the
89
+ * timeline is the only record of what the bidder actually committed at each
90
+ * step. `previousBidAmount` is excluded — it is the same information one
91
+ * entry earlier.
92
+ */
93
+ export declare const BID_TRACKED_FIELDS: readonly ["status", "isWinning", "bidAmount", "orderId"];
94
+ /**
95
+ * PII on this document, for `withHistory`'s scrub. `userName` and `userEmail`
96
+ * are denormalised onto every bid, so a diff of an unrelated field would drag
97
+ * them into an array `encryptPiiFields` cannot descend into — the exact leak
98
+ * the primitive's `piiFields` exists to prevent.
99
+ */
100
+ export declare const BID_HISTORY_PII_FIELDS: readonly ["userName", "userEmail"];
@@ -49,3 +49,24 @@ export const bidQueryHelpers = {
49
49
  export function createBidId(input) {
50
50
  return generateBidId(input);
51
51
  }
52
+ /**
53
+ * The fields whose changes earn a timeline entry.
54
+ *
55
+ * `bidAmount` is included because a proxy bid raises it in place, so the
56
+ * timeline is the only record of what the bidder actually committed at each
57
+ * step. `previousBidAmount` is excluded — it is the same information one
58
+ * entry earlier.
59
+ */
60
+ export const BID_TRACKED_FIELDS = [
61
+ "status",
62
+ "isWinning",
63
+ "bidAmount",
64
+ "orderId",
65
+ ];
66
+ /**
67
+ * PII on this document, for `withHistory`'s scrub. `userName` and `userEmail`
68
+ * are denormalised onto every bid, so a diff of an unrelated field would drag
69
+ * them into an array `encryptPiiFields` cannot descend into — the exact leak
70
+ * the primitive's `piiFields` exists to prevent.
71
+ */
72
+ export const BID_HISTORY_PII_FIELDS = ["userName", "userEmail"];
@@ -65,14 +65,14 @@ export declare const authUserSchema: z.ZodObject<{
65
65
  isEmailVerified: z.ZodOptional<z.ZodBoolean>;
66
66
  }, "strip", z.ZodTypeAny, {
67
67
  id: string;
68
- role?: "user" | "admin" | "moderator" | "seller" | "employee" | undefined;
68
+ role?: "user" | "seller" | "admin" | "moderator" | "employee" | undefined;
69
69
  email?: string | undefined;
70
70
  displayName?: string | undefined;
71
71
  photoURL?: string | undefined;
72
72
  isEmailVerified?: boolean | undefined;
73
73
  }, {
74
74
  id: string;
75
- role?: "user" | "admin" | "moderator" | "seller" | "employee" | undefined;
75
+ role?: "user" | "seller" | "admin" | "moderator" | "employee" | undefined;
76
76
  email?: string | undefined;
77
77
  displayName?: string | undefined;
78
78
  photoURL?: string | undefined;
@@ -23,11 +23,12 @@ export async function approveCatalogueListingAction(itemId) {
23
23
  throw new ValidationError("Only items pending approval can be approved");
24
24
  }
25
25
  const product = await createProductFromCatalogueItem(item, CONSIGNMENT_STORE_ID);
26
- await catalogueRepository.update(itemId, {
26
+ // `item` came from the findById above, so the timeline entry is free.
27
+ await catalogueRepository.setListingStatus(itemId, {
27
28
  listingStatus: "listed",
28
29
  linkedProductId: product.id,
29
30
  linkedProductSlug: product.slug ?? product.id,
30
- });
31
+ }, { actor: { role: "admin", uid: user.uid }, trigger: "approveCatalogueListing" }, item);
31
32
  return { productId: product.id, productSlug: product.slug ?? product.id };
32
33
  });
33
34
  }
@@ -42,9 +43,9 @@ export async function rejectCatalogueListingAction(itemId, reason) {
42
43
  if (item.listingStatus !== "pending_admin_approval") {
43
44
  throw new ValidationError("Only items pending approval can be rejected");
44
45
  }
45
- await catalogueRepository.update(itemId, {
46
- listingStatus: "rejected",
47
- rejectionReason: reason,
48
- });
46
+ await catalogueRepository.setListingStatus(itemId, { listingStatus: "rejected", rejectionReason: reason },
47
+ // The reason is the point of the entry — `rejectionReason` on the
48
+ // document is overwritten by the next decision.
49
+ { actor: { role: "admin", uid: user.uid }, trigger: "rejectCatalogueListing", reason }, item);
49
50
  });
50
51
  }
@@ -40,11 +40,11 @@ export async function listFromCatalogueAction(itemId) {
40
40
  storeId = store.id;
41
41
  }
42
42
  const product = await createProductFromCatalogueItem(item, storeId);
43
- await catalogueRepository.update(itemId, {
43
+ await catalogueRepository.setListingStatus(itemId, {
44
44
  listingStatus: "listed",
45
45
  linkedProductId: product.id,
46
46
  linkedProductSlug: product.slug ?? product.id,
47
- });
47
+ }, { actor: { role: "seller", uid: user.uid }, trigger: "listFromCatalogue" }, item);
48
48
  return { productId: product.id, productSlug: product.slug ?? product.id };
49
49
  });
50
50
  }
@@ -22,9 +22,6 @@ export async function submitCatalogueItemForApprovalAction(itemId) {
22
22
  throw new ValidationError("Only items not yet submitted can be requested for listing");
23
23
  }
24
24
  assertCatalogueImagesFresh(item);
25
- await catalogueRepository.update(itemId, {
26
- listingStatus: "pending_admin_approval",
27
- submittedForApprovalAt: new Date(),
28
- });
25
+ await catalogueRepository.setListingStatus(itemId, { listingStatus: "pending_admin_approval", submittedForApprovalAt: new Date() }, { actor: { role: "buyer", uid: user.uid }, trigger: "submitCatalogueForApproval" }, item);
29
26
  });
30
27
  }
@@ -10,6 +10,7 @@ import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
10
10
  import { apiClient } from "../../../http";
11
11
  import { toRecordArray, toRelativeDate, toStringValue, } from "../../admin/hooks/useAdminListingData";
12
12
  import { DataListingView } from "../../admin/components/DataListingView";
13
+ import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
13
14
  /** The submitted item's photos — the whole point of a visual approval. */
14
15
  function toPhotoEntries(raw) {
15
16
  const images = Array.isArray(raw.images) ? raw.images : [];
@@ -71,7 +72,10 @@ export function AdminCatalogueApprovalsView() {
71
72
  { label: "Quantity", value: String(raw.quantity ?? 1) },
72
73
  { label: "Condition", value: toStringValue(raw.condition, "—") },
73
74
  { label: "Submitted", value: selected?.updatedAt ?? "—" },
74
- ], items: { heading: "Photos", entries: toPhotoEntries(raw) }, footer: selected ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "danger", onClick: () => {
75
+ ], items: { heading: "Photos", entries: toPhotoEntries(raw) }, extra:
76
+ // "Why was mine rejected and by whom" — `rejectionReason` alone is
77
+ // overwritten by the next decision.
78
+ _jsx(RecordStatusTimeline, { entries: raw.statusHistory, truncatedCount: raw.statusHistoryTruncated }), footer: selected ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "danger", onClick: () => {
75
79
  setRejectTarget(selected.id);
76
80
  setSelected(null);
77
81
  }, children: "Reject" }), _jsx(Button, { isLoading: approveMutation.isPending, onClick: () => {
@@ -3,9 +3,32 @@
3
3
  */
4
4
  import { BaseRepository, parseSieveDateValue, type SieveModel, type FirebaseSieveResult } from "../../../providers/db-firebase";
5
5
  import { type CatalogueItemDocument, type CatalogueItemCreateInput, type CatalogueItemUpdateInput } from "../schemas/firestore";
6
+ import { type HistoryActor } from "../../../_internal/shared/history/index";
7
+ /** Who/why for a catalogue write that lands in `statusHistory`. */
8
+ export interface CatalogueWriteContext {
9
+ actor?: HistoryActor;
10
+ trigger?: string;
11
+ reason?: string;
12
+ note?: string;
13
+ }
6
14
  export declare class CatalogueRepository extends BaseRepository<CatalogueItemDocument> {
7
15
  constructor();
8
16
  create(input: CatalogueItemCreateInput): Promise<CatalogueItemDocument>;
17
+ /**
18
+ * The approval-workflow write path — the funnel this repository did not have.
19
+ *
20
+ * `listingStatus` was moved by FIVE scattered `update()` calls across four
21
+ * action files and a scheduled job, none of which recorded who did it or
22
+ * why. `rejectionReason` was the only trace an admin decision left, and the
23
+ * next decision overwrites it.
24
+ *
25
+ * Every caller already holds the item (each does its own `findById` to
26
+ * authorise the transition), so `prior` makes the timeline entry free.
27
+ * Deliberately NOT a `BaseRepository` hook: `update()` does not read before
28
+ * writing, so a generic hook would force a pre-read on every update of
29
+ * every collection (Rule #6), and it carries neither actor nor trigger.
30
+ */
31
+ setListingStatus(itemId: string, patch: CatalogueItemUpdateInput, ctx?: CatalogueWriteContext, prior?: CatalogueItemDocument | null): Promise<CatalogueItemDocument>;
9
32
  /** Stamps lastImageUpdateAt whenever `images` changes — the single field the freshness gate checks. */
10
33
  update(itemId: string, input: CatalogueItemUpdateInput): Promise<CatalogueItemDocument>;
11
34
  listPublicByOwner(ownerId: string): Promise<CatalogueItemDocument[]>;
@@ -2,7 +2,8 @@
2
2
  * Personal Catalogue Repository (Feature B).
3
3
  */
4
4
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
5
- import { CATALOGUE_COLLECTION, CATALOGUE_FIELDS, createCatalogueItemId, } from "../schemas/firestore";
5
+ import { CATALOGUE_COLLECTION, CATALOGUE_FIELDS, CATALOGUE_TRACKED_FIELDS, CATALOGUE_HISTORY_PII_FIELDS, createCatalogueItemId, } from "../schemas/firestore";
6
+ import { withHistory } from "../../../_internal/shared/history/index";
6
7
  export class CatalogueRepository extends BaseRepository {
7
8
  constructor() {
8
9
  super(CATALOGUE_COLLECTION);
@@ -20,6 +21,32 @@ export class CatalogueRepository extends BaseRepository {
20
21
  await this.getCollection().doc(id).set(prepareForFirestore(data));
21
22
  return { id, ...data };
22
23
  }
24
+ /**
25
+ * The approval-workflow write path — the funnel this repository did not have.
26
+ *
27
+ * `listingStatus` was moved by FIVE scattered `update()` calls across four
28
+ * action files and a scheduled job, none of which recorded who did it or
29
+ * why. `rejectionReason` was the only trace an admin decision left, and the
30
+ * next decision overwrites it.
31
+ *
32
+ * Every caller already holds the item (each does its own `findById` to
33
+ * authorise the transition), so `prior` makes the timeline entry free.
34
+ * Deliberately NOT a `BaseRepository` hook: `update()` does not read before
35
+ * writing, so a generic hook would force a pre-read on every update of
36
+ * every collection (Rule #6), and it carries neither actor nor trigger.
37
+ */
38
+ async setListingStatus(itemId, patch, ctx, prior) {
39
+ const current = prior !== undefined ? prior : await this.findById(itemId);
40
+ const withEntry = withHistory(current, patch, {
41
+ tracked: CATALOGUE_TRACKED_FIELDS,
42
+ actor: ctx?.actor ?? { role: "system" },
43
+ trigger: ctx?.trigger ?? "setListingStatus",
44
+ reason: ctx?.reason,
45
+ note: ctx?.note,
46
+ piiFields: CATALOGUE_HISTORY_PII_FIELDS,
47
+ });
48
+ return this.update(itemId, withEntry ?? patch);
49
+ }
23
50
  /** Stamps lastImageUpdateAt whenever `images` changes — the single field the freshness gate checks. */
24
51
  async update(itemId, input) {
25
52
  const patch = { ...input };
@@ -7,6 +7,7 @@
7
7
  * `mycatalog-` to stay visually distinct from `catalog-`.
8
8
  */
9
9
  import { type GenerateCatalogueItemIdInput } from "../../../utils/id-generators";
10
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
10
11
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
11
12
  import type { ProductDraftFields } from "../../shipments/schemas/firestore";
12
13
  export declare const CATALOGUE_COLLECTION: "catalogueItems";
@@ -32,6 +33,17 @@ export interface CatalogueItemDocument extends BaseDocument, Partial<Omit<Produc
32
33
  rejectionReason?: string;
33
34
  linkedProductId?: string;
34
35
  linkedProductSlug?: string;
36
+ /**
37
+ * Who changed what, when, and why. See § "Status History" in CLAUDE.md.
38
+ *
39
+ * A catalogue item's status is a small approval workflow between an owner
40
+ * and an admin (`not_listed → pending_admin_approval → listed | rejected`),
41
+ * so "why was mine rejected and by whom" is exactly the question the
42
+ * timeline exists for. `rejectionReason` alone is overwritten by the next
43
+ * decision.
44
+ */
45
+ statusHistory?: StatusChangeEntry[];
46
+ statusHistoryTruncated?: number;
35
47
  }
36
48
  export declare const CATALOGUE_IMAGE_FRESHNESS_DAYS: 30;
37
49
  export declare const CATALOGUE_FIELDS: {
@@ -45,3 +57,10 @@ export declare const CATALOGUE_FIELDS: {
45
57
  export type CatalogueItemCreateInput = Omit<CatalogueItemDocument, "id" | "createdAt" | "updatedAt" | "lastImageUpdateAt" | "listingStatus">;
46
58
  export type CatalogueItemUpdateInput = Partial<Omit<CatalogueItemDocument, "id" | "createdAt" | "ownerId">>;
47
59
  export declare function createCatalogueItemId(input: GenerateCatalogueItemIdInput): string;
60
+ /** The fields whose changes earn a timeline entry. */
61
+ export declare const CATALOGUE_TRACKED_FIELDS: readonly ["listingStatus", "rejectionReason", "linkedProductId", "submittedForApprovalAt"];
62
+ /**
63
+ * PII on this document, for `withHistory`'s scrub. `ownerId` is a UID, not
64
+ * PII, and is what the timeline's actor already carries.
65
+ */
66
+ export declare const CATALOGUE_HISTORY_PII_FIELDS: readonly ["ownerName", "ownerEmail"];
@@ -20,3 +20,15 @@ export const CATALOGUE_FIELDS = {
20
20
  export function createCatalogueItemId(input) {
21
21
  return generateCatalogueItemId(input);
22
22
  }
23
+ /** The fields whose changes earn a timeline entry. */
24
+ export const CATALOGUE_TRACKED_FIELDS = [
25
+ "listingStatus",
26
+ "rejectionReason",
27
+ "linkedProductId",
28
+ "submittedForApprovalAt",
29
+ ];
30
+ /**
31
+ * PII on this document, for `withHistory`'s scrub. `ownerId` is a UID, not
32
+ * PII, and is what the timeline's actor already carries.
33
+ */
34
+ export const CATALOGUE_HISTORY_PII_FIELDS = ["ownerName", "ownerEmail"];
@@ -452,7 +452,7 @@ export declare const categoryFirestoreSchema: z.ZodObject<{
452
452
  }, z.ZodTypeAny, "passthrough"> | undefined;
453
453
  featuredPriority?: number | undefined;
454
454
  isBrand?: boolean | undefined;
455
- createdByType?: "store" | "admin" | undefined;
455
+ createdByType?: "admin" | "store" | undefined;
456
456
  createdByStoreId?: string | undefined;
457
457
  itemCode?: string | undefined;
458
458
  brandWebsite?: string | undefined;
@@ -551,7 +551,7 @@ export declare const categoryFirestoreSchema: z.ZodObject<{
551
551
  }, z.ZodTypeAny, "passthrough"> | undefined;
552
552
  featuredPriority?: number | undefined;
553
553
  isBrand?: boolean | undefined;
554
- createdByType?: "store" | "admin" | undefined;
554
+ createdByType?: "admin" | "store" | undefined;
555
555
  createdByStoreId?: string | undefined;
556
556
  itemCode?: string | undefined;
557
557
  brandWebsite?: string | undefined;
@@ -21,7 +21,7 @@ export declare const conversationMessageSchema: z.ZodObject<{
21
21
  body: string;
22
22
  isRead: boolean;
23
23
  senderId: string;
24
- senderRole: "seller" | "buyer";
24
+ senderRole: "buyer" | "seller";
25
25
  sentAt: string | Date | z.objectOutputType<{
26
26
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
27
27
  }, z.ZodTypeAny, "passthrough">;
@@ -31,7 +31,7 @@ export declare const conversationMessageSchema: z.ZodObject<{
31
31
  body: string;
32
32
  isRead: boolean;
33
33
  senderId: string;
34
- senderRole: "seller" | "buyer";
34
+ senderRole: "buyer" | "seller";
35
35
  sentAt: string | Date | z.objectInputType<{
36
36
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
37
37
  }, z.ZodTypeAny, "passthrough">;
@@ -79,7 +79,7 @@ export declare const conversationFirestoreSchema: z.ZodObject<{
79
79
  body: string;
80
80
  isRead: boolean;
81
81
  senderId: string;
82
- senderRole: "seller" | "buyer";
82
+ senderRole: "buyer" | "seller";
83
83
  sentAt: string | Date | z.objectOutputType<{
84
84
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
85
85
  }, z.ZodTypeAny, "passthrough">;
@@ -89,7 +89,7 @@ export declare const conversationFirestoreSchema: z.ZodObject<{
89
89
  body: string;
90
90
  isRead: boolean;
91
91
  senderId: string;
92
- senderRole: "seller" | "buyer";
92
+ senderRole: "buyer" | "seller";
93
93
  sentAt: string | Date | z.objectInputType<{
94
94
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
95
95
  }, z.ZodTypeAny, "passthrough">;
@@ -127,7 +127,7 @@ export declare const conversationFirestoreSchema: z.ZodObject<{
127
127
  body: string;
128
128
  isRead: boolean;
129
129
  senderId: string;
130
- senderRole: "seller" | "buyer";
130
+ senderRole: "buyer" | "seller";
131
131
  sentAt: string | Date | z.objectOutputType<{
132
132
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
133
133
  }, z.ZodTypeAny, "passthrough">;
@@ -160,7 +160,7 @@ export declare const conversationFirestoreSchema: z.ZodObject<{
160
160
  body: string;
161
161
  isRead: boolean;
162
162
  senderId: string;
163
- senderRole: "seller" | "buyer";
163
+ senderRole: "buyer" | "seller";
164
164
  sentAt: string | Date | z.objectInputType<{
165
165
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
166
166
  }, z.ZodTypeAny, "passthrough">;
@@ -128,9 +128,9 @@ export declare const orderRefundEventSchema: z.ZodObject<{
128
128
  overrideReason: z.ZodOptional<z.ZodString>;
129
129
  }, "strip", z.ZodTypeAny, {
130
130
  type: "full" | "partial";
131
+ reason: string;
131
132
  amount: number;
132
133
  refundId: string;
133
- reason: string;
134
134
  refundedAt: string | Date | z.objectOutputType<{
135
135
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
136
136
  }, z.ZodTypeAny, "passthrough">;
@@ -143,9 +143,9 @@ export declare const orderRefundEventSchema: z.ZodObject<{
143
143
  overrideReason?: string | undefined;
144
144
  }, {
145
145
  type: "full" | "partial";
146
+ reason: string;
146
147
  amount: number;
147
148
  refundId: string;
148
- reason: string;
149
149
  refundedAt: string | Date | z.objectInputType<{
150
150
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
151
151
  }, z.ZodTypeAny, "passthrough">;
@@ -168,14 +168,14 @@ export declare const appliedOrderDiscountSchema: z.ZodObject<{
168
168
  code: string;
169
169
  type: "auto" | "coupon" | "deal";
170
170
  discountAmount: number;
171
- scope?: "admin" | "seller" | undefined;
171
+ scope?: "seller" | "admin" | undefined;
172
172
  storeId?: string | undefined;
173
173
  couponId?: string | undefined;
174
174
  }, {
175
175
  code: string;
176
176
  type: "auto" | "coupon" | "deal";
177
177
  discountAmount: number;
178
- scope?: "admin" | "seller" | undefined;
178
+ scope?: "seller" | "admin" | undefined;
179
179
  storeId?: string | undefined;
180
180
  couponId?: string | undefined;
181
181
  }>;
@@ -326,14 +326,14 @@ export declare const orderFirestoreSchema: z.ZodObject<{
326
326
  code: string;
327
327
  type: "auto" | "coupon" | "deal";
328
328
  discountAmount: number;
329
- scope?: "admin" | "seller" | undefined;
329
+ scope?: "seller" | "admin" | undefined;
330
330
  storeId?: string | undefined;
331
331
  couponId?: string | undefined;
332
332
  }, {
333
333
  code: string;
334
334
  type: "auto" | "coupon" | "deal";
335
335
  discountAmount: number;
336
- scope?: "admin" | "seller" | undefined;
336
+ scope?: "seller" | "admin" | undefined;
337
337
  storeId?: string | undefined;
338
338
  couponId?: string | undefined;
339
339
  }>, "many">>;
@@ -464,9 +464,9 @@ export declare const orderFirestoreSchema: z.ZodObject<{
464
464
  overrideReason: z.ZodOptional<z.ZodString>;
465
465
  }, "strip", z.ZodTypeAny, {
466
466
  type: "full" | "partial";
467
+ reason: string;
467
468
  amount: number;
468
469
  refundId: string;
469
- reason: string;
470
470
  refundedAt: string | Date | z.objectOutputType<{
471
471
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
472
472
  }, z.ZodTypeAny, "passthrough">;
@@ -479,9 +479,9 @@ export declare const orderFirestoreSchema: z.ZodObject<{
479
479
  overrideReason?: string | undefined;
480
480
  }, {
481
481
  type: "full" | "partial";
482
+ reason: string;
482
483
  amount: number;
483
484
  refundId: string;
484
- reason: string;
485
485
  refundedAt: string | Date | z.objectInputType<{
486
486
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
487
487
  }, z.ZodTypeAny, "passthrough">;
@@ -622,7 +622,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
622
622
  code: string;
623
623
  type: "auto" | "coupon" | "deal";
624
624
  discountAmount: number;
625
- scope?: "admin" | "seller" | undefined;
625
+ scope?: "seller" | "admin" | undefined;
626
626
  storeId?: string | undefined;
627
627
  couponId?: string | undefined;
628
628
  }[] | undefined;
@@ -654,9 +654,9 @@ export declare const orderFirestoreSchema: z.ZodObject<{
654
654
  paymentBatchId?: string | undefined;
655
655
  refunds?: {
656
656
  type: "full" | "partial";
657
+ reason: string;
657
658
  amount: number;
658
659
  refundId: string;
659
- reason: string;
660
660
  refundedAt: string | Date | z.objectOutputType<{
661
661
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
662
662
  }, z.ZodTypeAny, "passthrough">;
@@ -770,7 +770,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
770
770
  code: string;
771
771
  type: "auto" | "coupon" | "deal";
772
772
  discountAmount: number;
773
- scope?: "admin" | "seller" | undefined;
773
+ scope?: "seller" | "admin" | undefined;
774
774
  storeId?: string | undefined;
775
775
  couponId?: string | undefined;
776
776
  }[] | undefined;
@@ -802,9 +802,9 @@ export declare const orderFirestoreSchema: z.ZodObject<{
802
802
  paymentBatchId?: string | undefined;
803
803
  refunds?: {
804
804
  type: "full" | "partial";
805
+ reason: string;
805
806
  amount: number;
806
807
  refundId: string;
807
- reason: string;
808
808
  refundedAt: string | Date | z.objectInputType<{
809
809
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
810
810
  }, z.ZodTypeAny, "passthrough">;