diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8a18a69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# database +/prisma/db.sqlite +/prisma/db.sqlite-journal + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee6d780 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +FROM node:18.19-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* ./ +COPY prisma/ ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then yarn global add 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 yarn build + +# If using npm comment out above and use below instead +# RUN npm run build + +# 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 + +# 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 + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +# set hostname to localhost +ENV HOSTNAME "0.0.0.0" + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD ["node", "server.js"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1e48cd8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +# docker-compose.dev.yml +version: '3' + +services: + web: + container_name: web + build: + context: . + dockerfile: ./Dockerfile + volumes: + - .:/app/web + environment: + POSTGRES_ADDR: postgres + POSTGRES_DATABASE: savage_tracking + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/savage_tracking?schema=public + JWT_SECRET: secret + TZ: Asia/Taipei + RFID_PKEY: secret + depends_on: + postgres: + condition: service_healthy + restart: always + ports: + - 3000:3000 + postgres: + image: postgres:16-alpine + restart: always + environment: + POSTGRES_DB: savage_tracking + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: + ["CMD", "pg_isready", "-U", "postgres", "-d", "savage_tracking"] + interval: 5s + timeout: 10s + retries: 5 + ports: + - 3001:5432 + +volumes: + pgdata: {} diff --git a/next.config.js b/next.config.js index ffbeb9f..c824120 100644 --- a/next.config.js +++ b/next.config.js @@ -17,6 +17,11 @@ const config = { locales: ["en"], defaultLocale: "en", }, + + eslint: { + ignoreDuringBuilds: true, + }, + output: 'standalone', // for docker }; export default config; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9351e95..e8d9ca0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,10 +3,11 @@ generator client { provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db { - provider = "sqlite" + provider = "postgresql" url = env("DATABASE_URL") } diff --git a/src/components/admin/Periods.tsx b/src/components/admin/Periods.tsx index fc1e0a1..ca74853 100644 --- a/src/components/admin/Periods.tsx +++ b/src/components/admin/Periods.tsx @@ -43,7 +43,7 @@ export default function Periods() { { - Object.keys(periods.data || {}).map((date) => { + Object.keys(periods.data ?? {}).map((date) => { periodCnt = 0; return ( diff --git a/src/components/admin/attendance/ListView.tsx b/src/components/admin/attendance/ListView.tsx index b0c9d54..15b9c97 100644 --- a/src/components/admin/attendance/ListView.tsx +++ b/src/components/admin/attendance/ListView.tsx @@ -1,15 +1,13 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { z } from "zod"; import { api } from "~/utils/api"; import { AddAttendance } from "~/utils/types"; export default function ListView() { - const today = new Date(); - - const [startDate, setStartDate] = useState(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`); - const [endDate, setEndDate] = useState(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); const attendanceData = api.admin.getAttendanceFromRange.useQuery({ start: startDate, end: endDate @@ -24,6 +22,12 @@ export default function ListView() { attendanceData.refetch(); }).catch((err) => console.log(err)); + useEffect(() => { + const today = new Date(); + setStartDate(today.toISOString().split("T")[0]!) + setEndDate(today.toISOString().split("T")[0]!) + }, []) + return
@@ -47,8 +51,9 @@ export default function ListView() { { (attendanceData.isLoading) &&
diff --git a/src/components/admin/attendance/UserView.tsx b/src/components/admin/attendance/UserView.tsx index d0d6678..e86eff3 100644 --- a/src/components/admin/attendance/UserView.tsx +++ b/src/components/admin/attendance/UserView.tsx @@ -37,36 +37,36 @@ export default function UserView() { { - (userSelectedPeriods.isLoading || userData.isLoading) &&
+ (userSelectedPeriods.isLoading ?? userData.isLoading) &&
= 30 ? "bg-emerald-500" : "bg-red-300" + (userActualAttendTime.data ?? 0) + (userAttendTime.data ?? 0) >= 30 ? "bg-emerald-500" : "bg-red-300" ].join(" ")}>
預估總時數
-
{((userActualAttendTime.data || 0) + (userAttendTime.data || 0)).toFixed(1)}
+
{((userActualAttendTime.data ?? 0) + (userAttendTime.data ?? 0)).toFixed(1)}
@@ -121,7 +121,7 @@ export default function UserView() { { - Object.keys(periods.data || {}).map((date) => { + Object.keys(periods.data ?? {}).map((date) => { periodCnt = 0; const borderTop = new Date(date).setHours(0, 0, 0, 0) > new Date().getTime() && !passedDate; if (borderTop) passedDate = true; @@ -140,7 +140,7 @@ export default function UserView() { return userToggleAttendance.mutateAsync({ username: username, - periodId: thisPeriodId || -1, + periodId: thisPeriodId ?? -1, attendance: false }).then(() => { userSelectedPeriods.refetch(); @@ -153,7 +153,7 @@ export default function UserView() { return userToggleAttendance.mutateAsync({ username: username, - periodId: thisPeriodId || -1, + periodId: thisPeriodId ?? -1, attendance: true }).then(() => { userSelectedPeriods.refetch(); @@ -185,7 +185,7 @@ export default function UserView() { { - Object.keys(periods.data || {}).map((date) => { + Object.keys(periods.data ?? {}).map((date) => { periodCnt = 0; entered = false; const borderTop = new Date(date).setHours(0, 0, 0, 0) > new Date().getTime() && !passedDate; @@ -206,7 +206,7 @@ export default function UserView() { const thisPeriodEnd = new Date(date); thisPeriodEnd.setHours(parseInt(timePeriod.end.split(":")[0]!), parseInt(timePeriod.end.split(":")[1]!), 0, 0) - for (let i = attCnt; i < (userAttendance.data || []).length; i++) { + for (let i = attCnt; i < (userAttendance.data ?? []).length; i++) { const thisAtt = userAttendance.data![i]; if (thisAtt?.datetime! < thisPeriodStart) continue; if (thisAtt?.datetime! > thisPeriodEnd) { diff --git a/src/pages/dash/admin/rfid.tsx b/src/pages/dash/admin/rfid.tsx index d801d6d..8987e48 100644 --- a/src/pages/dash/admin/rfid.tsx +++ b/src/pages/dash/admin/rfid.tsx @@ -73,7 +73,7 @@ export default function Dash() { if (lastRfid.data === "") return; setUserRfid.mutateAsync({ username: user.username, - rfid: lastRfid.data || "" + rfid: lastRfid.data ?? "" }).then(() => users.refetch()) }}> Select diff --git a/src/pages/dash/admin/roster.tsx b/src/pages/dash/admin/roster.tsx index b7e57ef..791055a 100644 --- a/src/pages/dash/admin/roster.tsx +++ b/src/pages/dash/admin/roster.tsx @@ -48,7 +48,7 @@ export default function Dash() { }}> { - Object.keys(periods.data || {}).map((date) => { + Object.keys(periods.data ?? {}).map((date) => { return () }) } diff --git a/src/pages/dash/index.tsx b/src/pages/dash/index.tsx index 2776699..bed031f 100644 --- a/src/pages/dash/index.tsx +++ b/src/pages/dash/index.tsx @@ -51,11 +51,11 @@ export default function Dash() {
= 30 ? "bg-emerald-500" : "bg-red-300" + (actualAttendTime.data ?? 0) + (attendTime.data ?? 0) >= 30 ? "bg-emerald-500" : "bg-red-300" ].join(" ")}>
預估總時數
-
{((actualAttendTime.data || 0) + (attendTime.data || 0)).toFixed(1)}
+
{((actualAttendTime.data ?? 0) + (attendTime.data ?? 0)).toFixed(1)}
@@ -73,7 +73,7 @@ export default function Dash() { { - Object.keys(periods.data || {}).map((date) => { + Object.keys(periods.data ?? {}).map((date) => { periodCnt = 0; entered = false; return ( @@ -88,7 +88,7 @@ export default function Dash() { if (mySelectedPeriods.data?.findIndex((period) => period == thisPeriodId) != -1) { return toggleAttendance.mutateAsync({ - periodId: thisPeriodId || -1, + periodId: thisPeriodId ?? -1, attendance: false }).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch()).catch((e) => alert(e.message))}> Will Attend @@ -96,7 +96,7 @@ export default function Dash() { } else { return toggleAttendance.mutateAsync({ - periodId: thisPeriodId || -1, + periodId: thisPeriodId ?? -1, attendance: true }).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch()).catch((e) => alert(e.message))}> } @@ -114,7 +114,7 @@ export default function Dash() { const thisPeriodEnd = new Date(date); thisPeriodEnd.setHours(parseInt(timePeriod.end.split(":")[0]!), parseInt(timePeriod.end.split(":")[1]!), 0, 0) - for (let i = attCnt; i < (myAttendance.data || []).length; i++) { + for (let i = attCnt; i < (myAttendance.data ?? []).length; i++) { const thisAtt = myAttendance.data![i]; if (thisAtt?.datetime! < thisPeriodStart) continue; if (thisAtt?.datetime! > thisPeriodEnd) { diff --git a/src/server/api/context.ts b/src/server/api/context.ts index a8ba2c4..2381d9c 100644 --- a/src/server/api/context.ts +++ b/src/server/api/context.ts @@ -15,7 +15,7 @@ export async function myCreateContext(opts: CreateNextContextOptions) { // Verify JWT let session = PublicUserType.parse(undefined); try { - jwt.verify(token || "", process.env.JWT_SECRET || "", (err, decoded) => { + jwt.verify(token ?? "", process.env.JWT_SECRET ?? "", (err, decoded) => { if (err) { console.log(err); } else { diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts index 660f038..6002d0a 100644 --- a/src/server/api/routers/admin.ts +++ b/src/server/api/routers/admin.ts @@ -18,10 +18,10 @@ export const adminRouter = createTRPCRouter({ username: input.username, } }).then((user) => { - const result = compareSync(input.password, user?.password || ""); + const result = compareSync(input.password, user?.password ?? ""); if (result) { const session = PublicUserType.parse(user)!; - const token = jwt.sign(session, process.env.JWT_SECRET || "", { expiresIn: "1d" }); + const token = jwt.sign(session, process.env.JWT_SECRET ?? "", { expiresIn: "1d" }); ctx.res.setHeader("Set-Cookie", `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24};`); return { status: "success", @@ -57,7 +57,7 @@ export const adminRouter = createTRPCRouter({ username: ctx.session?.username, } }).then(async (user) => { - const result = compareSync(input.oldPassword, user?.password || ""); + const result = compareSync(input.oldPassword, user?.password ?? ""); if (result) { await ctx.db.user.update({ where: { @@ -241,7 +241,7 @@ export const adminRouter = createTRPCRouter({ timePeriods.forEach(async (timePeriod) => { await ctx.db.period.create({ data: { - date: new Date(input.date), + date: new Date(input.date.replace(/-/g, "/")), timePeriodId: timePeriod.id, }, }); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 079d3b3..e2046a9 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -38,7 +38,7 @@ export const createTRPCContext = (_opts: CreateNextContextOptions) => { // Verify JWT let session = PublicUserType.parse(undefined); try { - jwt.verify(token || "", process.env.JWT_SECRET || "", (err, decoded) => { + jwt.verify(token ?? "", process.env.JWT_SECRET ?? "", (err, decoded) => { if (err) { session = undefined; } else { diff --git a/tsconfig.json b/tsconfig.json index 905062d..2d5c520 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "lib": ["dom", "dom.iterable", "ES2022"], "noEmit": true, "module": "ESNext", - "moduleResolution": "Bundler", + "moduleResolution": "node", "jsx": "preserve", "plugins": [{ "name": "next" }], "incremental": true,