Commit 28f4b1a0 authored by ThinhNC's avatar ThinhNC

refactor: remove applications, interns, and tasks modules; update user module and database schema

parent 34f8f91b
node_modules
dist
.env
.env.local
.DS_Store
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
coverage
.vscode
.idea
*.log
NODE_ENV=development
PORT=3000
PORT=7777
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-1-[region].pooler.supabase.com:6543/postgres?pgbouncer=true
DIRECT_URL=postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres
DB_HOST=aws-1-[region].pooler.supabase.com
DB_PORT=6543
DB_USER=postgres.[project-ref]
DB_PASSWORD=[password]
DB_NAME=postgres
SUPABASE_URL=https://[project-ref].supabase.co
SUPABASE_PUBLISHABLE_KEY=your_supabase_publishable_key
SUPABASE_SECRET_KEY=your_supabase_secret_key
SUPABASE_JWKS_URL=https://[project-ref].supabase.co/auth/v1/.well-known/jwks.json
DATABASE_URL="postgresql://postgres:[PASSWORD]@localhost:5432/datafinwise?schema=public"
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=[PASSWORD]
DB_NAME=datafinwise
JWT_ACCESS_SECRET=change_me_access_secret
JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
\ No newline at end of file
REDIS_HOST=redis
REDIS_PORT=6381
\ No newline at end of file
# NexCampus BE - Intern Management System
# FinWise BE - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App)
Backend API service for managing interns, supervisors (leaders), tasks, submissions, evaluations, and notifications.
Backend API service for FinWise mini-app. Hiện tại backend cung cấp:
- Xác thực người dùng JWT
- Quản lý tài khoản người dùng
- Health check hệ thống
## Tech Stack
- **Node.js** & **Express** with **TypeScript**
- **Prisma ORM** with **PostgreSQL** (Supabase)
- **JWT Authentication** & **Role-Based Access Control**
......@@ -10,32 +15,31 @@ Backend API service for managing interns, supervisors (leaders), tasks, submissi
---
## Database Design & Models
The system is structured around 12 core models:
1. **Role**: Represents user authorizations (`ADMIN`, `LEADER`, `INTERN`).
2. **User**: Credentials, active status, and role reference.
3. **Application**: Registry/application form details. Spawns a User account upon approval.
4. **InternProfile**: Profile details for approved interns, linking them to their corresponding `User` and supervisor (`User` with role `LEADER`).
5. **NotificationSetting**: Communication channels configuration per user.
6. **Task**: Assignments created by administrators or leaders.
7. **TaskAssignment**: Task assignations linking exactly one task to one intern.
8. **TaskSubmission**: Intern submissions containing code links (PR), video demos, notes, and reviewer comments. Supports multiple attempts.
9. **DailyReport**: Log reports written daily by interns.
10. **WeeklyEvaluation**: Evaluative scoring (communication, attitude, learning, coding, total score) performed weekly by leaders.
11. **Notification**: In-app notifications.
12. **NotificationLog**: Delivery logs tracking web, email, and Discord channel notifications.
## Core Models
Dựa trên Prisma schema hiện tại, repository chứa các model chính sau:
- **User**: Email, mật khẩu, tên, trạng thái và role.
- **Wallet**: Ví người dùng.
- **Category**: Danh mục thu/chi.
- **Transaction**: Giao dịch thu/chi.
- **Budget**: Ngân sách theo danh mục.
- **RefreshToken**: Lưu refresh token cho đăng nhập lâu dài.
> Lưu ý: hiện tại các route public/active trong project chỉ bao gồm Auth và Users.
---
## Getting Started
### Prerequisites
- Node.js (v18+)
- pnpm (recommended) or npm
### Installation
1. Clone the repository
1. Clone the repository.
2. Install dependencies:
```bash
pnpm install
......@@ -51,38 +55,50 @@ The system is structured around 12 core models:
```
### Database Initialization
Apply migrations and run the seeding script to populate initial roles and create the default admin account:
Apply migrations and run the seeding script:
```bash
# Validate schema
# Validate Prisma schema
npx prisma validate
# Run migrations
npx prisma migrate dev --name init_intern_mgmt
npx prisma migrate dev --name init_finwise
# Seed initial data (Roles & Default Admin)
# Seed initial data
npm run db:seed
```
*Default Admin credentials:*
- **Email**: `admin@nexcampus.local`
_Default Admin credentials:_
- **Email**: `admin@finwise.local`
- **Password**: `Admin@123456`
### Running the App
Start the development server:
```bash
npm run dev
```
The server runs on http://localhost:3000 by default.
Swagger UI is available at `http://localhost:3000/api/docs`.
---
## API Endpoints
- **Auth Routing (`/api/v1/auth`)**:
- `POST /login`: User authentication.
- **System**:
- `GET /api/v1/health`: Health check.
- **Auth** (`/api/v1/auth`):
- `POST /login`: Authenticate user and receive access/refresh tokens.
- `GET /me`: Get current authenticated user details.
- **Users Routing (`/api/v1/users`)** (Admin only):
- `GET /`: Get all users.
- `POST /refresh`: Refresh JWT tokens.
- `POST /logout`: Invalidate refresh token.
- **Users** (`/api/v1/users`) (Admin only):
- `GET /`: Get all users with optional filters, sorting, and pagination.
- `GET /:id`: Get user details by ID.
- `POST /`: Create a new user account.
- `PUT /:id`: Update user details.
- `PUT /:id`: Update user status or role.
version: '3.8'
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: crawl_data_postgres
container_name: finwise_postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: crawl_data_db
POSTGRES_USER: "${DB_USER:-postgres}"
POSTGRES_PASSWORD: "${DB_PASSWORD:-postgres}"
POSTGRES_DB: "${DB_NAME:-datafinwise}"
ports:
- "5432:5432"
- "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: crawl_data_redis
container_name: finwise_redis
ports:
- "6379:6379"
- "${REDIS_PORT:-6381}:6379"
# app:
# build:
# context: .
# dockerfile: Dockerfile
# container_name: finwise_app
# env_file:
# - .env
# environment:
# NODE_ENV: "${NODE_ENV:-development}"
# PORT: "${PORT:-8888}"
# DB_HOST: postgres
# DB_PORT: "5432"
# DB_USER: "${DB_USER:-postgres}"
# DB_PASSWORD: "${DB_PASSWORD:-postgres}"
# DB_NAME: "${DB_NAME:-datafinwise}"
# DATABASE_URL: "postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-datafinwise}?schema=public"
# JWT_ACCESS_SECRET: "${JWT_ACCESS_SECRET:-default_access_secret}"
# JWT_REFRESH_SECRET: "${JWT_REFRESH_SECRET:-default_refresh_secret}"
# JWT_ACCESS_EXPIRES_IN: "${JWT_ACCESS_EXPIRES_IN:-1d}"
# JWT_REFRESH_EXPIRES_IN: "${JWT_REFRESH_EXPIRES_IN:-7d}"
# REDIS_HOST: redis
# REDIS_PORT: "6379"
# volumes:
# - ./:/usr/src/app
# - /usr/src/app/node_modules
# ports:
# - "${PORT:-8888}:${PORT:-8888}"
# depends_on:
# - postgres
# - redis
# command: sh -c "corepack enable && pnpm install && pnpm dev"
volumes:
postgres_data:
{
"name": "data-crawler-be",
"name": "finwise-miniapp-be",
"version": "1.0.0",
"description": "Backend API for data crawling service",
"description": "Backend API service for FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App)",
"main": "dist/server.js",
"packageManager": "pnpm@9.15.0",
"scripts": {
......
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'CRAWLER_USER', 'VIEWER');
-- CreateEnum
CREATE TYPE "CrawlJobStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'PARTIAL_COMPLETED', 'FAILED', 'CANCELED', 'BLOCKED');
-- CreateEnum
CREATE TYPE "CrawlMode" AS ENUM ('SCRAPE', 'CRAWL', 'SITEMAP', 'URL_LIST');
-- CreateEnum
CREATE TYPE "CrawlPageStatus" AS ENUM ('PENDING', 'SUCCESS', 'FAILED', 'BLOCKED', 'REQUIRES_LOGIN', 'CAPTCHA_DETECTED', 'PAYWALL_DETECTED', 'TIMEOUT', 'SKIPPED');
-- CreateEnum
CREATE TYPE "ExportType" AS ENUM ('JSON', 'CSV', 'XLSX', 'MARKDOWN', 'MARKDOWN_ZIP', 'FULL_ZIP');
-- CreateEnum
CREATE TYPE "ExportStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "AssetType" AS ENUM ('IMAGE', 'LINK', 'PDF', 'FILE', 'VIDEO', 'OTHER');
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"full_name" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'CRAWLER_USER',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "crawl_jobs" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"start_url" TEXT NOT NULL,
"domain" TEXT,
"mode" "CrawlMode" NOT NULL DEFAULT 'SCRAPE',
"status" "CrawlJobStatus" NOT NULL DEFAULT 'PENDING',
"max_pages" INTEGER NOT NULL DEFAULT 20,
"max_depth" INTEGER NOT NULL DEFAULT 1,
"total_pages" INTEGER NOT NULL DEFAULT 0,
"success_pages" INTEGER NOT NULL DEFAULT 0,
"failed_pages" INTEGER NOT NULL DEFAULT 0,
"error_message" TEXT,
"started_at" TIMESTAMP(3),
"finished_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "crawl_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "crawl_pages" (
"id" UUID NOT NULL,
"job_id" UUID NOT NULL,
"url" TEXT NOT NULL,
"title" TEXT,
"description" TEXT,
"markdown_content" TEXT,
"html_content_path" TEXT,
"status" "CrawlPageStatus" NOT NULL DEFAULT 'PENDING',
"status_code" INTEGER,
"error_message" TEXT,
"crawled_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "crawl_pages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "crawl_assets" (
"id" UUID NOT NULL,
"job_id" UUID NOT NULL,
"page_id" UUID,
"asset_type" "AssetType" NOT NULL,
"url" TEXT NOT NULL,
"source_url" TEXT,
"alt_text" TEXT,
"mime_type" TEXT,
"order_index" INTEGER,
"css_selector" TEXT,
"dom_path" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "crawl_assets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "crawl_exports" (
"id" UUID NOT NULL,
"job_id" UUID NOT NULL,
"export_type" "ExportType" NOT NULL,
"status" "ExportStatus" NOT NULL DEFAULT 'PENDING',
"file_name" TEXT NOT NULL,
"file_path" TEXT NOT NULL,
"file_size" INTEGER,
"mime_type" TEXT,
"error_message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "crawl_exports_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "crawl_jobs_user_id_idx" ON "crawl_jobs"("user_id");
-- CreateIndex
CREATE INDEX "crawl_jobs_status_idx" ON "crawl_jobs"("status");
-- CreateIndex
CREATE INDEX "crawl_jobs_created_at_idx" ON "crawl_jobs"("created_at");
-- CreateIndex
CREATE INDEX "crawl_pages_job_id_idx" ON "crawl_pages"("job_id");
-- CreateIndex
CREATE INDEX "crawl_pages_status_idx" ON "crawl_pages"("status");
-- CreateIndex
CREATE INDEX "crawl_assets_job_id_idx" ON "crawl_assets"("job_id");
-- CreateIndex
CREATE INDEX "crawl_assets_page_id_idx" ON "crawl_assets"("page_id");
-- CreateIndex
CREATE INDEX "crawl_assets_asset_type_idx" ON "crawl_assets"("asset_type");
-- CreateIndex
CREATE INDEX "crawl_exports_job_id_idx" ON "crawl_exports"("job_id");
-- CreateIndex
CREATE INDEX "crawl_exports_export_type_idx" ON "crawl_exports"("export_type");
-- AddForeignKey
ALTER TABLE "crawl_jobs" ADD CONSTRAINT "crawl_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "crawl_pages" ADD CONSTRAINT "crawl_pages_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "crawl_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "crawl_assets" ADD CONSTRAINT "crawl_assets_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "crawl_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "crawl_assets" ADD CONSTRAINT "crawl_assets_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "crawl_pages"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "crawl_exports" ADD CONSTRAINT "crawl_exports_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "crawl_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" UUID NOT NULL,
"token" TEXT NOT NULL,
"user_id" UUID NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- AddForeignKey
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "refresh_tokens" ADD COLUMN "ip_address" TEXT,
ADD COLUMN "user_agent" TEXT;
-- CreateIndex
CREATE INDEX "applications_approved_by_idx" ON "applications"("approved_by");
-- CreateIndex
CREATE INDEX "daily_reports_intern_id_idx" ON "daily_reports"("intern_id");
-- CreateIndex
CREATE INDEX "intern_profiles_leader_id_idx" ON "intern_profiles"("leader_id");
-- CreateIndex
CREATE INDEX "notification_logs_notification_id_idx" ON "notification_logs"("notification_id");
-- CreateIndex
CREATE INDEX "notifications_user_id_idx" ON "notifications"("user_id");
-- CreateIndex
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
-- CreateIndex
CREATE INDEX "task_assignments_intern_id_idx" ON "task_assignments"("intern_id");
-- CreateIndex
CREATE INDEX "task_assignments_assigned_by_idx" ON "task_assignments"("assigned_by");
-- CreateIndex
CREATE INDEX "task_submissions_assignment_id_idx" ON "task_submissions"("assignment_id");
-- CreateIndex
CREATE INDEX "task_submissions_reviewed_by_idx" ON "task_submissions"("reviewed_by");
-- CreateIndex
CREATE INDEX "tasks_created_by_idx" ON "tasks"("created_by");
-- CreateIndex
CREATE INDEX "users_role_id_idx" ON "users"("role_id");
-- CreateIndex
CREATE INDEX "weekly_evaluations_leader_id_idx" ON "weekly_evaluations"("leader_id");
-- AlterTable
ALTER TABLE "users" ADD COLUMN "full_name" TEXT;
-- AlterTable
ALTER TABLE "applications" ADD COLUMN "deleted_at" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "intern_profiles" ADD COLUMN "deleted_at" TIMESTAMP(3);
/*
Warnings:
- You are about to drop the `intern_profiles` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "daily_reports" DROP CONSTRAINT "daily_reports_intern_id_fkey";
-- DropForeignKey
ALTER TABLE "intern_profiles" DROP CONSTRAINT "intern_profiles_leader_id_fkey";
-- DropForeignKey
ALTER TABLE "intern_profiles" DROP CONSTRAINT "intern_profiles_user_id_fkey";
-- DropForeignKey
ALTER TABLE "task_assignments" DROP CONSTRAINT "task_assignments_intern_id_fkey";
-- DropForeignKey
ALTER TABLE "weekly_evaluations" DROP CONSTRAINT "weekly_evaluations_intern_id_fkey";
-- DropTable
DROP TABLE "intern_profiles";
-- CreateTable
CREATE TABLE "interns" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"leader_id" UUID,
"full_name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"department" TEXT NOT NULL,
"position" TEXT NOT NULL,
"start_date" TIMESTAMP(3) NOT NULL,
"duration" INTEGER NOT NULL,
"discord_username" TEXT,
"discord_role_granted" BOOLEAN NOT NULL DEFAULT false,
"status" "InternStatus" NOT NULL DEFAULT 'ACTIVE',
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "interns_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "interns_user_id_key" ON "interns"("user_id");
-- CreateIndex
CREATE INDEX "interns_leader_id_idx" ON "interns"("leader_id");
-- AddForeignKey
ALTER TABLE "interns" ADD CONSTRAINT "interns_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "interns" ADD CONSTRAINT "interns_leader_id_fkey" FOREIGN KEY ("leader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_assignments" ADD CONSTRAINT "task_assignments_intern_id_fkey" FOREIGN KEY ("intern_id") REFERENCES "interns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "daily_reports" ADD CONSTRAINT "daily_reports_intern_id_fkey" FOREIGN KEY ("intern_id") REFERENCES "interns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "weekly_evaluations" ADD CONSTRAINT "weekly_evaluations_intern_id_fkey" FOREIGN KEY ("intern_id") REFERENCES "interns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "deleted_at" TIMESTAMP(3);
-- CreateEnum
CREATE TYPE "TransactionType" AS ENUM ('INCOME', 'EXPENSE');
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"full_name" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"role_id" UUID NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wallets" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"balance" DOUBLE PRECISION NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "transactions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"wallet_id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"type" "TransactionType" NOT NULL,
"description" TEXT,
"date" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "budgets" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"start_date" TIMESTAMP(3) NOT NULL,
"end_date" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "budgets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" UUID NOT NULL,
"token" TEXT NOT NULL,
"user_id" UUID NOT NULL,
"user_agent" TEXT,
"ip_address" TEXT,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "wallets_user_id_name_key" ON "wallets"("user_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "categories_user_id_name_type_key" ON "categories"("user_id", "name", "type");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- CreateIndex
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wallets" ADD CONSTRAINT "wallets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_wallet_id_fkey" FOREIGN KEY ("wallet_id") REFERENCES "wallets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
This diff is collapsed.
......@@ -17,13 +17,13 @@ async function main() {
console.log(`Role ${roleName} upserted with ID ${role.id}`);
}
const adminEmail = 'admin@nexcampus.local';
const adminEmail = 'admin@finwise.local';
const adminPassword = await bcrypt.hash('Admin@123456', 10);
const leaderEmail = 'leader@nexcampus.local';
const leaderEmail = 'leader@finwise.local';
const leaderPassword = await bcrypt.hash('Leader@123456', 10);
const internEmail = 'intern@nexcampus.local';
const internEmail = 'intern@finwise.local';
const internPassword = await bcrypt.hash('Intern@123456', 10);
await prisma.user.upsert({
......
......@@ -6,16 +6,13 @@ import swaggerUi from 'swagger-ui-express';
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware';
import routes from './routes';
import { swaggerSpec, swaggerOptions } from './config/swagger.config';
import { rateLimitMiddleware } from './middlewares/rate-limit.middleware';
const app = express();
app.set('trust proxy', true);
app.use(
helmet({
contentSecurityPolicy: false, // Tắt CSP để Swagger UI tải được stylesheet
}),
);
app.use(helmet({ contentSecurityPolicy: false, }),);
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
......@@ -23,7 +20,7 @@ app.use(express.urlencoded({ extended: true }));
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
app.use('/api/v1', routes);
app.use('/api/v1', rateLimitMiddleware, routes);
app.use(notFoundMiddleware);
app.use(errorMiddleware);
......
This diff is collapsed.
......@@ -3,7 +3,7 @@ import { Request, Response, NextFunction } from 'express';
const requestCounts = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 15 * 60 * 1000;
const MAX_REQUESTS = 100;
const MAX_REQUESTS = 1000;
export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
......
import { Request, Response, NextFunction } from 'express';
import { ApplicationService } from './application.service';
import { ApplicationQueryDto } from './application.dto';
export class ApplicationController {
private readonly service = new ApplicationService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as ApplicationQueryDto;
const result = await this.service.findAll(query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findById(req.params.id);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.create(req.body);
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
};
review = async (req: Request, res: Response, next: NextFunction) => {
try {
const approverId = req.user.id;
const result = await this.service.review(req.params.id, req.body, approverId);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.service.delete(req.params.id);
res.json({ success: true, message: 'Application deleted successfully' });
} catch (error) {
next(error);
}
};
}
import { ApplicationStatus } from '@prisma/client';
export interface ApplicationQueryDto {
status?: ApplicationStatus;
department?: string;
position?: string;
email?: string;
startDateFrom?: string;
startDateTo?: string;
sortBy?: 'createdAt' | 'startDate' | 'fullName' | 'status';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface CreateApplicationDto {
fullName: string;
email: string;
phone: string;
department: string;
position: string;
startDate: string;
duration: number;
}
export interface ReviewApplicationDto {
status: 'APPROVED' | 'REJECTED';
}
export interface ApplicationResponseDto {
id: string;
fullName: string;
email: string;
phone: string;
department: string;
position: string;
startDate: Date;
duration: number;
status: ApplicationStatus;
approvedBy: string | null;
approvedAt: Date | null;
createdAt: Date;
updatedAt: Date;
approver?: {
id: string;
email: string;
fullName: string | null;
} | null;
}
import { ApplicationStatus, Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { ApplicationQueryDto } from './application.dto';
const approverSelect = {
id: true,
email: true,
fullName: true,
};
export class ApplicationRepository {
async findAll(query: ApplicationQueryDto) {
const {
status,
department,
position,
email,
startDateFrom,
startDateTo,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 20,
} = query;
const where: Prisma.ApplicationWhereInput = {
deletedAt: null,
...(status ? { status } : {}),
...(department ? { department: { contains: department, mode: 'insensitive' } } : {}),
...(position ? { position: { contains: position, mode: 'insensitive' } } : {}),
...(email ? { email: { contains: email, mode: 'insensitive' } } : {}),
...(startDateFrom || startDateTo
? {
startDate: {
...(startDateFrom ? { gte: new Date(startDateFrom) } : {}),
...(startDateTo ? { lte: new Date(startDateTo) } : {}),
},
}
: {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.application.findMany({
where,
include: { approver: { select: approverSelect } },
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.application.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
findById(id: string) {
return prisma.application.findFirst({
where: { id, deletedAt: null },
include: { approver: { select: approverSelect } },
});
}
create(data: {
fullName: string;
email: string;
phone: string;
department: string;
position: string;
startDate: Date;
duration: number;
}) {
return prisma.application.create({
data,
});
}
review(
id: string,
status: 'APPROVED' | 'REJECTED',
approverId: string,
) {
return prisma.application.update({
where: { id },
data: {
status,
approvedBy: approverId,
approvedAt: new Date(),
},
include: { approver: { select: approverSelect } },
});
}
softDelete(id: string) {
return prisma.application.update({
where: { id },
data: { deletedAt: new Date() },
});
}
}
import { Router } from 'express';
import { ApplicationController } from './application.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import {
createApplicationSchema,
findAllApplicationSchema,
reviewApplicationSchema,
} from './application.validation';
const router = Router();
const controller = new ApplicationController();
router.post('/', validate(createApplicationSchema), controller.create);
// GET /applications?status=...&department=...&page=...&limit=...&sortBy=...&order=...
router.get('/', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(findAllApplicationSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), controller.findById);
router.patch('/:id/review', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(reviewApplicationSchema), controller.review);
router.delete('/:id', authMiddleware, requireRole('ADMIN'), controller.delete);
export default router;
import { ApplicationRepository } from './application.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { ApplicationQueryDto, CreateApplicationDto, ReviewApplicationDto } from './application.dto';
export class ApplicationService {
private readonly repository = new ApplicationRepository();
async findAll(query: ApplicationQueryDto) {
return this.repository.findAll(query);
}
async findById(id: string) {
const application = await this.repository.findById(id);
if (!application) {
throw new AppError('Application not found', 404, ERROR_CODE.NOT_FOUND);
}
return application;
}
async create(data: CreateApplicationDto) {
return this.repository.create({
fullName: data.fullName,
email: data.email,
phone: data.phone,
department: data.department,
position: data.position,
startDate: new Date(data.startDate),
duration: data.duration,
});
}
async review(id: string, dto: ReviewApplicationDto, approverId: string) {
const application = await this.findById(id);
if (application.status !== 'PENDING') {
throw new AppError(
`Application is already ${application.status.toLowerCase()}`,
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
return this.repository.review(id, dto.status, approverId);
}
async delete(id: string) {
const application = await this.findById(id);
if (application.status === 'APPROVED') {
throw new AppError(
'Cannot delete an approved application',
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
return this.repository.softDelete(id);
}
}
import { z } from 'zod';
export const findAllApplicationSchema = z.object({
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
department: z.string().optional(),
position: z.string().optional(),
email: z.string().email('Invalid email').optional(),
startDateFrom: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'startDateFrom must be a valid ISO date' })
.optional(),
startDateTo: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'startDateTo must be a valid ISO date' })
.optional(),
sortBy: z.enum(['createdAt', 'startDate', 'fullName', 'status']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const createApplicationSchema = z.object({
fullName: z.string().min(1, 'Full name is required').max(100),
email: z.string().min(1, 'Email is required').email('Invalid email format'),
phone: z.string().min(9, 'Phone must be at least 9 digits').max(15),
department: z.string().min(1, 'Department is required'),
position: z.string().min(1, 'Position is required'),
startDate: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: 'startDate must be a valid ISO date string',
}),
duration: z
.number({ invalid_type_error: 'Duration must be a number' })
.int()
.positive('Duration must be a positive integer'),
});
export const reviewApplicationSchema = z.object({
status: z.enum(['APPROVED', 'REJECTED'], {
errorMap: () => ({ message: 'status must be APPROVED or REJECTED' }),
}),
});
import { Request, Response, NextFunction } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './auth.dto';
export class AuthController {
private readonly service = new AuthService();
login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password } = req.body;
const body = req.body as LoginDto;
const userAgent = req.headers['user-agent'];
const ipAddress = req.ip;
const result = await this.service.login(email, password, { userAgent, ipAddress });
const result = await this.service.login(body, { userAgent, ipAddress });
res.json({
success: true,
......
......@@ -16,3 +16,14 @@ export interface MeDto {
isActive: boolean;
createdAt: Date;
}
export interface LoginResponseDto {
accessToken: string;
refreshToken: string;
user: {
id: string;
email: string;
fullName: string | null;
role: string;
};
}
......@@ -4,11 +4,13 @@ import { AuthRepository } from './auth.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, LoginResponseDto, AuthTokensDto, MeDto } from './auth.dto';
export class AuthService {
private readonly repository = new AuthRepository();
async login(email: string, password: string, metadata?: { userAgent?: string; ipAddress?: string }) {
async login(data: LoginDto, metadata?: { userAgent?: string; ipAddress?: string }): Promise<LoginResponseDto> {
const { email, password } = data;
const user = await this.repository.findByEmail(email);
if (!user) {
......@@ -51,7 +53,7 @@ export class AuthService {
};
}
async refresh(token: string, metadata?: { userAgent?: string; ipAddress?: string }) {
async refresh(token: string, metadata?: { userAgent?: string; ipAddress?: string }): Promise<AuthTokensDto> {
let payload: any;
try {
payload = jwt.verify(token, jwtConfig.refreshSecret);
......@@ -104,7 +106,7 @@ export class AuthService {
}
}
async getMe(userId: string) {
async getMe(userId: string): Promise<MeDto> {
const user = await this.repository.findById(userId);
if (!user || !user.isActive) {
......
import { Request, Response, NextFunction } from 'express';
import { InternService } from './intern.service';
import { InternQueryDto } from './intern.dto';
export class InternController {
private readonly service = new InternService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as InternQueryDto;
const result = await this.service.findAll(query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findById(req.params.id);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.create(req.body);
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.update(req.params.id, req.body);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.service.delete(req.params.id);
res.json({ success: true, message: 'Intern deleted successfully' });
} catch (error) {
next(error);
}
};
}
import { InternStatus } from '@prisma/client';
export interface InternQueryDto {
fullName?: string;
department?: string;
position?: string;
status?: InternStatus;
leaderId?: string;
discordRoleGranted?: boolean;
startDateFrom?: string;
startDateTo?: string;
sortBy?: 'createdAt' | 'fullName' | 'startDate' | 'status';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface CreateInternDto {
userId: string;
leaderId?: string;
fullName: string;
phone: string;
department: string;
position: string;
startDate: string;
duration: number;
discordUsername?: string;
}
export interface UpdateInternDto {
leaderId?: string | null;
fullName?: string;
phone?: string;
department?: string;
position?: string;
startDate?: string;
duration?: number;
discordUsername?: string | null;
discordRoleGranted?: boolean;
status?: InternStatus;
}
import { InternStatus, Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { InternQueryDto, CreateInternDto, UpdateInternDto } from './intern.dto';
const userSelect = {
id: true,
email: true,
fullName: true,
isActive: true,
};
const defaultInclude = {
user: { select: userSelect },
leader: { select: userSelect },
};
export class InternRepository {
async findAll(query: InternQueryDto) {
const {
fullName,
department,
position,
status,
leaderId,
discordRoleGranted,
startDateFrom,
startDateTo,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 20,
} = query;
const where: Prisma.InternWhereInput = {
deletedAt: null,
...(fullName ? { fullName: { contains: fullName, mode: 'insensitive' } } : {}),
...(department ? { department: { contains: department, mode: 'insensitive' } } : {}),
...(position ? { position: { contains: position, mode: 'insensitive' } } : {}),
...(status ? { status } : {}),
...(leaderId ? { leaderId } : {}),
...(discordRoleGranted !== undefined ? { discordRoleGranted } : {}),
...(startDateFrom || startDateTo
? {
startDate: {
...(startDateFrom ? { gte: new Date(startDateFrom) } : {}),
...(startDateTo ? { lte: new Date(startDateTo) } : {}),
},
}
: {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.intern.findMany({
where,
include: defaultInclude,
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.intern.count({ where }),
]);
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
findById(id: string) {
return prisma.intern.findFirst({
where: { id, deletedAt: null },
include: defaultInclude,
});
}
findByUserId(userId: string) {
return prisma.intern.findFirst({
where: { userId, deletedAt: null },
include: defaultInclude,
});
}
create(data: CreateInternDto) {
return prisma.intern.create({
data: {
userId: data.userId,
leaderId: data.leaderId,
fullName: data.fullName,
phone: data.phone,
department: data.department,
position: data.position,
startDate: new Date(data.startDate),
duration: data.duration,
discordUsername: data.discordUsername,
},
include: defaultInclude,
});
}
update(id: string, data: UpdateInternDto) {
return prisma.intern.update({
where: { id },
data: {
...(data.leaderId !== undefined ? { leaderId: data.leaderId } : {}),
...(data.fullName !== undefined ? { fullName: data.fullName } : {}),
...(data.phone !== undefined ? { phone: data.phone } : {}),
...(data.department !== undefined ? { department: data.department } : {}),
...(data.position !== undefined ? { position: data.position } : {}),
...(data.startDate !== undefined ? { startDate: new Date(data.startDate)} : {}),
...(data.duration !== undefined ? { duration: data.duration } : {}),
...(data.discordUsername !== undefined ? { discordUsername: data.discordUsername } : {}),
...(data.discordRoleGranted !== undefined ? { discordRoleGranted: data.discordRoleGranted } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
},
include: defaultInclude,
});
}
softDelete(id: string) {
return prisma.intern.update({
where: { id },
data: { deletedAt: new Date() },
include: defaultInclude,
});
}
}
import { Router } from 'express';
import { InternController } from './intern.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import {
findAllInternSchema,
createInternSchema,
updateInternSchema,
} from './intern.validation';
const router = Router();
const controller = new InternController();
// GET /interns?fullName=...&department=...&position=...&status=...&leaderId=...&discordRoleGranted=...&startDateFrom=...&startDateTo=...&sortBy=...&order=...&page=...&limit=...
router.get('/', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(findAllInternSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), controller.findById);
router.post('/', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(createInternSchema), controller.create);
router.put('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(updateInternSchema), controller.update);
router.delete('/:id', authMiddleware, requireRole('ADMIN'), controller.delete);
export default router;
import { InternRepository } from './intern.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import {
InternQueryDto,
CreateInternDto,
UpdateInternDto,
} from './intern.dto';
export class InternService {
private readonly repository = new InternRepository();
async findAll(query: InternQueryDto) {
return this.repository.findAll(query);
}
async findById(id: string) {
const profile = await this.repository.findById(id);
if (!profile) {
throw new AppError('Intern not found', 404, ERROR_CODE.NOT_FOUND);
}
return profile;
}
async create(data: CreateInternDto) {
const existing = await this.repository.findByUserId(data.userId);
if (existing) {
throw new AppError(
'This user already has an intern profile',
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
return this.repository.create(data);
}
async update(id: string, data: UpdateInternDto) {
await this.findById(id);
return this.repository.update(id, data);
}
async delete(id: string) {
await this.findById(id);
return this.repository.softDelete(id);
}
}
import { z } from 'zod';
export const findAllInternSchema = z.object({
fullName: z.string().optional(),
department: z.string().optional(),
position: z.string().optional(),
status: z.enum(['ACTIVE', 'COMPLETED', 'DROPPED']).optional(),
leaderId: z.string().uuid('Invalid leaderId').optional(),
discordRoleGranted: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.optional(),
startDateFrom: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'startDateFrom must be a valid ISO date' })
.optional(),
startDateTo: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'startDateTo must be a valid ISO date' })
.optional(),
sortBy: z.enum(['createdAt', 'fullName', 'startDate', 'status']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const createInternSchema = z.object({
userId: z.string().uuid('Invalid userId'),
leaderId: z.string().uuid('Invalid leaderId').optional(),
fullName: z.string().min(1, 'Full name is required').max(100),
phone: z.string().min(9).max(15),
department: z.string().min(1, 'Department is required'),
position: z.string().min(1, 'Position is required'),
startDate: z.string().refine((v) => !isNaN(Date.parse(v)), {
message: 'startDate must be a valid ISO date',
}),
duration: z.number().int().positive('Duration must be a positive integer'),
discordUsername: z.string().optional(),
});
export const updateInternSchema = z.object({
leaderId: z.string().uuid().nullable().optional(),
fullName: z.string().min(1).max(100).optional(),
phone: z.string().min(9).max(15).optional(),
department: z.string().optional(),
position: z.string().optional(),
startDate: z.string().refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid date' }).optional(),
duration: z.number().int().positive().optional(),
discordUsername: z.string().nullable().optional(),
discordRoleGranted: z.boolean().optional(),
status: z.enum(['ACTIVE', 'COMPLETED', 'DROPPED']).optional(),
});
import { Request, Response, NextFunction } from 'express';
import { TaskService } from './task.service';
import { TaskQueryDto } from './task.dto';
export class TaskController {
private readonly service = new TaskService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as TaskQueryDto;
const result = await this.service.findAll(query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findById(req.params.id);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const createdBy = req.user.id;
const result = await this.service.create(req.body, createdBy);
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.update(req.params.id, req.body);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.service.delete(req.params.id);
res.json({ success: true, message: 'Task deleted successfully' });
} catch (error) {
next(error);
}
};
}
import { TaskPriority } from '@prisma/client';
export interface TaskQueryDto {
title?: string;
priority?: TaskPriority;
createdBy?: string;
deadlineFrom?: string;
deadlineTo?: string;
sortBy?: 'createdAt' | 'title' | 'deadline' | 'priority';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface CreateTaskDto {
title: string;
description?: string;
deadline: string;
priority?: TaskPriority;
}
export interface UpdateTaskDto {
title?: string;
description?: string | null;
deadline?: string;
priority?: TaskPriority;
}
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { TaskQueryDto, CreateTaskDto, UpdateTaskDto } from './task.dto';
const creatorSelect = {
id: true,
email: true,
fullName: true,
};
const defaultInclude = {
creator: { select: creatorSelect },
assignment: true,
};
export class TaskRepository {
async findAll(query: TaskQueryDto) {
const {
title,
priority,
createdBy,
deadlineFrom,
deadlineTo,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 20,
} = query;
const where: Prisma.TaskWhereInput = {
deletedAt: null,
...(title ? { title: { contains: title, mode: 'insensitive' } } : {}),
...(priority ? { priority } : {}),
...(createdBy ? { createdBy } : {}),
...(deadlineFrom || deadlineTo
? {
deadline: {
...(deadlineFrom ? { gte: new Date(deadlineFrom) } : {}),
...(deadlineTo ? { lte: new Date(deadlineTo) } : {}),
},
}
: {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.task.findMany({
where,
include: defaultInclude,
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.task.count({ where }),
]);
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
findById(id: string) {
return prisma.task.findFirst({
where: { id, deletedAt: null },
include: defaultInclude,
});
}
create(data: CreateTaskDto, createdBy: string) {
return prisma.task.create({
data: {
title: data.title,
description: data.description,
deadline: new Date(data.deadline),
priority: data.priority,
createdBy,
},
include: defaultInclude,
});
}
update(id: string, data: UpdateTaskDto) {
return prisma.task.update({
where: { id },
data: {
...(data.title !== undefined ? { title: data.title } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.deadline !== undefined ? { deadline: new Date(data.deadline) } : {}),
...(data.priority !== undefined ? { priority: data.priority } : {}),
},
include: defaultInclude,
});
}
softDelete(id: string) {
return prisma.task.update({
where: { id },
data: { deletedAt: new Date() },
include: defaultInclude,
});
}
}
import { Router } from 'express';
import { TaskController } from './task.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import {
findAllTaskSchema,
createTaskSchema,
updateTaskSchema,
} from './task.validation';
const router = Router();
const controller = new TaskController();
// GET /tasks?page=...&limit=...&sortBy=...&order=...&title=...&priority=...&createdBy=...&deadlineFrom=...&deadlineTo=...
router.get('/', authMiddleware, requireRole('ADMIN', 'LEADER', 'INTERN'), validate(findAllTaskSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole('ADMIN', 'LEADER', 'INTERN'), controller.findById);
router.post('/', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(createTaskSchema), controller.create);
router.put('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(updateTaskSchema), controller.update);
router.delete('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), controller.delete);
export default router;
\ No newline at end of file
import { TaskRepository } from './task.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { TaskQueryDto, CreateTaskDto, UpdateTaskDto } from './task.dto';
export class TaskService {
private readonly repository = new TaskRepository();
async findAll(query: TaskQueryDto) {
return this.repository.findAll(query);
}
async findById(id: string) {
const task = await this.repository.findById(id);
if (!task) {
throw new AppError('Task not found', 404, ERROR_CODE.NOT_FOUND);
}
return task;
}
async create(data: CreateTaskDto, createdBy: string) {
return this.repository.create(data, createdBy);
}
async update(id: string, data: UpdateTaskDto) {
await this.findById(id);
return this.repository.update(id, data);
}
async delete(id: string) {
await this.findById(id);
return this.repository.softDelete(id);
}
}
import { z } from 'zod';
export const findAllTaskSchema = z.object({
title: z.string().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
createdBy: z.string().uuid().optional(),
deadlineFrom: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid deadlineFrom' })
.optional(),
deadlineTo: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid deadlineTo' })
.optional(),
sortBy: z.enum(['createdAt', 'title', 'deadline', 'priority']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const createTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
deadline: z.string().refine((v) => !isNaN(Date.parse(v)), {
message: 'Invalid deadline',
}),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
});
export const updateTaskSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().nullable().optional(),
deadline: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid deadline' })
.optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
});
import { Request, Response, NextFunction } from 'express';
import { UserService } from './user.service';
import { UserQueryDto } from './user.dto';
import { UserQueryDto, CreateUserDto, UpdateUserDto } from './user.dto';
export class UserController {
private readonly service = new UserService();
......@@ -31,7 +31,8 @@ export class UserController {
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.create(req.body);
const body = req.body as CreateUserDto;
const result = await this.service.create(body);
res.status(201).json({
success: true,
......@@ -44,7 +45,8 @@ export class UserController {
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.update(req.params.id, req.body);
const body = req.body as UpdateUserDto;
const result = await this.service.update(req.params.id, body);
res.json({
success: true,
......
......@@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs';
import { UserRepository } from './user.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { UserQueryDto } from './user.dto';
import { UserQueryDto, CreateUserDto, UpdateUserDto } from './user.dto';
export class UserService {
private readonly repository = new UserRepository();
......@@ -21,7 +21,7 @@ export class UserService {
return user;
}
async create(data: { email: string; password: string; roleId: string }) {
async create(data: CreateUserDto) {
const existing = await this.repository.findByEmail(data.email);
if (existing) {
......@@ -37,7 +37,7 @@ export class UserService {
});
}
async update(id: string, data: { isActive?: boolean; roleId?: string }) {
async update(id: string, data: UpdateUserDto) {
await this.findById(id);
return this.repository.update(id, {
......
import { Router } from 'express';
import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.route';
import applicationRoute from '../modules/applications/application.route';
import internRoute from '../modules/interns/intern.route';
import taskRoute from '../modules/tasks/task.route';
const router = Router();
......@@ -13,8 +10,5 @@ router.get('/health', (req, res) => {
router.use('/auth', authRoute);
router.use('/users', userRoute);
router.use('/applications', applicationRoute);
router.use('/interns', internRoute);
router.use('/tasks', taskRoute);
export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment