Compare commits

...

10 Commits

10 changed files with 231 additions and 139 deletions

View File

@@ -16,3 +16,8 @@ tasks:
studio: studio:
cmds: cmds:
- pnpm drizzle-kit studio - pnpm drizzle-kit studio
start:
cmds:
- node -r @swc-node/register src/app.ts
env:
NODE_ENV: production

View File

@@ -3,6 +3,13 @@ import { group, opinion, zone, province } from "./src/schema.ts";
import { Groups, Opinions, Provinces, Districts } from "./initialData.ts"; import { Groups, Opinions, Provinces, Districts } from "./initialData.ts";
async function main() { async function main() {
const isInitialized = await db.query.group
.findMany()
.then((groups) => groups.length > 0);
if (isInitialized) {
console.log("Already initialized");
return;
}
let groupValues = Groups.map((group) => ({ name: group })); let groupValues = Groups.map((group) => ({ name: group }));
await db.insert(group).values(groupValues); await db.insert(group).values(groupValues);
let opinionValues = Opinions.map((opinion) => ({ let opinionValues = Opinions.map((opinion) => ({

View File

@@ -27,6 +27,7 @@
"@swc-node/register": "^1.9.0", "@swc-node/register": "^1.9.0",
"@swc/core": "^1.4.16", "@swc/core": "^1.4.16",
"@types/better-sqlite3": "^7.6.9", "@types/better-sqlite3": "^7.6.9",
"@types/cors": "^2.8.17",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"drizzle-kit": "^0.20.14", "drizzle-kit": "^0.20.14",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",

9
pnpm-lock.yaml generated
View File

@@ -46,6 +46,9 @@ devDependencies:
'@types/better-sqlite3': '@types/better-sqlite3':
specifier: ^7.6.9 specifier: ^7.6.9
version: 7.6.9 version: 7.6.9
'@types/cors':
specifier: ^2.8.17
version: 2.8.17
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: ^9.0.6 specifier: ^9.0.6
version: 9.0.6 version: 9.0.6
@@ -711,6 +714,12 @@ packages:
dependencies: dependencies:
'@types/node': 20.12.7 '@types/node': 20.12.7
/@types/cors@2.8.17:
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
dependencies:
'@types/node': 20.12.7
dev: true
/@types/jsonwebtoken@9.0.6: /@types/jsonwebtoken@9.0.6:
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
dependencies: dependencies:

View File

@@ -3,9 +3,11 @@ import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { userRoute } from "./userRoute"; import { userRoute } from "./userRoute";
import { runPlayground } from "./playgroud"; import { runPlayground } from "./playgroud";
import cors from "cors"; import cors from "cors";
import { infoRoute } from "./infoRoute";
export const appRouter = router({ export const appRouter = router({
user: userRoute, user: userRoute,
info: infoRoute,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@@ -1,6 +1,5 @@
export const Config = { export const Config = {
sms_api_key: "1796570121771765", jwt_secret:
sms_api_secret: "0957b611d575febff1ae0fc51070c8b7", "T4kE6/tIqCVEZYg9lwsqeJjYfOoXTXSXDEMyParsJjj57CjSdkrfPOLWP74/9lJpcBA=",
sms_api_request_endpoint: "https://otp.thaibulksms.com/v2/otp/request", token_duration: "365d",
sms_api_verify_endpoint: "https://otp.thaibulksms.com/v2/otp/verify",
}; };

30
src/infoRoute.ts Normal file
View File

@@ -0,0 +1,30 @@
import { router, publicProcedure } from "./trpc";
import { db } from "./db";
import { z } from "zod";
export const infoRoute = router({
getAllProvinces: publicProcedure.query(getProvinces),
getAllGroups: publicProcedure.query(getGroups),
getAllZones: publicProcedure
.input(z.object({ provice_id: z.number().optional() }))
.query(async ({ input }) => await getZone(input.provice_id)),
});
async function getProvinces() {
return await db.query.province.findMany();
}
async function getZone(province?: number) {
return await db.query.zone.findMany({
where: (zone, { and, eq }) => {
if (province === undefined) {
return and();
}
return eq(zone.province, province);
},
});
}
async function getGroups() {
return await db.query.group.findMany();
}

View File

@@ -3,28 +3,43 @@ import {
text, text,
integer, integer,
primaryKey, primaryKey,
unique,
index,
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
//----------------User //----------------User
export const user = sqliteTable("users", { export const user = sqliteTable(
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), "users",
firstName: text("firstName").notNull(), {
lastName: text("lastName").notNull(), id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
title: text("title").notNull(), firstName: text("firstName").notNull(),
phone: text("phone").unique().notNull(), lastName: text("lastName").notNull(),
email: text("email"), title: text("title").notNull(),
job: text("job").notNull(), cid: text("cid", { length: 13 }).notNull().unique(),
education: text("education").notNull(), age: integer("age").notNull(),
vision: text("vision"), phone: text("phone").unique().notNull(),
reason: text("reason"), public_phone: text("public_phone"),
group: integer("group_id") facebook: text("facebook"),
.references(() => group.id) twitter: text("twitter"),
.notNull(), tiktok: text("tiktok"),
zone: integer("zone_id") otherSocial: text("other_social"),
.notNull() email: text("email"),
.references(() => zone.id), job: text("job").notNull(),
}); education: text("education").notNull(),
vision: text("vision"),
reason: text("reason"),
group: integer("group_id")
.references(() => group.id)
.notNull(),
zone: integer("zone_id")
.notNull()
.references(() => zone.id),
},
(t) => ({
phone_idx: index("phone_idx").on(t.phone),
})
);
export const userRelation = relations(user, ({ many, one }) => ({ export const userRelation = relations(user, ({ many, one }) => ({
opinions: many(userOpinion), opinions: many(userOpinion),
@@ -41,7 +56,7 @@ export const userRelation = relations(user, ({ many, one }) => ({
//----------------Group //----------------Group
export const group = sqliteTable("groups", { export const group = sqliteTable("groups", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").unique().notNull(),
}); });
export const groupRelation = relations(group, ({ many }) => ({ export const groupRelation = relations(group, ({ many }) => ({
@@ -51,7 +66,7 @@ export const groupRelation = relations(group, ({ many }) => ({
//----------------Opinion //----------------Opinion
export const opinion = sqliteTable("opinions", { export const opinion = sqliteTable("opinions", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").unique().notNull(),
type: text("type", { enum: ["3Choice", "4Choice"] }) type: text("type", { enum: ["3Choice", "4Choice"] })
.default("3Choice") .default("3Choice")
.notNull(), .notNull(),
@@ -84,13 +99,17 @@ export const userOpinionRelation = relations(userOpinion, ({ one }) => ({
})); }));
//----------------Zone //----------------Zone
export const zone = sqliteTable("zones", { export const zone = sqliteTable(
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), "zones",
name: text("name").notNull(), {
province: integer("province_id") id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
.notNull() name: text("name").notNull(),
.references(() => province.id), province: integer("province_id")
}); .notNull()
.references(() => province.id),
},
(t) => ({ unique_name_province: unique().on(t.name, t.province) })
);
export const zoneRelation = relations(zone, ({ one }) => ({ export const zoneRelation = relations(zone, ({ one }) => ({
province: one(province, { province: one(province, {
fields: [zone.province], fields: [zone.province],
@@ -101,7 +120,7 @@ export const zoneRelation = relations(zone, ({ one }) => ({
//----------------Province //----------------Province
export const province = sqliteTable("provinces", { export const province = sqliteTable("provinces", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").unique().notNull(),
}); });
export const provinceRelation = relations(province, ({ many }) => ({ export const provinceRelation = relations(province, ({ many }) => ({

View File

@@ -1,6 +1,9 @@
import { initTRPC } from "@trpc/server"; import { initTRPC } from "@trpc/server";
import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone"; import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone";
import { db } from "./db"; import { db } from "./db";
import * as jwt from "jsonwebtoken";
import { Config } from "./config";
import { z } from "zod";
const t = initTRPC.context<Context>().create(); const t = initTRPC.context<Context>().create();
export const router = t.router; export const router = t.router;
@@ -35,8 +38,10 @@ type Context = Awaited<ReturnType<typeof createContext>>;
export const createContext = async (opts: CreateHTTPContextOptions) => { export const createContext = async (opts: CreateHTTPContextOptions) => {
const authorizationHeader = opts.req.headers.authorization || ""; const authorizationHeader = opts.req.headers.authorization || "";
const bearerToken = authorizationHeader.split(" ")[1]; const bearerToken = authorizationHeader.split(" ")[1];
const phone = verifyToken(bearerToken); console.log(authorizationHeader, bearerToken);
const phone = await verifyToken(bearerToken);
if (phone !== null) { if (phone !== null) {
let user = await db.query.user.findFirst({ let user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.phone, phone), where: (user, { eq }) => eq(user.phone, phone),
@@ -53,7 +58,28 @@ export const createContext = async (opts: CreateHTTPContextOptions) => {
} }
}; };
function verifyToken(token: string): string | null { async function verifyToken(token: string): Promise<string | null> {
//TODO: Implement token verification try {
return "08999"; let rs = await new Promise((resolve, reject) => {
jwt.verify(token, Config.jwt_secret, (err, decoded) => {
if (err) {
reject(err);
} else {
resolve(decoded);
}
});
});
let data = z
.object({
phone: z.string(),
})
.safeParse(rs);
if (data.success) {
return data.data.phone;
} else {
return null;
}
} catch (e) {
return null;
}
} }

View File

@@ -1,18 +1,21 @@
import { import { router, publicProcedure, protectedProcedure } from "./trpc";
router,
verifiedPhone,
publicProcedure,
protectedProcedure,
} from "./trpc";
import { db } from "./db"; import { db } from "./db";
import { phoneToken, user, userOpinion } from "./schema"; import { opinion, user, userOpinion } from "./schema";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { z } from "zod"; import { z } from "zod";
import { SQL, eq } from "drizzle-orm"; import { SQL, eq } from "drizzle-orm";
import { Config } from "./config"; import { Config } from "./config";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import * as jwt from "jsonwebtoken";
const userInsertSchema = createInsertSchema(user); 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) const opinionInsertSchema = createInsertSchema(userOpinion)
.omit({ .omit({
userId: true, userId: true,
@@ -27,6 +30,7 @@ const opinionUpdateSchema = createInsertSchema(userOpinion)
.required({ opinionId: true }); .required({ opinionId: true });
type OpinionInsertSchema = z.infer<typeof opinionInsertSchema>; type OpinionInsertSchema = z.infer<typeof opinionInsertSchema>;
type UserInsertSchema = z.infer<typeof userInsertSchema>; type UserInsertSchema = z.infer<typeof userInsertSchema>;
type UserUpdateSchema = z.infer<typeof userUpdateSchema>;
export const userRoute = router({ export const userRoute = router({
getAllUser: publicProcedure getAllUser: publicProcedure
@@ -36,33 +40,34 @@ export const userRoute = router({
limit: z.number().max(50).default(10), limit: z.number().max(50).default(10),
group: z.number().optional(), group: z.number().optional(),
zone: z.number().optional(), zone: z.number().optional(),
opinionCount: z.number().default(3),
}) })
) )
.query( .query(
async ({ input }) => async ({ input }) =>
await getAllUser(input.offset, input.limit, input.group, input.zone) await getAllUser(
input.offset,
input.limit,
input.opinionCount,
input.group,
input.zone
)
), ),
createUser: verifiedPhone createUser: publicProcedure
.input( .input(
userInsertSchema.omit({ id: true, phone: true }).extend({ userInsertSchema.omit({ id: true }).extend({
opinions: opinionInsertSchema, opinions: opinionInsertSchema,
}) })
) )
.mutation( .mutation(
async ({ input, ctx }) => async ({ input }) => await createUser({ ...input }, input.opinions)
await createUser({ ...input, phone: ctx.phone }, input.opinions)
), ),
requestOtp: publicProcedure updateUser: protectedProcedure
.input(z.object({ phone: z.string().trim().min(5) })) .input(userUpdateSchema)
.mutation(async ({ input }) => await requestOtp(input.phone)), .mutation(async ({ input, ctx }) => await updateUser(ctx.user.id, input)),
verifyOtp: publicProcedure login: publicProcedure
.input( .input(z.object({ cid: z.string(), phone: z.string() }))
z.object({ .mutation(async ({ input }) => await login(input.cid, input.phone)),
pin: z.string().trim(),
token: z.string(),
})
)
.mutation(async ({ input }) => await verifyOtp(input.token, input.pin)),
changeOpinion: protectedProcedure changeOpinion: protectedProcedure
.input(opinionUpdateSchema) .input(opinionUpdateSchema)
.mutation( .mutation(
@@ -74,13 +79,16 @@ export const userRoute = router({
async function getAllUser( async function getAllUser(
offset: number, offset: number,
limit: number, limit: number,
opinionLimit: number,
group?: number, group?: number,
zone?: number zone?: number
) { ) {
let users = await db.query.user.findMany({ let users = await db.query.user.findMany({
with: { with: {
group: true, group: true,
opinions: true, opinions: {
limit: opinionLimit,
},
zone: { zone: {
with: { province: true }, with: { province: true },
}, },
@@ -98,7 +106,11 @@ async function getAllUser(
return and(...conditions); return and(...conditions);
}, },
}); });
return users;
return users.map((u) => ({
...u,
phone: hidePhone(u.phone),
}));
} }
async function createUser( async function createUser(
newUser: UserInsertSchema, newUser: UserInsertSchema,
@@ -111,112 +123,78 @@ async function createUser(
for (let op of opinions) { for (let op of opinions) {
await db.insert(userOpinion).values({ ...op, userId: result.id }); await db.insert(userOpinion).values({ ...op, userId: result.id });
} }
return { status: "OK" }; return { token: createJWT(newUser.phone) };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new Error(`Unable to create new user:\n${e}`); throw new Error(`Unable to create new user`);
} }
} }
async function requestOtp(phone: string) { async function updateUser(userId: number, update: UserUpdateSchema) {
const _phone = phone.trim();
try { try {
let rs = await fetch(Config.sms_api_request_endpoint, { await db.update(user).set(update).where(eq(user.id, userId));
method: "POST", return { status: "success" };
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
key: Config.sms_api_key,
secret: Config.sms_api_secret,
msisdn: _phone,
}),
}).then((res) => res.json());
if (rs.errors) {
console.error(rs);
throw new TRPCError({
message: `Unable to request OTP`,
code: "INTERNAL_SERVER_ERROR",
});
}
await db.insert(phoneToken).values({ phone: _phone, token: rs.token });
return {
status: rs.status as string,
token: rs.token as string,
refno: rs.refno as string,
};
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new TRPCError({ throw new Error(`Unable to update user`);
message: `Unable to request OTP:\n${e}`,
code: "INTERNAL_SERVER_ERROR",
});
} }
} }
async function verifyOtp(token: string, pin: string) { async function login(cid: string, phone: string) {
try { let user = await db.query.user.findFirst({
console.log(token, pin); where: (user, { and, eq }) => and(eq(user.cid, cid), eq(user.phone, phone)),
let pt = await db.query.phoneToken.findFirst({ });
where: (pt, { eq }) => eq(pt.token, token), if (user === undefined) {
orderBy: (pt, { desc }) => desc(pt.createdOn),
});
if (pt === undefined) {
throw new TRPCError({
message: `Invalid token`,
code: "BAD_REQUEST",
});
}
pt;
let rs = await fetch(Config.sms_api_verify_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
key: Config.sms_api_key,
secret: Config.sms_api_secret,
token,
pin,
}),
}).then((res) => res.json());
if (rs.errors) {
console.error(rs);
throw new TRPCError({
message: `Unable to verify OTP`,
code: "BAD_REQUEST",
});
} else {
await db.delete(phoneToken).where(eq(phoneToken.phone, pt.phone));
console.log(rs, pt.phone);
return rs;
}
} catch (e) {
console.error(e);
throw new TRPCError({ throw new TRPCError({
message: `Unable to verify OTP:\n${e}`, message: "Invalid Credentials",
code: "BAD_REQUEST", code: "BAD_REQUEST",
}); });
} else {
return { token: createJWT(user.phone) };
} }
} }
function createJWT(phone: string) {
return jwt.sign({ phone: phone }, Config.jwt_secret, {
expiresIn: "365d",
});
}
async function changeOpinion( async function changeOpinion(
opinionId: number, opinionId: number,
userId: number, userId: number,
opinion: OpinionInsertSchema[0]["choice"] opinionChoice: OpinionInsertSchema[0]["choice"]
) { ) {
try { try {
db.insert(userOpinion) 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({ .values({
opinionId, opinionId,
userId, userId,
choice: opinion, choice: opinionChoice,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [userOpinion.userId, userOpinion.opinionId], target: [userOpinion.userId, userOpinion.opinionId],
set: { choice: opinion }, set: { choice: opinionChoice },
}); });
return { status: "success" };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new TRPCError({ throw new TRPCError({
@@ -225,3 +203,19 @@ async function changeOpinion(
}); });
} }
} }
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));
}