@devlider001/washlab-backend 1.0.4 → 1.0.5

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 (73) hide show
  1. package/convex/_generated/api.d.ts +2 -0
  2. package/convex/audit.d.ts +39 -0
  3. package/convex/notifications.d.ts +170 -0
  4. package/convex/schema.d.ts +51 -0
  5. package/dist/convex/admin.d.ts +377 -0
  6. package/dist/convex/admin.d.ts.map +1 -0
  7. package/dist/convex/admin.js +959 -0
  8. package/dist/convex/admin.js.map +1 -0
  9. package/dist/convex/analytics.d.ts +87 -0
  10. package/dist/convex/analytics.d.ts.map +1 -0
  11. package/dist/convex/analytics.js +361 -0
  12. package/dist/convex/analytics.js.map +1 -0
  13. package/dist/convex/attendants.d.ts +140 -0
  14. package/dist/convex/attendants.d.ts.map +1 -0
  15. package/dist/convex/attendants.js +337 -0
  16. package/dist/convex/attendants.js.map +1 -0
  17. package/dist/convex/audit.d.ts +158 -0
  18. package/dist/convex/audit.d.ts.map +1 -0
  19. package/dist/convex/audit.js +184 -0
  20. package/dist/convex/audit.js.map +1 -0
  21. package/dist/convex/clerk.d.ts +53 -0
  22. package/dist/convex/clerk.d.ts.map +1 -0
  23. package/dist/convex/clerk.js +316 -0
  24. package/dist/convex/clerk.js.map +1 -0
  25. package/dist/convex/customers.d.ts +224 -0
  26. package/dist/convex/customers.d.ts.map +1 -0
  27. package/dist/convex/customers.js +504 -0
  28. package/dist/convex/customers.js.map +1 -0
  29. package/dist/convex/http.d.ts +3 -0
  30. package/dist/convex/http.d.ts.map +1 -0
  31. package/dist/convex/http.js +115 -0
  32. package/dist/convex/http.js.map +1 -0
  33. package/dist/convex/lib/audit.d.ts +36 -0
  34. package/dist/convex/lib/audit.d.ts.map +1 -0
  35. package/dist/convex/lib/audit.js +59 -0
  36. package/dist/convex/lib/audit.js.map +1 -0
  37. package/dist/convex/lib/auth.d.ts +96 -0
  38. package/dist/convex/lib/auth.d.ts.map +1 -0
  39. package/dist/convex/lib/auth.js +94 -0
  40. package/dist/convex/lib/auth.js.map +1 -0
  41. package/dist/convex/lib/utils.d.ts +38 -0
  42. package/dist/convex/lib/utils.d.ts.map +1 -0
  43. package/dist/convex/lib/utils.js +71 -0
  44. package/dist/convex/lib/utils.js.map +1 -0
  45. package/dist/convex/loyalty.d.ts +82 -0
  46. package/dist/convex/loyalty.d.ts.map +1 -0
  47. package/dist/convex/loyalty.js +286 -0
  48. package/dist/convex/loyalty.js.map +1 -0
  49. package/dist/convex/orders.d.ts +326 -0
  50. package/dist/convex/orders.d.ts.map +1 -0
  51. package/dist/convex/orders.js +570 -0
  52. package/dist/convex/orders.js.map +1 -0
  53. package/dist/convex/payments.d.ts +134 -0
  54. package/dist/convex/payments.d.ts.map +1 -0
  55. package/dist/convex/payments.js +360 -0
  56. package/dist/convex/payments.js.map +1 -0
  57. package/dist/convex/resources.d.ts +119 -0
  58. package/dist/convex/resources.d.ts.map +1 -0
  59. package/dist/convex/resources.js +283 -0
  60. package/dist/convex/resources.js.map +1 -0
  61. package/dist/convex/schema.d.ts +450 -0
  62. package/dist/convex/schema.d.ts.map +1 -0
  63. package/dist/convex/schema.js +347 -0
  64. package/dist/convex/schema.js.map +1 -0
  65. package/dist/convex/vouchers.d.ts +187 -0
  66. package/dist/convex/vouchers.d.ts.map +1 -0
  67. package/dist/convex/vouchers.js +464 -0
  68. package/dist/convex/vouchers.js.map +1 -0
  69. package/dist/src/index.d.ts +9 -0
  70. package/dist/src/index.d.ts.map +1 -0
  71. package/dist/src/index.js +9 -0
  72. package/dist/src/index.js.map +1 -0
  73. package/package.json +3 -3
@@ -0,0 +1,959 @@
1
+ import { query, mutation } from "./_generated/server";
2
+ import { v } from "convex/values";
3
+ import { paginationOptsValidator } from "convex/server";
4
+ import { getCurrentAdmin, getSuperAdmin, getCurrentAttendant, verifyAttendantBranch } from "./lib/auth";
5
+ import { createAuditLog } from "./lib/audit";
6
+ import { getCurrentTimestamp } from "./lib/utils";
7
+ /**
8
+ * Admin Functions
9
+ *
10
+ * Handles admin operations: branch management, attendant management,
11
+ * system-wide order viewing, analytics, and customer management.
12
+ */
13
+ /**
14
+ * Get current admin profile (from authenticated Clerk session)
15
+ */
16
+ export const getCurrentUser = query({
17
+ args: {},
18
+ handler: async (ctx) => {
19
+ const admin = await getCurrentAdmin(ctx);
20
+ return admin;
21
+ },
22
+ });
23
+ /**
24
+ * Get all branches - Paginated
25
+ * Supports usePaginatedQuery for infinite scroll
26
+ */
27
+ export const getBranches = query({
28
+ args: {
29
+ includeInactive: v.optional(v.boolean()),
30
+ cursor: v.optional(v.string()),
31
+ numItems: v.optional(v.number()),
32
+ },
33
+ handler: async (ctx, args) => {
34
+ await getCurrentAdmin(ctx);
35
+ const numItems = args.numItems ?? 20;
36
+ const result = await ctx.db
37
+ .query("branches")
38
+ .filter((q) => q.eq(q.field("isDeleted"), false))
39
+ .paginate({
40
+ cursor: args.cursor ?? null,
41
+ numItems,
42
+ });
43
+ let filtered = result.page;
44
+ if (!args.includeInactive) {
45
+ filtered = filtered.filter((b) => b.isActive);
46
+ }
47
+ return {
48
+ page: filtered,
49
+ isDone: result.isDone,
50
+ continueCursor: result.continueCursor,
51
+ };
52
+ },
53
+ });
54
+ /**
55
+ * Get branch by code
56
+ */
57
+ export const getBranchByCode = query({
58
+ args: {
59
+ code: v.string(),
60
+ },
61
+ handler: async (ctx, args) => {
62
+ // Allow both attendants and admins
63
+ try {
64
+ await getCurrentAttendant(ctx);
65
+ }
66
+ catch {
67
+ await getCurrentAdmin(ctx);
68
+ }
69
+ const branch = await ctx.db
70
+ .query("branches")
71
+ .withIndex("by_code", (q) => q.eq("code", args.code.toUpperCase()))
72
+ .first();
73
+ if (!branch || branch.isDeleted) {
74
+ return null;
75
+ }
76
+ return branch;
77
+ },
78
+ });
79
+ /**
80
+ * Get single branch by ID
81
+ */
82
+ export const getBranch = query({
83
+ args: {
84
+ branchId: v.id("branches"),
85
+ },
86
+ handler: async (ctx, args) => {
87
+ // Allow both attendants and admins
88
+ try {
89
+ const attendant = await getCurrentAttendant(ctx);
90
+ await verifyAttendantBranch(ctx, attendant._id, args.branchId);
91
+ }
92
+ catch {
93
+ await getCurrentAdmin(ctx);
94
+ }
95
+ const branch = await ctx.db.get(args.branchId);
96
+ if (!branch || branch.isDeleted) {
97
+ return null;
98
+ }
99
+ return branch;
100
+ },
101
+ });
102
+ /**
103
+ * Get all attendants - Paginated
104
+ * Supports usePaginatedQuery for infinite scroll
105
+ */
106
+ export const getAttendants = query({
107
+ args: {
108
+ branchId: v.optional(v.id("branches")),
109
+ includeInactive: v.optional(v.boolean()),
110
+ cursor: v.optional(v.string()),
111
+ numItems: v.optional(v.number()),
112
+ },
113
+ handler: async (ctx, args) => {
114
+ await getCurrentAdmin(ctx);
115
+ const numItems = args.numItems ?? 20;
116
+ let query;
117
+ if (args.branchId) {
118
+ query = ctx.db
119
+ .query("attendants")
120
+ .withIndex("by_branch", (q) => q.eq("branchId", args.branchId));
121
+ }
122
+ else {
123
+ query = ctx.db.query("attendants");
124
+ }
125
+ const result = await query
126
+ .filter((q) => q.eq(q.field("isDeleted"), false))
127
+ .paginate({
128
+ cursor: args.cursor ?? null,
129
+ numItems,
130
+ });
131
+ let filtered = result.page;
132
+ if (!args.includeInactive) {
133
+ filtered = filtered.filter((a) => a.isActive);
134
+ }
135
+ return {
136
+ page: filtered,
137
+ isDone: result.isDone,
138
+ continueCursor: result.continueCursor,
139
+ };
140
+ },
141
+ });
142
+ /**
143
+ * Get all orders (with filters) - Paginated
144
+ * Supports usePaginatedQuery for infinite scroll
145
+ */
146
+ export const getOrders = query({
147
+ args: {
148
+ branchId: v.optional(v.id("branches")),
149
+ status: v.optional(v.union(v.literal("pending"), v.literal("in_progress"), v.literal("ready_for_pickup"), v.literal("delivered"), v.literal("completed"), v.literal("cancelled"))),
150
+ startDate: v.optional(v.number()),
151
+ endDate: v.optional(v.number()),
152
+ cursor: v.optional(v.string()),
153
+ numItems: v.optional(v.number()),
154
+ },
155
+ handler: async (ctx, args) => {
156
+ await getCurrentAdmin(ctx);
157
+ const numItems = args.numItems ?? 20;
158
+ // Build base query
159
+ let query;
160
+ if (args.branchId) {
161
+ query = ctx.db
162
+ .query("orders")
163
+ .withIndex("by_branch", (q) => q.eq("branchId", args.branchId))
164
+ .order("desc");
165
+ }
166
+ else {
167
+ query = ctx.db
168
+ .query("orders")
169
+ .withIndex("by_created")
170
+ .order("desc");
171
+ }
172
+ // Apply pagination
173
+ const result = await query
174
+ .filter((q) => q.eq(q.field("isDeleted"), false))
175
+ .paginate({
176
+ cursor: args.cursor ?? null,
177
+ numItems,
178
+ });
179
+ // Apply additional filters
180
+ let filtered = result.page;
181
+ if (args.status) {
182
+ filtered = filtered.filter((o) => o.status === args.status);
183
+ }
184
+ if (args.startDate) {
185
+ filtered = filtered.filter((o) => o.createdAt >= args.startDate);
186
+ }
187
+ if (args.endDate) {
188
+ filtered = filtered.filter((o) => o.createdAt <= args.endDate);
189
+ }
190
+ return {
191
+ page: filtered,
192
+ isDone: result.isDone,
193
+ continueCursor: result.continueCursor,
194
+ };
195
+ },
196
+ });
197
+ /**
198
+ * Get analytics dashboard data
199
+ */
200
+ export const getAnalytics = query({
201
+ args: {
202
+ branchId: v.optional(v.id("branches")),
203
+ startDate: v.optional(v.number()),
204
+ endDate: v.optional(v.number()),
205
+ },
206
+ handler: async (ctx, args) => {
207
+ await getCurrentAdmin(ctx);
208
+ // Get all orders in date range
209
+ let orders;
210
+ if (args.branchId) {
211
+ orders = await ctx.db
212
+ .query("orders")
213
+ .withIndex("by_branch", (q) => q.eq("branchId", args.branchId))
214
+ .collect();
215
+ }
216
+ else {
217
+ orders = await ctx.db.query("orders").collect();
218
+ }
219
+ let filtered = orders.filter((o) => !o.isDeleted);
220
+ if (args.startDate) {
221
+ filtered = filtered.filter((o) => o.createdAt >= args.startDate);
222
+ }
223
+ if (args.endDate) {
224
+ filtered = filtered.filter((o) => o.createdAt <= args.endDate);
225
+ }
226
+ // Calculate metrics
227
+ const totalOrders = filtered.length;
228
+ const totalRevenue = filtered
229
+ .filter((o) => o.paymentStatus === "paid")
230
+ .reduce((sum, o) => sum + o.finalPrice, 0);
231
+ const ordersByStatus = filtered.reduce((acc, o) => {
232
+ acc[o.status] = (acc[o.status] || 0) + 1;
233
+ return acc;
234
+ }, {});
235
+ const ordersByDay = filtered.reduce((acc, o) => {
236
+ const date = new Date(o.createdAt).toISOString().split("T")[0];
237
+ acc[date] = (acc[date] || 0) + 1;
238
+ return acc;
239
+ }, {});
240
+ // Top customers by order count
241
+ const customerOrderCounts = filtered.reduce((acc, o) => {
242
+ acc[o.customerId] = (acc[o.customerId] || 0) + 1;
243
+ return acc;
244
+ }, {});
245
+ const topCustomerIds = Object.entries(customerOrderCounts)
246
+ .sort(([, a], [, b]) => b - a)
247
+ .slice(0, 10)
248
+ .map(([id]) => id);
249
+ const topCustomers = await Promise.all(topCustomerIds.map(async (id) => {
250
+ const customer = await ctx.db.get(id);
251
+ return {
252
+ customerId: id,
253
+ name: customer?.name || "Unknown",
254
+ phoneNumber: customer?.phoneNumber || "",
255
+ orderCount: customerOrderCounts[id],
256
+ };
257
+ }));
258
+ const averageOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
259
+ return {
260
+ totalOrders,
261
+ totalRevenue,
262
+ averageOrderValue: Math.round(averageOrderValue * 100) / 100,
263
+ ordersByStatus,
264
+ ordersByDay,
265
+ topCustomers,
266
+ dateRange: {
267
+ start: args.startDate,
268
+ end: args.endDate,
269
+ },
270
+ };
271
+ },
272
+ });
273
+ /**
274
+ * Get all customers (with filters) - Paginated
275
+ * Supports usePaginatedQuery for infinite scroll
276
+ */
277
+ export const getCustomers = query({
278
+ args: {
279
+ search: v.optional(v.string()), // Search by name or phone
280
+ status: v.optional(v.union(v.literal("active"), v.literal("blocked"), v.literal("suspended"), v.literal("restricted"))), // Filter by status
281
+ isRegistered: v.optional(v.boolean()), // Filter by registered status
282
+ paginationOpts: paginationOptsValidator, // Pagination options from usePaginatedQuery
283
+ },
284
+ handler: async (ctx, args) => {
285
+ await getCurrentAdmin(ctx);
286
+ // Get all customers with pagination
287
+ let result;
288
+ // Use index if filtering by status
289
+ if (args.status) {
290
+ result = await ctx.db
291
+ .query("users")
292
+ .withIndex("by_status", (q) => q.eq("status", args.status))
293
+ .filter((q) => q.eq(q.field("isDeleted"), false))
294
+ .paginate(args.paginationOpts);
295
+ }
296
+ else {
297
+ result = await ctx.db
298
+ .query("users")
299
+ .filter((q) => q.eq(q.field("isDeleted"), false))
300
+ .paginate(args.paginationOpts);
301
+ }
302
+ let filtered = result.page;
303
+ // Apply search filter
304
+ if (args.search) {
305
+ const searchLower = args.search.toLowerCase();
306
+ filtered = filtered.filter((c) => c.name.toLowerCase().includes(searchLower) ||
307
+ c.phoneNumber.includes(args.search));
308
+ }
309
+ // Apply registered filter
310
+ if (args.isRegistered !== undefined) {
311
+ filtered = filtered.filter((c) => c.isRegistered === args.isRegistered);
312
+ }
313
+ // Enrich with order stats
314
+ const customersWithStats = await Promise.all(filtered.map(async (customer) => {
315
+ const orders = await ctx.db
316
+ .query("orders")
317
+ .withIndex("by_customer", (q) => q.eq("customerId", customer._id))
318
+ .collect();
319
+ const activeOrders = orders.filter((o) => !o.isDeleted);
320
+ const completedOrders = activeOrders.filter((o) => o.status === "completed");
321
+ const totalSpent = activeOrders
322
+ .filter((o) => o.paymentStatus === "paid")
323
+ .reduce((sum, o) => sum + o.finalPrice, 0);
324
+ const lastOrder = activeOrders
325
+ .sort((a, b) => b.createdAt - a.createdAt)[0];
326
+ return {
327
+ ...customer,
328
+ orderCount: activeOrders.length,
329
+ completedOrderCount: completedOrders.length,
330
+ totalSpent: Math.round(totalSpent * 100) / 100,
331
+ lastOrderDate: lastOrder?.createdAt,
332
+ lastOrderNumber: lastOrder?.orderNumber,
333
+ };
334
+ }));
335
+ return {
336
+ page: customersWithStats,
337
+ isDone: result.isDone,
338
+ continueCursor: result.continueCursor,
339
+ };
340
+ },
341
+ });
342
+ /**
343
+ * Get customer statistics/aggregations
344
+ */
345
+ export const getCustomerStats = query({
346
+ args: {},
347
+ handler: async (ctx) => {
348
+ await getCurrentAdmin(ctx);
349
+ const allCustomers = await ctx.db
350
+ .query("users")
351
+ .filter((q) => q.eq(q.field("isDeleted"), false))
352
+ .collect();
353
+ const totalCustomers = allCustomers.length;
354
+ const registeredCustomers = allCustomers.filter((c) => c.isRegistered).length;
355
+ const walkInCustomers = allCustomers.filter((c) => !c.isRegistered).length;
356
+ const activeStatusCustomers = allCustomers.filter((c) => c.status === "active").length;
357
+ const blockedCustomers = allCustomers.filter((c) => c.status === "blocked").length;
358
+ const suspendedCustomers = allCustomers.filter((c) => c.status === "suspended").length;
359
+ const restrictedCustomers = allCustomers.filter((c) => c.status === "restricted").length;
360
+ const verifiedCustomers = allCustomers.filter((c) => c.isVerified).length;
361
+ // Get customers with orders
362
+ const customersWithOrders = await Promise.all(allCustomers.map(async (customer) => {
363
+ const orders = await ctx.db
364
+ .query("orders")
365
+ .withIndex("by_customer", (q) => q.eq("customerId", customer._id))
366
+ .collect();
367
+ return {
368
+ customerId: customer._id,
369
+ hasOrders: orders.filter((o) => !o.isDeleted).length > 0,
370
+ };
371
+ }));
372
+ const customersWithActiveOrders = customersWithOrders.filter((c) => c.hasOrders).length;
373
+ // Calculate total revenue from all customers
374
+ let totalRevenue = 0;
375
+ for (const customer of allCustomers) {
376
+ const orders = await ctx.db
377
+ .query("orders")
378
+ .withIndex("by_customer", (q) => q.eq("customerId", customer._id))
379
+ .collect();
380
+ const customerRevenue = orders
381
+ .filter((o) => !o.isDeleted && o.paymentStatus === "paid")
382
+ .reduce((sum, o) => sum + o.finalPrice, 0);
383
+ totalRevenue += customerRevenue;
384
+ }
385
+ return {
386
+ totalCustomers,
387
+ registeredCustomers,
388
+ walkInCustomers,
389
+ activeCustomers: activeStatusCustomers,
390
+ blockedCustomers,
391
+ suspendedCustomers,
392
+ restrictedCustomers,
393
+ verifiedCustomers,
394
+ customersWithActiveOrders,
395
+ totalRevenue: Math.round(totalRevenue * 100) / 100,
396
+ };
397
+ },
398
+ });
399
+ /**
400
+ * Change customer status (with required note)
401
+ */
402
+ export const changeCustomerStatus = mutation({
403
+ args: {
404
+ customerId: v.id("users"),
405
+ status: v.union(v.literal("active"), v.literal("blocked"), v.literal("suspended"), v.literal("restricted")),
406
+ note: v.string(), // Required note explaining the status change
407
+ },
408
+ handler: async (ctx, args) => {
409
+ const admin = await getCurrentAdmin(ctx);
410
+ if (!args.note || args.note.trim().length < 3) {
411
+ throw new Error("Note is required and must be at least 3 characters");
412
+ }
413
+ const customer = await ctx.db.get(args.customerId);
414
+ if (!customer || customer.isDeleted) {
415
+ throw new Error("Customer not found");
416
+ }
417
+ if (customer.status === args.status) {
418
+ throw new Error(`Customer is already ${args.status}`);
419
+ }
420
+ const now = getCurrentTimestamp();
421
+ await ctx.db.patch(args.customerId, {
422
+ status: args.status,
423
+ statusNote: args.note.trim(),
424
+ statusChangedBy: admin._id,
425
+ statusChangedAt: now,
426
+ });
427
+ // Get admin name for audit log
428
+ const adminName = admin.name || admin.email;
429
+ await createAuditLog({
430
+ ctx,
431
+ actorId: admin._id,
432
+ actorType: "admin",
433
+ actorRole: admin.role,
434
+ action: `customer.status_changed`,
435
+ entityType: "user",
436
+ entityId: args.customerId,
437
+ details: JSON.stringify({
438
+ customerName: customer.name,
439
+ phoneNumber: customer.phoneNumber,
440
+ oldStatus: customer.status,
441
+ newStatus: args.status,
442
+ note: args.note.trim(),
443
+ changedBy: adminName,
444
+ }),
445
+ });
446
+ return args.customerId;
447
+ },
448
+ });
449
+ /**
450
+ * Delete a customer (soft delete)
451
+ */
452
+ export const deleteCustomer = mutation({
453
+ args: {
454
+ customerId: v.id("users"),
455
+ },
456
+ handler: async (ctx, args) => {
457
+ const admin = await getCurrentAdmin(ctx);
458
+ const customer = await ctx.db.get(args.customerId);
459
+ if (!customer || customer.isDeleted) {
460
+ throw new Error("Customer not found");
461
+ }
462
+ // Check for active orders
463
+ const activeOrders = await ctx.db
464
+ .query("orders")
465
+ .withIndex("by_customer", (q) => q.eq("customerId", args.customerId))
466
+ .filter((q) => q.eq(q.field("isDeleted"), false))
467
+ .filter((q) => q.neq(q.field("status"), "completed"))
468
+ .collect();
469
+ if (activeOrders.length > 0) {
470
+ throw new Error(`Cannot delete customer with ${activeOrders.length} active order(s). Please complete or cancel orders first.`);
471
+ }
472
+ await ctx.db.patch(args.customerId, {
473
+ isDeleted: true,
474
+ });
475
+ await createAuditLog({
476
+ ctx,
477
+ actorId: admin._id,
478
+ actorType: "admin",
479
+ actorRole: admin.role,
480
+ action: "customer.deleted",
481
+ entityType: "user",
482
+ entityId: args.customerId,
483
+ details: JSON.stringify({ customerName: customer.name, phoneNumber: customer.phoneNumber }),
484
+ });
485
+ return args.customerId;
486
+ },
487
+ });
488
+ /**
489
+ * Create new branch
490
+ */
491
+ export const createBranch = mutation({
492
+ args: {
493
+ name: v.string(),
494
+ code: v.string(), // Unique branch code (e.g., "IND", "BRU")
495
+ address: v.string(),
496
+ city: v.string(),
497
+ country: v.string(),
498
+ phoneNumber: v.string(),
499
+ email: v.optional(v.string()),
500
+ pricingPerKg: v.number(),
501
+ deliveryFee: v.number(),
502
+ },
503
+ handler: async (ctx, args) => {
504
+ const admin = await getCurrentAdmin(ctx);
505
+ // Validate and normalize code (uppercase, no spaces)
506
+ const code = args.code.toUpperCase().trim().replace(/\s+/g, "");
507
+ if (code.length < 2 || code.length > 10) {
508
+ throw new Error("Branch code must be between 2 and 10 characters");
509
+ }
510
+ // Check if code already exists
511
+ const existing = await ctx.db
512
+ .query("branches")
513
+ .withIndex("by_code", (q) => q.eq("code", code))
514
+ .first();
515
+ if (existing && !existing.isDeleted) {
516
+ throw new Error(`Branch code "${code}" already exists`);
517
+ }
518
+ const now = getCurrentTimestamp();
519
+ const branchId = await ctx.db.insert("branches", {
520
+ name: args.name,
521
+ code,
522
+ address: args.address,
523
+ city: args.city,
524
+ country: args.country,
525
+ phoneNumber: args.phoneNumber,
526
+ email: args.email,
527
+ pricingPerKg: args.pricingPerKg,
528
+ deliveryFee: args.deliveryFee,
529
+ isActive: true,
530
+ createdAt: now,
531
+ createdBy: admin._id,
532
+ isDeleted: false,
533
+ });
534
+ await createAuditLog({
535
+ ctx,
536
+ actorId: admin._id,
537
+ actorType: "admin",
538
+ actorRole: admin.role,
539
+ action: "branch.created",
540
+ entityType: "branch",
541
+ entityId: branchId,
542
+ details: JSON.stringify({ ...args, code }),
543
+ });
544
+ return branchId;
545
+ },
546
+ });
547
+ /**
548
+ * Update branch details
549
+ */
550
+ export const updateBranch = mutation({
551
+ args: {
552
+ branchId: v.id("branches"),
553
+ name: v.optional(v.string()),
554
+ code: v.optional(v.string()), // Unique branch code
555
+ address: v.optional(v.string()),
556
+ city: v.optional(v.string()),
557
+ country: v.optional(v.string()),
558
+ phoneNumber: v.optional(v.string()),
559
+ email: v.optional(v.string()),
560
+ pricingPerKg: v.optional(v.number()),
561
+ deliveryFee: v.optional(v.number()),
562
+ isActive: v.optional(v.boolean()),
563
+ },
564
+ handler: async (ctx, args) => {
565
+ const admin = await getCurrentAdmin(ctx);
566
+ const branch = await ctx.db.get(args.branchId);
567
+ if (!branch || branch.isDeleted) {
568
+ throw new Error("Branch not found");
569
+ }
570
+ const { branchId, code, ...updates } = args;
571
+ // Prepare update object with proper typing
572
+ const updateData = { ...updates };
573
+ // If code is being updated, validate uniqueness
574
+ if (code !== undefined) {
575
+ const normalizedCode = code.toUpperCase().trim().replace(/\s+/g, "");
576
+ if (normalizedCode.length < 2 || normalizedCode.length > 10) {
577
+ throw new Error("Branch code must be between 2 and 10 characters");
578
+ }
579
+ // Check if code already exists (excluding current branch)
580
+ const existing = await ctx.db
581
+ .query("branches")
582
+ .withIndex("by_code", (q) => q.eq("code", normalizedCode))
583
+ .first();
584
+ if (existing && existing._id !== args.branchId && !existing.isDeleted) {
585
+ throw new Error(`Branch code "${normalizedCode}" already exists`);
586
+ }
587
+ updateData.code = normalizedCode;
588
+ }
589
+ await ctx.db.patch(args.branchId, updateData);
590
+ await createAuditLog({
591
+ ctx,
592
+ actorId: admin._id,
593
+ actorType: "admin",
594
+ actorRole: admin.role,
595
+ action: "branch.updated",
596
+ entityType: "branch",
597
+ entityId: args.branchId,
598
+ oldValue: JSON.stringify(branch),
599
+ newValue: JSON.stringify({ ...branch, ...updates }),
600
+ });
601
+ return args.branchId;
602
+ },
603
+ });
604
+ /**
605
+ * Toggle branch active status (enable/disable)
606
+ */
607
+ export const toggleBranchStatus = mutation({
608
+ args: {
609
+ branchId: v.id("branches"),
610
+ },
611
+ handler: async (ctx, args) => {
612
+ const admin = await getCurrentAdmin(ctx);
613
+ const branch = await ctx.db.get(args.branchId);
614
+ if (!branch || branch.isDeleted) {
615
+ throw new Error("Branch not found");
616
+ }
617
+ const newStatus = !branch.isActive;
618
+ await ctx.db.patch(args.branchId, {
619
+ isActive: newStatus,
620
+ });
621
+ await createAuditLog({
622
+ ctx,
623
+ actorId: admin._id,
624
+ actorType: "admin",
625
+ actorRole: admin.role,
626
+ action: newStatus ? "branch.enabled" : "branch.disabled",
627
+ entityType: "branch",
628
+ entityId: args.branchId,
629
+ branchId: args.branchId,
630
+ oldValue: JSON.stringify({ isActive: branch.isActive }),
631
+ newValue: JSON.stringify({ isActive: newStatus }),
632
+ });
633
+ return { branchId: args.branchId, isActive: newStatus };
634
+ },
635
+ });
636
+ /**
637
+ * Delete branch (soft delete)
638
+ */
639
+ export const deleteBranch = mutation({
640
+ args: {
641
+ branchId: v.id("branches"),
642
+ },
643
+ handler: async (ctx, args) => {
644
+ const admin = await getCurrentAdmin(ctx);
645
+ const branch = await ctx.db.get(args.branchId);
646
+ if (!branch || branch.isDeleted) {
647
+ throw new Error("Branch not found");
648
+ }
649
+ // Check if branch has active orders
650
+ const activeOrders = await ctx.db
651
+ .query("orders")
652
+ .withIndex("by_branch", (q) => q.eq("branchId", args.branchId))
653
+ .filter((q) => q.and(q.eq(q.field("isDeleted"), false), q.neq(q.field("status"), "completed"), q.neq(q.field("status"), "cancelled")))
654
+ .first();
655
+ if (activeOrders) {
656
+ throw new Error("Cannot delete branch with active orders. Please complete or cancel all orders first.");
657
+ }
658
+ await ctx.db.patch(args.branchId, {
659
+ isDeleted: true,
660
+ isActive: false, // Also deactivate when deleting
661
+ });
662
+ await createAuditLog({
663
+ ctx,
664
+ actorId: admin._id,
665
+ actorType: "admin",
666
+ actorRole: admin.role,
667
+ action: "branch.deleted",
668
+ entityType: "branch",
669
+ entityId: args.branchId,
670
+ branchId: args.branchId,
671
+ oldValue: JSON.stringify(branch),
672
+ });
673
+ return args.branchId;
674
+ },
675
+ });
676
+ /**
677
+ * Create attendant account
678
+ * Note: Clerk user must be created separately, then link clerkUserId
679
+ */
680
+ export const createAttendant = mutation({
681
+ args: {
682
+ name: v.string(),
683
+ email: v.string(),
684
+ phoneNumber: v.string(),
685
+ clerkUserId: v.string(), // Clerk user ID (must be created in Clerk first)
686
+ branchId: v.id("branches"),
687
+ passcode: v.optional(v.string()),
688
+ },
689
+ handler: async (ctx, args) => {
690
+ const admin = await getCurrentAdmin(ctx);
691
+ // Verify branch exists
692
+ const branch = await ctx.db.get(args.branchId);
693
+ if (!branch || branch.isDeleted) {
694
+ throw new Error("Branch not found");
695
+ }
696
+ // Check if attendant already exists with this email or Clerk ID
697
+ const existingByEmail = await ctx.db
698
+ .query("attendants")
699
+ .withIndex("by_email", (q) => q.eq("email", args.email))
700
+ .first();
701
+ if (existingByEmail && !existingByEmail.isDeleted) {
702
+ throw new Error("Attendant with this email already exists");
703
+ }
704
+ const existingByClerk = await ctx.db
705
+ .query("attendants")
706
+ .withIndex("by_clerk_user", (q) => q.eq("clerkUserId", args.clerkUserId))
707
+ .first();
708
+ if (existingByClerk && !existingByClerk.isDeleted) {
709
+ throw new Error("Attendant with this Clerk account already exists");
710
+ }
711
+ const now = getCurrentTimestamp();
712
+ const attendantId = await ctx.db.insert("attendants", {
713
+ name: args.name,
714
+ email: args.email,
715
+ phoneNumber: args.phoneNumber,
716
+ clerkUserId: args.clerkUserId,
717
+ branchId: args.branchId,
718
+ passcode: args.passcode,
719
+ isActive: true,
720
+ createdAt: now,
721
+ isDeleted: false,
722
+ });
723
+ await createAuditLog({
724
+ ctx,
725
+ actorId: admin._id,
726
+ actorType: "admin",
727
+ actorRole: admin.role,
728
+ action: "attendant.created",
729
+ entityType: "attendant",
730
+ entityId: attendantId,
731
+ branchId: args.branchId,
732
+ details: JSON.stringify({ email: args.email, name: args.name }),
733
+ });
734
+ return attendantId;
735
+ },
736
+ });
737
+ /**
738
+ * Update attendant details
739
+ */
740
+ export const updateAttendant = mutation({
741
+ args: {
742
+ attendantId: v.id("attendants"),
743
+ name: v.optional(v.string()),
744
+ email: v.optional(v.string()),
745
+ phoneNumber: v.optional(v.string()),
746
+ branchId: v.optional(v.id("branches")),
747
+ isActive: v.optional(v.boolean()),
748
+ passcode: v.optional(v.string()),
749
+ },
750
+ handler: async (ctx, args) => {
751
+ const admin = await getCurrentAdmin(ctx);
752
+ const attendant = await ctx.db.get(args.attendantId);
753
+ if (!attendant || attendant.isDeleted) {
754
+ throw new Error("Attendant not found");
755
+ }
756
+ const { attendantId, ...updates } = args;
757
+ if (args.branchId) {
758
+ // Verify branch exists
759
+ const branch = await ctx.db.get(args.branchId);
760
+ if (!branch || branch.isDeleted) {
761
+ throw new Error("Branch not found");
762
+ }
763
+ }
764
+ await ctx.db.patch(args.attendantId, updates);
765
+ await createAuditLog({
766
+ ctx,
767
+ actorId: admin._id,
768
+ actorType: "admin",
769
+ actorRole: admin.role,
770
+ action: "attendant.updated",
771
+ entityType: "attendant",
772
+ entityId: args.attendantId,
773
+ branchId: attendant.branchId,
774
+ oldValue: JSON.stringify(attendant),
775
+ newValue: JSON.stringify({ ...attendant, ...updates }),
776
+ });
777
+ return args.attendantId;
778
+ },
779
+ });
780
+ /**
781
+ * Assign attendant to branch
782
+ */
783
+ export const assignAttendantToBranch = mutation({
784
+ args: {
785
+ attendantId: v.id("attendants"),
786
+ branchId: v.id("branches"),
787
+ },
788
+ handler: async (ctx, args) => {
789
+ const admin = await getCurrentAdmin(ctx);
790
+ const attendant = await ctx.db.get(args.attendantId);
791
+ if (!attendant || attendant.isDeleted) {
792
+ throw new Error("Attendant not found");
793
+ }
794
+ const branch = await ctx.db.get(args.branchId);
795
+ if (!branch || branch.isDeleted) {
796
+ throw new Error("Branch not found");
797
+ }
798
+ await ctx.db.patch(args.attendantId, {
799
+ branchId: args.branchId,
800
+ });
801
+ await createAuditLog({
802
+ ctx,
803
+ actorId: admin._id,
804
+ actorType: "admin",
805
+ actorRole: admin.role,
806
+ action: "attendant.assigned_to_branch",
807
+ entityType: "attendant",
808
+ entityId: args.attendantId,
809
+ branchId: args.branchId,
810
+ details: JSON.stringify({
811
+ previousBranchId: attendant.branchId,
812
+ newBranchId: args.branchId,
813
+ }),
814
+ });
815
+ return args.attendantId;
816
+ },
817
+ });
818
+ /**
819
+ * Delete attendant (soft delete)
820
+ */
821
+ export const deleteAttendant = mutation({
822
+ args: {
823
+ attendantId: v.id("attendants"),
824
+ },
825
+ handler: async (ctx, args) => {
826
+ const admin = await getCurrentAdmin(ctx);
827
+ const attendant = await ctx.db.get(args.attendantId);
828
+ if (!attendant || attendant.isDeleted) {
829
+ throw new Error("Attendant not found");
830
+ }
831
+ // Check if attendant has active orders
832
+ const activeOrders = await ctx.db
833
+ .query("orders")
834
+ .withIndex("by_fulfilled_by", (q) => q.eq("fulfilledBy", args.attendantId))
835
+ .filter((q) => q.and(q.eq(q.field("isDeleted"), false), q.neq(q.field("status"), "completed"), q.neq(q.field("status"), "cancelled")))
836
+ .first();
837
+ if (activeOrders) {
838
+ throw new Error("Cannot delete attendant with active orders. Please complete or cancel all orders first.");
839
+ }
840
+ await ctx.db.patch(args.attendantId, {
841
+ isDeleted: true,
842
+ isActive: false, // Also deactivate when deleting
843
+ });
844
+ await createAuditLog({
845
+ ctx,
846
+ actorId: admin._id,
847
+ actorType: "admin",
848
+ actorRole: admin.role,
849
+ action: "attendant.deleted",
850
+ entityType: "attendant",
851
+ entityId: args.attendantId,
852
+ branchId: attendant.branchId,
853
+ oldValue: JSON.stringify(attendant),
854
+ });
855
+ return args.attendantId;
856
+ },
857
+ });
858
+ /**
859
+ * Create admin account
860
+ * Only super admins can create other admins
861
+ * Note: Clerk user must be created separately, then link clerkUserId
862
+ */
863
+ export const createAdmin = mutation({
864
+ args: {
865
+ name: v.string(),
866
+ email: v.string(),
867
+ clerkUserId: v.string(), // Clerk user ID (must be created in Clerk first)
868
+ role: v.union(v.literal("admin"), v.literal("super_admin")),
869
+ },
870
+ handler: async (ctx, args) => {
871
+ const superAdmin = await getSuperAdmin(ctx);
872
+ // Check if admin already exists with this email or Clerk ID
873
+ const existingByEmail = await ctx.db
874
+ .query("admins")
875
+ .withIndex("by_email", (q) => q.eq("email", args.email))
876
+ .first();
877
+ if (existingByEmail && !existingByEmail.isDeleted) {
878
+ throw new Error("Admin with this email already exists");
879
+ }
880
+ const existingByClerk = await ctx.db
881
+ .query("admins")
882
+ .withIndex("by_clerk_user", (q) => q.eq("clerkUserId", args.clerkUserId))
883
+ .first();
884
+ if (existingByClerk && !existingByClerk.isDeleted) {
885
+ throw new Error("Admin with this Clerk account already exists");
886
+ }
887
+ const now = getCurrentTimestamp();
888
+ const adminId = await ctx.db.insert("admins", {
889
+ name: args.name,
890
+ email: args.email,
891
+ clerkUserId: args.clerkUserId,
892
+ role: args.role,
893
+ createdAt: now,
894
+ lastLoginAt: now,
895
+ isDeleted: false,
896
+ });
897
+ await createAuditLog({
898
+ ctx,
899
+ actorId: superAdmin._id,
900
+ actorType: "admin",
901
+ actorRole: superAdmin.role,
902
+ action: "admin.created",
903
+ entityType: "admin",
904
+ entityId: adminId,
905
+ details: JSON.stringify({ email: args.email, name: args.name, role: args.role }),
906
+ });
907
+ return adminId;
908
+ },
909
+ });
910
+ /**
911
+ * Create the first super admin (one-time setup)
912
+ * This function can only be used when no super admins exist in the database
913
+ * After the first super admin is created, use createAdmin instead
914
+ */
915
+ export const createFirstSuperAdmin = mutation({
916
+ args: {
917
+ name: v.string(),
918
+ email: v.string(),
919
+ clerkUserId: v.string(), // Clerk user ID (must be created in Clerk first)
920
+ },
921
+ handler: async (ctx, args) => {
922
+ // Check if any super admin already exists
923
+ const existingSuperAdmin = await ctx.db
924
+ .query("admins")
925
+ .filter((q) => q.eq(q.field("role"), "super_admin"))
926
+ .filter((q) => q.eq(q.field("isDeleted"), false))
927
+ .first();
928
+ if (existingSuperAdmin) {
929
+ throw new Error("Super admin already exists. Use createAdmin mutation instead.");
930
+ }
931
+ // Check if admin already exists with this email or Clerk ID
932
+ const existingByEmail = await ctx.db
933
+ .query("admins")
934
+ .withIndex("by_email", (q) => q.eq("email", args.email))
935
+ .first();
936
+ if (existingByEmail && !existingByEmail.isDeleted) {
937
+ throw new Error("Admin with this email already exists");
938
+ }
939
+ const existingByClerk = await ctx.db
940
+ .query("admins")
941
+ .withIndex("by_clerk_user", (q) => q.eq("clerkUserId", args.clerkUserId))
942
+ .first();
943
+ if (existingByClerk && !existingByClerk.isDeleted) {
944
+ throw new Error("Admin with this Clerk account already exists");
945
+ }
946
+ const now = getCurrentTimestamp();
947
+ const adminId = await ctx.db.insert("admins", {
948
+ name: args.name,
949
+ email: args.email,
950
+ clerkUserId: args.clerkUserId,
951
+ role: "super_admin",
952
+ createdAt: now,
953
+ lastLoginAt: now,
954
+ isDeleted: false,
955
+ });
956
+ return adminId;
957
+ },
958
+ });
959
+ //# sourceMappingURL=admin.js.map