@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.
Files changed (112) hide show
  1. package/dist/_internal/server/features/bundles/actions.js +7 -0
  2. package/dist/_internal/server/features/checkout/actions.js +24 -16
  3. package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
  4. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  5. package/dist/_internal/server/features/stores/adapters.js +5 -0
  6. package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
  7. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
  8. package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
  9. package/dist/_internal/shared/actions/action-registry.js +41 -0
  10. package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
  11. package/dist/_internal/shared/checkout/order-math.js +29 -6
  12. package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
  13. package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
  14. package/dist/_internal/shared/features/cart/config.d.ts +2 -1
  15. package/dist/_internal/shared/features/cart/config.js +2 -1
  16. package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
  17. package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
  18. package/dist/_internal/shared/features/notifications/action-url.js +111 -0
  19. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  20. package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
  21. package/dist/_internal/shared/history/index.d.ts +1 -1
  22. package/dist/_internal/shared/history/types.d.ts +16 -0
  23. package/dist/client.d.ts +2 -0
  24. package/dist/client.js +1 -0
  25. package/dist/constants/field-names.d.ts +12 -0
  26. package/dist/constants/field-names.js +12 -0
  27. package/dist/constants/notification-types.d.ts +38 -10
  28. package/dist/constants/notification-types.js +39 -28
  29. package/dist/features/account/types/index.d.ts +1 -10
  30. package/dist/features/admin/actions/admin-actions.js +13 -3
  31. package/dist/features/admin/actions/notification-actions.d.ts +12 -0
  32. package/dist/features/admin/actions/notification-actions.js +149 -4
  33. package/dist/features/admin/components/AdminBidsView.js +7 -1
  34. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  35. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  36. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  37. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  38. package/dist/features/admin/schemas/firestore.d.ts +14 -3
  39. package/dist/features/admin/schemas/firestore.js +79 -0
  40. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  41. package/dist/features/auctions/repository/bid.repository.js +57 -33
  42. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  43. package/dist/features/auctions/schemas/firestore.js +21 -0
  44. package/dist/features/auth/schemas/index.d.ts +2 -2
  45. package/dist/features/cart/actions/cart-actions.d.ts +16 -0
  46. package/dist/features/cart/actions/cart-actions.js +26 -12
  47. package/dist/features/cart/components/CartDrawer.js +2 -1
  48. package/dist/features/cart/schemas/firestore.d.ts +132 -8
  49. package/dist/features/cart/schemas/firestore.js +22 -0
  50. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  51. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  52. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  53. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  54. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  55. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  56. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  57. package/dist/features/catalogue/schemas/firestore.js +12 -0
  58. package/dist/features/categories/schemas/index.d.ts +2 -2
  59. package/dist/features/email/index.d.ts +1 -1
  60. package/dist/features/email/index.js +5 -1
  61. package/dist/features/email/notification-templates.d.ts +35 -0
  62. package/dist/features/email/notification-templates.js +96 -0
  63. package/dist/features/messages/schemas/index.d.ts +6 -6
  64. package/dist/features/orders/schemas/index.d.ts +14 -14
  65. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  66. package/dist/features/payments/repository/payout.repository.js +45 -19
  67. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  68. package/dist/features/payments/schemas/firestore.js +39 -0
  69. package/dist/features/payments/schemas/index.d.ts +12 -12
  70. package/dist/features/products/schemas/index.d.ts +2 -2
  71. package/dist/features/promotions/schemas/index.d.ts +12 -12
  72. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  73. package/dist/features/scams/repository/scammer.repository.js +24 -2
  74. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  75. package/dist/features/scams/schemas/firestore.js +30 -0
  76. package/dist/features/shipments/components/AdminShipmentEditorView.js +2 -1
  77. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  78. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  79. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  80. package/dist/features/shipments/schemas/firestore.js +19 -0
  81. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  82. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  83. package/dist/features/status-history/index.d.ts +2 -0
  84. package/dist/features/status-history/index.js +1 -0
  85. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  86. package/dist/features/stores/repository/store.repository.js +43 -4
  87. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  88. package/dist/features/stores/schemas/firestore.js +31 -0
  89. package/dist/features/support/repository/support.repository.d.ts +47 -3
  90. package/dist/features/support/repository/support.repository.js +72 -12
  91. package/dist/features/support/schemas/firestore.d.ts +38 -1
  92. package/dist/features/support/schemas/firestore.js +34 -0
  93. package/dist/features/support/schemas/index.d.ts +2 -2
  94. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +84 -0
  95. package/dist/index.d.ts +7 -4
  96. package/dist/index.js +15 -2
  97. package/dist/schemas/registry.d.ts +34 -34
  98. package/dist/seed/bids-seed-data.js +8 -0
  99. package/dist/seed/factories/notification.factory.d.ts +2 -1
  100. package/dist/server-entry.d.ts +1 -0
  101. package/dist/server-entry.js +5 -0
  102. package/dist/server.d.ts +5 -1
  103. package/dist/server.js +10 -1
  104. package/dist/styles.css +58 -1
  105. package/dist/tailwind-utilities.css +1 -1
  106. package/dist/ui/components/QuantityStepper.d.ts +38 -0
  107. package/dist/ui/components/QuantityStepper.js +20 -0
  108. package/dist/ui/components/QuantityStepper.style.css +56 -0
  109. package/dist/ui/components/index.style.css +1 -0
  110. package/dist/ui/index.d.ts +2 -0
  111. package/dist/ui/index.js +1 -0
  112. 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";
@@ -8,3 +8,4 @@
8
8
  * is an entirely unrelated concept.
9
9
  */
10
10
  export { StatusTimeline, stepsFromEntries } from "./components/StatusTimeline";
11
+ export { RecordStatusTimeline } 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 reject a store.
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
- setStatus(storeSlug: string, status: StoreDocument["status"]): Promise<StoreDocument>;
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 reject a store.
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
- return this.update(storeSlug, {
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
- updateTicketStatus(ticketId: string, update: SupportTicketUpdateInput): Promise<SupportTicketDocument>;
14
- addMessage(ticketId: string, message: TicketMessage, newStatus?: TicketStatus): Promise<void>;
15
- assignTicket(ticketId: string, assignedTo: string, assignedToName: string): Promise<SupportTicketDocument>;
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
- async updateTicketStatus(ticketId, update) {
77
- return this.update(ticketId, {
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
- async addMessage(ticketId, message, newStatus) {
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
- updateData.status = newStatus;
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
- return this.update(ticketId, {
102
- assignedTo,
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" | "resolvedAt" | "closedAt" | "relatedParties">>;
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>;