feat: add nestjs fastify backend scaffold
This commit is contained in:
parent
470f8463b1
commit
d1ff1eaa1c
32 changed files with 4345 additions and 0 deletions
6
backend-nest/.gitignore
vendored
Normal file
6
backend-nest/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.env
|
||||
coverage
|
||||
dist
|
||||
.DS_Store
|
||||
.prisma
|
||||
22
backend-nest/Dockerfile
Normal file
22
backend-nest/Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
FROM node:iron-slim AS base
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
RUN pnpm prisma:generate
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS production
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
backend-nest/nest-cli.json
Normal file
8
backend-nest/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
51
backend-nest/package.json
Normal file
51
backend-nest/package.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"name": "backend-nest",
|
||||
"version": "0.1.0",
|
||||
"description": "NestJS backend using Fastify, Prisma, and JWT auth",
|
||||
"packageManager": "pnpm@8.15.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
||||
"start:debug": "ts-node-dev --respawn --inspect --transpile-only src/main.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"prisma:generate": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.3.1",
|
||||
"@fastify/swagger": "^8.12.0",
|
||||
"@fastify/swagger-ui": "^1.9.2",
|
||||
"@nestjs/common": "^10.3.6",
|
||||
"@nestjs/config": "^3.2.2",
|
||||
"@nestjs/core": "^10.3.6",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.3.6",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@prisma/client": "^5.16.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"fastify": "^4.28.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"prisma": "^5.16.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"prettier": "^3.3.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
3549
backend-nest/pnpm-lock.yaml
Normal file
3549
backend-nest/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
72
backend-nest/prisma/schema.prisma
Normal file
72
backend-nest/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum CompanyStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
model Company {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
status CompanyStatus @default(ACTIVE)
|
||||
users User[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
name String
|
||||
role UserRole @default(USER)
|
||||
companyId Int
|
||||
company Company @relation(fields: [companyId], references: [id])
|
||||
refreshToken String?
|
||||
orders Order[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
sku String @unique
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
inventory InventoryItem?
|
||||
orders Order[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int @unique
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
quantity Int
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
buyerId Int
|
||||
productId Int
|
||||
quantity Int
|
||||
total Decimal @db.Decimal(12, 2)
|
||||
buyer User @relation(fields: [buyerId], references: [id])
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
19
backend-nest/src/app.module.ts
Normal file
19
backend-nest/src/app.module.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
InventoryModule,
|
||||
WebhooksModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
48
backend-nest/src/auth/auth.controller.ts
Normal file
48
backend-nest/src/auth/auth.controller.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { CreateUserDto } from '../users/dto/create-user.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RefreshTokenGuard } from './guards/refresh-token.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() dto: CreateUserDto, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
return this.authService.register(dto, reply);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
return this.authService.login(dto, reply);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@UseGuards(RefreshTokenGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(@Req() req: any, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
const userId = req.user?.sub;
|
||||
const refreshToken = req.user?.refreshToken;
|
||||
return this.authService.refreshTokens(userId, refreshToken, reply);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
async logout(@Req() req: any, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
return this.authService.logout(req.user.sub, reply);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
async profile(@Req() req: any) {
|
||||
return req.user;
|
||||
}
|
||||
}
|
||||
33
backend-nest/src/auth/auth.module.ts
Normal file
33
backend-nest/src/auth/auth.module.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RefreshTokenGuard } from './guards/refresh-token.guard';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { RefreshTokenStrategy } from './strategies/refresh-token.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_ACCESS_SECRET') || 'access-secret',
|
||||
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES', '15m') },
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
UsersModule,
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, RefreshTokenStrategy, JwtAuthGuard, RefreshTokenGuard],
|
||||
controllers: [AuthController],
|
||||
exports: [JwtAuthGuard, RefreshTokenGuard, AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
129
backend-nest/src/auth/auth.service.ts
Normal file
129
backend-nest/src/auth/auth.service.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { CreateUserDto } from '../users/dto/create-user.dto';
|
||||
import { JwtPayload } from './types/jwt-payload.type';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async register(dto: CreateUserDto, reply: FastifyReply) {
|
||||
const existingUser = await this.usersService.findByEmail(dto.email);
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Email already registered');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.usersService.createWithCompany(dto, hashedPassword);
|
||||
|
||||
const tokens = await this.issueTokens(user);
|
||||
await this.updateRefreshToken(user.id, tokens.refreshToken);
|
||||
this.attachRefreshTokenCookie(reply, tokens.refreshToken);
|
||||
|
||||
return { accessToken: tokens.accessToken };
|
||||
}
|
||||
|
||||
async login(dto: LoginDto, reply: FastifyReply) {
|
||||
const user = await this.usersService.findByEmailWithCompany(dto.email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordMatches = await bcrypt.compare(dto.password, user.password);
|
||||
if (!passwordMatches) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const tokens = await this.issueTokens(user);
|
||||
await this.updateRefreshToken(user.id, tokens.refreshToken);
|
||||
this.attachRefreshTokenCookie(reply, tokens.refreshToken);
|
||||
|
||||
return { accessToken: tokens.accessToken };
|
||||
}
|
||||
|
||||
async refreshTokens(userId: number, refreshToken: string, reply: FastifyReply) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
if (!user || !user.refreshToken) {
|
||||
throw new UnauthorizedException('Refresh token not found');
|
||||
}
|
||||
|
||||
const refreshMatches = await bcrypt.compare(refreshToken, user.refreshToken);
|
||||
if (!refreshMatches) {
|
||||
throw new UnauthorizedException('Refresh token invalid');
|
||||
}
|
||||
|
||||
const tokens = await this.issueTokens(user);
|
||||
await this.updateRefreshToken(user.id, tokens.refreshToken);
|
||||
this.attachRefreshTokenCookie(reply, tokens.refreshToken);
|
||||
|
||||
return { accessToken: tokens.accessToken };
|
||||
}
|
||||
|
||||
async logout(userId: number, reply: FastifyReply) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
|
||||
reply.clearCookie(REFRESH_COOKIE, { path: '/' });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async issueTokens(user: any) {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
companyId: user.companyId,
|
||||
companyStatus: user.company?.status,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_ACCESS_SECRET') || 'access-secret',
|
||||
expiresIn: this.configService.get<string>('JWT_ACCESS_EXPIRES', '15m'),
|
||||
}),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET') || 'refresh-secret',
|
||||
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES', '7d'),
|
||||
}),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private async updateRefreshToken(userId: number, refreshToken: string) {
|
||||
const hashedRefresh = await bcrypt.hash(refreshToken, 10);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { refreshToken: hashedRefresh },
|
||||
});
|
||||
}
|
||||
|
||||
attachRefreshTokenCookie(reply: FastifyReply, token: string) {
|
||||
reply.setCookie(REFRESH_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: this.configService.get<string>('NODE_ENV') === 'production',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
});
|
||||
}
|
||||
}
|
||||
10
backend-nest/src/auth/dto/login.dto.ts
Normal file
10
backend-nest/src/auth/dto/login.dto.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
5
backend-nest/src/auth/guards/jwt-auth.guard.ts
Normal file
5
backend-nest/src/auth/guards/jwt-auth.guard.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
5
backend-nest/src/auth/guards/refresh-token.guard.ts
Normal file
5
backend-nest/src/auth/guards/refresh-token.guard.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {}
|
||||
20
backend-nest/src/auth/strategies/jwt.strategy.ts
Normal file
20
backend-nest/src/auth/strategies/jwt.strategy.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../types/jwt-payload.type';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_ACCESS_SECRET') || 'access-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
25
backend-nest/src/auth/strategies/refresh-token.strategy.ts
Normal file
25
backend-nest/src/auth/strategies/refresh-token.strategy.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { JwtPayload } from '../types/jwt-payload.type';
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(request: FastifyRequest) => request?.cookies?.['refresh_token'] || null,
|
||||
]),
|
||||
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET') || 'refresh-secret',
|
||||
passReqToCallback: true,
|
||||
ignoreExpiration: false,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(request: FastifyRequest, payload: JwtPayload) {
|
||||
const refreshToken = request.cookies?.['refresh_token'];
|
||||
return { ...payload, refreshToken };
|
||||
}
|
||||
}
|
||||
7
backend-nest/src/auth/types/jwt-payload.type.ts
Normal file
7
backend-nest/src/auth/types/jwt-payload.type.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export type JwtPayload = {
|
||||
sub: number;
|
||||
email: string;
|
||||
companyId: number;
|
||||
companyStatus?: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
||||
role: 'USER' | 'ADMIN';
|
||||
};
|
||||
27
backend-nest/src/common/guards/active-company.guard.ts
Normal file
27
backend-nest/src/common/guards/active-company.guard.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ActiveCompanyGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user?.sub) {
|
||||
throw new ForbiddenException('User context missing');
|
||||
}
|
||||
|
||||
const dbUser = await this.prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
if (!dbUser?.company || dbUser.company.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('Company must be active to complete this action');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
11
backend-nest/src/inventory/dto/purchase.dto.ts
Normal file
11
backend-nest/src/inventory/dto/purchase.dto.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { IsInt, IsPositive } from 'class-validator';
|
||||
|
||||
export class PurchaseDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
productId!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
quantity!: number;
|
||||
}
|
||||
24
backend-nest/src/inventory/inventory.controller.ts
Normal file
24
backend-nest/src/inventory/inventory.controller.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ActiveCompanyGuard } from '../common/guards/active-company.guard';
|
||||
import { PurchaseDto } from './dto/purchase.dto';
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
@ApiTags('inventory')
|
||||
@Controller('inventory')
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventoryService: InventoryService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.inventoryService.listProducts();
|
||||
}
|
||||
|
||||
@Post('purchase')
|
||||
@UseGuards(JwtAuthGuard, ActiveCompanyGuard)
|
||||
@ApiBearerAuth()
|
||||
async purchase(@Body() dto: PurchaseDto, @Req() req: any) {
|
||||
return this.inventoryService.purchaseProduct(req.user.sub, dto);
|
||||
}
|
||||
}
|
||||
12
backend-nest/src/inventory/inventory.module.ts
Normal file
12
backend-nest/src/inventory/inventory.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ActiveCompanyGuard } from '../common/guards/active-company.guard';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [InventoryController],
|
||||
providers: [InventoryService, ActiveCompanyGuard],
|
||||
})
|
||||
export class InventoryModule {}
|
||||
44
backend-nest/src/inventory/inventory.service.ts
Normal file
44
backend-nest/src/inventory/inventory.service.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PurchaseDto } from './dto/purchase.dto';
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listProducts() {
|
||||
return this.prisma.product.findMany({ include: { inventory: true } });
|
||||
}
|
||||
|
||||
async purchaseProduct(userId: number, dto: PurchaseDto) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: dto.productId },
|
||||
include: { inventory: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
if (!product.inventory || product.inventory.quantity < dto.quantity) {
|
||||
throw new BadRequestException('Insufficient stock');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.inventoryItem.update({
|
||||
where: { productId: dto.productId },
|
||||
data: { quantity: { decrement: dto.quantity } },
|
||||
}),
|
||||
this.prisma.order.create({
|
||||
data: {
|
||||
buyerId: userId,
|
||||
productId: dto.productId,
|
||||
quantity: dto.quantity,
|
||||
total: product.price.mul(dto.quantity),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { message: 'Purchase created successfully' };
|
||||
}
|
||||
}
|
||||
51
backend-nest/src/main.ts
Normal file
51
backend-nest/src/main.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import fastifyCookie, { FastifyCookieOptions } from '@fastify/cookie';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter(),
|
||||
);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
const cookieOptions: FastifyCookieOptions = {
|
||||
parseOptions: {
|
||||
sameSite: 'lax',
|
||||
},
|
||||
};
|
||||
|
||||
await app.register(fastifyCookie as any, cookieOptions);
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('SaveInMed Backend')
|
||||
.setDescription('API Gateway for users, inventory and payments')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth({
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Access token',
|
||||
})
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
|
||||
const port = configService.get<number>('PORT') || 3000;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
}
|
||||
bootstrap();
|
||||
9
backend-nest/src/prisma/prisma.module.ts
Normal file
9
backend-nest/src/prisma/prisma.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
backend-nest/src/prisma/prisma.service.ts
Normal file
13
backend-nest/src/prisma/prisma.service.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
18
backend-nest/src/users/dto/create-user.dto.ts
Normal file
18
backend-nest/src/users/dto/create-user.dto.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
companyName!: string;
|
||||
}
|
||||
17
backend-nest/src/users/users.controller.ts
Normal file
17
backend-nest/src/users/users.controller.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
async me(@Req() req: any) {
|
||||
return this.usersService.getSafeUser(req.user.sub);
|
||||
}
|
||||
}
|
||||
12
backend-nest/src/users/users.module.ts
Normal file
12
backend-nest/src/users/users.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
49
backend-nest/src/users/users.service.ts
Normal file
49
backend-nest/src/users/users.service.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async createWithCompany(dto: CreateUserDto, hashedPassword: string) {
|
||||
return this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
const company = await tx.company.create({
|
||||
data: {
|
||||
name: dto.companyName,
|
||||
},
|
||||
});
|
||||
|
||||
return tx.user.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
password: hashedPassword,
|
||||
companyId: company.id,
|
||||
},
|
||||
include: { company: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async findByEmail(email: string) {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
async findByEmailWithCompany(email: string) {
|
||||
return this.prisma.user.findUnique({ where: { email }, include: { company: true } });
|
||||
}
|
||||
|
||||
async findById(id: number) {
|
||||
return this.prisma.user.findUnique({ where: { id }, include: { company: true } });
|
||||
}
|
||||
|
||||
async getSafeUser(id: number) {
|
||||
const user = await this.findById(id);
|
||||
if (!user) return null;
|
||||
|
||||
const { password, refreshToken, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
13
backend-nest/src/webhooks/webhooks.controller.ts
Normal file
13
backend-nest/src/webhooks/webhooks.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('webhooks')
|
||||
@Controller('webhooks')
|
||||
export class WebhooksController {
|
||||
@Post('mercado-pago')
|
||||
@HttpCode(200)
|
||||
async handleMercadoPago(@Body() payload: any) {
|
||||
// In a real-world scenario, verify Mercado Pago signatures and process events.
|
||||
return { received: true, event: payload?.type ?? 'unknown' };
|
||||
}
|
||||
}
|
||||
7
backend-nest/src/webhooks/webhooks.module.ts
Normal file
7
backend-nest/src/webhooks/webhooks.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [WebhooksController],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
10
backend-nest/tsconfig.build.json
Normal file
10
backend-nest/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"noEmit": false,
|
||||
"incremental": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
19
backend-nest/tsconfig.json
Normal file
19
backend-nest/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue