added playground and userroute

This commit is contained in:
2024-04-19 13:01:58 +07:00
parent a90529f258
commit a3f61b35c1
13 changed files with 1110 additions and 80 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules node_modules
sqlite.db sqlite.db
.DS_Store

View File

@@ -9,6 +9,12 @@ CREATE TABLE `opinions` (
`type` text `type` text
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `phone_tokens` (
`phone` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`created_on` integer DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE TABLE `provinces` ( CREATE TABLE `provinces` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL `name` text NOT NULL
@@ -16,21 +22,26 @@ CREATE TABLE `provinces` (
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `users` ( CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text, `firstName` text NOT NULL,
`lastName` text NOT NULL,
`title` text NOT NULL,
`phone` text NOT NULL, `phone` text NOT NULL,
`email` text, `email` text,
`job` text, `job` text NOT NULL,
`education` text, `education` text NOT NULL,
`vision` text, `vision` text,
`reason` text, `reason` text,
`group_id` integer, `group_id` integer NOT NULL,
FOREIGN KEY (`group_id`) REFERENCES `groups`(`id`) ON UPDATE no action ON DELETE no action `zone_id` integer NOT NULL,
FOREIGN KEY (`group_id`) REFERENCES `groups`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`zone_id`) REFERENCES `zones`(`id`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `user_opinions` ( CREATE TABLE `user_opinions` (
`user_id` integer, `user_id` integer,
`opinion_id` integer, `opinion_id` integer,
`choice` text DEFAULT 'ignore', `choice` text DEFAULT 'ignore',
PRIMARY KEY(`opinion_id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`opinion_id`) REFERENCES `opinions`(`id`) ON UPDATE no action ON DELETE no action FOREIGN KEY (`opinion_id`) REFERENCES `opinions`(`id`) ON UPDATE no action ON DELETE no action
); );

View File

@@ -1,7 +1,7 @@
{ {
"version": "5", "version": "5",
"dialect": "sqlite", "dialect": "sqlite",
"id": "71187b23-6b9a-4f78-9f3c-0af6fb035f1b", "id": "86633c77-c16c-46e0-92a5-f0e696d3276c",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"groups": { "groups": {
@@ -57,6 +57,37 @@
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {} "uniqueConstraints": {}
}, },
"phone_tokens": {
"name": "phone_tokens",
"columns": {
"phone": {
"name": "phone",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_on": {
"name": "created_on",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"provinces": { "provinces": {
"name": "provinces", "name": "provinces",
"columns": { "columns": {
@@ -90,11 +121,25 @@
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": true
}, },
"name": { "firstName": {
"name": "name", "name": "firstName",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false
},
"lastName": {
"name": "lastName",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false "autoincrement": false
}, },
"phone": { "phone": {
@@ -115,14 +160,14 @@
"name": "job", "name": "job",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"education": { "education": {
"name": "education", "name": "education",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"vision": { "vision": {
@@ -143,7 +188,14 @@
"name": "group_id", "name": "group_id",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false
},
"zone_id": {
"name": "zone_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false "autoincrement": false
} }
}, },
@@ -169,6 +221,19 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
},
"users_zone_id_zones_id_fk": {
"name": "users_zone_id_zones_id_fk",
"tableFrom": "users",
"tableTo": "zones",
"columnsFrom": [
"zone_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
@@ -229,7 +294,15 @@
"onUpdate": "no action" "onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {
"user_opinions_user_id_opinion_id_pk": {
"columns": [
"opinion_id",
"user_id"
],
"name": "user_opinions_user_id_opinion_id_pk"
}
},
"uniqueConstraints": {} "uniqueConstraints": {}
}, },
"zones": { "zones": {

View File

@@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "5", "version": "5",
"when": 1713465576653, "when": 1713506374595,
"tag": "0000_aspiring_paibok", "tag": "0000_hot_gressill",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -11,10 +11,15 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@trpc/client": "11.0.0-rc.340", "@trpc/client": "^10.45.2",
"@trpc/server": "11.0.0-rc.340", "@trpc/server": "^10.45.2",
"better-sqlite3": "^9.5.0", "better-sqlite3": "^9.5.0",
"drizzle-orm": "^0.30.8" "cors": "^2.8.5",
"drizzle-orm": "^0.30.8",
"drizzle-zod": "^0.5.1",
"express": "^4.19.2",
"trpc-playground": "^1.0.4",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@swc/core": "^1.4.16", "@swc/core": "^1.4.16",

721
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,24 @@
import { drizzle } from "drizzle-orm/better-sqlite3"; import { createContext, router } from "./trpc";
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createHTTPServer } from "@trpc/server/adapters/standalone";
import Database from "better-sqlite3"; import { db } from "./db";
import { user } from "./schema.ts"; import { userRoute } from "./userRoute";
import { runPlayground } from "./playgroud";
import cors from "cors";
const sqlite = new Database("sqlite.db"); export const appRouter = router({
const db = drizzle(sqlite); user: userRoute,
migrate(db, { migrationsFolder: "drizzle" }); });
export type AppRouter = typeof appRouter;
async function main() { async function main() {
await db.insert(user).values({ const server = createHTTPServer({
job: "Software Engineer", createContext: createContext,
phone: "1234567890", router: appRouter,
group: 1, middleware: cors(),
name: "John Doe",
education: "Bachelor",
}); });
console.log("User inserted");
server.listen(3000);
runPlayground(appRouter);
} }
(async () => { (async () => {

6
src/config.ts Normal file
View File

@@ -0,0 +1,6 @@
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",
};

8
src/db.ts Normal file
View File

@@ -0,0 +1,8 @@
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import Database from "better-sqlite3";
import * as schema from "./schema.ts";
const sqlite = new Database("sqlite.db");
export const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: "drizzle" });

23
src/playgroud.ts Normal file
View File

@@ -0,0 +1,23 @@
import express from "express";
import { expressHandler } from "trpc-playground/handlers/express";
import { AppRouter } from "./app";
export const runPlayground = async (appRouter: AppRouter) => {
const app = express();
const trpcApiEndpoint = "http://localhost:3000";
const playgroundEndpoint = "/";
app.use(
playgroundEndpoint,
await expressHandler({
trpcApiEndpoint,
playgroundEndpoint,
router: appRouter,
})
);
app.listen(3001, () => {
console.log("listening at http://localhost:3001");
});
};

View File

@@ -2,14 +2,16 @@ import {
sqliteTable, sqliteTable,
text, text,
integer, integer,
uniqueIndex, primaryKey,
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
//----------------User //----------------User
export const user = sqliteTable("users", { export const user = sqliteTable("users", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
name: text("name").notNull(), firstName: text("firstName").notNull(),
lastName: text("lastName").notNull(),
title: text("title").notNull(),
phone: text("phone").unique().notNull(), phone: text("phone").unique().notNull(),
email: text("email"), email: text("email"),
job: text("job").notNull(), job: text("job").notNull(),
@@ -19,6 +21,9 @@ export const user = sqliteTable("users", {
group: integer("group_id") group: integer("group_id")
.references(() => group.id) .references(() => group.id)
.notNull(), .notNull(),
zone: integer("zone_id")
.notNull()
.references(() => zone.id),
}); });
export const userRelation = relations(user, ({ many, one }) => ({ export const userRelation = relations(user, ({ many, one }) => ({
@@ -27,6 +32,10 @@ export const userRelation = relations(user, ({ many, one }) => ({
fields: [user.group], fields: [user.group],
references: [group.id], references: [group.id],
}), }),
zone: one(zone, {
fields: [user.zone],
references: [zone.id],
}),
})); }));
//----------------Group //----------------Group
@@ -47,13 +56,19 @@ export const opinion = sqliteTable("opinions", {
}); });
//----------------UserOpinion //----------------UserOpinion
export const userOpinion = sqliteTable("user_opinions", { export const userOpinion = sqliteTable(
userId: integer("user_id").references(() => user.id), "user_opinions",
opinionId: integer("opinion_id").references(() => opinion.id), {
choice: text("choice", { userId: integer("user_id").references(() => user.id),
enum: ["agree", "disagree", "deciding", "ignore"], opinionId: integer("opinion_id").references(() => opinion.id),
}).default("ignore"), choice: text("choice", {
}); enum: ["agree", "disagree", "deciding", "ignore"],
}).default("ignore"),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.opinionId] }),
})
);
export const userOpinionRelation = relations(userOpinion, ({ one }) => ({ export const userOpinionRelation = relations(userOpinion, ({ one }) => ({
user: one(user, { user: one(user, {
@@ -84,5 +99,14 @@ export const province = sqliteTable("provinces", {
}); });
export const provinceRelation = relations(province, ({ many }) => ({ export const provinceRelation = relations(province, ({ many }) => ({
province: many(zone), zones: many(zone),
})); }));
//----------------PhoneToken
export const phoneToken = sqliteTable("phone_tokens", {
phone: text("phone").primaryKey(),
token: text("token").notNull(),
createdOn: integer("created_on", { mode: "timestamp" }).default(
sql`CURRENT_TIMESTAMP`
),
});

59
src/trpc.ts Normal file
View File

@@ -0,0 +1,59 @@
import { initTRPC } from "@trpc/server";
import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone";
import { db } from "./db";
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async (opts) => {
const { ctx } = opts;
if (ctx.user === undefined) {
throw new Error("Unauthorized");
} else {
return opts.next({
ctx: {
...ctx,
user: ctx.user,
},
});
}
});
export const verifiedPhone = t.procedure.use(async (opts) => {
const { ctx } = opts;
if (ctx.phone === undefined) {
throw new Error("Unauthorized");
} else {
return opts.next({
ctx: {
phone: ctx.phone,
},
});
}
});
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);
if (phone !== null) {
let user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.phone, phone),
});
return {
phone,
user: user,
};
} else {
return {
phone: undefined,
user: undefined,
};
}
};
function verifyToken(token: string): string | null {
//TODO: Implement token verification
return "08999";
}

170
src/userRoute.ts Normal file
View File

@@ -0,0 +1,170 @@
import {
router,
verifiedPhone,
publicProcedure,
protectedProcedure,
} from "./trpc";
import { db } from "./db";
import { phoneToken, user } 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";
const userInsertSchema = createInsertSchema(user);
type UserInsertSchema = z.infer<typeof userInsertSchema>;
export const userRoute = router({
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(),
})
)
.query(
async ({ input }) =>
await getAllUser(input.offset, input.limit, input.group, input.zone)
),
createUser: verifiedPhone
.input(userInsertSchema.omit({ id: true, phone: true }))
.mutation(
async ({ input, ctx }) => await createUser({ ...input, phone: ctx.phone })
),
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)),
});
async function getAllUser(
offset: number,
limit: number,
group?: number,
zone?: number
) {
let users = await db.query.user.findMany({
with: {
group: true,
opinions: true,
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;
}
async function createUser(newUser: UserInsertSchema) {
try {
let result = await db.insert(user).values(newUser);
return result.lastInsertRowid;
} catch (e) {
console.error(e);
throw new Error(`Unable to create new user:\n${e}`);
}
}
async function requestOtp(phone: string) {
const _phone = phone.trim();
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,
};
} catch (e) {
console.error(e);
throw new TRPCError({
message: `Unable to request OTP:\n${e}`,
code: "INTERNAL_SERVER_ERROR",
});
}
}
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),
});
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({
message: `Unable to verify OTP:\n${e}`,
code: "BAD_REQUEST",
});
}
}