Add user auth with JWT, switch to bun test
- User table with company_id FK, unique email, role enum - Register/login routes with bcrypt + JWT token generation - Auth plugin with authenticate decorator and role guards - Login uses globally unique email (no company header needed) - Dev-auth plugin kept as fallback when JWT_SECRET not set - Switched from vitest to bun:test (vitest had ESM resolution issues with zod in Bun's module structure) - Upgraded to zod 4 - Added Dockerfile.dev and API service to docker-compose - 8 tests passing (health + auth)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
18
Dockerfile.dev
Normal file
18
Dockerfile.dev
Normal file
@@ -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"]
|
||||
51
bun.lock
51
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=="],
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,5 +16,8 @@
|
||||
"eslint": "^9",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/backend/bunfig.toml
Normal file
3
packages/backend/bunfig.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[test]
|
||||
preload = ["./src/test/setup.ts"]
|
||||
timeout = 15000
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './schema/stores.js'
|
||||
export * from './schema/users.js'
|
||||
|
||||
14
packages/backend/src/db/migrations/0001_gray_lightspeed.sql
Normal file
14
packages/backend/src/db/migrations/0001_gray_lightspeed.sql
Normal file
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");
|
||||
273
packages/backend/src/db/migrations/meta/0001_snapshot.json
Normal file
273
packages/backend/src/db/migrations/meta/0001_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
281
packages/backend/src/db/migrations/meta/0002_snapshot.json
Normal file
281
packages/backend/src/db/migrations/meta/0002_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
27
packages/backend/src/db/schema/users.ts
Normal file
27
packages/backend/src/db/schema/users.ts
Normal file
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
62
packages/backend/src/plugins/auth.ts
Normal file
62
packages/backend/src/plugins/auth.ts
Normal file
@@ -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<void>
|
||||
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void>
|
||||
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
166
packages/backend/src/routes/v1/auth.test.ts
Normal file
166
packages/backend/src/routes/v1/auth.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
111
packages/backend/src/routes/v1/auth.ts
Normal file
111
packages/backend/src/routes/v1/auth.ts
Normal file
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<FastifyInstance> {
|
||||
const app = await buildApp()
|
||||
@@ -14,7 +17,6 @@ export async function createTestApp(): Promise<FastifyInstance> {
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
await app.db.execute(sql`
|
||||
@@ -27,3 +29,49 @@ export async function cleanDb(app: FastifyInstance): Promise<void> {
|
||||
END $$
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a test company and location. Call after cleanDb.
|
||||
*/
|
||||
export async function seedTestCompany(app: FastifyInstance): Promise<void> {
|
||||
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<string, unknown> }> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
testTimeout: 15000,
|
||||
},
|
||||
})
|
||||
@@ -12,6 +12,9 @@
|
||||
"lint": "eslint src/",
|
||||
"test": "echo 'no tests yet'"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
19
packages/shared/src/schemas/auth.schema.ts
Normal file
19
packages/shared/src/schemas/auth.schema.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const UserRole = z.enum(['admin', 'manager', 'staff', 'technician', 'instructor'])
|
||||
export type UserRole = z.infer<typeof UserRole>
|
||||
|
||||
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<typeof RegisterSchema>
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
export type LoginInput = z.infer<typeof LoginSchema>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user