@mohasinac/appkit 4.21.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 (102) 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 +7 -0
  12. package/dist/client.js +4 -0
  13. package/dist/constants/field-names.d.ts +12 -0
  14. package/dist/constants/field-names.js +12 -0
  15. package/dist/features/account/components/AddressForm.js +45 -29
  16. package/dist/features/admin/actions/admin-actions.js +13 -3
  17. package/dist/features/admin/components/AdminBidsView.js +7 -1
  18. package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
  19. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  20. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  21. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  22. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  23. package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
  24. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  25. package/dist/features/auctions/repository/bid.repository.js +57 -33
  26. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  27. package/dist/features/auctions/schemas/firestore.js +21 -0
  28. package/dist/features/auth/schemas/index.d.ts +2 -2
  29. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  30. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  31. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  32. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  33. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
  34. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  35. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  36. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  37. package/dist/features/catalogue/schemas/firestore.js +12 -0
  38. package/dist/features/categories/schemas/index.d.ts +2 -2
  39. package/dist/features/layout/BottomActions.js +18 -1
  40. package/dist/features/layout/BottomActionsContext.d.ts +19 -0
  41. package/dist/features/layout/BottomActionsContext.js +37 -0
  42. package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
  43. package/dist/features/layout/hooks/useBottomActions.js +48 -11
  44. package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
  45. package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
  46. package/dist/features/layout/index.d.ts +2 -0
  47. package/dist/features/layout/index.js +1 -0
  48. package/dist/features/messages/schemas/index.d.ts +6 -6
  49. package/dist/features/orders/schemas/index.d.ts +12 -12
  50. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  51. package/dist/features/payments/repository/payout.repository.js +45 -19
  52. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  53. package/dist/features/payments/schemas/firestore.js +39 -0
  54. package/dist/features/payments/schemas/index.d.ts +12 -12
  55. package/dist/features/products/components/MakeOfferButton.js +5 -3
  56. package/dist/features/products/schemas/index.d.ts +2 -2
  57. package/dist/features/promotions/schemas/index.d.ts +12 -12
  58. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  59. package/dist/features/scams/repository/scammer.repository.js +24 -2
  60. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  61. package/dist/features/scams/schemas/firestore.js +30 -0
  62. package/dist/features/shell/QuickFormDrawer.js +15 -1
  63. package/dist/features/shell/SectionForm.d.ts +16 -2
  64. package/dist/features/shell/SectionForm.js +28 -2
  65. package/dist/features/shipments/components/AdminShipmentEditorView.js +5 -2
  66. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  67. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  68. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  69. package/dist/features/shipments/schemas/firestore.js +19 -0
  70. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  71. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  72. package/dist/features/status-history/index.d.ts +2 -0
  73. package/dist/features/status-history/index.js +1 -0
  74. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  75. package/dist/features/stores/repository/store.repository.js +43 -4
  76. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  77. package/dist/features/stores/schemas/firestore.js +31 -0
  78. package/dist/features/support/repository/support.repository.d.ts +47 -3
  79. package/dist/features/support/repository/support.repository.js +72 -12
  80. package/dist/features/support/schemas/firestore.d.ts +38 -1
  81. package/dist/features/support/schemas/firestore.js +34 -0
  82. package/dist/features/support/schemas/index.d.ts +2 -2
  83. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +90 -0
  84. package/dist/index.d.ts +7 -1
  85. package/dist/index.js +16 -1
  86. package/dist/schemas/registry.d.ts +32 -32
  87. package/dist/seed/bids-seed-data.js +8 -0
  88. package/dist/ui/components/Form.d.ts +22 -1
  89. package/dist/ui/components/Form.js +30 -2
  90. package/dist/ui/components/Modal.js +4 -3
  91. package/dist/ui/components/SideDrawer.js +5 -4
  92. package/dist/ui/components/overlay-context.d.ts +21 -0
  93. package/dist/ui/components/overlay-context.js +25 -0
  94. package/dist/ui/forms/FormErrorList.d.ts +29 -0
  95. package/dist/ui/forms/FormErrorList.js +36 -0
  96. package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
  97. package/dist/ui/forms/FormErrorSummary.js +15 -8
  98. package/dist/ui/forms/FormShell.d.ts +25 -1
  99. package/dist/ui/forms/FormShell.js +39 -4
  100. package/dist/ui/forms/index.d.ts +2 -0
  101. package/dist/ui/forms/index.js +1 -0
  102. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
6
6
  import { Button, Div, FormActions, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ADMIN_ENDPOINTS, SUPPORT_ENDPOINTS } from "../../../constants/api-endpoints";
9
+ import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
9
10
  const __P = {
10
11
  p3: "p-[var(--appkit-space-3)]",
11
12
  p4: "p-[var(--appkit-space-4)]",
@@ -38,7 +39,7 @@ const ROLE_LABEL = {
38
39
  support: "Support",
39
40
  admin: "Admin",
40
41
  };
41
- export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages = [], internalNotes, orderId, relatedParties, }) {
42
+ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages = [], internalNotes, orderId, relatedParties, statusHistory, statusHistoryTruncated, }) {
42
43
  const queryClient = useQueryClient();
43
44
  const { showToast } = useToast();
44
45
  const [status, setStatus] = React.useState(currentStatus ?? "open");
@@ -259,7 +260,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
259
260
  showToast(err?.message ?? "Failed to update order items.", "error");
260
261
  },
261
262
  });
262
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsx(Select, { label: "Priority", options: PRIORITY_OPTIONS, value: priority, onValueChange: setPriority }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Internal notes (staff only)" }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), rows: 3, placeholder: "Notes visible only to admins and employees\u2026" })] }), _jsxs(Stack, { padding: "sm", gap: "sm", rounded: "lg", border: "default", children: [_jsx(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Linked parties" }), _jsx(Text, { size: "xs", color: "muted", children: "Tag the buyer / store / order / product / bid this ticket concerns." }), [
263
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsx(Select, { label: "Priority", options: PRIORITY_OPTIONS, value: priority, onValueChange: setPriority }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Internal notes (staff only)" }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), rows: 3, placeholder: "Notes visible only to admins and employees\u2026" })] }), _jsxs(Stack, { padding: "sm", gap: "sm", rounded: "lg", border: "default", children: [_jsx(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Linked parties" }), _jsx(Text, { size: "xs", color: "muted", children: "Tag the buyer / store / order / product / bid this ticket concerns." }), [
263
264
  ["userId", "User slug"],
264
265
  ["storeId", "Store slug"],
265
266
  ["orderId", "Order ID"],
@@ -183,7 +183,7 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
183
183
  return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Current bid" : "Starting bid" }), _jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Starting bid" : "Minimum bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(hasBids ? startingBid : minBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { schema: schema, onSubmit: (e) => {
184
184
  // handler wired through render-prop below — keeps setFieldError in scope
185
185
  e.preventDefault();
186
- }, children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "sm", children: [_jsx(FormErrorSummary, {}), _jsxs(Row, { gap: "xs", wrap: true, role: "radiogroup", "aria-label": "Bid amount preset", children: [BID_PRESET_MULTIPLIERS.map((n) => {
186
+ }, children: ({ setFieldError, clearErrors, markSubmitAttempted }) => (_jsxs(Stack, { gap: "sm", children: [_jsx(FormErrorSummary, {}), _jsxs(Row, { gap: "xs", wrap: true, role: "radiogroup", "aria-label": "Bid amount preset", children: [BID_PRESET_MULTIPLIERS.map((n) => {
187
187
  const amount = presetAmount(n);
188
188
  const delta = amount - currentBid;
189
189
  // `delta` is 0 only for the opening preset on a bid-free
@@ -204,6 +204,8 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
204
204
  : hasBids
205
205
  ? `Minimum next bid ${formatCurrency(minBid, currency)} (current bid + ${formatCurrency(minBidIncrement, currency)} increment).`
206
206
  : `Minimum opening bid ${formatCurrency(minBid, currency)} — the seller's starting bid. The ${formatCurrency(minBidIncrement, currency)} increment applies from the second bid on.` }), success && (_jsx(Text, { className: "text-success", size: "xs", children: "\u2713 Bid placed successfully!" })), !isEnded && auctionEndDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: auctionEndDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded || isPending, isLoading: isPending, type: "button", onClick: () => {
207
+ // type="button" — no native submit, so unhide the summary here.
208
+ markSubmitAttempted();
207
209
  clearErrors();
208
210
  const parsed = schema.safeParse({ bidAmount });
209
211
  if (!parsed.success)
@@ -7,6 +7,14 @@ import { BaseRepository, parseSieveDateValue } from "../../../providers/db-fireb
7
7
  import type { DocumentReference, WriteBatch } from "firebase-admin/firestore";
8
8
  import type { SieveModel, FirebaseSieveResult } from "../../../providers/db-firebase";
9
9
  import type { BidDocument, BidCreateInput, BidUpdateInput, BidAdminUpdateInput, BidStatus } from "../schemas";
10
+ import { type HistoryActor } from "../../../_internal/shared/history/index";
11
+ /** Who/why for a bid write that lands in `statusHistory`. */
12
+ export interface BidWriteContext {
13
+ actor?: HistoryActor;
14
+ trigger?: string;
15
+ reason?: string;
16
+ note?: string;
17
+ }
10
18
  export declare class BidRepository extends BaseRepository<BidDocument> {
11
19
  constructor();
12
20
  /** Override mapDoc to auto-decrypt PII on every bid read */
@@ -49,7 +57,7 @@ export declare class BidRepository extends BaseRepository<BidDocument> {
49
57
  ref: DocumentReference;
50
58
  data: BidDocument;
51
59
  } | null>;
52
- markWon(batch: WriteBatch, ref: DocumentReference): void;
60
+ markWon(batch: WriteBatch, ref: DocumentReference, ctx?: BidWriteContext, prior?: BidDocument | null): void;
53
61
  /**
54
62
  * Link a won bid to the order it eventually became.
55
63
  *
@@ -58,29 +66,34 @@ export declare class BidRepository extends BaseRepository<BidDocument> {
58
66
  * /user/bids could not link anywhere, and nothing could tell a settled win
59
67
  * from an unpaid one.
60
68
  */
61
- attachOrder(bidId: string, orderId: string): Promise<void>;
69
+ attachOrder(bidId: string, orderId: string, ctx?: BidWriteContext, prior?: BidDocument | null): Promise<void>;
62
70
  /**
63
71
  * The winner never paid within their checkout window. Distinct from "lost"
64
72
  * (outbid) — this bidder won and then defaulted, which a seller may want to
65
73
  * act on.
66
74
  */
67
- markForfeited(bidId: string): Promise<void>;
68
- markLost(batch: WriteBatch, ref: DocumentReference): void;
75
+ markForfeited(bidId: string, ctx?: BidWriteContext, prior?: BidDocument | null): Promise<void>;
76
+ markLost(batch: WriteBatch, ref: DocumentReference, ctx?: BidWriteContext, prior?: BidDocument | null): void;
69
77
  /**
70
78
  * Batched "everyone else lost", for callers that are not already inside a
71
79
  * batch of their own — a Buy Now purchase ends the auction outside the
72
80
  * settlement job, and the losing bids can be any number, so this writes them
73
81
  * in Firestore-batch chunks rather than one round-trip per bid.
74
82
  */
75
- markManyLost(refs: readonly DocumentReference[]): Promise<void>;
83
+ markManyLost(entries: readonly (DocumentReference | {
84
+ ref: DocumentReference;
85
+ data?: BidDocument;
86
+ })[], ctx?: BidWriteContext): Promise<void>;
76
87
  /**
77
88
  * A pending Buy Now claim that never became a sale. Not `forfeited` — that
78
89
  * status means "won, then defaulted", and a lapsed claim never won anything;
79
90
  * the auction it was placed against carried on untouched.
80
91
  */
81
- markCancelled(bidId: string): Promise<void>;
82
- markOutbid(batch: WriteBatch, ref: DocumentReference): void;
83
- markWinning(batch: WriteBatch, ref: DocumentReference): void;
92
+ markCancelled(bidId: string, ctx?: BidWriteContext, prior?: BidDocument | null): Promise<void>;
93
+ markOutbid(batch: WriteBatch, ref: DocumentReference, ctx?: BidWriteContext, prior?: BidDocument | null): void;
94
+ markWinning(batch: WriteBatch, ref: DocumentReference, ctx?: BidWriteContext, prior?: BidDocument | null): void;
95
+ /** Append a timeline entry when the patch touches a tracked field. */
96
+ private withBidHistory;
84
97
  /**
85
98
  * Find current winning bid for a product
86
99
  */
@@ -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: () => {
@@ -62,7 +62,9 @@ export function CatalogueItemEditorView({ item, onSaved }) {
62
62
  onSaved?.(saved.id);
63
63
  },
64
64
  });
65
- return (_jsx(Form, { schema: createCatalogueItemSchema, spacing: "md", children: ({ setFieldError }) => (_jsxs(Stack, { gap: "md", children: [item?.listingStatus && item.listingStatus !== "not_listed" && (_jsxs(Alert, { variant: "info", children: ["This item is ", item.listingStatus.replace(/_/g, " "), item.listingStatus === "rejected" && item.rejectionReason ? `: ${item.rejectionReason}` : "."] })), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", required: true, value: title, onChange: setTitle }), _jsx(FieldSelect, { name: "condition", label: "Condition", value: condition, onChange: setCondition, options: CONDITION_OPTIONS }), _jsx(FieldInput, { name: "price", label: "Estimated resale price (\u20B9)", type: "number", min: "0", value: price, onChange: setPrice }), _jsx(FieldInput, { name: "quantity", label: "Quantity", type: "number", min: "1", value: quantity, onChange: setQuantity })] }), _jsx(FieldTextarea, { name: "description", label: "Description", value: description, onChange: setDescription, rows: 3 }), _jsx(MediaUploadList, { label: "Photos", value: galleryFields, onChange: (fields) => setImages(fields.map((f) => f.url)), onUpload: handleUpload, accept: "image/*", maxItems: 8, helperText: "Photos older than 30 days must be refreshed before this item can be listed." }), _jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Toggle, { checked: visibility === "public", onChange: (checked) => setVisibility(checked ? "public" : "private"), size: "sm" }), _jsx(Span, { children: visibility === "public" ? "Public — visible on your profile" : "Private — only you can see it" })] }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", isLoading: saveMutation.isPending, onClick: () => {
65
+ return (_jsx(Form, { schema: createCatalogueItemSchema, spacing: "md", children: ({ setFieldError, markSubmitAttempted }) => (_jsxs(Stack, { gap: "md", children: [item?.listingStatus && item.listingStatus !== "not_listed" && (_jsxs(Alert, { variant: "info", children: ["This item is ", item.listingStatus.replace(/_/g, " "), item.listingStatus === "rejected" && item.rejectionReason ? `: ${item.rejectionReason}` : "."] })), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", required: true, value: title, onChange: setTitle }), _jsx(FieldSelect, { name: "condition", label: "Condition", value: condition, onChange: setCondition, options: CONDITION_OPTIONS }), _jsx(FieldInput, { name: "price", label: "Estimated resale price (\u20B9)", type: "number", min: "0", value: price, onChange: setPrice }), _jsx(FieldInput, { name: "quantity", label: "Quantity", type: "number", min: "1", value: quantity, onChange: setQuantity })] }), _jsx(FieldTextarea, { name: "description", label: "Description", value: description, onChange: setDescription, rows: 3 }), _jsx(MediaUploadList, { label: "Photos", value: galleryFields, onChange: (fields) => setImages(fields.map((f) => f.url)), onUpload: handleUpload, accept: "image/*", maxItems: 8, helperText: "Photos older than 30 days must be refreshed before this item can be listed." }), _jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Toggle, { checked: visibility === "public", onChange: (checked) => setVisibility(checked ? "public" : "private"), size: "sm" }), _jsx(Span, { children: visibility === "public" ? "Public — visible on your profile" : "Private — only you can see it" })] }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", isLoading: saveMutation.isPending, onClick: () => {
66
+ // type="button" — no native submit, so unhide the summary here.
67
+ markSubmitAttempted();
66
68
  const parsed = createCatalogueItemSchema.safeParse({
67
69
  title,
68
70
  images,
@@ -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;