enh
This commit is contained in:
parent
5b19a0486c
commit
ee0b71fcd3
8 changed files with 3830 additions and 12 deletions
File diff suppressed because one or more lines are too long
|
|
@ -11,6 +11,7 @@ import {
|
||||||
AppContainer,
|
AppContainer,
|
||||||
MyText,
|
MyText,
|
||||||
tw,
|
tw,
|
||||||
|
Checkbox,
|
||||||
} from 'common-ui';
|
} from 'common-ui';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
@ -103,10 +104,20 @@ export default function UserDetails() {
|
||||||
{ enabled: !!userId }
|
{ enabled: !!userId }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateSuspension = trpc.admin.user.updateUserSuspension.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleOrderPress = useCallback((orderId: number) => {
|
const handleOrderPress = useCallback((orderId: number) => {
|
||||||
router.push(`/(drawer)/order-details/${orderId}`);
|
router.push(`/(drawer)/order-details/${orderId}`);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSuspensionToggle = useCallback((isSuspended: boolean) => {
|
||||||
|
updateSuspension.mutate({ userId, isSuspended });
|
||||||
|
}, [userId, updateSuspension]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<AppContainer>
|
||||||
|
|
@ -163,16 +174,23 @@ export default function UserDetails() {
|
||||||
<MaterialIcons name="person" size={24} color="#3B82F6" />
|
<MaterialIcons name="person" size={24} color="#3B82F6" />
|
||||||
</View>
|
</View>
|
||||||
<View style={tw`flex-1`}>
|
<View style={tw`flex-1`}>
|
||||||
<MyText style={tw`text-gray-900 font-bold text-lg mb-0.5`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
{user.mobile || 'No Mobile'}
|
<MyText style={tw`text-gray-900 font-bold text-lg mb-0.5`}>
|
||||||
</MyText>
|
{user.mobile || 'No Mobile'}
|
||||||
|
</MyText>
|
||||||
|
{user.isSuspended && (
|
||||||
|
<View style={tw`ml-2 px-2 py-0.5 bg-red-100 rounded-full border border-red-200`}>
|
||||||
|
<MyText style={tw`text-[10px] font-bold text-red-700 uppercase`}>Suspended</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<MyText style={tw`text-gray-500`}>
|
<MyText style={tw`text-gray-500`}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={tw`bg-gray-50 p-3 rounded-xl`}>
|
<View style={tw`bg-gray-50 p-3 rounded-xl mb-4`}>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<MaterialIcons name="access-time" size={18} color="#6B7280" />
|
<MaterialIcons name="access-time" size={18} color="#6B7280" />
|
||||||
<MyText style={tw`ml-2 text-gray-600`}>
|
<MyText style={tw`ml-2 text-gray-600`}>
|
||||||
|
|
@ -180,6 +198,27 @@ export default function UserDetails() {
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Suspension Toggle */}
|
||||||
|
<View style={tw`flex-row items-center justify-between pt-4 border-t border-gray-100`}>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-gray-900 font-bold text-base mb-1`}>
|
||||||
|
Suspend User
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-sm`}>
|
||||||
|
Prevent user from placing orders
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{updateSuspension.isPending ? (
|
||||||
|
<ActivityIndicator size="small" color="#3b82f6" />
|
||||||
|
) : (
|
||||||
|
<Checkbox
|
||||||
|
checked={user.isSuspended}
|
||||||
|
onPress={() => handleSuspensionToggle(!user.isSuspended)}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Orders Section */}
|
{/* Orders Section */}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface User {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
totalOrders: number;
|
totalOrders: number;
|
||||||
lastOrderDate: string | null;
|
lastOrderDate: string | null;
|
||||||
|
isSuspended: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserItemProps {
|
interface UserItemProps {
|
||||||
|
|
@ -56,9 +57,16 @@ const UserItem: React.FC<UserItemProps> = ({ user, index, onPress }) => {
|
||||||
{/* Middle: User Info */}
|
{/* Middle: User Info */}
|
||||||
<View style={tw`flex-1`}>
|
<View style={tw`flex-1`}>
|
||||||
{/* Mobile number - primary identifier */}
|
{/* Mobile number - primary identifier */}
|
||||||
<MyText style={tw`text-gray-900 font-bold text-base mb-0.5`}>
|
<View style={tw`flex-row items-center mb-0.5`}>
|
||||||
{user.mobile || 'No Mobile'}
|
<MyText style={tw`text-gray-900 font-bold text-base`}>
|
||||||
</MyText>
|
{user.mobile || 'No Mobile'}
|
||||||
|
</MyText>
|
||||||
|
{user.isSuspended && (
|
||||||
|
<View style={tw`ml-2 px-2 py-0.5 bg-red-100 rounded-full border border-red-200`}>
|
||||||
|
<MyText style={tw`text-[10px] font-bold text-red-700 uppercase`}>Suspended</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<MyText style={tw`text-gray-500 text-sm mb-1`}>
|
<MyText style={tw`text-gray-500 text-sm mb-1`}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
ENV_MODE=PROD
|
ENV_MODE=PROD
|
||||||
DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
||||||
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||||
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||||
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
||||||
PHONE_PE_CLIENT_VERSION=1
|
PHONE_PE_CLIENT_VERSION=1
|
||||||
|
|
|
||||||
7
apps/backend/drizzle/0072_flowery_deathbird.sql
Normal file
7
apps/backend/drizzle/0072_flowery_deathbird.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE "mf"."user_notifications" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "mf"."user_notifications_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"image_url" varchar(500),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"applicable_users" jsonb
|
||||||
|
);
|
||||||
3685
apps/backend/drizzle/meta/0072_snapshot.json
Normal file
3685
apps/backend/drizzle/meta/0072_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -505,6 +505,13 @@
|
||||||
"when": 1770321591876,
|
"when": 1770321591876,
|
||||||
"tag": "0071_moaning_shadow_king",
|
"tag": "0071_moaning_shadow_king",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1770546741428,
|
||||||
|
"tag": "0072_flowery_deathbird",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { protectedProcedure } from '../trpc-index';
|
import { protectedProcedure } from '../trpc-index';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db } from '../../db/db_index';
|
import { db } from '../../db/db_index';
|
||||||
import { users, complaints, orders, orderItems, notifCreds, userNotifications } from '../../db/schema';
|
import { users, complaints, orders, orderItems, notifCreds, userNotifications, userDetails } from '../../db/schema';
|
||||||
import { eq, sql, desc, asc, count, max } from 'drizzle-orm';
|
import { eq, sql, desc, asc, count, max } from 'drizzle-orm';
|
||||||
import { ApiError } from '../../lib/api-error';
|
import { ApiError } from '../../lib/api-error';
|
||||||
|
|
||||||
|
|
@ -103,6 +103,7 @@ export const userRouter = {
|
||||||
|
|
||||||
let orderCounts: { userId: number; totalOrders: number }[] = [];
|
let orderCounts: { userId: number; totalOrders: number }[] = [];
|
||||||
let lastOrders: { userId: number; lastOrderDate: Date | null }[] = [];
|
let lastOrders: { userId: number; lastOrderDate: Date | null }[] = [];
|
||||||
|
let suspensionStatuses: { userId: number; isSuspended: boolean }[] = [];
|
||||||
|
|
||||||
if (userIds.length > 0) {
|
if (userIds.length > 0) {
|
||||||
// Get total orders per user
|
// Get total orders per user
|
||||||
|
|
@ -124,17 +125,28 @@ export const userRouter = {
|
||||||
.from(orders)
|
.from(orders)
|
||||||
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
|
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
|
||||||
.groupBy(orders.userId);
|
.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
|
// Create lookup maps
|
||||||
const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders]));
|
const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders]));
|
||||||
const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate]));
|
const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate]));
|
||||||
|
const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended]));
|
||||||
|
|
||||||
// Combine data
|
// Combine data
|
||||||
const usersWithStats = usersToReturn.map(user => ({
|
const usersWithStats = usersToReturn.map(user => ({
|
||||||
...user,
|
...user,
|
||||||
totalOrders: orderCountMap.get(user.id) || 0,
|
totalOrders: orderCountMap.get(user.id) || 0,
|
||||||
lastOrderDate: lastOrderMap.get(user.id) || null,
|
lastOrderDate: lastOrderMap.get(user.id) || null,
|
||||||
|
isSuspended: suspensionMap.get(user.id) ?? false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get next cursor
|
// Get next cursor
|
||||||
|
|
@ -170,6 +182,15 @@ export const userRouter = {
|
||||||
throw new ApiError('User not found', 404);
|
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
|
// Get all orders for this user with order items count
|
||||||
const userOrders = await db
|
const userOrders = await db
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -237,11 +258,62 @@ export const userRouter = {
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: user[0],
|
user: {
|
||||||
|
...user[0],
|
||||||
|
isSuspended: userDetail[0]?.isSuspended ?? false,
|
||||||
|
},
|
||||||
orders: ordersWithDetails,
|
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
|
getUsersForNotification: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue