Compare commits

...

10 Commits

10 changed files with 231 additions and 139 deletions

View File

@@ -16,3 +16,8 @@ tasks:
studio:
cmds:
- 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";
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 }));
await db.insert(group).values(groupValues);
let opinionValues = Opinions.map((opinion) => ({

View File

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

9
pnpm-lock.yaml generated
View File

@@ -46,6 +46,9 @@ devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.9
version: 7.6.9
'@types/cors':
specifier: ^2.8.17
version: 2.8.17
'@types/jsonwebtoken':
specifier: ^9.0.6
version: 9.0.6
@@ -711,6 +714,12 @@ packages:
dependencies:
'@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:
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
dependencies:

View File

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

View File

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

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

View File

@@ -1,6 +1,9 @@
import { initTRPC } from "@trpc/server";
import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone";
import { db } from "./db";
import * as jwt from "jsonwebtoken";
import { Config } from "./config";
import { z } from "zod";
const t = initTRPC.context<Context>().create();
export const router = t.router;
@@ -35,8 +38,10 @@ type Context = Awaited<ReturnType<typeof createContext>>;
export const createContext = async (opts: CreateHTTPContextOptions) => {
const authorizationHeader = opts.req.headers.authorization || "";
const bearerToken = authorizationHeader.split(" ")[1];
const phone = verifyToken(bearerToken);
console.log(authorizationHeader, bearerToken);
const phone = await verifyToken(bearerToken);
if (phone !== null) {
let user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.phone, phone),
@@ -53,7 +58,28 @@ export const createContext = async (opts: CreateHTTPContextOptions) => {
}
};
function verifyToken(token: string): string | null {
//TODO: Implement token verification
return "08999";
async function verifyToken(token: string): Promise<string | null> {
try {
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 {
router,
verifiedPhone,
publicProcedure,
protectedProcedure,
} from "./trpc";
import { router, publicProcedure, protectedProcedure } from "./trpc";
import { db } from "./db";
import { phoneToken, user, userOpinion } from "./schema";
import { opinion, user, userOpinion } from "./schema";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { SQL, eq } from "drizzle-orm";
import { Config } from "./config";
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)
.omit({
userId: true,
@@ -27,6 +30,7 @@ const opinionUpdateSchema = createInsertSchema(userOpinion)
.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({
getAllUser: publicProcedure
@@ -36,33 +40,34 @@ export const userRoute = router({
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.group, input.zone)
await getAllUser(
input.offset,
input.limit,
input.opinionCount,
input.group,
input.zone
)
),
createUser: verifiedPhone
createUser: publicProcedure
.input(
userInsertSchema.omit({ id: true, phone: true }).extend({
userInsertSchema.omit({ id: true }).extend({
opinions: opinionInsertSchema,
})
)
.mutation(
async ({ input, ctx }) =>
await createUser({ ...input, phone: ctx.phone }, input.opinions)
async ({ input }) => await createUser({ ...input }, input.opinions)
),
requestOtp: publicProcedure
.input(z.object({ phone: z.string().trim().min(5) }))
.mutation(async ({ input }) => await requestOtp(input.phone)),
verifyOtp: publicProcedure
.input(
z.object({
pin: z.string().trim(),
token: z.string(),
})
)
.mutation(async ({ input }) => await verifyOtp(input.token, input.pin)),
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(
@@ -74,13 +79,16 @@ export const userRoute = router({
async function getAllUser(
offset: number,
limit: number,
opinionLimit: number,
group?: number,
zone?: number
) {
let users = await db.query.user.findMany({
with: {
group: true,
opinions: true,
opinions: {
limit: opinionLimit,
},
zone: {
with: { province: true },
},
@@ -98,7 +106,11 @@ async function getAllUser(
return and(...conditions);
},
});
return users;
return users.map((u) => ({
...u,
phone: hidePhone(u.phone),
}));
}
async function createUser(
newUser: UserInsertSchema,
@@ -111,112 +123,78 @@ async function createUser(
for (let op of opinions) {
await db.insert(userOpinion).values({ ...op, userId: result.id });
}
return { status: "OK" };
return { token: createJWT(newUser.phone) };
} catch (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) {
const _phone = phone.trim();
async function updateUser(userId: number, update: UserUpdateSchema) {
try {
let rs = await fetch(Config.sms_api_request_endpoint, {
method: "POST",
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,
};
await db.update(user).set(update).where(eq(user.id, userId));
return { status: "success" };
} catch (e) {
console.error(e);
throw new TRPCError({
message: `Unable to request OTP:\n${e}`,
code: "INTERNAL_SERVER_ERROR",
});
throw new Error(`Unable to update user`);
}
}
async function verifyOtp(token: string, pin: string) {
try {
console.log(token, pin);
let pt = await db.query.phoneToken.findFirst({
where: (pt, { eq }) => eq(pt.token, token),
orderBy: (pt, { desc }) => desc(pt.createdOn),
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 (pt === undefined) {
if (user === 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`,
message: "Invalid Credentials",
code: "BAD_REQUEST",
});
} else {
await db.delete(phoneToken).where(eq(phoneToken.phone, pt.phone));
console.log(rs, pt.phone);
return rs;
return { token: createJWT(user.phone) };
}
} catch (e) {
console.error(e);
throw new TRPCError({
message: `Unable to verify OTP:\n${e}`,
code: "BAD_REQUEST",
}
function createJWT(phone: string) {
return jwt.sign({ phone: phone }, Config.jwt_secret, {
expiresIn: "365d",
});
}
}
async function changeOpinion(
opinionId: number,
userId: number,
opinion: OpinionInsertSchema[0]["choice"]
opinionChoice: OpinionInsertSchema[0]["choice"]
) {
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({
opinionId,
userId,
choice: opinion,
choice: opinionChoice,
})
.onConflictDoUpdate({
target: [userOpinion.userId, userOpinion.opinionId],
set: { choice: opinion },
set: { choice: opinionChoice },
});
return { status: "success" };
} catch (e) {
console.error(e);
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));
}