@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.
- package/convex/_generated/api.d.ts +2 -0
- package/convex/audit.d.ts +39 -0
- package/convex/notifications.d.ts +170 -0
- package/convex/schema.d.ts +51 -0
- package/dist/convex/admin.d.ts +377 -0
- package/dist/convex/admin.d.ts.map +1 -0
- package/dist/convex/admin.js +959 -0
- package/dist/convex/admin.js.map +1 -0
- package/dist/convex/analytics.d.ts +87 -0
- package/dist/convex/analytics.d.ts.map +1 -0
- package/dist/convex/analytics.js +361 -0
- package/dist/convex/analytics.js.map +1 -0
- package/dist/convex/attendants.d.ts +140 -0
- package/dist/convex/attendants.d.ts.map +1 -0
- package/dist/convex/attendants.js +337 -0
- package/dist/convex/attendants.js.map +1 -0
- package/dist/convex/audit.d.ts +158 -0
- package/dist/convex/audit.d.ts.map +1 -0
- package/dist/convex/audit.js +184 -0
- package/dist/convex/audit.js.map +1 -0
- package/dist/convex/clerk.d.ts +53 -0
- package/dist/convex/clerk.d.ts.map +1 -0
- package/dist/convex/clerk.js +316 -0
- package/dist/convex/clerk.js.map +1 -0
- package/dist/convex/customers.d.ts +224 -0
- package/dist/convex/customers.d.ts.map +1 -0
- package/dist/convex/customers.js +504 -0
- package/dist/convex/customers.js.map +1 -0
- package/dist/convex/http.d.ts +3 -0
- package/dist/convex/http.d.ts.map +1 -0
- package/dist/convex/http.js +115 -0
- package/dist/convex/http.js.map +1 -0
- package/dist/convex/lib/audit.d.ts +36 -0
- package/dist/convex/lib/audit.d.ts.map +1 -0
- package/dist/convex/lib/audit.js +59 -0
- package/dist/convex/lib/audit.js.map +1 -0
- package/dist/convex/lib/auth.d.ts +96 -0
- package/dist/convex/lib/auth.d.ts.map +1 -0
- package/dist/convex/lib/auth.js +94 -0
- package/dist/convex/lib/auth.js.map +1 -0
- package/dist/convex/lib/utils.d.ts +38 -0
- package/dist/convex/lib/utils.d.ts.map +1 -0
- package/dist/convex/lib/utils.js +71 -0
- package/dist/convex/lib/utils.js.map +1 -0
- package/dist/convex/loyalty.d.ts +82 -0
- package/dist/convex/loyalty.d.ts.map +1 -0
- package/dist/convex/loyalty.js +286 -0
- package/dist/convex/loyalty.js.map +1 -0
- package/dist/convex/orders.d.ts +326 -0
- package/dist/convex/orders.d.ts.map +1 -0
- package/dist/convex/orders.js +570 -0
- package/dist/convex/orders.js.map +1 -0
- package/dist/convex/payments.d.ts +134 -0
- package/dist/convex/payments.d.ts.map +1 -0
- package/dist/convex/payments.js +360 -0
- package/dist/convex/payments.js.map +1 -0
- package/dist/convex/resources.d.ts +119 -0
- package/dist/convex/resources.d.ts.map +1 -0
- package/dist/convex/resources.js +283 -0
- package/dist/convex/resources.js.map +1 -0
- package/dist/convex/schema.d.ts +450 -0
- package/dist/convex/schema.d.ts.map +1 -0
- package/dist/convex/schema.js +347 -0
- package/dist/convex/schema.js.map +1 -0
- package/dist/convex/vouchers.d.ts +187 -0
- package/dist/convex/vouchers.d.ts.map +1 -0
- package/dist/convex/vouchers.js +464 -0
- package/dist/convex/vouchers.js.map +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +9 -0
- package/dist/src/index.js.map +1 -0
- 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
|