@mohasinac/appkit 4.22.0 → 4.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/field-names.d.ts +12 -0
- package/dist/constants/field-names.js +12 -0
- package/dist/features/admin/actions/admin-actions.js +13 -3
- package/dist/features/admin/components/AdminBidsView.js +7 -1
- 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/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/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/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/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/shipments/components/AdminShipmentEditorView.js +2 -1
- 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 +54 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +12 -1
- package/dist/schemas/registry.d.ts +32 -32
- package/dist/seed/bids-seed-data.js +8 -0
- package/package.json +1 -1
|
@@ -6,7 +6,8 @@ import { normalizeError } from "../../../errors/normalize";
|
|
|
6
6
|
*/
|
|
7
7
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
8
8
|
import { DatabaseError } from "../../../errors";
|
|
9
|
-
import { BID_COLLECTION, createBidId } from "../schemas";
|
|
9
|
+
import { BID_COLLECTION, BID_TRACKED_FIELDS, BID_HISTORY_PII_FIELDS, createBidId } from "../schemas";
|
|
10
|
+
import { withHistory } from "../../../_internal/shared/history/index";
|
|
10
11
|
import { encryptPiiFields, decryptPiiFields, BID_PII_FIELDS, } from "../../../security";
|
|
11
12
|
import { BID_FIELDS } from "../../../constants/field-names";
|
|
12
13
|
export class BidRepository extends BaseRepository {
|
|
@@ -115,12 +116,21 @@ export class BidRepository extends BaseRepository {
|
|
|
115
116
|
data: this.mapDoc(doc),
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
|
-
|
|
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: () => {
|
|
@@ -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;
|
|
@@ -21,7 +21,7 @@ export declare const conversationMessageSchema: z.ZodObject<{
|
|
|
21
21
|
body: string;
|
|
22
22
|
isRead: boolean;
|
|
23
23
|
senderId: string;
|
|
24
|
-
senderRole: "
|
|
24
|
+
senderRole: "buyer" | "seller";
|
|
25
25
|
sentAt: string | Date | z.objectOutputType<{
|
|
26
26
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
27
27
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -31,7 +31,7 @@ export declare const conversationMessageSchema: z.ZodObject<{
|
|
|
31
31
|
body: string;
|
|
32
32
|
isRead: boolean;
|
|
33
33
|
senderId: string;
|
|
34
|
-
senderRole: "
|
|
34
|
+
senderRole: "buyer" | "seller";
|
|
35
35
|
sentAt: string | Date | z.objectInputType<{
|
|
36
36
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
37
37
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -79,7 +79,7 @@ export declare const conversationFirestoreSchema: z.ZodObject<{
|
|
|
79
79
|
body: string;
|
|
80
80
|
isRead: boolean;
|
|
81
81
|
senderId: string;
|
|
82
|
-
senderRole: "
|
|
82
|
+
senderRole: "buyer" | "seller";
|
|
83
83
|
sentAt: string | Date | z.objectOutputType<{
|
|
84
84
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
85
85
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -89,7 +89,7 @@ export declare const conversationFirestoreSchema: z.ZodObject<{
|
|
|
89
89
|
body: string;
|
|
90
90
|
isRead: boolean;
|
|
91
91
|
senderId: string;
|
|
92
|
-
senderRole: "
|
|
92
|
+
senderRole: "buyer" | "seller";
|
|
93
93
|
sentAt: string | Date | z.objectInputType<{
|
|
94
94
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
95
95
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -127,7 +127,7 @@ export declare const conversationFirestoreSchema: z.ZodObject<{
|
|
|
127
127
|
body: string;
|
|
128
128
|
isRead: boolean;
|
|
129
129
|
senderId: string;
|
|
130
|
-
senderRole: "
|
|
130
|
+
senderRole: "buyer" | "seller";
|
|
131
131
|
sentAt: string | Date | z.objectOutputType<{
|
|
132
132
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
133
133
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -160,7 +160,7 @@ export declare const conversationFirestoreSchema: z.ZodObject<{
|
|
|
160
160
|
body: string;
|
|
161
161
|
isRead: boolean;
|
|
162
162
|
senderId: string;
|
|
163
|
-
senderRole: "
|
|
163
|
+
senderRole: "buyer" | "seller";
|
|
164
164
|
sentAt: string | Date | z.objectInputType<{
|
|
165
165
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
166
166
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -128,9 +128,9 @@ export declare const orderRefundEventSchema: z.ZodObject<{
|
|
|
128
128
|
overrideReason: z.ZodOptional<z.ZodString>;
|
|
129
129
|
}, "strip", z.ZodTypeAny, {
|
|
130
130
|
type: "full" | "partial";
|
|
131
|
+
reason: string;
|
|
131
132
|
amount: number;
|
|
132
133
|
refundId: string;
|
|
133
|
-
reason: string;
|
|
134
134
|
refundedAt: string | Date | z.objectOutputType<{
|
|
135
135
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
136
136
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -143,9 +143,9 @@ export declare const orderRefundEventSchema: z.ZodObject<{
|
|
|
143
143
|
overrideReason?: string | undefined;
|
|
144
144
|
}, {
|
|
145
145
|
type: "full" | "partial";
|
|
146
|
+
reason: string;
|
|
146
147
|
amount: number;
|
|
147
148
|
refundId: string;
|
|
148
|
-
reason: string;
|
|
149
149
|
refundedAt: string | Date | z.objectInputType<{
|
|
150
150
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
151
151
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -168,14 +168,14 @@ export declare const appliedOrderDiscountSchema: z.ZodObject<{
|
|
|
168
168
|
code: string;
|
|
169
169
|
type: "auto" | "coupon" | "deal";
|
|
170
170
|
discountAmount: number;
|
|
171
|
-
scope?: "
|
|
171
|
+
scope?: "seller" | "admin" | undefined;
|
|
172
172
|
storeId?: string | undefined;
|
|
173
173
|
couponId?: string | undefined;
|
|
174
174
|
}, {
|
|
175
175
|
code: string;
|
|
176
176
|
type: "auto" | "coupon" | "deal";
|
|
177
177
|
discountAmount: number;
|
|
178
|
-
scope?: "
|
|
178
|
+
scope?: "seller" | "admin" | undefined;
|
|
179
179
|
storeId?: string | undefined;
|
|
180
180
|
couponId?: string | undefined;
|
|
181
181
|
}>;
|
|
@@ -326,14 +326,14 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
326
326
|
code: string;
|
|
327
327
|
type: "auto" | "coupon" | "deal";
|
|
328
328
|
discountAmount: number;
|
|
329
|
-
scope?: "
|
|
329
|
+
scope?: "seller" | "admin" | undefined;
|
|
330
330
|
storeId?: string | undefined;
|
|
331
331
|
couponId?: string | undefined;
|
|
332
332
|
}, {
|
|
333
333
|
code: string;
|
|
334
334
|
type: "auto" | "coupon" | "deal";
|
|
335
335
|
discountAmount: number;
|
|
336
|
-
scope?: "
|
|
336
|
+
scope?: "seller" | "admin" | undefined;
|
|
337
337
|
storeId?: string | undefined;
|
|
338
338
|
couponId?: string | undefined;
|
|
339
339
|
}>, "many">>;
|
|
@@ -464,9 +464,9 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
464
464
|
overrideReason: z.ZodOptional<z.ZodString>;
|
|
465
465
|
}, "strip", z.ZodTypeAny, {
|
|
466
466
|
type: "full" | "partial";
|
|
467
|
+
reason: string;
|
|
467
468
|
amount: number;
|
|
468
469
|
refundId: string;
|
|
469
|
-
reason: string;
|
|
470
470
|
refundedAt: string | Date | z.objectOutputType<{
|
|
471
471
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
472
472
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -479,9 +479,9 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
479
479
|
overrideReason?: string | undefined;
|
|
480
480
|
}, {
|
|
481
481
|
type: "full" | "partial";
|
|
482
|
+
reason: string;
|
|
482
483
|
amount: number;
|
|
483
484
|
refundId: string;
|
|
484
|
-
reason: string;
|
|
485
485
|
refundedAt: string | Date | z.objectInputType<{
|
|
486
486
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
487
487
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -622,7 +622,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
622
622
|
code: string;
|
|
623
623
|
type: "auto" | "coupon" | "deal";
|
|
624
624
|
discountAmount: number;
|
|
625
|
-
scope?: "
|
|
625
|
+
scope?: "seller" | "admin" | undefined;
|
|
626
626
|
storeId?: string | undefined;
|
|
627
627
|
couponId?: string | undefined;
|
|
628
628
|
}[] | undefined;
|
|
@@ -654,9 +654,9 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
654
654
|
paymentBatchId?: string | undefined;
|
|
655
655
|
refunds?: {
|
|
656
656
|
type: "full" | "partial";
|
|
657
|
+
reason: string;
|
|
657
658
|
amount: number;
|
|
658
659
|
refundId: string;
|
|
659
|
-
reason: string;
|
|
660
660
|
refundedAt: string | Date | z.objectOutputType<{
|
|
661
661
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
662
662
|
}, z.ZodTypeAny, "passthrough">;
|
|
@@ -770,7 +770,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
770
770
|
code: string;
|
|
771
771
|
type: "auto" | "coupon" | "deal";
|
|
772
772
|
discountAmount: number;
|
|
773
|
-
scope?: "
|
|
773
|
+
scope?: "seller" | "admin" | undefined;
|
|
774
774
|
storeId?: string | undefined;
|
|
775
775
|
couponId?: string | undefined;
|
|
776
776
|
}[] | undefined;
|
|
@@ -802,9 +802,9 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
802
802
|
paymentBatchId?: string | undefined;
|
|
803
803
|
refunds?: {
|
|
804
804
|
type: "full" | "partial";
|
|
805
|
+
reason: string;
|
|
805
806
|
amount: number;
|
|
806
807
|
refundId: string;
|
|
807
|
-
reason: string;
|
|
808
808
|
refundedAt: string | Date | z.objectInputType<{
|
|
809
809
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
810
810
|
}, z.ZodTypeAny, "passthrough">;
|