Version 1.0.0

This commit is contained in:
Diya 2026-02-28 20:58:17 +01:00
commit f72e6e3b01
88 changed files with 2354 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

14
Makefile Normal file
View 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
View 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

Binary file not shown.

5
backend/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
npm-debug.log
dist
.git
.gitignore

17
backend/Dockerfile Normal file
View 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
View 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
View File

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

47
backend/package.json Normal file
View 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"
}
}

View 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

Binary file not shown.

37
backend/src/app.module.ts Normal file
View 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 {}

View 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);
}
}

View 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 {}

View 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,
});
}
}

View 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,
});
}
}

View 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 {}

View 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,
},
};
}
}

View 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;
}

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class RefreshDto {
@IsOptional()
@IsString()
refreshToken?: string;
}

View 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);
}
}

View 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;
}

View 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,
};
}
}

View 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();
}
}

View 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 {}

View 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' } });
}
}

View 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;
});

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View 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);

View 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);
}
}

View 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;
};

View 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');
}
}

View File

@ -0,0 +1,4 @@
import { DataSource } from 'typeorm';
import { dataSourceOptions } from './typeorm.config';
export const AppDataSource = new DataSource(dataSourceOptions());

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -0,0 +1,4 @@
export enum TaskStatus {
PENDING = 'pending',
DONE = 'done',
}

View File

@ -0,0 +1,6 @@
export enum UserRole {
SUPER_ADMIN = 'SuperAdmin',
ADMIN = 'Admin',
SUPERVISOR = 'Supervisor',
WORKER = 'Worker',
}

View 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"`);
}
}

View 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);
});

View 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);
});

View 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
View 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();

View 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;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateShiftDto } from './create-shift.dto';
export class UpdateShiftDto extends PartialType(CreateShiftDto) {}

View 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);
}
}

View 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 {}

View 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' };
}
}

View 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;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateTaskDto } from './create-task.dto';
export class UpdateTaskDto extends PartialType(CreateTaskDto) {}

View 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);
}
}

View 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 {}

View 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' };
}
}

View 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);
}
}

View 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 {}

View 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,
});
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

22
backend/tsconfig.json Normal file
View 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
View 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

Binary file not shown.

5
frontend/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.next
npm-debug.log
.git
.gitignore

1
frontend/.env.local Normal file
View File

@ -0,0 +1 @@
BACKEND_URL=http://localhost:3000/api

3
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

18
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

27
frontend/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
frontend/src/.DS_Store vendored Normal file

Binary file not shown.

View 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);
}

View 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));
}

View 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
View 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();
}

View 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} />;
}

View 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';

View 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} />;
}

View 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';

View 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} />;
}

View File

@ -0,0 +1,3 @@
export function cn(...inputs: Array<string | false | null | undefined>): string {
return inputs.filter(Boolean).join(' ');
}

View 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>
);
}

View 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>;
}

View 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>;
}

View 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
View 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"]
}