diff --git a/compose.dev.yaml b/compose.dev.yaml index fef9263..02cb311 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -66,6 +66,35 @@ services: depends_on: - db + user-server: + build: + context: ./user-server + dockerfile: ./docker/Dockerfile.dev + ports: + - 4200:4200 + env_file: + - ./secrets/order-db/.db.env + develop: + watch: + - action: sync + path: ./user-server/src + target: /usr/user-server/src + ignore: + - node_modules/ + - dist/ + - action: rebuild + path: ./user-server/package.json + - action: rebuild + path: ./user-server/tsconfig.json + - action: rebuild + path: ./user-server/nest-cli.json + - action: rebuild + path: ./user-server/docker + - action: rebuild + path: ./user-server/prisma/schema.prisma + depends_on: + - db + ionic-client: build: context: ./ionic-client @@ -92,6 +121,7 @@ services: - ionic-client - calc-server - order-server + - user-server - db db: diff --git a/compose.prod.yaml b/compose.prod.yaml index 60b8d03..92aedaf 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -33,6 +33,19 @@ services: volumes: - ./order-server/static:/usr/order-server/static + user-server: + build: + context: ./user-server + dockerfile: ./docker/Dockerfile.prod + ports: + - 4200:4200 + env_file: + - ./secrets/order-db/.db.env + environment: + - DB_HOST=host.docker.internal + extra_hosts: + - "host.docker.internal:host-gateway" + ionic-client: build: context: ./ionic-client @@ -54,3 +67,4 @@ services: - ionic-client - calc-server - order-server + - user-server diff --git a/user-server/docker/Dockerfile.dev b/user-server/docker/Dockerfile.dev new file mode 100644 index 0000000..8de114e --- /dev/null +++ b/user-server/docker/Dockerfile.dev @@ -0,0 +1,20 @@ +FROM node:20-alpine + +ENV NODE_ENV production + +WORKDIR /usr/user-server + +RUN npm i -g typescript +RUN npm i -g @nestjs/cli + +# Install dependencies +COPY package*.json ./ +RUN npm install + +# Copy source files +COPY ./ ./ + +EXPOSE 4200 +RUN npm run prisma:gen + +CMD ["sh", "-c", "npx prisma migrate deploy && npm run start:dev"] diff --git a/user-server/docker/Dockerfile.prod b/user-server/docker/Dockerfile.prod new file mode 100644 index 0000000..f477509 --- /dev/null +++ b/user-server/docker/Dockerfile.prod @@ -0,0 +1,19 @@ +FROM node:20-alpine + +ENV NODE_ENV production + +WORKDIR /usr/user-server + +RUN npm i -g typescript +RUN npm i -g @nestjs/cli + +COPY ./package*.json ./ +RUN npm install + +COPY ./ ./ +RUN npm run prisma:gen +RUN npm run build + +EXPOSE 4200 + +CMD ["sh", "-c", "npx prisma migrate deploy && npm run start:prod"] diff --git a/user-server/eslint.config.js b/user-server/eslint.config.js new file mode 100644 index 0000000..18925a6 --- /dev/null +++ b/user-server/eslint.config.js @@ -0,0 +1,55 @@ +import js from '@eslint/js'; +import typescript from '@typescript-eslint/eslint-plugin'; +import typescriptParser from '@typescript-eslint/parser'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; + +export default [ + js.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + parser: typescriptParser, + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: import.meta.dirname, + sourceType: 'module', + }, + globals: { + ...globals.node, + ...globals.es2021, + }, + }, + plugins: { + '@typescript-eslint': typescript, + }, + rules: { + ...typescript.configs.recommended.rules, + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + { + files: ['**/*.spec.ts', '**/*.test.ts', 'test/**/*.ts'], + languageOptions: { + globals: { + ...globals.jest, + describe: 'readonly', + it: 'readonly', + test: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + jest: 'readonly', + }, + }, + }, + prettier, + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], + }, +]; diff --git a/user-server/nest-cli.json b/user-server/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/user-server/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/user-server/package.json b/user-server/package.json new file mode 100644 index 0000000..056047c --- /dev/null +++ b/user-server/package.json @@ -0,0 +1,79 @@ +{ + "name": "user-server", + "version": "0.0.1", + "description": "User service for authentication and user management", + "author": "", + "private": true, + "license": "UNLICENSED", + "type": "module", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/src/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:gen": "npx prisma generate", + "prisma:seed": "npx prisma db seed" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/platform-express": "^10.0.0", + "@prisma/client": "^6.1.0", + "bcryptjs": "^2.4.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "pg": "^8.13.1", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/bcryptjs": "^2.4.6", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.2", + "@types/node": "^22.10.2", + "@types/supertest": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "prisma": "^6.1.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\\\.spec\\\\.ts$", + "transform": { + "^.+\\\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + }, + "prisma": { + "schema": "prisma/schema.prisma", + "seed": "ts-node --compiler-options '{\\\"module\\\":\\\"CommonJS\\\"}' prisma/seed.ts" + } +} diff --git a/user-server/prisma/migrations/00000000000000_init/migration.sql b/user-server/prisma/migrations/00000000000000_init/migration.sql new file mode 100644 index 0000000..ba9801c --- /dev/null +++ b/user-server/prisma/migrations/00000000000000_init/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL PRIMARY KEY, + "email" TEXT NOT NULL UNIQUE, + "password" TEXT NOT NULL, + "name" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Trigger to keep updatedAt current (PostgreSQL specific) +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW."updatedAt" = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_user_updated_at +BEFORE UPDATE ON "User" +FOR EACH ROW +EXECUTE PROCEDURE set_updated_at(); diff --git a/user-server/prisma/prisma.service.ts b/user-server/prisma/prisma.service.ts new file mode 100644 index 0000000..ba00c9f --- /dev/null +++ b/user-server/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/user-server/prisma/schema.prisma b/user-server/prisma/schema.prisma new file mode 100644 index 0000000..1bd2d8e --- /dev/null +++ b/user-server/prisma/schema.prisma @@ -0,0 +1,17 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + password String + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/user-server/prisma/seed.ts b/user-server/prisma/seed.ts new file mode 100644 index 0000000..45e5b1c --- /dev/null +++ b/user-server/prisma/seed.ts @@ -0,0 +1,35 @@ +import { PrismaClient } from '@prisma/client'; +import { hashSync } from 'bcryptjs'; + +const prisma = new PrismaClient(); + +async function main() { + const email = process.env.DEFAULT_USER_EMAIL ?? 'admin@example.com'; + const name = process.env.DEFAULT_USER_NAME ?? 'Default User'; + const password = process.env.DEFAULT_USER_PASSWORD ?? 'changeme'; + const hashedPassword = hashSync(password, 10); + + await prisma.user.upsert({ + where: { id: 1 }, + update: { + email, + name, + password: hashedPassword, + }, + create: { + id: 1, + email, + name, + password: hashedPassword, + }, + }); +} + +main() + .catch((error) => { + console.error('Failed to seed default user', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/user-server/src/app.module.ts b/user-server/src/app.module.ts new file mode 100644 index 0000000..2049ce8 --- /dev/null +++ b/user-server/src/app.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AuthModule } from './auth/auth.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { UsersModule } from './users/users.module'; + +@Module({ + imports: [ConfigModule.forRoot({ isGlobal: true }), PrismaModule, UsersModule, AuthModule], +}) +export class AppModule {} diff --git a/user-server/src/auth/auth.controller.ts b/user-server/src/auth/auth.controller.ts new file mode 100644 index 0000000..3c1a32e --- /dev/null +++ b/user-server/src/auth/auth.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { LogInDto } from './dto/log-in.dto'; +import { SignInDto } from './dto/sign-in.dto'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('sign-in') + async signIn(@Body() dto: SignInDto) { + return this.authService.signIn(dto); + } + + @Post('log-in') + async logIn(@Body() dto: LogInDto) { + return this.authService.logIn(dto); + } +} diff --git a/user-server/src/auth/auth.module.ts b/user-server/src/auth/auth.module.ts new file mode 100644 index 0000000..25f8aea --- /dev/null +++ b/user-server/src/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { UsersModule } from '../users/users.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +@Module({ + imports: [ + UsersModule, + JwtModule.register({ + secret: process.env.JWT_SECRET ?? 'dev-secret', + signOptions: { expiresIn: '1h' }, + }), + ], + controllers: [AuthController], + providers: [AuthService], +}) +export class AuthModule {} diff --git a/user-server/src/auth/auth.service.ts b/user-server/src/auth/auth.service.ts new file mode 100644 index 0000000..afbf35b --- /dev/null +++ b/user-server/src/auth/auth.service.ts @@ -0,0 +1,54 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { User } from '@prisma/client'; +import { compare, hash } from 'bcryptjs'; +import { UsersService } from '../users/users.service'; +import { LogInDto } from './dto/log-in.dto'; +import { SignInDto } from './dto/sign-in.dto'; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + ) {} + + async signIn(dto: SignInDto) { + const hashedPassword = await hash(dto.password, 10); + const user = await this.usersService.createUser({ + email: dto.email, + name: dto.name ?? null, + password: hashedPassword, + }); + + return this.buildAuthResponse(user); + } + + async logIn(dto: LogInDto) { + const user = await this.usersService.findByEmail(dto.email); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + + const passwordMatch = await compare(dto.password, user.password); + if (!passwordMatch) { + throw new UnauthorizedException('Invalid credentials'); + } + + return this.buildAuthResponse(user); + } + + private buildAuthResponse(user: User) { + const payload = { sub: user.id, email: user.email }; + const accessToken = this.jwtService.sign(payload); + + return { + accessToken, + user: { + id: user.id, + email: user.email, + name: user.name, + }, + }; + } +} diff --git a/user-server/src/auth/dto/log-in.dto.ts b/user-server/src/auth/dto/log-in.dto.ts new file mode 100644 index 0000000..c70b98f --- /dev/null +++ b/user-server/src/auth/dto/log-in.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class LogInDto { + @IsEmail() + email!: string; + + @IsString() + @MinLength(6) + password!: string; +} diff --git a/user-server/src/auth/dto/sign-in.dto.ts b/user-server/src/auth/dto/sign-in.dto.ts new file mode 100644 index 0000000..d4f6d57 --- /dev/null +++ b/user-server/src/auth/dto/sign-in.dto.ts @@ -0,0 +1,14 @@ +import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; + +export class SignInDto { + @IsEmail() + email!: string; + + @IsString() + @MinLength(6) + password!: string; + + @IsOptional() + @IsString() + name?: string; +} diff --git a/user-server/src/main.ts b/user-server/src/main.ts new file mode 100644 index 0000000..df8728f --- /dev/null +++ b/user-server/src/main.ts @@ -0,0 +1,21 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.setGlobalPrefix('user/api/v1'); + + const port = process.env.PORT ? Number(process.env.PORT) : 4200; + await app.listen(port); + console.log(`User server is running on port ${port}`); +} + +void bootstrap(); diff --git a/user-server/src/prisma/prisma.module.ts b/user-server/src/prisma/prisma.module.ts new file mode 100644 index 0000000..864e748 --- /dev/null +++ b/user-server/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/user-server/src/users/users.module.ts b/user-server/src/users/users.module.ts new file mode 100644 index 0000000..8fa904f --- /dev/null +++ b/user-server/src/users/users.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; + +@Module({ + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/user-server/src/users/users.service.ts b/user-server/src/users/users.service.ts new file mode 100644 index 0000000..a77c23e --- /dev/null +++ b/user-server/src/users/users.service.ts @@ -0,0 +1,28 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { Prisma, User } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async createUser(data: Prisma.UserCreateInput): Promise { + const existing = await this.prisma.user.findUnique({ + where: { email: data.email }, + }); + + if (existing) { + throw new ConflictException('User with this email already exists'); + } + + return this.prisma.user.create({ data }); + } + + async findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email } }); + } + + async findById(id: number): Promise { + return this.prisma.user.findUnique({ where: { id } }); + } +} diff --git a/user-server/tsconfig.build.json b/user-server/tsconfig.build.json new file mode 100644 index 0000000..c1e192f --- /dev/null +++ b/user-server/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/user-server/tsconfig.json b/user-server/tsconfig.json new file mode 100644 index 0000000..3018452 --- /dev/null +++ b/user-server/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./src", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "paths": { + "~/*": ["*"] + } + } +}