@mohasinac/appkit 4.22.0 → 4.24.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/bundles/actions.js +7 -0
- package/dist/_internal/server/features/checkout/actions.js +24 -16
- 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/actions/action-registry.js +41 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
- package/dist/_internal/shared/checkout/order-math.js +29 -6
- package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
- package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
- package/dist/_internal/shared/features/cart/config.d.ts +2 -1
- package/dist/_internal/shared/features/cart/config.js +2 -1
- package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
- package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
- package/dist/_internal/shared/features/notifications/action-url.js +111 -0
- 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/constants/notification-types.d.ts +38 -10
- package/dist/constants/notification-types.js +39 -28
- package/dist/features/account/types/index.d.ts +1 -10
- package/dist/features/admin/actions/admin-actions.js +13 -3
- package/dist/features/admin/actions/notification-actions.d.ts +12 -0
- package/dist/features/admin/actions/notification-actions.js +149 -4
- 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/admin/schemas/firestore.d.ts +14 -3
- package/dist/features/admin/schemas/firestore.js +79 -0
- 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/cart/actions/cart-actions.d.ts +16 -0
- package/dist/features/cart/actions/cart-actions.js +26 -12
- package/dist/features/cart/components/CartDrawer.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +132 -8
- package/dist/features/cart/schemas/firestore.js +22 -0
- 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/email/index.d.ts +1 -1
- package/dist/features/email/index.js +5 -1
- package/dist/features/email/notification-templates.d.ts +35 -0
- package/dist/features/email/notification-templates.js +96 -0
- package/dist/features/messages/schemas/index.d.ts +6 -6
- package/dist/features/orders/schemas/index.d.ts +14 -14
- 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 +84 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +15 -2
- package/dist/schemas/registry.d.ts +34 -34
- package/dist/seed/bids-seed-data.js +8 -0
- package/dist/seed/factories/notification.factory.d.ts +2 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +5 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +10 -1
- package/dist/styles.css +58 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/QuantityStepper.d.ts +38 -0
- package/dist/ui/components/QuantityStepper.js +20 -0
- package/dist/ui/components/QuantityStepper.style.css +56 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { StatusTimeline } from "./StatusTimeline";
|
|
4
|
+
/**
|
|
5
|
+
* The timeline for an entity with no fixed phase vocabulary.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this exists rather than seven more wrappers
|
|
8
|
+
*
|
|
9
|
+
* `OrderStatusTimeline` and `OfferPhaseTimeline` each supply a KNOWN sequence
|
|
10
|
+
* of phases — placed → paid → shipped → delivered, made → countered →
|
|
11
|
+
* accepted → paid — and render the steps a record has not reached yet as
|
|
12
|
+
* upcoming. That shape earns a dedicated wrapper because the vocabulary is
|
|
13
|
+
* part of the product.
|
|
14
|
+
*
|
|
15
|
+
* Support tickets, payouts, stores, scammer profiles, bids, shipments,
|
|
16
|
+
* catalogue items and ads have no such sequence: a store goes active →
|
|
17
|
+
* suspended → active, a bid goes active → outbid → active → won, a payout
|
|
18
|
+
* retries. There is nothing to pre-render. So the timeline is simply the
|
|
19
|
+
* entries that happened, in order — and writing that eight times would be
|
|
20
|
+
* eight chances for the actor label, the em-dash rule or the negative tone to
|
|
21
|
+
* drift, which is the bug-fix multiplier the Duplication Framework names.
|
|
22
|
+
*
|
|
23
|
+
* ## Never fabricate a step
|
|
24
|
+
*
|
|
25
|
+
* A record written before its entity adopted history has no `statusHistory`
|
|
26
|
+
* at all. That renders `emptyLabel`, NOT a step derived from `updatedAt` —
|
|
27
|
+
* "last write of any kind" is not an event, and a made-up date makes the
|
|
28
|
+
* record lie. Same rule the offer timeline follows for `expired`.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Statuses that render in the error tone.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately a shared set rather than a per-entity prop: these words mean
|
|
34
|
+
* the same thing on every entity that uses them, and a per-entity list is one
|
|
35
|
+
* more thing to keep aligned. A value not listed renders neutral, which is
|
|
36
|
+
* the safe direction — a real transition shown in the wrong tone is a smaller
|
|
37
|
+
* defect than a normal one shown as a failure.
|
|
38
|
+
*/
|
|
39
|
+
const NEGATIVE_STATUSES = new Set([
|
|
40
|
+
"cancelled",
|
|
41
|
+
"canceled",
|
|
42
|
+
"rejected",
|
|
43
|
+
"failed",
|
|
44
|
+
"forfeited",
|
|
45
|
+
"suspended",
|
|
46
|
+
"removed",
|
|
47
|
+
"declined",
|
|
48
|
+
"expired",
|
|
49
|
+
"lost",
|
|
50
|
+
]);
|
|
51
|
+
/** Ended, but not a failure — the record simply moved on. */
|
|
52
|
+
const MUTED_STATUSES = new Set(["outbid", "closed", "superseded", "archived"]);
|
|
53
|
+
function humanise(value) {
|
|
54
|
+
if (value === null || value === undefined || value === "")
|
|
55
|
+
return "—";
|
|
56
|
+
if (typeof value === "boolean")
|
|
57
|
+
return value ? "yes" : "no";
|
|
58
|
+
const s = String(value);
|
|
59
|
+
return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* One entry → one step.
|
|
63
|
+
*
|
|
64
|
+
* `status` is the headline when it changed; otherwise the step is named after
|
|
65
|
+
* the fields that did move, so an entry recording only "ETA moved" still
|
|
66
|
+
* reads as a real event rather than a blank row.
|
|
67
|
+
*/
|
|
68
|
+
function toStep(entry, index) {
|
|
69
|
+
const statusChange = entry.changes["status"] ?? entry.changes["listingStatus"];
|
|
70
|
+
const to = statusChange?.to;
|
|
71
|
+
const label = statusChange
|
|
72
|
+
? humanise(to)
|
|
73
|
+
: Object.keys(entry.changes).map(humanise).join(", ") || "Updated";
|
|
74
|
+
const toKey = typeof to === "string" ? to.toLowerCase() : "";
|
|
75
|
+
return {
|
|
76
|
+
key: `${entry.at instanceof Date ? entry.at.toISOString() : String(entry.at)}-${index}`,
|
|
77
|
+
label,
|
|
78
|
+
date: entry.at instanceof Date ? entry.at.toISOString() : String(entry.at),
|
|
79
|
+
negative: NEGATIVE_STATUSES.has(toKey),
|
|
80
|
+
muted: MUTED_STATUSES.has(toKey),
|
|
81
|
+
actorRole: entry.actorRole,
|
|
82
|
+
reason: entry.reason,
|
|
83
|
+
note: entry.note,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function RecordStatusTimeline({ entries, truncatedCount, title = "History", emptyLabel = "No recorded history yet", className, }) {
|
|
87
|
+
const steps = (entries ?? []).map(toStep);
|
|
88
|
+
return (_jsx(StatusTimeline, { title: title, steps: steps, truncatedCount: truncatedCount, emptyLabel: emptyLabel, className: className }));
|
|
89
|
+
}
|
|
90
|
+
export default RecordStatusTimeline;
|
|
@@ -9,3 +9,5 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export { StatusTimeline, stepsFromEntries } from "./components/StatusTimeline";
|
|
11
11
|
export type { StatusTimelineProps, TimelineEntry, TimelineStep } from "./components/StatusTimeline";
|
|
12
|
+
export { RecordStatusTimeline } from "./components/RecordStatusTimeline";
|
|
13
|
+
export type { RecordStatusTimelineProps } from "./components/RecordStatusTimeline";
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { BaseRepository, type SieveModel, type FirebaseSieveResult, type DocumentSnapshot } from "../../../providers/db-firebase";
|
|
8
8
|
import { StoreDocument } from "../schemas";
|
|
9
|
+
import { type HistoryActor } from "../../../_internal/shared/history/index";
|
|
10
|
+
/** Who/why for a store write that lands in `statusHistory`. */
|
|
11
|
+
export interface StoreWriteContext {
|
|
12
|
+
actor?: HistoryActor;
|
|
13
|
+
trigger?: string;
|
|
14
|
+
reason?: string;
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
9
17
|
export declare class StoreRepository extends BaseRepository<StoreDocument> {
|
|
10
18
|
constructor();
|
|
11
19
|
private encryptSecrets;
|
|
@@ -33,9 +41,29 @@ export declare class StoreRepository extends BaseRepository<StoreDocument> {
|
|
|
33
41
|
*/
|
|
34
42
|
updateStore(storeSlug: string, data: Partial<Omit<StoreDocument, "id" | "storeSlug" | "ownerId" | "createdAt">>): Promise<StoreDocument>;
|
|
35
43
|
/**
|
|
36
|
-
* Admin: approve or
|
|
44
|
+
* Admin: approve, reject or suspend a store — and the ONLY correct way to
|
|
45
|
+
* move `status`, because `isPublic` must move with it.
|
|
46
|
+
*
|
|
47
|
+
* `status` and `isPublic` are two distinct fields and public visibility
|
|
48
|
+
* gates on `isPublic`. The admin PATCH route wrote `status` directly and
|
|
49
|
+
* never touched `isPublic` until 2026-08-26, so approving a pending store
|
|
50
|
+
* left it `isPublic: false` — active, and invisible on every public
|
|
51
|
+
* surface, with nothing anywhere reporting a problem. Same two-field trap
|
|
52
|
+
* CLAUDE.md already records for `becomeSeller` / `createStore`.
|
|
53
|
+
*
|
|
54
|
+
* `extra` carries the other admin fields so a single save is a single
|
|
55
|
+
* write and a single timeline entry; `prior` keeps that entry read-free
|
|
56
|
+
* for the route, which has already fetched the store to 404 on a missing
|
|
57
|
+
* one (Rule #6).
|
|
58
|
+
*/
|
|
59
|
+
setStatus(storeSlug: string, status: StoreDocument["status"], extra?: Partial<StoreDocument>, ctx?: StoreWriteContext, prior?: StoreDocument | null): Promise<StoreDocument>;
|
|
60
|
+
/**
|
|
61
|
+
* Admin field edits that do NOT move `status` — verification, featuring,
|
|
62
|
+
* capabilities, the suspension reason on an already-suspended store.
|
|
37
63
|
*/
|
|
38
|
-
|
|
64
|
+
adminUpdate(storeSlug: string, patch: Partial<StoreDocument>, ctx?: StoreWriteContext, prior?: StoreDocument | null): Promise<StoreDocument>;
|
|
65
|
+
/** Append a timeline entry when the patch touches a tracked field. */
|
|
66
|
+
private withStoreHistory;
|
|
39
67
|
/**
|
|
40
68
|
* Paginated list of stores with Sieve filtering/sorting.
|
|
41
69
|
* Filters to active + public stores for the public listing.
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { BaseRepository, prepareForFirestore, applySieveToFirestore, } from "../../../providers/db-firebase";
|
|
8
8
|
import { increment } from "../../../contracts/field-ops";
|
|
9
|
-
import { STORE_COLLECTION, STORE_FIELDS, DEFAULT_STORE_DATA, StoreStatusValues, } from "../schemas";
|
|
9
|
+
import { STORE_COLLECTION, STORE_FIELDS, DEFAULT_STORE_DATA, StoreStatusValues, STORE_TRACKED_FIELDS, STORE_HISTORY_PII_FIELDS, } from "../schemas";
|
|
10
10
|
import { DatabaseError } from "../../../errors";
|
|
11
11
|
import { normalizeError } from "../../../errors/normalize";
|
|
12
12
|
import { encryptSecret, decryptSecret, } from "../../../security/settings-encryption";
|
|
13
|
+
import { withHistory } from "../../../_internal/shared/history/index";
|
|
13
14
|
export class StoreRepository extends BaseRepository {
|
|
14
15
|
constructor() {
|
|
15
16
|
super(STORE_COLLECTION);
|
|
@@ -97,16 +98,54 @@ export class StoreRepository extends BaseRepository {
|
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
100
|
/**
|
|
100
|
-
* Admin: approve or
|
|
101
|
+
* Admin: approve, reject or suspend a store — and the ONLY correct way to
|
|
102
|
+
* move `status`, because `isPublic` must move with it.
|
|
103
|
+
*
|
|
104
|
+
* `status` and `isPublic` are two distinct fields and public visibility
|
|
105
|
+
* gates on `isPublic`. The admin PATCH route wrote `status` directly and
|
|
106
|
+
* never touched `isPublic` until 2026-08-26, so approving a pending store
|
|
107
|
+
* left it `isPublic: false` — active, and invisible on every public
|
|
108
|
+
* surface, with nothing anywhere reporting a problem. Same two-field trap
|
|
109
|
+
* CLAUDE.md already records for `becomeSeller` / `createStore`.
|
|
110
|
+
*
|
|
111
|
+
* `extra` carries the other admin fields so a single save is a single
|
|
112
|
+
* write and a single timeline entry; `prior` keeps that entry read-free
|
|
113
|
+
* for the route, which has already fetched the store to 404 on a missing
|
|
114
|
+
* one (Rule #6).
|
|
101
115
|
*/
|
|
102
|
-
async setStatus(storeSlug, status) {
|
|
103
|
-
|
|
116
|
+
async setStatus(storeSlug, status, extra, ctx, prior) {
|
|
117
|
+
const current = prior !== undefined ? prior : await this.findById(storeSlug);
|
|
118
|
+
return this.update(storeSlug, this.withStoreHistory(current, {
|
|
104
119
|
status,
|
|
105
120
|
// Auto-set isPublic when approved
|
|
106
121
|
...(status === StoreStatusValues.ACTIVE && { isPublic: true }),
|
|
107
122
|
...(status !== StoreStatusValues.ACTIVE && { isPublic: false }),
|
|
123
|
+
...extra,
|
|
108
124
|
updatedAt: new Date(),
|
|
125
|
+
}, ctx, "setStatus"));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Admin field edits that do NOT move `status` — verification, featuring,
|
|
129
|
+
* capabilities, the suspension reason on an already-suspended store.
|
|
130
|
+
*/
|
|
131
|
+
async adminUpdate(storeSlug, patch, ctx, prior) {
|
|
132
|
+
const current = prior !== undefined ? prior : await this.findById(storeSlug);
|
|
133
|
+
return this.update(storeSlug, this.withStoreHistory(current, { ...patch, updatedAt: new Date() }, ctx, "adminUpdateStore"));
|
|
134
|
+
}
|
|
135
|
+
/** Append a timeline entry when the patch touches a tracked field. */
|
|
136
|
+
withStoreHistory(current, patch, ctx, defaultTrigger) {
|
|
137
|
+
const withEntry = withHistory(current, patch, {
|
|
138
|
+
tracked: STORE_TRACKED_FIELDS,
|
|
139
|
+
actor: ctx?.actor ?? { role: "system" },
|
|
140
|
+
trigger: ctx?.trigger ?? defaultTrigger,
|
|
141
|
+
reason: ctx?.reason,
|
|
142
|
+
note: ctx?.note,
|
|
143
|
+
// `mapDoc` decrypts whatsappConfig.accessToken on EVERY read, so a
|
|
144
|
+
// live Meta credential is in memory whenever this diff runs, and
|
|
145
|
+
// `encryptPiiFields` never descends into arrays.
|
|
146
|
+
piiFields: STORE_HISTORY_PII_FIELDS,
|
|
109
147
|
});
|
|
148
|
+
return withEntry ?? patch;
|
|
110
149
|
}
|
|
111
150
|
/**
|
|
112
151
|
* Paginated list of stores with Sieve filtering/sorting.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Canonical Firestore document interfaces, collection names, field constants,
|
|
5
5
|
* and helpers for the stores feature.
|
|
6
6
|
*/
|
|
7
|
+
import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
|
|
7
8
|
import type { StoreCapability } from "../../auth/permissions/constants";
|
|
8
9
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
9
10
|
export type ShippingProviderType = "self-courier" | "store-pickup" | "custom";
|
|
@@ -139,7 +140,35 @@ export interface StoreDocument extends BaseDocument {
|
|
|
139
140
|
/** When the seller first connected their account */
|
|
140
141
|
connectedAt?: Date;
|
|
141
142
|
};
|
|
143
|
+
/**
|
|
144
|
+
* Who changed what, when, and why. See § "Status History" in CLAUDE.md.
|
|
145
|
+
*
|
|
146
|
+
* The question this answers for a seller is "why is my store suspended and
|
|
147
|
+
* who did it" — previously recoverable only from `adminAuditLog`, which is
|
|
148
|
+
* admin-only and which the seller can never see.
|
|
149
|
+
*/
|
|
150
|
+
statusHistory?: StatusChangeEntry[];
|
|
151
|
+
statusHistoryTruncated?: number;
|
|
142
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* The fields whose changes earn a timeline entry.
|
|
155
|
+
*
|
|
156
|
+
* `isPublic` is tracked alongside `status` deliberately: the two are supposed
|
|
157
|
+
* to move together (`setStatus` syncs them) and the admin PATCH route did NOT
|
|
158
|
+
* until 2026-08-26, so an approved store stayed invisible. A timeline that
|
|
159
|
+
* shows them diverging is how the next instance gets noticed.
|
|
160
|
+
*
|
|
161
|
+
* `adminNotes` is excluded — its own schema comment says it is never shown to
|
|
162
|
+
* the store owner or the public, and `statusHistory` rides on the store
|
|
163
|
+
* document the owner can read.
|
|
164
|
+
*/
|
|
165
|
+
export declare const STORE_TRACKED_FIELDS: readonly ["status", "isPublic", "isVerified", "isFeatured", "suspensionReason", "capabilities"];
|
|
166
|
+
/**
|
|
167
|
+
* PII on this document, for `withHistory`'s scrub. `whatsappConfig.accessToken`
|
|
168
|
+
* is a live Meta credential; `mapDoc` decrypts it on EVERY read, so it is
|
|
169
|
+
* present in memory whenever a diff runs.
|
|
170
|
+
*/
|
|
171
|
+
export declare const STORE_HISTORY_PII_FIELDS: readonly ["accessToken", "payoutDetails", "upiVpa", "accountNumber"];
|
|
143
172
|
export type { StoreCapability } from "../../auth/permissions/constants";
|
|
144
173
|
export declare const STORE_COLLECTION: "stores";
|
|
145
174
|
export declare const STORE_FIELDS: {
|
|
@@ -12,6 +12,37 @@ export const StoreStatusValues = {
|
|
|
12
12
|
SUSPENDED: "suspended",
|
|
13
13
|
REJECTED: "rejected",
|
|
14
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* The fields whose changes earn a timeline entry.
|
|
17
|
+
*
|
|
18
|
+
* `isPublic` is tracked alongside `status` deliberately: the two are supposed
|
|
19
|
+
* to move together (`setStatus` syncs them) and the admin PATCH route did NOT
|
|
20
|
+
* until 2026-08-26, so an approved store stayed invisible. A timeline that
|
|
21
|
+
* shows them diverging is how the next instance gets noticed.
|
|
22
|
+
*
|
|
23
|
+
* `adminNotes` is excluded — its own schema comment says it is never shown to
|
|
24
|
+
* the store owner or the public, and `statusHistory` rides on the store
|
|
25
|
+
* document the owner can read.
|
|
26
|
+
*/
|
|
27
|
+
export const STORE_TRACKED_FIELDS = [
|
|
28
|
+
"status",
|
|
29
|
+
"isPublic",
|
|
30
|
+
"isVerified",
|
|
31
|
+
"isFeatured",
|
|
32
|
+
"suspensionReason",
|
|
33
|
+
"capabilities",
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* PII on this document, for `withHistory`'s scrub. `whatsappConfig.accessToken`
|
|
37
|
+
* is a live Meta credential; `mapDoc` decrypts it on EVERY read, so it is
|
|
38
|
+
* present in memory whenever a diff runs.
|
|
39
|
+
*/
|
|
40
|
+
export const STORE_HISTORY_PII_FIELDS = [
|
|
41
|
+
"accessToken",
|
|
42
|
+
"payoutDetails",
|
|
43
|
+
"upiVpa",
|
|
44
|
+
"accountNumber",
|
|
45
|
+
];
|
|
15
46
|
export const STORE_COLLECTION = "stores";
|
|
16
47
|
export const STORE_FIELDS = {
|
|
17
48
|
ID: "id",
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { BaseRepository } from "../../../providers/db-firebase";
|
|
2
2
|
import type { FirebaseSieveFields, FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
|
|
3
3
|
import { type SupportTicketDocument, type SupportTicketCreateInput, type SupportTicketUpdateInput, type TicketMessage, type TicketStatus } from "../schemas/firestore";
|
|
4
|
+
import { type HistoryActor } from "../../../_internal/shared/history/index";
|
|
5
|
+
/** Who/why for a write that lands in `statusHistory`. */
|
|
6
|
+
export interface TicketWriteContext {
|
|
7
|
+
actor?: HistoryActor;
|
|
8
|
+
trigger?: string;
|
|
9
|
+
reason?: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
4
12
|
export declare class SupportRepository extends BaseRepository<SupportTicketDocument> {
|
|
5
13
|
static readonly SIEVE_FIELDS: FirebaseSieveFields;
|
|
6
14
|
constructor();
|
|
@@ -10,9 +18,45 @@ export declare class SupportRepository extends BaseRepository<SupportTicketDocum
|
|
|
10
18
|
countActiveTickets(userId: string): Promise<number>;
|
|
11
19
|
getActiveOrderTicket(userId: string, orderId: string): Promise<SupportTicketDocument | null>;
|
|
12
20
|
getActiveCategoryTicket(userId: string, category: string): Promise<SupportTicketDocument | null>;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The single status/assignment write path.
|
|
23
|
+
*
|
|
24
|
+
* `prior` is not an optimisation detail — the caller that matters (the admin
|
|
25
|
+
* PATCH route) already fetches the ticket to 404 on a missing one, so
|
|
26
|
+
* threading it through means history costs ZERO extra Firestore reads
|
|
27
|
+
* (Rule #6). Omit it and this falls back to a read, which is correct but
|
|
28
|
+
* avoidable.
|
|
29
|
+
*/
|
|
30
|
+
updateTicketStatus(ticketId: string, update: SupportTicketUpdateInput, ctx?: TicketWriteContext, prior?: SupportTicketDocument | null): Promise<SupportTicketDocument>;
|
|
31
|
+
/**
|
|
32
|
+
* Stamp `resolvedAt` / `closedAt` on the transition INTO that status.
|
|
33
|
+
*
|
|
34
|
+
* Both fields were declared, constant-named and listed in the update input,
|
|
35
|
+
* and no code path had ever written either (verified 2026-08-26) — so every
|
|
36
|
+
* resolved ticket in the collection has no resolution time.
|
|
37
|
+
*
|
|
38
|
+
* Guarded on the transition, not on the status: re-saving a priority change
|
|
39
|
+
* on an already-resolved ticket must not move its resolution time forward.
|
|
40
|
+
* Never fabricated for a ticket resolved before this existed — that one
|
|
41
|
+
* renders an em-dash, the same rule the offer timeline follows.
|
|
42
|
+
*/
|
|
43
|
+
private resolutionStamps;
|
|
44
|
+
/** Append a timeline entry when the patch touches a tracked field. */
|
|
45
|
+
private withTicketHistory;
|
|
46
|
+
/**
|
|
47
|
+
* Append a reply, optionally moving the ticket's status.
|
|
48
|
+
*
|
|
49
|
+
* The message itself is NOT history — the thread is already the record of
|
|
50
|
+
* what was said. Only an accompanying status change earns an entry, and
|
|
51
|
+
* only then is a read taken, so an ordinary reply still costs one write and
|
|
52
|
+
* no read.
|
|
53
|
+
*
|
|
54
|
+
* `messages` stays an `arrayUnion` (concurrent replies must not clobber one
|
|
55
|
+
* another); `statusHistory` cannot, because `arrayUnion` has no way to
|
|
56
|
+
* enforce the FIFO cap — so it is folded in memory and written whole.
|
|
57
|
+
*/
|
|
58
|
+
addMessage(ticketId: string, message: TicketMessage, newStatus?: TicketStatus, ctx?: TicketWriteContext, prior?: SupportTicketDocument | null): Promise<void>;
|
|
59
|
+
assignTicket(ticketId: string, assignedTo: string, assignedToName: string, ctx?: TicketWriteContext, prior?: SupportTicketDocument | null): Promise<SupportTicketDocument>;
|
|
16
60
|
listAll(model: SieveModel): Promise<FirebaseSieveResult<SupportTicketDocument>>;
|
|
17
61
|
}
|
|
18
62
|
export declare const supportRepository: SupportRepository;
|
|
@@ -3,7 +3,8 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
|
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
4
|
import { DatabaseError } from "../../../errors";
|
|
5
5
|
import { SUPPORT_TICKET_FIELDS } from "../../../constants/field-names";
|
|
6
|
-
import { SUPPORT_TICKET_COLLECTION, ACTIVE_TICKET_STATUSES, } from "../schemas/firestore";
|
|
6
|
+
import { SUPPORT_TICKET_COLLECTION, SUPPORT_TICKET_PII_FIELDS, SUPPORT_TICKET_TRACKED_FIELDS, TicketStatusValues, ACTIVE_TICKET_STATUSES, } from "../schemas/firestore";
|
|
7
|
+
import { withHistory } from "../../../_internal/shared/history/index";
|
|
7
8
|
export class SupportRepository extends BaseRepository {
|
|
8
9
|
constructor() {
|
|
9
10
|
super(SUPPORT_TICKET_COLLECTION);
|
|
@@ -73,13 +74,73 @@ export class SupportRepository extends BaseRepository {
|
|
|
73
74
|
const doc = snap.docs[0];
|
|
74
75
|
return { id: doc.id, ...doc.data() };
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The single status/assignment write path.
|
|
79
|
+
*
|
|
80
|
+
* `prior` is not an optimisation detail — the caller that matters (the admin
|
|
81
|
+
* PATCH route) already fetches the ticket to 404 on a missing one, so
|
|
82
|
+
* threading it through means history costs ZERO extra Firestore reads
|
|
83
|
+
* (Rule #6). Omit it and this falls back to a read, which is correct but
|
|
84
|
+
* avoidable.
|
|
85
|
+
*/
|
|
86
|
+
async updateTicketStatus(ticketId, update, ctx, prior) {
|
|
87
|
+
const current = prior !== undefined ? prior : await this.getTicketById(ticketId);
|
|
88
|
+
const patch = {
|
|
78
89
|
...update,
|
|
90
|
+
...this.resolutionStamps(current, update.status),
|
|
79
91
|
updatedAt: new Date(),
|
|
92
|
+
};
|
|
93
|
+
return this.update(ticketId, this.withTicketHistory(current, patch, ctx, "updateTicketStatus"));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Stamp `resolvedAt` / `closedAt` on the transition INTO that status.
|
|
97
|
+
*
|
|
98
|
+
* Both fields were declared, constant-named and listed in the update input,
|
|
99
|
+
* and no code path had ever written either (verified 2026-08-26) — so every
|
|
100
|
+
* resolved ticket in the collection has no resolution time.
|
|
101
|
+
*
|
|
102
|
+
* Guarded on the transition, not on the status: re-saving a priority change
|
|
103
|
+
* on an already-resolved ticket must not move its resolution time forward.
|
|
104
|
+
* Never fabricated for a ticket resolved before this existed — that one
|
|
105
|
+
* renders an em-dash, the same rule the offer timeline follows.
|
|
106
|
+
*/
|
|
107
|
+
resolutionStamps(current, next) {
|
|
108
|
+
if (!next || next === current?.status)
|
|
109
|
+
return {};
|
|
110
|
+
const now = new Date();
|
|
111
|
+
if (next === TicketStatusValues.RESOLVED && !current?.resolvedAt)
|
|
112
|
+
return { resolvedAt: now };
|
|
113
|
+
if (next === TicketStatusValues.CLOSED && !current?.closedAt)
|
|
114
|
+
return { closedAt: now };
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
/** Append a timeline entry when the patch touches a tracked field. */
|
|
118
|
+
withTicketHistory(current, patch, ctx, defaultTrigger) {
|
|
119
|
+
const withEntry = withHistory(current, patch, {
|
|
120
|
+
tracked: SUPPORT_TICKET_TRACKED_FIELDS,
|
|
121
|
+
actor: ctx?.actor ?? { role: "system" },
|
|
122
|
+
trigger: ctx?.trigger ?? defaultTrigger,
|
|
123
|
+
reason: ctx?.reason,
|
|
124
|
+
note: ctx?.note,
|
|
125
|
+
// `encryptPiiFields` never descends into arrays, so a PII-named value
|
|
126
|
+
// reaching `changes` would persist in PLAINTEXT inside statusHistory.
|
|
127
|
+
piiFields: SUPPORT_TICKET_PII_FIELDS,
|
|
80
128
|
});
|
|
129
|
+
return withEntry ?? patch;
|
|
81
130
|
}
|
|
82
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Append a reply, optionally moving the ticket's status.
|
|
133
|
+
*
|
|
134
|
+
* The message itself is NOT history — the thread is already the record of
|
|
135
|
+
* what was said. Only an accompanying status change earns an entry, and
|
|
136
|
+
* only then is a read taken, so an ordinary reply still costs one write and
|
|
137
|
+
* no read.
|
|
138
|
+
*
|
|
139
|
+
* `messages` stays an `arrayUnion` (concurrent replies must not clobber one
|
|
140
|
+
* another); `statusHistory` cannot, because `arrayUnion` has no way to
|
|
141
|
+
* enforce the FIFO cap — so it is folded in memory and written whole.
|
|
142
|
+
*/
|
|
143
|
+
async addMessage(ticketId, message, newStatus, ctx, prior) {
|
|
83
144
|
try {
|
|
84
145
|
const ref = this.db
|
|
85
146
|
.collection(SUPPORT_TICKET_COLLECTION)
|
|
@@ -88,8 +149,10 @@ export class SupportRepository extends BaseRepository {
|
|
|
88
149
|
messages: firebaseFieldOps.arrayUnion(prepareForFirestore(message)),
|
|
89
150
|
updatedAt: new Date(),
|
|
90
151
|
};
|
|
91
|
-
if (newStatus)
|
|
92
|
-
|
|
152
|
+
if (newStatus) {
|
|
153
|
+
const current = prior !== undefined ? prior : await this.getTicketById(ticketId);
|
|
154
|
+
Object.assign(updateData, this.withTicketHistory(current, { status: newStatus, ...this.resolutionStamps(current, newStatus) }, ctx, "addMessage"));
|
|
155
|
+
}
|
|
93
156
|
await ref.update(updateData);
|
|
94
157
|
}
|
|
95
158
|
catch (err) {
|
|
@@ -97,12 +160,9 @@ export class SupportRepository extends BaseRepository {
|
|
|
97
160
|
throw new DatabaseError("Failed to add message to ticket", err);
|
|
98
161
|
}
|
|
99
162
|
}
|
|
100
|
-
async assignTicket(ticketId, assignedTo, assignedToName) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
assignedToName,
|
|
104
|
-
updatedAt: new Date(),
|
|
105
|
-
});
|
|
163
|
+
async assignTicket(ticketId, assignedTo, assignedToName, ctx, prior) {
|
|
164
|
+
const current = prior !== undefined ? prior : await this.getTicketById(ticketId);
|
|
165
|
+
return this.update(ticketId, this.withTicketHistory(current, { assignedTo, assignedToName, updatedAt: new Date() }, ctx, "assignTicket"));
|
|
106
166
|
}
|
|
107
167
|
async listAll(model) {
|
|
108
168
|
return this.sieveQuery(model, SupportRepository.SIEVE_FIELDS, { defaultPageSize: 25, maxPageSize: 100 });
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* - No new ticket in the same category while existing one is waiting_on_user
|
|
16
16
|
*/
|
|
17
17
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
18
|
+
import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
|
|
18
19
|
export declare const TicketCategoryValues: {
|
|
19
20
|
readonly ORDER_ISSUE: "order_issue";
|
|
20
21
|
readonly BILLING_PAYMENT: "billing_payment";
|
|
@@ -101,11 +102,47 @@ export interface SupportTicketDocument extends BaseDocument {
|
|
|
101
102
|
internalNotes?: string;
|
|
102
103
|
/** Threaded conversation. Ordered by createdAt asc. */
|
|
103
104
|
messages: TicketMessage[];
|
|
105
|
+
/**
|
|
106
|
+
* Stamped by the repository the first time `status` becomes `resolved` /
|
|
107
|
+
* `closed`. Both were declared, given `SUPPORT_TICKET_FIELDS` constants and
|
|
108
|
+
* listed in `SupportTicketUpdateInput` — and **nothing ever wrote either**
|
|
109
|
+
* (verified 2026-08-26), so no resolved ticket in the collection carries a
|
|
110
|
+
* resolution time and "how long do tickets take" has never been answerable.
|
|
111
|
+
*
|
|
112
|
+
* Server-stamped, never client-sent: the same discipline the reports route
|
|
113
|
+
* adopted after a client-sent `resolvedAt` arrived as a JSON string into a
|
|
114
|
+
* `Date` field and split the collection into two shapes.
|
|
115
|
+
*/
|
|
104
116
|
resolvedAt?: Date;
|
|
105
117
|
closedAt?: Date;
|
|
118
|
+
/**
|
|
119
|
+
* Who changed what, when, and why. See § "Status History" in CLAUDE.md.
|
|
120
|
+
* Append-only, capped at STATUS_HISTORY_MAX, oldest evicted first.
|
|
121
|
+
*/
|
|
122
|
+
statusHistory?: StatusChangeEntry[];
|
|
123
|
+
/** How many entries fell off the front. Never imply "this is all of it". */
|
|
124
|
+
statusHistoryTruncated?: number;
|
|
106
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The fields whose changes earn a timeline entry.
|
|
128
|
+
*
|
|
129
|
+
* Explicit, not a whole-document diff: a diff would record the `updatedAt`
|
|
130
|
+
* bump on every write and exhaust the 50-entry cap within days. `messages` is
|
|
131
|
+
* excluded on purpose — the thread is already the conversation, and mirroring
|
|
132
|
+
* every reply into history would bury the handful of transitions anyone reads.
|
|
133
|
+
*/
|
|
134
|
+
export declare const SUPPORT_TICKET_TRACKED_FIELDS: readonly ["status", "priority", "assignedTo", "resolvedAt", "closedAt"];
|
|
135
|
+
/**
|
|
136
|
+
* PII on this document, for `withHistory`'s scrub.
|
|
137
|
+
*
|
|
138
|
+
* `assignedToName` is a staff display name denormalised for the admin table —
|
|
139
|
+
* not the ticket author's identity — but it is still a person's name reaching
|
|
140
|
+
* an array `encryptPiiFields` cannot descend into, so history carries the UID
|
|
141
|
+
* (`assignedTo`) and never the name.
|
|
142
|
+
*/
|
|
143
|
+
export declare const SUPPORT_TICKET_PII_FIELDS: readonly ["userEmail", "userDisplayName", "assignedToName"];
|
|
107
144
|
export type SupportTicketCreateInput = Pick<SupportTicketDocument, "userId" | "userEmail" | "userDisplayName" | "category" | "subject" | "description" | "orderId">;
|
|
108
|
-
export type SupportTicketUpdateInput = Partial<Pick<SupportTicketDocument, "status" | "priority" | "assignedTo" | "assignedToName" | "internalNotes" | "
|
|
145
|
+
export type SupportTicketUpdateInput = Partial<Pick<SupportTicketDocument, "status" | "priority" | "assignedTo" | "assignedToName" | "internalNotes" | "relatedParties">>;
|
|
109
146
|
export declare const SUPPORT_TICKET_COLLECTION: "supportTickets";
|
|
110
147
|
export declare const SUPPORT_TICKET_ID_PREFIX: "ticket-";
|
|
111
148
|
export declare const SUPPORT_TICKET_INDEXED_FIELDS: readonly ["userId", "status", "category", "priority", "assignedTo", "orderId", "createdAt", "updatedAt"];
|
|
@@ -63,6 +63,40 @@ export const ELIGIBLE_ORDER_STATUSES_FOR_TICKET = [
|
|
|
63
63
|
"SHIPPED",
|
|
64
64
|
"RETURN_REQUESTED",
|
|
65
65
|
];
|
|
66
|
+
/**
|
|
67
|
+
* The fields whose changes earn a timeline entry.
|
|
68
|
+
*
|
|
69
|
+
* Explicit, not a whole-document diff: a diff would record the `updatedAt`
|
|
70
|
+
* bump on every write and exhaust the 50-entry cap within days. `messages` is
|
|
71
|
+
* excluded on purpose — the thread is already the conversation, and mirroring
|
|
72
|
+
* every reply into history would bury the handful of transitions anyone reads.
|
|
73
|
+
*/
|
|
74
|
+
export const SUPPORT_TICKET_TRACKED_FIELDS = [
|
|
75
|
+
"status",
|
|
76
|
+
"priority",
|
|
77
|
+
"assignedTo",
|
|
78
|
+
"resolvedAt",
|
|
79
|
+
"closedAt",
|
|
80
|
+
];
|
|
81
|
+
/**
|
|
82
|
+
* PII on this document, for `withHistory`'s scrub.
|
|
83
|
+
*
|
|
84
|
+
* `assignedToName` is a staff display name denormalised for the admin table —
|
|
85
|
+
* not the ticket author's identity — but it is still a person's name reaching
|
|
86
|
+
* an array `encryptPiiFields` cannot descend into, so history carries the UID
|
|
87
|
+
* (`assignedTo`) and never the name.
|
|
88
|
+
*/
|
|
89
|
+
export const SUPPORT_TICKET_PII_FIELDS = [
|
|
90
|
+
"userEmail",
|
|
91
|
+
"userDisplayName",
|
|
92
|
+
"assignedToName",
|
|
93
|
+
];
|
|
94
|
+
/*
|
|
95
|
+
* `resolvedAt` / `closedAt` are deliberately NOT in the update input. They are
|
|
96
|
+
* events, and the server is the only thing that knows when one happened —
|
|
97
|
+
* letting a client send them is how the reports collection ended up holding
|
|
98
|
+
* both Timestamps and ISO strings in one field.
|
|
99
|
+
*/
|
|
66
100
|
// ============================================================================
|
|
67
101
|
// COLLECTION CONSTANTS
|
|
68
102
|
// ============================================================================
|
|
@@ -182,6 +182,7 @@ export declare const supportTicketFirestoreSchema: z.ZodObject<{
|
|
|
182
182
|
closedAt?: string | Date | z.objectOutputType<{
|
|
183
183
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
184
184
|
}, z.ZodTypeAny, "passthrough"> | undefined;
|
|
185
|
+
assignedToName?: string | undefined;
|
|
185
186
|
relatedParties?: {
|
|
186
187
|
storeId?: string | undefined;
|
|
187
188
|
userId?: string | undefined;
|
|
@@ -189,7 +190,6 @@ export declare const supportTicketFirestoreSchema: z.ZodObject<{
|
|
|
189
190
|
productId?: string | undefined;
|
|
190
191
|
bidId?: string | undefined;
|
|
191
192
|
} | undefined;
|
|
192
|
-
assignedToName?: string | undefined;
|
|
193
193
|
internalNotes?: string | undefined;
|
|
194
194
|
}, {
|
|
195
195
|
status: "closed" | "open" | "resolved" | "in_progress" | "waiting_on_user";
|
|
@@ -225,6 +225,7 @@ export declare const supportTicketFirestoreSchema: z.ZodObject<{
|
|
|
225
225
|
closedAt?: string | Date | z.objectInputType<{
|
|
226
226
|
toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
|
|
227
227
|
}, z.ZodTypeAny, "passthrough"> | undefined;
|
|
228
|
+
assignedToName?: string | undefined;
|
|
228
229
|
relatedParties?: {
|
|
229
230
|
storeId?: string | undefined;
|
|
230
231
|
userId?: string | undefined;
|
|
@@ -232,7 +233,6 @@ export declare const supportTicketFirestoreSchema: z.ZodObject<{
|
|
|
232
233
|
productId?: string | undefined;
|
|
233
234
|
bidId?: string | undefined;
|
|
234
235
|
} | undefined;
|
|
235
|
-
assignedToName?: string | undefined;
|
|
236
236
|
internalNotes?: string | undefined;
|
|
237
237
|
}>;
|
|
238
238
|
export type SupportTicketFromSchema = z.infer<typeof supportTicketFirestoreSchema>;
|