489 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
};
|