import { protectedProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; import { db } from '@/src/db/db_index'; import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '@/src/db/schema'; import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm'; import { ApiError } from '@/src/lib/api-error'; import { notificationQueue } from '@/src/lib/notif-job'; import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store'; async function createUserByMobile(mobile: string): Promise { // Clean mobile number (remove non-digits) const cleanMobile = mobile.replace(/\D/g, ''); // Validate: exactly 10 digits if (cleanMobile.length !== 10) { throw new ApiError('Mobile number must be exactly 10 digits', 400); } // Check if user already exists const [existingUser] = await db .select() .from(users) .where(eq(users.mobile, cleanMobile)) .limit(1); if (existingUser) { throw new ApiError('User with this mobile number already exists', 409); } // Create user const [newUser] = await db .insert(users) .values({ name: null, email: null, mobile: cleanMobile, }) .returning(); return newUser; } export const userRouter = { createUserByMobile: protectedProcedure .input(z.object({ mobile: z.string().min(1, 'Mobile number is required'), })) .mutation(async ({ input }) => { const newUser = await createUserByMobile(input.mobile); return { success: true, data: newUser, }; }), getEssentials: protectedProcedure .query(async () => { const count = await db.$count(complaints, eq(complaints.isResolved, false)); return { unresolvedComplaints: count || 0, }; }), getAllUsers: protectedProcedure .input(z.object({ limit: z.number().min(1).max(100).default(50), cursor: z.number().optional(), search: z.string().optional(), })) .query(async ({ input }) => { const { limit, cursor, search } = input; // Build where conditions const whereConditions = []; if (search && search.trim()) { whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`); } if (cursor) { whereConditions.push(sql`${users.id} > ${cursor}`); } // Get users with filters applied const usersList = await db .select({ id: users.id, name: users.name, mobile: users.mobile, createdAt: users.createdAt, }) .from(users) .where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined) .orderBy(asc(users.id)) .limit(limit + 1); // Get one extra to determine if there's more // Check if there are more results const hasMore = usersList.length > limit; const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList; // Get order stats for each user const userIds = usersToReturn.map(u => u.id); let orderCounts: { userId: number; totalOrders: number }[] = []; let lastOrders: { userId: number; lastOrderDate: Date | null }[] = []; let suspensionStatuses: { userId: number; isSuspended: boolean }[] = []; if (userIds.length > 0) { // Get total orders per user orderCounts = await db .select({ userId: orders.userId, totalOrders: count(orders.id), }) .from(orders) .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) .groupBy(orders.userId); // Get last order date per user lastOrders = await db .select({ userId: orders.userId, lastOrderDate: max(orders.createdAt), }) .from(orders) .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) .groupBy(orders.userId); // Get suspension status for each user suspensionStatuses = await db .select({ userId: userDetails.userId, isSuspended: userDetails.isSuspended, }) .from(userDetails) .where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`); } // Create lookup maps const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders])); const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate])); const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended])); // Combine data const usersWithStats = usersToReturn.map(user => ({ ...user, totalOrders: orderCountMap.get(user.id) || 0, lastOrderDate: lastOrderMap.get(user.id) || null, isSuspended: suspensionMap.get(user.id) ?? false, })); // Get next cursor const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined; return { users: usersWithStats, nextCursor, hasMore, }; }), getUserDetails: protectedProcedure .input(z.object({ userId: z.number(), })) .query(async ({ input }) => { const { userId } = input; // Get user info const user = await db .select({ id: users.id, name: users.name, mobile: users.mobile, createdAt: users.createdAt, }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!user || user.length === 0) { throw new ApiError('User not found', 404); } // Get user suspension status const userDetail = await db .select({ isSuspended: userDetails.isSuspended, }) .from(userDetails) .where(eq(userDetails.userId, userId)) .limit(1); // Get all orders for this user with order items count const userOrders = await db .select({ id: orders.id, readableId: orders.readableId, totalAmount: orders.totalAmount, createdAt: orders.createdAt, isFlashDelivery: orders.isFlashDelivery, }) .from(orders) .where(eq(orders.userId, userId)) .orderBy(desc(orders.createdAt)); // Get order status for each order const orderIds = userOrders.map(o => o.id); let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = []; if (orderIds.length > 0) { const { orderStatus } = await import('@/src/db/schema'); orderStatuses = await db .select({ orderId: orderStatus.orderId, isDelivered: orderStatus.isDelivered, isCancelled: orderStatus.isCancelled, }) .from(orderStatus) .where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`); } // Get item counts for each order const itemCounts = await db .select({ orderId: orderItems.orderId, itemCount: count(orderItems.id), }) .from(orderItems) .where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`) .groupBy(orderItems.orderId); // Create lookup maps const statusMap = new Map(orderStatuses.map(s => [s.orderId, s])); const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount])); // Determine status string const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => { if (!status) return 'pending'; if (status.isCancelled) return 'cancelled'; if (status.isDelivered) return 'delivered'; return 'pending'; }; // Combine data const ordersWithDetails = userOrders.map(order => { const status = statusMap.get(order.id); return { id: order.id, readableId: order.readableId, totalAmount: order.totalAmount, createdAt: order.createdAt, isFlashDelivery: order.isFlashDelivery, status: getStatus(status), itemCount: itemCountMap.get(order.id) || 0, }; }); return { user: { ...user[0], isSuspended: userDetail[0]?.isSuspended ?? false, }, orders: ordersWithDetails, }; }), updateUserSuspension: protectedProcedure .input(z.object({ userId: z.number(), isSuspended: z.boolean(), })) .mutation(async ({ input }) => { const { userId, isSuspended } = input; // Check if user exists const user = await db .select({ id: users.id }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!user || user.length === 0) { throw new ApiError('User not found', 404); } // Check if user_details record exists const existingDetail = await db .select({ id: userDetails.id }) .from(userDetails) .where(eq(userDetails.userId, userId)) .limit(1); if (existingDetail.length > 0) { // Update existing record await db .update(userDetails) .set({ isSuspended }) .where(eq(userDetails.userId, userId)); } else { // Insert new record await db .insert(userDetails) .values({ userId, isSuspended, }); } return { success: true, message: `User ${isSuspended ? 'suspended' : 'unsuspended'} successfully`, }; }), getUsersForNotification: protectedProcedure .input(z.object({ search: z.string().optional(), })) .query(async ({ input }) => { const { search } = input; // Get all users let usersList; if (search && search.trim()) { usersList = await db .select({ id: users.id, name: users.name, mobile: users.mobile, }) .from(users) .where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`); } else { usersList = await db .select({ id: users.id, name: users.name, mobile: users.mobile, }) .from(users); } // Get eligible users (have notif_creds entry) const eligibleUsers = await db .select({ userId: notifCreds.userId }) .from(notifCreds); const eligibleSet = new Set(eligibleUsers.map(u => u.userId)); return { users: usersList.map(user => ({ id: user.id, name: user.name, mobile: user.mobile, isEligibleForNotif: eligibleSet.has(user.id), })), }; }), sendNotification: protectedProcedure .input(z.object({ userIds: z.array(z.number()).default([]), title: z.string().min(1, 'Title is required'), text: z.string().min(1, 'Message is required'), imageUrl: z.string().optional(), })) .mutation(async ({ input }) => { const { userIds, title, text, imageUrl } = input; let tokens: string[] = []; if (userIds.length === 0) { // Send to all users - get tokens from both logged-in and unlogged users const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds); const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens); tokens = [ ...loggedInTokens.map(t => t.token), ...unloggedTokens.map(t => t.token) ]; } else { // Send to specific users - get their tokens const userTokens = await db .select({ token: notifCreds.token }) .from(notifCreds) .where(inArray(notifCreds.userId, userIds)); tokens = userTokens.map(t => t.token); } // Queue one job per token let queuedCount = 0; for (const token of tokens) { try { await notificationQueue.add('send-admin-notification', { token, title, body: text, imageUrl: imageUrl || null, }, { attempts: 3, backoff: { type: 'exponential', delay: 2000, }, }); queuedCount++; } catch (error) { console.error(`Failed to queue notification for token:`, error); } } return { success: true, message: `Notification queued for ${queuedCount} users`, }; }), getUserIncidents: protectedProcedure .input(z.object({ userId: z.number(), })) .query(async ({ input }) => { const { userId } = input; const incidents = await db.query.userIncidents.findMany({ where: eq(userIncidents.userId, userId), with: { order: { with: { orderStatus: true, }, }, addedBy: true, }, orderBy: desc(userIncidents.dateAdded), }); return { incidents: incidents.map(incident => ({ id: incident.id, userId: incident.userId, orderId: incident.orderId, dateAdded: incident.dateAdded, adminComment: incident.adminComment, addedBy: incident.addedBy?.name || 'Unknown', negativityScore: incident.negativityScore, orderStatus: incident.order?.orderStatus?.[0]?.isCancelled ? 'cancelled' : 'active', })), }; }), addUserIncident: protectedProcedure .input(z.object({ userId: z.number(), orderId: z.number().optional(), adminComment: z.string().optional(), negativityScore: z.number().optional(), })) .mutation(async ({ input, ctx }) => { const { userId, orderId, adminComment, negativityScore } = input; const adminUserId = ctx.staffUser?.id; if (!adminUserId) { throw new ApiError('Admin user not authenticated', 401); } const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore }; const [incident] = await db.insert(userIncidents) .values({ ...incidentObj, }) .returning(); recomputeUserNegativityScore(userId); return { success: true, data: incident, }; }), };