This commit is contained in:
shafi54 2026-03-22 16:11:01 +05:30
parent 1122159552
commit 501667a4d2
12 changed files with 107 additions and 475 deletions

File diff suppressed because one or more lines are too long

View file

@ -75,7 +75,6 @@ app.use('/api/trpc', createExpressMiddleware({
let staffUser = null;
const authHeader = req.headers.authorization;
console.log({authHeader})
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {

View file

@ -4,8 +4,6 @@ import { ApiError } from "@/src/lib/api-error"
import v1Router from "@/src/v1-router"
import testController from "@/src/test-controller"
import { authenticateUser } from "@/src/middleware/auth.middleware"
import { raiseComplaint } from "@/src/uv-apis/user-rest.controller"
import uploadHandler from "@/src/lib/upload-handler"
const router = Router();

View file

@ -69,7 +69,7 @@ export const commonApiRouter = router({
generateUploadUrls: protectedProcedure
.input(z.object({
contextString: z.enum(['review', 'product_info', 'store', 'notification', 'profile']),
contextString: z.enum(['review', 'product_info', 'store', 'notification', 'profile', 'complaint']),
mimeTypes: z.array(z.string()),
}))
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
@ -89,6 +89,8 @@ export const commonApiRouter = router({
folder = 'store-images';
} else if (contextString === 'profile') {
folder = 'profile-images';
} else if (contextString === 'complaint') {
folder = 'complaint-images';
}
// else if (contextString === 'review_response') {
//

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { complaints } from '@/src/db/schema';
import { eq } from 'drizzle-orm';
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client';
export const complaintRouter = router({
getAll: protectedProcedure
@ -17,6 +18,7 @@ export const complaintRouter = router({
isResolved: complaints.isResolved,
createdAt: complaints.createdAt,
orderId: complaints.orderId,
images: complaints.images,
})
.from(complaints)
.where(eq(complaints.userId, userId))
@ -30,34 +32,39 @@ export const complaintRouter = router({
isResolved: c.isResolved,
createdAt: c.createdAt,
orderId: c.orderId,
images: c.images ? scaffoldAssetUrl(c.images as string[]) : [],
})),
};
}),
raise: protectedProcedure
.input(z.object({
orderId: z.string().optional(),
orderId: z.number().optional(),
complaintBody: z.string().min(1, 'Complaint body is required'),
imageKeys: z.array(z.string()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { orderId, complaintBody } = input;
let orderIdNum: number | null = null;
if (orderId) {
const readableIdMatch = orderId.match(/^ORD(\d+)$/);
if (readableIdMatch) {
orderIdNum = parseInt(readableIdMatch[1]);
}
}
const { orderId, complaintBody, imageKeys } = input;
await db.insert(complaints).values({
userId,
orderId: orderIdNum,
orderId: orderId || null,
complaintBody: complaintBody.trim(),
images: imageKeys || [],
});
// Claim upload URLs for images
if (imageKeys && imageKeys.length > 0) {
for (const key of imageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
return { success: true, message: 'Complaint raised successfully' };
}),
});

View file

@ -1,321 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { eq } from 'drizzle-orm';
import { db } from '@/src/db/db_index'
import { users, userCreds, userDetails } from '@/src/db/schema'
import { ApiError } from '@/src/lib/api-error'
import catchAsync from '@/src/lib/catch-async'
import { jwtSecret } from '@/src/lib/env-exporter';
import uploadHandler from '@/src/lib/upload-handler'
import { imageUploadS3, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
interface RegisterRequest {
name: string;
email: string;
mobile: string;
password: string;
profileImage?: string;
}
interface UpdateProfileRequest {
name?: string;
email?: string;
mobile?: string;
password?: string;
bio?: string;
dateOfBirth?: string;
gender?: string;
occupation?: string;
}
interface AuthResponse {
token: string;
user: {
id: number;
name: string | null;
email: string | null;
mobile: string | null;
profileImage?: string | null;
bio?: string | null;
dateOfBirth?: string | null;
gender?: string | null;
occupation?: string | null;
};
}
const generateToken = (userId: number): string => {
const secret = jwtSecret;
if (!secret) {
throw new ApiError('JWT secret not configured', 500);
}
return jwt.sign({ userId }, secret, { expiresIn: '7d' });
};
export const register = catchAsync(async (req: Request, res: Response, next: NextFunction) => {
const { name, email, mobile, password }: RegisterRequest = req.body;
// Handle profile image upload
let profileImageUrl: string | undefined;
if (req.file) {
const key = `profile-images/${Date.now()}-${req.file.originalname}`;
profileImageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
}
if (!name || !email || !mobile || !password) {
throw new ApiError('All fields are required', 400);
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new ApiError('Invalid email format', 400);
}
// Validate mobile format (Indian mobile numbers)
const cleanMobile = mobile.replace(/\D/g, '');
if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile)) {
throw new ApiError('Invalid mobile number', 400);
}
// Check if email already exists
const [existingEmail] = await db
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (existingEmail) {
throw new ApiError('Email already registered', 409);
}
// Check if mobile already exists
const [existingMobile] = await db
.select()
.from(users)
.where(eq(users.mobile, cleanMobile))
.limit(1);
if (existingMobile) {
throw new ApiError('Mobile number already registered', 409);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user and credentials in a transaction
const newUser = await db.transaction(async (tx) => {
// Create user
const [user] = await tx
.insert(users)
.values({
name: name.trim(),
email: email.toLowerCase().trim(),
mobile: cleanMobile,
})
.returning();
// Create user credentials
await tx
.insert(userCreds)
.values({
userId: user.id,
userPassword: hashedPassword,
});
// Create user details with profile image
await tx
.insert(userDetails)
.values({
userId: user.id,
profileImage: profileImageUrl,
});
return user;
});
const token = generateToken(newUser.id);
// Generate signed URL for profile image if it was uploaded
const profileImageSignedUrl = profileImageUrl
? await generateSignedUrlFromS3Url(profileImageUrl)
: null;
const response: AuthResponse = {
token,
user: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
mobile: newUser.mobile,
profileImage: profileImageSignedUrl,
bio: null,
dateOfBirth: null,
gender: null,
occupation: null,
},
};
res.status(201).json({
success: true,
data: response,
});
});
export const updateProfile = catchAsync(async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.userId;
if (!userId) {
throw new ApiError('User not authenticated', 401);
}
const { name, email, mobile, password, bio, dateOfBirth, gender, occupation }: UpdateProfileRequest = req.body;
// Handle profile image upload
let profileImageUrl: string | undefined;
if (req.file) {
const key = `profile-images/${Date.now()}-${req.file.originalname}`;
profileImageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
}
// Validate email format if provided
if (email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new ApiError('Invalid email format', 400);
}
}
// Validate mobile format if provided
if (mobile) {
const cleanMobile = mobile.replace(/\D/g, '');
if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile)) {
throw new ApiError('Invalid mobile number', 400);
}
}
// Check if email already exists (if changing email)
if (email) {
const [existingEmail] = await db
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (existingEmail && existingEmail.id !== userId) {
throw new ApiError('Email already registered', 409);
}
}
// Check if mobile already exists (if changing mobile)
if (mobile) {
const cleanMobile = mobile.replace(/\D/g, '');
const [existingMobile] = await db
.select()
.from(users)
.where(eq(users.mobile, cleanMobile))
.limit(1);
if (existingMobile && existingMobile.id !== userId) {
throw new ApiError('Mobile number already registered', 409);
}
}
// Update user and user details in a transaction
const updatedUser = await db.transaction(async (tx) => {
// Update user table
const updateData: any = {};
if (name) updateData.name = name.trim();
if (email) updateData.email = email.toLowerCase().trim();
if (mobile) updateData.mobile = mobile.replace(/\D/g, '');
if (Object.keys(updateData).length > 0) {
await tx
.update(users)
.set(updateData)
.where(eq(users.id, userId));
}
// Update password if provided
if (password) {
const hashedPassword = await bcrypt.hash(password, 12);
await tx
.update(userCreds)
.set({ userPassword: hashedPassword })
.where(eq(userCreds.userId, userId));
}
// Update or insert user details
const userDetailsUpdate: any = {};
if (bio !== undefined) userDetailsUpdate.bio = bio;
if (dateOfBirth !== undefined) userDetailsUpdate.dateOfBirth = dateOfBirth ? new Date(dateOfBirth) : null;
if (gender !== undefined) userDetailsUpdate.gender = gender;
if (occupation !== undefined) userDetailsUpdate.occupation = occupation;
if (profileImageUrl) userDetailsUpdate.profileImage = profileImageUrl;
userDetailsUpdate.updatedAt = new Date();
// Check if user details record exists
const [existingDetails] = await tx
.select()
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
if (existingDetails) {
// Update existing record
await tx
.update(userDetails)
.set(userDetailsUpdate)
.where(eq(userDetails.userId, userId));
} else {
// Create new record
userDetailsUpdate.userId = userId;
userDetailsUpdate.createdAt = new Date();
await tx
.insert(userDetails)
.values(userDetailsUpdate);
}
// Return updated user data
const [user] = await tx
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user;
});
// Get updated user details for response
const [userDetail] = await db
.select()
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
// Generate signed URL for profile image if it exists
const profileImageSignedUrl = userDetail?.profileImage
? await generateSignedUrlFromS3Url(userDetail.profileImage)
: null;
const response: AuthResponse = {
token: req.headers.authorization?.replace('Bearer ', '') || '', // Keep existing token
user: {
id: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email,
mobile: updatedUser.mobile,
profileImage: profileImageSignedUrl,
bio: userDetail?.bio || null,
dateOfBirth: userDetail?.dateOfBirth || null,
gender: userDetail?.gender || null,
occupation: userDetail?.occupation || null,
},
};
res.status(200).json({
success: true,
data: response,
});
});

View file

@ -1,12 +0,0 @@
import { Router } from 'express';
import { register, updateProfile } from '@/src/uv-apis/auth.controller'
import { verifyToken } from '@/src/middleware/auth'
import uploadHandler from '@/src/lib/upload-handler'
const router = Router();
router.post('/register', uploadHandler.single('profileImage'), register);
router.put('/profile', verifyToken, uploadHandler.single('profileImage'), updateProfile);
const authRouter = router;
export default authRouter;

View file

@ -1,57 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { db } from '@/src/db/db_index'
import { complaints } from '@/src/db/schema'
import { ApiError } from '@/src/lib/api-error'
import catchAsync from '@/src/lib/catch-async'
import { imageUploadS3 } from '@/src/lib/s3-client'
interface RaiseComplaintRequest {
orderId?: string;
complaintBody: string;
}
export const raiseComplaint = catchAsync(async (req: Request, res: Response, next: NextFunction) => {
console.log('raising complaint')
const userId = req.user?.userId;
if (!userId) {
throw new ApiError('User not authenticated', 401);
}
const { orderId, complaintBody }: RaiseComplaintRequest = req.body;
let orderIdNum: number | null = null;
if (orderId) {
const readableIdMatch = orderId.match(/^ORD(\d+)$/);
if (readableIdMatch) {
orderIdNum = parseInt(readableIdMatch[1]);
}
}
// Handle image uploads
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
let uploadedImageUrls: string[] = [];
if (images && Array.isArray(images)) {
const imageUploadPromises = images.map((file, index) => {
const key = `complaint-images/${Date.now()}-${index}`;
return imageUploadS3(file.buffer, file.mimetype, key);
});
uploadedImageUrls = await Promise.all(imageUploadPromises);
}
await db.insert(complaints).values({
userId,
orderId: orderIdNum,
complaintBody: complaintBody.trim(),
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : null,
});
res.status(200).json({
success: true,
message: 'Complaint raised successfully'
});
});

View file

@ -1,12 +1,8 @@
import { Router } from "express";
import authRouter from "@/src/uv-apis/auth.router"
import { raiseComplaint } from "@/src/uv-apis/user-rest.controller"
import uploadHandler from "@/src/lib/upload-handler";
const router = Router();
router.use("/auth", authRouter);
router.use("/complaints/raise", uploadHandler.array('images'),raiseComplaint)
// All user APIs migrated to tRPC
const uvRouter = router;
export default uvRouter;

View file

@ -48,6 +48,23 @@ const ComplaintItem: React.FC<ComplaintItemProps> = ({ item }) => (
{item.complaintBody}
</MyText>
{/* Complaint Images */}
{item.images && item.images.length > 0 && (
<View style={tw`mb-4`}>
<View style={tw`flex-row flex-wrap -m-1`}>
{item.images.map((imageUrl: string, index: number) => (
<View key={index} style={tw`w-1/3 p-1`}>
<Image
source={{ uri: imageUrl }}
style={tw`w-full aspect-square rounded-lg`}
contentFit="cover"
/>
</View>
))}
</View>
</View>
)}
{/* Admin Response */}
{item.response && (
<View style={tw`bg-blue-50 p-4 rounded-xl border border-blue-100`}>

View file

@ -1,11 +1,10 @@
import React, { useState } from 'react';
import { View, TextInput, ActivityIndicator, KeyboardAvoidingView, Platform, Alert } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useMutation } from "@tanstack/react-query";
import { MyText, ImageUploader, tw, MyTouchableOpacity } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import axios from '../services/axios-user-ui';
// import axios from 'common-ui/src/services/axios';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/src/hooks/useUploadToObjectStorage';
interface ComplaintFormProps {
open: boolean;
@ -15,70 +14,73 @@ interface ComplaintFormProps {
export default function ComplaintForm({ open, onClose, orderId }: ComplaintFormProps) {
const [complaintBody, setComplaintBody] = useState('');
const [complaintImages, setComplaintImages] = useState<{ uri?: string }[]>([]);
// API function
const raiseComplaintApi = async (payload: { complaintBody: string; images: { uri?: string }[] }) => {
const formData = new FormData();
formData.append('orderId', orderId.toString());
formData.append('complaintBody', payload.complaintBody);
// Add images if provided
if (payload.images && payload.images.length > 0) {
payload.images.forEach((image, index) => {
if (image.uri) {
const fileName = `complaint-image-${index}.jpg`;
formData.append('images', {
uri: image.uri,
name: fileName,
type: 'image/jpeg',
} as any);
}
});
}
const response = await axios.post('/uv/complaints/raise', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
// Hook
const raiseComplaintMutation = useMutation({
mutationFn: raiseComplaintApi,
});
const [complaintImages, setComplaintImages] = useState<{ uri: string; mimeType: string }[]>([]);
const raiseComplaintMutation = trpc.user.complaint.raise.useMutation();
const { upload, isUploading } = useUploadToObjectStorage();
const pickComplaintImage = usePickImage({
setFile: (files) => setComplaintImages(prev => [...prev, ...files]),
setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
return;
}
const files = Array.isArray(assets) ? assets : [assets];
const newImages = files.map((asset: any) => ({
uri: asset.uri,
mimeType: asset.mimeType || 'image/jpeg',
}));
setComplaintImages(prev => [...prev, ...newImages]);
},
multiple: true,
});
const handleSubmit = () => {
const handleRemoveImage = (image: { uri: string; mimeType: string }) => {
setComplaintImages(prev => prev.filter(img => img.uri !== image.uri));
};
const handleSubmit = async () => {
if (!complaintBody.trim()) {
Alert.alert('Error', 'Please enter complaint details');
return;
}
raiseComplaintMutation.mutate(
{
complaintBody: complaintBody.trim(),
images: complaintImages,
},
{
onSuccess: () => {
Alert.alert('Success', 'Complaint raised successfully');
setComplaintBody('');
setComplaintImages([]);
onClose();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to raise complaint');
},
try {
let imageKeys: string[] = [];
// Upload images if provided
if (complaintImages.length > 0) {
// Fetch blobs from URIs
const imagesWithBlobs = await Promise.all(
complaintImages.map(async (img) => {
const response = await fetch(img.uri);
const blob = await response.blob();
return { blob, mimeType: img.mimeType };
})
);
const result = await upload({
images: imagesWithBlobs,
contextString: 'complaint',
});
imageKeys = result.keys;
}
);
// Submit complaint
await raiseComplaintMutation.mutateAsync({
orderId,
complaintBody: complaintBody.trim(),
imageKeys,
});
Alert.alert('Success', 'Complaint raised successfully');
setComplaintBody('');
setComplaintImages([]);
onClose();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to raise complaint');
}
};
if (!open) return null;
@ -109,14 +111,15 @@ export default function ComplaintForm({ open, onClose, orderId }: ComplaintFormP
images={complaintImages}
onAddImage={pickComplaintImage}
onRemoveImage={(uri) => setComplaintImages(prev => prev.filter(img => img.uri !== uri))}
allowMultiple={true}
/>
<MyTouchableOpacity
style={tw`bg-yellow-500 py-4 rounded-xl shadow-sm items-center mt-4 ${raiseComplaintMutation.isPending ? 'opacity-70' : ''}`}
style={tw`bg-yellow-500 py-4 rounded-xl shadow-sm items-center mt-4 ${isUploading || raiseComplaintMutation.isPending ? 'opacity-70' : ''}`}
onPress={handleSubmit}
disabled={raiseComplaintMutation.isPending}
disabled={isUploading || raiseComplaintMutation.isPending}
>
{raiseComplaintMutation.isPending ? (
{isUploading || raiseComplaintMutation.isPending ? (
<ActivityIndicator color="white" />
) : (
<MyText style={tw`text-white font-bold text-lg`}>Submit Complaint</MyText>
@ -125,4 +128,4 @@ export default function ComplaintForm({ open, onClose, orderId }: ComplaintFormP
</View>
</KeyboardAvoidingView>
);
}
}

View file

@ -2,7 +2,7 @@ import { useState } from 'react';
import { trpc } from '../trpc-client';
// import { trpc } from '../src/trpc-client';
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'profile';
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'profile' | 'complaint';
interface UploadInput {
blob: Blob;