diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..716f70a --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# Resilient installs for CI/Docker (transient ECONNRESET from registry) +fetch-retries=5 +fetch-retry-mintimeout=20000 +fetch-retry-maxtimeout=120000 +fetch-timeout=300000 diff --git a/Dockerfile b/Dockerfile index e15e0e8..5287885 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,31 @@ -FROM node:20-alpine +FROM node:22-alpine WORKDIR /app -# Copy package files -COPY package*.json prisma.config.ts ./ +# Prisma validate/generate needs DATABASE_URL before .env is copied; no DB connection occurs during generate +ARG DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres +ENV DATABASE_URL=$DATABASE_URL + +# Copy package files (.npmrc improves registry resilience during docker build) +COPY package*.json prisma.config.ts .npmrc ./ COPY prisma ./prisma # Install ALL dependencies (including devDependencies needed for build) -RUN npm ci && npx prisma generate +# Retry full npm ci on transient network errors (e.g. ECONNRESET) +RUN set -e; \ + attempt=1; max=5; \ + until npm ci; do \ + attempt=$((attempt + 1)); \ + if [ "$attempt" -gt "$max" ]; then echo "npm ci failed after $max attempts"; exit 1; fi; \ + echo "npm ci failed (attempt $((attempt - 1))/$max), retrying in 15s..."; \ + sleep 15; \ + done && npx prisma generate # Copy application code (includes .env created in CI from EC2_ENV for NEXT_PUBLIC_* at build time) COPY . . +# Regenerate client after full tree copy (schema may differ from the early prisma/ layer). +RUN npx prisma generate # Build the Next.js application (reads .env and embeds NEXT_PUBLIC_* into the bundle) RUN npm run build diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0a1ad94..f4f84a9 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -18,6 +18,9 @@ done echo "Syncing database schema with Prisma (db push)..." npx prisma db push --accept-data-loss +echo "Regenerating Prisma Client..." +npx prisma generate + if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then echo "Seeding predefined email templates..." # Run seed script; if it fails, log the error but continue starting the app diff --git a/next.config.ts b/next.config.ts index aa21032..58daf53 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,16 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + // Keep Prisma + pg as Node externals so Turbopack does not bundle the + // generated client (fixes "Cannot read properties of undefined (reading 'graph')" + // during `next build` in some Docker/Linux environments). + serverExternalPackages: [ + "@prisma/client", + "prisma", + "@prisma/adapter-pg", + "@prisma/driver-adapter-utils", + "pg", + ], images: { remotePatterns: [ { diff --git a/package-lock.json b/package-lock.json index c08378a..ae5db40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-bedrock-runtime": "^3.989.0", "@aws-sdk/client-s3": "^3.989.0", - "@prisma/adapter-pg": "^7.3.0", - "@prisma/client": "^7.3.0", + "@prisma/adapter-pg": "^7.6.0", + "@prisma/client": "^7.6.0", "@tailwindcss/postcss": "^4", "@types/nodemailer": "^7.0.9", "@types/qrcode": "^1.5.6", @@ -29,13 +29,13 @@ "lenis": "^1.3.17", "lucide-react": "^0.563.0", "mammoth": "^1.11.0", - "next": "16.1.6", + "next": "^16.2.1", "next-auth": "^5.0.0-beta.30", "nodemailer": "^7.0.7", "otplib": "^13.2.1", "pdf-parse": "^2.4.5", "pg": "^8.18.0", - "prisma": "^7.3.0", + "prisma": "^7.6.0", "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3", @@ -1373,12 +1373,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", - "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", "dependencies": { - "@smithy/types": "^4.12.0", - "fast-xml-parser": "5.3.4", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, "engines": { @@ -1617,57 +1617,47 @@ "node": ">=6.9.0" } }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", - "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "node_modules/@clack/core": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", + "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", "dependencies": { - "@chevrotain/gast": "10.5.0", - "@chevrotain/types": "10.5.0", - "lodash": "4.17.21" + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" } }, - "node_modules/@chevrotain/gast": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", - "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "node_modules/@clack/prompts": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz", + "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", "dependencies": { - "@chevrotain/types": "10.5.0", - "lodash": "4.17.21" + "@clack/core": "0.5.0", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" } }, - "node_modules/@chevrotain/types": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", - "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==" - }, - "node_modules/@chevrotain/utils": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", - "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==" - }, "node_modules/@electric-sql/pglite": { - "version": "0.3.15", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", - "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==" }, "node_modules/@electric-sql/pglite-socket": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", - "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", "bin": { "pglite-server": "dist/scripts/server.js" }, "peerDependencies": { - "@electric-sql/pglite": "0.3.15" + "@electric-sql/pglite": "0.4.1" } }, "node_modules/@electric-sql/pglite-tools": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", - "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", "peerDependencies": { - "@electric-sql/pglite": "0.3.15" + "@electric-sql/pglite": "0.4.1" } }, "node_modules/@emnapi/core": { @@ -2249,9 +2239,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "engines": { "node": ">=18.14.1" }, @@ -2788,17 +2778,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mrleebo/prisma-ast": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", - "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", - "dependencies": { - "chevrotain": "^10.5.0", - "lilconfig": "^2.1.0" - }, - "engines": { - "node": ">=16" - } + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" }, "node_modules/@napi-rs/canvas": { "version": "0.1.80", @@ -2982,9 +2965,9 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==" + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", + "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==" }, "node_modules/@next/eslint-plugin-next": { "version": "16.1.6", @@ -2996,9 +2979,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", + "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", "cpu": [ "arm64" ], @@ -3011,9 +2994,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", + "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", "cpu": [ "x64" ], @@ -3026,9 +3009,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", + "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", "cpu": [ "arm64" ], @@ -3041,9 +3024,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", + "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", "cpu": [ "arm64" ], @@ -3056,9 +3039,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", + "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", "cpu": [ "x64" ], @@ -3071,9 +3054,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", + "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", "cpu": [ "x64" ], @@ -3086,9 +3069,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", + "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", "cpu": [ "arm64" ], @@ -3101,9 +3084,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", + "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", "cpu": [ "x64" ], @@ -3229,21 +3212,22 @@ } }, "node_modules/@prisma/adapter-pg": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.0.tgz", - "integrity": "sha512-LWwTHaio0bMxvzahmpwpWqsZM0vTfMqwF8zo06YvILL/o47voaSfKzCVxZw/o9awf4fRgS5Vgthobikj9Dusaw==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.6.0.tgz", + "integrity": "sha512-BjHNmJqqa42NqJSDPnXUfwUofWo8LJY7Ui2gqxN4DmAOb+H/gGKv+hln2Xq/1kSJXPW5AXMXuNiPDMpywvyIOw==", "dependencies": { - "@prisma/driver-adapter-utils": "7.4.0", + "@prisma/driver-adapter-utils": "7.6.0", + "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "node_modules/@prisma/client": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.0.tgz", - "integrity": "sha512-Sc+ncr7+ph1hMf1LQfn6UyEXDEamCd5pXMsx8Q3SBH0NGX+zjqs3eaABt9hXwbcK9l7f8UyK8ldxOWA2LyPynQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.6.0.tgz", + "integrity": "sha512-7Pe/1ayh3GgWPEg4mmT4ax77LJ1wC+XlnIFvQ94bLP2DsUnOpnruQQR3Jw7r+Frthk94QqDNxo3FjSg8h9PXeQ==", "dependencies": { - "@prisma/client-runtime-utils": "7.4.0" + "@prisma/client-runtime-utils": "7.6.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0" @@ -3262,41 +3246,41 @@ } }, "node_modules/@prisma/client-runtime-utils": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.4.0.tgz", - "integrity": "sha512-jTmWAOBGBSCT8n7SMbpjCpHjELgcDW9GNP/CeK6CeqjUFlEL6dn8Cl81t/NBDjJdXDm85XDJmc+PEQqqQee3xw==" + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.6.0.tgz", + "integrity": "sha512-fD7jlqubsZvVODKvsp9lOpXVecx2aWGxC2l35Ioz2t+teUJ5CfR0SAMsi7UkU1VvaZmmm+DS6BdujF622nY7tQ==" }, "node_modules/@prisma/config": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.0.tgz", - "integrity": "sha512-EnNrZMwZ9+O6UlG+YO9SP3VhVw4zwMahDRzQm3r0DQn9KeU5NwzmaDAY+BzACrgmaU71Id1/0FtWIDdl7xQp9g==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.6.0.tgz", + "integrity": "sha512-MuAz1MK4PeG5/03YzfzX3CnFVHQ6qePGwUpQRzPzX5tT0ffJ3Tzi9zJZbBc+VzEGFCM8ghW/gTVDR85Syjt+Yw==", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.18.4", + "effect": "3.20.0", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.0.tgz", - "integrity": "sha512-fZicwzgFHvvPMrRLCUinrsBTdadJsi/1oirzShjmFvNLwtu2DYlkxwRVy5zEGhp85mrEGnLeS/PdNRCdE027+Q==" + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.6.0.tgz", + "integrity": "sha512-LpHr3qos4lQZ6sxwjStf59YBht7m9/QF7NSQsMH6qGENWZu2w3UkQUGn1h5iRkDjnWRj3VHykOu9qFhps4ADvA==" }, "node_modules/@prisma/dev": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", - "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", "dependencies": { - "@electric-sql/pglite": "0.3.15", - "@electric-sql/pglite-socket": "0.0.20", - "@electric-sql/pglite-tools": "0.2.20", - "@hono/node-server": "1.19.9", - "@mrleebo/prisma-ast": "0.13.1", + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", "foreground-child": "3.3.1", "get-port-please": "3.2.0", - "hono": "4.11.4", + "hono": "^4.12.8", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", @@ -3307,54 +3291,54 @@ } }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.0.tgz", - "integrity": "sha512-jEyE5LkqZ27Ba/DIOfCGOQl6nKMLxuwJNRceCfh7/LRs46UkIKn3bmkI97MEH2t7zkYV3PGBrUr+6sMJaHvc0A==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.6.0.tgz", + "integrity": "sha512-D8j3p0RnhLuufMaRLX6QqtGgPC5Ao3l5oFP6Q5AL0rTHi4vna+NzGEipwCsfvcSvaGFCbsH3lsTMbb4WvY+ovA==", "dependencies": { - "@prisma/debug": "7.4.0" + "@prisma/debug": "7.6.0" } }, "node_modules/@prisma/engines": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.0.tgz", - "integrity": "sha512-H+dgpbbY3VN/j5hOSVP1LXsv/rU0w/4C2zh5PZUwo/Q3NqZjOvBlVvkhtziioRmeEZ3SBAqPCsf1sQ74sI3O/w==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.6.0.tgz", + "integrity": "sha512-Sn5edRzhHqgRV2M+A0eIbY442B4mReWWf3pKs/LKreYgW7oa/up8JtK/s4iv/EQA097cyboZ08mmkpbLp+tZ3w==", "hasInstallScript": true, "dependencies": { - "@prisma/debug": "7.4.0", - "@prisma/engines-version": "7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57", - "@prisma/fetch-engine": "7.4.0", - "@prisma/get-platform": "7.4.0" + "@prisma/debug": "7.6.0", + "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "@prisma/fetch-engine": "7.6.0", + "@prisma/get-platform": "7.6.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57.tgz", - "integrity": "sha512-5o3/bubIYdUeg38cyNf+VDq+LVtxvvi2393Fd1Uru52LPfkGJnmVbCaX1wBOAncgKR3BCloMJFD+Koog9LtYqQ==" + "version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711.tgz", + "integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.0.tgz", - "integrity": "sha512-fOUIoGzAPgtjHVs4DsVSnEDPBEauAmFeZr4Ej3tMwxywam7hHdRtCzgKagQBKcYIJuya8gzYrTqUoukzXtWJaA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.6.0.tgz", + "integrity": "sha512-ohZDwXvtmnbzOcutR2D13lDWpZP1wQjmPyztmt0AwXLzQI7q95EE7NYCvS+M6N6SivT+BM0NOqLmTH3wms4L3A==", "dependencies": { - "@prisma/debug": "7.4.0" + "@prisma/debug": "7.6.0" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.0.tgz", - "integrity": "sha512-IXPOYskT89UTVsntuSnMTiKRWCuTg5JMWflgEDV1OSKFpuhwP5vqbfF01/iwo9y6rCjR0sDIO+jdV5kq38/hgA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.6.0.tgz", + "integrity": "sha512-N575Ni95c3FkduWY/eKTHqNYgNbceZ1tQaSknVtJjpKmiiBXmniESn/GTxsDvICC4ZeiNrXxioGInzQrCdx16w==", "dependencies": { - "@prisma/debug": "7.4.0", - "@prisma/engines-version": "7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57", - "@prisma/get-platform": "7.4.0" + "@prisma/debug": "7.6.0", + "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "@prisma/get-platform": "7.6.0" } }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.0.tgz", - "integrity": "sha512-fOUIoGzAPgtjHVs4DsVSnEDPBEauAmFeZr4Ej3tMwxywam7hHdRtCzgKagQBKcYIJuya8gzYrTqUoukzXtWJaA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.6.0.tgz", + "integrity": "sha512-ohZDwXvtmnbzOcutR2D13lDWpZP1wQjmPyztmt0AwXLzQI7q95EE7NYCvS+M6N6SivT+BM0NOqLmTH3wms4L3A==", "dependencies": { - "@prisma/debug": "7.4.0" + "@prisma/debug": "7.6.0" } }, "node_modules/@prisma/get-platform": { @@ -3375,16 +3359,190 @@ "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==" }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/@prisma/studio-core": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", - "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -3888,9 +4046,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", "dependencies": { "tslib": "^2.6.2" }, @@ -4484,7 +4642,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", - "dev": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -4511,7 +4668,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4719,12 +4876,12 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5103,9 +5260,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", @@ -5491,15 +5648,26 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/better-result": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.7.0.tgz", + "integrity": "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA==", + "dependencies": { + "@clack/prompts": "^0.11.0" + }, + "bin": { + "better-result": "bin/cli.mjs" + } + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" }, "node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==" + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==" }, "node_modules/bowser": { "version": "2.14.1", @@ -5715,17 +5883,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chevrotain": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", - "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "dependencies": { - "@chevrotain/cst-dts-gen": "10.5.0", - "@chevrotain/gast": "10.5.0", - "@chevrotain/types": "10.5.0", - "@chevrotain/utils": "10.5.0", - "lodash": "4.17.21", - "regexp-to-ast": "0.5.0" + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" } }, "node_modules/chokidar": { @@ -6178,9 +6344,9 @@ } }, "node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" @@ -6218,6 +6384,17 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -6896,8 +7073,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.1", @@ -6939,10 +7115,25 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", @@ -6950,7 +7141,23 @@ } ], "dependencies": { - "strnum": "^2.1.0" + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -7019,9 +7226,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "node_modules/for-each": { @@ -7318,9 +7525,9 @@ "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==" }, "node_modules/graphmatch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.0.tgz", - "integrity": "sha512-0E62MaTW5rPZVRLyIJZG/YejmdA/Xr1QydHEw3Vt+qOKkMIOE8WDLc9ZX2bmAjtJFZcId4lEdrdmASsEy7D1QA==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==" }, "node_modules/has-bigints": { "version": "1.1.0", @@ -7421,9 +7628,9 @@ } }, "node_modules/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "engines": { "node": ">=16.9.0" } @@ -7453,21 +7660,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", @@ -8427,14 +8619,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "engines": { - "node": ">=10" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8450,11 +8634,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8602,9 +8781,9 @@ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -8658,6 +8837,21 @@ "node": ">= 8.0" } }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/named-placeholders": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", @@ -8708,13 +8902,13 @@ "dev": true }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", + "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.2.1", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -8726,15 +8920,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.1", + "@next/swc-darwin-x64": "16.2.1", + "@next/swc-linux-arm64-gnu": "16.2.1", + "@next/swc-linux-arm64-musl": "16.2.1", + "@next/swc-linux-x64-gnu": "16.2.1", + "@next/swc-linux-x64-musl": "16.2.1", + "@next/swc-win32-arm64-msvc": "16.2.1", + "@next/swc-win32-x64-msvc": "16.2.1", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -9131,6 +9325,20 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -9288,9 +9496,9 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "engines": { "node": ">=8.6" @@ -9426,15 +9634,15 @@ } }, "node_modules/prisma": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.0.tgz", - "integrity": "sha512-n2xU9vSaH4uxZF/l2aKoGYtKtC7BL936jM9Q94Syk1zOD39t/5hjDUxMgaPkVRDX5wWEMsIqvzQxoebNIesOKw==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.6.0.tgz", + "integrity": "sha512-OKJIPT81K3+F+AayIkY/Y3mkF2NWoFh7lZApaaqPYy7EHILKdO0VsmGkP+hDKYTySHsFSyLWXm/JgcR1B8fY1Q==", "hasInstallScript": true, "dependencies": { - "@prisma/config": "7.4.0", - "@prisma/dev": "0.20.0", - "@prisma/engines": "7.4.0", - "@prisma/studio-core": "0.13.1", + "@prisma/config": "7.6.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.6.0", + "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" }, @@ -9727,11 +9935,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexp-to-ast": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", - "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -9768,6 +9971,14 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -10155,6 +10366,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -10374,9 +10590,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", @@ -10462,9 +10678,9 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "engines": { "node": ">=18" } @@ -10503,9 +10719,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "engines": { "node": ">=12" @@ -10732,9 +10948,9 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==" }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==" + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==" }, "node_modules/undici-types": { "version": "6.21.0", diff --git a/package.json b/package.json index 90b729c..78b436f 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,16 @@ "re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts", "db:reset": "prisma migrate reset", "db:studio": "prisma studio", - "db:fix-migration": "tsx scripts/fix-failed-migration.ts" + "db:fix-migration": "tsx scripts/fix-failed-migration.ts", + "db:role-audit": "tsx scripts/audit-role-consistency.ts" }, "dependencies": { "@auth/core": "^0.41.0", "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-bedrock-runtime": "^3.989.0", "@aws-sdk/client-s3": "^3.989.0", - "@prisma/adapter-pg": "^7.3.0", - "@prisma/client": "^7.3.0", + "@prisma/adapter-pg": "^7.6.0", + "@prisma/client": "^7.6.0", "@tailwindcss/postcss": "^4", "@types/nodemailer": "^7.0.9", "@types/qrcode": "^1.5.6", @@ -34,13 +35,13 @@ "lenis": "^1.3.17", "lucide-react": "^0.563.0", "mammoth": "^1.11.0", - "next": "16.1.6", + "next": "^16.2.1", "next-auth": "^5.0.0-beta.30", "nodemailer": "^7.0.7", "otplib": "^13.2.1", "pdf-parse": "^2.4.5", "pg": "^8.18.0", - "prisma": "^7.3.0", + "prisma": "^7.6.0", "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/scripts/audit-role-consistency.ts b/scripts/audit-role-consistency.ts new file mode 100644 index 0000000..999801e --- /dev/null +++ b/scripts/audit-role-consistency.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env tsx +import "dotenv/config" +import { prisma } from "../src/lib/prisma" + +async function main() { + const shouldApply = process.argv.includes("--apply") + const mode = shouldApply ? "APPLY" : "DRY_RUN" + + console.log(`[ROLE_AUDIT] Starting role consistency audit in ${mode} mode`) + + const mismatches = await prisma.user.findMany({ + where: { + role: { + not: "SUPPLIER", + }, + supplierProfile: { + isNot: null, + }, + }, + select: { + id: true, + email: true, + role: true, + supplierProfile: { + select: { + id: true, + companyName: true, + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }) + + if (mismatches.length === 0) { + console.log("[ROLE_AUDIT] No mismatches found.") + return + } + + console.log(`[ROLE_AUDIT] Found ${mismatches.length} mismatched users:`) + for (const user of mismatches) { + console.log( + ` - userId=${user.id} email=${user.email ?? "N/A"} currentRole=${user.role} supplierProfileId=${user.supplierProfile?.id ?? "N/A"} company=${user.supplierProfile?.companyName ?? "N/A"}` + ) + } + + if (!shouldApply) { + console.log( + '[ROLE_AUDIT] Dry run only. Re-run with "--apply" to update these users to role=SUPPLIER.' + ) + return + } + + type MismatchUser = (typeof mismatches)[number] + const result = await prisma.user.updateMany({ + where: { + id: { + in: mismatches.map((u: MismatchUser) => u.id), + }, + }, + data: { + role: "SUPPLIER", + }, + }) + + console.log(`[ROLE_AUDIT] Updated ${result.count} user role(s) to SUPPLIER.`) +} + +main() + .catch((error) => { + console.error("[ROLE_AUDIT] Failed:", error) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/scripts/fix-domains.ts b/scripts/fix-domains.ts index 0f765a4..2de3e4a 100644 --- a/scripts/fix-domains.ts +++ b/scripts/fix-domains.ts @@ -6,16 +6,13 @@ */ import "dotenv/config" -import pg from "pg" import { PrismaPg } from "@prisma/adapter-pg" import { PrismaClient } from "@prisma/client" -import { PrismaClientOptions } from "@prisma/client/runtime/client" -// Use the same setup as the main app -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }) -const adapter = new PrismaPg(pool) -const prisma = new PrismaClient({ - adapter: adapter as unknown as PrismaClientOptions["adapter"] +const prisma = new PrismaClient({ + adapter: new PrismaPg({ + connectionString: process.env.DATABASE_URL!, + }), }) async function fixStoreDomains() { @@ -52,7 +49,6 @@ async function fixStoreDomains() { throw error } finally { await prisma.$disconnect() - await pool.end() } } diff --git a/scripts/re-embed-knowledge-base.ts b/scripts/re-embed-knowledge-base.ts index 5039af7..0b06f0b 100644 --- a/scripts/re-embed-knowledge-base.ts +++ b/scripts/re-embed-knowledge-base.ts @@ -58,7 +58,7 @@ async function reEmbedKnowledgeBase() { try { // Generate embeddings for this batch - const texts = batch.map((chunk) => chunk.content) + const texts = batch.map((chunk: KnowledgeChunk) => chunk.content) const embeddings = await embedBatch(texts) if (embeddings.length !== batch.length) { @@ -70,7 +70,7 @@ async function reEmbedKnowledgeBase() { } // Update each chunk with its new embedding - const updatePromises = batch.map(async (chunk, index) => { + const updatePromises = batch.map(async (chunk: KnowledgeChunk, index: number) => { const embedding = embeddings[index] if (!embedding || embedding.length === 0) { diff --git a/src/app/admin/events/page.tsx b/src/app/admin/events/page.tsx index 1f11806..a0d6d87 100644 --- a/src/app/admin/events/page.tsx +++ b/src/app/admin/events/page.tsx @@ -36,8 +36,12 @@ async function getEvents(type?: AdminEventType): Promise { }), ]) + type ConnectionEventRow = (typeof connectionEvents)[number] + type ProductSyncEventRow = (typeof productSyncEvents)[number] + type SyncHealthEventRow = (typeof syncHealthEvents)[number] + const events: AdminEvent[] = [ - ...connectionEvents.map((c) => ({ + ...connectionEvents.map((c: ConnectionEventRow) => ({ id: c.id, type: "CONNECTION" as AdminEventType, status: c.status, @@ -46,7 +50,7 @@ async function getEvents(type?: AdminEventType): Promise { supplierName: c.supplier.companyName, metadata: {}, })), - ...productSyncEvents.map((p) => ({ + ...productSyncEvents.map((p: ProductSyncEventRow) => ({ id: p.id, type: "PRODUCT_SYNC" as AdminEventType, status: p.status, @@ -55,7 +59,7 @@ async function getEvents(type?: AdminEventType): Promise { supplierName: p.supplier.companyName, metadata: { productTitle: p.shopifyProductTitle, shopifyProductId: p.shopifyProductId }, })), - ...syncHealthEvents.map((s) => ({ + ...syncHealthEvents.map((s: SyncHealthEventRow) => ({ id: s.id, type: "SYNC_HEALTH" as AdminEventType, status: s.syncStatus, diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx index 39007c0..7d7ae2a 100644 --- a/src/app/admin/orders/page.tsx +++ b/src/app/admin/orders/page.tsx @@ -25,8 +25,9 @@ async function getOrders(): Promise { }) if (acceptedSyncs.length === 0) return [] + type AcceptedSyncPick = (typeof acceptedSyncs)[number] const syncsByStore = new Map>() - acceptedSyncs.forEach((sync) => { + acceptedSyncs.forEach((sync: AcceptedSyncPick) => { if (!syncsByStore.has(sync.storeId)) syncsByStore.set(sync.storeId, new Set()) syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId) }) @@ -40,7 +41,9 @@ async function getOrders(): Promise { orderBy: { shopifyCreatedAt: "desc" }, }) - const relevantOrders = cachedOrders.filter((order) => { + type CachedOrderRow = (typeof cachedOrders)[number] + + const relevantOrders = cachedOrders.filter((order: CachedOrderRow) => { const syncedProductIds = syncsByStore.get(order.storeId) if (!syncedProductIds?.size) return false try { @@ -56,7 +59,7 @@ async function getOrders(): Promise { } }) - return relevantOrders.map((order) => { + return relevantOrders.map((order: CachedOrderRow) => { const store = order.store as { id: string; businessName: string; shopifyStoreName: string } return { id: order.shopifyOrderId, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index bd01df6..baf095c 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -72,17 +72,14 @@ export async function POST(req: Request) { } // Regular user login flow - const userResult = await prisma.$queryRawUnsafe< - Array<{ - id: string - password: string | null - mfaEnabled: boolean | null - mfaSecret: string | null - }> - >( - `SELECT id, password, "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`, - email, - ) + const userResult = await prisma.$queryRaw` + SELECT id, password, "mfaEnabled", "mfaSecret" FROM "User" WHERE email = ${email} + ` as Array<{ + id: string + password: string | null + mfaEnabled: boolean | null + mfaSecret: string | null + }> if (!userResult || userResult.length === 0) { return NextResponse.json( @@ -125,7 +122,7 @@ export async function POST(req: Request) { } // Verify MFA code - const mfaSecret = decryptString(user.mfaSecret) + const mfaSecret = decryptString(user.mfaSecret) if (!mfaSecret) { return NextResponse.json( { message: "MFA is enabled but no secret found. Please contact support." }, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index cdf4ba4..12666be 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -44,7 +44,7 @@ export async function POST(req: Request) { name, email, password: hashedPassword, - role: role || "VENDOR" + role } }) diff --git a/src/app/api/auth/supplier/[...nextauth]/route.ts b/src/app/api/auth/supplier/[...nextauth]/route.ts new file mode 100644 index 0000000..f822190 --- /dev/null +++ b/src/app/api/auth/supplier/[...nextauth]/route.ts @@ -0,0 +1,4 @@ +import { handlers } from "@/auth.supplier" + +export const { GET, POST } = handlers + diff --git a/src/app/api/auth/vendor/[...nextauth]/route.ts b/src/app/api/auth/vendor/[...nextauth]/route.ts new file mode 100644 index 0000000..a7df155 --- /dev/null +++ b/src/app/api/auth/vendor/[...nextauth]/route.ts @@ -0,0 +1,4 @@ +import { handlers } from "@/auth.vendor" + +export const { GET, POST } = handlers + diff --git a/src/app/api/auth/verify-mfa/route.ts b/src/app/api/auth/verify-mfa/route.ts index 1648b60..c815b3f 100644 --- a/src/app/api/auth/verify-mfa/route.ts +++ b/src/app/api/auth/verify-mfa/route.ts @@ -25,10 +25,9 @@ export async function POST(req: Request) { } // Use raw query to access MFA fields until Prisma client is fully regenerated - const userResult = await prisma.$queryRawUnsafe>( - `SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`, - email - ) + const userResult = await prisma.$queryRaw< + Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }> + >`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE email = ${email}` if (!userResult || userResult.length === 0) { return NextResponse.json( diff --git a/src/app/api/chat/attach-orders/route.ts b/src/app/api/chat/attach-orders/route.ts index 99f055f..77b0c58 100644 --- a/src/app/api/chat/attach-orders/route.ts +++ b/src/app/api/chat/attach-orders/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" import { getCachedOrders, isCacheFresh, updateOrderCache } from "@/lib/cache" @@ -13,7 +14,7 @@ const PER_STORE_LIMIT = 25 export async function GET() { try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } @@ -31,6 +32,8 @@ export async function GET() { return NextResponse.json({ orders: [] }) } + type StoreRow = (typeof stores)[number] + type OrderItem = { id: number name: string @@ -50,7 +53,8 @@ export async function GET() { const cacheFresh = await isCacheFresh(store.id, "orders") if (cacheFresh) { const cached = await getCachedOrders(store.id) - return cached.slice(0, PER_STORE_LIMIT).map((order) => ({ + type CachedOrderRow = (typeof cached)[number] + return cached.slice(0, PER_STORE_LIMIT).map((order: CachedOrderRow) => ({ id: parseInt(order.shopifyOrderId), name: order.orderNumber, total_price: order.totalPrice, @@ -93,8 +97,12 @@ export async function GET() { } } - const results = await Promise.allSettled(stores.map((s) => fetchOrdersForStore(s))) - const allOrders: OrderItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])) + const results = await Promise.allSettled( + stores.map((s: StoreRow) => fetchOrdersForStore(s)) + ) + const allOrders: OrderItem[] = results.flatMap((r: PromiseSettledResult) => + r.status === "fulfilled" ? r.value : [] + ) allOrders.sort((a, b) => (b.id > a.id ? 1 : -1)) return NextResponse.json({ orders: allOrders.slice(0, 50) }) } catch (error) { diff --git a/src/app/api/chat/attach-products/route.ts b/src/app/api/chat/attach-products/route.ts index fe8e73d..cea7f4d 100644 --- a/src/app/api/chat/attach-products/route.ts +++ b/src/app/api/chat/attach-products/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" import { getCachedProducts, isCacheFresh, updateProductCache } from "@/lib/cache" @@ -13,7 +14,7 @@ const PER_STORE_LIMIT = 25 export async function GET() { try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } @@ -31,6 +32,8 @@ export async function GET() { return NextResponse.json({ products: [] }) } + type StoreRow = (typeof stores)[number] + type ProductItem = { id: number; title: string; storeId: string; storeName: string } const fetchProductsForStore = async ( @@ -43,7 +46,8 @@ export async function GET() { const cacheFresh = await isCacheFresh(store.id, "products") if (cacheFresh) { const cached = await getCachedProducts(store.id) - return cached.slice(0, PER_STORE_LIMIT).map((product) => ({ + type CachedProductRow = (typeof cached)[number] + return cached.slice(0, PER_STORE_LIMIT).map((product: CachedProductRow) => ({ id: parseInt(product.shopifyProductId), title: product.title || "Product", storeId: store.id, @@ -82,8 +86,12 @@ export async function GET() { } } - const results = await Promise.allSettled(stores.map((s) => fetchProductsForStore(s))) - const allProducts: ProductItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])) + const results = await Promise.allSettled( + stores.map((s: StoreRow) => fetchProductsForStore(s)) + ) + const allProducts: ProductItem[] = results.flatMap((r: PromiseSettledResult) => + r.status === "fulfilled" ? r.value : [] + ) return NextResponse.json({ products: allProducts.slice(0, 50) }) } catch (error) { console.error("Chat attach products error:", error) diff --git a/src/app/api/chat/contacts/route.ts b/src/app/api/chat/contacts/route.ts index 7012792..80e0ec5 100644 --- a/src/app/api/chat/contacts/route.ts +++ b/src/app/api/chat/contacts/route.ts @@ -53,7 +53,8 @@ export async function GET() { } } }) - contacts = connections.map(c => ({ + type VendorSideConnection = (typeof connections)[number] + contacts = connections.map((c: VendorSideConnection) => ({ id: c.supplier.user.id, name: c.supplier.companyName || c.supplier.user.name, email: c.supplier.user.email, @@ -84,7 +85,8 @@ export async function GET() { } } }) - contacts = connections.map(c => ({ + type SupplierSideConnection = (typeof connections)[number] + contacts = connections.map((c: SupplierSideConnection) => ({ id: c.store.user.id, name: c.store.businessName || c.store.user.name, email: c.store.user.email, diff --git a/src/app/api/integrations/gmail/callback/route.ts b/src/app/api/integrations/gmail/callback/route.ts index d073192..04df548 100644 --- a/src/app/api/integrations/gmail/callback/route.ts +++ b/src/app/api/integrations/gmail/callback/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { getBaseUrl } from "@/lib/utils" +import { cookies } from "next/headers" +import crypto from "crypto" /** * GET /api/integrations/gmail/callback @@ -9,30 +11,53 @@ import { getBaseUrl } from "@/lib/utils" */ export async function GET(req: Request) { try { + const cookieStore = await cookies() + const stateCookie = cookieStore.get("juno_gmail_oauth_state")?.value + const redirectWithError = (errorCode: string, extra?: Record) => { + const url = new URL(`/dashboard/juno-engine/gmail-connect?error=${errorCode}`, getBaseUrl()) + if (extra) { + for (const [key, value] of Object.entries(extra)) { + url.searchParams.set(key, value) + } + } + const response = NextResponse.redirect(url) + response.cookies.delete("juno_gmail_oauth_state") + return response + } + const session = await auth() if (!session?.user?.id) { - return NextResponse.redirect( - new URL("/login?error=unauthorized", getBaseUrl()) - ) + const response = NextResponse.redirect(new URL("/login?error=unauthorized", getBaseUrl())) + response.cookies.delete("juno_gmail_oauth_state") + return response } const { searchParams } = new URL(req.url) const code = searchParams.get("code") const error = searchParams.get("error") - const _state = searchParams.get("state") + const state = searchParams.get("state") // Handle OAuth errors if (error) { console.error("Gmail OAuth error:", error) - return NextResponse.redirect( - new URL("/dashboard/juno-engine/gmail-connect?error=oauth_failed", getBaseUrl()) - ) + return redirectWithError("oauth_failed") } if (!code) { - return NextResponse.redirect( - new URL("/dashboard/juno-engine/gmail-connect?error=no_code", getBaseUrl()) - ) + return redirectWithError("no_code") + } + + if (!state || !stateCookie) { + return redirectWithError("invalid_state") + } + + const stateBuffer = Buffer.from(state) + const cookieBuffer = Buffer.from(stateCookie) + if ( + stateBuffer.length !== cookieBuffer.length || + !crypto.timingSafeEqual(stateBuffer, cookieBuffer) + ) { + return redirectWithError("invalid_state") } // Exchange authorization code for tokens @@ -62,17 +87,12 @@ export async function GET(req: Request) { // Provide more specific error message if it's a redirect_uri_mismatch if (errorData.error === "redirect_uri_mismatch") { - return NextResponse.redirect( - new URL( - `/dashboard/juno-engine/gmail-connect?error=redirect_uri_mismatch&redirect_uri=${encodeURIComponent(redirectUri)}`, - getBaseUrl() - ) - ) + return redirectWithError("redirect_uri_mismatch", { + redirect_uri: redirectUri, + }) } - return NextResponse.redirect( - new URL("/dashboard/juno-engine/gmail-connect?error=token_exchange_failed", getBaseUrl()) - ) + return redirectWithError("token_exchange_failed") } const tokens = await tokenResponse.json() @@ -113,24 +133,19 @@ export async function GET(req: Request) { } } catch (error) { console.error("Error fetching user info:", error) - return NextResponse.redirect( - new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", getBaseUrl()) - ) + return redirectWithError("user_info_failed") } if (!userInfo?.email) { console.error("No email found in user info") - return NextResponse.redirect( - new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", getBaseUrl()) - ) + return redirectWithError("user_info_failed") } // Calculate token expiration const expiresAt = new Date(Date.now() + (tokens.expires_in * 1000)) // Store or update Gmail integration - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (prisma as any).gmailIntegration.upsert({ + await prisma.gmailIntegration.upsert({ where: { userId: session.user.id }, @@ -151,13 +166,17 @@ export async function GET(req: Request) { } }) - return NextResponse.redirect( + const successResponse = NextResponse.redirect( new URL("/dashboard/juno-engine/gmail-connect?connected=true", getBaseUrl()) ) + successResponse.cookies.delete("juno_gmail_oauth_state") + return successResponse } catch (error) { console.error("Gmail callback error:", error) - return NextResponse.redirect( + const response = NextResponse.redirect( new URL("/dashboard/juno-engine/gmail-connect?error=callback_failed", getBaseUrl()) ) + response.cookies.delete("juno_gmail_oauth_state") + return response } } diff --git a/src/app/api/integrations/gmail/connect/route.ts b/src/app/api/integrations/gmail/connect/route.ts index 2d2d7da..6eed30d 100644 --- a/src/app/api/integrations/gmail/connect/route.ts +++ b/src/app/api/integrations/gmail/connect/route.ts @@ -20,9 +20,6 @@ export async function POST() { // Generate state token for CSRF protection const state = crypto.randomBytes(32).toString("hex") - // Store state in a cookie or session (for simplicity, we'll include it in the callback URL) - // In production, use a proper session store or encrypted cookie - // Gmail API scopes // Adding 'email' and 'profile' scopes to get user email address const scopes = [ @@ -56,12 +53,19 @@ export async function POST() { authUrl.searchParams.set("prompt", "consent") // Force consent to get refresh token authUrl.searchParams.set("state", state) - return NextResponse.json({ + const response = NextResponse.json({ authUrl: authUrl.toString(), - state, // Return state so frontend can verify it on callback redirectUri, // Include redirect URI in response for debugging message: "Redirecting to Google OAuth" }) + response.cookies.set("juno_gmail_oauth_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 10, + path: "/", + }) + return response } catch (error) { console.error("Error initiating Gmail OAuth:", error) return NextResponse.json( diff --git a/src/app/api/juno-engine/flagged-emails/[id]/route.ts b/src/app/api/juno-engine/flagged-emails/[id]/route.ts index 68eebdc..ecef683 100644 --- a/src/app/api/juno-engine/flagged-emails/[id]/route.ts +++ b/src/app/api/juno-engine/flagged-emails/[id]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" async function getActiveStoreId(userId: string): Promise { @@ -25,7 +26,7 @@ export async function DELETE( if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } diff --git a/src/app/api/juno-engine/flagged-emails/route.ts b/src/app/api/juno-engine/flagged-emails/route.ts index c09afbb..016d7bd 100644 --- a/src/app/api/juno-engine/flagged-emails/route.ts +++ b/src/app/api/juno-engine/flagged-emails/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" async function getActiveStoreId(userId: string): Promise { @@ -22,7 +23,7 @@ export async function GET() { if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } @@ -36,8 +37,9 @@ export async function GET() { orderBy: { flaggedAt: "desc" }, }) + type FlaggedEmailRow = (typeof flaggedEmails)[number] return NextResponse.json({ - flaggedEmails: flaggedEmails.map((e) => ({ + flaggedEmails: flaggedEmails.map((e: FlaggedEmailRow) => ({ id: e.id, subject: e.subject, from: e.from, @@ -61,7 +63,7 @@ export async function POST(req: Request) { if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } diff --git a/src/app/api/juno-engine/templates/[templateId]/route.ts b/src/app/api/juno-engine/templates/[templateId]/route.ts index a0d7dfd..cf9a1ce 100644 --- a/src/app/api/juno-engine/templates/[templateId]/route.ts +++ b/src/app/api/juno-engine/templates/[templateId]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" @@ -41,7 +42,7 @@ export async function GET( return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } @@ -104,7 +105,7 @@ export async function PUT( return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } @@ -204,7 +205,7 @@ export async function DELETE( return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } diff --git a/src/app/api/juno-engine/templates/route.ts b/src/app/api/juno-engine/templates/route.ts index 3c07e8a..7531352 100644 --- a/src/app/api/juno-engine/templates/route.ts +++ b/src/app/api/juno-engine/templates/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" import { randomUUID } from "crypto" @@ -24,7 +25,7 @@ export async function GET(req: Request) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } @@ -98,7 +99,7 @@ export async function POST(req: Request) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } diff --git a/src/app/api/juno-engine/tickets/route.ts b/src/app/api/juno-engine/tickets/route.ts index b07e829..3e4a3bb 100644 --- a/src/app/api/juno-engine/tickets/route.ts +++ b/src/app/api/juno-engine/tickets/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" @@ -26,7 +27,7 @@ export async function GET() { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + if (!isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } diff --git a/src/app/api/onboarding/check/route.ts b/src/app/api/onboarding/check/route.ts index e0c3aed..380a2c0 100644 --- a/src/app/api/onboarding/check/route.ts +++ b/src/app/api/onboarding/check/route.ts @@ -9,7 +9,8 @@ export async function GET() { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - if (session.user.role !== "VENDOR") { + // Suppliers use a different portal; treat them as "onboarding N/A" + if (session.user.role === "SUPPLIER") { return NextResponse.json({ complete: true }) } diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 44200f5..e0b5d71 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" @@ -9,6 +10,12 @@ export async function POST(req: NextRequest) { if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } + if (!isVendorPortalRole(session.user.role)) { + return NextResponse.json( + { message: "Only vendor accounts can complete store onboarding." }, + { status: 403 } + ) + } const body = await req.json() const { diff --git a/src/app/api/onboarding/shopify/route.ts b/src/app/api/onboarding/shopify/route.ts index 5142fe4..2f9c8c7 100644 --- a/src/app/api/onboarding/shopify/route.ts +++ b/src/app/api/onboarding/shopify/route.ts @@ -1,4 +1,5 @@ import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" import { NextResponse } from "next/server" import { logger } from "@/lib/logger" @@ -9,6 +10,12 @@ export async function POST(req: Request) { if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } + if (!isVendorPortalRole(session.user.role)) { + return NextResponse.json( + { message: "Only vendor accounts can connect Shopify stores." }, + { status: 403 } + ) + } const { businessName, diff --git a/src/app/api/products/sync/[syncId]/route.ts b/src/app/api/products/sync/[syncId]/route.ts index b1bb342..3d63047 100644 --- a/src/app/api/products/sync/[syncId]/route.ts +++ b/src/app/api/products/sync/[syncId]/route.ts @@ -163,9 +163,16 @@ export async function DELETE( // Verify user owns the sync (vendor who requested or supplier who received) const isVendor = productSync.store.userId === session.user.id - const isSupplier = session.user.role === "SUPPLIER" - - if (!isVendor && !isSupplier) { + let isSupplierOwner = false + if (session.user.role === "SUPPLIER") { + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id }, + select: { id: true }, + }) + isSupplierOwner = supplierProfile?.id === productSync.supplierId + } + + if (!isVendor && !isSupplierOwner) { return NextResponse.json( { message: "Unauthorized" }, { status: 403 } @@ -173,7 +180,7 @@ export async function DELETE( } // Only vendor can delete pending requests, supplier should reject instead - if (isSupplier && productSync.status === "PENDING") { + if (isSupplierOwner && productSync.status === "PENDING") { return NextResponse.json( { message: "Please reject the request instead of deleting it" }, { status: 400 } diff --git a/src/app/api/products/sync/route.ts b/src/app/api/products/sync/route.ts index cae05c9..37977d6 100644 --- a/src/app/api/products/sync/route.ts +++ b/src/app/api/products/sync/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" /** @@ -10,7 +11,7 @@ export async function GET() { try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } @@ -63,7 +64,7 @@ export async function POST(req: Request) { try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } diff --git a/src/app/api/settings/mfa/setup/route.ts b/src/app/api/settings/mfa/setup/route.ts index f45d8bc..5f481d4 100644 --- a/src/app/api/settings/mfa/setup/route.ts +++ b/src/app/api/settings/mfa/setup/route.ts @@ -47,11 +47,7 @@ export async function POST() { // Store the secret temporarily (not enabled yet - will be enabled after verification) // Encrypt the secret at rest const encryptedSecret = encryptString(secret) - await prisma.$executeRawUnsafe( - `UPDATE "User" SET "mfaSecret" = $1 WHERE id = $2`, - encryptedSecret, - session.user.id - ) + await prisma.$executeRaw`UPDATE "User" SET "mfaSecret" = ${encryptedSecret} WHERE id = ${session.user.id}` return NextResponse.json({ secret, diff --git a/src/app/api/settings/mfa/status/route.ts b/src/app/api/settings/mfa/status/route.ts index 4962d66..27c325f 100644 --- a/src/app/api/settings/mfa/status/route.ts +++ b/src/app/api/settings/mfa/status/route.ts @@ -12,10 +12,9 @@ export async function GET() { } // Use raw query to access MFA fields until Prisma client is fully regenerated - const result = await prisma.$queryRawUnsafe>( - `SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE id = $1`, - session.user.id - ) + const result = await prisma.$queryRaw< + Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }> + >`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE id = ${session.user.id}` if (!result || result.length === 0) { return NextResponse.json({ message: "User not found" }, { status: 404 }) diff --git a/src/app/api/settings/mfa/toggle/route.ts b/src/app/api/settings/mfa/toggle/route.ts index 8d5b629..5f7526b 100644 --- a/src/app/api/settings/mfa/toggle/route.ts +++ b/src/app/api/settings/mfa/toggle/route.ts @@ -13,10 +13,9 @@ export async function POST(req: Request) { const { enabled } = await req.json() // Use raw query to check mfaSecret until Prisma client is fully regenerated - const result = await prisma.$queryRawUnsafe>( - `SELECT "mfaSecret" FROM "User" WHERE id = $1`, - session.user.id - ) + const result = await prisma.$queryRaw< + Array<{ mfaSecret: string | null }> + >`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}` if (!result || result.length === 0) { return NextResponse.json({ message: "User not found" }, { status: 404 }) @@ -33,10 +32,7 @@ export async function POST(req: Request) { // Disable MFA (can be done without verification) if (!enabled) { - await prisma.$executeRawUnsafe( - `UPDATE "User" SET "mfaEnabled" = false WHERE id = $1`, - session.user.id - ) + await prisma.$executeRaw`UPDATE "User" SET "mfaEnabled" = false WHERE id = ${session.user.id}` return NextResponse.json({ message: "MFA disabled successfully", diff --git a/src/app/api/settings/mfa/verify/route.ts b/src/app/api/settings/mfa/verify/route.ts index 4ee66e9..36bae38 100644 --- a/src/app/api/settings/mfa/verify/route.ts +++ b/src/app/api/settings/mfa/verify/route.ts @@ -31,10 +31,7 @@ export async function POST(req: Request) { } // Use raw query to access mfaSecret until Prisma client is fully regenerated - const userResult = await prisma.$queryRawUnsafe>( - `SELECT "mfaSecret" FROM "User" WHERE id = $1`, - session.user.id - ) + const userResult = await prisma.$queryRaw>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}` if (!userResult || userResult.length === 0) { return NextResponse.json( @@ -64,10 +61,7 @@ export async function POST(req: Request) { } // Enable MFA - await prisma.$executeRawUnsafe( - `UPDATE "User" SET "mfaEnabled" = true WHERE id = $1`, - session.user.id - ) + await prisma.$executeRaw`UPDATE "User" SET "mfaEnabled" = true WHERE id = ${session.user.id}` return NextResponse.json({ message: "MFA enabled successfully", diff --git a/src/app/api/shopify/customers/route.ts b/src/app/api/shopify/customers/route.ts index 53fc423..49784ad 100644 --- a/src/app/api/shopify/customers/route.ts +++ b/src/app/api/shopify/customers/route.ts @@ -31,7 +31,8 @@ export async function GET() { if (cacheFresh) { // Return cached data transformed to match Shopify format const cachedCustomers = await getCachedCustomers(store.id) - const transformedCustomers = cachedCustomers.map(customer => ({ + type CachedCustomerRow = (typeof cachedCustomers)[number] + const transformedCustomers = cachedCustomers.map((customer: CachedCustomerRow) => ({ id: parseInt(customer.shopifyCustomerId), email: customer.email, first_name: customer.firstName, diff --git a/src/app/api/shopify/inventory/route.ts b/src/app/api/shopify/inventory/route.ts index cc3c1d5..f5b8a33 100644 --- a/src/app/api/shopify/inventory/route.ts +++ b/src/app/api/shopify/inventory/route.ts @@ -31,7 +31,8 @@ export async function GET() { if (cacheFresh) { // Return cached data transformed to match Shopify format const cachedProducts = await getCachedProducts(store.id) - const transformedProducts = cachedProducts.map(product => { + type CachedProductRow = (typeof cachedProducts)[number] + const transformedProducts = cachedProducts.map((product: CachedProductRow) => { const images = (product.images as { src: string }[]) || [] return { id: parseInt(product.shopifyProductId), diff --git a/src/app/api/shopify/orders/route.ts b/src/app/api/shopify/orders/route.ts index 7fc6771..729e7c2 100644 --- a/src/app/api/shopify/orders/route.ts +++ b/src/app/api/shopify/orders/route.ts @@ -31,7 +31,8 @@ export async function GET() { if (cacheFresh) { // Return cached data transformed to match Shopify format const cachedOrders = await getCachedOrders(store.id) - const transformedOrders = cachedOrders.map(order => ({ + type CachedOrderRow = (typeof cachedOrders)[number] + const transformedOrders = cachedOrders.map((order: CachedOrderRow) => ({ id: parseInt(order.shopifyOrderId), name: order.orderNumber, email: order.email, diff --git a/src/app/api/stores/[storeId]/kb/query/route.ts b/src/app/api/stores/[storeId]/kb/query/route.ts index 741b48b..e09de94 100644 --- a/src/app/api/stores/[storeId]/kb/query/route.ts +++ b/src/app/api/stores/[storeId]/kb/query/route.ts @@ -12,6 +12,16 @@ type QueryBody = { includeDocuments?: boolean } +type KnowledgeChunkRow = { + id: string + content: string + sourceType: "DOCUMENT" | "FAQ" + documentId: string | null + faqItemId: string | null + metadata: unknown + similarity: number +} + export async function POST( request: Request, { params }: { params: Promise<{ storeId: string }> }, @@ -73,17 +83,7 @@ export async function POST( const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "" - const chunks = await prisma.$queryRawUnsafe< - { - id: string - content: string - sourceType: "DOCUMENT" | "FAQ" - documentId: string | null - faqItemId: string | null - metadata: unknown - similarity: number - }[] - >( + const chunks = await prisma.$queryRawUnsafe( ` SELECT "id", @@ -109,7 +109,7 @@ export async function POST( }) } - const contextPieces = chunks.map((chunk, index) => { + const contextPieces = chunks.map((chunk: KnowledgeChunkRow, index: number) => { const label = chunk.sourceType === "FAQ" ? `FAQ #${index + 1}` @@ -145,7 +145,7 @@ export async function POST( return NextResponse.json({ answer, - sources: chunks.map((chunk) => ({ + sources: chunks.map((chunk: KnowledgeChunkRow) => ({ id: chunk.id, type: chunk.sourceType, documentId: chunk.documentId, diff --git a/src/app/api/supplier/analytics/route.ts b/src/app/api/supplier/analytics/route.ts index f6dd445..cb100dd 100644 --- a/src/app/api/supplier/analytics/route.ts +++ b/src/app/api/supplier/analytics/route.ts @@ -49,7 +49,7 @@ export async function GET() { }) const averagePrice = products.length > 0 - ? products.reduce((sum, p) => sum + p.price, 0) / products.length + ? products.reduce((sum: number, p: { price: number }) => sum + p.price, 0) / products.length : 0 // Get recent activity (last 7 days) diff --git a/src/app/api/supplier/orders/route.ts b/src/app/api/supplier/orders/route.ts index 644d940..7e95ddc 100644 --- a/src/app/api/supplier/orders/route.ts +++ b/src/app/api/supplier/orders/route.ts @@ -49,8 +49,7 @@ export async function GET() { } // Get all accepted product syncs for this supplier - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const acceptedSyncs = await (prisma as any).productSync.findMany({ + const acceptedSyncs = await prisma.productSync.findMany({ where: { supplierId: supplierProfile.id, status: "ACCEPTED" @@ -71,10 +70,11 @@ export async function GET() { }) } + type AcceptedSyncRow = (typeof acceptedSyncs)[number] + // Group syncs by storeId const syncsByStore = new Map>() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - acceptedSyncs.forEach((sync: any) => { + acceptedSyncs.forEach((sync: AcceptedSyncRow) => { if (!syncsByStore.has(sync.storeId)) { syncsByStore.set(sync.storeId, new Set()) } @@ -101,8 +101,11 @@ export async function GET() { } }) + type ShopifyLineItem = { product_id?: number } + type CachedOrderWithStore = (typeof cachedOrders)[number] + // Filter orders that contain synced products - const relevantOrders = cachedOrders.filter((order) => { + const relevantOrders = cachedOrders.filter((order: CachedOrderWithStore) => { const syncedProductIds = syncsByStore.get(order.storeId) if (!syncedProductIds || syncedProductIds.size === 0) return false @@ -115,8 +118,7 @@ export async function GET() { if (!Array.isArray(lineItems)) return false // Check if any line item's product_id matches a synced product - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return lineItems.some((item: any) => { + return lineItems.some((item: ShopifyLineItem) => { const productId = item.product_id?.toString() return productId && syncedProductIds.has(productId) }) @@ -126,22 +128,20 @@ export async function GET() { }) // Transform orders to include supplier-relevant information - const transformedOrders = relevantOrders.map((order) => { + const transformedOrders = relevantOrders.map((order: CachedOrderWithStore) => { const syncedProductIds = syncsByStore.get(order.storeId) || new Set() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let lineItems: any[] = [] + let lineItems: ShopifyLineItem[] = [] try { - lineItems = typeof order.lineItems === "string" - ? JSON.parse(order.lineItems) - : order.lineItems + const parsed = + typeof order.lineItems === "string" ? JSON.parse(order.lineItems) : order.lineItems + lineItems = Array.isArray(parsed) ? (parsed as ShopifyLineItem[]) : [] } catch { lineItems = [] } // Filter line items to only show synced products - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const syncedLineItems = lineItems.filter((item: any) => { + const syncedLineItems = lineItems.filter((item: ShopifyLineItem) => { const productId = item.product_id?.toString() return productId && syncedProductIds.has(productId) }) @@ -158,8 +158,7 @@ export async function GET() { store: order.store, lineItems: syncedLineItems, // Include all line items for reference, but mark synced ones - // eslint-disable-next-line @typescript-eslint/no-explicit-any - allLineItems: lineItems.map((item: any) => ({ + allLineItems: lineItems.map((item: ShopifyLineItem) => ({ ...item, isSynced: syncedProductIds.has(item.product_id?.toString() || "") })) diff --git a/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts b/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts index 4682164..a7b3f71 100644 --- a/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts +++ b/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" import { sendEmail, generateInvitationEmailHtml } from "@/lib/email" import { notifyInvitationReceived } from "@/lib/notifications" @@ -17,7 +18,7 @@ export async function DELETE( try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } @@ -40,9 +41,10 @@ export async function DELETE( ) } + type DeleteFlowStoreRow = (typeof vendorStores)[number] const storeIdsToCheck = storeId ? [storeId] - : vendorStores.map(s => s.id) + : vendorStores.map((s: DeleteFlowStoreRow) => s.id) // Find and delete pending invitations const pendingInvitations = await prisma.invitation.findMany({ @@ -64,9 +66,10 @@ export async function DELETE( } // Delete all pending invitations + type PendingInvRow = (typeof pendingInvitations)[number] await prisma.invitation.deleteMany({ where: { - id: { in: pendingInvitations.map((inv) => inv.id) } + id: { in: pendingInvitations.map((inv: PendingInvRow) => inv.id) } } }) @@ -93,7 +96,7 @@ export async function POST( try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } @@ -122,10 +125,12 @@ export async function POST( ) } + type PostFlowStoreRow = (typeof vendorStores)[number] + // Determine which stores to connect const storesToConnect = connectToAllStores ? vendorStores - : vendorStores.filter(store => store.isActive) + : vendorStores.filter((store: PostFlowStoreRow) => store.isActive) if (storesToConnect.length === 0) { return NextResponse.json( @@ -161,7 +166,7 @@ export async function POST( } // Check for existing connections and invitations for all stores - const storeIds = storesToConnect.map(s => s.id) + const storeIds = storesToConnect.map((s: PostFlowStoreRow) => s.id) const existingConnections = await prisma.connection.findMany({ where: { storeId: { in: storeIds }, @@ -180,13 +185,18 @@ export async function POST( } }) + type ExistingConnectionRow = (typeof existingConnections)[number] + type ExistingInvitationRow = (typeof existingInvitations)[number] + // Filter out stores that already have connections or pending invitations const connectedStoreIds = new Set([ - ...existingConnections.map(c => c.storeId), - ...existingInvitations.map((i) => i.storeId) + ...existingConnections.map((c: ExistingConnectionRow) => c.storeId), + ...existingInvitations.map((i: ExistingInvitationRow) => i.storeId) ]) - const storesToInvite = storesToConnect.filter(store => !connectedStoreIds.has(store.id)) + const storesToInvite = storesToConnect.filter( + (store: PostFlowStoreRow) => !connectedStoreIds.has(store.id) + ) if (storesToInvite.length === 0) { const allConnected = storesToConnect.length === existingConnections.length + existingInvitations.length @@ -204,8 +214,8 @@ export async function POST( const expiresAt = new Date() expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry - const invitations = [] - const storeNames = storesToInvite.map(s => s.businessName).join(", ") + const invitations: Array<{ id: string; storeId: string; token: string }> = [] + const storeNames = storesToInvite.map((s: PostFlowStoreRow) => s.businessName).join(", ") for (const store of storesToInvite) { const invitationToken = crypto.randomBytes(32).toString("hex").toUpperCase().slice(0, 16) @@ -221,7 +231,7 @@ export async function POST( } }) invitations.push({ id: invitation.id, storeId: store.id, token: invitationToken }) - } catch (dbError: any) { + } catch (dbError: unknown) { console.error(`Database error creating invitation for store ${store.id}:`, dbError) // Continue with other stores } @@ -280,7 +290,7 @@ export async function POST( message: connectToAllStores && storesToInvite.length > 1 ? `Invitations sent successfully to ${invitations.length} store(s)` : "Invitation sent successfully", - invitations: invitations.map(inv => ({ + invitations: invitations.map((inv) => ({ id: inv.id, storeId: inv.storeId, token: inv.token, diff --git a/src/app/api/vendors/suppliers/my/route.ts b/src/app/api/vendors/suppliers/my/route.ts index 2eb76cc..fe2e53c 100644 --- a/src/app/api/vendors/suppliers/my/route.ts +++ b/src/app/api/vendors/suppliers/my/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" /** @@ -10,7 +11,7 @@ export async function GET() { try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } @@ -27,7 +28,8 @@ export async function GET() { } // Get all connections for this vendor's stores - const vendorStoreIds = vendorStores.map(s => s.id) + type VendorStoreRow = (typeof vendorStores)[number] + const vendorStoreIds = vendorStores.map((s: VendorStoreRow) => s.id) const connections = await prisma.connection.findMany({ where: { storeId: { in: vendorStoreIds } @@ -65,7 +67,7 @@ export async function GET() { }) // Get pending invitations (not yet accepted - no connection created) - const pendingInvitations = await (prisma as any).invitation.findMany({ + const pendingInvitations = await prisma.invitation.findMany({ where: { storeId: { in: vendorStoreIds }, used: false, @@ -98,28 +100,39 @@ export async function GET() { } }) + type PendingInvitationRow = (typeof pendingInvitations)[number] + type ConnectionRow = (typeof connections)[number] + // Convert invitations to connection-like objects with PENDING status - const pendingConnections = pendingInvitations.map((invitation: any) => ({ + const pendingConnections = pendingInvitations.map((invitation: PendingInvitationRow) => ({ id: invitation.id, // Use invitation ID as temporary connection ID status: "PENDING" as const, createdAt: invitation.createdAt, supplier: invitation.supplier, store: { id: invitation.storeId, - businessName: vendorStores.find(s => s.id === invitation.storeId)?.businessName || "Unknown Store", - isActive: vendorStores.find(s => s.id === invitation.storeId)?.isActive || false - } + businessName: + vendorStores.find((s: VendorStoreRow) => s.id === invitation.storeId)?.businessName || + "Unknown Store", + isActive: + vendorStores.find((s: VendorStoreRow) => s.id === invitation.storeId)?.isActive || false, + }, })) + type PendingConnectionRow = (typeof pendingConnections)[number] + // Combine connections and pending invitations, removing duplicates // (if a connection exists, don't include the invitation) - const connectionSupplierIds = new Set(connections.map(c => c.supplier.id)) + const connectionSupplierIds = new Set( + connections.map((c: ConnectionRow) => c.supplier.id) + ) const uniquePendingConnections = pendingConnections.filter( - (pc: any) => !connectionSupplierIds.has(pc.supplier.id) + (pc: PendingConnectionRow) => !connectionSupplierIds.has(pc.supplier.id) ) const allConnections = [...connections, ...uniquePendingConnections].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + (a: ConnectionRow | PendingConnectionRow, b: ConnectionRow | PendingConnectionRow) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) return NextResponse.json({ connections: allConnections }) diff --git a/src/app/api/vendors/suppliers/route.ts b/src/app/api/vendors/suppliers/route.ts index 6cf856e..d5d7fe5 100644 --- a/src/app/api/vendors/suppliers/route.ts +++ b/src/app/api/vendors/suppliers/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" /** @@ -10,7 +11,7 @@ export async function GET() { try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } @@ -48,9 +49,11 @@ export async function GET() { } }) + type SupplierListRow = (typeof suppliers)[number] + // Mark which suppliers are already connected/pending const suppliersWithStatus = await Promise.all( - suppliers.map(async (supplier) => { + suppliers.map(async (supplier: SupplierListRow) => { let connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" = "NONE" if (vendorStore) { @@ -71,7 +74,7 @@ export async function GET() { connectionStatus = connection.status as "PENDING" | "CONNECTED" | "REJECTED" } else { // Check for pending invitation (not yet accepted) - const pendingInvitation = await (prisma as any).invitation.findFirst({ + const pendingInvitation = await prisma.invitation.findFirst({ where: { storeId: vendorStore.id, supplierId: supplier.id, diff --git a/src/app/api/vendors/synced-products/[id]/route.ts b/src/app/api/vendors/synced-products/[id]/route.ts index eeb79ad..9f195ed 100644 --- a/src/app/api/vendors/synced-products/[id]/route.ts +++ b/src/app/api/vendors/synced-products/[id]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" /** @@ -16,7 +17,7 @@ export async function GET( try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } diff --git a/src/app/api/vendors/synced-products/route.ts b/src/app/api/vendors/synced-products/route.ts index 3006608..4bf1c28 100644 --- a/src/app/api/vendors/synced-products/route.ts +++ b/src/app/api/vendors/synced-products/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" /** @@ -13,7 +14,7 @@ export async function GET() { try { const session = await auth() - if (!session?.user?.id || session.user.role !== "VENDOR") { + if (!session?.user?.id || !isVendorPortalRole(session.user.role)) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } diff --git a/src/app/dashboard/inventory/page.tsx b/src/app/dashboard/inventory/page.tsx index 16da638..be9f6e0 100644 --- a/src/app/dashboard/inventory/page.tsx +++ b/src/app/dashboard/inventory/page.tsx @@ -571,19 +571,19 @@ export default function InventoryPage() { {/* Supplier Selection Modal */} {showSupplierModal && selectedProduct && ( -
-
-
+
+
+
-

Select Supplier

-

Choose a supplier to sync "{selectedProduct.title}"

+

Select Supplier

+

Choose a supplier to sync "{selectedProduct.title}"

@@ -591,9 +591,9 @@ export default function InventoryPage() {
{connectedSuppliers.length === 0 ? (
- -

No connected suppliers

-

Please connect with suppliers first from the Suppliers page.

+ +

No connected suppliers

+

Please connect with suppliers first from the Suppliers page.

) : (
@@ -602,15 +602,18 @@ export default function InventoryPage() { key={supplier.id} onClick={() => handleSelectSupplier(supplier.id)} disabled={syncingProduct} - className="p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 hover:border-indigo-500/50 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed group" + className={`p-4 rounded-xl transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed group ${isLight + ? "bg-slate-50 border border-slate-200 hover:bg-slate-100 hover:border-indigo-300" + : "bg-white/5 border border-white/10 hover:bg-white/10 hover:border-indigo-500/50" + }`} >
-

{supplier.companyName}

+

{supplier.companyName}

{supplier.description && ( -

{supplier.description}

+

{supplier.description}

)} -

{supplier.user.email}

+

{supplier.user.email}

{syncingProduct ? ( diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index a586d0b..e628025 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -5,7 +5,7 @@ import UserAccountMenu from "@/components/UserAccountMenu" import NotificationBell from "@/components/NotificationBell" import PageTransition from "@/components/PageTransition" import ThemeToggle from "@/components/ThemeToggle" -import { auth } from "@/auth" +import { auth } from "@/auth.vendor" import { redirect } from "next/navigation" export default async function DashboardLayout({ @@ -15,13 +15,18 @@ export default async function DashboardLayout({ }) { const session = await auth() - if (!session) { + if (!session?.user) { redirect("/login") } - if (session.user.role !== "VENDOR") { - if (session.user.role === "SUPPLIER") { - redirect("/login?error=WrongPortal&role=SUPPLIER") + const role = session.user.role + if (!role) { + redirect("/login") + } + + if (role !== "VENDOR") { + if (role === "SUPPLIER") { + redirect("/supplier") } redirect("/login?error=WrongRole") } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 3530436..56734db 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,6 +1,5 @@ "use client" -import { signIn } from "next-auth/react" import { Suspense, useEffect, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" import { motion } from "framer-motion" @@ -24,12 +23,22 @@ function LoginFormContent() { const applyRoleCookie = (selectedRole: "VENDOR" | "SUPPLIER") => { try { - document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600` + document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600; samesite=lax` } catch { // ignore cookie errors – server-side will just skip role enforcement } } + const roleBasePath = (selectedRole: "VENDOR" | "SUPPLIER") => + selectedRole === "SUPPLIER" ? "/api/auth/supplier" : "/api/auth/vendor" + + const getRoleCsrfToken = async (selectedRole: "VENDOR" | "SUPPLIER") => { + const res = await fetch(`${roleBasePath(selectedRole)}/csrf`, { method: "GET" }) + if (!res.ok) return "" + const data = await res.json() + return data?.csrfToken ?? "" + } + // Map Auth.js error codes from query string to friendly messages useEffect(() => { const errorCode = searchParams.get("error") @@ -113,22 +122,41 @@ function LoginFormContent() { // Ensure the selected role is visible to the auth callbacks applyRoleCookie(role) - // If MFA was required and code provided, or MFA not required, proceed with NextAuth signIn - const res = await signIn("credentials", { + const callbackUrl = role === "SUPPLIER" ? "/supplier" : "/dashboard" + const csrfToken = await getRoleCsrfToken(role) + if (!csrfToken) { + setError("Unable to start login session. Please refresh and try again.") + setRequiresMFA(false) + return + } + + // Use a real browser form POST for credentials callback. + // This is the most reliable way to ensure Auth.js sets cookies and performs redirect flow. + const form = document.createElement("form") + form.method = "POST" + form.action = `${roleBasePath(role)}/callback/credentials` + form.style.display = "none" + + const fields: Record = { email: emailValue, password: passwordValue, - redirect: false, - callbackUrl: "/onboarding", - }) - - if (res?.error) { - setError("Authentication failed. Please try again.") - setRequiresMFA(false) - } else { - // Redirect based on user role - middleware will handle the final routing - // For now, just refresh and let middleware redirect appropriately - router.refresh() + role, + csrfToken, + callbackUrl, } + if (mfaCodeValue) fields.mfaCode = mfaCodeValue + + for (const [name, value] of Object.entries(fields)) { + const input = document.createElement("input") + input.type = "hidden" + input.name = name + input.value = value + form.appendChild(input) + } + + document.body.appendChild(form) + form.submit() + return } catch { setError("An error occurred. Please try again.") setRequiresMFA(false) @@ -137,9 +165,22 @@ function LoginFormContent() { } } - const handleGoogleSignIn = () => { + const handleGoogleSignIn = async () => { applyRoleCookie(role) - signIn("google", { callbackUrl: "/onboarding" }) + const callbackUrl = role === "SUPPLIER" ? "/supplier" : "/dashboard" + const csrfToken = await getRoleCsrfToken(role) + const res = await fetch(`${roleBasePath(role)}/signin/google`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Auth-Return-Redirect": "1", + }, + body: new URLSearchParams({ csrfToken, callbackUrl }), + }) + + const data = await res.json() + const url = data?.url ?? callbackUrl + window.location.href = url } return ( diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index c7ff8e9..7d9259e 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -5,7 +5,6 @@ import { motion } from "framer-motion" import { Mail, Lock, Loader2, User, ShoppingBag, Truck } from "lucide-react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" -import { signIn } from "next-auth/react" import Image from "next/image" function SignupFormContent() { @@ -16,6 +15,24 @@ function SignupFormContent() { const initialRole = searchParams.get("role")?.toUpperCase() === "SUPPLIER" ? "SUPPLIER" : "VENDOR" const [role, setRole] = useState<"VENDOR" | "SUPPLIER">(initialRole) + const applyRoleCookie = (selectedRole: "VENDOR" | "SUPPLIER") => { + try { + document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600; samesite=lax` + } catch { + // ignore cookie errors – server-side will just skip role enforcement + } + } + + const roleBasePath = (selectedRole: "VENDOR" | "SUPPLIER") => + selectedRole === "SUPPLIER" ? "/api/auth/supplier" : "/api/auth/vendor" + + const getRoleCsrfToken = async (selectedRole: "VENDOR" | "SUPPLIER") => { + const res = await fetch(`${roleBasePath(selectedRole)}/csrf`, { method: "GET" }) + if (!res.ok) return "" + const data = await res.json() + return data?.csrfToken ?? "" + } + const handleSignup = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) @@ -47,7 +64,24 @@ function SignupFormContent() { } const handleGoogleSignIn = () => { - signIn("google", { callbackUrl: "/onboarding" }) + void (async () => { + applyRoleCookie(role) + const callbackUrl = role === "SUPPLIER" ? "/supplier" : "/dashboard" + + const csrfToken = await getRoleCsrfToken(role) + const res = await fetch(`${roleBasePath(role)}/signin/google`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Auth-Return-Redirect": "1", + }, + body: new URLSearchParams({ csrfToken, callbackUrl }), + }) + + const data = await res.json() + const url = data?.url ?? callbackUrl + window.location.href = url + })() } return ( diff --git a/src/app/supplier/layout.tsx b/src/app/supplier/layout.tsx index 865ea36..471e5f6 100644 --- a/src/app/supplier/layout.tsx +++ b/src/app/supplier/layout.tsx @@ -4,7 +4,7 @@ import UserAccountMenu from "@/components/UserAccountMenu" import NotificationBell from "@/components/NotificationBell" import PageTransition from "@/components/PageTransition" import ThemeToggle from "@/components/ThemeToggle" -import { auth } from "@/auth" +import { auth } from "@/auth.supplier" import { redirect } from "next/navigation" export default async function SupplierLayout({ @@ -14,14 +14,19 @@ export default async function SupplierLayout({ }) { const session = await auth() - if (!session) { + if (!session?.user) { redirect("/login") } // Only allow SUPPLIER role to access supplier dashboard - if (session.user.role !== "SUPPLIER") { - if (session.user.role === "VENDOR") { - redirect("/login?error=WrongPortal&role=VENDOR") + const role = session.user.role + if (!role) { + redirect("/login") + } + + if (role !== "SUPPLIER") { + if (role === "VENDOR") { + redirect("/dashboard") } redirect("/login?error=WrongRole") } diff --git a/src/auth.config.ts b/src/auth.config.ts index 3ccdddd..c8943c3 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -7,9 +7,6 @@ export default { Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, - // Allow account linking: users can sign up/sign in with Google OAuth - // This enables Google sign-in to create new accounts or link to existing accounts with matching email - allowDangerousEmailAccountLinking: true, }), Credentials({ name: "Credentials", diff --git a/src/auth.supplier.ts b/src/auth.supplier.ts new file mode 100644 index 0000000..bcbf472 --- /dev/null +++ b/src/auth.supplier.ts @@ -0,0 +1,183 @@ +import NextAuth from "next-auth" +import { PrismaAdapter } from "@auth/prisma-adapter" +import { prisma } from "@/lib/prisma" +import authConfig from "./auth.config" +import bcrypt from "bcryptjs" +import { cookies } from "next/headers" +import { decryptString } from "@/lib/encryption" + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD + +export const { handlers, auth, signIn, signOut } = NextAuth({ + adapter: PrismaAdapter(prisma), + session: { strategy: "jwt" }, + // Required when behind proxies/tunnels (e.g. ngrok) in production. + trustHost: true, + // Ensure OAuth callback URLs match the role-scoped NextAuth route. + basePath: "/api/auth/supplier", + ...authConfig, + // Role-scoped session cookie to prevent cross-private-window session overwrites. + cookies: { + sessionToken: { + name: "authjs.session-token-supplier", + }, + }, + callbacks: { + async signIn({ user, credentials }) { + // Allow non-email-based sign-in flows to proceed as usual + if (!user?.email) return true + + // Never constrain the admin account via role toggle + if (user.email === ADMIN_EMAIL || user.role === "ADMIN") { + return true + } + + // Read the desired role from the role toggle cookie set on the client + const cookieStore = await cookies() + const cookieRole = cookieStore.get("juno_login_role")?.value + const credentialsRole = + typeof credentials?.role === "string" ? credentials.role : null + const desiredRoleRaw = cookieRole ?? credentialsRole + const desiredRole = + desiredRoleRaw === "SUPPLIER" || desiredRoleRaw === "VENDOR" + ? desiredRoleRaw + : null + + // If no role was explicitly chosen (e.g. legacy flows), fall back to existing behaviour + if (!desiredRole) { + return true + } + + const existingUser = await prisma.user.findUnique({ + where: { email: user.email }, + select: { id: true, role: true }, + }) + + // New user via OAuth: align their role with the chosen role on first sign-in + if (existingUser && existingUser.role !== desiredRole) { + // If the role already exists and differs, block sign-in with a clear error code + const url = new URL("/login", process.env.NEXTAUTH_URL || "http://localhost:3000") + url.searchParams.set("error", "SigninRoleMismatch") + url.searchParams.set("existingRole", existingUser.role || "VENDOR") + url.searchParams.set("desiredRole", desiredRole) + return url.toString() + } + + // If the user record exists but has the default role and this is effectively a first login + // we keep the current behaviour and let onboarding/layouts route correctly. + return true + }, + async jwt({ token, user }) { + if (user) { + token.id = user.id + token.role = user.role + } + return token + }, + async session({ session, token }) { + if (!session.user) return session + + const userIdFromToken = + typeof (token as { id?: unknown })?.id === "string" + ? (token as { id: string }).id + : null + const userIdFromSession = + typeof (session.user as { id?: unknown }).id === "string" + ? (session.user as { id: string }).id + : null + + const sessionEmail = + typeof session.user.email === "string" ? session.user.email : null + + const userId = userIdFromToken ?? userIdFromSession + + const user = userId + ? await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, chatPublicKey: true }, + }) + : sessionEmail + ? await prisma.user.findUnique({ + where: { email: sessionEmail }, + select: { id: true, role: true, chatPublicKey: true }, + }) + : null + + if (user) { + session.user.id = user.id + session.user.role = user.role + session.user.chatPublicKey = user.chatPublicKey ?? undefined + } + + return session + }, + }, + providers: [ + ...authConfig.providers.filter((p) => (p as { id?: string }).id !== "credentials"), + { + id: "credentials", + name: "Credentials", + type: "credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials: { email: string; password: string; mfaCode?: string; role?: string }) { + if (!credentials?.email || !credentials?.password) return null + + const email = credentials.email as string + const password = credentials.password as string + const mfaCode = typeof credentials.mfaCode === "string" ? credentials.mfaCode.trim() : "" + + // Handle env-configured admin login + if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) { + // Ensure an ADMIN user exists (create or update on the fly) + const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10) + + const adminUser = await prisma.user.upsert({ + where: { email: ADMIN_EMAIL }, + update: { + password: hashedPassword, + role: "ADMIN", + }, + create: { + email: ADMIN_EMAIL, + password: hashedPassword, + role: "ADMIN", + name: "Admin", + }, + }) + + const isValid = await bcrypt.compare(password, adminUser.password!) + if (!isValid) return null + + return adminUser + } + + // Default credentials flow for non-admin users + const user = await prisma.user.findUnique({ + where: { email }, + }) + + if (!user || !user.password) return null + + const isValid = await bcrypt.compare(password, user.password) + if (!isValid) return null + + if (user.mfaEnabled) { + const secret = decryptString(user.mfaSecret) + if (!secret || !mfaCode) return null + + const { verify } = await import("otplib") + const verifyResult = await verify({ token: mfaCode, secret }) + const isValidMfa = verifyResult.valid ?? false + if (!isValidMfa) return null + } + + return user + }, + } as unknown as import("@auth/core/providers").Provider, + ], +}) + diff --git a/src/auth.ts b/src/auth.ts index 338b269..a7e8267 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,18 +3,22 @@ import { PrismaAdapter } from "@auth/prisma-adapter" import { prisma } from "@/lib/prisma" import authConfig from "./auth.config" import bcrypt from "bcryptjs" -import { cookies } from "next/headers" +import { cookies, headers } from "next/headers" +import { decryptString } from "@/lib/encryption" +import { auth as vendorAuth } from "@/auth.vendor" +import { auth as supplierAuth } from "@/auth.supplier" const ADMIN_EMAIL = process.env.ADMIN_EMAIL const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD -export const { handlers, auth, signIn, signOut } = NextAuth({ +const nextAuth = NextAuth({ adapter: PrismaAdapter(prisma), session: { strategy: "jwt" }, - trustHost: true, // Trust all hosts (needed for ngrok/proxies) + // Required when behind proxies/tunnels (e.g. ngrok) in production. + trustHost: true, ...authConfig, callbacks: { - async signIn({ user, account }) { + async signIn({ user, credentials }) { // Allow non-email-based sign-in flows to proceed as usual if (!user?.email) return true @@ -26,8 +30,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // Read the desired role from the role toggle cookie set on the client const cookieStore = await cookies() const cookieRole = cookieStore.get("juno_login_role")?.value + const credentialsRole = + typeof credentials?.role === "string" ? credentials.role : null + const desiredRoleRaw = cookieRole ?? credentialsRole const desiredRole = - cookieRole === "SUPPLIER" || cookieRole === "VENDOR" ? cookieRole : null + desiredRoleRaw === "SUPPLIER" || desiredRoleRaw === "VENDOR" + ? desiredRoleRaw + : null // If no role was explicitly chosen (e.g. legacy flows), fall back to existing behaviour if (!desiredRole) { @@ -61,18 +70,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ return token }, async session({ session, token }) { - if (token && session.user) { - session.user.id = token.id as string - if (token.role) { - session.user.role = token.role as "VENDOR" | "SUPPLIER" | "ADMIN" - } - // Load chatPublicKey from DB so it stays current after chat-setup - const user = await prisma.user.findUnique({ - where: { id: token.id as string }, - select: { chatPublicKey: true }, - }) - if (user) session.user.chatPublicKey = user.chatPublicKey ?? undefined + if (!session.user) return session + + const userIdFromToken = + typeof (token as { id?: unknown })?.id === "string" + ? (token as { id: string }).id + : null + const userIdFromSession = + typeof (session.user as { id?: unknown }).id === "string" + ? (session.user as { id: string }).id + : null + + const sessionEmail = + typeof session.user.email === "string" ? session.user.email : null + + const userId = userIdFromToken ?? userIdFromSession + + const user = userId + ? await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, chatPublicKey: true }, + }) + : sessionEmail + ? await prisma.user.findUnique({ + where: { email: sessionEmail }, + select: { id: true, role: true, chatPublicKey: true }, + }) + : null + + if (user) { + session.user.id = user.id + session.user.role = user.role + session.user.chatPublicKey = user.chatPublicKey ?? undefined } + return session }, }, @@ -86,11 +117,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, - async authorize(credentials: { email: string; password: string }) { + async authorize(credentials: { email: string; password: string; mfaCode?: string; role?: string }) { if (!credentials?.email || !credentials?.password) return null const email = credentials.email as string const password = credentials.password as string + const mfaCode = typeof credentials.mfaCode === "string" ? credentials.mfaCode.trim() : "" // Handle env-configured admin login if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) { @@ -127,8 +159,84 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const isValid = await bcrypt.compare(password, user.password) if (!isValid) return null + if (user.mfaEnabled) { + const secret = decryptString(user.mfaSecret) + if (!secret || !mfaCode) return null + + const { verify } = await import("otplib") + const verifyResult = await verify({ token: mfaCode, secret }) + const isValidMfa = verifyResult.valid ?? false + if (!isValidMfa) return null + } + return user }, } as unknown as import("@auth/core/providers").Provider, ], }) + +export const { handlers, signIn, signOut } = nextAuth +const baseAuth = nextAuth.auth + +/** Auth.js may split large JWT session cookies into chunked names like `name.0`, `name.1`. */ +function hasRoleSessionCookie( + cookieStore: Awaited>, + sessionTokenBaseName: string +): boolean { + const secureName = `__Secure-${sessionTokenBaseName}` + return cookieStore.getAll().some( + (c) => + c.name === sessionTokenBaseName || + c.name === secureName || + c.name.startsWith(`${sessionTokenBaseName}.`) || + c.name.startsWith(`${secureName}.`) + ) +} + +/** + * Match proxy/middleware portal routing so API routes see the same session as the page. + * (Previously supplier cookie was always checked first, so vendor dashboard + both cookies + * could resolve the wrong NextAuth instance → 403 on vendor-only APIs.) + */ +function portalHintFromReferer(referer: string | null): "vendor" | "supplier" | null { + if (!referer) return null + try { + const path = new URL(referer).pathname + if (path.startsWith("/supplier")) return "supplier" + if ( + path.startsWith("/dashboard") || + path.startsWith("/onboarding") || + path.startsWith("/admin") + ) { + return "vendor" + } + } catch { + /* invalid referer URL */ + } + return null +} + +// Role-aware auth for all existing `import { auth } from "@/auth"` call sites. +// Prefer the session that matches the page (Referer); otherwise prefer vendor cookie so +// dashboard flows win over a stale supplier cookie. +export const auth = async () => { + const cookieStore = await cookies() + const hint = portalHintFromReferer((await headers()).get("referer")) + + const hasSupplier = hasRoleSessionCookie(cookieStore, "authjs.session-token-supplier") + const hasVendor = hasRoleSessionCookie(cookieStore, "authjs.session-token-vendor") + + if (hint === "vendor" && hasVendor) { + return vendorAuth() + } + if (hint === "supplier" && hasSupplier) { + return supplierAuth() + } + if (hasVendor) { + return vendorAuth() + } + if (hasSupplier) { + return supplierAuth() + } + return baseAuth() +} diff --git a/src/auth.vendor.ts b/src/auth.vendor.ts new file mode 100644 index 0000000..40757d1 --- /dev/null +++ b/src/auth.vendor.ts @@ -0,0 +1,183 @@ +import NextAuth from "next-auth" +import { PrismaAdapter } from "@auth/prisma-adapter" +import { prisma } from "@/lib/prisma" +import authConfig from "./auth.config" +import bcrypt from "bcryptjs" +import { cookies } from "next/headers" +import { decryptString } from "@/lib/encryption" + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD + +export const { handlers, auth, signIn, signOut } = NextAuth({ + adapter: PrismaAdapter(prisma), + session: { strategy: "jwt" }, + // Required when behind proxies/tunnels (e.g. ngrok) in production. + trustHost: true, + // Ensure OAuth callback URLs match the role-scoped NextAuth route. + basePath: "/api/auth/vendor", + ...authConfig, + // Role-scoped session cookie to prevent cross-private-window session overwrites. + cookies: { + sessionToken: { + name: "authjs.session-token-vendor", + }, + }, + callbacks: { + async signIn({ user, credentials }) { + // Allow non-email-based sign-in flows to proceed as usual + if (!user?.email) return true + + // Never constrain the admin account via role toggle + if (user.email === ADMIN_EMAIL || user.role === "ADMIN") { + return true + } + + // Read the desired role from the role toggle cookie set on the client + const cookieStore = await cookies() + const cookieRole = cookieStore.get("juno_login_role")?.value + const credentialsRole = + typeof credentials?.role === "string" ? credentials.role : null + const desiredRoleRaw = cookieRole ?? credentialsRole + const desiredRole = + desiredRoleRaw === "SUPPLIER" || desiredRoleRaw === "VENDOR" + ? desiredRoleRaw + : null + + // If no role was explicitly chosen (e.g. legacy flows), fall back to existing behaviour + if (!desiredRole) { + return true + } + + const existingUser = await prisma.user.findUnique({ + where: { email: user.email }, + select: { id: true, role: true }, + }) + + // New user via OAuth: align their role with the chosen role on first sign-in + if (existingUser && existingUser.role !== desiredRole) { + // If the role already exists and differs, block sign-in with a clear error code + const url = new URL("/login", process.env.NEXTAUTH_URL || "http://localhost:3000") + url.searchParams.set("error", "SigninRoleMismatch") + url.searchParams.set("existingRole", existingUser.role || "VENDOR") + url.searchParams.set("desiredRole", desiredRole) + return url.toString() + } + + // If the user record exists but has the default role and this is effectively a first login + // we keep the current behaviour and let onboarding/layouts route correctly. + return true + }, + async jwt({ token, user }) { + if (user) { + token.id = user.id + token.role = user.role + } + return token + }, + async session({ session, token }) { + if (!session.user) return session + + const userIdFromToken = + typeof (token as { id?: unknown })?.id === "string" + ? (token as { id: string }).id + : null + const userIdFromSession = + typeof (session.user as { id?: unknown }).id === "string" + ? (session.user as { id: string }).id + : null + + const sessionEmail = + typeof session.user.email === "string" ? session.user.email : null + + const userId = userIdFromToken ?? userIdFromSession + + const user = userId + ? await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, chatPublicKey: true }, + }) + : sessionEmail + ? await prisma.user.findUnique({ + where: { email: sessionEmail }, + select: { id: true, role: true, chatPublicKey: true }, + }) + : null + + if (user) { + session.user.id = user.id + session.user.role = user.role + session.user.chatPublicKey = user.chatPublicKey ?? undefined + } + + return session + }, + }, + providers: [ + ...authConfig.providers.filter((p) => (p as { id?: string }).id !== "credentials"), + { + id: "credentials", + name: "Credentials", + type: "credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials: { email: string; password: string; mfaCode?: string; role?: string }) { + if (!credentials?.email || !credentials?.password) return null + + const email = credentials.email as string + const password = credentials.password as string + const mfaCode = typeof credentials.mfaCode === "string" ? credentials.mfaCode.trim() : "" + + // Handle env-configured admin login + if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) { + // Ensure an ADMIN user exists (create or update on the fly) + const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10) + + const adminUser = await prisma.user.upsert({ + where: { email: ADMIN_EMAIL }, + update: { + password: hashedPassword, + role: "ADMIN", + }, + create: { + email: ADMIN_EMAIL, + password: hashedPassword, + role: "ADMIN", + name: "Admin", + }, + }) + + const isValid = await bcrypt.compare(password, adminUser.password!) + if (!isValid) return null + + return adminUser + } + + // Default credentials flow for non-admin users + const user = await prisma.user.findUnique({ + where: { email }, + }) + + if (!user || !user.password) return null + + const isValid = await bcrypt.compare(password, user.password) + if (!isValid) return null + + if (user.mfaEnabled) { + const secret = decryptString(user.mfaSecret) + if (!secret || !mfaCode) return null + + const { verify } = await import("otplib") + const verifyResult = await verify({ token: mfaCode, secret }) + const isValidMfa = verifyResult.valid ?? false + if (!isValidMfa) return null + } + + return user + }, + } as unknown as import("@auth/core/providers").Provider, + ], +}) + diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3e38560..fb8f3cb 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -15,7 +15,8 @@ import { Flag, Mail, FileText, - HelpCircle + HelpCircle, + Store } from "lucide-react" import { cn } from "@/lib/utils" import { useTheme } from "@/components/ThemeProvider" @@ -52,6 +53,12 @@ const routes = [ href: "/dashboard/new-products", color: "text-yellow-500", }, + { + label: "Stores", + icon: Store, + href: "/dashboard/stores", + color: "text-teal-500", + }, { label: "Suppliers", icon: Users, diff --git a/src/components/StoreSwitcher.tsx b/src/components/StoreSwitcher.tsx index 0fb83e2..fad3e07 100644 --- a/src/components/StoreSwitcher.tsx +++ b/src/components/StoreSwitcher.tsx @@ -89,7 +89,27 @@ export default function StoreSwitcher() { } } - if (stores.length === 0) return null + if (stores.length === 0) { + return ( + + ) + } return (
diff --git a/src/components/UserAccountMenu.tsx b/src/components/UserAccountMenu.tsx index 104a2a7..70a29da 100644 --- a/src/components/UserAccountMenu.tsx +++ b/src/components/UserAccountMenu.tsx @@ -1,8 +1,8 @@ "use client" import { useState, useEffect, useRef } from "react" -import { useRouter } from "next/navigation" -import { signOut, useSession } from "next-auth/react" +import { usePathname, useRouter } from "next/navigation" +import { useSession } from "next-auth/react" import { Settings, LogOut, ChevronDown } from "lucide-react" import { motion, AnimatePresence } from "framer-motion" import { useTheme } from "@/components/ThemeProvider" @@ -10,6 +10,7 @@ import { useTheme } from "@/components/ThemeProvider" export default function UserAccountMenu() { const { data: session } = useSession() const router = useRouter() + const pathname = usePathname() const [isOpen, setIsOpen] = useState(false) const menuRef = useRef(null) const { theme } = useTheme() @@ -37,7 +38,40 @@ export default function UserAccountMenu() { const handleLogout = async () => { setIsOpen(false) - await signOut({ callbackUrl: "/login" }) + const role = pathname?.startsWith("/supplier") ? "SUPPLIER" : "VENDOR" + const basePath = role === "SUPPLIER" ? "/api/auth/supplier" : "/api/auth/vendor" + const callbackUrl = `/login?role=${role}&loggedOut=1` + + try { + const csrfRes = await fetch(`${basePath}/csrf`, { method: "GET" }) + const csrfData = await csrfRes.json().catch(() => ({})) + const csrfToken = csrfData?.csrfToken ?? "" + + const signoutRes = await fetch(`${basePath}/signout`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Auth-Return-Redirect": "1", + }, + body: new URLSearchParams({ csrfToken, callbackUrl }), + }) + const data = await signoutRes.json().catch(() => ({})) + window.location.href = data?.url ?? callbackUrl + } catch { + // Fallback: best-effort local cookie clear (role-scoped cookie names) + const cookieNames = + role === "SUPPLIER" + ? ["authjs.session-token-supplier", "__Secure-authjs.session-token-supplier"] + : ["authjs.session-token-vendor", "__Secure-authjs.session-token-vendor"] + for (const cookieName of cookieNames) { + try { + document.cookie = `${cookieName}=; path=/; max-age=0; samesite=lax` + } catch { + // ignore + } + } + window.location.href = callbackUrl + } } const userInitial = session?.user?.name?.[0] || session?.user?.email?.[0] || "V" diff --git a/src/components/dashboard-sidebar.tsx b/src/components/dashboard-sidebar.tsx index 7e669ca..2c958e1 100644 --- a/src/components/dashboard-sidebar.tsx +++ b/src/components/dashboard-sidebar.tsx @@ -15,7 +15,6 @@ import { ChevronRight, Layout } from "lucide-react" -import { signOut } from "next-auth/react" const menuItems = [ { icon: LayoutDashboard, label: "Dashboard", href: "/dashboard" }, @@ -32,6 +31,40 @@ export default function DashboardSidebar({ }) { const pathname = usePathname() + const handleSignOut = async () => { + const callbackUrl = "/login?role=VENDOR&loggedOut=1" + const basePath = "/api/auth/vendor" + + try { + const csrfRes = await fetch(`${basePath}/csrf`, { method: "GET" }) + const csrfData = await csrfRes.json().catch(() => ({})) + const csrfToken = csrfData?.csrfToken ?? "" + + const signoutRes = await fetch(`${basePath}/signout`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Auth-Return-Redirect": "1", + }, + body: new URLSearchParams({ csrfToken, callbackUrl }), + }) + + const data = await signoutRes.json().catch(() => ({})) + window.location.href = data?.url ?? callbackUrl + } catch { + // Fallback: best-effort local cookie clear (role-scoped cookie names) + const cookieNames = ["authjs.session-token-vendor", "__Secure-authjs.session-token-vendor"] + for (const cookieName of cookieNames) { + try { + document.cookie = `${cookieName}=; path=/; max-age=0; samesite=lax` + } catch { + // ignore + } + } + window.location.href = callbackUrl + } + } + return (
{/* Sidebar */} @@ -75,7 +108,7 @@ export default function DashboardSidebar({ Settings