345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
|
|
import AWS from 'aws-sdk';
|
|
import dayjs from 'dayjs';
|
|
import { z } from 'zod';
|
|
import { prismaClient } from '../../../common/database/prisma.lambda.service';
|
|
import { verifyHostToken } from '../../../common/middlewares/jwt/authForHost';
|
|
import { ROLE } from '../../../common/utils/constants/common.constant';
|
|
import { safeHandler } from '../../../common/utils/handlers/safeHandler';
|
|
import ApiError from "../../../common/utils/helper/ApiError";
|
|
import { parseMultipartFormData } from '../../../common/utils/helper/parseMultipartFormData';
|
|
import config from '../../../config/config';
|
|
|
|
const s3 = new AWS.S3({
|
|
region: config.aws.region,
|
|
});
|
|
|
|
const updateHostProfileSchema = z
|
|
.strictObject({
|
|
// Personal
|
|
fullName: z.string().min(1).optional(),
|
|
firstName: z.string().min(1).optional(),
|
|
lastName: z.string().min(1).optional(),
|
|
isdCode: z.string().min(1).max(6).optional(),
|
|
mobileNumber: z.string().min(5).max(15).optional(),
|
|
dateOfBirth: z.string().min(1).optional(),
|
|
|
|
profileImage: z.string().url().optional(),
|
|
|
|
// Address
|
|
address1: z.string().min(1).optional(),
|
|
address2: z.string().min(1).optional(),
|
|
countryXid: z.number().int().positive().optional(),
|
|
stateXid: z.number().int().positive().optional(),
|
|
cityXid: z.number().int().positive().optional(),
|
|
pinCode: z.string().min(1).optional(),
|
|
|
|
// explicitly forbidden
|
|
emailAddress: z.any().optional(),
|
|
})
|
|
.strip();
|
|
|
|
async function uploadProfileImageToS3(buffer: Buffer, mimeType: string, originalName: string, userId: number) {
|
|
const sanitizeFileName = (name: string) => {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9.]/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_+|_+$/g, '');
|
|
};
|
|
|
|
const fileExtension = originalName.split('.').pop() || 'jpg';
|
|
const fileName = `profile_image.${fileExtension}`;
|
|
const sanitizedFileName = sanitizeFileName(fileName);
|
|
const s3Key = `Host/ProfileImages/${userId}/${sanitizedFileName}`;
|
|
|
|
await s3
|
|
.upload({
|
|
Bucket: config.aws.bucketName,
|
|
Key: s3Key,
|
|
Body: buffer,
|
|
ContentType: mimeType,
|
|
ACL: 'private',
|
|
})
|
|
.promise();
|
|
|
|
return `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`;
|
|
}
|
|
|
|
function parseDob(dateOfBirth: string): Date {
|
|
const parsed = dayjs(dateOfBirth, ['YYYY-MM-DD', 'MM/DD/YYYY', 'DD/MM/YYYY'], true);
|
|
if (!parsed.isValid()) {
|
|
throw new ApiError(400, 'Invalid dateOfBirth. Use YYYY-MM-DD (recommended) or MM/DD/YYYY.');
|
|
}
|
|
return parsed.toDate();
|
|
}
|
|
|
|
function splitFullName(fullName: string): { firstName: string; lastName: string | null } {
|
|
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
|
const firstName = parts[0] || '';
|
|
const lastName = parts.length > 1 ? parts.slice(1).join(' ') : null;
|
|
return { firstName, lastName };
|
|
}
|
|
|
|
function getAuthToken(event: APIGatewayProxyEvent): string {
|
|
const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token'];
|
|
if (!token) {
|
|
throw new ApiError(400, 'This is a protected route. Please provide a valid token.');
|
|
}
|
|
return token;
|
|
}
|
|
|
|
function parseJsonBody(event: APIGatewayProxyEvent): any {
|
|
try {
|
|
return event.body ? JSON.parse(event.body) : {};
|
|
} catch {
|
|
throw new ApiError(400, 'Invalid JSON in request body');
|
|
}
|
|
}
|
|
|
|
function validateBody(body: any) {
|
|
const parsed = updateHostProfileSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
throw new ApiError(400, parsed.error.issues.map((i) => i.message).join(', '));
|
|
}
|
|
if (parsed.data.emailAddress !== undefined) {
|
|
throw new ApiError(400, 'Email address cannot be updated.');
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function normalizeNameFields(data: any): { firstName?: string; lastName?: string | null } {
|
|
if (data.fullName && !data.firstName && !data.lastName) {
|
|
const split = splitFullName(data.fullName);
|
|
return { firstName: split.firstName, lastName: split.lastName };
|
|
}
|
|
return { firstName: data.firstName, lastName: data.lastName };
|
|
}
|
|
|
|
function buildAddressInput(data: any) {
|
|
return {
|
|
address1: data.address1,
|
|
address2: data.address2,
|
|
countryXid: data.countryXid,
|
|
stateXid: data.stateXid,
|
|
cityXid: data.cityXid,
|
|
pinCode: data.pinCode,
|
|
};
|
|
}
|
|
|
|
function hasAnyDefined(obj: Record<string, unknown>) {
|
|
return Object.values(obj).some((v) => v !== undefined);
|
|
}
|
|
|
|
async function ensureHostUser(tx: any, userId: number) {
|
|
const user = await tx.user.findUnique({
|
|
where: { id: userId, isActive: true },
|
|
select: { id: true, roleXid: true },
|
|
});
|
|
|
|
if (!user) throw new ApiError(404, 'User not found');
|
|
if (user.roleXid !== ROLE.HOST) throw new ApiError(403, 'Access denied.');
|
|
}
|
|
|
|
async function updateUserIfNeeded(
|
|
tx: any,
|
|
userId: number,
|
|
input: {
|
|
firstName?: string;
|
|
lastName?: string | null;
|
|
isdCode?: string;
|
|
mobileNumber?: string;
|
|
dateOfBirth?: string;
|
|
profileImage?: string;
|
|
},
|
|
) {
|
|
const userUpdateData: any = {};
|
|
if (input.firstName !== undefined) userUpdateData.firstName = input.firstName || null;
|
|
if (input.lastName !== undefined) userUpdateData.lastName = input.lastName;
|
|
if (input.isdCode !== undefined) userUpdateData.isdCode = input.isdCode || null;
|
|
if (input.mobileNumber !== undefined) userUpdateData.mobileNumber = input.mobileNumber || null;
|
|
if (input.dateOfBirth !== undefined) {
|
|
userUpdateData.dateOfBirth = input.dateOfBirth ? parseDob(input.dateOfBirth) : null;
|
|
}
|
|
if (input.profileImage !== undefined) {
|
|
userUpdateData.profileImage = input.profileImage || null;
|
|
}
|
|
|
|
if (!hasAnyDefined(userUpdateData)) return;
|
|
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
...userUpdateData,
|
|
isProfileUpdated: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function upsertAddressIfNeeded(tx: any, userId: number, addressData: Record<string, any>) {
|
|
if (!hasAnyDefined(addressData)) return;
|
|
|
|
const existingAddress = await tx.userAddressDetails.findFirst({
|
|
where: { userXid: userId, isActive: true },
|
|
select: { id: true },
|
|
});
|
|
|
|
const addressUpdateData: any = {};
|
|
if (addressData.address1 !== undefined) addressUpdateData.address1 = addressData.address1;
|
|
if (addressData.address2 !== undefined) addressUpdateData.address2 = addressData.address2;
|
|
if (addressData.countryXid !== undefined) addressUpdateData.countryXid = addressData.countryXid;
|
|
if (addressData.stateXid !== undefined) addressUpdateData.stateXid = addressData.stateXid;
|
|
if (addressData.cityXid !== undefined) addressUpdateData.cityXid = addressData.cityXid;
|
|
if (addressData.pinCode !== undefined) addressUpdateData.pinCode = addressData.pinCode;
|
|
|
|
if (existingAddress) {
|
|
await tx.userAddressDetails.update({
|
|
where: { id: existingAddress.id },
|
|
data: addressUpdateData,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const required = ['address1', 'countryXid', 'stateXid', 'cityXid', 'pinCode'] as const;
|
|
const missing = required.filter((k) => addressData[k] === undefined);
|
|
if (missing.length) {
|
|
throw new ApiError(400, `Missing required address fields: ${missing.join(', ')}`);
|
|
}
|
|
|
|
await tx.userAddressDetails.create({
|
|
data: {
|
|
userXid: userId,
|
|
...addressUpdateData,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function getProfileSnapshot(tx: any, userId: number) {
|
|
const updated = await tx.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
emailAddress: true,
|
|
isdCode: true,
|
|
mobileNumber: true,
|
|
dateOfBirth: true,
|
|
profileImage: true,
|
|
isProfileUpdated: true,
|
|
userAddressDetails: {
|
|
where: { isActive: true },
|
|
take: 1,
|
|
select: {
|
|
id: true,
|
|
address1: true,
|
|
address2: true,
|
|
countryXid: true,
|
|
stateXid: true,
|
|
cityXid: true,
|
|
pinCode: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return {
|
|
user: updated,
|
|
address: updated?.userAddressDetails?.[0] ?? null,
|
|
};
|
|
}
|
|
|
|
export const handler = safeHandler(async (
|
|
event: APIGatewayProxyEvent,
|
|
context?: Context,
|
|
): Promise<APIGatewayProxyResult> => {
|
|
const token = getAuthToken(event);
|
|
const userInfo = await verifyHostToken(token);
|
|
const userId = Number(userInfo.id);
|
|
if (!userId || Number.isNaN(userId)) {
|
|
throw new ApiError(400, 'Invalid user id');
|
|
}
|
|
|
|
const contentType = event.headers['Content-Type'] || event.headers['content-type'] || '';
|
|
const isMultipart = contentType.includes('multipart/form-data');
|
|
|
|
let body: any;
|
|
|
|
if (isMultipart) {
|
|
const isBase64Encoded = event.isBase64Encoded || false;
|
|
const { fields, files } = parseMultipartFormData(event.body || null, contentType, isBase64Encoded);
|
|
|
|
const multipartBody: any = {};
|
|
|
|
const copyIfPresent = (key: string) => {
|
|
if (fields[key] !== undefined) {
|
|
multipartBody[key] = fields[key];
|
|
}
|
|
};
|
|
|
|
['fullName', 'firstName', 'lastName', 'isdCode', 'mobileNumber', 'dateOfBirth', 'address1', 'address2', 'pinCode'].forEach(
|
|
copyIfPresent,
|
|
);
|
|
|
|
const parseNumberField = (key: string) => {
|
|
if (fields[key] !== undefined) {
|
|
const value = Number(fields[key]);
|
|
if (!Number.isNaN(value)) {
|
|
multipartBody[key] = value;
|
|
}
|
|
}
|
|
};
|
|
|
|
['countryXid', 'stateXid', 'cityXid'].forEach(parseNumberField);
|
|
|
|
const profileImageFile = files.find((f) => f.fieldName === 'profileImage');
|
|
if (profileImageFile) {
|
|
const uploadedUrl = await uploadProfileImageToS3(
|
|
profileImageFile.data,
|
|
profileImageFile.contentType,
|
|
profileImageFile.fileName,
|
|
userId,
|
|
);
|
|
multipartBody.profileImage = uploadedUrl;
|
|
} else if (fields.profileImage) {
|
|
multipartBody.profileImage = fields.profileImage;
|
|
}
|
|
|
|
body = multipartBody;
|
|
} else {
|
|
body = parseJsonBody(event);
|
|
}
|
|
|
|
const data = validateBody(body);
|
|
const name = normalizeNameFields(data);
|
|
const address = buildAddressInput(data);
|
|
|
|
const result = await prismaClient.$transaction(async (tx) => {
|
|
await ensureHostUser(tx, userId);
|
|
await updateUserIfNeeded(tx, userId, {
|
|
firstName: name.firstName,
|
|
lastName: name.lastName,
|
|
isdCode: data.isdCode,
|
|
mobileNumber: data.mobileNumber,
|
|
dateOfBirth: data.dateOfBirth,
|
|
profileImage: data.profileImage,
|
|
});
|
|
await upsertAddressIfNeeded(tx, userId, address);
|
|
return getProfileSnapshot(tx, userId);
|
|
});
|
|
|
|
return {
|
|
statusCode: 200,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Access-Control-Allow-Origin': '*',
|
|
},
|
|
body: JSON.stringify({
|
|
success: true,
|
|
message: 'Profile updated successfully',
|
|
data : null// no data payload per request
|
|
}),
|
|
};
|
|
});
|
|
|