freshyo/verifier/apis/user.ts
2026-03-22 20:20:18 +05:30

489 lines
14 KiB
TypeScript

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<typeof users.$inferSelect> {
// 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,
};
}),
};