import { router, publicProcedure, protectedProcedure } from "./trpc"; import { db } from "./db"; import { imageToUser, opinion, user, userOpinion } from "./schema"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; import { SQL, count, eq } from "drizzle-orm"; import { Config } from "./config"; import { TRPCError } from "@trpc/server"; import * as jwt from "jsonwebtoken"; import { createClient, createUploadImageUrl } from "./minio"; const userInsertSchema = createInsertSchema(user, { cid: (schema) => schema.cid.length(13).refine(isValidThaiID, { message: "Invalid Thai ID" }), }); const userUpdateSchema = userInsertSchema .omit({ id: true, cid: true, phone: true }) .partial(); const opinionInsertSchema = createInsertSchema(userOpinion) .omit({ userId: true, }) .array() .default([]); const opinionUpdateSchema = createInsertSchema(userOpinion) .omit({ userId: true, }) .required({ opinionId: true }); type OpinionInsertSchema = z.infer; type UserInsertSchema = z.infer; type UserUpdateSchema = z.infer; export const userRoute = router({ createUser: publicProcedure .input( userInsertSchema.omit({ id: true }).extend({ opinions: opinionInsertSchema, }) ) .mutation( async ({ input }) => await createUser({ ...input }, input.opinions) ), // changeImage: protectedProcedure updateUser: protectedProcedure .input(userUpdateSchema) .mutation(async ({ input, ctx }) => await updateUser(ctx.user.id, input)), login: publicProcedure .input(z.object({ cid: z.string(), phone: z.string() })) .mutation(async ({ input }) => await login(input.cid, input.phone)), changeOpinion: protectedProcedure .input(opinionUpdateSchema) .mutation( async ({ input, ctx }) => await changeOpinion(input.opinionId, ctx.user.id, input.choice) ), requestChangeImage: protectedProcedure .input(z.object({ imageName: z.string(), contentType: z.string() })) .mutation( async ({ input, ctx }) => await requestChangeImage( ctx.user.id, input.imageName, input.contentType ) ), confirmChangeImage: protectedProcedure.mutation( async ({ ctx }) => await confirmChangeImage(ctx.user.id, ctx.user.image) ), getAllUser: publicProcedure .input( z.object({ offset: z.number().default(0), limit: z.number().max(50).default(10), group: z.number().optional(), zone: z.number().optional(), opinionCount: z.number().default(3), }) ) .query( async ({ input }) => await getAllUser( input.offset, input.limit, input.opinionCount, input.group, input.zone ) ), }); async function getAllUser( offset: number, limit: number, opinionLimit: number, group?: number, zone?: number ) { let users = await db.query.user.findMany({ with: { group: true, opinions: { limit: opinionLimit, }, zone: { with: { province: true }, }, }, limit, offset, where: (user, { eq, and }) => { let conditions: SQL[] = []; if (group) { conditions.push(eq(user.group, group)); } if (zone) { conditions.push(eq(user.zone, zone)); } return and(...conditions); }, }); return users.map((u) => ({ ...u, phone: hidePhone(u.phone), })); } async function createUser( newUser: UserInsertSchema, opinions: OpinionInsertSchema ) { try { let result = ( await db.insert(user).values(newUser).returning({ id: user.id }) )[0]; for (let op of opinions) { await db.insert(userOpinion).values({ ...op, userId: result.id }); } return { token: createJWT(newUser.phone) }; } catch (e) { console.error(e); throw new Error(`Unable to create new user`); } } async function updateUser(userId: number, update: UserUpdateSchema) { try { await db.update(user).set(update).where(eq(user.id, userId)); return { status: "success" }; } catch (e) { console.error(e); throw new Error(`Unable to update user`); } } async function login(cid: string, phone: string) { let user = await db.query.user.findFirst({ where: (user, { and, eq }) => and(eq(user.cid, cid), eq(user.phone, phone)), }); if (user === undefined) { throw new TRPCError({ message: "Invalid Credentials", code: "BAD_REQUEST", }); } else { return { token: createJWT(user.phone) }; } } async function changeOpinion( opinionId: number, userId: number, opinionChoice: OpinionInsertSchema[0]["choice"] ) { try { let thisOpinion = await db .select() .from(opinion) .where(eq(opinion.id, opinionId)) .then((opinions) => opinions.at(0)); if (thisOpinion === undefined) { throw new TRPCError({ message: "Invalid Opinion ID", code: "BAD_REQUEST", }); } else if (thisOpinion.type === "3Choice" && opinionChoice === "ignore") { throw new TRPCError({ message: "Invalid Opinion Choice", code: "BAD_REQUEST", }); } await db .insert(userOpinion) .values({ opinionId, userId, choice: opinionChoice, }) .onConflictDoUpdate({ target: [userOpinion.userId, userOpinion.opinionId], set: { choice: opinionChoice }, }); return { status: "success" }; } catch (e) { console.error(e); throw new TRPCError({ message: "Error updating schema", code: "INTERNAL_SERVER_ERROR", }); } } async function requestChangeImage( userId: number, imageName: string, contentType: string ) { const mc = createClient(); // Check if the image is valid const allowedImageTypes = z.enum(["image/png", "image/jpeg", "image/webp"]); if (!allowedImageTypes.safeParse(contentType).success) { throw new TRPCError({ message: "Only PNG, JPEG, and WEBP images are allowed", code: "BAD_REQUEST", }); } const allowedExtension = z.enum(["png", "jpeg", "jpg", "webp"]); const extension = imageName.split(".").pop(); if (!allowedExtension.safeParse(extension).success) { throw new TRPCError({ message: "only .png, .jpeg, .jpg, and .webp extensions are allowed", code: "BAD_REQUEST", }); } // Create a unique image name let tryCount = 0; let objectName: string | null = null; while (tryCount < 3) { let imageName = `${generateRandomString()}.${extension}`; let ok = await db .select({ value: count(user.image) }) .from(user) .where(eq(user.image, imageName)) .then((v) => v[0].value === 0); if (ok) { objectName = imageName; break; } } if (objectName === null) { throw new TRPCError({ message: "Unable to create image request (conflicting name)", code: "INTERNAL_SERVER_ERROR", }); } // Store a record in the database await db .insert(imageToUser) .values({ userId, imageName: objectName }) .onConflictDoUpdate({ target: [imageToUser.userId], set: { imageName: objectName }, }); return await createUploadImageUrl(mc, objectName, contentType); } async function confirmChangeImage(userId: number, oldImage: string | null) { const mc = createClient(); let rs = await db .select({ imageName: imageToUser.imageName }) .from(imageToUser) .where(eq(imageToUser.userId, userId)); if (rs.length === 0) { throw new TRPCError({ message: "No image request found", code: "BAD_REQUEST", }); } let imageName = rs[0].imageName; const isImageExist = await mc .statObject(Config.bucketName, imageName) .then(() => true) .catch(() => false); if (!isImageExist) { throw new TRPCError({ message: "Image not found", code: "BAD_REQUEST", }); } const promises: Promise[] = []; if (oldImage) { promises.push(mc.removeObject(Config.bucketName, oldImage).catch(() => {})); } const updateUser = db .update(user) .set({ image: imageName }) .where(eq(user.id, userId)); const deleteRecord = db .delete(imageToUser) .where(eq(imageToUser.userId, userId)); await Promise.all([...promises, updateUser, deleteRecord]); return { status: "success" }; } function isValidThaiID(id: string) { if (!/^\d{13}$/.test(id)) { return false; } let sum = 0; for (let i = 0; i < 12; i++) { sum += Number(id[i]) * (13 - i); } const checkSum = (11 - (sum % 11)) % 10; return checkSum === Number(id[12]); } function hidePhone(phone: string | null) { if (phone === null) return phone; return phone.slice(0, 2).concat("******").concat(phone.slice(-3)); } function createJWT(phone: string) { return jwt.sign({ phone: phone }, Config.jwt_secret, { expiresIn: "365d", }); } function generateRandomString() { return Math.random().toString(36).substring(2, 15); }