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