From f72e6e3b0127786237cb9ace093250832a8e15b4 Mon Sep 17 00:00:00 2001 From: Diya Date: Sat, 28 Feb 2026 20:58:17 +0100 Subject: [PATCH] Version 1.0.0 --- .DS_Store | Bin 0 -> 6148 bytes Makefile | 14 ++ PROJECT_STRUCTURE.md | 148 +++++++++++++++++ backend/.DS_Store | Bin 0 -> 6148 bytes backend/.dockerignore | 5 + backend/Dockerfile | 17 ++ backend/README.md | 16 ++ backend/nest-cli.json | 5 + backend/package.json | 47 ++++++ backend/scripts/start-prod.sh | 10 ++ backend/src/.DS_Store | Bin 0 -> 8196 bytes backend/src/app.module.ts | 37 +++++ backend/src/audit/audit.controller.ts | 20 +++ backend/src/audit/audit.module.ts | 13 ++ backend/src/audit/audit.service.ts | 46 ++++++ backend/src/auth/auth.controller.ts | 88 ++++++++++ backend/src/auth/auth.module.ts | 14 ++ backend/src/auth/auth.service.ts | 101 ++++++++++++ backend/src/auth/dto/login.dto.ts | 11 ++ backend/src/auth/dto/refresh.dto.ts | 7 + backend/src/auth/guards/jwt-auth.guard.ts | 22 +++ .../auth/interfaces/jwt-payload.interface.ts | 8 + backend/src/auth/strategies/jwt.strategy.ts | 26 +++ backend/src/branches/branches.controller.ts | 18 +++ backend/src/branches/branches.module.ts | 12 ++ backend/src/branches/branches.service.ts | 16 ++ .../decorators/current-user.decorator.ts | 7 + .../src/common/decorators/public.decorator.ts | 4 + .../src/common/decorators/roles.decorator.ts | 5 + backend/src/common/guards/roles.guard.ts | 25 +++ backend/src/common/types/auth-user.type.ts | 8 + backend/src/common/utils/branch-scope.util.ts | 30 ++++ backend/src/database/data-source.ts | 4 + .../src/database/entities/audit-log.entity.ts | 31 ++++ .../src/database/entities/branch.entity.ts | 19 +++ backend/src/database/entities/shift.entity.ts | 44 +++++ backend/src/database/entities/task.entity.ts | 45 ++++++ backend/src/database/entities/user.entity.ts | 48 ++++++ .../src/database/enums/task-status.enum.ts | 4 + backend/src/database/enums/user-role.enum.ts | 6 + .../migrations/1700000000000-init-schema.ts | 116 ++++++++++++++ backend/src/database/run-migrations.ts | 13 ++ backend/src/database/seeds/seed.ts | 86 ++++++++++ backend/src/database/typeorm.config.ts | 27 ++++ backend/src/main.ts | 40 +++++ backend/src/shifts/dto/create-shift.dto.ts | 21 +++ backend/src/shifts/dto/update-shift.dto.ts | 4 + backend/src/shifts/shifts.controller.ts | 46 ++++++ backend/src/shifts/shifts.module.ts | 13 ++ backend/src/shifts/shifts.service.ts | 101 ++++++++++++ backend/src/tasks/dto/create-task.dto.ts | 15 ++ backend/src/tasks/dto/update-task.dto.ts | 4 + backend/src/tasks/tasks.controller.ts | 52 ++++++ backend/src/tasks/tasks.module.ts | 13 ++ backend/src/tasks/tasks.service.ts | 121 ++++++++++++++ backend/src/users/users.controller.ts | 26 +++ backend/src/users/users.module.ts | 13 ++ backend/src/users/users.service.ts | 51 ++++++ backend/tsconfig.build.json | 4 + backend/tsconfig.json | 22 +++ docker-compose.yml | 45 ++++++ frontend/.DS_Store | Bin 0 -> 6148 bytes frontend/.dockerignore | 5 + frontend/.env.local | 1 + frontend/.eslintrc.json | 3 + frontend/Dockerfile | 18 +++ frontend/README.md | 25 +++ frontend/components.json | 17 ++ frontend/next-env.d.ts | 4 + frontend/next.config.mjs | 4 + frontend/package.json | 27 ++++ frontend/postcss.config.js | 6 + frontend/src/.DS_Store | Bin 0 -> 6148 bytes .../src/app/api/backend/[...path]/route.ts | 70 ++++++++ frontend/src/app/globals.css | 27 ++++ frontend/src/app/layout.tsx | 16 ++ frontend/src/app/page.tsx | 150 ++++++++++++++++++ frontend/src/components/ui/alert.tsx | 16 ++ frontend/src/components/ui/button.tsx | 28 ++++ frontend/src/components/ui/card.tsx | 22 +++ frontend/src/components/ui/input.tsx | 18 +++ frontend/src/components/ui/label.tsx | 6 + frontend/src/lib/utils.ts | 3 + frontend/src/uikit/JsonViewer.tsx | 7 + frontend/src/uikit/PageShell.tsx | 5 + frontend/src/uikit/SectionTitle.tsx | 5 + frontend/tailwind.config.ts | 34 ++++ frontend/tsconfig.json | 23 +++ 88 files changed, 2354 insertions(+) create mode 100644 .DS_Store create mode 100644 Makefile create mode 100644 PROJECT_STRUCTURE.md create mode 100644 backend/.DS_Store create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/nest-cli.json create mode 100644 backend/package.json create mode 100644 backend/scripts/start-prod.sh create mode 100644 backend/src/.DS_Store create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/audit/audit.controller.ts create mode 100644 backend/src/audit/audit.module.ts create mode 100644 backend/src/audit/audit.service.ts create mode 100644 backend/src/auth/auth.controller.ts create mode 100644 backend/src/auth/auth.module.ts create mode 100644 backend/src/auth/auth.service.ts create mode 100644 backend/src/auth/dto/login.dto.ts create mode 100644 backend/src/auth/dto/refresh.dto.ts create mode 100644 backend/src/auth/guards/jwt-auth.guard.ts create mode 100644 backend/src/auth/interfaces/jwt-payload.interface.ts create mode 100644 backend/src/auth/strategies/jwt.strategy.ts create mode 100644 backend/src/branches/branches.controller.ts create mode 100644 backend/src/branches/branches.module.ts create mode 100644 backend/src/branches/branches.service.ts create mode 100644 backend/src/common/decorators/current-user.decorator.ts create mode 100644 backend/src/common/decorators/public.decorator.ts create mode 100644 backend/src/common/decorators/roles.decorator.ts create mode 100644 backend/src/common/guards/roles.guard.ts create mode 100644 backend/src/common/types/auth-user.type.ts create mode 100644 backend/src/common/utils/branch-scope.util.ts create mode 100644 backend/src/database/data-source.ts create mode 100644 backend/src/database/entities/audit-log.entity.ts create mode 100644 backend/src/database/entities/branch.entity.ts create mode 100644 backend/src/database/entities/shift.entity.ts create mode 100644 backend/src/database/entities/task.entity.ts create mode 100644 backend/src/database/entities/user.entity.ts create mode 100644 backend/src/database/enums/task-status.enum.ts create mode 100644 backend/src/database/enums/user-role.enum.ts create mode 100644 backend/src/database/migrations/1700000000000-init-schema.ts create mode 100644 backend/src/database/run-migrations.ts create mode 100644 backend/src/database/seeds/seed.ts create mode 100644 backend/src/database/typeorm.config.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/shifts/dto/create-shift.dto.ts create mode 100644 backend/src/shifts/dto/update-shift.dto.ts create mode 100644 backend/src/shifts/shifts.controller.ts create mode 100644 backend/src/shifts/shifts.module.ts create mode 100644 backend/src/shifts/shifts.service.ts create mode 100644 backend/src/tasks/dto/create-task.dto.ts create mode 100644 backend/src/tasks/dto/update-task.dto.ts create mode 100644 backend/src/tasks/tasks.controller.ts create mode 100644 backend/src/tasks/tasks.module.ts create mode 100644 backend/src/tasks/tasks.service.ts create mode 100644 backend/src/users/users.controller.ts create mode 100644 backend/src/users/users.module.ts create mode 100644 backend/src/users/users.service.ts create mode 100644 backend/tsconfig.build.json create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/.DS_Store create mode 100644 frontend/.dockerignore create mode 100644 frontend/.env.local create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.mjs create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/.DS_Store create mode 100644 frontend/src/app/api/backend/[...path]/route.ts create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/uikit/JsonViewer.tsx create mode 100644 frontend/src/uikit/PageShell.tsx create mode 100644 frontend/src/uikit/SectionTitle.tsx create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..10ed69bda25bfec70a3b36e785505306f262e148 GIT binary patch literal 6148 zcmeHKF;2r!47Jlrr0UX<@sF(K1R)CI0KEXD0V>gus)CInBM0CToP?Dla0zyvzpbiK z0%Ag_vL*lb<8S}(yhL$KM4UfXbD|j$rBK0Sf?+^pU9=~OS!9vp9@lhF)%te6ST;Rx zH~dEiO;MEVro>8mQcjbX*SE7Fuj&_Go0YsRcG~D& z4o&HXw$$KVdKk*6ej4)WIT;@za4JwgA8}%uz6xUP5AmVP@DWVgq8I&Xm9vA5qmsFm_;y`e>h-d128vi6=8t{O$BOdup>s$bl9WC<%X@I zrc;p0xQ|s1c0vhK9rh^U6mmr$odIW{%Rs7^BkuoKU!VWGNq*%FI0Ju*fiNhh#RRWp yy|wpp+-oEB8p^_Pt>QKX6KTbW^XBI9yxg*9B2v{+dxfY>L;*VEqj?ND zjpN)W+5aDo9JHeF+aX=gp2gCt!Kpglt9KfE7gb5S=lym>ATz4h;GT0G!3NZg`J#fX)dmMivu;Iu+M6rw3(Il|5o8la6@EbYNsL zF=*0BnRHUNva%->rK=+^)O1pjK^esXabVJc5K{%(pb@ocD|>z~z3`*GxK?ZK#7)cw z$AvFPn|lYZvyAp17`t8f3BfYbaR=b~L+biZ51wAQF}L>Nt6yJj?KHO+k1OZJ{kjR) z4X8=)P`ghp>QaXWe*MBp9>>-3as9`4t7GC?uAbX?zAo)Y5qe_k;T2Qg*Hn55Osvxfj@1yGemEq52)j7dHyYr=pWAr8u2a@k zgqAK=>n5~iKC>sh9e8n9&(9BM=czx=HC!#zIKGZG)=|*j3}`#6VqwXTx*95#xl60T z$#)gxGji*X_*20e&bUyz()H7*TYta$>G9U@zgvefULpAmzrJ3l*oyTo43zz!7lbr% z;2t?JW3-m}{QqY9^Z$GF8;MkLKpeQM18TNbuT_zSjXbyUE+3*d(77=$F{o28c>f6C e=kyChybnR;oXBEg5Ho1DX8VjfZ literal 0 HcmV?d00001 diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..ee33437 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from './auth/auth.module'; +import { BranchesModule } from './branches/branches.module'; +import { RolesGuard } from './common/guards/roles.guard'; +import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { typeOrmModuleOptions } from './database/typeorm.config'; +import { UsersModule } from './users/users.module'; +import { ShiftsModule } from './shifts/shifts.module'; +import { TasksModule } from './tasks/tasks.module'; +import { AuditModule } from './audit/audit.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + TypeOrmModule.forRoot(typeOrmModuleOptions()), + UsersModule, + AuthModule, + BranchesModule, + ShiftsModule, + TasksModule, + AuditModule, + ], + providers: [ + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + ], +}) +export class AppModule {} diff --git a/backend/src/audit/audit.controller.ts b/backend/src/audit/audit.controller.ts new file mode 100644 index 0000000..1c8afff --- /dev/null +++ b/backend/src/audit/audit.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from 'src/common/decorators/current-user.decorator'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { UserRole } from 'src/database/enums/user-role.enum'; +import { AuditService } from './audit.service'; + +@ApiTags('Audit') +@ApiBearerAuth() +@Controller('audit-logs') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get() + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR) + findAll(@CurrentUser() actor: AuthUser) { + return this.auditService.findAll(actor); + } +} diff --git a/backend/src/audit/audit.module.ts b/backend/src/audit/audit.module.ts new file mode 100644 index 0000000..6759c03 --- /dev/null +++ b/backend/src/audit/audit.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from 'src/database/entities/audit-log.entity'; +import { AuditController } from './audit.controller'; +import { AuditService } from './audit.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + controllers: [AuditController], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/backend/src/audit/audit.service.ts b/backend/src/audit/audit.service.ts new file mode 100644 index 0000000..a807b7b --- /dev/null +++ b/backend/src/audit/audit.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { scopedWhere } from 'src/common/utils/branch-scope.util'; +import { AuditLog } from 'src/database/entities/audit-log.entity'; +import { Repository } from 'typeorm'; + +export type AuditPayload = { + actor: AuthUser; + entityType: string; + entityId?: string; + action: 'create' | 'update' | 'delete' | 'done'; + branchId: string; + oldData?: Record | null; + newData?: Record | null; +}; + +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLog) + private readonly auditRepository: Repository, + ) {} + + async log(payload: AuditPayload): Promise { + const log = this.auditRepository.create({ + actorUserId: payload.actor.userId, + branchId: payload.branchId, + entityType: payload.entityType, + entityId: payload.entityId ?? null, + action: payload.action, + oldData: (payload.oldData ?? null) as AuditLog['oldData'], + newData: (payload.newData ?? null) as AuditLog['newData'], + }); + await this.auditRepository.save(log); + } + + async findAll(actor: AuthUser): Promise { + const where = scopedWhere(actor, {}); + return this.auditRepository.find({ + where, + order: { createdAt: 'DESC' }, + take: 200, + }); + } +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..aaa685f --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,88 @@ +import { + Body, + Controller, + Get, + HttpCode, + Post, + Req, + Res, + UnauthorizedException, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { CurrentUser } from 'src/common/decorators/current-user.decorator'; +import { Public } from 'src/common/decorators/public.decorator'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { LoginDto } from './dto/login.dto'; +import { RefreshDto } from './dto/refresh.dto'; +import { AuthService } from './auth.service'; + +@ApiTags('Auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post('login') + @HttpCode(200) + async login(@Body() dto: LoginDto, @Res({ passthrough: true }) response: Response) { + const result = await this.authService.login(dto.cardNumber, dto.password); + this.setRefreshCookie(response, result.refreshToken, result.refreshExpiresAt); + + return { + accessToken: result.accessToken, + user: result.user, + }; + } + + @Public() + @Post('refresh') + @HttpCode(200) + async refresh( + @Req() request: Request, + @Body() dto: RefreshDto, + @Res({ passthrough: true }) response: Response, + ) { + const tokenFromCookie = request.cookies?.refresh_token as string | undefined; + const refreshToken = tokenFromCookie ?? dto.refreshToken; + + if (!refreshToken) { + throw new UnauthorizedException('Refresh token missing'); + } + + const payload = await this.authService.verifyRefreshToken(refreshToken); + const result = await this.authService.refresh(payload.sub, refreshToken); + + this.setRefreshCookie(response, result.refreshToken, result.refreshExpiresAt); + + return { + accessToken: result.accessToken, + user: result.user, + }; + } + + @ApiBearerAuth() + @Post('logout') + @HttpCode(200) + async logout(@CurrentUser() user: AuthUser, @Res({ passthrough: true }) response: Response) { + await this.authService.logout(user.userId); + response.clearCookie('refresh_token', { path: '/api/auth/refresh' }); + return { message: 'Logged out' }; + } + + @ApiBearerAuth() + @Get('me') + me(@CurrentUser() user: AuthUser) { + return user; + } + + private setRefreshCookie(response: Response, token: string, expiresAt: Date): void { + response.cookie('refresh_token', token, { + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/api/auth/refresh', + expires: expiresAt, + }); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..62a4fb8 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { UsersModule } from 'src/users/users.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [UsersModule, JwtModule.register({})], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..a4d8b98 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,101 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { User } from 'src/database/entities/user.entity'; +import { JwtPayload } from './interfaces/jwt-payload.interface'; +import { UsersService } from 'src/users/users.service'; + +@Injectable() +export class AuthService { + private readonly accessTtlSeconds = 15 * 60; + private readonly refreshTtlSeconds = 7 * 24 * 60 * 60; + + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async login(cardNumber: string, password: string) { + const user = await this.usersService.findByCardNumber(cardNumber); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + + const passwordMatch = await bcrypt.compare(password, user.passwordHash); + if (!passwordMatch) { + throw new UnauthorizedException('Invalid credentials'); + } + + return this.issueTokens(user); + } + + async refresh(userId: string, refreshToken: string) { + const user = await this.usersService.findById(userId); + if (!user || !user.refreshTokenHash || !user.refreshTokenExpiresAt) { + throw new UnauthorizedException('Invalid refresh token'); + } + + if (new Date(user.refreshTokenExpiresAt).getTime() < Date.now()) { + throw new UnauthorizedException('Refresh token expired'); + } + + const tokenMatch = await bcrypt.compare(refreshToken, user.refreshTokenHash); + if (!tokenMatch) { + throw new UnauthorizedException('Invalid refresh token'); + } + + return this.issueTokens(user); + } + + async verifyRefreshToken(token: string): Promise { + try { + return await this.jwtService.verifyAsync(token, { + secret: this.configService.getOrThrow('JWT_REFRESH_SECRET'), + }); + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + async logout(userId: string): Promise { + await this.usersService.clearRefreshToken(userId); + } + + private async issueTokens(user: User) { + const payload: JwtPayload = { + sub: user.id, + cardNumber: user.cardNumber, + role: user.role, + branchId: user.branchId, + }; + + const accessToken = await this.jwtService.signAsync(payload, { + secret: this.configService.getOrThrow('JWT_ACCESS_SECRET'), + expiresIn: this.accessTtlSeconds, + }); + + const refreshToken = await this.jwtService.signAsync(payload, { + secret: this.configService.getOrThrow('JWT_REFRESH_SECRET'), + expiresIn: this.refreshTtlSeconds, + }); + + const refreshTokenHash = await bcrypt.hash(refreshToken, 10); + const refreshExpiresAt = new Date(Date.now() + this.refreshTtlSeconds * 1000); + + await this.usersService.updateRefreshToken(user.id, refreshTokenHash, refreshExpiresAt); + + return { + accessToken, + refreshToken, + refreshExpiresAt, + user: { + id: user.id, + cardNumber: user.cardNumber, + role: user.role, + branchId: user.branchId, + }, + }; + } +} diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..f7e16e3 --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,11 @@ +import { IsString, Length } from 'class-validator'; + +export class LoginDto { + @IsString() + @Length(1, 30) + cardNumber: string; + + @IsString() + @Length(8, 100) + password: string; +} diff --git a/backend/src/auth/dto/refresh.dto.ts b/backend/src/auth/dto/refresh.dto.ts new file mode 100644 index 0000000..eda224f --- /dev/null +++ b/backend/src/auth/dto/refresh.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class RefreshDto { + @IsOptional() + @IsString() + refreshToken?: string; +} diff --git a/backend/src/auth/guards/jwt-auth.guard.ts b/backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..f146a28 --- /dev/null +++ b/backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,22 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from 'src/common/decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + return super.canActivate(context); + } +} diff --git a/backend/src/auth/interfaces/jwt-payload.interface.ts b/backend/src/auth/interfaces/jwt-payload.interface.ts new file mode 100644 index 0000000..4559d78 --- /dev/null +++ b/backend/src/auth/interfaces/jwt-payload.interface.ts @@ -0,0 +1,8 @@ +import { UserRole } from 'src/database/enums/user-role.enum'; + +export interface JwtPayload { + sub: string; + cardNumber: string; + role: UserRole; + branchId: string; +} diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..67a4af7 --- /dev/null +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; +import { AuthUser } from 'src/common/types/auth-user.type'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.getOrThrow('JWT_ACCESS_SECRET'), + }); + } + + validate(payload: JwtPayload): AuthUser { + return { + userId: payload.sub, + cardNumber: payload.cardNumber, + role: payload.role, + branchId: payload.branchId, + }; + } +} diff --git a/backend/src/branches/branches.controller.ts b/backend/src/branches/branches.controller.ts new file mode 100644 index 0000000..75d2a7f --- /dev/null +++ b/backend/src/branches/branches.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { UserRole } from 'src/database/enums/user-role.enum'; +import { BranchesService } from './branches.service'; + +@ApiTags('Branches') +@ApiBearerAuth() +@Controller('branches') +export class BranchesController { + constructor(private readonly branchesService: BranchesService) {} + + @Get() + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR) + findAll() { + return this.branchesService.findAll(); + } +} diff --git a/backend/src/branches/branches.module.ts b/backend/src/branches/branches.module.ts new file mode 100644 index 0000000..4ddb7f0 --- /dev/null +++ b/backend/src/branches/branches.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Branch } from 'src/database/entities/branch.entity'; +import { BranchesController } from './branches.controller'; +import { BranchesService } from './branches.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Branch])], + controllers: [BranchesController], + providers: [BranchesService], +}) +export class BranchesModule {} diff --git a/backend/src/branches/branches.service.ts b/backend/src/branches/branches.service.ts new file mode 100644 index 0000000..8ea8ea4 --- /dev/null +++ b/backend/src/branches/branches.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Branch } from 'src/database/entities/branch.entity'; +import { Repository } from 'typeorm'; + +@Injectable() +export class BranchesService { + constructor( + @InjectRepository(Branch) + private readonly branchesRepository: Repository, + ) {} + + findAll(): Promise { + return this.branchesRepository.find({ order: { name: 'ASC' } }); + } +} diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..221c621 --- /dev/null +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,7 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthUser } from '../types/auth-user.type'; + +export const CurrentUser = createParamDecorator((_: unknown, ctx: ExecutionContext): AuthUser => { + const request = ctx.switchToHttp().getRequest(); + return request.user as AuthUser; +}); diff --git a/backend/src/common/decorators/public.decorator.ts b/backend/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/backend/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/src/common/decorators/roles.decorator.ts b/backend/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..70a6e69 --- /dev/null +++ b/backend/src/common/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from 'src/database/enums/user-role.enum'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/common/guards/roles.guard.ts b/backend/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..2844d78 --- /dev/null +++ b/backend/src/common/guards/roles.guard.ts @@ -0,0 +1,25 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { AuthUser } from '../types/auth-user.type'; +import { UserRole } from 'src/database/enums/user-role.enum'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user as AuthUser; + return requiredRoles.includes(user.role); + } +} diff --git a/backend/src/common/types/auth-user.type.ts b/backend/src/common/types/auth-user.type.ts new file mode 100644 index 0000000..9439963 --- /dev/null +++ b/backend/src/common/types/auth-user.type.ts @@ -0,0 +1,8 @@ +import { UserRole } from 'src/database/enums/user-role.enum'; + +export type AuthUser = { + userId: string; + cardNumber: string; + role: UserRole; + branchId: string; +}; diff --git a/backend/src/common/utils/branch-scope.util.ts b/backend/src/common/utils/branch-scope.util.ts new file mode 100644 index 0000000..8993b66 --- /dev/null +++ b/backend/src/common/utils/branch-scope.util.ts @@ -0,0 +1,30 @@ +import { ForbiddenException } from '@nestjs/common'; +import { AuthUser } from '../types/auth-user.type'; +import { UserRole } from 'src/database/enums/user-role.enum'; + +export function isSuperAdmin(user: AuthUser): boolean { + return user.role === UserRole.SUPER_ADMIN; +} + +export function scopedWhere( + user: AuthUser, + where: T, +): T & { branchId?: string } { + if (isSuperAdmin(user)) { + return where as T & { branchId?: string }; + } + return { ...where, branchId: user.branchId }; +} + +export function resolveCreateBranchId(user: AuthUser, requestedBranchId?: string): string { + if (isSuperAdmin(user) && requestedBranchId) { + return requestedBranchId; + } + return user.branchId; +} + +export function ensureBranchAccess(user: AuthUser, branchId: string): void { + if (!isSuperAdmin(user) && user.branchId !== branchId) { + throw new ForbiddenException('Forbidden branch access'); + } +} diff --git a/backend/src/database/data-source.ts b/backend/src/database/data-source.ts new file mode 100644 index 0000000..c1049d9 --- /dev/null +++ b/backend/src/database/data-source.ts @@ -0,0 +1,4 @@ +import { DataSource } from 'typeorm'; +import { dataSourceOptions } from './typeorm.config'; + +export const AppDataSource = new DataSource(dataSourceOptions()); diff --git a/backend/src/database/entities/audit-log.entity.ts b/backend/src/database/entities/audit-log.entity.ts new file mode 100644 index 0000000..cdb53c6 --- /dev/null +++ b/backend/src/database/entities/audit-log.entity.ts @@ -0,0 +1,31 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('audit_logs') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + branchId: string; + + @Column({ type: 'varchar', length: 80 }) + entityType: string; + + @Column({ type: 'uuid', nullable: true }) + entityId: string | null; + + @Column({ type: 'varchar', length: 40 }) + action: string; + + @Column({ type: 'uuid' }) + actorUserId: string; + + @Column({ type: 'jsonb', nullable: true }) + oldData: Record | null; + + @Column({ type: 'jsonb', nullable: true }) + newData: Record | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; +} diff --git a/backend/src/database/entities/branch.entity.ts b/backend/src/database/entities/branch.entity.ts new file mode 100644 index 0000000..e8d8a3b --- /dev/null +++ b/backend/src/database/entities/branch.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('branches') +export class Branch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 120, unique: true }) + name: string; + + @Column({ type: 'varchar', length: 40, unique: true }) + code: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/database/entities/shift.entity.ts b/backend/src/database/entities/shift.entity.ts new file mode 100644 index 0000000..4a0a368 --- /dev/null +++ b/backend/src/database/entities/shift.entity.ts @@ -0,0 +1,44 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +@Entity('shifts') +export class Shift { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + branchId: string; + + @ManyToOne(() => Branch, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'branchId' }) + branch: Branch; + + @Column({ type: 'varchar', length: 180 }) + name: string; + + @Column({ type: 'timestamptz' }) + startsAt: Date; + + @Column({ type: 'timestamptz' }) + endsAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'uuid', nullable: true }) + createdBy: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/database/entities/task.entity.ts b/backend/src/database/entities/task.entity.ts new file mode 100644 index 0000000..ed56ab8 --- /dev/null +++ b/backend/src/database/entities/task.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Branch } from './branch.entity'; +import { TaskStatus } from '../enums/task-status.enum'; + +@Entity('tasks') +export class Task { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + branchId: string; + + @ManyToOne(() => Branch, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'branchId' }) + branch: Branch; + + @Column({ type: 'varchar', length: 180 }) + title: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'enum', enum: TaskStatus, default: TaskStatus.PENDING }) + status: TaskStatus; + + @Column({ type: 'timestamptz', nullable: true }) + doneAt: Date | null; + + @Column({ type: 'uuid', nullable: true }) + doneBy: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/database/entities/user.entity.ts b/backend/src/database/entities/user.entity.ts new file mode 100644 index 0000000..4faa218 --- /dev/null +++ b/backend/src/database/entities/user.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Branch } from './branch.entity'; +import { UserRole } from '../enums/user-role.enum'; + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 30, unique: true }) + cardNumber: string; + + @Column({ type: 'varchar', length: 255 }) + passwordHash: string; + + @Column({ + type: 'enum', + enum: UserRole, + }) + role: UserRole; + + @Column({ type: 'uuid' }) + branchId: string; + + @ManyToOne(() => Branch, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'branchId' }) + branch: Branch; + + @Column({ type: 'varchar', length: 255, nullable: true }) + refreshTokenHash: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + refreshTokenExpiresAt: Date | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/database/enums/task-status.enum.ts b/backend/src/database/enums/task-status.enum.ts new file mode 100644 index 0000000..eced7f4 --- /dev/null +++ b/backend/src/database/enums/task-status.enum.ts @@ -0,0 +1,4 @@ +export enum TaskStatus { + PENDING = 'pending', + DONE = 'done', +} diff --git a/backend/src/database/enums/user-role.enum.ts b/backend/src/database/enums/user-role.enum.ts new file mode 100644 index 0000000..36fbf4c --- /dev/null +++ b/backend/src/database/enums/user-role.enum.ts @@ -0,0 +1,6 @@ +export enum UserRole { + SUPER_ADMIN = 'SuperAdmin', + ADMIN = 'Admin', + SUPERVISOR = 'Supervisor', + WORKER = 'Worker', +} diff --git a/backend/src/database/migrations/1700000000000-init-schema.ts b/backend/src/database/migrations/1700000000000-init-schema.ts new file mode 100644 index 0000000..dd664a0 --- /dev/null +++ b/backend/src/database/migrations/1700000000000-init-schema.ts @@ -0,0 +1,116 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitSchema1700000000000 implements MigrationInterface { + name = 'InitSchema1700000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`); + + await queryRunner.query( + `CREATE TYPE "public"."users_role_enum" AS ENUM('SuperAdmin', 'Admin', 'Supervisor', 'Worker')`, + ); + await queryRunner.query(`CREATE TYPE "public"."tasks_status_enum" AS ENUM('pending', 'done')`); + + await queryRunner.query(` + CREATE TABLE "branches" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "name" character varying(120) NOT NULL, + "code" character varying(40) NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_branches_id" PRIMARY KEY ("id"), + CONSTRAINT "UQ_branches_name" UNIQUE ("name"), + CONSTRAINT "UQ_branches_code" UNIQUE ("code") + ) + `); + + await queryRunner.query(` + CREATE TABLE "users" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "cardNumber" character varying(30) NOT NULL, + "passwordHash" character varying(255) NOT NULL, + "role" "public"."users_role_enum" NOT NULL, + "branchId" uuid NOT NULL, + "refreshTokenHash" character varying(255), + "refreshTokenExpiresAt" TIMESTAMPTZ, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_users_id" PRIMARY KEY ("id"), + CONSTRAINT "UQ_users_cardNumber" UNIQUE ("cardNumber"), + CONSTRAINT "FK_users_branch" FOREIGN KEY ("branchId") REFERENCES "branches"("id") ON DELETE RESTRICT + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_users_branchId" ON "users" ("branchId")`); + + await queryRunner.query(` + CREATE TABLE "shifts" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "branchId" uuid NOT NULL, + "name" character varying(180) NOT NULL, + "startsAt" TIMESTAMPTZ NOT NULL, + "endsAt" TIMESTAMPTZ NOT NULL, + "notes" text, + "createdBy" uuid, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_shifts_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_shifts_branch" FOREIGN KEY ("branchId") REFERENCES "branches"("id") ON DELETE RESTRICT + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_shifts_branchId" ON "shifts" ("branchId")`); + + await queryRunner.query(` + CREATE TABLE "tasks" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "branchId" uuid NOT NULL, + "title" character varying(180) NOT NULL, + "description" text, + "status" "public"."tasks_status_enum" NOT NULL DEFAULT 'pending', + "doneAt" TIMESTAMPTZ, + "doneBy" uuid, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_tasks_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_tasks_branch" FOREIGN KEY ("branchId") REFERENCES "branches"("id") ON DELETE RESTRICT + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_tasks_branchId" ON "tasks" ("branchId")`); + + await queryRunner.query(` + CREATE TABLE "audit_logs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "branchId" uuid NOT NULL, + "entityType" character varying(80) NOT NULL, + "entityId" uuid, + "action" character varying(40) NOT NULL, + "actorUserId" uuid NOT NULL, + "oldData" jsonb, + "newData" jsonb, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_audit_logs_id" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_audit_logs_branchId" ON "audit_logs" ("branchId")`); + await queryRunner.query(`CREATE INDEX "IDX_audit_logs_entityType_entityId" ON "audit_logs" ("entityType", "entityId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_entityType_entityId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_branchId"`); + await queryRunner.query(`DROP TABLE "audit_logs"`); + + await queryRunner.query(`DROP INDEX "public"."IDX_tasks_branchId"`); + await queryRunner.query(`DROP TABLE "tasks"`); + + await queryRunner.query(`DROP INDEX "public"."IDX_shifts_branchId"`); + await queryRunner.query(`DROP TABLE "shifts"`); + + await queryRunner.query(`DROP INDEX "public"."IDX_users_branchId"`); + await queryRunner.query(`DROP TABLE "users"`); + + await queryRunner.query(`DROP TABLE "branches"`); + + await queryRunner.query(`DROP TYPE "public"."tasks_status_enum"`); + await queryRunner.query(`DROP TYPE "public"."users_role_enum"`); + } +} diff --git a/backend/src/database/run-migrations.ts b/backend/src/database/run-migrations.ts new file mode 100644 index 0000000..804de16 --- /dev/null +++ b/backend/src/database/run-migrations.ts @@ -0,0 +1,13 @@ +import { AppDataSource } from './data-source'; + +async function runMigrations(): Promise { + await AppDataSource.initialize(); + await AppDataSource.runMigrations(); + await AppDataSource.destroy(); +} + +runMigrations().catch((error) => { + // eslint-disable-next-line no-console + console.error('Migration run failed:', error); + process.exit(1); +}); diff --git a/backend/src/database/seeds/seed.ts b/backend/src/database/seeds/seed.ts new file mode 100644 index 0000000..162ea9b --- /dev/null +++ b/backend/src/database/seeds/seed.ts @@ -0,0 +1,86 @@ +import * as bcrypt from 'bcrypt'; +import { AppDataSource } from '../data-source'; +import { Branch } from '../entities/branch.entity'; +import { User } from '../entities/user.entity'; +import { UserRole } from '../enums/user-role.enum'; + +const DEFAULT_PASSWORD = 'Password123'; + +type SeedUser = { + cardNumber: string; + role: UserRole; + branchCode: string; +}; + +const BRANCHES = [ + { code: 'A', name: 'Branch A' }, + { code: 'B', name: 'Branch B' }, +]; + +const USERS: SeedUser[] = [ + { cardNumber: '100000', role: UserRole.SUPER_ADMIN, branchCode: 'A' }, + { cardNumber: '200000', role: UserRole.ADMIN, branchCode: 'A' }, + { cardNumber: '300000', role: UserRole.SUPERVISOR, branchCode: 'B' }, + { cardNumber: '400000', role: UserRole.WORKER, branchCode: 'B' }, +]; + +async function upsertBranches(): Promise> { + const branchRepo = AppDataSource.getRepository(Branch); + const branchMap = new Map(); + + for (const branchInput of BRANCHES) { + let branch = await branchRepo.findOne({ where: { code: branchInput.code } }); + if (!branch) { + branch = branchRepo.create(branchInput); + } else { + branch.name = branchInput.name; + } + const saved = await branchRepo.save(branch); + branchMap.set(saved.code, saved); + } + + return branchMap; +} + +async function upsertUsers(branches: Map): Promise { + const userRepo = AppDataSource.getRepository(User); + const passwordHash = await bcrypt.hash(DEFAULT_PASSWORD, 10); + + for (const seedUser of USERS) { + const branch = branches.get(seedUser.branchCode); + if (!branch) { + throw new Error(`Branch with code ${seedUser.branchCode} not found during seed`); + } + + let user = await userRepo.findOne({ where: { cardNumber: seedUser.cardNumber } }); + if (!user) { + user = userRepo.create({ + cardNumber: seedUser.cardNumber, + role: seedUser.role, + branchId: branch.id, + passwordHash, + refreshTokenHash: null, + refreshTokenExpiresAt: null, + }); + } else { + user.role = seedUser.role; + user.branchId = branch.id; + user.passwordHash = passwordHash; + } + + await userRepo.save(user); + } +} + +async function runSeed(): Promise { + await AppDataSource.initialize(); + const branches = await upsertBranches(); + await upsertUsers(branches); + await AppDataSource.destroy(); +} + +runSeed().catch((error) => { + // eslint-disable-next-line no-console + console.error('Seed failed:', error); + process.exit(1); +}); diff --git a/backend/src/database/typeorm.config.ts b/backend/src/database/typeorm.config.ts new file mode 100644 index 0000000..e6bf751 --- /dev/null +++ b/backend/src/database/typeorm.config.ts @@ -0,0 +1,27 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { DataSourceOptions } from 'typeorm'; +import { AuditLog } from './entities/audit-log.entity'; +import { Branch } from './entities/branch.entity'; +import { Shift } from './entities/shift.entity'; +import { Task } from './entities/task.entity'; +import { User } from './entities/user.entity'; +import { InitSchema1700000000000 } from './migrations/1700000000000-init-schema'; + +export const dataSourceOptions = (): DataSourceOptions => ({ + type: 'postgres', + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT ?? 5432), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + synchronize: false, + logging: false, + entities: [Branch, User, Shift, Task, AuditLog], + migrations: [InitSchema1700000000000], + migrationsTableName: 'typeorm_migrations', +}); + +export const typeOrmModuleOptions = (): TypeOrmModuleOptions => ({ + ...(dataSourceOptions() as TypeOrmModuleOptions), + autoLoadEntities: false, +}); diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..e07d411 --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,40 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import * as cookieParser from 'cookie-parser'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('api'); + + app.use(cookieParser()); + + app.enableCors({ + origin: true, + credentials: true, + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const swaggerConfig = new DocumentBuilder() + .setTitle('Work System API') + .setDescription('Backend API documentation') + .setVersion('1.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/docs', app, document); + + const appPort = Number(process.env.APP_PORT ?? 3000); + await app.listen(appPort); +} + +bootstrap(); diff --git a/backend/src/shifts/dto/create-shift.dto.ts b/backend/src/shifts/dto/create-shift.dto.ts new file mode 100644 index 0000000..f9a19f1 --- /dev/null +++ b/backend/src/shifts/dto/create-shift.dto.ts @@ -0,0 +1,21 @@ +import { IsDateString, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +export class CreateShiftDto { + @IsString() + @MaxLength(180) + name: string; + + @IsDateString() + startsAt: string; + + @IsDateString() + endsAt: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsUUID() + branchId?: string; +} diff --git a/backend/src/shifts/dto/update-shift.dto.ts b/backend/src/shifts/dto/update-shift.dto.ts new file mode 100644 index 0000000..a0ad60f --- /dev/null +++ b/backend/src/shifts/dto/update-shift.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateShiftDto } from './create-shift.dto'; + +export class UpdateShiftDto extends PartialType(CreateShiftDto) {} diff --git a/backend/src/shifts/shifts.controller.ts b/backend/src/shifts/shifts.controller.ts new file mode 100644 index 0000000..bd8af7f --- /dev/null +++ b/backend/src/shifts/shifts.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from 'src/common/decorators/current-user.decorator'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { UserRole } from 'src/database/enums/user-role.enum'; +import { CreateShiftDto } from './dto/create-shift.dto'; +import { UpdateShiftDto } from './dto/update-shift.dto'; +import { ShiftsService } from './shifts.service'; + +@ApiTags('Shifts') +@ApiBearerAuth() +@Controller('shifts') +export class ShiftsController { + constructor(private readonly shiftsService: ShiftsService) {} + + @Get() + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.WORKER) + findAll(@CurrentUser() actor: AuthUser) { + return this.shiftsService.findAll(actor); + } + + @Get(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.WORKER) + findOne(@Param('id') id: string, @CurrentUser() actor: AuthUser) { + return this.shiftsService.findOne(id, actor); + } + + @Post() + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR) + create(@Body() dto: CreateShiftDto, @CurrentUser() actor: AuthUser) { + return this.shiftsService.create(dto, actor); + } + + @Patch(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR) + update(@Param('id') id: string, @Body() dto: UpdateShiftDto, @CurrentUser() actor: AuthUser) { + return this.shiftsService.update(id, dto, actor); + } + + @Delete(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN) + remove(@Param('id') id: string, @CurrentUser() actor: AuthUser) { + return this.shiftsService.remove(id, actor); + } +} diff --git a/backend/src/shifts/shifts.module.ts b/backend/src/shifts/shifts.module.ts new file mode 100644 index 0000000..aa3dc70 --- /dev/null +++ b/backend/src/shifts/shifts.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditModule } from 'src/audit/audit.module'; +import { Shift } from 'src/database/entities/shift.entity'; +import { ShiftsController } from './shifts.controller'; +import { ShiftsService } from './shifts.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Shift]), AuditModule], + controllers: [ShiftsController], + providers: [ShiftsService], +}) +export class ShiftsModule {} diff --git a/backend/src/shifts/shifts.service.ts b/backend/src/shifts/shifts.service.ts new file mode 100644 index 0000000..58989b7 --- /dev/null +++ b/backend/src/shifts/shifts.service.ts @@ -0,0 +1,101 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { resolveCreateBranchId, scopedWhere } from 'src/common/utils/branch-scope.util'; +import { Shift } from 'src/database/entities/shift.entity'; +import { Repository } from 'typeorm'; +import { AuditService } from 'src/audit/audit.service'; +import { CreateShiftDto } from './dto/create-shift.dto'; +import { UpdateShiftDto } from './dto/update-shift.dto'; + +@Injectable() +export class ShiftsService { + constructor( + @InjectRepository(Shift) + private readonly shiftsRepository: Repository, + private readonly auditService: AuditService, + ) {} + + findAll(actor: AuthUser): Promise { + return this.shiftsRepository.find({ + where: scopedWhere(actor, {}), + order: { createdAt: 'DESC' }, + }); + } + + async findOne(id: string, actor: AuthUser): Promise { + const shift = await this.shiftsRepository.findOne({ where: scopedWhere(actor, { id }) }); + if (!shift) { + throw new NotFoundException('Shift not found'); + } + return shift; + } + + async create(dto: CreateShiftDto, actor: AuthUser): Promise { + const branchId = resolveCreateBranchId(actor, dto.branchId); + + const shift = this.shiftsRepository.create({ + branchId, + name: dto.name, + startsAt: new Date(dto.startsAt), + endsAt: new Date(dto.endsAt), + notes: dto.notes ?? null, + createdBy: actor.userId, + }); + + const created = await this.shiftsRepository.save(shift); + + await this.auditService.log({ + actor, + action: 'create', + entityType: 'Shift', + entityId: created.id, + branchId: created.branchId, + newData: created as unknown as Record, + }); + + return created; + } + + async update(id: string, dto: UpdateShiftDto, actor: AuthUser): Promise { + const shift = await this.findOne(id, actor); + const oldData = { ...shift } as unknown as Record; + + if (dto.name !== undefined) shift.name = dto.name; + if (dto.startsAt !== undefined) shift.startsAt = new Date(dto.startsAt); + if (dto.endsAt !== undefined) shift.endsAt = new Date(dto.endsAt); + if (dto.notes !== undefined) shift.notes = dto.notes; + + const updated = await this.shiftsRepository.save(shift); + + await this.auditService.log({ + actor, + action: 'update', + entityType: 'Shift', + entityId: updated.id, + branchId: updated.branchId, + oldData, + newData: updated as unknown as Record, + }); + + return updated; + } + + async remove(id: string, actor: AuthUser): Promise<{ message: string }> { + const shift = await this.findOne(id, actor); + const oldData = { ...shift } as unknown as Record; + + await this.shiftsRepository.delete(shift.id); + + await this.auditService.log({ + actor, + action: 'delete', + entityType: 'Shift', + entityId: shift.id, + branchId: shift.branchId, + oldData, + }); + + return { message: 'Shift deleted' }; + } +} diff --git a/backend/src/tasks/dto/create-task.dto.ts b/backend/src/tasks/dto/create-task.dto.ts new file mode 100644 index 0000000..b4e770b --- /dev/null +++ b/backend/src/tasks/dto/create-task.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +export class CreateTaskDto { + @IsString() + @MaxLength(180) + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + branchId?: string; +} diff --git a/backend/src/tasks/dto/update-task.dto.ts b/backend/src/tasks/dto/update-task.dto.ts new file mode 100644 index 0000000..3e714e3 --- /dev/null +++ b/backend/src/tasks/dto/update-task.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTaskDto } from './create-task.dto'; + +export class UpdateTaskDto extends PartialType(CreateTaskDto) {} diff --git a/backend/src/tasks/tasks.controller.ts b/backend/src/tasks/tasks.controller.ts new file mode 100644 index 0000000..4676de6 --- /dev/null +++ b/backend/src/tasks/tasks.controller.ts @@ -0,0 +1,52 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from 'src/common/decorators/current-user.decorator'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { UserRole } from 'src/database/enums/user-role.enum'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; +import { TasksService } from './tasks.service'; + +@ApiTags('Tasks') +@ApiBearerAuth() +@Controller('tasks') +export class TasksController { + constructor(private readonly tasksService: TasksService) {} + + @Get() + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.WORKER) + findAll(@CurrentUser() actor: AuthUser) { + return this.tasksService.findAll(actor); + } + + @Get(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.WORKER) + findOne(@Param('id') id: string, @CurrentUser() actor: AuthUser) { + return this.tasksService.findOne(id, actor); + } + + @Post() + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR) + create(@Body() dto: CreateTaskDto, @CurrentUser() actor: AuthUser) { + return this.tasksService.create(dto, actor); + } + + @Patch(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR) + update(@Param('id') id: string, @Body() dto: UpdateTaskDto, @CurrentUser() actor: AuthUser) { + return this.tasksService.update(id, dto, actor); + } + + @Patch(':id/done') + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.WORKER) + done(@Param('id') id: string, @CurrentUser() actor: AuthUser) { + return this.tasksService.done(id, actor); + } + + @Delete(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN) + remove(@Param('id') id: string, @CurrentUser() actor: AuthUser) { + return this.tasksService.remove(id, actor); + } +} diff --git a/backend/src/tasks/tasks.module.ts b/backend/src/tasks/tasks.module.ts new file mode 100644 index 0000000..6e333c2 --- /dev/null +++ b/backend/src/tasks/tasks.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditModule } from 'src/audit/audit.module'; +import { Task } from 'src/database/entities/task.entity'; +import { TasksController } from './tasks.controller'; +import { TasksService } from './tasks.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Task]), AuditModule], + controllers: [TasksController], + providers: [TasksService], +}) +export class TasksModule {} diff --git a/backend/src/tasks/tasks.service.ts b/backend/src/tasks/tasks.service.ts new file mode 100644 index 0000000..aae5408 --- /dev/null +++ b/backend/src/tasks/tasks.service.ts @@ -0,0 +1,121 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AuditService } from 'src/audit/audit.service'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { resolveCreateBranchId, scopedWhere } from 'src/common/utils/branch-scope.util'; +import { Task } from 'src/database/entities/task.entity'; +import { TaskStatus } from 'src/database/enums/task-status.enum'; +import { Repository } from 'typeorm'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; + +@Injectable() +export class TasksService { + constructor( + @InjectRepository(Task) + private readonly tasksRepository: Repository, + private readonly auditService: AuditService, + ) {} + + findAll(actor: AuthUser): Promise { + return this.tasksRepository.find({ + where: scopedWhere(actor, {}), + order: { createdAt: 'DESC' }, + }); + } + + async findOne(id: string, actor: AuthUser): Promise { + const task = await this.tasksRepository.findOne({ where: scopedWhere(actor, { id }) }); + if (!task) { + throw new NotFoundException('Task not found'); + } + return task; + } + + async create(dto: CreateTaskDto, actor: AuthUser): Promise { + const branchId = resolveCreateBranchId(actor, dto.branchId); + + const task = this.tasksRepository.create({ + title: dto.title, + description: dto.description ?? null, + branchId, + status: TaskStatus.PENDING, + }); + + const created = await this.tasksRepository.save(task); + + await this.auditService.log({ + actor, + action: 'create', + entityType: 'Task', + entityId: created.id, + branchId: created.branchId, + newData: created as unknown as Record, + }); + + return created; + } + + async update(id: string, dto: UpdateTaskDto, actor: AuthUser): Promise { + const task = await this.findOne(id, actor); + const oldData = { ...task } as unknown as Record; + + if (dto.title !== undefined) task.title = dto.title; + if (dto.description !== undefined) task.description = dto.description; + + const updated = await this.tasksRepository.save(task); + + await this.auditService.log({ + actor, + action: 'update', + entityType: 'Task', + entityId: updated.id, + branchId: updated.branchId, + oldData, + newData: updated as unknown as Record, + }); + + return updated; + } + + async done(id: string, actor: AuthUser): Promise { + const task = await this.findOne(id, actor); + const oldData = { ...task } as unknown as Record; + + task.status = TaskStatus.DONE; + task.doneAt = new Date(); + task.doneBy = actor.userId; + + const updated = await this.tasksRepository.save(task); + + await this.auditService.log({ + actor, + action: 'done', + entityType: 'Task', + entityId: updated.id, + branchId: updated.branchId, + oldData, + newData: updated as unknown as Record, + }); + + return updated; + } + + async remove(id: string, actor: AuthUser): Promise<{ message: string }> { + const task = await this.findOne(id, actor); + const oldData = { ...task } as unknown as Record; + + await this.tasksRepository.delete(task.id); + + await this.auditService.log({ + actor, + action: 'delete', + entityType: 'Task', + entityId: task.id, + branchId: task.branchId, + oldData, + }); + + return { message: 'Task deleted' }; + } +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts new file mode 100644 index 0000000..238b03b --- /dev/null +++ b/backend/src/users/users.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from 'src/common/decorators/current-user.decorator'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { UserRole } from 'src/database/enums/user-role.enum'; +import { UsersService } from './users.service'; + +@ApiTags('Users') +@ApiBearerAuth() +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get('me') + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.WORKER) + me(@CurrentUser() actor: AuthUser) { + return actor; + } + + @Get() + @Roles(UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.SUPERVISOR) + findAll(@CurrentUser() actor: AuthUser) { + return this.usersService.findAllScoped(actor); + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts new file mode 100644 index 0000000..d6af582 --- /dev/null +++ b/backend/src/users/users.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from 'src/database/entities/user.entity'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts new file mode 100644 index 0000000..db207a2 --- /dev/null +++ b/backend/src/users/users.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AuthUser } from 'src/common/types/auth-user.type'; +import { scopedWhere } from 'src/common/utils/branch-scope.util'; +import { User } from 'src/database/entities/user.entity'; +import { Repository } from 'typeorm'; + +@Injectable() +export class UsersService { + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + ) {} + + findByCardNumber(cardNumber: string): Promise { + return this.usersRepository.findOne({ where: { cardNumber } }); + } + + findById(id: string): Promise { + return this.usersRepository.findOne({ where: { id } }); + } + + findAllScoped(actor: AuthUser): Promise { + return this.usersRepository.find({ + where: scopedWhere(actor, {}), + select: { + id: true, + cardNumber: true, + role: true, + branchId: true, + createdAt: true, + updatedAt: true, + }, + order: { createdAt: 'DESC' }, + }); + } + + async updateRefreshToken(userId: string, refreshTokenHash: string, expiresAt: Date): Promise { + await this.usersRepository.update(userId, { + refreshTokenHash, + refreshTokenExpiresAt: expiresAt, + }); + } + + async clearRefreshToken(userId: string): Promise { + await this.usersRepository.update(userId, { + refreshTokenHash: null, + refreshTokenExpiresAt: null, + }); + } +} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..5797371 --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..f32f3fc --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "strictPropertyInitialization": false, + "moduleResolution": "node", + "paths": { + "src/*": ["src/*"] + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b3e5f6a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + postgres: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: + context: ./backend + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + env_file: + - .env + environment: + SEED_ON_BOOT: "false" + command: sh ./scripts/start-prod.sh + + frontend: + build: + context: ./frontend + ports: + - "3001:3001" + depends_on: + - backend + environment: + BACKEND_URL: http://backend:3000/api + NODE_ENV: production + command: sh -c "npm run start" + +volumes: + postgres_data: diff --git a/frontend/.DS_Store b/frontend/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..989ad1ef3b061be23263bb9914ec2604a45c84b1 GIT binary patch literal 6148 zcmeHK%}T>S5T3C`0$zIbxCdXLZxBl=cWd@I|I(ZcMQn+5YPm3!>E{! z4s@vm0FJSo1Y@Zs#3vZ$hEWk32&*elUD-+uR(Griv&#*mqPi1X@xhk)y?Ei29q~gl zC(aeUcLtn+E(0T-PUQZdE>bZWt9MimWGhp#KOYLcDVZeu05E!N^Jz literal 0 HcmV?d00001 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..7e6bf2f --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +npm-debug.log +.git +.gitignore diff --git a/frontend/.env.local b/frontend/.env.local new file mode 100644 index 0000000..5082e85 --- /dev/null +++ b/frontend/.env.local @@ -0,0 +1 @@ +BACKEND_URL=http://localhost:3000/api diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9515703 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install --no-audit --no-fund +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3001 +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/next.config.mjs ./next.config.mjs +EXPOSE 3001 +CMD ["npm", "run", "start"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e406b6f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,25 @@ +# Frontend + +## Local development + +```bash +npm install +npm run dev +``` + +Frontend runs on `http://localhost:3001`. + +`.env.local` must include: + +```env +BACKEND_URL=http://localhost:3000/api +``` + +## Docker compose (from project root) + +```bash +make build +make up +``` + +Frontend container listens on port `3001` and uses `BACKEND_URL=http://backend:3000/api` from root compose. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..7559f63 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..84ab714 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,4 @@ +/// +/// + +// NOTE: This file should not be edited diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs new file mode 100644 index 0000000..4678774 --- /dev/null +++ b/frontend/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..630a2ff --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "work-system-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -H 0.0.0.0 -p 3001", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.16", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.17.6", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "autoprefixer": "10.4.20", + "eslint": "8.57.1", + "eslint-config-next": "14.2.16", + "postcss": "8.4.49", + "tailwindcss": "3.4.16", + "typescript": "5.7.2" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/.DS_Store b/frontend/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..568417f25a32334c029605f2b3baf2267180126a GIT binary patch literal 6148 zcmeHKy-ve05I(m>stQ9#CKQR0g}y3=F^5xure@|XYPnspkc$tl1X+LaVPZ!Ud3*S5a!kaJrSuc@B9hy)^ zDQaePM>FV(?!)Rw@B8BXwOkE8wsX6d+Bws#T-kTAHS28Bl5S`mu8baK=i>3#(tX;L zyf|-Jz3L6?@nfI^TBF`AbQRWYj6$zp_v~%=JiL5==k*a`{d2Cb^1m2B&t_>KTGUY) zPzIEN9RvJ*h@gzI!@{C`Ixxr)0N8?A1lQ8bfDs#jvBSb5ED&Q;fi~5+BZjf*utzQ~ zc34=n>13ob=5dvcJE0h<4tpfwWMYdtDg(+u$UxKHcDer#_MiX5B)wAxlz~6RfN3Se zq>nAR-dfol_u2@04Q1iD!lI;LaP3$wxD_8mMc|A00vJ0iEW!fO9|5632W8+#8TbTq CNrvJ8 literal 0 HcmV?d00001 diff --git a/frontend/src/app/api/backend/[...path]/route.ts b/frontend/src/app/api/backend/[...path]/route.ts new file mode 100644 index 0000000..f40be44 --- /dev/null +++ b/frontend/src/app/api/backend/[...path]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const FORWARDED_HEADERS = new Set(['content-type', 'authorization', 'cookie']); + +async function proxy(request: NextRequest, params: { path: string[] }) { + const backendBaseUrl = process.env.BACKEND_URL; + + if (!backendBaseUrl) { + return NextResponse.json({ message: 'BACKEND_URL is not configured' }, { status: 500 }); + } + + const upstreamUrl = new URL(`${backendBaseUrl}/${params.path.join('/')}`); + request.nextUrl.searchParams.forEach((value, key) => { + upstreamUrl.searchParams.append(key, value); + }); + + const headers = new Headers(); + request.headers.forEach((value, key) => { + if (FORWARDED_HEADERS.has(key.toLowerCase())) { + headers.set(key, value); + } + }); + + const method = request.method.toUpperCase(); + const body = method === 'GET' || method === 'HEAD' ? undefined : await request.arrayBuffer(); + + const upstreamResponse = await fetch(upstreamUrl.toString(), { + method, + headers, + body, + cache: 'no-store', + redirect: 'manual', + }); + + const responseHeaders = new Headers(); + const contentType = upstreamResponse.headers.get('content-type'); + if (contentType) { + responseHeaders.set('content-type', contentType); + } + + const setCookie = upstreamResponse.headers.get('set-cookie'); + if (setCookie) { + responseHeaders.append('set-cookie', setCookie); + } + + return new NextResponse(upstreamResponse.body, { + status: upstreamResponse.status, + headers: responseHeaders, + }); +} + +export async function GET(request: NextRequest, { params }: { params: { path: string[] } }) { + return proxy(request, params); +} + +export async function POST(request: NextRequest, { params }: { params: { path: string[] } }) { + return proxy(request, params); +} + +export async function PUT(request: NextRequest, { params }: { params: { path: string[] } }) { + return proxy(request, params); +} + +export async function PATCH(request: NextRequest, { params }: { params: { path: string[] } }) { + return proxy(request, params); +} + +export async function DELETE(request: NextRequest, { params }: { params: { path: string[] } }) { + return proxy(request, params); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..8d790f4 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --border: 214.3 31.8% 91.4%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --radius: 0.5rem; +} + +* { + border-color: hsl(var(--border)); +} + +body { + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..7630d84 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,16 @@ +import './globals.css'; +import type { Metadata } from 'next'; +import { ReactNode } from 'react'; + +export const metadata: Metadata = { + title: 'Work System Test UI', + description: 'Minimal admin test interface', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..473000b --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { FormEvent, useState } from 'react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { JsonViewer } from '@/uikit/JsonViewer'; +import { PageShell } from '@/uikit/PageShell'; +import { SectionTitle } from '@/uikit/SectionTitle'; + +type ResultState = { + status: string; + data: unknown; +}; + +export default function HomePage() { + const [cardNumber, setCardNumber] = useState('100000'); + const [password, setPassword] = useState('Password123'); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const handleLogin = async (event: FormEvent) => { + event.preventDefault(); + setLoading(true); + setResult(null); + + try { + const loginResponse = await fetch('/api/backend/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ cardNumber, password }), + }); + + const loginData = await parseJson(loginResponse); + + if (!loginResponse.ok) { + setResult({ + status: `Login failed (${loginResponse.status})`, + data: loginData, + }); + return; + } + + const accessToken = (loginData as { accessToken?: string })?.accessToken; + const authHeaders: HeadersInit = accessToken + ? { Authorization: `Bearer ${accessToken}` } + : {}; + + let protectedResponse = await fetch('/api/backend/users/me', { + method: 'GET', + headers: authHeaders, + credentials: 'include', + }); + + if (!protectedResponse.ok) { + protectedResponse = await fetch('/api/backend/users', { + method: 'GET', + headers: authHeaders, + credentials: 'include', + }); + } + + const protectedData = await parseJson(protectedResponse); + + setResult({ + status: `Login ok (${loginResponse.status}), Protected call status (${protectedResponse.status})`, + data: { + login: loginData, + protected: protectedData, + }, + }); + } catch (error) { + setResult({ + status: 'Request failed', + data: { + error: error instanceof Error ? error.message : 'Unknown error', + }, + }); + } finally { + setLoading(false); + } + }; + + return ( + <> + + + Apply ONLY explicitly requested changes. Preserve existing behavior. Fix + improve, NEVER fix + break. No + refactor, no unrelated edits, no renaming outside touched lines. + + + + + Backend Auth Test + + + + Login + Authenticate then call a protected endpoint. + + +
+
+ + setCardNumber(event.target.value)} + required + /> +
+ +
+ + setPassword(event.target.value)} + required + /> +
+ + +
+
+
+ +
+ Status +

{result?.status ?? 'No request yet'}

+ +
+
+ + ); +} + +async function parseJson(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + return response.json(); + } + return response.text(); +} diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..daecc69 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export function Alert({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +export function AlertDescription({ className, ...props }: React.HTMLAttributes) { + return
; +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..276c4a4 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'default' | 'outline'; +} + +const variantClasses: Record, string> = { + default: 'bg-primary text-primary-foreground hover:opacity-90', + outline: 'border border-border bg-background hover:bg-muted', +}; + +export const Button = React.forwardRef( + ({ className, variant = 'default', ...props }, ref) => { + return ( +