diff --git a/.env.example b/.env.example index fbd683f..977675b 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,14 @@ # Forte — Development Environment Variables +# These are used inside Docker Compose (docker-compose.dev.yml overrides most of these) # Database -DATABASE_URL=postgresql://forte:forte@localhost:5432/forte +DATABASE_URL=postgresql://forte:forte@postgres:5432/forte # Valkey (Redis-compatible) -REDIS_URL=redis://localhost:6379 +REDIS_URL=redis://valkey:6379 + +# JWT +JWT_SECRET=change-me-in-production-use-a-long-random-string # API Server PORT=8000 diff --git a/CLAUDE.md b/CLAUDE.md index a6ddcfc..bc4ad9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ - **Queue:** BullMQ (Valkey-backed) - **Cache:** Valkey 8 (Redis-compatible fork) - **Monorepo:** Turborepo with Bun workspaces -- **Testing:** Vitest +- **Testing:** bun test (built-in, uses bun:test imports) - **Linting:** ESLint 9 flat config + Prettier ## Package Namespace diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..97b3ec8 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,18 @@ +FROM oven/bun:1 + +WORKDIR /app + +# Install dependencies first (cached layer) +COPY package.json bun.lock ./ +COPY packages/shared/package.json packages/shared/ +COPY packages/backend/package.json packages/backend/ +RUN bun install --frozen-lockfile + +# Copy source +COPY . . + +WORKDIR /app/packages/backend + +EXPOSE 8000 + +CMD ["bun", "--watch", "run", "src/main.ts"] diff --git a/bun.lock b/bun.lock index 017bf47..bf372fc 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "forte", + "dependencies": { + "zod": "^4.3.6", + }, "devDependencies": { "eslint": "^9", "prettier": "^3", @@ -17,15 +20,18 @@ "version": "0.0.1", "dependencies": { "@fastify/cors": "^10", + "@fastify/jwt": "^9", "@forte/shared": "workspace:*", + "bcrypt": "^6", "drizzle-orm": "^0.38", "fastify": "^5", "fastify-plugin": "^5", "ioredis": "^5", "postgres": "^3", - "zod": "^3", + "zod": "^4", }, "devDependencies": { + "@types/bcrypt": "^5", "@types/node": "^22", "drizzle-kit": "^0.30", "pino-pretty": "^13", @@ -36,6 +42,9 @@ "packages/shared": { "name": "@forte/shared", "version": "0.0.1", + "dependencies": { + "zod": "^4", + }, "devDependencies": { "typescript": "^5", }, @@ -128,6 +137,8 @@ "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], + "@fastify/jwt": ["@fastify/jwt@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "@lukeed/ms": "^2.0.2", "fast-jwt": "^5.0.0", "fastify-plugin": "^5.0.0", "steed": "^1.1.3" } }, "sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w=="], + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], @@ -148,6 +159,8 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], + "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], @@ -214,6 +227,8 @@ "@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="], + "@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -272,6 +287,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], @@ -280,6 +297,10 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], + + "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -324,6 +345,8 @@ "drizzle-orm": ["drizzle-orm@0.38.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], @@ -366,6 +389,8 @@ "fast-json-stringify": ["fast-json-stringify@6.3.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="], + "fast-jwt": ["fast-jwt@5.0.6", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "asn1.js": "^5.4.1", "ecdsa-sig-formatter": "^1.0.11", "mnemonist": "^0.40.0" } }, "sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g=="], + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], @@ -374,12 +399,18 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fastfall": ["fastfall@1.5.1", "", { "dependencies": { "reusify": "^1.0.0" } }, "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q=="], + "fastify": ["fastify@5.8.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="], "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], + "fastparallel": ["fastparallel@2.4.1", "", { "dependencies": { "reusify": "^1.0.4", "xtend": "^4.0.2" } }, "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fastseries": ["fastseries@1.7.2", "", { "dependencies": { "reusify": "^1.0.0", "xtend": "^4.0.0" } }, "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -412,6 +443,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], @@ -454,6 +487,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -466,6 +501,10 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -536,10 +575,14 @@ "rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -570,6 +613,8 @@ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "steed": ["steed@1.1.3", "", { "dependencies": { "fastfall": "^1.5.0", "fastparallel": "^2.2.0", "fastq": "^1.3.0", "fastseries": "^1.7.0", "reusify": "^1.0.0" } }, "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], @@ -620,9 +665,11 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d636036..bf10c0c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,31 @@ services: + api: + build: + context: . + dockerfile: Dockerfile.dev + container_name: forte-api + restart: unless-stopped + ports: + - "8000:8000" + volumes: + - ./packages:/app/packages + - ./package.json:/app/package.json + - /app/node_modules + - /app/packages/backend/node_modules + - /app/packages/shared/node_modules + environment: + DATABASE_URL: postgresql://forte:forte@postgres:5432/forte + REDIS_URL: redis://valkey:6379 + JWT_SECRET: dev-secret-do-not-use-in-production + NODE_ENV: development + PORT: "8000" + HOST: "0.0.0.0" + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + postgres: image: postgres:16 container_name: forte-postgres diff --git a/package.json b/package.json index da2170a..baec411 100644 --- a/package.json +++ b/package.json @@ -16,5 +16,8 @@ "eslint": "^9", "typescript": "^5", "typescript-eslint": "^8" + }, + "dependencies": { + "zod": "^4.3.6" } } diff --git a/packages/backend/bunfig.toml b/packages/backend/bunfig.toml new file mode 100644 index 0000000..d72e93a --- /dev/null +++ b/packages/backend/bunfig.toml @@ -0,0 +1,3 @@ +[test] +preload = ["./src/test/setup.ts"] +timeout = 15000 diff --git a/packages/backend/package.json b/packages/backend/package.json index 9f54b12..4c7b975 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -6,8 +6,8 @@ "scripts": { "dev": "bun --watch run src/main.ts", "start": "bun run src/main.ts", - "test": "vitest run", - "test:watch": "vitest", + "test": "bun test", + "test:watch": "bun test --watch", "lint": "eslint src/", "db:generate": "bunx drizzle-kit generate", "db:migrate": "bunx drizzle-kit migrate", @@ -21,13 +21,15 @@ "drizzle-orm": "^0.38", "postgres": "^3", "ioredis": "^5", - "zod": "^3" + "zod": "^4", + "@fastify/jwt": "^9", + "bcrypt": "^6" }, "devDependencies": { "typescript": "^5", "drizzle-kit": "^0.30", "pino-pretty": "^13", - "vitest": "^3", - "@types/node": "^22" + "@types/node": "^22", + "@types/bcrypt": "^5" } } diff --git a/packages/backend/src/db/index.ts b/packages/backend/src/db/index.ts index b49455a..5d0e29f 100644 --- a/packages/backend/src/db/index.ts +++ b/packages/backend/src/db/index.ts @@ -1 +1,2 @@ export * from './schema/stores.js' +export * from './schema/users.js' diff --git a/packages/backend/src/db/migrations/0001_gray_lightspeed.sql b/packages/backend/src/db/migrations/0001_gray_lightspeed.sql new file mode 100644 index 0000000..affd7ba --- /dev/null +++ b/packages/backend/src/db/migrations/0001_gray_lightspeed.sql @@ -0,0 +1,14 @@ +CREATE TYPE "public"."user_role" AS ENUM('admin', 'manager', 'staff', 'technician', 'instructor');--> statement-breakpoint +CREATE TABLE "user" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "email" varchar(255) NOT NULL, + "password_hash" varchar(255) NOT NULL, + "first_name" varchar(100) NOT NULL, + "last_name" varchar(100) NOT NULL, + "role" "user_role" DEFAULT 'staff' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user" ADD CONSTRAINT "user_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/src/db/migrations/0002_bumpy_mandarin.sql b/packages/backend/src/db/migrations/0002_bumpy_mandarin.sql new file mode 100644 index 0000000..aee76fa --- /dev/null +++ b/packages/backend/src/db/migrations/0002_bumpy_mandarin.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email"); \ No newline at end of file diff --git a/packages/backend/src/db/migrations/meta/0001_snapshot.json b/packages/backend/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..fe882c1 --- /dev/null +++ b/packages/backend/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,273 @@ +{ + "id": "0f909014-3256-4320-9da2-a39a3b68671c", + "prevId": "fd79ece0-66f3-4238-a2ca-af44060363b5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'staff'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_company_id_company_id_fk": { + "name": "user_company_id_company_id_fk", + "tableFrom": "user", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company": { + "name": "company", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'America/Chicago'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "location_company_id_company_id_fk": { + "name": "location_company_id_company_id_fk", + "tableFrom": "location", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "manager", + "staff", + "technician", + "instructor" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/db/migrations/meta/0002_snapshot.json b/packages/backend/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..a54f912 --- /dev/null +++ b/packages/backend/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,281 @@ +{ + "id": "69ee3c88-af63-4f16-88e1-15e4ce355a87", + "prevId": "0f909014-3256-4320-9da2-a39a3b68671c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'staff'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_company_id_company_id_fk": { + "name": "user_company_id_company_id_fk", + "tableFrom": "user", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company": { + "name": "company", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'America/Chicago'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "location_company_id_company_id_fk": { + "name": "location_company_id_company_id_fk", + "tableFrom": "location", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "manager", + "staff", + "technician", + "instructor" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 22dc1bc..d491316 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1774635439354, "tag": "0000_hot_purifiers", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1774646377107, + "tag": "0001_gray_lightspeed", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1774648659531, + "tag": "0002_bumpy_mandarin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/users.ts b/packages/backend/src/db/schema/users.ts new file mode 100644 index 0000000..418a5f2 --- /dev/null +++ b/packages/backend/src/db/schema/users.ts @@ -0,0 +1,27 @@ +import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core' +import { companies } from './stores.js' + +export const userRoleEnum = pgEnum('user_role', [ + 'admin', + 'manager', + 'staff', + 'technician', + 'instructor', +]) + +export const users = pgTable('user', { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + email: varchar('email', { length: 255 }).notNull().unique(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + firstName: varchar('first_name', { length: 100 }).notNull(), + lastName: varchar('last_name', { length: 100 }).notNull(), + role: userRoleEnum('role').notNull().default('staff'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type User = typeof users.$inferSelect +export type UserInsert = typeof users.$inferInsert diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 5eeb2d5..abd7701 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -3,8 +3,10 @@ import { databasePlugin } from './plugins/database.js' import { redisPlugin } from './plugins/redis.js' import { corsPlugin } from './plugins/cors.js' import { errorHandlerPlugin } from './plugins/error-handler.js' +import { authPlugin } from './plugins/auth.js' import { devAuthPlugin } from './plugins/dev-auth.js' import { healthRoutes } from './routes/v1/health.js' +import { authRoutes } from './routes/v1/auth.js' export async function buildApp() { const app = Fastify({ @@ -20,10 +22,17 @@ export async function buildApp() { await app.register(errorHandlerPlugin) await app.register(databasePlugin) await app.register(redisPlugin) - await app.register(devAuthPlugin) + + // Auth — use JWT if secret is set, otherwise dev bypass + if (process.env.JWT_SECRET) { + await app.register(authPlugin) + } else { + await app.register(devAuthPlugin) + } // Routes await app.register(healthRoutes, { prefix: '/v1' }) + await app.register(authRoutes, { prefix: '/v1' }) return app } @@ -42,4 +51,7 @@ async function start() { } } -start() +// Only auto-start when not imported by tests +if (process.env.NODE_ENV !== 'test') { + start() +} diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts new file mode 100644 index 0000000..953d789 --- /dev/null +++ b/packages/backend/src/plugins/auth.ts @@ -0,0 +1,62 @@ +import fp from 'fastify-plugin' +import fjwt from '@fastify/jwt' + +declare module 'fastify' { + interface FastifyRequest { + companyId: string + locationId: string + user: { id: string; companyId: string; role: string } + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { id: string; companyId: string; role: string } + user: { id: string; companyId: string; role: string } + } +} + +export const authPlugin = fp(async (app) => { + const secret = process.env.JWT_SECRET + if (!secret) { + throw new Error('JWT_SECRET environment variable is required') + } + + await app.register(fjwt, { + secret, + sign: { expiresIn: '24h' }, + }) + + // Set companyId from header on all requests (for unauthenticated routes like register/login). + // Authenticated routes override this with the JWT payload via the authenticate decorator. + app.addHook('onRequest', async (request) => { + request.companyId = (request.headers['x-company-id'] as string) ?? '' + request.locationId = (request.headers['x-location-id'] as string) ?? '' + }) + + app.decorate('authenticate', async function (request: any, reply: any) { + try { + await request.jwtVerify() + request.companyId = request.user.companyId + } catch (_err) { + reply.status(401).send({ error: { message: 'Unauthorized', statusCode: 401 } }) + } + }) + + app.decorate('requireRole', function (...roles: string[]) { + return async function (request: any, reply: any) { + if (!roles.includes(request.user.role)) { + reply + .status(403) + .send({ error: { message: 'Insufficient permissions', statusCode: 403 } }) + } + } + }) +}) + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: any, reply: any) => Promise + requireRole: (...roles: string[]) => (request: any, reply: any) => Promise + } +} diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index 0350957..c8f673e 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -1,7 +1,10 @@ import fp from 'fastify-plugin' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import * as schema from '../db/schema/stores.js' +import * as storeSchema from '../db/schema/stores.js' +import * as userSchema from '../db/schema/users.js' + +const schema = { ...storeSchema, ...userSchema } declare module 'fastify' { interface FastifyInstance { diff --git a/packages/backend/src/plugins/dev-auth.ts b/packages/backend/src/plugins/dev-auth.ts index 7b68da3..ea888af 100644 --- a/packages/backend/src/plugins/dev-auth.ts +++ b/packages/backend/src/plugins/dev-auth.ts @@ -4,27 +4,39 @@ declare module 'fastify' { interface FastifyRequest { companyId: string locationId: string - user: { id: string; role: string } + user: { id: string; companyId: string; role: string } } } /** - * Dev-only auth bypass. Reads headers to set request context - * without real JWT validation. - * - * Will be replaced by real JWT auth in Phase 2. + * Dev-only auth bypass. Used when JWT_SECRET is not set. + * Reads x-company-id and x-location-id headers to set request context. */ export const devAuthPlugin = fp(async (app) => { app.addHook('onRequest', async (request) => { - const companyId = request.headers['x-dev-company'] as string | undefined - const locationId = request.headers['x-dev-location'] as string | undefined - const userId = request.headers['x-dev-user'] as string | undefined + const companyId = (request.headers['x-company-id'] as string) ?? '00000000-0000-0000-0000-000000000001' + const locationId = (request.headers['x-location-id'] as string) ?? '00000000-0000-0000-0000-000000000010' + const userId = (request.headers['x-user-id'] as string) ?? '00000000-0000-0000-0000-000000000001' - request.companyId = companyId ?? '00000000-0000-0000-0000-000000000001' - request.locationId = locationId ?? '00000000-0000-0000-0000-000000000010' + request.companyId = companyId + request.locationId = locationId request.user = { - id: userId ?? '00000000-0000-0000-0000-000000000001', + id: userId, + companyId, role: 'admin', } }) + + // No-op decorators so routes that use authenticate/requireRole still work + app.decorate('authenticate', async function () {}) + app.decorate('requireRole', function () { + return async function () {} + }) }) + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: any, reply: any) => Promise + requireRole: (...roles: string[]) => (request: any, reply: any) => Promise + } +} diff --git a/packages/backend/src/routes/v1/auth.test.ts b/packages/backend/src/routes/v1/auth.test.ts new file mode 100644 index 0000000..1664e82 --- /dev/null +++ b/packages/backend/src/routes/v1/auth.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test' +import type { FastifyInstance } from 'fastify' +import { createTestApp, cleanDb, seedTestCompany, TEST_COMPANY_ID } from '../../test/helpers.js' + +describe('Auth routes', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await cleanDb(app) + await seedTestCompany(app) + }) + + afterAll(async () => { + await app.close() + }) + + describe('POST /v1/auth/register', () => { + it('creates a user and returns token', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'staff@musicstore.com', + password: 'securepassword', + firstName: 'Jane', + lastName: 'Doe', + role: 'staff', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json() + expect(body.user.email).toBe('staff@musicstore.com') + expect(body.user.firstName).toBe('Jane') + expect(body.user.role).toBe('staff') + expect(body.token).toBeDefined() + expect(body.user.passwordHash).toBeUndefined() + }) + + it('rejects duplicate email within same company', async () => { + await app.inject({ + method: 'POST', + url: '/v1/auth/register', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'dupe@test.com', + password: 'password123', + firstName: 'First', + lastName: 'User', + }, + }) + + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'dupe@test.com', + password: 'password456', + firstName: 'Second', + lastName: 'User', + }, + }) + + expect(response.statusCode).toBe(409) + }) + + it('rejects invalid email', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'not-an-email', + password: 'password123', + firstName: 'Bad', + lastName: 'Email', + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('rejects short password', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'short@test.com', + password: '123', + firstName: 'Short', + lastName: 'Pass', + }, + }) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('POST /v1/auth/login', () => { + beforeEach(async () => { + await app.inject({ + method: 'POST', + url: '/v1/auth/register', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'login@test.com', + password: 'correctpassword', + firstName: 'Login', + lastName: 'User', + }, + }) + }) + + it('returns token with valid credentials', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/login', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'login@test.com', + password: 'correctpassword', + }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.token).toBeDefined() + expect(body.user.email).toBe('login@test.com') + }) + + it('rejects wrong password', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/login', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'login@test.com', + password: 'wrongpassword', + }, + }) + + expect(response.statusCode).toBe(401) + }) + + it('rejects nonexistent email', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/login', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: 'nobody@test.com', + password: 'whatever', + }, + }) + + expect(response.statusCode).toBe(401) + }) + }) +}) diff --git a/packages/backend/src/routes/v1/auth.ts b/packages/backend/src/routes/v1/auth.ts new file mode 100644 index 0000000..e8ff068 --- /dev/null +++ b/packages/backend/src/routes/v1/auth.ts @@ -0,0 +1,111 @@ +import type { FastifyPluginAsync } from 'fastify' +import { eq } from 'drizzle-orm' +import bcrypt from 'bcrypt' +import { RegisterSchema, LoginSchema } from '@forte/shared/schemas' +import { users } from '../../db/schema/users.js' + +const SALT_ROUNDS = 10 + +export const authRoutes: FastifyPluginAsync = async (app) => { + app.post('/auth/register', async (request, reply) => { + const parsed = RegisterSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ + error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 }, + }) + } + + const { email, password, firstName, lastName, role } = parsed.data + const companyId = request.companyId + + // Email is globally unique across all companies + const existing = await app.db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, email)) + .limit(1) + + if (existing.length > 0) { + return reply.status(409).send({ + error: { message: 'User with this email already exists', statusCode: 409 }, + }) + } + + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS) + + const [user] = await app.db + .insert(users) + .values({ + companyId, + email, + passwordHash, + firstName, + lastName, + role, + }) + .returning({ + id: users.id, + email: users.email, + firstName: users.firstName, + lastName: users.lastName, + role: users.role, + createdAt: users.createdAt, + }) + + const token = app.jwt.sign({ + id: user.id, + companyId, + role: user.role, + }) + + return reply.status(201).send({ user, token }) + }) + + app.post('/auth/login', async (request, reply) => { + const parsed = LoginSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ + error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 }, + }) + } + + const { email, password } = parsed.data + + // Email is globally unique — company is derived from the user record + const [user] = await app.db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1) + + if (!user) { + return reply.status(401).send({ + error: { message: 'Invalid email or password', statusCode: 401 }, + }) + } + + const valid = await bcrypt.compare(password, user.passwordHash) + if (!valid) { + return reply.status(401).send({ + error: { message: 'Invalid email or password', statusCode: 401 }, + }) + } + + const token = app.jwt.sign({ + id: user.id, + companyId: user.companyId, + role: user.role, + }) + + return reply.send({ + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + }, + token, + }) + }) +} diff --git a/packages/backend/src/routes/v1/health.test.ts b/packages/backend/src/routes/v1/health.test.ts index 94a8490..6adf97a 100644 --- a/packages/backend/src/routes/v1/health.test.ts +++ b/packages/backend/src/routes/v1/health.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import type { FastifyInstance } from 'fastify' import { createTestApp } from '../../test/helpers.js' diff --git a/packages/backend/src/test/helpers.ts b/packages/backend/src/test/helpers.ts index 17a41f6..fcebb48 100644 --- a/packages/backend/src/test/helpers.ts +++ b/packages/backend/src/test/helpers.ts @@ -1,10 +1,13 @@ import type { FastifyInstance } from 'fastify' import { buildApp } from '../main.js' import { sql } from 'drizzle-orm' +import { companies, locations } from '../db/schema/stores.js' + +export const TEST_COMPANY_ID = '00000000-0000-0000-0000-000000000099' +export const TEST_LOCATION_ID = '00000000-0000-0000-0000-000000000099' /** * Build a fresh Fastify app instance for testing. - * Each test gets its own app — no shared state. */ export async function createTestApp(): Promise { const app = await buildApp() @@ -14,7 +17,6 @@ export async function createTestApp(): Promise { /** * Truncate all tables in the test database. - * Call this in beforeEach to guarantee a clean slate per test. */ export async function cleanDb(app: FastifyInstance): Promise { await app.db.execute(sql` @@ -27,3 +29,49 @@ export async function cleanDb(app: FastifyInstance): Promise { END $$ `) } + +/** + * Seed a test company and location. Call after cleanDb. + */ +export async function seedTestCompany(app: FastifyInstance): Promise { + await app.db.insert(companies).values({ + id: TEST_COMPANY_ID, + name: 'Test Music Co.', + timezone: 'America/Chicago', + }) + await app.db.insert(locations).values({ + id: TEST_LOCATION_ID, + companyId: TEST_COMPANY_ID, + name: 'Test Location', + }) +} + +/** + * Register a user and return the JWT token. + */ +export async function registerAndLogin( + app: FastifyInstance, + overrides: { + email?: string + password?: string + firstName?: string + lastName?: string + role?: string + } = {}, +): Promise<{ token: string; user: Record }> { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + headers: { 'x-company-id': TEST_COMPANY_ID }, + payload: { + email: overrides.email ?? 'test@forte.dev', + password: overrides.password ?? 'testpassword123', + firstName: overrides.firstName ?? 'Test', + lastName: overrides.lastName ?? 'User', + role: overrides.role ?? 'admin', + }, + }) + + const body = response.json() + return { token: body.token, user: body.user } +} diff --git a/packages/backend/src/test/setup.ts b/packages/backend/src/test/setup.ts index 3f2da22..e7f69b2 100644 --- a/packages/backend/src/test/setup.ts +++ b/packages/backend/src/test/setup.ts @@ -8,6 +8,7 @@ const TEST_DB_URL = process.env.DATABASE_URL = TEST_DB_URL process.env.NODE_ENV = 'test' process.env.LOG_LEVEL = 'silent' +process.env.JWT_SECRET = 'test-secret-for-jwt-signing' /** * Ensure the forte_test database exists before tests run. diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts deleted file mode 100644 index f9345d6..0000000 --- a/packages/backend/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - setupFiles: ['./src/test/setup.ts'], - testTimeout: 15000, - }, -}) diff --git a/packages/shared/package.json b/packages/shared/package.json index f18dd4a..d3184ed 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,9 @@ "lint": "eslint src/", "test": "echo 'no tests yet'" }, + "dependencies": { + "zod": "^4" + }, "devDependencies": { "typescript": "^5" } diff --git a/packages/shared/src/schemas/auth.schema.ts b/packages/shared/src/schemas/auth.schema.ts new file mode 100644 index 0000000..445f583 --- /dev/null +++ b/packages/shared/src/schemas/auth.schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +export const UserRole = z.enum(['admin', 'manager', 'staff', 'technician', 'instructor']) +export type UserRole = z.infer + +export const RegisterSchema = z.object({ + email: z.string().email(), + password: z.string().min(8).max(128), + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + role: UserRole.default('staff'), +}) +export type RegisterInput = z.infer + +export const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}) +export type LoginInput = z.infer diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 924c652..0090472 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,2 +1,2 @@ -// @forte/shared Zod schemas -// Shared validation schemas will be added as each domain is implemented +export { UserRole, RegisterSchema, LoginSchema } from './auth.schema.js' +export type { RegisterInput, LoginInput } from './auth.schema.js'