335 lines
8.9 KiB
TypeScript
335 lines
8.9 KiB
TypeScript
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<typeof opinionInsertSchema>;
|
|
type UserInsertSchema = z.infer<typeof userInsertSchema>;
|
|
type UserUpdateSchema = z.infer<typeof userUpdateSchema>;
|
|
|
|
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<any>[] = [];
|
|
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);
|
|
}
|