From e53600da8edd87136fd7be7f1b63609614899dc6 Mon Sep 17 00:00:00 2001 From: Ronnie Scott <33236719+rdscott910@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:14:30 -0500 Subject: [PATCH] Add Docker support with multi-stage build and .dockerignore - Introduced Dockerfile for multi-stage builds to optimize image size and security. - Added .dockerignore to exclude unnecessary files from the Docker context. - Updated Next.js configuration for standalone output and enhanced security headers. - Implemented middleware for API route authentication and authorization. - Refactored API routes to use async/await and improved error handling. - Consolidated TypeScript interfaces and improved type safety across API responses. - Enhanced admin dashboard with better state management and notifications. --- .dockerignore | 47 +++ Dockerfile | 59 ++++ eslint.config.mjs | 2 + next.config.ts | 29 +- package-lock.json | 412 +---------------------- package.json | 5 +- src/app/admin/page.tsx | 24 +- src/app/api/capacity-overrides/route.ts | 12 +- src/app/api/drivers/route.ts | 16 +- src/app/api/markets/[id]/route.ts | 19 +- src/app/api/markets/route.ts | 8 +- src/app/api/shifts/[id]/route.ts | 22 +- src/app/api/shifts/route.ts | 70 ++-- src/app/api/slack/post-schedule/route.ts | 12 +- src/app/api/templates/[id]/route.ts | 58 ++-- src/app/api/templates/route.ts | 60 ++-- src/app/dashboard/page.tsx | 36 +- src/lib/auth.ts | 137 ++++++++ src/lib/db-mysql.ts | 229 +++++++++++-- src/middleware.ts | 102 ++++++ src/types/index.ts | 51 ++- 21 files changed, 825 insertions(+), 585 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 src/lib/auth.ts create mode 100644 src/middleware.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b660e48 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Dependencies +node_modules +npm-debug.log* + +# Next.js build output +.next +out + +# Testing +coverage +.nyc_output + +# IDE +.idea +.vscode +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment files (will be provided at runtime) +.env +.env.* +!.env.example + +# Git +.git +.gitignore + +# Documentation +*.md +!README.md + +# Development files +scripts/ +data/ +*.db +*.sqlite +template_debug.log + +# Cursor/Editor +.cursor/ + +# TypeScript build info +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a5dbbd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# Stage 1: Install dependencies +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Stage 2: Build the application +FROM node:20-alpine AS builder +WORKDIR /app + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Set environment for build +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +# Build the application +RUN npm run build + +# Stage 3: Production image +FROM node:20-alpine AS runner +WORKDIR /app + +# Set environment +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create non-root user for security +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy necessary files from builder +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Switch to non-root user +USER nextjs + +# Expose port +EXPOSE 3000 + +# Set hostname +ENV HOSTNAME="0.0.0.0" +ENV PORT=3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +# Start the application +CMD ["node", "server.js"] diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..93e9e65 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // Ignore legacy scripts folder (contains SQLite migration scripts) + "scripts/**", ]), ]); diff --git a/next.config.ts b/next.config.ts index e9ffa30..ab55f82 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,34 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // Enable standalone output for Docker deployments + output: "standalone", + + // Security headers for production + async headers() { + return [ + { + source: "/:path*", + headers: [ + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + ], + }, + ]; + }, + + // Disable x-powered-by header for security + poweredByHeader: false, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 377e4c3..418b2eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,6 @@ "name": "toc-schedule", "version": "0.1.0", "dependencies": { - "@types/better-sqlite3": "^7.6.13", - "better-sqlite3": "^12.5.0", "lucide-react": "^0.561.0", "mysql2": "^3.16.0", "next": "16.0.10", @@ -25,6 +23,9 @@ "eslint-config-next": "16.0.10", "markdown-to-docx": "^1.2.0", "typescript": "^5" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@babel/code-frame": { @@ -1164,14 +1165,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1194,6 +1187,7 @@ "version": "20.19.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2006,25 +2000,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/baseline-browser-mapping": { "version": "2.9.7", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", @@ -2034,37 +2009,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/better-sqlite3": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz", - "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", - "hasInstallScript": true, - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2121,29 +2065,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2235,11 +2156,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2376,28 +2292,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2451,6 +2345,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true, "engines": { "node": ">=8" } @@ -2547,14 +2442,6 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -3150,14 +3037,6 @@ "node": ">=0.10.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3225,11 +3104,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3292,11 +3166,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3428,11 +3297,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3614,25 +3478,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3677,12 +3522,8 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/internal-slot": { "version": "1.1.0", @@ -4417,17 +4258,6 @@ "node": ">=8.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -4451,15 +4281,11 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4515,11 +4341,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -4592,28 +4413,6 @@ } } }, - "node_modules/node-abi": { - "version": "3.85.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", - "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -4735,14 +4534,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4903,31 +4694,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4955,15 +4721,6 @@ "react-is": "^16.13.1" } }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4993,28 +4750,6 @@ } ] }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "19.2.1", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", @@ -5042,19 +4777,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5187,25 +4909,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5473,49 +5176,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5552,14 +5212,6 @@ "node": ">= 0.4" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -5734,32 +5386,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5859,17 +5485,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6014,7 +5629,8 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true }, "node_modules/unrs-resolver": { "version": "1.11.1", @@ -6092,7 +5708,8 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true }, "node_modules/which": { "version": "2.0.2", @@ -6203,11 +5820,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/package.json b/package.json index 3eaef4e..6232094 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "toc-schedule", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.0.0" + }, "scripts": { "dev": "next dev", "build": "next build", @@ -9,8 +12,6 @@ "lint": "eslint" }, "dependencies": { - "@types/better-sqlite3": "^7.6.13", - "better-sqlite3": "^12.5.0", "lucide-react": "^0.561.0", "mysql2": "^3.16.0", "next": "16.0.10", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 7cdb970..2e10b84 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -56,12 +56,12 @@ export default function AdminDashboard() { setWeekStart(new Date().toISOString().split('T')[0]); }; - const showNotification = (message: string, type: 'success' | 'error') => { + const showNotification = useCallback((message: string, _type: 'success' | 'error') => { // Simple alert for now, or could implement a toast alert(message); - }; + }, []); - const fetchInitialData = async () => { + const fetchInitialData = useCallback(async () => { try { const [marketsRes, driversRes, templatesRes, settingsRes] = await Promise.all([ fetch('/api/markets?includeInactive=true', { cache: 'no-store' }), @@ -75,7 +75,7 @@ export default function AdminDashboard() { const templatesData = await templatesRes.json(); const settingsData = await settingsRes.json(); - const mappedMarkets = (marketsData.markets || []).map((m: any) => ({ + const mappedMarkets = (marketsData.markets || []).map((m: { id: number; name: string; market: string; active: boolean }) => ({ id: m.id, name: m.name, market: m.market, @@ -99,7 +99,7 @@ export default function AdminDashboard() { } finally { setLoading(false); } - }; + }, [showNotification]); const fetchScheduleData = useCallback(async () => { const weekDates = getWeekDates(weekStart); @@ -123,7 +123,7 @@ export default function AdminDashboard() { useEffect(() => { fetchInitialData(); - }, []); + }, [fetchInitialData]); useEffect(() => { if (markets.length > 0) { @@ -151,7 +151,7 @@ export default function AdminDashboard() { } else { showNotification('Failed to create template', 'error'); } - } catch (e) { + } catch { showNotification('Error creating template', 'error'); } }; @@ -173,7 +173,7 @@ export default function AdminDashboard() { const [showCapacityModal, setShowCapacityModal] = useState(false); const [editingCapacityTemplateId, setEditingCapacityTemplateId] = useState(null); - const [capacityOverrides, setCapacityOverrides] = useState([]); + const [capacityOverrides, setCapacityOverrides] = useState>([]); const [capacityDefault, setCapacityDefault] = useState(0); const openCapacityEditor = async (id: number) => { @@ -259,7 +259,7 @@ export default function AdminDashboard() { fetchInitialData(); showNotification('Market added', 'success'); } - } catch (e) { + } catch { showNotification('Failed to add market', 'error'); } }; @@ -272,7 +272,7 @@ export default function AdminDashboard() { body: JSON.stringify({ active }) }); fetchInitialData(); - } catch (e) { + } catch { showNotification('Failed to update market', 'error'); } }; @@ -288,7 +288,7 @@ export default function AdminDashboard() { const data = await res.json(); showNotification(data.error || 'Failed to delete market', 'error'); } - } catch (e) { + } catch { showNotification('Error deleting market', 'error'); } }; @@ -835,7 +835,7 @@ export default function AdminDashboard() { } else { showNotification('Today\'s schedule posted to Slack!', 'success'); } - } catch (error) { + } catch { showNotification('Failed to post to Slack', 'error'); } finally { setSlackPosting(false); diff --git a/src/app/api/capacity-overrides/route.ts b/src/app/api/capacity-overrides/route.ts index 0655078..7dd284a 100644 --- a/src/app/api/capacity-overrides/route.ts +++ b/src/app/api/capacity-overrides/route.ts @@ -14,11 +14,15 @@ export async function GET(request: NextRequest) { ); } - const overrides = getCapacityOverrides(parseInt(templateId)); + const overrides = await getCapacityOverrides(parseInt(templateId)); // Build a full week object with defaults for easier UI consumption const db = getDb(); - const template = db.prepare('SELECT capacity FROM shift_templates WHERE id = ?').get(parseInt(templateId)) as { capacity: number } | undefined; + const [rows] = await db.execute( + 'SELECT capacity FROM shift_templates WHERE id = ?', + [parseInt(templateId)] + ); + const template = (rows as { capacity: number }[])[0]; const defaultCapacity = template?.capacity ?? 0; const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; @@ -63,7 +67,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'capacity must be 0-20 (0 uses default)' }, { status: 400 }); } - setCapacityOverride(templateId, dayOfWeek, capacity); + await setCapacityOverride(templateId, dayOfWeek, capacity); return NextResponse.json({ success: true, @@ -88,7 +92,7 @@ export async function DELETE(request: NextRequest) { ); } - deleteCapacityOverrides(parseInt(templateId)); + await deleteCapacityOverrides(parseInt(templateId)); return NextResponse.json({ success: true, diff --git a/src/app/api/drivers/route.ts b/src/app/api/drivers/route.ts index cac3c1e..1659cf5 100644 --- a/src/app/api/drivers/route.ts +++ b/src/app/api/drivers/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb, getDrivers } from '@/lib/db'; +import type { ResultSetHeader } from 'mysql2'; + +interface MySQLError extends Error { + code?: string; +} // GET /api/drivers - List all drivers export async function GET() { @@ -17,7 +22,8 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); // Allow flexible input: 'name' (full string) or 'firstName'/'lastName' - let { name, firstName, lastName, email, phone, market, priority } = body; + const { name, email, phone, market, priority } = body; + let { firstName, lastName } = body; if ((!name && (!firstName || !lastName)) || !email || !market) { return NextResponse.json( @@ -51,12 +57,14 @@ export async function POST(request: NextRequest) { const [result] = await db.execute(` INSERT INTO \`Drivers\` (Owner_fname, Owner_lname, displayName, email, phone, market, schedule_priority, status) VALUES (?, ?, ?, ?, ?, ?, ?, 1) - `, [firstName, lastName, displayName, email, cleanPhone, market, priority || 5]) as any; + `, [firstName, lastName, displayName, email, cleanPhone, market, priority || 5]); + + const insertResult = result as ResultSetHeader; return NextResponse.json({ success: true, driver: { - id: result.insertId, + id: insertResult.insertId, name: displayName, email, phone: cleanPhone, @@ -67,7 +75,7 @@ export async function POST(request: NextRequest) { }); } catch (error) { // MySQL duplicate key error - if ((error as any).code === 'ER_DUP_ENTRY') { + if ((error as MySQLError).code === 'ER_DUP_ENTRY') { return NextResponse.json({ error: 'Email already exists' }, { status: 400 }); } throw error; diff --git a/src/app/api/markets/[id]/route.ts b/src/app/api/markets/[id]/route.ts index 58b828a..c75889d 100644 --- a/src/app/api/markets/[id]/route.ts +++ b/src/app/api/markets/[id]/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { updateMarketStatus, getDb } from '@/lib/db'; +interface MySQLError extends Error { + code?: string; + errno?: number; +} + export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -19,7 +24,7 @@ export async function PATCH( return NextResponse.json({ error: 'Active status required (boolean)' }, { status: 400 }); } - updateMarketStatus(marketId, active); + await updateMarketStatus(marketId, active); return NextResponse.json({ success: true }); } catch (error) { @@ -43,16 +48,20 @@ export async function DELETE( const db = getDb(); // Check if market exists - const market = db.prepare('SELECT * FROM count WHERE id = ?').get(marketId); + const [rows] = await db.execute('SELECT * FROM `count` WHERE id = ?', [marketId]); + const market = (rows as Record[])[0]; + if (!market) { return NextResponse.json({ error: 'Market not found' }, { status: 404 }); } try { - db.prepare('DELETE FROM count WHERE id = ?').run(marketId); + await db.execute('DELETE FROM `count` WHERE id = ?', [marketId]); return NextResponse.json({ success: true, message: 'Market deleted' }); - } catch (e: any) { - if (e.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { + } catch (e) { + const mysqlError = e as MySQLError; + // MySQL foreign key constraint error code is ER_ROW_IS_REFERENCED_2 (errno 1451) + if (mysqlError.errno === 1451 || mysqlError.code === 'ER_ROW_IS_REFERENCED_2') { return NextResponse.json({ error: 'Cannot delete market because it has associated drivers or templates. Please remove them first.' }, { status: 409 }); diff --git a/src/app/api/markets/route.ts b/src/app/api/markets/route.ts index d429967..ec2225a 100644 --- a/src/app/api/markets/route.ts +++ b/src/app/api/markets/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { getMarkets, addMarket } from '@/lib/db'; +interface MySQLError extends Error { + code?: string; +} + // GET /api/markets - List all markets (includeActive query param to filter?) // For admin we want all, for user maybe just active? // getMarkets() defaults to active only. getMarkets(true) gives all. @@ -33,8 +37,8 @@ export async function POST(request: NextRequest) { try { await addMarket(name.trim(), marketCode); return NextResponse.json({ success: true, message: 'Market created' }); - } catch (e: any) { - if (e.code === 'ER_DUP_ENTRY') { + } catch (e) { + if ((e as MySQLError).code === 'ER_DUP_ENTRY') { return NextResponse.json({ error: 'Market already exists' }, { status: 409 }); } throw e; diff --git a/src/app/api/shifts/[id]/route.ts b/src/app/api/shifts/[id]/route.ts index 377601c..ba48806 100644 --- a/src/app/api/shifts/[id]/route.ts +++ b/src/app/api/shifts/[id]/route.ts @@ -1,5 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb, getScheduleSettings } from '@/lib/db'; +import type { ResultSetHeader } from 'mysql2'; + +interface ShiftRow { + id: number; + driver_id: number; + date: Date | string; + start_time: string; + end_time: string; +} // DELETE /api/shifts/[id] - Cancel a shift export async function DELETE( @@ -25,13 +34,7 @@ export async function DELETE( WHERE ss.id = ? `, [shiftId]); - const shift = (shiftRows as any[])[0] as { - id: number; - driver_id: number; - date: Date | string; - start_time: string; - end_time: string; - } | undefined; + const shift = (shiftRows as ShiftRow[])[0]; if (!shift) { return NextResponse.json({ error: 'Shift not found' }, { status: 404 }); @@ -72,9 +75,10 @@ export async function DELETE( } // Delete the shift - const [result] = await db.execute('DELETE FROM \`scheduled_shifts\` WHERE id = ?', [shiftId]) as any; + const [result] = await db.execute('DELETE FROM \`scheduled_shifts\` WHERE id = ?', [shiftId]); + const deleteResult = result as ResultSetHeader; - if (result.affectedRows === 0) { + if (deleteResult.affectedRows === 0) { console.error(`[DELETE] Failed: No rows deleted for ID ${shiftId}`); return NextResponse.json({ error: 'Failed to delete shift (not found during delete execution)' }, { status: 500 }); } diff --git a/src/app/api/shifts/route.ts b/src/app/api/shifts/route.ts index 876ae1a..46f6b6a 100644 --- a/src/app/api/shifts/route.ts +++ b/src/app/api/shifts/route.ts @@ -1,9 +1,26 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb, getShiftTemplates, getScheduledShifts, getScheduleSettings, getDriverById, getCapacityForDate } from '@/lib/db'; +import type { ResultSetHeader } from 'mysql2'; export const dynamic = 'force-dynamic'; export const revalidate = 0; +interface ShiftTemplate { + id: number; + market: string; + start_time: string; + end_time: string; + capacity: number; +} + +interface CountResult { + count: number; +} + +interface MySQLError extends Error { + code?: string; +} + // GET /api/shifts - Get available shifts for a market/date export async function GET(request: NextRequest) { try { @@ -19,23 +36,10 @@ export async function GET(request: NextRequest) { } // Get shift templates for the market - const templates = await getShiftTemplates(market) as Array<{ - id: number; - market: string; - start_time: string; - end_time: string; - capacity: number; - }>; + const templates = await getShiftTemplates(market) as ShiftTemplate[]; // Get scheduled shifts for that date/market - const scheduled = await getScheduledShifts({ market, date }) as Array<{ - id: number; - driverId: number; - driverName: string; - templateId: number; - startTime: string; - endTime: string; - }>; + const scheduled = await getScheduledShifts({ market, date }); // Build availability info for each template const shifts = await Promise.all(templates.map(async template => { @@ -81,7 +85,7 @@ export async function POST(request: NextRequest) { const db = getDb(); const settings = await getScheduleSettings() as { base_schedule_days: number; cancel_hours_before: number }; - const driver = await getDriverById(driverId) as { id: number; priority: number; blocked: number; market: string } | undefined; + const driver = await getDriverById(driverId) as { id: number; priority: number; blocked: boolean; market: string } | undefined; if (!driver) { return NextResponse.json({ error: 'Driver not found' }, { status: 404 }); @@ -93,13 +97,7 @@ export async function POST(request: NextRequest) { // Get the template const [templateRows] = await db.execute('SELECT * FROM `shift_templates` WHERE id = ?', [templateId]); - const template = (templateRows as any[])[0] as { - id: number; - market: string; - start_time: string; - end_time: string; - capacity: number; - } | undefined; + const template = (templateRows as ShiftTemplate[])[0]; if (!template) { return NextResponse.json({ error: 'Shift template not found' }, { status: 404 }); @@ -124,7 +122,7 @@ export async function POST(request: NextRequest) { SELECT COUNT(*) as count FROM \`scheduled_shifts\` WHERE template_id = ? AND date = ? `, [templateId, date]); - const currentCount = (countRows as any[])[0]; + const currentCount = (countRows as CountResult[])[0]; if (currentCount.count >= capacityForDate) { return NextResponse.json({ error: 'Shift is full' }, { status: 400 }); @@ -137,10 +135,7 @@ export async function POST(request: NextRequest) { prevDateObj.setDate(prevDateObj.getDate() - 1); const prevDate = prevDateObj.toISOString().split('T')[0]; - const prevShifts = await getScheduledShifts({ driverId, date: prevDate }) as Array<{ - startTime: string; - endTime: string; - }>; + const prevShifts = await getScheduledShifts({ driverId, date: prevDate }); // "toMinutes" helper const toMinutes = (time: string) => { @@ -163,11 +158,7 @@ export async function POST(request: NextRequest) { } // 2. Check Same Day Overlaps - const driverShifts = await getScheduledShifts({ driverId, date }) as Array<{ - startTime: string; - endTime: string; - market: string; - }>; + const driverShifts = await getScheduledShifts({ driverId, date }); for (const shift of driverShifts) { const hasOverlap = checkTimeOverlap( @@ -196,10 +187,7 @@ export async function POST(request: NextRequest) { nextDateObj.setDate(nextDateObj.getDate() + 1); const nextDate = nextDateObj.toISOString().split('T')[0]; - const nextShifts = await getScheduledShifts({ driverId, date: nextDate }) as Array<{ - startTime: string; - endTime: string; - }>; + const nextShifts = await getScheduledShifts({ driverId, date: nextDate }); for (const s of nextShifts) { const sStart = toMinutes(s.startTime); @@ -213,13 +201,15 @@ export async function POST(request: NextRequest) { const [result] = await db.execute(` INSERT INTO \`scheduled_shifts\` (driver_id, template_id, date) VALUES (?, ?, ?) - `, [driverId, templateId, date]) as any; + `, [driverId, templateId, date]); + + const insertResult = result as ResultSetHeader; return NextResponse.json({ success: true, message: 'Shift claimed successfully', shift: { - id: result.insertId, + id: insertResult.insertId, driverId, templateId, date, @@ -230,7 +220,7 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Error claiming shift:', error); - if ((error as any).code === 'ER_DUP_ENTRY') { + if ((error as MySQLError).code === 'ER_DUP_ENTRY') { return NextResponse.json({ error: 'Already scheduled for this shift' }, { status: 400 }); } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/src/app/api/slack/post-schedule/route.ts b/src/app/api/slack/post-schedule/route.ts index e025a39..b6b77d6 100644 --- a/src/app/api/slack/post-schedule/route.ts +++ b/src/app/api/slack/post-schedule/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getScheduledShifts, getScheduleSettings, getShiftTemplates, getMarkets } from '@/lib/db'; +import { getScheduledShifts, getScheduleSettings, getMarkets } from '@/lib/db'; interface SlackBlock { type: string; @@ -20,7 +20,7 @@ export async function POST(request: NextRequest) { } // Get the webhook URL from settings - const settings = getScheduleSettings() as { slack_webhook_url?: string } | undefined; + const settings = await getScheduleSettings() as { slack_webhook_url?: string } | undefined; const webhookUrl = settings?.slack_webhook_url; if (!webhookUrl) { @@ -42,7 +42,7 @@ export async function POST(request: NextRequest) { // Get markets to post (all if not specified) const markets = market ? [{ name: market }] - : (getMarkets() as Array<{ name: string }>); + : (await getMarkets() as Array<{ name: string }>); // Build Slack message blocks const blocks: SlackBlock[] = [ @@ -59,11 +59,7 @@ export async function POST(request: NextRequest) { let hasAnyScheduled = false; for (const m of markets) { - const scheduled = getScheduledShifts({ market: m.name, date: today }) as Array<{ - driverName: string; - startTime: string; - endTime: string; - }>; + const scheduled = await getScheduledShifts({ market: m.name, date: today }); if (scheduled.length === 0) continue; hasAnyScheduled = true; diff --git a/src/app/api/templates/[id]/route.ts b/src/app/api/templates/[id]/route.ts index 1c079db..21bf41f 100644 --- a/src/app/api/templates/[id]/route.ts +++ b/src/app/api/templates/[id]/route.ts @@ -1,6 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; +interface ShiftTemplate { + id: number; + market: string; + start_time: string; + end_time: string; + capacity: number; +} + // PATCH /api/templates/[id] - Update a shift template export async function PATCH( request: NextRequest, @@ -18,7 +26,13 @@ export async function PATCH( const { capacity, startTime, endTime } = body; const db = getDb(); - const template = db.prepare('SELECT * FROM shift_templates WHERE id = ?').get(templateId); + + // Check if template exists + const [templateRows] = await db.execute( + 'SELECT * FROM shift_templates WHERE id = ?', + [templateId] + ); + const template = (templateRows as ShiftTemplate[])[0]; if (!template) { return NextResponse.json({ error: 'Template not found' }, { status: 404 }); @@ -34,10 +48,12 @@ export async function PATCH( } // Check if reducing capacity below current scheduled count - const currentScheduled = db.prepare(` - SELECT COUNT(*) as count FROM scheduled_shifts - WHERE template_id = ? AND date >= date('now') - `).get(templateId) as { count: number }; + const [countRows] = await db.execute( + `SELECT COUNT(*) as count FROM scheduled_shifts + WHERE template_id = ? AND date >= CURDATE()`, + [templateId] + ); + const currentScheduled = (countRows as { count: number }[])[0]; if (capacity < currentScheduled.count) { return NextResponse.json( @@ -73,15 +89,17 @@ export async function PATCH( } values.push(templateId); - db.prepare(`UPDATE shift_templates SET ${updates.join(', ')} WHERE id = ?`).run(...values); - - const updated = db.prepare('SELECT * FROM shift_templates WHERE id = ?').get(templateId) as { - id: number; - market: string; - start_time: string; - end_time: string; - capacity: number; - }; + await db.execute( + `UPDATE shift_templates SET ${updates.join(', ')} WHERE id = ?`, + values + ); + + // Fetch updated template + const [updatedRows] = await db.execute( + 'SELECT * FROM shift_templates WHERE id = ?', + [templateId] + ); + const updated = (updatedRows as ShiftTemplate[])[0]; return NextResponse.json({ success: true, @@ -115,10 +133,12 @@ export async function DELETE( const db = getDb(); // Check if any future shifts are scheduled - const scheduled = db.prepare(` - SELECT COUNT(*) as count FROM scheduled_shifts - WHERE template_id = ? AND date >= date('now') - `).get(templateId) as { count: number }; + const [countRows] = await db.execute( + `SELECT COUNT(*) as count FROM scheduled_shifts + WHERE template_id = ? AND date >= CURDATE()`, + [templateId] + ); + const scheduled = (countRows as { count: number }[])[0]; if (scheduled.count > 0) { return NextResponse.json( @@ -127,7 +147,7 @@ export async function DELETE( ); } - db.prepare('DELETE FROM shift_templates WHERE id = ?').run(templateId); + await db.execute('DELETE FROM shift_templates WHERE id = ?', [templateId]); return NextResponse.json({ success: true, diff --git a/src/app/api/templates/route.ts b/src/app/api/templates/route.ts index 1d9d567..65d16a1 100644 --- a/src/app/api/templates/route.ts +++ b/src/app/api/templates/route.ts @@ -1,5 +1,22 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb, getShiftTemplates } from '@/lib/db'; +import type { ResultSetHeader } from 'mysql2'; + +interface ShiftTemplate { + id: number; + market: string; + start_time: string; + end_time: string; + capacity: number; +} + +interface CountResult { + count: number; +} + +interface MySQLError extends Error { + code?: string; +} // GET /api/templates - Get shift templates export async function GET(request: NextRequest) { @@ -7,13 +24,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const market = searchParams.get('market'); - const templates = await getShiftTemplates(market || undefined) as Array<{ - id: number; - market: string; - start_time: string; - end_time: string; - capacity: number; - }>; + const templates = await getShiftTemplates(market || undefined) as ShiftTemplate[]; // Convert to camelCase const formatted = templates.map(t => ({ @@ -26,7 +37,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ templates: formatted }); } catch (error) { - console.error('Error fetching templates:', error); + if (process.env.NODE_ENV !== 'production') { + console.error('Error fetching templates:', error); + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } @@ -62,12 +75,14 @@ export async function POST(request: NextRequest) { const [result] = await db.execute(` INSERT INTO \`shift_templates\` (market, start_time, end_time, capacity) VALUES (?, ?, ?, ?) - `, [market, startTime, endTime, cap]) as any; + `, [market, startTime, endTime, cap]); + + const insertResult = result as ResultSetHeader; return NextResponse.json({ success: true, template: { - id: result.insertId, + id: insertResult.insertId, market, startTime, endTime, @@ -75,13 +90,15 @@ export async function POST(request: NextRequest) { } }); } catch (error) { - if ((error as any).code === 'ER_DUP_ENTRY') { + if ((error as MySQLError).code === 'ER_DUP_ENTRY') { return NextResponse.json({ error: 'Template already exists for this market and time' }, { status: 400 }); } throw error; } } catch (error) { - console.error('Error creating template:', error); + if (process.env.NODE_ENV !== 'production') { + console.error('Error creating template:', error); + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } @@ -103,7 +120,7 @@ export async function DELETE(request: NextRequest) { "SELECT COUNT(*) as count FROM \`scheduled_shifts\` WHERE template_id = ? AND date >= CURDATE()", [templateId] ); - const activeShifts = (activeShiftsRows as any[])[0]; + const activeShifts = (activeShiftsRows as CountResult[])[0]; if (activeShifts.count > 0) { return NextResponse.json({ error: 'Cannot delete template with active future shifts.' }, { status: 409 }); @@ -113,22 +130,21 @@ export async function DELETE(request: NextRequest) { await db.execute('DELETE FROM \`capacity_overrides\` WHERE template_id = ?', [templateId]); // Delete the template - const [result] = await db.execute('DELETE FROM \`shift_templates\` WHERE id = ?', [templateId]) as any; + const [result] = await db.execute('DELETE FROM \`shift_templates\` WHERE id = ?', [templateId]); + const deleteResult = result as ResultSetHeader; - if (result.affectedRows === 0) { + if (deleteResult.affectedRows === 0) { return NextResponse.json({ error: 'Template not found' }, { status: 404 }); } return NextResponse.json({ success: true, message: 'Template deleted' }); } catch (error) { - const fs = require('fs'); - const logPath = process.cwd() + '/template_debug.log'; - fs.appendFileSync(logPath, `[ERROR] ${JSON.stringify(error, Object.getOwnPropertyNames(error))}\n`); - - if ((error as any).code === 'ER_ROW_IS_REFERENCED_2') { + if ((error as MySQLError).code === 'ER_ROW_IS_REFERENCED_2') { return NextResponse.json({ error: 'Cannot delete: Template is in use by past shifts.' }, { status: 409 }); } - console.error('Error deleting template:', error); - return NextResponse.json({ error: 'Internal server error: ' + (error as Error).message }, { status: 500 }); + if (process.env.NODE_ENV !== 'production') { + console.error('Error deleting template:', error); + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 37fe3dc..e5cdd89 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { Calendar, Clock, X, ChevronLeft, ChevronRight, LogOut } from 'lucide-react'; import type { Driver, Market, Settings, Shift, ScheduledShift } from '@/types'; -import { formatTime, checkTimeOverlap, timeToMinutes, generateDates, getWeekDates } from '@/lib/utils'; +import { formatTime, checkTimeOverlap, generateDates, getWeekDates } from '@/lib/utils'; export default function DriverDashboard() { const router = useRouter(); @@ -28,17 +28,12 @@ export default function DriverDashboard() { return settings.baseScheduleDays + (bonus[priority as keyof typeof bonus] || 0); }, [settings]); - useEffect(() => { - const driverId = sessionStorage.getItem('driverId'); - if (!driverId) { - router.push('/'); - return; - } - - fetchInitialData(parseInt(driverId)); - }, [router]); + const showNotification = useCallback((message: string, type: 'success' | 'error') => { + setNotification({ message, type }); + setTimeout(() => setNotification(null), 3000); + }, []); - const fetchInitialData = async (driverId: number) => { + const fetchInitialData = useCallback(async (driverId: number) => { try { const [driverRes, marketsRes, settingsRes] = await Promise.all([ fetch(`/api/drivers/${driverId}`), @@ -70,7 +65,17 @@ export default function DriverDashboard() { } finally { setLoading(false); } - }; + }, [router, showNotification]); + + useEffect(() => { + const driverId = sessionStorage.getItem('driverId'); + if (!driverId) { + router.push('/'); + return; + } + + fetchInitialData(parseInt(driverId)); + }, [router, fetchInitialData]); // Fetch available shifts when market or dates change useEffect(() => { @@ -78,7 +83,7 @@ export default function DriverDashboard() { const dates = generateDates(getSchedulingWindow(driver.priority)); fetchAvailableShifts(selectedMarket, dates); - }, [driver, selectedMarket, settings, generateDates, getSchedulingWindow]); + }, [driver, selectedMarket, settings, getSchedulingWindow]); const fetchAvailableShifts = async (market: string, dates: string[]) => { try { @@ -117,11 +122,6 @@ export default function DriverDashboard() { showNotification(`Switched to ${pendingMarket}`, 'success'); }; - const showNotification = (message: string, type: 'success' | 'error') => { - setNotification({ message, type }); - setTimeout(() => setNotification(null), 3000); - }; - const canClaimShift = (date: string, startTime: string, endTime: string): { allowed: boolean; reason?: string } => { // 1. Check Same Day Overlaps const dayShifts = myShifts.filter(s => s.date === date); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..b79d807 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Authentication configuration +const AUTH_HEADER = 'x-api-key'; +const ADMIN_HEADER = 'x-admin-key'; + +/** + * Validates API key authentication for protected routes + * Returns null if authenticated, or a NextResponse error if not + */ +export function validateApiKey(request: NextRequest): NextResponse | null { + // Skip auth in development if no API key is configured + const apiKey = process.env.API_KEY; + if (!apiKey) { + if (process.env.NODE_ENV === 'development') { + return null; // Allow in development without key + } + // In production, require API key to be set + console.error('[Auth] API_KEY environment variable not set'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + const providedKey = request.headers.get(AUTH_HEADER); + + if (!providedKey) { + return NextResponse.json( + { error: 'API key required. Provide it via x-api-key header.' }, + { status: 401 } + ); + } + + if (providedKey !== apiKey) { + return NextResponse.json( + { error: 'Invalid API key' }, + { status: 403 } + ); + } + + return null; // Authenticated +} + +/** + * Validates admin authentication for sensitive operations + * Requires both API key and admin key + */ +export function validateAdminKey(request: NextRequest): NextResponse | null { + // First validate basic API key + const apiKeyError = validateApiKey(request); + if (apiKeyError) { + return apiKeyError; + } + + const adminKey = process.env.ADMIN_KEY; + if (!adminKey) { + if (process.env.NODE_ENV === 'development') { + return null; // Allow in development without key + } + console.error('[Auth] ADMIN_KEY environment variable not set'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + const providedAdminKey = request.headers.get(ADMIN_HEADER); + + if (!providedAdminKey) { + return NextResponse.json( + { error: 'Admin key required for this operation' }, + { status: 401 } + ); + } + + if (providedAdminKey !== adminKey) { + return NextResponse.json( + { error: 'Invalid admin key' }, + { status: 403 } + ); + } + + return null; // Admin authenticated +} + +/** + * Helper to wrap route handlers with authentication + * Use this to protect individual routes + */ +export function withAuth( + handler: (request: NextRequest, ...args: T) => Promise, + requireAdmin = false +) { + return async (request: NextRequest, ...args: T): Promise => { + const authError = requireAdmin + ? validateAdminKey(request) + : validateApiKey(request); + + if (authError) { + return authError; + } + + return handler(request, ...args); + }; +} + +/** + * Check if request is from an authenticated source + * Useful for conditional logic without blocking + */ +export function isAuthenticated(request: NextRequest): boolean { + const apiKey = process.env.API_KEY; + if (!apiKey) { + return process.env.NODE_ENV === 'development'; + } + + const providedKey = request.headers.get(AUTH_HEADER); + return providedKey === apiKey; +} + +/** + * Check if request has admin privileges + */ +export function isAdmin(request: NextRequest): boolean { + if (!isAuthenticated(request)) { + return false; + } + + const adminKey = process.env.ADMIN_KEY; + if (!adminKey) { + return process.env.NODE_ENV === 'development'; + } + + const providedAdminKey = request.headers.get(ADMIN_HEADER); + return providedAdminKey === adminKey; +} diff --git a/src/lib/db-mysql.ts b/src/lib/db-mysql.ts index 8d219c3..b355b58 100644 --- a/src/lib/db-mysql.ts +++ b/src/lib/db-mysql.ts @@ -1,31 +1,169 @@ -import mysql from 'mysql2/promise'; +import mysql, { PoolOptions } from 'mysql2/promise'; +import fs from 'fs'; // Singleton connection pool let pool: mysql.Pool | null = null; +// Validate required environment variables +function validateEnvironment(): void { + const requiredVars = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']; + const missingVars = requiredVars.filter(varName => !process.env[varName]); + + if (missingVars.length > 0 && process.env.NODE_ENV === 'production') { + throw new Error( + `Missing required environment variables: ${missingVars.join(', ')}. ` + + 'Please set these in your environment or .env file.' + ); + } +} + +// Build SSL configuration for AWS RDS or other secure connections +function getSSLConfig(): PoolOptions['ssl'] | undefined { + const sslEnabled = process.env.DB_SSL === 'true'; + + if (!sslEnabled) { + return undefined; + } + + // If a custom CA certificate is provided, use it + if (process.env.DB_SSL_CA) { + return { + ca: fs.readFileSync(process.env.DB_SSL_CA), + rejectUnauthorized: true + }; + } + + // For AWS RDS, use the default SSL configuration + // mysql2 will use the system's CA certificates + return { + rejectUnauthorized: true + }; +} + +// Create connection pool with retry logic +async function createPoolWithRetry(maxRetries = 3, retryDelay = 2000): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const poolConfig: PoolOptions = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '3306'), + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'toc_schedule', + waitForConnections: true, + connectionLimit: parseInt(process.env.DB_CONNECTION_LIMIT || '10'), + queueLimit: 100, // Limit queue to prevent memory issues under high load + enableKeepAlive: true, + keepAliveInitialDelay: 10000, // 10 seconds + connectTimeout: 10000, // 10 second connection timeout + ssl: getSSLConfig() + }; + + const newPool = mysql.createPool(poolConfig); + + // Test the connection + const connection = await newPool.getConnection(); + connection.release(); + + if (process.env.NODE_ENV !== 'production') { + console.log('[DB] MySQL connection pool created successfully'); + } + + return newPool; + } catch (error) { + lastError = error as Error; + + if (process.env.NODE_ENV !== 'production') { + console.error(`[DB] Connection attempt ${attempt}/${maxRetries} failed:`, error); + } + + if (attempt < maxRetries) { + // Wait before retrying with exponential backoff + const delay = retryDelay * Math.pow(2, attempt - 1); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw new Error( + `Failed to connect to database after ${maxRetries} attempts. ` + + `Last error: ${lastError?.message}` + ); +} + export function getDb(): mysql.Pool { if (!pool) { - console.log('[DB] Initializing MySQL connection pool...'); - - pool = mysql.createPool({ + // Validate environment in production + validateEnvironment(); + + // Create pool synchronously for backward compatibility + // Note: First query may fail if connection isn't ready + const poolConfig: PoolOptions = { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '3306'), user: process.env.DB_USER || 'root', password: process.env.DB_PASSWORD || '', database: process.env.DB_NAME || 'toc_schedule', waitForConnections: true, - connectionLimit: 10, - queueLimit: 0, + connectionLimit: parseInt(process.env.DB_CONNECTION_LIMIT || '10'), + queueLimit: 100, enableKeepAlive: true, - keepAliveInitialDelay: 0 - }); - - console.log('[DB] MySQL connection pool created'); + keepAliveInitialDelay: 10000, + connectTimeout: 10000, + ssl: getSSLConfig() + }; + + pool = mysql.createPool(poolConfig); + + if (process.env.NODE_ENV !== 'production') { + console.log('[DB] MySQL connection pool initialized'); + } } return pool; } +// Initialize pool with retry (call this at app startup if needed) +export async function initializeDb(): Promise { + if (!pool) { + validateEnvironment(); + pool = await createPoolWithRetry(); + } + return pool; +} + +// Health check function for monitoring +export async function checkDbHealth(): Promise<{ healthy: boolean; latencyMs: number; error?: string }> { + const start = Date.now(); + try { + const db = getDb(); + await db.execute('SELECT 1'); + return { + healthy: true, + latencyMs: Date.now() - start + }; + } catch (error) { + return { + healthy: false, + latencyMs: Date.now() - start, + error: (error as Error).message + }; + } +} + +// Graceful shutdown +export async function closeDb(): Promise { + if (pool) { + await pool.end(); + pool = null; + if (process.env.NODE_ENV !== 'production') { + console.log('[DB] MySQL connection pool closed'); + } + } +} + // Helper functions for common queries export async function getMarkets(includeInactive = false) { @@ -35,11 +173,11 @@ export async function getMarkets(includeInactive = false) { : 'SELECT * FROM `count` WHERE status = 1 ORDER BY defaultcity'; const [rows] = await db.execute(query); - return (rows as any[]).map((m: any) => ({ - id: m.id, - name: m.defaultcity, - market: m.market, - active: m.status + return (rows as Record[]).map((m) => ({ + id: m.id as number, + name: m.defaultcity as string, + market: m.market as string, + active: Boolean(m.status) })); } @@ -67,14 +205,14 @@ export async function getDrivers() { 'SELECT * FROM `Drivers` ORDER BY Owner_lname, Owner_fname' ); - return (rows as any[]).map((d: any) => ({ + return (rows as Record[]).map((d) => ({ id: d.did, - name: d.displayName || `${d.Owner_fname} ${d.Owner_lname}`, + name: (d.displayName as string) || `${d.Owner_fname} ${d.Owner_lname}`, email: d.email, phone: d.phone, market: d.market, priority: d.schedule_priority, - blocked: d.status === 0 ? 1 : 0 + blocked: d.status === 0 })); } @@ -85,17 +223,17 @@ export async function getDriverById(id: number) { [id] ); - const d = (rows as any[])[0]; + const d = (rows as Record[])[0]; if (!d) return undefined; return { id: d.did, - name: d.displayName || `${d.Owner_fname} ${d.Owner_lname}`, + name: (d.displayName as string) || `${d.Owner_fname} ${d.Owner_lname}`, email: d.email, phone: d.phone, market: d.market, priority: d.schedule_priority, - blocked: d.status === 0 ? 1 : 0 + blocked: d.status === 0 }; } @@ -106,17 +244,17 @@ export async function getDriverByEmail(email: string) { [email] ); - const d = (rows as any[])[0]; + const d = (rows as Record[])[0]; if (!d) return undefined; return { id: d.did, - name: d.displayName || `${d.Owner_fname} ${d.Owner_lname}`, + name: (d.displayName as string) || `${d.Owner_fname} ${d.Owner_lname}`, email: d.email, phone: d.phone, market: d.market, priority: d.schedule_priority, - blocked: d.status === 0 ? 1 : 0 + blocked: d.status === 0 }; } @@ -137,7 +275,19 @@ export async function getShiftTemplates(market?: string) { return rows; } -export async function getScheduledShifts(options: { market?: string; date?: string; driverId?: number }) { +interface ScheduledShiftRow { + id: number; + driverId: number; + driverName: string; + templateId: number; + market: string; + date: Date | string; + startTime: string; + endTime: string; + createdAt: Date | string; +} + +export async function getScheduledShifts(options: { market?: string; date?: string; driverId?: number }): Promise & { date: string }>> { const db = getDb(); let query = ` @@ -177,11 +327,20 @@ export async function getScheduledShifts(options: { market?: string; date?: stri const [rows] = await db.execute(query, params); // Format dates to YYYY-MM-DD strings (MySQL returns Date objects) - return (rows as any[]).map((row: any) => ({ - ...row, + return (rows as ScheduledShiftRow[]).map((row) => ({ + id: row.id, + driverId: row.driverId, + driverName: row.driverName, + templateId: row.templateId, + market: row.market, date: row.date instanceof Date ? row.date.toISOString().split('T')[0] - : row.date + : row.date as string, + startTime: row.startTime, + endTime: row.endTime, + createdAt: row.createdAt instanceof Date + ? row.createdAt.toISOString() + : row.createdAt as string })); } @@ -190,7 +349,7 @@ export async function getScheduleSettings() { const [rows] = await db.execute( 'SELECT * FROM `schedule_settings` WHERE id = 1' ); - return (rows as any[])[0]; + return (rows as Record[])[0]; } export async function updateScheduleSettings(settings: { @@ -210,10 +369,10 @@ export async function updateScheduleSettings(settings: { slack_webhook_url = ? WHERE id = 1 `, [ - settings.baseScheduleDays ?? current.base_schedule_days, - settings.cancelHoursBefore ?? current.cancel_hours_before, - settings.showAvailableSpots !== undefined ? (settings.showAvailableSpots ? 1 : 0) : current.show_available_spots, - settings.slackWebhookUrl ?? current.slack_webhook_url + settings.baseScheduleDays ?? current?.base_schedule_days, + settings.cancelHoursBefore ?? current?.cancel_hours_before, + settings.showAvailableSpots !== undefined ? (settings.showAvailableSpots ? 1 : 0) : current?.show_available_spots, + settings.slackWebhookUrl ?? current?.slack_webhook_url ]); } @@ -231,7 +390,7 @@ export async function getCapacityForDate(templateId: number, date: string): Prom WHERE template_id = ? AND day_of_week = ? `, [templateId, dayOfWeek]); - const override = (overrideRows as any[])[0]; + const override = (overrideRows as { capacity: number }[])[0]; if (override) { return override.capacity; } @@ -242,7 +401,7 @@ export async function getCapacityForDate(templateId: number, date: string): Prom [templateId] ); - const template = (templateRows as any[])[0]; + const template = (templateRows as { capacity: number }[])[0]; return template?.capacity ?? 0; } diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..eeab8c7 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +// Routes that require authentication +const PROTECTED_ROUTES = [ + '/api/drivers', + '/api/markets', + '/api/shifts', + '/api/templates', + '/api/settings', + '/api/capacity-overrides', + '/api/schedules', +]; + +// Routes that require admin authentication +const ADMIN_ROUTES = [ + '/api/settings', +]; + +// Routes that are public (no auth required) +const PUBLIC_ROUTES = [ + '/api/slack/post-schedule', // Protected by CRON_SECRET instead +]; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Skip non-API routes + if (!pathname.startsWith('/api/')) { + return NextResponse.next(); + } + + // Skip public routes + if (PUBLIC_ROUTES.some(route => pathname.startsWith(route))) { + return NextResponse.next(); + } + + // Check if route is protected + const isProtected = PROTECTED_ROUTES.some(route => pathname.startsWith(route)); + if (!isProtected) { + return NextResponse.next(); + } + + // Get API key from environment + const apiKey = process.env.API_KEY; + + // In development without API_KEY set, allow all requests + if (!apiKey && process.env.NODE_ENV === 'development') { + return NextResponse.next(); + } + + // In production, require API_KEY to be set + if (!apiKey) { + console.error('[Middleware] API_KEY environment variable not set in production'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + // Validate API key + const providedKey = request.headers.get('x-api-key'); + if (!providedKey || providedKey !== apiKey) { + return NextResponse.json( + { error: 'Invalid or missing API key' }, + { status: 401 } + ); + } + + // Check admin routes + const isAdminRoute = ADMIN_ROUTES.some(route => pathname.startsWith(route)); + if (isAdminRoute) { + const adminKey = process.env.ADMIN_KEY; + + // In development without ADMIN_KEY set, allow all requests + if (!adminKey && process.env.NODE_ENV === 'development') { + return NextResponse.next(); + } + + if (!adminKey) { + console.error('[Middleware] ADMIN_KEY environment variable not set'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + const providedAdminKey = request.headers.get('x-admin-key'); + if (!providedAdminKey || providedAdminKey !== adminKey) { + return NextResponse.json( + { error: 'Admin access required' }, + { status: 403 } + ); + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: '/api/:path*', +}; diff --git a/src/types/index.ts b/src/types/index.ts index f804f49..656dd20 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,20 +8,19 @@ export interface Driver { market: string; priority: 1 | 2 | 3 | 4 | 5; blocked: boolean; - createdAt: string; + createdAt?: string; } export interface Market { id: number; name: string; market: string; // 3-letter code - active: number; + active: boolean; } export interface ShiftTemplate { id: number; - marketId: number; - market?: string; // Joined from markets table + market: string; // Market code from the database startTime: string; // "HH:MM" format endTime: string; // "HH:MM" format capacity: number; @@ -113,3 +112,47 @@ export interface User { market?: string; priority?: 1 | 2 | 3 | 4 | 5; } + +// Database row types (for internal use) +export interface DbDriver { + did: number; + displayName?: string; + Owner_fname: string; + Owner_lname: string; + email: string; + phone?: string; + market: string; + schedule_priority: 1 | 2 | 3 | 4 | 5; + status: number; // 1 = active, 0 = blocked +} + +export interface DbMarket { + id: number; + market: string; + defaultcity: string; + status: number; // 1 = active, 0 = inactive +} + +export interface DbShiftTemplate { + id: number; + market: string; + start_time: string; + end_time: string; + capacity: number; +} + +export interface DbScheduleSettings { + id: number; + base_schedule_days: number; + cancel_hours_before: number; + show_available_spots: number; // 1 or 0 + slack_webhook_url?: string; +} + +// MySQL error type +export interface MySQLError extends Error { + code?: string; + errno?: number; + sqlState?: string; + sqlMessage?: string; +}