Version 1.0.0
This commit is contained in:
commit
f72e6e3b01
14
Makefile
Normal file
14
Makefile
Normal file
@ -0,0 +1,14 @@
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
start:
|
||||
docker compose up -d
|
||||
|
||||
stop:
|
||||
docker compose down
|
||||
|
||||
ps:
|
||||
docker compose ps
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
148
PROJECT_STRUCTURE.md
Normal file
148
PROJECT_STRUCTURE.md
Normal file
@ -0,0 +1,148 @@
|
||||
# Work System - Project Structure Guide
|
||||
|
||||
## نظرة عامة
|
||||
هذا المشروع مقسوم إلى 3 أجزاء رئيسية:
|
||||
- `backend/`: خدمة API مبنية بـ NestJS.
|
||||
- `frontend/`: واجهة اختبار بسيطة مبنية بـ Next.js (App Router).
|
||||
- `docker-compose.yml`: تشغيل الخدمات معًا (backend + frontend + postgres).
|
||||
|
||||
---
|
||||
|
||||
## الشجرة العامة
|
||||
```text
|
||||
work-system/
|
||||
├── .env
|
||||
├── Makefile
|
||||
├── PROJECT_STRUCTURE.md
|
||||
├── docker-compose.yml
|
||||
├── backend/
|
||||
│ ├── .git/
|
||||
│ ├── Dockerfile
|
||||
│ ├── nest-cli.json
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── tsconfig.build.json
|
||||
│ └── src/
|
||||
│ ├── main.ts
|
||||
│ ├── app.module.ts
|
||||
│ ├── auth/
|
||||
│ ├── branches/
|
||||
│ │ └── dto/
|
||||
│ ├── shifts/
|
||||
│ │ └── dto/
|
||||
│ ├── tasks/
|
||||
│ │ └── dto/
|
||||
│ └── users/
|
||||
│ └── dto/
|
||||
└── frontend/
|
||||
├── .env.local
|
||||
├── .eslintrc.json
|
||||
├── Dockerfile
|
||||
├── README.md
|
||||
├── components.json
|
||||
├── docker-compose.yml
|
||||
├── next-env.d.ts
|
||||
├── next.config.ts
|
||||
├── package.json
|
||||
├── postcss.config.js
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
├── public/
|
||||
└── src/
|
||||
├── app/
|
||||
│ ├── layout.tsx
|
||||
│ ├── page.tsx
|
||||
│ ├── globals.css
|
||||
│ └── api/backend/[...path]/route.ts
|
||||
├── components/ui/
|
||||
└── lib/utils.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ملفات الجذر (Root)
|
||||
- `.env`
|
||||
- متغيرات البيئة المشتركة (خصوصًا backend و postgres).
|
||||
- `docker-compose.yml`
|
||||
- تعريف الخدمات:
|
||||
- `backend` من `./backend`
|
||||
- `frontend` من `./frontend`
|
||||
- `postgres` قاعدة البيانات
|
||||
- `Makefile`
|
||||
- أوامر مختصرة للتشغيل:
|
||||
- `make build`, `make up`, `make down`, `make ps`, `make logs`
|
||||
- `PROJECT_STRUCTURE.md`
|
||||
- هذا الملف لتوثيق البنية.
|
||||
|
||||
---
|
||||
|
||||
## backend/
|
||||
- `package.json`
|
||||
- إعدادات الحزم وسكربتات NestJS (`build`, `start`, `start:dev`, `start:prod`).
|
||||
- `nest-cli.json`
|
||||
- إعدادات Nest CLI وتحديد `sourceRoot`.
|
||||
- `tsconfig.json` و `tsconfig.build.json`
|
||||
- إعدادات TypeScript للتطوير والبناء.
|
||||
- `Dockerfile`
|
||||
- بناء وتشغيل backend داخل حاوية Docker.
|
||||
- `src/main.ts`
|
||||
- نقطة تشغيل NestJS والاستماع على `APP_PORT`.
|
||||
- `src/app.module.ts`
|
||||
- الموديول الرئيسي للتطبيق.
|
||||
- `src/auth`, `src/branches`, `src/shifts`, `src/tasks`, `src/users`
|
||||
- مجلدات الوحدات (Modules) الخاصة بالدومين.
|
||||
- `src/*/dto`
|
||||
- تعريفات DTO لكل وحدة.
|
||||
|
||||
---
|
||||
|
||||
## frontend/
|
||||
- `package.json`
|
||||
- إعدادات Next.js وسكربتات التشغيل.
|
||||
- `next.config.ts`, `next-env.d.ts`
|
||||
- إعدادات Next.js.
|
||||
- `tsconfig.json`
|
||||
- إعدادات TypeScript للواجهة.
|
||||
- `tailwind.config.ts`, `postcss.config.js`
|
||||
- إعدادات TailwindCSS و PostCSS.
|
||||
- `.eslintrc.json`
|
||||
- إعداد ESLint.
|
||||
- `components.json`
|
||||
- إعداد shadcn/ui.
|
||||
- `.env.local`
|
||||
- يحتوي `BACKEND_URL` للاتصال بالـ backend عبر proxy.
|
||||
- `Dockerfile`
|
||||
- بناء وتشغيل frontend داخل Docker.
|
||||
- `src/app/page.tsx`
|
||||
- صفحة الواجهة الوحيدة (تسجيل دخول + اختبار endpoint محمي).
|
||||
- `src/app/api/backend/[...path]/route.ts`
|
||||
- Proxy server-side لتمرير الطلبات إلى backend مع الكوكيز.
|
||||
- `src/components/ui/*`
|
||||
- مكونات UI المستخدمة: `button`, `input`, `card`, `label`, `alert`.
|
||||
- `src/lib/utils.ts`
|
||||
- دوال مساعدة للتعامل مع class names.
|
||||
- `frontend/README.md`
|
||||
- تعليمات تشغيل frontend محليًا.
|
||||
|
||||
---
|
||||
|
||||
## كيف يتم التشغيل
|
||||
### عبر Makefile
|
||||
```bash
|
||||
make build
|
||||
make up
|
||||
make ps
|
||||
```
|
||||
|
||||
### مباشرة عبر Docker Compose
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### المنافذ الافتراضية
|
||||
- Backend: `http://localhost:3000`
|
||||
- Frontend: `http://localhost:3001`
|
||||
- Postgres: `localhost:5432`
|
||||
|
||||
BIN
backend/.DS_Store
vendored
Normal file
BIN
backend/.DS_Store
vendored
Normal file
Binary file not shown.
5
backend/.dockerignore
Normal file
5
backend/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
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
|
||||
COPY package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/scripts/start-prod.sh ./scripts/start-prod.sh
|
||||
RUN chmod +x ./scripts/start-prod.sh
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start:prod"]
|
||||
16
backend/README.md
Normal file
16
backend/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Backend
|
||||
|
||||
## Run with Docker (from project root)
|
||||
|
||||
```bash
|
||||
make build
|
||||
make up
|
||||
```
|
||||
|
||||
API base URL: `http://localhost:3000/api`
|
||||
Swagger: `http://localhost:3000/api/docs`
|
||||
|
||||
Backend container startup command runs:
|
||||
1. `npm run migration:run`
|
||||
2. `npm run seed`
|
||||
3. `npm run start:prod`
|
||||
5
backend/nest-cli.json
Normal file
5
backend/nest-cli.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
47
backend/package.json
Normal file
47
backend/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "work-system-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"migration:run": "node dist/database/run-migrations.js",
|
||||
"migration:run:dev": "ts-node -r tsconfig-paths/register src/database/run-migrations.ts",
|
||||
"seed": "node dist/database/seeds/seed.js",
|
||||
"seed:dev": "ts-node -r tsconfig-paths/register src/database/seeds/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.13.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.17.6",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
10
backend/scripts/start-prod.sh
Normal file
10
backend/scripts/start-prod.sh
Normal file
@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
npm run migration:run
|
||||
|
||||
if [ "${SEED_ON_BOOT}" = "true" ]; then
|
||||
npm run seed
|
||||
fi
|
||||
|
||||
npm run start:prod
|
||||
BIN
backend/src/.DS_Store
vendored
Normal file
BIN
backend/src/.DS_Store
vendored
Normal file
Binary file not shown.
37
backend/src/app.module.ts
Normal file
37
backend/src/app.module.ts
Normal file
@ -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 {}
|
||||
20
backend/src/audit/audit.controller.ts
Normal file
20
backend/src/audit/audit.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
13
backend/src/audit/audit.module.ts
Normal file
13
backend/src/audit/audit.module.ts
Normal file
@ -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 {}
|
||||
46
backend/src/audit/audit.service.ts
Normal file
46
backend/src/audit/audit.service.ts
Normal file
@ -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<string, unknown> | null;
|
||||
newData?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
@InjectRepository(AuditLog)
|
||||
private readonly auditRepository: Repository<AuditLog>,
|
||||
) {}
|
||||
|
||||
async log(payload: AuditPayload): Promise<void> {
|
||||
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<AuditLog[]> {
|
||||
const where = scopedWhere(actor, {});
|
||||
return this.auditRepository.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
88
backend/src/auth/auth.controller.ts
Normal file
88
backend/src/auth/auth.controller.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
14
backend/src/auth/auth.module.ts
Normal file
14
backend/src/auth/auth.module.ts
Normal file
@ -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 {}
|
||||
101
backend/src/auth/auth.service.ts
Normal file
101
backend/src/auth/auth.service.ts
Normal file
@ -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<JwtPayload> {
|
||||
try {
|
||||
return await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: string): Promise<void> {
|
||||
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<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: this.accessTtlSeconds,
|
||||
});
|
||||
|
||||
const refreshToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.getOrThrow<string>('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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
11
backend/src/auth/dto/login.dto.ts
Normal file
11
backend/src/auth/dto/login.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
7
backend/src/auth/dto/refresh.dto.ts
Normal file
7
backend/src/auth/dto/refresh.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class RefreshDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refreshToken?: string;
|
||||
}
|
||||
22
backend/src/auth/guards/jwt-auth.guard.ts
Normal file
22
backend/src/auth/guards/jwt-auth.guard.ts
Normal file
@ -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<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
8
backend/src/auth/interfaces/jwt-payload.interface.ts
Normal file
8
backend/src/auth/interfaces/jwt-payload.interface.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { UserRole } from 'src/database/enums/user-role.enum';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
cardNumber: string;
|
||||
role: UserRole;
|
||||
branchId: string;
|
||||
}
|
||||
26
backend/src/auth/strategies/jwt.strategy.ts
Normal file
26
backend/src/auth/strategies/jwt.strategy.ts
Normal file
@ -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<string>('JWT_ACCESS_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): AuthUser {
|
||||
return {
|
||||
userId: payload.sub,
|
||||
cardNumber: payload.cardNumber,
|
||||
role: payload.role,
|
||||
branchId: payload.branchId,
|
||||
};
|
||||
}
|
||||
}
|
||||
18
backend/src/branches/branches.controller.ts
Normal file
18
backend/src/branches/branches.controller.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
12
backend/src/branches/branches.module.ts
Normal file
12
backend/src/branches/branches.module.ts
Normal file
@ -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 {}
|
||||
16
backend/src/branches/branches.service.ts
Normal file
16
backend/src/branches/branches.service.ts
Normal file
@ -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<Branch>,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<Branch[]> {
|
||||
return this.branchesRepository.find({ order: { name: 'ASC' } });
|
||||
}
|
||||
}
|
||||
7
backend/src/common/decorators/current-user.decorator.ts
Normal file
7
backend/src/common/decorators/current-user.decorator.ts
Normal file
@ -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;
|
||||
});
|
||||
4
backend/src/common/decorators/public.decorator.ts
Normal file
4
backend/src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
backend/src/common/decorators/roles.decorator.ts
Normal file
5
backend/src/common/decorators/roles.decorator.ts
Normal file
@ -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);
|
||||
25
backend/src/common/guards/roles.guard.ts
Normal file
25
backend/src/common/guards/roles.guard.ts
Normal file
@ -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<UserRole[]>(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);
|
||||
}
|
||||
}
|
||||
8
backend/src/common/types/auth-user.type.ts
Normal file
8
backend/src/common/types/auth-user.type.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { UserRole } from 'src/database/enums/user-role.enum';
|
||||
|
||||
export type AuthUser = {
|
||||
userId: string;
|
||||
cardNumber: string;
|
||||
role: UserRole;
|
||||
branchId: string;
|
||||
};
|
||||
30
backend/src/common/utils/branch-scope.util.ts
Normal file
30
backend/src/common/utils/branch-scope.util.ts
Normal file
@ -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<T extends object>(
|
||||
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');
|
||||
}
|
||||
}
|
||||
4
backend/src/database/data-source.ts
Normal file
4
backend/src/database/data-source.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { dataSourceOptions } from './typeorm.config';
|
||||
|
||||
export const AppDataSource = new DataSource(dataSourceOptions());
|
||||
31
backend/src/database/entities/audit-log.entity.ts
Normal file
31
backend/src/database/entities/audit-log.entity.ts
Normal file
@ -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<string, unknown> | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
newData: Record<string, unknown> | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
19
backend/src/database/entities/branch.entity.ts
Normal file
19
backend/src/database/entities/branch.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
44
backend/src/database/entities/shift.entity.ts
Normal file
44
backend/src/database/entities/shift.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
45
backend/src/database/entities/task.entity.ts
Normal file
45
backend/src/database/entities/task.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
48
backend/src/database/entities/user.entity.ts
Normal file
48
backend/src/database/entities/user.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
4
backend/src/database/enums/task-status.enum.ts
Normal file
4
backend/src/database/enums/task-status.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum TaskStatus {
|
||||
PENDING = 'pending',
|
||||
DONE = 'done',
|
||||
}
|
||||
6
backend/src/database/enums/user-role.enum.ts
Normal file
6
backend/src/database/enums/user-role.enum.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum UserRole {
|
||||
SUPER_ADMIN = 'SuperAdmin',
|
||||
ADMIN = 'Admin',
|
||||
SUPERVISOR = 'Supervisor',
|
||||
WORKER = 'Worker',
|
||||
}
|
||||
116
backend/src/database/migrations/1700000000000-init-schema.ts
Normal file
116
backend/src/database/migrations/1700000000000-init-schema.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class InitSchema1700000000000 implements MigrationInterface {
|
||||
name = 'InitSchema1700000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
13
backend/src/database/run-migrations.ts
Normal file
13
backend/src/database/run-migrations.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { AppDataSource } from './data-source';
|
||||
|
||||
async function runMigrations(): Promise<void> {
|
||||
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);
|
||||
});
|
||||
86
backend/src/database/seeds/seed.ts
Normal file
86
backend/src/database/seeds/seed.ts
Normal file
@ -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<Map<string, Branch>> {
|
||||
const branchRepo = AppDataSource.getRepository(Branch);
|
||||
const branchMap = new Map<string, Branch>();
|
||||
|
||||
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<string, Branch>): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
27
backend/src/database/typeorm.config.ts
Normal file
27
backend/src/database/typeorm.config.ts
Normal file
@ -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,
|
||||
});
|
||||
40
backend/src/main.ts
Normal file
40
backend/src/main.ts
Normal file
@ -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();
|
||||
21
backend/src/shifts/dto/create-shift.dto.ts
Normal file
21
backend/src/shifts/dto/create-shift.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
4
backend/src/shifts/dto/update-shift.dto.ts
Normal file
4
backend/src/shifts/dto/update-shift.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateShiftDto } from './create-shift.dto';
|
||||
|
||||
export class UpdateShiftDto extends PartialType(CreateShiftDto) {}
|
||||
46
backend/src/shifts/shifts.controller.ts
Normal file
46
backend/src/shifts/shifts.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
13
backend/src/shifts/shifts.module.ts
Normal file
13
backend/src/shifts/shifts.module.ts
Normal file
@ -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 {}
|
||||
101
backend/src/shifts/shifts.service.ts
Normal file
101
backend/src/shifts/shifts.service.ts
Normal file
@ -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<Shift>,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
findAll(actor: AuthUser): Promise<Shift[]> {
|
||||
return this.shiftsRepository.find({
|
||||
where: scopedWhere(actor, {}),
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, actor: AuthUser): Promise<Shift> {
|
||||
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<Shift> {
|
||||
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<string, unknown>,
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateShiftDto, actor: AuthUser): Promise<Shift> {
|
||||
const shift = await this.findOne(id, actor);
|
||||
const oldData = { ...shift } as unknown as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>,
|
||||
});
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
15
backend/src/tasks/dto/create-task.dto.ts
Normal file
15
backend/src/tasks/dto/create-task.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
4
backend/src/tasks/dto/update-task.dto.ts
Normal file
4
backend/src/tasks/dto/update-task.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateTaskDto } from './create-task.dto';
|
||||
|
||||
export class UpdateTaskDto extends PartialType(CreateTaskDto) {}
|
||||
52
backend/src/tasks/tasks.controller.ts
Normal file
52
backend/src/tasks/tasks.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
13
backend/src/tasks/tasks.module.ts
Normal file
13
backend/src/tasks/tasks.module.ts
Normal file
@ -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 {}
|
||||
121
backend/src/tasks/tasks.service.ts
Normal file
121
backend/src/tasks/tasks.service.ts
Normal file
@ -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<Task>,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
findAll(actor: AuthUser): Promise<Task[]> {
|
||||
return this.tasksRepository.find({
|
||||
where: scopedWhere(actor, {}),
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, actor: AuthUser): Promise<Task> {
|
||||
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<Task> {
|
||||
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<string, unknown>,
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTaskDto, actor: AuthUser): Promise<Task> {
|
||||
const task = await this.findOne(id, actor);
|
||||
const oldData = { ...task } as unknown as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async done(id: string, actor: AuthUser): Promise<Task> {
|
||||
const task = await this.findOne(id, actor);
|
||||
const oldData = { ...task } as unknown as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>,
|
||||
});
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
26
backend/src/users/users.controller.ts
Normal file
26
backend/src/users/users.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
13
backend/src/users/users.module.ts
Normal file
13
backend/src/users/users.module.ts
Normal file
@ -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 {}
|
||||
51
backend/src/users/users.service.ts
Normal file
51
backend/src/users/users.service.ts
Normal file
@ -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<User>,
|
||||
) {}
|
||||
|
||||
findByCardNumber(cardNumber: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { cardNumber } });
|
||||
}
|
||||
|
||||
findById(id: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findAllScoped(actor: AuthUser): Promise<User[]> {
|
||||
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<void> {
|
||||
await this.usersRepository.update(userId, {
|
||||
refreshTokenHash,
|
||||
refreshTokenExpiresAt: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
async clearRefreshToken(userId: string): Promise<void> {
|
||||
await this.usersRepository.update(userId, {
|
||||
refreshTokenHash: null,
|
||||
refreshTokenExpiresAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||
}
|
||||
22
backend/tsconfig.json
Normal file
22
backend/tsconfig.json
Normal file
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal file
@ -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:
|
||||
BIN
frontend/.DS_Store
vendored
Normal file
BIN
frontend/.DS_Store
vendored
Normal file
Binary file not shown.
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
1
frontend/.env.local
Normal file
1
frontend/.env.local
Normal file
@ -0,0 +1 @@
|
||||
BACKEND_URL=http://localhost:3000/api
|
||||
3
frontend/.eslintrc.json
Normal file
3
frontend/.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@ -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"]
|
||||
25
frontend/README.md
Normal file
25
frontend/README.md
Normal file
@ -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.
|
||||
17
frontend/components.json
Normal file
17
frontend/components.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
4
frontend/next-env.d.ts
vendored
Normal file
4
frontend/next-env.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
4
frontend/next.config.mjs
Normal file
4
frontend/next.config.mjs
Normal file
@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend/src/.DS_Store
vendored
Normal file
BIN
frontend/src/.DS_Store
vendored
Normal file
Binary file not shown.
70
frontend/src/app/api/backend/[...path]/route.ts
Normal file
70
frontend/src/app/api/backend/[...path]/route.ts
Normal file
@ -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);
|
||||
}
|
||||
27
frontend/src/app/globals.css
Normal file
27
frontend/src/app/globals.css
Normal file
@ -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));
|
||||
}
|
||||
16
frontend/src/app/layout.tsx
Normal file
16
frontend/src/app/layout.tsx
Normal file
@ -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 (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
150
frontend/src/app/page.tsx
Normal file
150
frontend/src/app/page.tsx
Normal file
@ -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<ResultState | null>(null);
|
||||
|
||||
const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<Alert className="fixed left-1/2 top-4 z-50 w-[calc(100%-2rem)] max-w-4xl -translate-x-1/2">
|
||||
<AlertDescription>
|
||||
Apply ONLY explicitly requested changes. Preserve existing behavior. Fix + improve, NEVER fix + break. No
|
||||
refactor, no unrelated edits, no renaming outside touched lines.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<PageShell>
|
||||
<SectionTitle>Backend Auth Test</SectionTitle>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>Authenticate then call a protected endpoint.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={handleLogin}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cardNumber">Card Number</Label>
|
||||
<Input
|
||||
id="cardNumber"
|
||||
value={cardNumber}
|
||||
onChange={(event) => setCardNumber(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<SectionTitle>Status</SectionTitle>
|
||||
<p className="text-sm text-muted-foreground">{result?.status ?? 'No request yet'}</p>
|
||||
<JsonViewer value={result?.data ?? { message: 'No response yet' }} />
|
||||
</div>
|
||||
</PageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function parseJson(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
16
frontend/src/components/ui/alert.tsx
Normal file
16
frontend/src/components/ui/alert.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Alert({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn('relative w-full rounded-lg border border-border bg-background px-4 py-3 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDescription({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('text-sm', className)} {...props} />;
|
||||
}
|
||||
28
frontend/src/components/ui/button.tsx
Normal file
28
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'outline';
|
||||
}
|
||||
|
||||
const variantClasses: Record<NonNullable<ButtonProps['variant']>, string> = {
|
||||
default: 'bg-primary text-primary-foreground hover:opacity-90',
|
||||
outline: 'border border-border bg-background hover:bg-muted',
|
||||
};
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50',
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
22
frontend/src/components/ui/card.tsx
Normal file
22
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-lg border border-border bg-card text-card-foreground shadow-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn('text-xl font-semibold leading-none tracking-tight', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('p-6 pt-0', className)} {...props} />;
|
||||
}
|
||||
18
frontend/src/components/ui/input.tsx
Normal file
18
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
6
frontend/src/components/ui/label.tsx
Normal file
6
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return <label className={cn('text-sm font-medium leading-none', className)} {...props} />;
|
||||
}
|
||||
3
frontend/src/lib/utils.ts
Normal file
3
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function cn(...inputs: Array<string | false | null | undefined>): string {
|
||||
return inputs.filter(Boolean).join(' ');
|
||||
}
|
||||
7
frontend/src/uikit/JsonViewer.tsx
Normal file
7
frontend/src/uikit/JsonViewer.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export function JsonViewer({ value }: { value: unknown }) {
|
||||
return (
|
||||
<pre className="max-h-[400px] overflow-auto rounded-md border border-border bg-muted p-3 text-xs">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
5
frontend/src/uikit/PageShell.tsx
Normal file
5
frontend/src/uikit/PageShell.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function PageShell({ children }: { children: ReactNode }) {
|
||||
return <main className="mx-auto max-w-3xl px-4 pb-10 pt-28">{children}</main>;
|
||||
}
|
||||
5
frontend/src/uikit/SectionTitle.tsx
Normal file
5
frontend/src/uikit/SectionTitle.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function SectionTitle({ children }: { children: ReactNode }) {
|
||||
return <h2 className="mb-4 text-lg font-semibold tracking-tight">{children}</h2>;
|
||||
}
|
||||
34
frontend/tailwind.config.ts
Normal file
34
frontend/tailwind.config.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: 'hsl(var(--card))',
|
||||
'card-foreground': 'hsl(var(--card-foreground))',
|
||||
muted: 'hsl(var(--muted))',
|
||||
'muted-foreground': 'hsl(var(--muted-foreground))',
|
||||
border: 'hsl(var(--border))',
|
||||
primary: 'hsl(var(--primary))',
|
||||
'primary-foreground': 'hsl(var(--primary-foreground))',
|
||||
destructive: 'hsl(var(--destructive))',
|
||||
'destructive-foreground': 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user