From 72475f7f714d57721ece2ce6ec24596f378f7214 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:36:11 +0530 Subject: [PATCH] enh --- .../manage-orders/order-details/[id].tsx | 189 +--------------- .../(drawer)/manage-orders/orders/index.tsx | 12 +- .../app/(drawer)/user-management/[id].tsx | 4 + .../admin-ui/components/UserIncidentsView.tsx | 206 ++++++++++++++++++ apps/backend/src/lib/init.ts | 3 + .../src/stores/user-negativity-store.ts | 118 ++++++++++ apps/backend/src/trpc/admin-apis/order.ts | 64 +++--- apps/backend/src/trpc/admin-apis/user.ts | 20 +- 8 files changed, 385 insertions(+), 231 deletions(-) create mode 100644 apps/admin-ui/components/UserIncidentsView.tsx create mode 100644 apps/backend/src/stores/user-negativity-store.ts diff --git a/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx b/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx index cf279af..cbd9009 100644 --- a/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx +++ b/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx @@ -13,194 +13,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import FontAwesome5 from "@expo/vector-icons/FontAwesome5"; import dayjs from "dayjs"; import CancelOrderDialog from "@/components/CancelOrderDialog"; - -function UserIncidentDialog({ userId, orderId, open, onClose, onSuccess }: { userId: number; orderId: number; open: boolean; onClose: () => void; onSuccess?: () => void }) { - const [adminComment, setAdminComment] = useState(''); - const [negativityScore, setNegativityScore] = useState(''); - - const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({ - onSuccess: () => { - Alert.alert('Success', 'Incident added successfully'); - setAdminComment(''); - setNegativityScore(''); - onClose(); - onSuccess?.(); - }, - onError: (error: any) => { - Alert.alert('Error', error.message || 'Failed to add incident'); - }, - }); - - const handleAddIncident = () => { - const score = negativityScore ? parseInt(negativityScore) : undefined; - - if (!adminComment.trim() && !negativityScore) { - Alert.alert('Error', 'Please enter a comment or negativity score'); - return; - } - - addIncidentMutation.mutate({ - userId, - orderId, - adminComment: adminComment || undefined, - negativityScore: score, - }); - }; - - return ( - - - - - - - - Add User Incident - - - Record an incident for this user. This will be visible in their profile. - - - - - - - - - - - Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior). - - - - - - Cancel - - - - {addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'} - - - - - - ); -} - -function UserIncidentsView({ userId, orderId }: { userId: number; orderId: number }) { - const [incidentDialogOpen, setIncidentDialogOpen] = useState(false); - - const { data: incidentsData, refetch: refetchIncidents } = trpc.admin.user.getUserIncidents.useQuery( - { userId }, - { enabled: !!userId } - ); - - return ( - <> - - - - User Incidents - - setIncidentDialogOpen(true)} - style={tw`flex-row items-center bg-amber-200 px-3 py-1.5 rounded-lg`} - > - - Add Incident - - - - {incidentsData?.incidents && incidentsData.incidents.length > 0 ? ( - - {incidentsData.incidents.map((incident: any, index: number) => ( - - - - - - {dayjs(incident.dateAdded).format('MMM DD, YYYY • h:mm A')} - - - {incident.negativityScore && ( - - - Score: {incident.negativityScore} - - - )} - - - {incident.adminComment && ( - - - {incident.adminComment} - - - )} - - - - - Added by {incident.addedBy} - - {incident.orderId && ( - <> - - - Order #{incident.orderId} - - - )} - - - ))} - - ) : ( - - - - No incidents recorded for this user - - - )} - - - setIncidentDialogOpen(false)} - onSuccess={refetchIncidents} - /> - - ); -} +import { UserIncidentsView } from "@/components/UserIncidentsView"; export default function OrderDetails() { const { id } = useLocalSearchParams<{ id: string }>(); diff --git a/apps/admin-ui/app/(drawer)/manage-orders/orders/index.tsx b/apps/admin-ui/app/(drawer)/manage-orders/orders/index.tsx index af6c94a..fd17855 100644 --- a/apps/admin-ui/app/(drawer)/manage-orders/orders/index.tsx +++ b/apps/admin-ui/app/(drawer)/manage-orders/orders/index.tsx @@ -56,6 +56,7 @@ interface OrderType { orderId: string; readableId: number; customerName: string | null; + customerMobile?: string | null; address: string; addressId: number; latitude: number | null; @@ -85,6 +86,7 @@ interface OrderType { discountAmount?: number; adminNotes?: string | null; userNotes?: string | null; + userNegativityScore?: number; } const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => { @@ -171,8 +173,8 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void } - - {order.customerName || 'Unknown Customer'} + 0 ? 'text-yellow-600' : 'text-gray-900')}`}> + {order.customerName || order.customerMobile || 'Unknown Customer'} #{order.readableId} @@ -189,6 +191,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void } {dayjs(order.createdAt).format('MMM D, h:mm A')} + {order.userNegativityScore && order.userNegativityScore > 0 && ( + + + Negative Customer + + )} diff --git a/apps/admin-ui/app/(drawer)/user-management/[id].tsx b/apps/admin-ui/app/(drawer)/user-management/[id].tsx index 03a7eef..d4a57ef 100644 --- a/apps/admin-ui/app/(drawer)/user-management/[id].tsx +++ b/apps/admin-ui/app/(drawer)/user-management/[id].tsx @@ -16,6 +16,7 @@ import { import { trpc } from '@/src/trpc-client'; import { formatDistanceToNow } from 'date-fns'; import dayjs from 'dayjs'; +import { UserIncidentsView } from '@/components/UserIncidentsView'; interface Order { id: number; @@ -221,6 +222,9 @@ export default function UserDetails() { + {/* User Incidents Section */} + + {/* Orders Section */} diff --git a/apps/admin-ui/components/UserIncidentsView.tsx b/apps/admin-ui/components/UserIncidentsView.tsx new file mode 100644 index 0000000..55c4e84 --- /dev/null +++ b/apps/admin-ui/components/UserIncidentsView.tsx @@ -0,0 +1,206 @@ +import React, { useState } from 'react'; +import { View, TouchableOpacity, Alert } from 'react-native'; +import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui'; +import { trpc } from '@/src/trpc-client'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import dayjs from 'dayjs'; + +function UserIncidentDialog({ + userId, + orderId, + open, + onClose, + onSuccess +}: { + userId: number; + orderId: number | null; + open: boolean; + onClose: () => void; + onSuccess?: () => void +}) { + const [adminComment, setAdminComment] = useState(''); + const [negativityScore, setNegativityScore] = useState(''); + + const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({ + onSuccess: () => { + Alert.alert('Success', 'Incident added successfully'); + setAdminComment(''); + setNegativityScore(''); + onClose(); + onSuccess?.(); + }, + onError: (error: any) => { + Alert.alert('Error', error.message || 'Failed to add incident'); + }, + }); + + const handleAddIncident = () => { + const score = negativityScore ? parseInt(negativityScore) : undefined; + + if (!adminComment.trim() && !negativityScore) { + Alert.alert('Error', 'Please enter a comment or negativity score'); + return; + } + + addIncidentMutation.mutate({ + userId, + orderId: orderId || undefined, + adminComment: adminComment || undefined, + negativityScore: score, + }); + }; + + return ( + + + + + + + + Add User Incident + + + Record an incident for this user. This will be visible in their profile. + + + + + + + + + + + Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior). + + + + + + Cancel + + + + {addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'} + + + + + + ); +} + +export function UserIncidentsView({ userId, orderId }: { userId: number; orderId: number | null }) { + const [incidentDialogOpen, setIncidentDialogOpen] = useState(false); + + const { data: incidentsData, refetch: refetchIncidents } = trpc.admin.user.getUserIncidents.useQuery( + { userId }, + { enabled: !!userId } + ); + + return ( + <> + + + + User Incidents + + setIncidentDialogOpen(true)} + style={tw`flex-row items-center bg-amber-200 px-3 py-1.5 rounded-lg`} + > + + Add Incident + + + + {incidentsData?.incidents && incidentsData.incidents.length > 0 ? ( + + {incidentsData.incidents.map((incident: any, index: number) => ( + + + + + + {dayjs(incident.dateAdded).format('MMM DD, YYYY • h:mm A')} + + + {incident.negativityScore && ( + + + Score: {incident.negativityScore} + + + )} + + + {incident.adminComment && ( + + + {incident.adminComment} + + + )} + + + + + Added by {incident.addedBy} + + {incident.orderId && ( + <> + + + Order #{incident.orderId} + + + )} + + + ))} + + ) : ( + + + + No incidents recorded for this user + + + )} + + + setIncidentDialogOpen(false)} + onSuccess={refetchIncidents} + /> + + ); +} diff --git a/apps/backend/src/lib/init.ts b/apps/backend/src/lib/init.ts index af2a5db..e5f9455 100755 --- a/apps/backend/src/lib/init.ts +++ b/apps/backend/src/lib/init.ts @@ -1,5 +1,6 @@ import './notif-job'; import { initializeAllStores } from '../stores/store-initializer'; +import { initializeUserNegativityStore } from '../stores/user-negativity-store'; import { startOrderHandler, startCancellationHandler, publishOrder } from './post-order-handler'; import { deleteOrders } from './delete-orders'; @@ -10,6 +11,7 @@ import { deleteOrders } from './delete-orders'; * - Const Store (syncs constants from DB to Redis) * - Post Order Handler (Redis Pub/Sub subscriber) * - Cancellation Handler (Redis Pub/Sub subscriber for order cancellations) + * - User Negativity Store (caches user negativity scores in Redis) * - Other services can be added here in the future */ export const initFunc = async (): Promise => { @@ -18,6 +20,7 @@ export const initFunc = async (): Promise => { await Promise.all([ initializeAllStores(), + initializeUserNegativityStore(), startOrderHandler(), startCancellationHandler(), ]); diff --git a/apps/backend/src/stores/user-negativity-store.ts b/apps/backend/src/stores/user-negativity-store.ts new file mode 100644 index 0000000..d1285b7 --- /dev/null +++ b/apps/backend/src/stores/user-negativity-store.ts @@ -0,0 +1,118 @@ +import redisClient from 'src/lib/redis-client'; +import { db } from '../db/db_index'; +import { userIncidents } from '../db/schema'; +import { eq, sum } from 'drizzle-orm'; + +export async function initializeUserNegativityStore(): Promise { + try { + console.log('Initializing user negativity store in Redis...'); + + const results = await db + .select({ + userId: userIncidents.userId, + totalNegativityScore: sum(userIncidents.negativityScore).mapWith(Number), + }) + .from(userIncidents) + .groupBy(userIncidents.userId); + + for (const { userId, totalNegativityScore } of results) { + await redisClient.set( + `user:negativity:${userId}`, + totalNegativityScore.toString(), + ); + } + + console.log(`User negativity store initialized for ${results.length} users`); + } catch (error) { + console.error('Error initializing user negativity store:', error); + throw error; + } +} + +export async function getUserNegativity(userId: number): Promise { + try { + const key = `user:negativity:${userId}`; + const data = await redisClient.get(key); + + if (!data) { + return 0; + } + + return parseInt(data, 10); + } catch (error) { + console.error(`Error getting negativity score for user ${userId}:`, error); + return 0; + } +} + +export async function getAllUserNegativityScores(): Promise> { + try { + const keys = await redisClient.KEYS('user:negativity:*'); + + if (keys.length === 0) return {}; + + const values = await redisClient.MGET(keys); + + const result: Record = {}; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const value = values[i]; + + const match = key.match(/user:negativity:(\d+)/); + if (match && value) { + const userId = parseInt(match[1], 10); + result[userId] = parseInt(value, 10); + } + } + + return result; + } catch (error) { + console.error('Error getting all user negativity scores:', error); + return {}; + } +} + +export async function getMultipleUserNegativityScores(userIds: number[]): Promise> { + try { + if (userIds.length === 0) return {}; + + const keys = userIds.map(id => `user:negativity:${id}`); + + const values = await redisClient.MGET(keys); + + const result: Record = {}; + for (let i = 0; i < userIds.length; i++) { + const value = values[i]; + if (value) { + result[userIds[i]] = parseInt(value, 10); + } else { + result[userIds[i]] = 0; + } + } + + return result; + } catch (error) { + console.error('Error getting multiple user negativity scores:', error); + return {}; + } +} + +export async function recomputeUserNegativityScore(userId: number): Promise { + try { + const [result] = await db + .select({ + totalNegativityScore: sum(userIncidents.negativityScore).mapWith(Number), + }) + .from(userIncidents) + .where(eq(userIncidents.userId, userId)) + .limit(1); + + const totalScore = result?.totalNegativityScore || 0; + + const key = `user:negativity:${userId}`; + await redisClient.set(key, totalScore.toString()); + } catch (error) { + console.error(`Error recomputing negativity score for user ${userId}:`, error); + throw error; + } +} diff --git a/apps/backend/src/trpc/admin-apis/order.ts b/apps/backend/src/trpc/admin-apis/order.ts index dcb5c2b..ba7e1f1 100644 --- a/apps/backend/src/trpc/admin-apis/order.ts +++ b/apps/backend/src/trpc/admin-apis/order.ts @@ -20,6 +20,7 @@ import { sendOrderDeliveredNotification, } from "../../lib/notif-job"; import { publishCancellation } from "../../lib/post-order-handler"; +import { getMultipleUserNegativityScores } from "../../stores/user-negativity-store"; const updateOrderNotesSchema = z.object({ orderId: z.number(), @@ -780,6 +781,9 @@ export const orderRouter = router({ const hasMore = allOrders.length > limit; const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders; + const userIds = [...new Set(ordersToReturn.map(o => o.userId))]; + const negativityScores = await getMultipleUserNegativityScores(userIds); + const filteredOrders = ordersToReturn.filter((order) => { const statusRecord = order.orderStatus[0]; return ( @@ -813,35 +817,37 @@ export const orderRouter = router({ .sort((first, second) => first.id - second.id); dayjs.extend(utc); return { - id: order.id, - orderId: order.id.toString(), - readableId: order.id, - customerName: order.user.name, - address: `${order.address.addressLine1}${ - order.address.addressLine2 - ? `, ${order.address.addressLine2}` - : "" - }, ${order.address.city}, ${order.address.state} - ${ - order.address.pincode - }, Phone: ${order.address.phone}`, - addressId: order.addressId, - latitude: order.address.adminLatitude ?? order.address.latitude, - longitude: order.address.adminLongitude ?? order.address.longitude, - totalAmount: parseFloat(order.totalAmount), - deliveryCharge: parseFloat(order.deliveryCharge || "0"), - items, - createdAt: order.createdAt, - // deliveryTime: order.slot ? dayjs.utc(order.slot.deliveryTime).format('ddd, MMM D • h:mm A') : 'Not scheduled', - deliveryTime: order.slot?.deliveryTime.toISOString() || null, - status, - isPackaged: - order.orderItems.every((item) => item.is_packaged) || false, - isDelivered: statusRecord?.isDelivered || false, - isCod: order.isCod, - isFlashDelivery: order.isFlashDelivery, - userNotes: order.userNotes, - adminNotes: order.adminNotes, - }; + id: order.id, + orderId: order.id.toString(), + readableId: order.id, + customerName: order.user.name, + customerMobile: order.user.mobile, + address: `${order.address.addressLine1}${ + order.address.addressLine2 + ? `, ${order.address.addressLine2}` + : "" + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }, Phone: ${order.address.phone}`, + addressId: order.addressId, + latitude: order.address.adminLatitude ?? order.address.latitude, + longitude: order.address.adminLongitude ?? order.address.longitude, + totalAmount: parseFloat(order.totalAmount), + deliveryCharge: parseFloat(order.deliveryCharge || "0"), + items, + createdAt: order.createdAt, + // deliveryTime: order.slot ? dayjs.utc(order.slot.deliveryTime).format('ddd, MMM D • h:mm A') : 'Not scheduled', + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: + order.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + isFlashDelivery: order.isFlashDelivery, + userNotes: order.userNotes, + adminNotes: order.adminNotes, + userNegativityScore: negativityScores[order.userId] || 0, + }; }); return { diff --git a/apps/backend/src/trpc/admin-apis/user.ts b/apps/backend/src/trpc/admin-apis/user.ts index f67b828..77141ce 100644 --- a/apps/backend/src/trpc/admin-apis/user.ts +++ b/apps/backend/src/trpc/admin-apis/user.ts @@ -4,7 +4,8 @@ import { db } from '../../db/db_index'; import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '../../db/schema'; import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm'; import { ApiError } from '../../lib/api-error'; -import { notificationQueue } from '../../lib/notif-job'; +import { notificationQueue } from '../../lib/notif-job'; +import { recomputeUserNegativityScore } from '../../stores/user-negativity-store'; async function createUserByMobile(mobile: string): Promise { // Clean mobile number (remove non-digits) @@ -470,21 +471,16 @@ export const userRouter = { } - const incidentObj = {userId, orderId, adminComment, addedBy: adminUserId, negativityScore} + const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore }; const [incident] = await db.insert(userIncidents) - .values( - // { - // userId, - // orderId, - // adminComment, - // addedBy: adminUserId, - // negativityScore, - // } - {...incidentObj} - ) + .values({ + ...incidentObj, + }) .returning(); + recomputeUserNegativityScore(userId); + return { success: true, data: incident,