@mohasinac/appkit 4.21.0 → 4.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
  2. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/stores/adapters.js +5 -0
  4. package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
  5. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
  6. package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
  7. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  8. package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
  9. package/dist/_internal/shared/history/index.d.ts +1 -1
  10. package/dist/_internal/shared/history/types.d.ts +16 -0
  11. package/dist/client.d.ts +7 -0
  12. package/dist/client.js +4 -0
  13. package/dist/constants/field-names.d.ts +12 -0
  14. package/dist/constants/field-names.js +12 -0
  15. package/dist/features/account/components/AddressForm.js +45 -29
  16. package/dist/features/admin/actions/admin-actions.js +13 -3
  17. package/dist/features/admin/components/AdminBidsView.js +7 -1
  18. package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
  19. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  20. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  21. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  22. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  23. package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
  24. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  25. package/dist/features/auctions/repository/bid.repository.js +57 -33
  26. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  27. package/dist/features/auctions/schemas/firestore.js +21 -0
  28. package/dist/features/auth/schemas/index.d.ts +2 -2
  29. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  30. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  31. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  32. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  33. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
  34. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  35. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  36. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  37. package/dist/features/catalogue/schemas/firestore.js +12 -0
  38. package/dist/features/categories/schemas/index.d.ts +2 -2
  39. package/dist/features/layout/BottomActions.js +18 -1
  40. package/dist/features/layout/BottomActionsContext.d.ts +19 -0
  41. package/dist/features/layout/BottomActionsContext.js +37 -0
  42. package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
  43. package/dist/features/layout/hooks/useBottomActions.js +48 -11
  44. package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
  45. package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
  46. package/dist/features/layout/index.d.ts +2 -0
  47. package/dist/features/layout/index.js +1 -0
  48. package/dist/features/messages/schemas/index.d.ts +6 -6
  49. package/dist/features/orders/schemas/index.d.ts +12 -12
  50. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  51. package/dist/features/payments/repository/payout.repository.js +45 -19
  52. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  53. package/dist/features/payments/schemas/firestore.js +39 -0
  54. package/dist/features/payments/schemas/index.d.ts +12 -12
  55. package/dist/features/products/components/MakeOfferButton.js +5 -3
  56. package/dist/features/products/schemas/index.d.ts +2 -2
  57. package/dist/features/promotions/schemas/index.d.ts +12 -12
  58. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  59. package/dist/features/scams/repository/scammer.repository.js +24 -2
  60. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  61. package/dist/features/scams/schemas/firestore.js +30 -0
  62. package/dist/features/shell/QuickFormDrawer.js +15 -1
  63. package/dist/features/shell/SectionForm.d.ts +16 -2
  64. package/dist/features/shell/SectionForm.js +28 -2
  65. package/dist/features/shipments/components/AdminShipmentEditorView.js +5 -2
  66. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  67. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  68. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  69. package/dist/features/shipments/schemas/firestore.js +19 -0
  70. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  71. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  72. package/dist/features/status-history/index.d.ts +2 -0
  73. package/dist/features/status-history/index.js +1 -0
  74. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  75. package/dist/features/stores/repository/store.repository.js +43 -4
  76. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  77. package/dist/features/stores/schemas/firestore.js +31 -0
  78. package/dist/features/support/repository/support.repository.d.ts +47 -3
  79. package/dist/features/support/repository/support.repository.js +72 -12
  80. package/dist/features/support/schemas/firestore.d.ts +38 -1
  81. package/dist/features/support/schemas/firestore.js +34 -0
  82. package/dist/features/support/schemas/index.d.ts +2 -2
  83. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +90 -0
  84. package/dist/index.d.ts +7 -1
  85. package/dist/index.js +16 -1
  86. package/dist/schemas/registry.d.ts +32 -32
  87. package/dist/seed/bids-seed-data.js +8 -0
  88. package/dist/ui/components/Form.d.ts +22 -1
  89. package/dist/ui/components/Form.js +30 -2
  90. package/dist/ui/components/Modal.js +4 -3
  91. package/dist/ui/components/SideDrawer.js +5 -4
  92. package/dist/ui/components/overlay-context.d.ts +21 -0
  93. package/dist/ui/components/overlay-context.js +25 -0
  94. package/dist/ui/forms/FormErrorList.d.ts +29 -0
  95. package/dist/ui/forms/FormErrorList.js +36 -0
  96. package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
  97. package/dist/ui/forms/FormErrorSummary.js +15 -8
  98. package/dist/ui/forms/FormShell.d.ts +25 -1
  99. package/dist/ui/forms/FormShell.js +39 -4
  100. package/dist/ui/forms/index.d.ts +2 -0
  101. package/dist/ui/forms/index.js +1 -0
  102. package/package.json +1 -1
@@ -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>;
@@ -1156,6 +1156,96 @@ const rawTesterChecklistItems = [
1156
1156
  description: "The fix had to cover the update path as well as create — a create-only fix would mean the listing saved correctly and then lost its per-type block the first time anyone edited anything else (Root Cause #39).",
1157
1157
  href: "/store/classified",
1158
1158
  },
1159
+ {
1160
+ key: "ticket-resolution-timestamp-stamped",
1161
+ label: "Resolving a support ticket records WHEN it was resolved",
1162
+ description: "`resolvedAt` and `closedAt` were declared on the document, had field-name constants, were listed in the update input — and nothing had ever written either (verified 2026-08-26), so no resolved ticket carried a resolution time. Move a ticket to Resolved, reopen it, and confirm the timeline shows a real date on that step. Re-save an unrelated field afterwards: the resolution time must NOT move forward.",
1163
+ href: "/admin/support-tickets",
1164
+ },
1165
+ {
1166
+ key: "payout-utr-actually-saves",
1167
+ label: "The UTR entered when marking a payout paid is still there on reload",
1168
+ description: "The mark-paid modal REQUIRES a transaction reference, the route declared it, its own test asserted it reached the action — and the action picked only status/adminNote/processedAt, so the UTR was dropped while the 200 echoed it back as if stored. Mark a payout paid with a reference, reload the payout, and confirm 'Transaction ref' shows it.",
1169
+ href: "/admin/payouts",
1170
+ },
1171
+ {
1172
+ key: "store-approval-makes-it-public",
1173
+ label: "Approving a pending store actually makes it visible on /stores",
1174
+ description: "`status` and `isPublic` are two distinct fields and public visibility gates on `isPublic`. The admin PATCH wrote `status` directly and never touched `isPublic`, so an approved store was active AND invisible with no error anywhere. Approve `store-blader-bazaar`, then open /stores and confirm it appears.",
1175
+ href: "/admin/stores",
1176
+ },
1177
+ {
1178
+ key: "store-timeline-shows-who-suspended",
1179
+ label: "A suspended store's page shows who suspended it, when, and why",
1180
+ description: "Previously recoverable only from /admin/audit-log, which a store owner can never see. Suspend a store with a reason, open its admin detail page, and confirm the History block names the admin, the time, and the reason.",
1181
+ href: "/admin/stores",
1182
+ },
1183
+ {
1184
+ key: "payout-failure-reasons-survive-retries",
1185
+ label: "A payout that failed twice shows BOTH reasons, not just the last",
1186
+ description: "`lastFailureReason` is overwritten by every retry, so the timeline is the only place earlier attempts survive. Also confirm the four dispatch fields (razorpayPayoutId, razorpayStatus, failureCount, lastFailureReason) render — they were written by the batch and UNDECLARED on the document until 2026-08-26, so nothing could safely surface them.",
1187
+ href: "/admin/payouts",
1188
+ },
1189
+ {
1190
+ key: "bid-timeline-explains-forfeited",
1191
+ label: "A forfeited bid says why it was forfeited",
1192
+ description: "Open a forfeited bid from /admin/bids. The History block must read 'Forfeited' with the system actor and the reason 'The 48-hour checkout window closed without payment.' A bid moves through more states than any record a buyer owns and every one of them happened inside a settlement batch with nothing recording it.",
1193
+ href: "/admin/bids",
1194
+ },
1195
+ {
1196
+ key: "catalogue-rejection-reason-survives",
1197
+ label: "A catalogue item rejected twice keeps both reasons",
1198
+ description: "`rejectionReason` holds only the last decision. Reject a submission, have the owner resubmit, reject again with a different reason — the approval modal's History block must show both, each with its own date.",
1199
+ href: "/admin/catalogue-approvals",
1200
+ },
1201
+ {
1202
+ key: "history-absent-renders-empty-not-invented",
1203
+ label: "A record created BEFORE history existed shows 'no recorded history', not a made-up date",
1204
+ description: "Never fabricate a timestamp. Open any store, payout or ticket that predates this feature: the History block must say there is none, NOT derive a step from `updatedAt` — 'last write of any kind' is not an event, and a made-up date makes the record lie.",
1205
+ href: "/admin/stores",
1206
+ },
1207
+ {
1208
+ key: "history-carries-no-pii",
1209
+ label: "No name or email appears anywhere in a record's History block",
1210
+ description: "`encryptPiiFields` is a flat top-level loop that never descends into arrays, so a PII value inside statusHistory would be stored in PLAINTEXT. Every entry shows a ROLE (Admin/System/Buyer/Store) and never a name. Check a payout (sellerEmail/upiId), a bid (userName/userEmail) and a scammer profile (built entirely of identifying details).",
1211
+ href: "/admin/payouts",
1212
+ },
1213
+ {
1214
+ key: "form-errors-wait-for-first-submit",
1215
+ label: "Opening the address form shows NO errors until you press Save",
1216
+ description: "Live bug fixed 2026-08-26: every schema-driven form greeted the user with a list of its own requirements as errors before they had typed anything. Two causes compounded — <FormErrorSummary> was deliberately un-gated, and ~20 views run validate(draft) in a mount effect, so an untouched empty draft failed every .min(1) on first paint. Open /user/addresses/new: the summary block must be absent. Press Save with the form empty: NOW it lists the missing fields.",
1217
+ href: "/user/addresses/new",
1218
+ },
1219
+ {
1220
+ key: "form-errors-stay-live-after-first-submit",
1221
+ label: "After a failed Save, the error list SHRINKS as you fill fields in",
1222
+ description: "The fix gates the DISPLAY, not the computation — the live validation underneath is what makes the list useful. Press Save on an empty address form, then fill Full name. The summary must drop that entry immediately, without a second Save. If the list is frozen, the on-mount validate() was removed instead of gated, which is the wrong fix.",
1223
+ href: "/user/addresses/new",
1224
+ },
1225
+ {
1226
+ key: "form-mobile-bar-pinned",
1227
+ label: "On a phone, Save and Cancel are pinned above the tab bar on the address form",
1228
+ description: "Added 2026-08-26 (W21). Narrow the window below 1024px, or use a real phone. Save/Cancel should sit in the same fixed bar the cart uses for 'Proceed to checkout' — above the bottom tab bar, not scrolled off the end of a long form. On desktop the bar is hidden and the inline Save/Cancel row at the bottom of the form is the control.",
1229
+ href: "/user/addresses/new",
1230
+ },
1231
+ {
1232
+ key: "form-mobile-error-sheet",
1233
+ label: "On a phone, a failed Save opens a 'Fix N issues' sheet you can tap through",
1234
+ description: "Below 1024px, press Save on an empty address form. A 'Fix 6 issues' row must appear above the buttons with the list already open. Collapse it, press Save again — it must RE-open (the count is a counter, not a boolean, precisely so a second attempt is a new event). Tapping an entry on a sectioned form jumps to that field.",
1235
+ href: "/user/addresses/new",
1236
+ },
1237
+ {
1238
+ key: "form-bar-restores-listing-bulk-bar",
1239
+ label: "Closing a form drawer over a listing brings the listing's bulk bar back",
1240
+ description: "Regression test for the singleton clobber: there is ONE bottom bar per route and DataListingView claims it on ~70 admin screens. Select a few rows on an admin listing so the bulk bar appears, open a row's editor drawer, then close it — the bulk bar must still be there with the selection intact. Before the claim stack landed, the drawer overwrote it and blanked it on close.",
1241
+ href: "/admin/products",
1242
+ },
1243
+ {
1244
+ key: "form-bar-absent-inside-a-modal",
1245
+ label: "A form inside a modal or drawer does NOT get a second bar at the bottom of the screen",
1246
+ description: "An overlay owns its own footer, and a viewport-fixed bar would render behind the backdrop, below the dialog it belongs to. Open any drawer-based editor on a phone-width window and confirm the only Save/Cancel controls are the ones inside the drawer.",
1247
+ href: "/admin/coupons",
1248
+ },
1159
1249
  {
1160
1250
  key: "address-routes-normalised",
1161
1251
  label: "Adding and editing an address works, and the OLD urls still resolve",
package/dist/index.d.ts CHANGED
@@ -2120,6 +2120,10 @@ export { SidebarLayout } from "./features/layout/index";
2120
2120
  export { TitleBar } from "./features/layout/index";
2121
2121
  export { TitleBarLayout } from "./features/layout/index";
2122
2122
  export { useBottomActions } from "./features/layout/index";
2123
+ export { RecordStatusTimeline } from "./features/status-history/index";
2124
+ export { useFormBottomActions } from "./features/layout/index";
2125
+ export { FormErrorList } from "./ui/forms";
2126
+ export { OverlayContext, useIsInsideOverlay } from "./ui/components/overlay-context";
2123
2127
  export { useBottomActionsContext } from "./features/layout/index";
2124
2128
  export { useDashboardNav } from "./features/layout/index";
2125
2129
  export type { AppLayoutShellProps } from "./features/layout/index";
@@ -3233,7 +3237,7 @@ export { getListingRule, getCategoryRule, pickOrderType, getSplitKey, runSyncPre
3233
3237
  export type { ListingCheckoutRule, CategoryCheckoutRule, CartItemProductPair, OrderItemInput, RefundPolicy, } from "./_internal/shared/checkout/rules";
3234
3238
  export { MEGABYTE, MAX_IMAGE_BYTES, MAX_PDF_BYTES, MAX_VIDEO_BYTES, MAX_LABEL, MAX_BYTES, ALLOWED_IMAGE_MIMES, ALLOWED_VIDEO_MIMES, ALLOWED_DOC_MIMES, ALLOWED_MIMES, ALLOWED_TYPES_LABEL, MIME_TO_EXT, PDF_MAGIC, VIDEO_CONVERSION_HINTS, classifyMime, isAllowedMime, maxBytesFor, getConversionHint, } from "./_internal/shared/media/limits";
3235
3239
  export type { MediaKind, AllowedImageMime, AllowedVideoMime, AllowedDocMime, AllowedMime, } from "./_internal/shared/media/limits";
3236
- export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS, BID_FIELDS, AD_FIELDS, BLOG_FIELDS, BRAND_FIELDS, WISHLIST_FIELDS, HISTORY_FIELDS, COUPON_USAGE_FIELDS, CONVERSATION_FIELDS, CAROUSEL_FIELDS, FAQ_FIELDS, HOMEPAGE_SECTION_FIELDS, SITE_SETTINGS_FIELDS, COMMON_FIELDS, OAUTH_STATE_VALUES, SCHEMA_DEFAULTS, } from "./constants/field-names";
3240
+ export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS, BID_FIELDS, AD_FIELDS, AD_TRACKED_FIELDS, BLOG_FIELDS, BRAND_FIELDS, WISHLIST_FIELDS, HISTORY_FIELDS, COUPON_USAGE_FIELDS, CONVERSATION_FIELDS, CAROUSEL_FIELDS, FAQ_FIELDS, HOMEPAGE_SECTION_FIELDS, SITE_SETTINGS_FIELDS, COMMON_FIELDS, OAUTH_STATE_VALUES, SCHEMA_DEFAULTS, } from "./constants/field-names";
3237
3241
  export { SORT_DIR, sortBy } from "./constants/sort";
3238
3242
  export type { SortDirection } from "./constants/sort";
3239
3243
  export { TABLE_KEYS, VIEW_MODE } from "./constants/table-keys";
@@ -3294,3 +3298,5 @@ export { adminUserUpdateSchema, whatsappSettingsSchema, lotteryPullSchema, } fro
3294
3298
  export { siteSettingsFormSchema } from "./features/admin/schemas/site-settings-form";
3295
3299
  export { createReviewSchema as reviewSubmitSchema } from "./_internal/shared/features/reviews/schema";
3296
3300
  export { wishlistAddSchema } from "./schemas/api-routes";
3301
+ export { withHistory, buildHistoryEntry, appendHistoryEntry, diffTrackedFields, STATUS_HISTORY_MAX, } from "./_internal/shared/history/index";
3302
+ export type { StatusChangeEntry, SerialisedStatusChangeEntry, HistoryActor, HistoryActorRole, FieldChange, } from "./_internal/shared/history/index";
package/dist/index.js CHANGED
@@ -3934,6 +3934,11 @@ export { TitleBarLayout } from "./features/layout/index";
3934
3934
  // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
3935
3935
  // useBottomActions - React hook for use bottom actions.
3936
3936
  export { useBottomActions } from "./features/layout/index";
3937
+ export { RecordStatusTimeline } from "./features/status-history/index";
3938
+ // useFormBottomActions - pins a form's Save/Cancel row + error sheet to the mobile bottom-chrome tier.
3939
+ export { useFormBottomActions } from "./features/layout/index";
3940
+ export { FormErrorList } from "./ui/forms";
3941
+ export { OverlayContext, useIsInsideOverlay } from "./ui/components/overlay-context";
3937
3942
  // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
3938
3943
  // useBottomActionsContext - React hook for use bottom actions context.
3939
3944
  export { useBottomActionsContext } from "./features/layout/index";
@@ -5637,7 +5642,7 @@ export { MEGABYTE, MAX_IMAGE_BYTES, MAX_PDF_BYTES, MAX_VIDEO_BYTES, MAX_LABEL, M
5637
5642
  // NOTE: STORE_FIELDS, CATEGORY_FIELDS, EVENT_FIELDS, EVENT_ENTRY_FIELDS, PAYOUT_FIELDS,
5638
5643
  // COUPON_FIELDS, CART_FIELDS, USER_FIELDS, ADDRESS_FIELDS, NOTIFICATION_FIELDS,
5639
5644
  // SESSION_FIELDS, SCAMMER_FIELDS are already exported from feature barrels above.
5640
- export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS, BID_FIELDS, AD_FIELDS, BLOG_FIELDS, BRAND_FIELDS, WISHLIST_FIELDS, HISTORY_FIELDS, COUPON_USAGE_FIELDS, CONVERSATION_FIELDS, CAROUSEL_FIELDS, FAQ_FIELDS, HOMEPAGE_SECTION_FIELDS, SITE_SETTINGS_FIELDS, COMMON_FIELDS, OAUTH_STATE_VALUES, SCHEMA_DEFAULTS, } from "./constants/field-names";
5645
+ export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS, BID_FIELDS, AD_FIELDS, AD_TRACKED_FIELDS, BLOG_FIELDS, BRAND_FIELDS, WISHLIST_FIELDS, HISTORY_FIELDS, COUPON_USAGE_FIELDS, CONVERSATION_FIELDS, CAROUSEL_FIELDS, FAQ_FIELDS, HOMEPAGE_SECTION_FIELDS, SITE_SETTINGS_FIELDS, COMMON_FIELDS, OAUTH_STATE_VALUES, SCHEMA_DEFAULTS, } from "./constants/field-names";
5641
5646
  // ── Sort helpers ──────────────────────────────────────────────────────────────
5642
5647
  export { SORT_DIR, sortBy } from "./constants/sort";
5643
5648
  // ── URL table keys + view mode ────────────────────────────────────────────────
@@ -5711,3 +5716,13 @@ export { siteSettingsFormSchema } from "./features/admin/schemas/site-settings-f
5711
5716
  */
5712
5717
  export { createReviewSchema as reviewSubmitSchema } from "./_internal/shared/features/reviews/schema";
5713
5718
  export { wishlistAddSchema } from "./schemas/api-routes";
5719
+ /*
5720
+ * Status history — the generic primitive behind every entity timeline.
5721
+ *
5722
+ * `withHistory` is exported because ads have no repository of their own: they
5723
+ * live inside the `siteSettings` singleton, so their route calls it directly.
5724
+ * Every other adopter funnels through its repository's write primitives and
5725
+ * should keep doing so — a call site reaching for this outside a repository
5726
+ * is usually a missing funnel, not a reason to export more of it.
5727
+ */
5728
+ export { withHistory, buildHistoryEntry, appendHistoryEntry, diffTrackedFields, STATUS_HISTORY_MAX, } from "./_internal/shared/history/index";