@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.
- package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +5 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
- package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
- package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
- package/dist/_internal/shared/features/products/schema.d.ts +8 -8
- package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
- package/dist/_internal/shared/history/index.d.ts +1 -1
- package/dist/_internal/shared/history/types.d.ts +16 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +4 -0
- package/dist/constants/field-names.d.ts +12 -0
- package/dist/constants/field-names.js +12 -0
- package/dist/features/account/components/AddressForm.js +45 -29
- package/dist/features/admin/actions/admin-actions.js +13 -3
- package/dist/features/admin/components/AdminBidsView.js +7 -1
- package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
- package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
- package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
- package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
- package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
- package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
- package/dist/features/auctions/repository/bid.repository.js +57 -33
- package/dist/features/auctions/schemas/firestore.d.ts +27 -0
- package/dist/features/auctions/schemas/firestore.js +21 -0
- package/dist/features/auth/schemas/index.d.ts +2 -2
- package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
- package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
- package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
- package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
- package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
- package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
- package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
- package/dist/features/catalogue/schemas/firestore.js +12 -0
- package/dist/features/categories/schemas/index.d.ts +2 -2
- package/dist/features/layout/BottomActions.js +18 -1
- package/dist/features/layout/BottomActionsContext.d.ts +19 -0
- package/dist/features/layout/BottomActionsContext.js +37 -0
- package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
- package/dist/features/layout/hooks/useBottomActions.js +48 -11
- package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
- package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
- package/dist/features/layout/index.d.ts +2 -0
- package/dist/features/layout/index.js +1 -0
- package/dist/features/messages/schemas/index.d.ts +6 -6
- package/dist/features/orders/schemas/index.d.ts +12 -12
- package/dist/features/payments/repository/payout.repository.d.ts +18 -5
- package/dist/features/payments/repository/payout.repository.js +45 -19
- package/dist/features/payments/schemas/firestore.d.ts +33 -1
- package/dist/features/payments/schemas/firestore.js +39 -0
- package/dist/features/payments/schemas/index.d.ts +12 -12
- package/dist/features/products/components/MakeOfferButton.js +5 -3
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/schemas/index.d.ts +12 -12
- package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
- package/dist/features/scams/repository/scammer.repository.js +24 -2
- package/dist/features/scams/schemas/firestore.d.ts +24 -0
- package/dist/features/scams/schemas/firestore.js +30 -0
- package/dist/features/shell/QuickFormDrawer.js +15 -1
- package/dist/features/shell/SectionForm.d.ts +16 -2
- package/dist/features/shell/SectionForm.js +28 -2
- package/dist/features/shipments/components/AdminShipmentEditorView.js +5 -2
- package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
- package/dist/features/shipments/repository/shipments.repository.js +23 -1
- package/dist/features/shipments/schemas/firestore.d.ts +14 -0
- package/dist/features/shipments/schemas/firestore.js +19 -0
- package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
- package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
- package/dist/features/status-history/index.d.ts +2 -0
- package/dist/features/status-history/index.js +1 -0
- package/dist/features/stores/repository/store.repository.d.ts +30 -2
- package/dist/features/stores/repository/store.repository.js +43 -4
- package/dist/features/stores/schemas/firestore.d.ts +29 -0
- package/dist/features/stores/schemas/firestore.js +31 -0
- package/dist/features/support/repository/support.repository.d.ts +47 -3
- package/dist/features/support/repository/support.repository.js +72 -12
- package/dist/features/support/schemas/firestore.d.ts +38 -1
- package/dist/features/support/schemas/firestore.js +34 -0
- package/dist/features/support/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +90 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +16 -1
- package/dist/schemas/registry.d.ts +32 -32
- package/dist/seed/bids-seed-data.js +8 -0
- package/dist/ui/components/Form.d.ts +22 -1
- package/dist/ui/components/Form.js +30 -2
- package/dist/ui/components/Modal.js +4 -3
- package/dist/ui/components/SideDrawer.js +5 -4
- package/dist/ui/components/overlay-context.d.ts +21 -0
- package/dist/ui/components/overlay-context.js +25 -0
- package/dist/ui/forms/FormErrorList.d.ts +29 -0
- package/dist/ui/forms/FormErrorList.js +36 -0
- package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
- package/dist/ui/forms/FormErrorSummary.js +15 -8
- package/dist/ui/forms/FormShell.d.ts +25 -1
- package/dist/ui/forms/FormShell.js +39 -4
- package/dist/ui/forms/index.d.ts +2 -0
- package/dist/ui/forms/index.js +1 -0
- 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(
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 <
|
|
167
|
+
for (let i = 0; i < entries.length; i += CHUNK) {
|
|
160
168
|
const batch = this.db.batch();
|
|
161
|
-
for (const
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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" | "
|
|
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" | "
|
|
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
|
-
|
|
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.
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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.
|
|
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) },
|
|
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?: "
|
|
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?: "
|
|
554
|
+
createdByType?: "admin" | "store" | undefined;
|
|
555
555
|
createdByStoreId?: string | undefined;
|
|
556
556
|
itemCode?: string | undefined;
|
|
557
557
|
brandWebsite?: string | undefined;
|