Compare commits

..

67 Commits

Author SHA1 Message Date
cba647ca27 randomize result
All checks were successful
backend-action / build-image (push) Successful in 11m53s
2024-06-24 15:16:47 +07:00
f6b18d3f82 apply limit to resulting array
All checks were successful
backend-action / build-image (push) Successful in 6m21s
2024-06-24 00:23:42 +07:00
d660fd12fa update province user code 2024-06-24 00:23:16 +07:00
38f769ef67 remove admin workflow
All checks were successful
backend-action / build-image (push) Successful in 10m41s
2024-06-08 10:52:58 +07:00
b152175499 change filter method
Some checks failed
backend-admin-action / build-image (push) Failing after 41s
backend-action / build-image (push) Has been cancelled
2024-06-08 10:51:53 +07:00
e0655a56d7 update top three to match filter condition
Some checks failed
backend-admin-action / build-image (push) Failing after 45s
backend-action / build-image (push) Successful in 13m59s
2024-06-08 10:32:37 +07:00
3d6de91dc8 added locking mechanism
Some checks failed
backend-admin-action / build-image (push) Failing after 38s
backend-action / build-image (push) Successful in 7m35s
2024-06-08 10:04:49 +07:00
b28aae201a update db schema + populate allUser 2024-06-08 09:44:53 +07:00
51f52c0a1e make search api public
Some checks failed
backend-admin-action / build-image (push) Failing after 2m33s
backend-action / build-image (push) Successful in 17m31s
2024-06-06 12:15:09 +07:00
decd9a6945 increase timeout
Some checks failed
backend-admin-action / build-image (push) Failing after 38s
backend-action / build-image (push) Successful in 1h53m1s
2024-05-27 11:30:13 +07:00
bb69b33481 increase timeout for cicd
Some checks failed
backend-admin-action / build-image (push) Failing after 42s
backend-action / build-image (push) Has been cancelled
2024-05-27 10:57:08 +07:00
a7eb7d5037 show phone number in api
Some checks failed
backend-admin-action / build-image (push) Failing after 52s
backend-action / build-image (push) Failing after 11m23s
2024-05-27 10:42:43 +07:00
96129c1fe9 added totalUserDeep
Some checks failed
backend-admin-action / build-image (push) Failing after 2m8s
backend-action / build-image (push) Successful in 3m22s
2024-05-25 08:43:12 +07:00
b990b04902 remove public folder clone
Some checks failed
backend-admin-action / build-image (push) Failing after 11m5s
backend-action / build-image (push) Failing after 11m38s
2024-05-20 17:06:32 +07:00
577d97cfcd remove target from admin workflow
Some checks failed
backend-action / build-image (push) Failing after 8s
backend-admin-action / build-image (push) Failing after 7s
2024-05-20 16:51:13 +07:00
23f37df217 added sorvor admin page
Some checks failed
backend-admin-action / build-image (push) Failing after 8s
backend-action / build-image (push) Failing after 1m40s
2024-05-20 16:48:06 +07:00
98a65043c9 add total to zone
Some checks failed
backend-action / build-image (push) Failing after 10m40s
2024-05-20 15:26:18 +07:00
4934f799f5 disable user verification change
All checks were successful
backend-action / build-image (push) Successful in 6m24s
2024-05-20 14:49:44 +07:00
2a69ff0f3e fix remove bug
All checks were successful
backend-action / build-image (push) Successful in 28s
2024-05-20 14:11:50 +07:00
64ea2e9524 add removation
All checks were successful
backend-action / build-image (push) Successful in 8m1s
2024-05-20 13:50:14 +07:00
a29bac1eb2 redo summary
All checks were successful
backend-action / build-image (push) Successful in 7m29s
2024-05-20 12:57:40 +07:00
9a54c4712e return summary
Some checks failed
backend-action / build-image (push) Has been cancelled
2024-05-20 12:56:36 +07:00
ca130dc2b8 update admin route
Some checks failed
backend-action / build-image (push) Has been cancelled
2024-05-20 12:34:54 +07:00
5c739bcfa4 fix initial data + workflow
All checks were successful
backend-action / build-image (push) Successful in 3m40s
2024-05-18 10:04:39 +07:00
7e8d8dc523 fix key typo
Some checks failed
backend-action / build-image (push) Has been cancelled
2024-05-18 09:57:47 +07:00
60a7753247 rename to key
Some checks failed
backend-action / build-image (push) Failing after 1m6s
2024-05-18 09:53:57 +07:00
d54649893c added update to server script
Some checks failed
backend-action / build-image (push) Failing after 1m8s
2024-05-18 09:51:27 +07:00
0f6ec77dc0 update to wal mode
All checks were successful
backend-action / build-image (push) Successful in 58s
2024-05-18 09:40:43 +07:00
99f9531d32 added registrationno field
All checks were successful
backend-action / build-image (push) Successful in 1m6s
2024-05-17 15:55:34 +07:00
3c37fbf59b temporary server action for group and inside 2024-05-17 15:33:15 +07:00
c1a019a461 lint
All checks were successful
backend-action / build-image (push) Successful in 1m9s
2024-05-16 17:26:27 +07:00
5c4abf24bb added linting
added linting
2024-05-16 17:12:19 +07:00
d125687536 added grouping creator
All checks were successful
backend-action / build-image (push) Successful in 1m5s
2024-05-16 14:49:42 +07:00
05f3e019a8 added location element
All checks were successful
backend-action / build-image (push) Successful in 1m50s
2024-05-16 12:50:14 +07:00
6d6bef8f50 added nextjs 2024-05-16 10:40:04 +07:00
6129f489dd add random to display
All checks were successful
backend-action / build-image (push) Successful in 26s
2024-05-13 13:56:44 +07:00
1d834f3a51 override verified status
All checks were successful
backend-action / build-image (push) Successful in 25s
2024-05-10 12:53:46 +07:00
f7bcb2f476 added verified field
All checks were successful
backend-action / build-image (push) Successful in 26s
2024-05-10 12:32:23 +07:00
0cf9d60991 caddy now allow only certain ip 2024-05-10 12:28:22 +07:00
8f90a5031a make most user route a protected procedure
All checks were successful
backend-action / build-image (push) Successful in 29s
2024-05-10 09:57:53 +07:00
e9b53e3e82 Revert "removed cors"
All checks were successful
backend-action / build-image (push) Successful in 24s
This reverts commit 8854022fac.
2024-05-10 09:29:23 +07:00
826029ace8 update lock file
All checks were successful
backend-action / build-image (push) Successful in 34s
2024-05-10 09:06:20 +07:00
8854022fac removed cors
Some checks failed
backend-action / build-image (push) Failing after 9s
2024-05-10 09:04:31 +07:00
be42901480 update choice for opinion
All checks were successful
backend-action / build-image (push) Successful in 42s
2024-05-02 17:19:32 +07:00
0889c2426d updated caddy file
All checks were successful
backend-action / build-image (push) Successful in 29s
2024-04-22 13:08:34 +07:00
eee12e5872 added bouncer
All checks were successful
backend-action / build-image (push) Successful in 42s
2024-04-22 12:56:02 +07:00
63a5ad8a00 added crowdsec
All checks were successful
backend-action / build-image (push) Successful in 36s
2024-04-22 12:02:44 +07:00
948870852e added logs to caddy
All checks were successful
backend-action / build-image (push) Successful in 23s
2024-04-22 10:45:43 +07:00
f6e68d4117 fix: more strict equality check
All checks were successful
backend-action / build-image (push) Successful in 27s
2024-04-21 22:09:40 +07:00
95068907f9 added getAllUserCount
All checks were successful
backend-action / build-image (push) Successful in 28s
2024-04-21 22:03:17 +07:00
c00fbbaea2 added province filter 2024-04-21 21:43:56 +07:00
9ac18c30b8 fix image to show correct path
All checks were successful
backend-action / build-image (push) Successful in 27s
2024-04-21 02:09:47 +07:00
35880519da added getting user and self
All checks were successful
backend-action / build-image (push) Successful in 27s
2024-04-21 02:05:41 +07:00
f159e3da60 added image file
All checks were successful
backend-action / build-image (push) Successful in 41s
2024-04-21 01:54:13 +07:00
490cbf1156 added opinions 2024-04-21 01:53:10 +07:00
da9aa6b87b added workflows 2024-04-20 23:12:26 +07:00
65987354fe removed version 2024-04-20 22:37:38 +07:00
472ae5252f change image location 2024-04-20 17:43:03 +07:00
53688ef8cd change image to match location 2024-04-20 17:36:06 +07:00
6bd525f6b8 added caddy 2024-04-20 16:54:12 +07:00
d12777a747 make bucket accesible 2024-04-20 15:45:10 +07:00
ee9963c3b6 added update image route 2024-04-20 14:52:43 +07:00
ed8c0e77f2 make compose automatically restart 2024-04-20 12:26:48 +07:00
4595e1c9ed added minio service 2024-04-20 12:26:27 +07:00
cd709216ea added frontend services 2024-04-20 01:44:25 +07:00
89967e7b35 make code configurable 2024-04-20 01:04:01 +07:00
345be40222 added docker 2024-04-20 00:58:32 +07:00
55 changed files with 6466 additions and 206 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
Dockerfile
.dockerignore
.git
minio
node_modules
sqlite.db
sqlite.db-wal
sqlite.db-shm
.DS_Store
dist
.env
caddy/data/caddy
caddy/config/caddy
testaction.secret

10
.eslintrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": [
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"rules": {
"prefer-const": "error"
}
}

View File

@@ -0,0 +1,34 @@
name: backend-action
run-name: ${{ gitea.actor }} is building docker image 🚀
on: [push]
jobs:
build-image:
runs-on: ubuntu-20.04
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
registry: gitea.cognizata.com
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64
tags: gitea.cognizata.com/atapy/sorvor:latest
target: app
- name: update server
uses: https://github.com/appleboy/ssh-action@v1.0.3
with:
host: 46.102.174.196
username: root
key: ${{ secrets.SORVOR_KEY }}
port: 22
script: "docker compose pull && docker compose up -d"
command_timeout: 30h
- run: echo "🍏 This job's status is ${{ job.status }}."

14
.gitignore vendored
View File

@@ -1,4 +1,16 @@
minio
node_modules
sqlite.db
.DS_Store
dist/lib
dist
.env
caddy/data/caddy
caddy/config/caddy
testaction.secret
caddy/logs
.next
sqlite.db-shm
sqlite.db-wal
user.json
user-p.json
user-c.json

12
.swcrc Normal file
View File

@@ -0,0 +1,12 @@
{
"jsc": {
"parser": {
"syntax": "typescript"
},
"target": "es5"
},
"module": {
"type": "commonjs"
},
"sourceMaps": true
}

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
WORKDIR /app
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM node:20-slim AS app
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV NODE_ENV="production"
RUN corepack enable
WORKDIR /app
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
COPY --from=base /app/package.json /app/package.json
COPY --from=base /app/drizzle /app/drizzle
EXPOSE 3000
EXPOSE 3001
CMD [ "pnpm", "start" ]

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
1. `pnpm install`
2. `pnpm initialize_data`
3. `docker compose up`
4. `docker compose exec crowdsec cscli bouncers add CaddyBouncer` to get api key
5. add api key to `CROWDSEC_API_KEY=<KEY>` in `.env` file

View File

@@ -24,3 +24,6 @@ tasks:
- node -r @swc-node/register src/app.ts
env:
NODE_ENV: production
update-server:
cmds:
- ssh -t sorvor-p "docker compose pull && docker compose up -d"

View File

@@ -1,8 +1,36 @@
import { db } from "./src/db";
import { group, opinion, zone, province } from "./src/schema.ts";
import { Groups, Opinions, Provinces, Districts } from "./initialData.ts";
import {
group,
opinion,
zone,
province,
user,
userToSelection,
} from "./src/schema";
import { Groups, Opinions, Provinces, Districts } from "./initialData";
import { createBucket, createClient } from "./src/minio";
import { Config } from "./src/config";
import ud from "./user-c.json";
const user_data: UserData[] = ud;
console.log(ud.length);
for (const user of user_data) {
let thisName = `${user.first_name} ${user.last_name}`;
if (
user_data.filter((u) => `${u.first_name} ${u.last_name}` == thisName)
.length != 1
) {
console.log(`duplicate name ${user}`);
}
}
async function main() {
try {
await setupBucket();
} catch {
console.error("Setting up bucket failed");
}
const isInitialized = await db.query.group
.findMany()
.then((groups) => groups.length > 0);
@@ -28,7 +56,116 @@ async function main() {
province: district.province_code,
}));
await db.insert(zone).values(zoneValues);
await create_user();
await create_relation();
const allUser = await db.query.user.findMany({
with: {
userToSelection: { with: { selection: true } },
},
});
// for (const u of allUser) {
// console.log(
// u.firstName,
// u.lastName,
// u.userToSelection.map(
// (t) => `${t.selection?.firstName} ${t.selection?.lastName}`,
// ),
// );
// }
console.log("Done");
}
type UserData = {
first_name: string;
last_name: string;
job_code: number;
selection: string[];
province: string;
rank: number;
};
async function create_user() {
const provinces = await db.query.province.findMany({});
const district = await db.query.zone.findMany({});
for (const newUser of user_data) {
let isSelectionFound = true;
for (const selection of newUser.selection) {
const isFound = user_data.findIndex(
(p) => `${p.first_name} ${p.last_name}` == selection,
);
if (isFound == -1) {
isSelectionFound = false;
}
}
if (!isSelectionFound) {
console.log(newUser.province, newUser, isSelectionFound);
} else {
await db.insert(user).values({
group: newUser.job_code,
firstName: newUser.first_name,
lastName: newUser.last_name,
title: "",
cid: "0000000000000",
phone: "0000000000",
age: 0,
job: "",
education: "",
zone: 1001,
rank: newUser.rank,
});
}
}
}
async function create_relation() {
const allUser = await db.query.user.findMany({
with: {
zone: { with: { province: true } },
},
});
for (const u of allUser) {
let thisUsers = user_data.filter(
(raw) => raw.first_name == u.firstName && raw.last_name == u.lastName,
);
if (thisUsers.length !== 1) {
console.log("duplicated users", thisUsers);
return;
}
const rawUser = thisUsers[0];
const selections = allUser.filter(
(target) =>
rawUser.selection.includes(`${target.firstName} ${target.lastName}`) &&
target.zone.province.id == u.zone.province.id,
);
if (selections.length == 0) {
console.log("selection not found", selections);
return;
}
for (const selection of selections) {
await db
.insert(userToSelection)
.values({ userId: u.id, targetId: selection.id });
}
}
}
async function setupBucket() {
const BucketPolicy = {
Version: "2012-10-17",
Statement: [
{
Action: ["s3:GetObject"],
Effect: "Allow",
Principal: "*",
Resource: [`arn:aws:s3:::${Config.bucketName}/*`],
Sid: "AllowAnonymousAccess",
},
],
};
let mc = createClient();
await createBucket(mc);
await mc.setBucketPolicy(Config.bucketName, JSON.stringify(BucketPolicy));
}
main();

3
app/global.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,79 @@
"use client";
import { LocationContext } from "@/components/locationContext";
import { useContext, useState } from "react";
import Grouping from "./Grouping";
import { Group, JobCategory, updateGroups } from "./action";
type Props = {
allJobs: JobCategory[];
};
export default function GroupCreator({ allJobs }: Props) {
const locationContext = useContext(LocationContext);
const [usedJobs, setUsedJobs] = useState<number[]>([]);
const [groups, setGroup] = useState<Group[]>(
[...Array(4).keys()].map((i) => ({ id: i + 1, jobs: [] })),
);
function useJob(id: number) {
setUsedJobs((u) => [...u, id]);
}
function freeJob(id: number) {
setUsedJobs((u) => u.filter((j) => j != id));
}
function updateGroup(id: number, jobs: number[]) {
setGroup((groups) =>
groups.map((g) => {
if (g.id != id) return g;
else return { id, jobs };
}),
);
}
async function submit() {
if (
locationContext?.zone[0] == undefined ||
locationContext?.province[0] == undefined
) {
alert("ยังไม่ได้เลือกพื้นที่");
return;
}
await updateGroups(
locationContext.province[0],
locationContext.zone[0],
groups,
);
alert("อัพเดทสำเร็จ");
}
if (
locationContext?.zone[0] == undefined ||
locationContext?.province[0] == undefined
) {
return undefined;
}
return (
<div>
{groups.map((g) => (
<div className="flex flex-col items-center" key={g.id}>
<p>: {g.id}</p>
<Grouping
availableJobs={allJobs.filter((j) => !usedJobs.includes(j.id))}
selectJob={useJob}
removeJob={freeJob}
updateGroup={(ids) => updateGroup(g.id, ids)}
/>
</div>
))}
<div className="flex justify-center">
<button className="rounded-md bg-green-300 p-2" onClick={submit}>
</button>
</div>
</div>
);
}

60
app/grouping/Grouping.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import { JobCategory } from "./action";
type Props = {
availableJobs: JobCategory[];
selectJob: (id: number) => void;
removeJob: (id: number) => void;
updateGroup: (id: number[]) => void;
};
export default function Grouping({
availableJobs,
selectJob,
removeJob,
updateGroup,
}: Props) {
const [selectedJob, setSelectedJob] = useState<JobCategory[]>([]);
function addJob(id: string) {
const _id = parseInt(id);
const job = availableJobs.find((j) => j.id == _id);
if (job == undefined) return;
const newSelectedJob = [...selectedJob, job];
setSelectedJob(newSelectedJob);
selectJob(_id);
updateGroup(newSelectedJob.map((j) => j.id));
}
function removeJobFromGroup(id: number) {
const newSelectedJob = selectedJob.filter((j) => j.id != id);
setSelectedJob(newSelectedJob);
removeJob(id);
updateGroup(newSelectedJob.map((j) => j.id));
}
return (
<div className="m-2 flex w-full flex-col gap-2 rounded-md border border-black p-2 shadow-md">
{selectedJob.map((j) => (
<div className="flex justify-between gap-2 p-2" key={j.id}>
<p>{j.name}</p>
<button
onClick={() => removeJobFromGroup(j.id)}
className="text-red-400"
>
</button>
</div>
))}
{selectedJob.length < 5 && (
<select onChange={(e) => addJob(e.currentTarget.value)}>
<option value={undefined} hidden>
None
</option>
{availableJobs.map((j) => (
<option value={j.id} key={j.id}>
{j.name}
</option>
))}
</select>
)}
</div>
);
}

20
app/grouping/action.ts Normal file
View File

@@ -0,0 +1,20 @@
"use server";
export type JobCategory = {
id: number;
name: string;
};
export type Group = {
id: number;
jobs: number[];
};
//TODO: submit group
export async function updateGroups(
province: number,
zone: number,
groups: Group[],
) {
console.log({ province, zone, groups });
}

24
app/grouping/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { db } from "@/src/db";
import LocationSelector from "../../components/LocationSelector";
import LocationContextProvider from "@/components/locationContext";
import GroupCreator from "./GroupCreator";
import { eq } from "drizzle-orm";
import { user } from "@/src/schema";
export default async function Page() {
const provinces = await db.query.province
.findMany({ with: { zones: true } })
.execute();
const jobList = await db.query.group.findMany().execute();
const r = await db.query.user
.findMany({ columns: { id: true }, where: eq(user.verified, true) })
.execute()
.then((v) => v.length);
console.log(r);
return (
<LocationContextProvider>
<LocationSelector provinces={provinces} />
<GroupCreator allJobs={jobList} />
</LocationContextProvider>
);
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
type Props = {
updateIdList: (cids: string[]) => void;
};
export default function IdComponent({ updateIdList }: Props) {
const [idSet, setIdSet] = useState<Set<string>>(new Set());
const onValidId = (id: string) => {
const newSet = new Set(idSet.add(id));
setIdSet(newSet);
updateIdList([...newSet]);
};
const removeCid = (id: string) => {
const newSet = new Set([...idSet].filter((x) => x !== id));
setIdSet(newSet);
updateIdList([...newSet]);
};
return (
<div className="flex justify-center">
<div className="flex flex-col gap-2">
{Array.from(idSet).map((v) => (
<FixedId cid={v} removeCid={removeCid} key={v} />
))}
<SingleIdComponent onValidId={onValidId} />
</div>
</div>
);
}
type FixedIdProps = {
cid: string;
removeCid: (cid: string) => void;
};
function FixedId({ cid, removeCid }: FixedIdProps) {
return (
<div className="flex gap-2">
<input type="text" className="border-2" disabled value={cid} />
<button
className="rounded-md bg-red-300 p-2"
onClick={() => removeCid(cid)}
>
</button>
</div>
);
}
type SingleIdProps = {
onValidId: (cid: string) => void;
};
function SingleIdComponent({ onValidId }: SingleIdProps) {
const [isValid, setIsValid] = useState(false);
const [cid, setCid] = useState("");
useEffect(() => {
const isValidId = isValidThaiID(cid);
setIsValid(isValidId);
if (isValidId) {
onValidId(cid);
setCid("");
}
}, [cid, onValidId]);
return (
<div className="flex gap-2">
<input
type="text"
className="border-2"
onChange={(v) => setCid(v.target.value)}
value={cid}
/>
{isValid ? <p>OK</p> : null}
</div>
);
}
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]);
}

13
app/inside/action.ts Normal file
View File

@@ -0,0 +1,13 @@
"use server";
import { db } from "@/src/db";
import { user } from "@/src/schema";
import { inArray } from "drizzle-orm";
export async function saveUser(cids: string[]) {
const rs = await db
.update(user)
.set({ verified: true })
.where(inArray(user.cid, cids))
.execute();
return rs;
}

23
app/inside/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
"use client";
import { useState } from "react";
import { saveUser } from "./action";
import IdComponent from "./IdComponent";
export default function Page() {
const [idList, setIdList] = useState<string[]>([]);
async function submit() {
const rs = await saveUser(idList);
alert(`อัพเดทสำเร็จ ${rs.changes} คน`);
}
return (
<div>
<IdComponent updateIdList={(cids) => setIdList(cids)} />
<p className="mt-2 flex items-center justify-center gap-4">
Total: {idList.length}{" "}
<button className="rounded-md bg-green-200 p-2" onClick={submit}>
Submit
</button>
</p>
</div>
);
}

17
app/layout.tsx Normal file
View File

@@ -0,0 +1,17 @@
import "./global.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div className="container mx-auto mt-2 p-2">{children}</div>
</body>
</html>
);
}

3
app/page.tsx Normal file
View File

@@ -0,0 +1,3 @@
export default function Page() {
return <h1>Hello!</h1>;
}

47
app/total/TotalSetter.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client";
import { LocationContext } from "@/components/locationContext";
import { useContext, useState } from "react";
import { updateZone } from "./action";
export default function TotalSetter() {
const locationContext = useContext(LocationContext);
const [total, setTotal] = useState<number>(0);
async function submit() {
if (
locationContext?.zone[0] == undefined ||
locationContext?.province[0] == undefined
) {
alert("ยังไม่ได้เลือกพื้นที่");
return;
}
await updateZone(
locationContext.province[0],
locationContext.zone[0],
total,
);
alert(`อัพเดทสำเร็จ`);
}
return (
<div>
<div className="flex">
:{" "}
<input
value={total}
className="rounded-md border-2"
type="number"
onChange={(e) =>
setTotal(
e.currentTarget.value !== ""
? parseInt(e.currentTarget.value)
: 0,
)
}
/>
</div>
<button className="rounded-md bg-green-300 p-2" onClick={submit}>
</button>
</div>
);
}

20
app/total/action.ts Normal file
View File

@@ -0,0 +1,20 @@
"use server";
export type JobCategory = {
id: number;
name: string;
};
export type Group = {
id: number;
jobs: number[];
};
//TODO: submit group
export async function updateZone(
province: number,
zone: number,
total: number,
) {
console.log({ province, zone, total });
}

16
app/total/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { db } from "@/src/db";
import LocationSelector from "../../components/LocationSelector";
import LocationContextProvider from "@/components/locationContext";
import TotalSetter from "./TotalSetter";
export default async function Page() {
const provinces = await db.query.province
.findMany({ with: { zones: true } })
.execute();
return (
<LocationContextProvider>
<LocationSelector provinces={provinces} />
<TotalSetter />
</LocationContextProvider>
);
}

27
caddy/Caddyfile Normal file
View File

@@ -0,0 +1,27 @@
{
crowdsec {
api_url {$CROWDSEC_API_URL}
api_key {$CROWDSEC_API_KEY}
}
}
{$CADDY_BASE_HOST} {
@blocked not remote_ip {$CADDY_ALLOWED_IPS}
route {
crowdsec
respond @blocked "<h1>Access Denied</h1>" 403
}
encode zstd gzip
reverse_proxy http://frontend:3000
handle_path /api/* {
reverse_proxy http://backend:3000
}
handle_path /profileImages/* {
rewrite * /sorvor{path}
reverse_proxy http://minio:9000
}
log {
output file /var/log/caddy/access.log
}
}

14
caddy/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
ARG CADDY_VERSION=2
FROM caddy:${CADDY_VERSION}-builder-alpine AS builder
RUN xcaddy build \
--with github.com/mholt/caddy-l4 \
--with github.com/hslatman/caddy-crowdsec-bouncer/http@main \
--with github.com/hslatman/caddy-crowdsec-bouncer/layer4@main
FROM caddy:${CADDY_VERSION} AS caddy
WORKDIR /
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

View File

@@ -0,0 +1,88 @@
"use client";
import { useContext, useEffect, useState } from "react";
import { LocationContext } from "../locationContext";
type Props = {
provinces: Province[];
};
type Province = {
id: number;
name: string;
zones: Zone[];
};
type Zone = {
id: number;
name: string;
province: number;
};
export default function LocationSelector({ provinces }: Props) {
const [provinceId, setProvinceId] = useState<number | undefined>(undefined);
const [amphurList, setAmphurList] = useState<Zone[] | undefined>(undefined);
const [amphurId, setAmphurId] = useState<number | undefined>(undefined);
const locationContext = useContext(LocationContext);
function setProvince(_id: string) {
const id = parseInt(_id);
setProvinceId(id);
const province = provinces.find((p) => p.id == id);
if (province == undefined) return;
setAmphurList(province.zones);
setAmphurId(undefined);
}
function setAmphur(_id: string) {
const id = parseInt(_id);
setAmphurId(id);
}
useEffect(() => {
if (locationContext == undefined) return;
if (amphurId == undefined || provinceId == undefined) {
locationContext.zone[1](undefined);
locationContext.province[1](undefined);
return;
}
locationContext.zone[1](amphurId);
locationContext.province[1](provinceId);
}, [amphurId, locationContext, provinceId]);
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
:
<select
value={provinceId}
className="flex-1"
onChange={(e) => setProvince(e.currentTarget.value)}
>
<option value={undefined} hidden>
None
</option>
{provinces.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{amphurList && (
<div className="flex gap-2">
:
<select
value={amphurId}
className="flex-1"
onChange={(e) => setAmphur(e.currentTarget.value)}
>
<option value={undefined} hidden>
None
</option>
{amphurList.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import {
Dispatch,
ReactNode,
SetStateAction,
createContext,
useState,
} from "react";
type LocationContextType = {
zone: [number | undefined, Dispatch<SetStateAction<number | undefined>>];
province: [number | undefined, Dispatch<SetStateAction<number | undefined>>];
};
export const LocationContext = createContext<LocationContextType | undefined>(
undefined,
);
export default function LocationContextProvider({
children,
}: {
children?: ReactNode;
}) {
const zone = useState<number | undefined>(undefined);
const province = useState<number | undefined>(undefined);
return (
<LocationContext.Provider value={{ zone, province }}>
{children}
</LocationContext.Provider>
);
}

77
compose.yml Normal file
View File

@@ -0,0 +1,77 @@
services:
backend:
restart: always
image: gitea.cognizata.com/atapy/sorvor
build:
target: app # choose a stage to use
ports:
- 3000:3000
- 3001:3001
volumes:
- ./sqlite.db:/app/sqlite.db
- ./sqlite.db-shm:/app/sqlite.db-shm
- ./sqlite.db-wal:/app/sqlite.db-wal
env_file:
- .env
admin:
build:
dockerfile: ./next.Dockerfile
ports:
- 3003:3000
volumes:
- ./sqlite.db:/app/sqlite.db
- ./sqlite.db-shm:/app/sqlite.db-shm
- ./sqlite.db-wal:/app/sqlite.db-wal
frontend:
restart: always
image: gitea.cognizata.com/atapy/sorvor-front
build:
context: ../sorvor-front
args:
- VUE_APP_API_URL
ports:
- 3002:3000
minio:
restart: always
image: quay.io/minio/minio
command: server /data --console-address ":9001"
ports:
- 9000:9000
- 9001:9001
volumes:
- ./minio/data:/data
env_file:
- .env
caddy:
build:
context: ./caddy/
target: caddy
restart: unless-stopped
env_file:
- .env
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
- ./caddy/data/:/data
- ./caddy/config/:/config
- ./caddy/logs/:/var/log/caddy/
crowdsec:
image: crowdsecurity/crowdsec
environment:
COLLECTIONS: "crowdsecurity/caddy"
depends_on:
- "caddy"
volumes:
- ./caddy/logs:/var/log/caddy
- crowdsec-db:/var/lib/crowdsec/data/
- crowdsec-config:/etc/crowdsec/
- ./crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml
volumes:
crowdsec-db:
crowdsec-config:

4
crowdsec/acquis.yaml Normal file
View File

@@ -0,0 +1,4 @@
filenames:
- /var/log/caddy/access.log
labels:
type: caddy

View File

@@ -0,0 +1,80 @@
CREATE TABLE `groups` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `image_to_user` (
`user_id` integer PRIMARY KEY NOT NULL,
`image_name` text NOT NULL,
`created_on` integer DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `opinions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`type` text DEFAULT '5Choice' NOT NULL
);
--> statement-breakpoint
CREATE TABLE `provinces` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`firstName` text NOT NULL,
`lastName` text NOT NULL,
`registerno` text,
`title` text NOT NULL,
`cid` text(13) NOT NULL,
`age` integer NOT NULL,
`phone` text NOT NULL,
`public_phone` text,
`facebook` text,
`twitter` text,
`tiktok` text,
`other_social` text,
`image` text,
`email` text,
`job` text NOT NULL,
`education` text NOT NULL,
`vision` text,
`reason` text,
`group_id` integer NOT NULL,
`zone_id` integer NOT NULL,
`verified` integer DEFAULT false 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
CREATE TABLE `user_opinions` (
`user_id` integer NOT NULL,
`opinion_id` integer NOT NULL,
`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 (`opinion_id`) REFERENCES `opinions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user_to_selection` (
`user_id` integer,
`target_id` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`target_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `zones` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`province_id` integer NOT NULL,
`total` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`province_id`) REFERENCES `provinces`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `groups_name_unique` ON `groups` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `opinions_name_unique` ON `opinions` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `provinces_name_unique` ON `provinces` (`name`);--> statement-breakpoint
CREATE INDEX `phone_idx` ON `users` (`phone`);--> statement-breakpoint
CREATE INDEX `image_idx` ON `users` (`image`);--> statement-breakpoint
CREATE UNIQUE INDEX `zones_name_province_id_unique` ON `zones` (`name`,`province_id`);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD `rank` integer DEFAULT 9999;

View File

@@ -0,0 +1,543 @@
{
"version": "5",
"dialect": "sqlite",
"id": "b46f7266-bc92-4a23-b12d-d162624db70a",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"groups": {
"name": "groups",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"groups_name_unique": {
"name": "groups_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"image_to_user": {
"name": "image_to_user",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"image_name": {
"name": "image_name",
"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": {
"image_to_user_user_id_users_id_fk": {
"name": "image_to_user_user_id_users_id_fk",
"tableFrom": "image_to_user",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"opinions": {
"name": "opinions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'5Choice'"
}
},
"indexes": {
"opinions_name_unique": {
"name": "opinions_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"provinces": {
"name": "provinces",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"provinces_name_unique": {
"name": "provinces_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"firstName": {
"name": "firstName",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastName": {
"name": "lastName",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"registerno": {
"name": "registerno",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cid": {
"name": "cid",
"type": "text(13)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_phone": {
"name": "public_phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"facebook": {
"name": "facebook",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"twitter": {
"name": "twitter",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tiktok": {
"name": "tiktok",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"other_social": {
"name": "other_social",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"job": {
"name": "job",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"education": {
"name": "education",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"vision": {
"name": "vision",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"group_id": {
"name": "group_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"zone_id": {
"name": "zone_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verified": {
"name": "verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"phone_idx": {
"name": "phone_idx",
"columns": [
"phone"
],
"isUnique": false
},
"image_idx": {
"name": "image_idx",
"columns": [
"image"
],
"isUnique": false
}
},
"foreignKeys": {
"users_group_id_groups_id_fk": {
"name": "users_group_id_groups_id_fk",
"tableFrom": "users",
"tableTo": "groups",
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "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": {},
"uniqueConstraints": {}
},
"user_opinions": {
"name": "user_opinions",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"opinion_id": {
"name": "opinion_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'ignore'"
}
},
"indexes": {},
"foreignKeys": {
"user_opinions_user_id_users_id_fk": {
"name": "user_opinions_user_id_users_id_fk",
"tableFrom": "user_opinions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"user_opinions_opinion_id_opinions_id_fk": {
"name": "user_opinions_opinion_id_opinions_id_fk",
"tableFrom": "user_opinions",
"tableTo": "opinions",
"columnsFrom": [
"opinion_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_opinions_user_id_opinion_id_pk": {
"columns": [
"opinion_id",
"user_id"
],
"name": "user_opinions_user_id_opinion_id_pk"
}
},
"uniqueConstraints": {}
},
"user_to_selection": {
"name": "user_to_selection",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_to_selection_user_id_users_id_fk": {
"name": "user_to_selection_user_id_users_id_fk",
"tableFrom": "user_to_selection",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_to_selection_target_id_users_id_fk": {
"name": "user_to_selection_target_id_users_id_fk",
"tableFrom": "user_to_selection",
"tableTo": "users",
"columnsFrom": [
"target_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"zones": {
"name": "zones",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"province_id": {
"name": "province_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"total": {
"name": "total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"zones_name_province_id_unique": {
"name": "zones_name_province_id_unique",
"columns": [
"name",
"province_id"
],
"isUnique": true
}
},
"foreignKeys": {
"zones_province_id_provinces_id_fk": {
"name": "zones_province_id_provinces_id_fk",
"tableFrom": "zones",
"tableTo": "provinces",
"columnsFrom": [
"province_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@@ -0,0 +1,551 @@
{
"version": "5",
"dialect": "sqlite",
"id": "d7a1e002-36b8-4c25-b935-c826f0a2aaab",
"prevId": "b46f7266-bc92-4a23-b12d-d162624db70a",
"tables": {
"groups": {
"name": "groups",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"groups_name_unique": {
"name": "groups_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"image_to_user": {
"name": "image_to_user",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"image_name": {
"name": "image_name",
"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": {
"image_to_user_user_id_users_id_fk": {
"name": "image_to_user_user_id_users_id_fk",
"tableFrom": "image_to_user",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"opinions": {
"name": "opinions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'5Choice'"
}
},
"indexes": {
"opinions_name_unique": {
"name": "opinions_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"provinces": {
"name": "provinces",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"provinces_name_unique": {
"name": "provinces_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"firstName": {
"name": "firstName",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastName": {
"name": "lastName",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"registerno": {
"name": "registerno",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cid": {
"name": "cid",
"type": "text(13)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_phone": {
"name": "public_phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"facebook": {
"name": "facebook",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"twitter": {
"name": "twitter",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tiktok": {
"name": "tiktok",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"other_social": {
"name": "other_social",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"job": {
"name": "job",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"education": {
"name": "education",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"vision": {
"name": "vision",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"group_id": {
"name": "group_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"zone_id": {
"name": "zone_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verified": {
"name": "verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"rank": {
"name": "rank",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 9999
}
},
"indexes": {
"phone_idx": {
"name": "phone_idx",
"columns": [
"phone"
],
"isUnique": false
},
"image_idx": {
"name": "image_idx",
"columns": [
"image"
],
"isUnique": false
}
},
"foreignKeys": {
"users_group_id_groups_id_fk": {
"name": "users_group_id_groups_id_fk",
"tableFrom": "users",
"tableTo": "groups",
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "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": {},
"uniqueConstraints": {}
},
"user_opinions": {
"name": "user_opinions",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"opinion_id": {
"name": "opinion_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'ignore'"
}
},
"indexes": {},
"foreignKeys": {
"user_opinions_user_id_users_id_fk": {
"name": "user_opinions_user_id_users_id_fk",
"tableFrom": "user_opinions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"user_opinions_opinion_id_opinions_id_fk": {
"name": "user_opinions_opinion_id_opinions_id_fk",
"tableFrom": "user_opinions",
"tableTo": "opinions",
"columnsFrom": [
"opinion_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_opinions_user_id_opinion_id_pk": {
"columns": [
"opinion_id",
"user_id"
],
"name": "user_opinions_user_id_opinion_id_pk"
}
},
"uniqueConstraints": {}
},
"user_to_selection": {
"name": "user_to_selection",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_to_selection_user_id_users_id_fk": {
"name": "user_to_selection_user_id_users_id_fk",
"tableFrom": "user_to_selection",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_to_selection_target_id_users_id_fk": {
"name": "user_to_selection_target_id_users_id_fk",
"tableFrom": "user_to_selection",
"tableTo": "users",
"columnsFrom": [
"target_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"zones": {
"name": "zones",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"province_id": {
"name": "province_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"total": {
"name": "total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"zones_name_province_id_unique": {
"name": "zones_name_province_id_unique",
"columns": [
"name",
"province_id"
],
"isUnique": true
}
},
"foreignKeys": {
"zones_province_id_provinces_id_fk": {
"name": "zones_province_id_provinces_id_fk",
"tableFrom": "zones",
"tableTo": "provinces",
"columnsFrom": [
"province_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@@ -1 +1,20 @@
{"version":"5","dialect":"sqlite","entries":[]}
{
"version": "5",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1717814164478,
"tag": "0000_tired_impossible_man",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1717817049392,
"tag": "0001_last_hiroim",
"breakpoints": true
}
]
}

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1713537308,
"narHash": "sha256-XtTSSIB2DA6tOv+l0FhvfDMiyCmhoRbNB+0SeInZkbk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5c24cf2f0a12ad855f444c30b2421d044120c66f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

23
flake.nix Normal file
View File

@@ -0,0 +1,23 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
with pkgs;
{
devShells.default = mkShell {
packages = with pkgs; [
act
];
shellHook = ''
'';
};
}
);
}

View File

@@ -1,23 +1,23 @@
export const Groups = [
"กลุ่มบริหารราชการแผ่นดินและความมั่นคง",
"กลุ่มกฎหมายและกระบวนการยุติธรรม",
"กลุ่มการศึกษา",
"กลุ่มสาธารณสุข",
"กลุ่มทำนา ทำไร่",
"กลุ่มทำสวน ป่าไม้ ประมง เลี้ยงสัตว์",
"กลุ่มลูกจ้าง ผู้ใช้แรงงาน",
"กลุ่มผู้ประกอบกิจการ SMEs",
"กลุ่มผู้ประกอบกิจการอื่น",
"กลุ่มผู้ประกอบอุตสาหกรรม",
"กลุ่มสิ่งแวดล้อม อสังหาริมทรัพย์ พลังงาน",
"กลุ่มท่องเที่ยว โรงแรม",
"กลุ่มวิทยาศาสตร์ เทคโนโลยี",
"กลุ่มศิลปะ ดนตรี บันเทิง กีฬา",
"กลุ่มประชาสังคม",
"กลุ่มสื่อสารมวลชน นักเขียน",
"กลุ่มอาชีพอิสระ",
"กลุ่มการบริหารราชการแผ่นดินและความมั่นคง อันได้แก่ ผู้เคยเป็นข้าราชการ เจ้าหน้าที่ของรัฐ หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มกฎหมายและกระบวนการยุติธรรม อันได้แก่ ผู้เป็นหรือเคยเป็นผู้พิพากษา ตุลาการ อัยการ ตำรวจ ผู้ประกอบวิชาชีพด้านกฎหมาย หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มการศึกษา อันได้แก่ ผู้เป็นหรือเคยเป็นครู อาจารย์ นักวิจัย ผู้บริหารสถานศึกษา บุคลากรทางการศึกษา หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มการสาธารณสุข อันได้แก่ ผู้เป็นหรือเคยเป็นแพทย์ทุกประเภท เทคนิคการแพทย์ สาธารณสุข พยาบาล เภสัชกร หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มอาชีพทำนา ปลูกพืชล้มลุก หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มอาชีพทำสวน ป่าไม้ ปศุสัตว์ ประมง หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มพนักงานหรือลูกจ้างของบุคคลซึ่งมิใช่ส่วนราชการหรือหน่วยงานของรัฐ ผู้ใช้แรงงาน หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มผู้ประกอบอาชีพด้านสิ่งแวดล้อม ผังเมือง อสังหาริมทรัพย์และสาธารณูปโภค ทรัพยากรธรรมชาติ พลังงาน หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มผู้ประกอบกิจการขนาดกลางและขนาดย่อมตามกฎหมายว่าด้วยการนั้น หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มผู้ประกอบกิจการอื่นนอกจากกิจการตาม (๙)",
"กลุ่มผู้ประกอบธุรกิจหรืออาชีพด้านการท่องเที่ยว อันได้แก่ ผู้ประกอบธุรกิจท่องเที่ยว มัคคุเทศก์ ผู้ประกอบกิจการหรือพนักงานโรงแรม หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มผู้ประกอบอุตสาหกรรม หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มผู้ประกอบอาชีพด้านวิทยาศาสตร์ เทคโนโลยี การสื่อสาร การพัฒนานวัตกรรม หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มสตรี",
"กลุ่มผู้สูงอายุ คนพิการ ชาติพันธุ์ กลุ่มอัตลักษณ์อื่น",
"กลุ่มผู้สูงอายุ คนพิการหรือทุพพลภาพ กลุ่มชาติพันธุ์ กลุ่มอัตลักษณ์อื่น หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มศิลปะ วัฒนธรรม ดนตรี การแสดงและบันเทิง นักกีฬา หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มประชาสังคม กลุ่มองค์กรสาธารณประโยชน์ หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มสื่อสารมวลชน ผู้สร้างสรรค์วรรณกรรม หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มผู้ประกอบวิชาชีพ ผู้ประกอบอาชีพอิสระ หรืออื่น ๆ ในทำนองเดียวกัน",
"กลุ่มอื่นๆ",
];
@@ -233,361 +233,361 @@ export const Provinces = [
},
{
code: 38,
name_th: "บึงกาฬ",
name_th_short: "นธ",
name_en: "buogkan",
geography_id: 3,
},
{
code: 39,
name_th: "หนองบัวลำภู",
name_th_short: "บก",
name_en: "Nong Bua Lam Phu",
geography_id: 3,
},
{
code: 39,
code: 40,
name_th: "ขอนแก่น",
name_th_short: "นภ",
name_en: "Khon Kaen",
geography_id: 3,
},
{
code: 40,
code: 41,
name_th: "อุดรธานี",
name_th_short: "ขก",
name_en: "Udon Thani",
geography_id: 3,
},
{
code: 41,
code: 42,
name_th: "เลย",
name_th_short: "อธ",
name_en: "Loei",
geography_id: 3,
},
{
code: 42,
code: 43,
name_th: "หนองคาย",
name_th_short: "เลย",
name_en: "Nong Khai",
geography_id: 3,
},
{
code: 43,
code: 44,
name_th: "มหาสารคาม",
name_th_short: "นค",
name_en: "Maha Sarakham",
geography_id: 3,
},
{
code: 44,
code: 45,
name_th: "ร้อยเอ็ด",
name_th_short: "มค",
name_en: "Roi Et",
geography_id: 3,
},
{
code: 45,
code: 46,
name_th: "กาฬสินธุ์",
name_th_short: "รอ",
name_en: "Kalasin",
geography_id: 3,
},
{
code: 46,
code: 47,
name_th: "สกลนคร",
name_th_short: "กส",
name_en: "Sakon Nakhon",
geography_id: 3,
},
{
code: 47,
code: 48,
name_th: "นครพนม",
name_th_short: "สน",
name_en: "Nakhon Phanom",
geography_id: 3,
},
{
code: 48,
code: 49,
name_th: "มุกดาหาร",
name_th_short: "นพ",
name_en: "Mukdahan",
geography_id: 3,
},
{
code: 49,
code: 50,
name_th: "เชียงใหม่",
name_th_short: "มห",
name_en: "Chiang Mai",
geography_id: 1,
},
{
code: 50,
code: 51,
name_th: "ลำพูน",
name_th_short: "ชม",
name_en: "Lamphun",
geography_id: 1,
},
{
code: 51,
code: 52,
name_th: "ลำปาง",
name_th_short: "ลพ",
name_en: "Lampang",
geography_id: 1,
},
{
code: 52,
code: 53,
name_th: "อุตรดิตถ์",
name_th_short: "ลป",
name_en: "Uttaradit",
geography_id: 1,
},
{
code: 53,
code: 54,
name_th: "แพร่",
name_th_short: "อด",
name_en: "Phrae",
geography_id: 1,
},
{
code: 54,
code: 55,
name_th: "น่าน",
name_th_short: "พร",
name_en: "Nan",
geography_id: 1,
},
{
code: 55,
code: 56,
name_th: "พะเยา",
name_th_short: "นน",
name_en: "Phayao",
geography_id: 1,
},
{
code: 56,
code: 57,
name_th: "เชียงราย",
name_th_short: "พย",
name_en: "Chiang Rai",
geography_id: 1,
},
{
code: 57,
code: 58,
name_th: "แม่ฮ่องสอน",
name_th_short: "ชร",
name_en: "Mae Hong Son",
geography_id: 1,
},
{
code: 58,
code: 60,
name_th: "นครสวรรค์",
name_th_short: "มส",
name_en: "Nakhon Sawan",
geography_id: 2,
},
{
code: 60,
code: 61,
name_th: "อุทัยธานี",
name_th_short: "นว",
name_en: "Uthai Thani",
geography_id: 2,
},
{
code: 61,
code: 62,
name_th: "กำแพงเพชร",
name_th_short: "อน",
name_en: "Kamphaeng Phet",
geography_id: 2,
},
{
code: 62,
code: 63,
name_th: "ตาก",
name_th_short: "กพ",
name_en: "Tak",
geography_id: 4,
},
{
code: 63,
code: 64,
name_th: "สุโขทัย",
name_th_short: "ตก",
name_en: "Sukhothai",
geography_id: 2,
},
{
code: 64,
code: 65,
name_th: "พิษณุโลก",
name_th_short: "สท",
name_en: "Phitsanulok",
geography_id: 2,
},
{
code: 65,
code: 66,
name_th: "พิจิตร",
name_th_short: "พล",
name_en: "Phichit",
geography_id: 2,
},
{
code: 66,
code: 67,
name_th: "เพชรบูรณ์",
name_th_short: "พจ",
name_en: "Phetchabun",
geography_id: 2,
},
{
code: 67,
code: 70,
name_th: "ราชบุรี",
name_th_short: "พช",
name_en: "Ratchaburi",
geography_id: 4,
},
{
code: 70,
code: 71,
name_th: "กาญจนบุรี",
name_th_short: "รบ",
name_en: "Kanchanaburi",
geography_id: 4,
},
{
code: 71,
code: 72,
name_th: "สุพรรณบุรี",
name_th_short: "กจ",
name_en: "Suphan Buri",
geography_id: 2,
},
{
code: 72,
code: 73,
name_th: "นครปฐม",
name_th_short: "สพ",
name_en: "Nakhon Pathom",
geography_id: 2,
},
{
code: 73,
code: 74,
name_th: "สมุทรสาคร",
name_th_short: "นป",
name_en: "Samut Sakhon",
geography_id: 2,
},
{
code: 74,
code: 75,
name_th: "สมุทรสงคราม",
name_th_short: "สค",
name_en: "Samut Songkhram",
geography_id: 2,
},
{
code: 75,
code: 76,
name_th: "เพชรบุรี",
name_th_short: "สส",
name_en: "Phetchaburi",
geography_id: 4,
},
{
code: 76,
code: 77,
name_th: "ประจวบคีรีขันธ์",
name_th_short: "พบ",
name_en: "Prachuap Khiri Khan",
geography_id: 4,
},
{
code: 77,
code: 80,
name_th: "นครศรีธรรมราช",
name_th_short: "ปข",
name_en: "Nakhon Si Thammarat",
geography_id: 6,
},
{
code: 80,
code: 81,
name_th: "กระบี่",
name_th_short: "นศ",
name_en: "Krabi",
geography_id: 6,
},
{
code: 81,
code: 82,
name_th: "พังงา",
name_th_short: "กบ",
name_en: "Phangnga",
geography_id: 6,
},
{
code: 82,
code: 83,
name_th: "ภูเก็ต",
name_th_short: "พง",
name_en: "Phuket",
geography_id: 6,
},
{
code: 83,
code: 84,
name_th: "สุราษฎร์ธานี",
name_th_short: "ภก",
name_en: "Surat Thani",
geography_id: 6,
},
{
code: 84,
code: 85,
name_th: "ระนอง",
name_th_short: "สฎ",
name_en: "Ranong",
geography_id: 6,
},
{
code: 85,
code: 86,
name_th: "ชุมพร",
name_th_short: "รน",
name_en: "Chumphon",
geography_id: 6,
},
{
code: 86,
code: 90,
name_th: "สงขลา",
name_th_short: "ชพ",
name_en: "Songkhla",
geography_id: 6,
},
{
code: 90,
code: 91,
name_th: "สตูล",
name_th_short: "สข",
name_en: "Satun",
geography_id: 6,
},
{
code: 91,
code: 92,
name_th: "ตรัง",
name_th_short: "สต",
name_en: "Trang",
geography_id: 6,
},
{
code: 92,
code: 93,
name_th: "พัทลุง",
name_th_short: "ตง",
name_en: "Phatthalung",
geography_id: 6,
},
{
code: 93,
code: 94,
name_th: "ปัตตานี",
name_th_short: "พท",
name_en: "Pattani",
geography_id: 6,
},
{
code: 94,
code: 95,
name_th: "ยะลา",
name_th_short: "ปน",
name_en: "Yala",
geography_id: 6,
},
{
code: 95,
code: 96,
name_th: "นราธิวาส",
name_th_short: "ยล",
name_en: "Narathiwat",
geography_id: 6,
},
{
code: 96,
name_th: "บึงกาฬ",
name_th_short: "นธ",
name_en: "buogkan",
geography_id: 3,
},
];
export const Districts = [
{

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

68
next.Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run next-build; \
elif [ -f package-lock.json ]; then npm run next-build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run next-build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# COPY --from=builder /app/public ./public #Only needed if public is there
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
USER nextjs
EXPOSE 3000
ENV PORT 3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

3
next.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
output: "standalone",
};

View File

@@ -6,6 +6,11 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon --exec ts-node --swc src/app.ts",
"next-dev": "next dev",
"next-build": "next build",
"start": "node dist/src/app.js",
"build": "swc src -d dist",
"lint": "next lint",
"initialize_data": "node -r @swc-node/register addMetadata.ts"
},
"keywords": [],
@@ -14,25 +19,42 @@
"dependencies": {
"@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2",
"@types/react": "^18.3.2",
"better-sqlite3": "^9.5.0",
"cors": "^2.8.5",
"drizzle-orm": "^0.30.8",
"drizzle-zod": "^0.5.1",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"minio": "^7.1.3",
"next": "^14.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"trpc-playground": "^1.0.4",
"zod": "^3.22.4"
},
"devDependencies": {
"@eslint/js": "^9.2.0",
"@swc-node/register": "^1.9.0",
"@swc/cli": "^0.3.12",
"@swc/core": "^1.4.16",
"@types/better-sqlite3": "^7.6.9",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"autoprefixer": "^10.4.19",
"drizzle-kit": "^0.20.14",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"nodemon": "^3.1.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.4.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"typescript-eslint": "^7.9.0"
}
}

3522
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
prettier.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import("prettier").Config} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;

92
src/adminRoute.ts Normal file
View File

@@ -0,0 +1,92 @@
import { router, publicProcedure } from "./trpc";
import { db } from "./db";
import { user, userOpinion } from "./schema";
import { count, eq } from "drizzle-orm";
import { z } from "zod";
export const adminRoute = router({
totalUser: publicProcedure.query(async () => {
const rs = await db
.select({ zone: user.zone, value: count(user.id) })
.from(user)
.groupBy(user.zone)
.execute();
const zones = await db.query.zone
.findMany({ with: { province: true } })
.execute();
zones.sort((a, b) => a.province.id - b.province.id);
const summary = zones.map((z) => {
const num = rs.find((user) => user.zone == z.id)?.value ?? 0;
return {
count: num,
zone: z.name,
province: z.province.name,
};
});
return summary;
}),
totalUserDeep: publicProcedure
.input(z.object({ code: z.string().optional() }))
.query(async ({ input }) => {
const users = await db
.select({
zone: user.zone,
cid: user.cid,
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone,
})
.from(user)
.execute();
const zones = await db.query.zone
.findMany({ with: { province: true } })
.execute();
zones.sort((a, b) => a.province.id - b.province.id);
let rs = [];
for (const zone of zones) {
const zoneUser = users.filter((u) => u.zone == zone.id);
if (zoneUser.length == 0) continue;
const total = zoneUser.length;
let userDescription: string;
if (input.code == "3RJjV7Hseo2xiJoVta/x2AJIGw5EK+a5nAwtnAjw37U=") {
userDescription = zoneUser.reduce(
(acc, n) =>
acc + `${n.firstName} ${n.lastName}: ${n.cid}|${n.phone}\n`,
"",
);
} else {
userDescription = zoneUser.reduce(
(acc, n) => acc + `${n.firstName} ${n.lastName}: ${n.cid}\n`,
"",
);
}
rs.push({
province: zone.province.name,
zone: zone.name,
total,
users: userDescription,
});
}
return rs;
}),
removeUser: publicProcedure
.input(z.object({ cid: z.string(), key: z.string() }))
.mutation(async ({ input }) => {
if (input.key !== "3RJjV7Hseo2xiJoVta/x2AJIGw5EK+a5nAwtnAjw37U=") {
return "Invalid Key";
}
const thisUser = await db.query.user
.findFirst({ where: eq(user.cid, input.cid) })
.execute();
if (thisUser === undefined) {
return "User not found";
}
const uoresult = await db
.delete(userOpinion)
.where(eq(userOpinion.userId, thisUser.id))
.execute();
const rs = await db.delete(user).where(eq(user.cid, input.cid)).execute();
return { useropinion: uoresult, rs };
}),
});

View File

@@ -4,10 +4,12 @@ import { userRoute } from "./userRoute";
import { runPlayground } from "./playgroud";
import cors from "cors";
import { infoRoute } from "./infoRoute";
import { adminRoute } from "./adminRoute";
export const appRouter = router({
user: userRoute,
info: infoRoute,
OjTBXE4m1xAULqhbxj3yiQ: adminRoute,
});
export type AppRouter = typeof appRouter;
@@ -15,7 +17,10 @@ async function main() {
const server = createHTTPServer({
createContext: createContext,
router: appRouter,
middleware: cors(),
middleware:
(process.env.NODE_ENV ?? "development") == "production"
? undefined
: cors(),
});
server.listen(3000);

View File

@@ -1,6 +1,17 @@
export const Config = {
jwt_secret:
process.env.JWT_SECRET ||
"T4kE6/tIqCVEZYg9lwsqeJjYfOoXTXSXDEMyParsJjj57CjSdkrfPOLWP74/9lJpcBA=",
token_duration: "365d",
api_url: "http://localhost:3000",
token_duration: process.env.TOKEN_DURATION || "365d",
api_url: process.env.API_URL || "http://localhost:3000",
bucketName: process.env.BUCKET_NAME || "sorvor",
minioPublicBucketEndpoint:
process.env.MINIO_PUBLIC_BUCKET_ENDPOINT || "http://localhost:9000/sorvor",
minioEndpoint: process.env.MINIO_ENDPOINT || "localhost",
minioSSL: (process.env.MINIO_SSL && process.env.MINIO_SSL == "true") || false,
minioPort: parseInt(process.env.MINIO_PORT || "9000"),
minioAccessKey: process.env.MINIO_ACCESS_KEY || "minioadmin",
minioSecretKey:
process.env.MINIO_SECRET_KEY ||
"K7RSS3iy/191QBeYwLJALtxGfZIHJVdBigSMdBXjqNE=",
};

View File

@@ -1,8 +1,9 @@
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";
import * as schema from "./schema";
const sqlite = new Database("sqlite.db");
sqlite.pragma("journal_mode = WAL");
export const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: "drizzle" });

View File

@@ -5,6 +5,7 @@ import { z } from "zod";
export const infoRoute = router({
getAllProvinces: publicProcedure.query(getProvinces),
getAllGroups: publicProcedure.query(getGroups),
getAllOpinions: publicProcedure.query(getOpinions),
getAllZones: publicProcedure
.input(z.object({ provice_id: z.number().optional() }))
.query(async ({ input }) => await getZone(input.provice_id)),
@@ -28,3 +29,7 @@ async function getZone(province?: number) {
async function getGroups() {
return await db.query.group.findMany();
}
async function getOpinions() {
return await db.query.opinion.findMany();
}

40
src/minio.ts Normal file
View File

@@ -0,0 +1,40 @@
import * as minio from "minio";
import { Config } from "./config";
export function createClient() {
const mc = new minio.Client({
endPoint: Config.minioEndpoint,
useSSL: Config.minioSSL,
port: Config.minioPort,
accessKey: Config.minioAccessKey,
secretKey: Config.minioSecretKey,
});
return mc;
}
export async function createUploadImageUrl(
mc: minio.Client,
objectName: string,
contentType: string,
) {
const policy = mc.newPostPolicy();
policy.setKey(objectName);
policy.setBucket(Config.bucketName);
const expires = new Date();
expires.setSeconds(30 * 60);
policy.setExpires(expires);
policy.setContentType(contentType);
policy.setContentDisposition(`attachment; filename="${objectName}"`);
policy.setContentLengthRange(1, 3 * 1024 * 1024);
const rs = await mc.presignedPostPolicy(policy);
return rs;
}
export async function createBucket(mc: minio.Client) {
if (!(await mc.bucketExists(Config.bucketName))) {
await mc.makeBucket(Config.bucketName);
} else {
console.log("Bucket already exists");
}
}

View File

@@ -15,7 +15,7 @@ export const runPlayground = async (appRouter: AppRouter) => {
trpcApiEndpoint,
playgroundEndpoint,
router: appRouter,
})
}),
);
app.listen(3001, () => {

View File

@@ -15,15 +15,17 @@ export const user = sqliteTable(
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
firstName: text("firstName").notNull(),
lastName: text("lastName").notNull(),
registerno: text("registerno"),
title: text("title").notNull(),
cid: text("cid", { length: 13 }).notNull().unique(),
cid: text("cid", { length: 13 }).notNull(),
age: integer("age").notNull(),
phone: text("phone").unique().notNull(),
phone: text("phone").notNull(),
public_phone: text("public_phone"),
facebook: text("facebook"),
twitter: text("twitter"),
tiktok: text("tiktok"),
otherSocial: text("other_social"),
image: text("image"),
email: text("email"),
job: text("job").notNull(),
education: text("education").notNull(),
@@ -35,10 +37,13 @@ export const user = sqliteTable(
zone: integer("zone_id")
.notNull()
.references(() => zone.id),
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
rank: integer("rank").default(9999),
},
(t) => ({
phone_idx: index("phone_idx").on(t.phone),
})
image_idx: index("image_idx").on(t.image),
}),
);
export const userRelation = relations(user, ({ many, one }) => ({
@@ -51,6 +56,7 @@ export const userRelation = relations(user, ({ many, one }) => ({
fields: [user.zone],
references: [zone.id],
}),
userToSelection: many(userToSelection, { relationName: "userRelation" }),
}));
//----------------Group
@@ -67,8 +73,8 @@ export const groupRelation = relations(group, ({ many }) => ({
export const opinion = sqliteTable("opinions", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
name: text("name").unique().notNull(),
type: text("type", { enum: ["3Choice", "4Choice"] })
.default("3Choice")
type: text("type", { enum: ["3Choice", "4Choice", "5Choice"] })
.default("5Choice")
.notNull(),
});
@@ -83,12 +89,19 @@ export const userOpinion = sqliteTable(
.notNull()
.references(() => opinion.id),
choice: text("choice", {
enum: ["agree", "disagree", "deciding", "ignore"],
}).default("deciding"),
enum: [
"strongly agree",
"agree",
"disagree",
"strongly disagree",
"ignore",
"deciding",
],
}).default("ignore"),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.opinionId] }),
})
}),
);
export const userOpinionRelation = relations(userOpinion, ({ one }) => ({
@@ -107,8 +120,9 @@ export const zone = sqliteTable(
province: integer("province_id")
.notNull()
.references(() => province.id),
total: integer("total").notNull().default(0),
},
(t) => ({ unique_name_province: unique().on(t.name, t.province) })
(t) => ({ unique_name_province: unique().on(t.name, t.province) }),
);
export const zoneRelation = relations(zone, ({ one }) => ({
province: one(province, {
@@ -127,11 +141,36 @@ export const provinceRelation = relations(province, ({ many }) => ({
zones: many(zone),
}));
//----------------PhoneToken
export const phoneToken = sqliteTable("phone_tokens", {
phone: text("phone").primaryKey(),
token: text("token").notNull(),
//----------------ImageToUser
export const imageToUser = sqliteTable("image_to_user", {
userId: integer("user_id")
.primaryKey()
.references(() => user.id),
imageName: text("image_name").notNull(),
createdOn: integer("created_on", { mode: "timestamp" }).default(
sql`CURRENT_TIMESTAMP`
sql`CURRENT_TIMESTAMP`,
),
});
export const userToSelection = sqliteTable("user_to_selection", {
userId: integer("user_id").references(() => user.id, { onDelete: "cascade" }),
targetId: integer("target_id").references(() => user.id, {
onDelete: "cascade",
}),
});
export const userToSelectionRelation = relations(
userToSelection,
({ one }) => ({
user: one(user, {
fields: [userToSelection.userId],
references: [user.id],
relationName: "userRelation",
}),
selection: one(user, {
fields: [userToSelection.targetId],
references: [user.id],
relationName: "selectionRelation",
}),
}),
);

View File

@@ -40,10 +40,9 @@ export const createContext = async (opts: CreateHTTPContextOptions) => {
const authorizationHeader = opts.req.headers.authorization || "";
const bearerToken = authorizationHeader.split(" ")[1];
console.log(authorizationHeader, bearerToken);
const phone = await verifyToken(bearerToken);
if (phone !== null) {
let user = await db.query.user.findFirst({
const user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.phone, phone),
});
return {
@@ -60,7 +59,7 @@ export const createContext = async (opts: CreateHTTPContextOptions) => {
async function verifyToken(token: string): Promise<string | null> {
try {
let rs = await new Promise((resolve, reject) => {
const rs = await new Promise((resolve, reject) => {
jwt.verify(token, Config.jwt_secret, (err, decoded) => {
if (err) {
reject(err);
@@ -69,7 +68,7 @@ async function verifyToken(token: string): Promise<string | null> {
}
});
});
let data = z
const data = z
.object({
phone: z.string(),
})

View File

@@ -1,17 +1,18 @@
import { router, publicProcedure, protectedProcedure } from "./trpc";
import { db } from "./db";
import { opinion, user, userOpinion } from "./schema";
import { imageToUser, opinion, user, userOpinion, zone } from "./schema";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { SQL, eq } from "drizzle-orm";
import { SQL, and, count, eq, inArray, sql } 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" }),
});
}).omit({ verified: true });
const userUpdateSchema = userInsertSchema
.omit({ id: true, cid: true, phone: true })
@@ -26,22 +27,67 @@ const opinionInsertSchema = createInsertSchema(userOpinion)
const opinionUpdateSchema = createInsertSchema(userOpinion)
.omit({
userId: true,
verified: 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)),
getUser: publicProcedure
.input(z.object({ userId: z.number() }))
.mutation(async ({ input }) => await getUser(input.userId, false)),
getSelf: protectedProcedure.mutation(
async ({ ctx }) => await getUser(ctx.user.id, true),
),
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),
limit: z.number().max(1000).default(1000),
group: z.number().optional(),
zone: z.number().optional(),
opinionCount: z.number().default(3),
})
province: z.number().optional(),
userId: z.number().optional(),
}),
)
.query(
async ({ input }) =>
@@ -50,39 +96,113 @@ export const userRoute = router({
input.limit,
input.opinionCount,
input.group,
input.zone
)
input.zone,
input.province,
input.userId,
),
),
createUser: publicProcedure
getAllUserCount: publicProcedure
.input(
userInsertSchema.omit({ id: true }).extend({
opinions: opinionInsertSchema,
})
z.object({
group: z.number().optional(),
zone: z.number().optional(),
province: z.number().optional(),
}),
)
.mutation(
async ({ input }) => await createUser({ ...input }, input.opinions)
),
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)
.query(
async ({ input }) =>
await getAllUserCount(input.group, input.zone, input.province),
),
});
async function getAllUserCount(
group?: number,
zoneId?: number,
provinceId?: number,
) {
const zoneIds: number[] = await getZone(provinceId);
if (provinceId && zoneIds.length === 0) {
return [];
}
const conditions: SQL[] = [];
if (group !== undefined) {
conditions.push(eq(user.group, group));
}
if (zoneId !== undefined) {
conditions.push(eq(user.zone, zoneId));
}
if (zoneIds.length > 0) {
conditions.push(inArray(user.zone, zoneIds));
}
return await db
.select({ count: count(user.id) })
.from(user)
.where(and(...conditions))
.then((v) => v[0].count);
}
async function getAllUser(
offset: number,
limit: number,
opinionLimit: number,
group?: number,
zone?: number
zoneId?: number,
provinceId?: number,
userId?: number,
) {
const zoneIds: number[] = await getZone(provinceId);
if (provinceId && zoneIds.length === 0) {
return [];
}
let thisUser =
userId == undefined
? undefined
: await db.query.user.findFirst({
where: eq(user.id, userId),
with: {
userToSelection: {
with: {
selection: {
with: {
group: true,
opinions: {
limit: opinionLimit,
},
zone: {
with: { province: true },
},
},
},
},
},
},
});
const topTen = await db.query.user.findMany({
with: {
group: true,
opinions: {
limit: opinionLimit,
},
zone: {
with: { province: true },
},
},
limit: 10,
orderBy: user.rank,
where: (user, { eq, and }) => {
const conditions: SQL[] = [];
if (group !== undefined) {
conditions.push(eq(user.group, group));
}
if (zoneId !== undefined) {
conditions.push(eq(user.zone, zoneId));
}
if (zoneIds.length > 0) {
conditions.push(inArray(user.zone, zoneIds));
}
return and(...conditions);
},
});
let users = await db.query.user.findMany({
with: {
group: true,
@@ -95,32 +215,90 @@ async function getAllUser(
},
limit,
offset,
orderBy: sql`random()`,
where: (user, { eq, and }) => {
let conditions: SQL[] = [];
if (group) {
const conditions: SQL[] = [];
if (group !== undefined) {
conditions.push(eq(user.group, group));
}
if (zone) {
conditions.push(eq(user.zone, zone));
if (zoneId !== undefined) {
conditions.push(eq(user.zone, zoneId));
}
if (zoneIds.length > 0) {
conditions.push(inArray(user.zone, zoneIds));
}
return and(...conditions);
},
});
let resultUser: typeof users;
if (thisUser && thisUser.group == group) {
const selections = thisUser.userToSelection.map((v) => v.selection);
let validSelection: typeof users = [];
for (const sl of selections) {
if (sl !== null) {
validSelection.push(sl);
}
}
resultUser = [
...validSelection,
...users.filter(
(u) => validSelection.filter((v) => v.id == u.id).length == 0,
),
];
resultUser = randomArray(1, 10, resultUser);
} else {
resultUser = [
...topTen,
...users.filter((u) => topTen.filter((v) => v.id == u.id).length == 0),
];
resultUser = randomArray(0, 5, resultUser);
resultUser = randomArray(5, 10, resultUser);
}
return users.map((u) => ({
...u,
phone: hidePhone(u.phone),
}));
return resultUser
.map((u) => ({
...u,
phone: hidePhone(u.phone),
verified: true,
image: u.image ? `${Config.minioPublicBucketEndpoint}${u.image}` : null,
}))
.slice(0, limit);
}
async function getUser(userId: number, showPhone: boolean) {
const user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, userId),
with: {
group: true,
opinions: true,
zone: true,
},
});
if (user === undefined) {
throw new TRPCError({
message: "User not found",
code: "BAD_REQUEST",
});
}
return {
...user,
phone: showPhone ? user.phone : hidePhone(user.phone),
image: user.image
? `${Config.minioPublicBucketEndpoint}${user.image}`
: null,
verified: true,
};
}
async function createUser(
newUser: UserInsertSchema,
opinions: OpinionInsertSchema
opinions: OpinionInsertSchema,
) {
try {
let result = (
const result = (
await db.insert(user).values(newUser).returning({ id: user.id })
)[0];
for (let op of opinions) {
for (const op of opinions) {
await db.insert(userOpinion).values({ ...op, userId: result.id });
}
return { token: createJWT(newUser.phone) };
@@ -141,7 +319,7 @@ async function updateUser(userId: number, update: UserUpdateSchema) {
}
async function login(cid: string, phone: string) {
let user = await db.query.user.findFirst({
const user = await db.query.user.findFirst({
where: (user, { and, eq }) => and(eq(user.cid, cid), eq(user.phone, phone)),
});
if (user === undefined) {
@@ -154,19 +332,13 @@ async function login(cid: string, phone: string) {
}
}
function createJWT(phone: string) {
return jwt.sign({ phone: phone }, Config.jwt_secret, {
expiresIn: "365d",
});
}
async function changeOpinion(
opinionId: number,
userId: number,
opinionChoice: OpinionInsertSchema[0]["choice"]
opinionChoice: OpinionInsertSchema[0]["choice"],
) {
try {
let thisOpinion = await db
const thisOpinion = await db
.select()
.from(opinion)
.where(eq(opinion.id, opinionId))
@@ -203,6 +375,101 @@ async function changeOpinion(
});
}
}
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
const tryCount = 0;
let objectName: string | null = null;
while (tryCount < 3) {
const imageName = `${generateRandomString()}.${extension}`;
const 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 },
});
const request = await createUploadImageUrl(mc, objectName, contentType);
request.postURL = Config.minioPublicBucketEndpoint;
return request;
}
async function confirmChangeImage(userId: number, oldImage: string | null) {
const mc = createClient();
const 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",
});
}
const 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<void>[] = [];
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;
@@ -219,3 +486,33 @@ 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);
}
async function getZone(province?: number) {
if (province === undefined) {
return [];
}
const zoneIds = await db
.select({ id: zone.id })
.from(zone)
.where(eq(zone.province, province))
.then((queryResult) => queryResult.map((v) => v.id));
return zoneIds;
}
function randomArray<T>(from: number, to: number, arr: T[]): T[] {
for (let i = Math.min(arr.length - 1, from); i < to; i++) {
const j = Math.floor(Math.random() * (i - from)) + from;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}

11
tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -1,7 +1,6 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
@@ -9,10 +8,8 @@
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
@@ -23,13 +20,14 @@
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"module": "commonjs" /* Specify what module code is generated. */, // "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
"baseUrl": "." /* Specify the base directory to resolve non-relative module names. */,
"paths": {
"@/components/*": ["components/*"],
"@/src/*": ["src/*"]
},
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
@@ -39,15 +37,13 @@
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "resolveJsonModule": true /* Enable importing .json files. */,
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
@@ -72,18 +68,13 @@
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */ /* Type Checking */,
"strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
@@ -101,9 +92,29 @@
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"incremental": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
"next.config.js"
],
"exclude": ["node_modules"]
}