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 NODE_ENV=development
PORT=3000 PORT=7777
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-1-[region].pooler.supabase.com:6543/postgres?pgbouncer=true DATABASE_URL="postgresql://postgres:[PASSWORD]@localhost:5432/datafinwise?schema=public"
DIRECT_URL=postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres DB_HOST=localhost
DB_HOST=aws-1-[region].pooler.supabase.com DB_PORT=5432
DB_PORT=6543 DB_USER=postgres
DB_USER=postgres.[project-ref] DB_PASSWORD=[PASSWORD]
DB_PASSWORD=[password] DB_NAME=datafinwise
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
JWT_ACCESS_SECRET=change_me_access_secret JWT_ACCESS_SECRET=change_me_access_secret
JWT_REFRESH_SECRET=change_me_refresh_secret JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_ACCESS_EXPIRES_IN=1d JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6381
\ No newline at end of file \ 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 ## Tech Stack
- **Node.js** & **Express** with **TypeScript** - **Node.js** & **Express** with **TypeScript**
- **Prisma ORM** with **PostgreSQL** (Supabase) - **Prisma ORM** with **PostgreSQL** (Supabase)
- **JWT Authentication** & **Role-Based Access Control** - **JWT Authentication** & **Role-Based Access Control**
...@@ -10,32 +15,31 @@ Backend API service for managing interns, supervisors (leaders), tasks, submissi ...@@ -10,32 +15,31 @@ Backend API service for managing interns, supervisors (leaders), tasks, submissi
--- ---
## Database Design & Models ## Core Models
The system is structured around 12 core models: Dựa trên Prisma schema hiện tại, repository chứa các model chính sau:
1. **Role**: Represents user authorizations (`ADMIN`, `LEADER`, `INTERN`).
2. **User**: Credentials, active status, and role reference. - **User**: Email, mật khẩu, tên, trạng thái và role.
3. **Application**: Registry/application form details. Spawns a User account upon approval. - **Wallet**: Ví người dùng.
4. **InternProfile**: Profile details for approved interns, linking them to their corresponding `User` and supervisor (`User` with role `LEADER`). - **Category**: Danh mục thu/chi.
5. **NotificationSetting**: Communication channels configuration per user. - **Transaction**: Giao dịch thu/chi.
6. **Task**: Assignments created by administrators or leaders. - **Budget**: Ngân sách theo danh mục.
7. **TaskAssignment**: Task assignations linking exactly one task to one intern. - **RefreshToken**: Lưu refresh token cho đăng nhập lâu dài.
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. > Lưu ý: hiện tại các route public/active trong project chỉ bao gồm Auth và Users.
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.
--- ---
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Node.js (v18+) - Node.js (v18+)
- pnpm (recommended) or npm - pnpm (recommended) or npm
### Installation ### Installation
1. Clone the repository
1. Clone the repository.
2. Install dependencies: 2. Install dependencies:
```bash ```bash
pnpm install pnpm install
...@@ -51,38 +55,50 @@ The system is structured around 12 core models: ...@@ -51,38 +55,50 @@ The system is structured around 12 core models:
``` ```
### Database Initialization ### 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 ```bash
# Validate schema # Validate Prisma schema
npx prisma validate npx prisma validate
# Run migrations # 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 npm run db:seed
``` ```
*Default Admin credentials:*
- **Email**: `admin@nexcampus.local` _Default Admin credentials:_
- **Email**: `admin@finwise.local`
- **Password**: `Admin@123456` - **Password**: `Admin@123456`
### Running the App ### Running the App
Start the development server: Start the development server:
```bash ```bash
npm run dev npm run dev
``` ```
The server runs on http://localhost:3000 by default. The server runs on http://localhost:3000 by default.
Swagger UI is available at `http://localhost:3000/api/docs`.
--- ---
## API Endpoints ## API Endpoints
- **Auth Routing (`/api/v1/auth`)**: - **System**:
- `POST /login`: User authentication. - `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. - `GET /me`: Get current authenticated user details.
- **Users Routing (`/api/v1/users`)** (Admin only): - `POST /refresh`: Refresh JWT tokens.
- `GET /`: Get all users. - `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. - `GET /:id`: Get user details by ID.
- `POST /`: Create a new user account. - `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: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: crawl_data_postgres container_name: finwise_postgres
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: "${DB_USER:-postgres}"
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: "${DB_PASSWORD:-postgres}"
POSTGRES_DB: crawl_data_db POSTGRES_DB: "${DB_NAME:-datafinwise}"
ports: ports:
- "5432:5432" - "${DB_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: crawl_data_redis container_name: finwise_redis
ports: 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: volumes:
postgres_data: postgres_data:
{ {
"name": "data-crawler-be", "name": "finwise-miniapp-be",
"version": "1.0.0", "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", "main": "dist/server.js",
"packageManager": "pnpm@9.15.0", "packageManager": "pnpm@9.15.0",
"scripts": { "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;
/*
Warnings:
- You are about to drop the column `full_name` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `password_hash` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `role` on the `users` table. All the data in the column will be lost.
- You are about to drop the `crawl_assets` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `crawl_exports` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `crawl_jobs` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `crawl_pages` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `password` to the `users` table without a default value. This is not possible if the table is not empty.
- Added the required column `role_id` to the `users` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "ApplicationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "InternStatus" AS ENUM ('ACTIVE', 'COMPLETED', 'DROPPED');
-- CreateEnum
CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateEnum
CREATE TYPE "AssignmentStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'REVIEW', 'DONE');
-- CreateEnum
CREATE TYPE "ReviewStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('WEB', 'EMAIL', 'DISCORD');
-- CreateEnum
CREATE TYPE "NotificationLogStatus" AS ENUM ('SUCCESS', 'FAILED');
-- DropForeignKey
ALTER TABLE "crawl_assets" DROP CONSTRAINT "crawl_assets_job_id_fkey";
-- DropForeignKey
ALTER TABLE "crawl_assets" DROP CONSTRAINT "crawl_assets_page_id_fkey";
-- DropForeignKey
ALTER TABLE "crawl_exports" DROP CONSTRAINT "crawl_exports_job_id_fkey";
-- DropForeignKey
ALTER TABLE "crawl_jobs" DROP CONSTRAINT "crawl_jobs_user_id_fkey";
-- DropForeignKey
ALTER TABLE "crawl_pages" DROP CONSTRAINT "crawl_pages_job_id_fkey";
-- AlterTable
ALTER TABLE "users" DROP COLUMN "full_name",
DROP COLUMN "password_hash",
DROP COLUMN "role",
ADD COLUMN "password" TEXT NOT NULL,
ADD COLUMN "role_id" UUID NOT NULL;
-- DropTable
DROP TABLE "crawl_assets";
-- DropTable
DROP TABLE "crawl_exports";
-- DropTable
DROP TABLE "crawl_jobs";
-- DropTable
DROP TABLE "crawl_pages";
-- DropEnum
DROP TYPE "AssetType";
-- DropEnum
DROP TYPE "CrawlJobStatus";
-- DropEnum
DROP TYPE "CrawlMode";
-- DropEnum
DROP TYPE "CrawlPageStatus";
-- DropEnum
DROP TYPE "ExportStatus";
-- DropEnum
DROP TYPE "ExportType";
-- DropEnum
DROP TYPE "UserRole";
-- 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 "applications" (
"id" UUID NOT NULL,
"full_name" TEXT NOT NULL,
"email" 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,
"status" "ApplicationStatus" NOT NULL DEFAULT 'PENDING',
"approved_by" UUID,
"approved_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "applications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "intern_profiles" (
"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',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "intern_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_settings" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"web_enabled" BOOLEAN NOT NULL DEFAULT true,
"email_enabled" BOOLEAN NOT NULL DEFAULT true,
"discord_enabled" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_settings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tasks" (
"id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"deadline" TIMESTAMP(3) NOT NULL,
"priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM',
"created_by" UUID NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "task_assignments" (
"id" UUID NOT NULL,
"task_id" UUID NOT NULL,
"intern_id" UUID NOT NULL,
"assigned_by" UUID NOT NULL,
"status" "AssignmentStatus" NOT NULL DEFAULT 'TODO',
"assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "task_assignments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "task_submissions" (
"id" UUID NOT NULL,
"assignment_id" UUID NOT NULL,
"attempt" INTEGER NOT NULL,
"pr_link" TEXT,
"video_demo" TEXT,
"note" TEXT,
"review_status" "ReviewStatus" NOT NULL DEFAULT 'PENDING',
"review_comment" TEXT,
"reviewed_by" UUID,
"reviewed_at" TIMESTAMP(3),
"submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "task_submissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "daily_reports" (
"id" UUID NOT NULL,
"intern_id" UUID NOT NULL,
"content" TEXT NOT NULL,
"pr_link" TEXT,
"video_demo" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "daily_reports_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "weekly_evaluations" (
"id" UUID NOT NULL,
"intern_id" UUID NOT NULL,
"leader_id" UUID NOT NULL,
"week" INTEGER NOT NULL,
"communication" DOUBLE PRECISION NOT NULL,
"attitude" DOUBLE PRECISION NOT NULL,
"learning" DOUBLE PRECISION NOT NULL,
"coding" DOUBLE PRECISION NOT NULL,
"total_score" DOUBLE PRECISION NOT NULL,
"comment" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "weekly_evaluations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"type" TEXT NOT NULL,
"is_read" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_logs" (
"id" UUID NOT NULL,
"notification_id" UUID NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"status" "NotificationLogStatus" NOT NULL,
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "intern_profiles_user_id_key" ON "intern_profiles"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "notification_settings_user_id_key" ON "notification_settings"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "task_assignments_task_id_key" ON "task_assignments"("task_id");
-- CreateIndex
CREATE UNIQUE INDEX "weekly_evaluations_intern_id_week_key" ON "weekly_evaluations"("intern_id", "week");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "applications" ADD CONSTRAINT "applications_approved_by_fkey" FOREIGN KEY ("approved_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "intern_profiles" ADD CONSTRAINT "intern_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "intern_profiles" ADD CONSTRAINT "intern_profiles_leader_id_fkey" FOREIGN KEY ("leader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_settings" ADD CONSTRAINT "notification_settings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_assignments" ADD CONSTRAINT "task_assignments_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_assignments" ADD CONSTRAINT "task_assignments_intern_id_fkey" FOREIGN KEY ("intern_id") REFERENCES "intern_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_assignments" ADD CONSTRAINT "task_assignments_assigned_by_fkey" FOREIGN KEY ("assigned_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_submissions" ADD CONSTRAINT "task_submissions_assignment_id_fkey" FOREIGN KEY ("assignment_id") REFERENCES "task_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_submissions" ADD CONSTRAINT "task_submissions_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "daily_reports" ADD CONSTRAINT "daily_reports_intern_id_fkey" FOREIGN KEY ("intern_id") REFERENCES "intern_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "weekly_evaluations" ADD CONSTRAINT "weekly_evaluations_intern_id_fkey" FOREIGN KEY ("intern_id") REFERENCES "intern_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "weekly_evaluations" ADD CONSTRAINT "weekly_evaluations_leader_id_fkey" FOREIGN KEY ("leader_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_logs" ADD CONSTRAINT "notification_logs_notification_id_fkey" FOREIGN KEY ("notification_id") REFERENCES "notifications"("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 is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
...@@ -8,294 +5,111 @@ generator client { ...@@ -8,294 +5,111 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
} }
// 1. Role enum TransactionType {
model Role { INCOME
id String @id @default(uuid()) @db.Uuid EXPENSE
name String @unique // ADMIN, LEADER, INTERN
users User[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("roles")
} }
// 2. User
model User { model User {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
email String @unique email String @unique
password String password String
fullName String? @map("full_name") fullName String? @map("full_name")
roleId String @map("role_id") @db.Uuid
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
roleId String @map("role_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
role Role @relation(fields: [roleId], references: [id])
// Relations // Relations
approvedApplications Application[] @relation("ApprovedApplications") role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
intern Intern? @relation("InternUser") wallets Wallet[]
leadedInterns Intern[] @relation("LeaderInterns") categories Category[]
notificationSetting NotificationSetting? transactions Transaction[]
createdTasks Task[] @relation("CreatedTasks") budgets Budget[]
assignedTasks TaskAssignment[] @relation("AssignedTasks")
reviewedSubmissions TaskSubmission[] @relation("ReviewedSubmissions")
leaderEvaluations WeeklyEvaluation[] @relation("LeaderEvaluations")
notifications Notification[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
@@index([roleId])
@@map("users") @@map("users")
} }
// 3. Application model Role {
enum ApplicationStatus {
PENDING
APPROVED
REJECTED
}
model Application {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
fullName String @map("full_name") name String @unique
email String users User[]
phone String
department String
position String
startDate DateTime @map("start_date")
duration Int // Duration of internship (months or weeks)
status ApplicationStatus @default(PENDING)
approvedBy String? @map("approved_by") @db.Uuid
approvedAt DateTime? @map("approved_at")
deletedAt DateTime? @map("deleted_at") // Soft delete
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
approver User? @relation("ApprovedApplications", fields: [approvedBy], references: [id], onDelete: SetNull) @@map("roles")
@@index([approvedBy])
@@map("applications")
}
// 4. Intern
enum InternStatus {
ACTIVE
COMPLETED
DROPPED
} }
model Intern { model Wallet {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
leaderId String? @map("leader_id") @db.Uuid name String
fullName String @map("full_name") balance Float
phone String
department String
position String
startDate DateTime @map("start_date")
duration Int // Duration of internship
discordUsername String? @map("discord_username")
discordRoleGranted Boolean @default(false) @map("discord_role_granted")
status InternStatus @default(ACTIVE)
deletedAt DateTime? @map("deleted_at") // Soft delete
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
user User @relation("InternUser", fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
leader User? @relation("LeaderInterns", fields: [leaderId], references: [id], onDelete: SetNull) transactions Transaction[]
// Relations
assignments TaskAssignment[]
dailyReports DailyReport[]
weeklyEvaluations WeeklyEvaluation[]
@@index([leaderId]) @@unique([userId, name])
@@map("interns") @@map("wallets")
} }
// 5. NotificationSetting model Category {
model NotificationSetting {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
webEnabled Boolean @default(true) @map("web_enabled") name String
emailEnabled Boolean @default(true) @map("email_enabled") type TransactionType
discordEnabled Boolean @default(false) @map("discord_enabled")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions Transaction[]
budgets Budget[]
@@map("notification_settings") @@unique([userId, name, type])
@@map("categories")
} }
// 6. Task model Transaction {
enum TaskPriority {
LOW
MEDIUM
HIGH
}
model Task {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
title String userId String @map("user_id") @db.Uuid
walletId String @map("wallet_id") @db.Uuid
categoryId String @map("category_id") @db.Uuid
amount Float
type TransactionType
description String? description String?
deadline DateTime date DateTime
priority TaskPriority @default(MEDIUM)
createdBy String @map("created_by") @db.Uuid
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
creator User @relation("CreatedTasks", fields: [createdBy], references: [id], onDelete: Cascade)
assignment TaskAssignment?
@@index([createdBy])
@@map("tasks")
}
// 7. TaskAssignment
enum AssignmentStatus {
TODO
IN_PROGRESS
REVIEW
DONE
}
model TaskAssignment {
id String @id @default(uuid()) @db.Uuid
taskId String @unique @map("task_id") @db.Uuid
internId String @map("intern_id") @db.Uuid
assignedBy String @map("assigned_by") @db.Uuid
status AssignmentStatus @default(TODO)
assignedAt DateTime @default(now()) @map("assigned_at")
updatedAt DateTime @updatedAt @map("updated_at")
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
intern Intern @relation(fields: [internId], references: [id], onDelete: Cascade)
assigner User @relation("AssignedTasks", fields: [assignedBy], references: [id], onDelete: Cascade)
submissions TaskSubmission[]
@@index([internId])
@@index([assignedBy])
@@map("task_assignments")
}
// 8. TaskSubmission
enum ReviewStatus {
PENDING
APPROVED
REJECTED
}
model TaskSubmission {
id String @id @default(uuid()) @db.Uuid
assignmentId String @map("assignment_id") @db.Uuid
attempt Int
prLink String? @map("pr_link")
videoDemo String? @map("video_demo")
note String?
reviewStatus ReviewStatus @default(PENDING) @map("review_status")
reviewComment String? @map("review_comment")
reviewedBy String? @map("reviewed_by") @db.Uuid
reviewedAt DateTime? @map("reviewed_at")
submittedAt DateTime @default(now()) @map("submitted_at")
updatedAt DateTime @updatedAt @map("updated_at")
assignment TaskAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
reviewer User? @relation("ReviewedSubmissions", fields: [reviewedBy], references: [id], onDelete: SetNull)
@@index([assignmentId])
@@index([reviewedBy])
@@map("task_submissions")
}
// 9. DailyReport
model DailyReport {
id String @id @default(uuid()) @db.Uuid
internId String @map("intern_id") @db.Uuid
content String
prLink String? @map("pr_link")
videoDemo String? @map("video_demo")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
intern Intern @relation(fields: [internId], references: [id], onDelete: Cascade)
@@index([internId])
@@map("daily_reports")
}
// 10. WeeklyEvaluation
model WeeklyEvaluation {
id String @id @default(uuid()) @db.Uuid
internId String @map("intern_id") @db.Uuid
leaderId String @map("leader_id") @db.Uuid
week Int
communication Float
attitude Float
learning Float
coding Float
totalScore Float @map("total_score")
comment String?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
intern Intern @relation(fields: [internId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
leader User @relation("LeaderEvaluations", fields: [leaderId], references: [id], onDelete: Cascade) wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
@@unique([internId, week]) @@map("transactions")
@@index([leaderId])
@@map("weekly_evaluations")
} }
// 11. Notification model Budget {
model Notification {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
title String categoryId String @map("category_id") @db.Uuid
content String name String
type String amount Float
isRead Boolean @default(false) @map("is_read") startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
logs NotificationLog[] category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("notifications")
}
// 12. NotificationLog
enum NotificationChannel {
WEB
EMAIL
DISCORD
}
enum NotificationLogStatus {
SUCCESS
FAILED
}
model NotificationLog {
id String @id @default(uuid()) @db.Uuid
notificationId String @map("notification_id") @db.Uuid
channel NotificationChannel
status NotificationLogStatus
sentAt DateTime @default(now()) @map("sent_at")
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
@@index([notificationId]) @@map("budgets")
@@map("notification_logs")
} }
// 13. RefreshToken
model RefreshToken { model RefreshToken {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
token String @unique token String @unique
......
...@@ -17,13 +17,13 @@ async function main() { ...@@ -17,13 +17,13 @@ async function main() {
console.log(`Role ${roleName} upserted with ID ${role.id}`); 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 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 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); const internPassword = await bcrypt.hash('Intern@123456', 10);
await prisma.user.upsert({ await prisma.user.upsert({
......
...@@ -6,16 +6,13 @@ import swaggerUi from 'swagger-ui-express'; ...@@ -6,16 +6,13 @@ import swaggerUi from 'swagger-ui-express';
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware'; import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware';
import routes from './routes'; import routes from './routes';
import { swaggerSpec, swaggerOptions } from './config/swagger.config'; import { swaggerSpec, swaggerOptions } from './config/swagger.config';
import { rateLimitMiddleware } from './middlewares/rate-limit.middleware';
const app = express(); const app = express();
app.set('trust proxy', true); app.set('trust proxy', true);
app.use( app.use(helmet({ contentSecurityPolicy: false, }),);
helmet({
contentSecurityPolicy: false, // Tắt CSP để Swagger UI tải được stylesheet
}),
);
app.use(cors()); app.use(cors());
app.use(morgan('dev')); app.use(morgan('dev'));
app.use(express.json()); app.use(express.json());
...@@ -23,7 +20,7 @@ app.use(express.urlencoded({ extended: true })); ...@@ -23,7 +20,7 @@ app.use(express.urlencoded({ extended: true }));
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions)); 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(notFoundMiddleware);
app.use(errorMiddleware); app.use(errorMiddleware);
......
...@@ -4,19 +4,18 @@ export const swaggerOptions: SwaggerUiOptions = { ...@@ -4,19 +4,18 @@ export const swaggerOptions: SwaggerUiOptions = {
customCss: ` customCss: `
.swagger-ui .topbar { background-color: #1a1a2e; } .swagger-ui .topbar { background-color: #1a1a2e; }
.swagger-ui .topbar-wrapper .link img { display: none; } .swagger-ui .topbar-wrapper .link img { display: none; }
.swagger-ui .topbar-wrapper .link::after { content: 'NexCampus API'; color: white; font-size: 1.2rem; font-weight: bold; } .swagger-ui .topbar-wrapper .link::after { content: 'FinWise API'; color: white; font-size: 1.2rem; font-weight: bold; }
`, `,
customSiteTitle: 'NexCampus API Docs', customSiteTitle: 'FinWise API Docs',
}; };
export const swaggerSpec = { export const swaggerSpec = {
openapi: '3.0.0', openapi: '3.0.0',
info: { info: {
title: 'NexCampus API', title: 'FinWise API',
version: '1.0.0', version: '1.0.0',
description: description: 'Backend API cho FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App). Hiện tại API cung cấp xác thực và quản lý người dùng.',
'Backend API cho hệ thống quản lý thực tập sinh NexCampus. Bao gồm các module: Auth, Users, Applications.', contact: { name: 'FinWise Team' },
contact: { name: 'NexCampus Team' },
}, },
servers: [ servers: [
{ url: '/api/v1', description: 'Development server' }, { url: '/api/v1', description: 'Development server' },
...@@ -27,11 +26,10 @@ export const swaggerSpec = { ...@@ -27,11 +26,10 @@ export const swaggerSpec = {
type: 'http', type: 'http',
scheme: 'bearer', scheme: 'bearer',
bearerFormat: 'JWT', bearerFormat: 'JWT',
description: 'Nhập Access Token nhận được từ POST /auth/login', description: 'Sử dụng header Authorization: Bearer <accessToken>',
}, },
}, },
schemas: { schemas: {
// ─── Common ───────────────────────────────────────────────────────────
SuccessResponse: { SuccessResponse: {
type: 'object', type: 'object',
properties: { properties: {
...@@ -55,7 +53,6 @@ export const swaggerSpec = { ...@@ -55,7 +53,6 @@ export const swaggerSpec = {
code: { type: 'string', example: 'NOT_FOUND' }, code: { type: 'string', example: 'NOT_FOUND' },
}, },
}, },
// ─── Role ─────────────────────────────────────────────────────────────
Role: { Role: {
type: 'object', type: 'object',
properties: { properties: {
...@@ -63,7 +60,6 @@ export const swaggerSpec = { ...@@ -63,7 +60,6 @@ export const swaggerSpec = {
name: { type: 'string', enum: ['ADMIN', 'LEADER', 'INTERN'] }, name: { type: 'string', enum: ['ADMIN', 'LEADER', 'INTERN'] },
}, },
}, },
// ─── User ─────────────────────────────────────────────────────────────
User: { User: {
type: 'object', type: 'object',
properties: { properties: {
...@@ -81,8 +77,8 @@ export const swaggerSpec = { ...@@ -81,8 +77,8 @@ export const swaggerSpec = {
type: 'object', type: 'object',
required: ['email', 'password', 'roleId'], required: ['email', 'password', 'roleId'],
properties: { properties: {
email: { type: 'string', format: 'email', example: 'intern@nexcampus.local' }, email: { type: 'string', format: 'email', example: 'admin@finwise.local' },
password: { type: 'string', minLength: 8, example: 'Intern@123456' }, password: { type: 'string', minLength: 8, example: 'Admin@123456' },
roleId: { type: 'string', format: 'uuid', example: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, roleId: { type: 'string', format: 'uuid', example: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
}, },
}, },
...@@ -93,198 +89,41 @@ export const swaggerSpec = { ...@@ -93,198 +89,41 @@ export const swaggerSpec = {
roleId: { type: 'string', format: 'uuid' }, roleId: { type: 'string', format: 'uuid' },
}, },
}, },
// ─── Auth ─────────────────────────────────────────────────────────────
LoginBody: { LoginBody: {
type: 'object', type: 'object',
required: ['email', 'password'], required: ['email', 'password'],
properties: { properties: {
email: { type: 'string', format: 'email', example: 'admin@nexcampus.local' }, email: { type: 'string', format: 'email', example: 'admin@finwise.local' },
password: { type: 'string', example: 'Admin@123456' }, password: { type: 'string', example: 'Admin@123456' },
}, },
}, },
TokenPair: { LoginResponse: {
type: 'object', type: 'object',
properties: { properties: {
accessToken: { type: 'string' }, accessToken: { type: 'string' },
refreshToken: { type: 'string' }, refreshToken: { type: 'string' },
user: { $ref: '#/components/schemas/User' },
}, },
}, },
RefreshBody: { TokenPair: {
type: 'object', type: 'object',
required: ['refreshToken'],
properties: { properties: {
accessToken: { type: 'string' },
refreshToken: { type: 'string' }, refreshToken: { type: 'string' },
}, },
}, },
LogoutBody: { RefreshBody: {
type: 'object', type: 'object',
required: ['refreshToken'], required: ['refreshToken'],
properties: { properties: {
refreshToken: { type: 'string' }, refreshToken: { type: 'string' },
}, },
}, },
// ─── Application ────────────────────────────────────────────────────── LogoutBody: {
Application: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
fullName: { type: 'string', example: 'Nguyễn Văn A' },
email: { type: 'string', format: 'email' },
phone: { type: 'string', example: '0912345678' },
department: { type: 'string', example: 'Engineering' },
position: { type: 'string', example: 'Frontend Developer' },
startDate: { type: 'string', format: 'date-time' },
duration: { type: 'integer', example: 3, description: 'Số tháng thực tập' },
status: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'] },
approvedBy: { type: 'string', format: 'uuid', nullable: true },
approvedAt: { type: 'string', format: 'date-time', nullable: true },
deletedAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
approver: {
nullable: true,
allOf: [{
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
fullName: { type: 'string', nullable: true },
},
}],
},
},
},
CreateApplicationBody: {
type: 'object',
required: ['fullName', 'email', 'phone', 'department', 'position', 'startDate', 'duration'],
properties: {
fullName: { type: 'string', example: 'Nguyễn Văn A' },
email: { type: 'string', format: 'email', example: 'vana@gmail.com' },
phone: { type: 'string', example: '0912345678' },
department: { type: 'string', example: 'Engineering' },
position: { type: 'string', example: 'Frontend Developer' },
startDate: { type: 'string', format: 'date', example: '2025-08-01' },
duration: { type: 'integer', example: 3, description: 'Số tháng thực tập' },
},
},
ReviewApplicationBody: {
type: 'object',
required: ['status'],
properties: {
status: { type: 'string', enum: ['APPROVED', 'REJECTED'] },
},
},
// ─── Intern ────────────────────────────────────────────────────
Intern: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
userId: { type: 'string', format: 'uuid' },
leaderId: { type: 'string', format: 'uuid', nullable: true },
fullName: { type: 'string', example: 'Nguyễn Chí Thịnh' },
phone: { type: 'string', example: '0912345678' },
department: { type: 'string', example: 'Engineering' },
position: { type: 'string', example: 'Frontend Developer' },
startDate: { type: 'string', format: 'date-time' },
duration: { type: 'integer', example: 3, description: 'Số tháng thực tập' },
discordUsername: { type: 'string', nullable: true },
discordRoleGranted: { type: 'boolean', example: false },
status: { type: 'string', enum: ['ACTIVE', 'COMPLETED', 'DROPPED'] },
deletedAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
user: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
fullName: { type: 'string', nullable: true },
isActive: { type: 'boolean' },
},
},
leader: {
nullable: true,
allOf: [{
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
fullName: { type: 'string', nullable: true },
isActive: { type: 'boolean' },
},
}],
},
},
},
CreateInternBody: {
type: 'object',
required: ['userId', 'fullName', 'phone', 'department', 'position', 'startDate', 'duration'],
properties: {
userId: { type: 'string', format: 'uuid' },
leaderId: { type: 'string', format: 'uuid' },
fullName: { type: 'string', example: 'Nguyễn Chí Thịnh' },
phone: { type: 'string', example: '0912345678' },
department: { type: 'string', example: 'Engineering' },
position: { type: 'string', example: 'Frontend Developer' },
startDate: { type: 'string', format: 'date', example: '2025-08-01' },
duration: { type: 'integer', example: 3 },
discordUsername: { type: 'string', example: 'chithinhdev' },
},
},
UpdateInternBody: {
type: 'object',
properties: {
leaderId: { type: 'string', format: 'uuid', nullable: true },
fullName: { type: 'string' },
phone: { type: 'string' },
department: { type: 'string' },
position: { type: 'string' },
startDate: { type: 'string', format: 'date' },
duration: { type: 'integer' },
discordUsername: { type: 'string', nullable: true },
discordRoleGranted: { type: 'boolean' },
status: { type: 'string', enum: ['ACTIVE', 'COMPLETED', 'DROPPED'] },
},
},
Task: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
title: { type: 'string', example: 'Lập trình tính năng đăng nhập' },
description: { type: 'string', nullable: true, example: 'Sử dụng JWT và bcrypt để bảo mật mật khẩu' },
deadline: { type: 'string', format: 'date-time' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
createdBy: { type: 'string', format: 'uuid' },
deletedAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
creator: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
fullName: { type: 'string', nullable: true },
},
},
},
},
CreateTaskBody: {
type: 'object',
required: ['title', 'deadline'],
properties: {
title: { type: 'string', example: 'Lập trình tính năng đăng nhập' },
description: { type: 'string', example: 'Sử dụng JWT và bcrypt' },
deadline: { type: 'string', format: 'date-time', example: '2025-08-10T17:00:00.000Z' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'], default: 'MEDIUM' },
},
},
UpdateTaskBody: {
type: 'object', type: 'object',
required: ['refreshToken'],
properties: { properties: {
title: { type: 'string' }, refreshToken: { type: 'string' },
description: { type: 'string', nullable: true },
deadline: { type: 'string', format: 'date-time' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
}, },
}, },
}, },
...@@ -293,46 +132,62 @@ export const swaggerSpec = { ...@@ -293,46 +132,62 @@ export const swaggerSpec = {
LimitParam: { in: 'query', name: 'limit', schema: { type: 'integer', default: 20, maximum: 100 } }, LimitParam: { in: 'query', name: 'limit', schema: { type: 'integer', default: 20, maximum: 100 } },
OrderParam: { in: 'query', name: 'order', schema: { type: 'string', enum: ['asc', 'desc'], default: 'desc' } }, OrderParam: { in: 'query', name: 'order', schema: { type: 'string', enum: ['asc', 'desc'], default: 'desc' } },
}, },
// ─── Reusable responses ───────────────────────────────────────────────────
responses: { responses: {
Unauthorized: { description: 'Chưa đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }, Unauthorized: { description: 'Chưa đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
Forbidden: { description: 'Không có quyền', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }, Forbidden: { description: 'Không có quyền', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
NotFound: { description: 'Không tìm thấy', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }, NotFound: { description: 'Không tìm thấy', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
Validation: { description: 'Dữ liệu không hợp lệ', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }, Validation: { description: 'Dữ liệu không hợp lệ', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
Conflict: { description: 'Xung đột dữ liệu', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
}, },
}, },
tags: [ tags: [
{ name: 'Auth', description: 'Đăng nhập, làm mới token, đăng xuất' },
{ name: 'Users', description: 'Quản lý tài khoản người dùng (Admin only)' },
{ name: 'Applications', description: 'Đơn xin thực tập (Onboarding)' },
{ name: 'Interns', description: 'Hồ sơ thực tập sinh (Interns)' },
{ name: 'Tasks', description: 'Quản lý công việc (Tasks)' },
{ name: 'System', description: 'Health check' }, { name: 'System', description: 'Health check' },
{ name: 'Auth', description: 'Authentication endpoints' },
{ name: 'Users', description: 'User account management (Admin only)' },
], ],
paths: { paths: {
// ─── System ─────────────────────────────────────────────────────────────
'/health': { '/health': {
get: { get: {
tags: ['System'], tags: ['System'],
summary: 'Health check', summary: 'Health check',
responses: { responses: {
200: { description: 'Server đang chạy', content: { 'application/json': { schema: { type: 'object', properties: { status: { type: 'string', example: 'ok' }, timestamp: { type: 'string' } } } } } }, 200: {
description: 'Server đang chạy',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
status: { type: 'string', example: 'ok' },
timestamp: { type: 'string', format: 'date-time' },
},
},
},
},
},
}, },
}, },
}, },
// ─── Auth ────────────────────────────────────────────────────────────────
'/auth/login': { '/auth/login': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Đăng nhập', summary: 'Đăng nhập và nhận token',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginBody' } } } }, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginBody' } } } },
responses: { responses: {
200: { 200: {
description: 'Đăng nhập thành công', description: 'Đăng nhập thành công',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/TokenPair' } } }] } } }, content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { data: { $ref: '#/components/schemas/LoginResponse' } } },
],
},
},
}, },
401: { description: 'Sai thông tin đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }, },
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' }, 422: { $ref: '#/components/responses/Validation' },
}, },
}, },
...@@ -345,7 +200,16 @@ export const swaggerSpec = { ...@@ -345,7 +200,16 @@ export const swaggerSpec = {
responses: { responses: {
200: { 200: {
description: 'Thông tin tài khoản đang đăng nhập', description: 'Thông tin tài khoản đang đăng nhập',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } }] } } }, content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } },
],
},
},
},
}, },
401: { $ref: '#/components/responses/Unauthorized' }, 401: { $ref: '#/components/responses/Unauthorized' },
}, },
...@@ -354,12 +218,21 @@ export const swaggerSpec = { ...@@ -354,12 +218,21 @@ export const swaggerSpec = {
'/auth/refresh': { '/auth/refresh': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Làm mới Access Token', summary: 'Làm mới token',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/RefreshBody' } } } }, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/RefreshBody' } } } },
responses: { responses: {
200: { 200: {
description: 'Cặp token mới', description: 'Cặp token mới',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/TokenPair' } } }] } } }, content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { data: { $ref: '#/components/schemas/TokenPair' } } },
],
},
},
},
}, },
401: { $ref: '#/components/responses/Unauthorized' }, 401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' }, 422: { $ref: '#/components/responses/Validation' },
...@@ -369,25 +242,35 @@ export const swaggerSpec = { ...@@ -369,25 +242,35 @@ export const swaggerSpec = {
'/auth/logout': { '/auth/logout': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Đăng xuất (thu hồi Refresh Token)', summary: 'Đăng xuất',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/LogoutBody' } } } }, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/LogoutBody' } } } },
responses: { responses: {
200: { description: 'Đăng xuất thành công', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { message: { type: 'string' } } }] } } } }, 200: {
description: 'Đăng xuất thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Logged out successfully' } } },
],
},
},
},
},
400: { description: 'Token không tồn tại', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }, 400: { description: 'Token không tồn tại', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' }, 422: { $ref: '#/components/responses/Validation' },
}, },
}, },
}, },
// ─── Users ───────────────────────────────────────────────────────────────
'/users': { '/users': {
get: { get: {
tags: ['Users'], tags: ['Users'],
summary: 'Danh sách tài khoản (có filter, sort, phân trang)', summary: 'Danh sách tài khoản',
security: [{ BearerAuth: [] }], security: [{ BearerAuth: [] }],
parameters: [ parameters: [
{ in: 'query', name: 'email', schema: { type: 'string' }, description: 'Tìm theo email (contains)' }, { in: 'query', name: 'email', schema: { type: 'string' }, description: 'Tìm theo email' },
{ in: 'query', name: 'fullName', schema: { type: 'string' }, description: 'Tìm theo tên (contains)' }, { in: 'query', name: 'fullName', schema: { type: 'string' }, description: 'Tìm theo tên' },
{ in: 'query', name: 'roleName', schema: { type: 'string', enum: ['ADMIN', 'LEADER', 'INTERN'] }, description: 'Lọc theo role' }, { in: 'query', name: 'roleName', schema: { type: 'string', enum: ['ADMIN', 'LEADER', 'INTERN'] }, description: 'Lọc theo role' },
{ in: 'query', name: 'isActive', schema: { type: 'string', enum: ['true', 'false'] }, description: 'Lọc theo trạng thái' }, { in: 'query', name: 'isActive', schema: { type: 'string', enum: ['true', 'false'] }, description: 'Lọc theo trạng thái' },
{ in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'email', 'fullName'], default: 'createdAt' } }, { in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'email', 'fullName'], default: 'createdAt' } },
...@@ -398,310 +281,104 @@ export const swaggerSpec = { ...@@ -398,310 +281,104 @@ export const swaggerSpec = {
responses: { responses: {
200: { 200: {
description: 'Danh sách user có phân trang', description: 'Danh sách user có phân trang',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/User' } }, meta: { $ref: '#/components/schemas/PaginationMeta' } } }] } } }, content: {
}, 'application/json': {
401: { $ref: '#/components/responses/Unauthorized' }, schema: {
403: { $ref: '#/components/responses/Forbidden' }, allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { type: 'array', items: { $ref: '#/components/schemas/User' } },
meta: { $ref: '#/components/schemas/PaginationMeta' },
}, },
}, },
post: { ],
tags: ['Users'],
summary: 'Tạo tài khoản mới (Admin only)',
security: [{ BearerAuth: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateUserBody' } } } },
responses: {
201: {
description: 'Tài khoản đã tạo',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } }] } } },
}, },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
409: { description: 'Email đã tồn tại', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' },
}, },
}, },
}, },
'/users/{id}': {
get: {
tags: ['Users'],
summary: 'Chi tiết tài khoản theo ID',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Thông tin user', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' }, 401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' }, 403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
}, },
}, },
put: { post: {
tags: ['Users'], tags: ['Users'],
summary: 'Cập nhật tài khoản (kích hoạt/đổi role)', summary: 'Tạo tài khoản mới',
security: [{ BearerAuth: [] }], security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateUserBody' } } } },
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateUserBody' } } } },
responses: {
200: { description: 'Cập nhật thành công', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
// ─── Applications ────────────────────────────────────────────────────────
'/applications': {
post: {
tags: ['Applications'],
summary: 'Nộp đơn xin thực tập (Public — không cần đăng nhập)',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateApplicationBody' } } } },
responses: { responses: {
201: { 201: {
description: 'Đơn đã được tiếp nhận (status = PENDING)', description: 'Tài khoản đã tạo',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Application' } } }] } } }, content: {
}, 'application/json': {
422: { $ref: '#/components/responses/Validation' }, schema: {
}, allOf: [
}, { $ref: '#/components/schemas/SuccessResponse' },
get: { { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } },
tags: ['Applications'],
summary: 'Danh sách đơn (Admin / Leader)',
security: [{ BearerAuth: [] }],
parameters: [
{ in: 'query', name: 'status', schema: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'] } },
{ in: 'query', name: 'department', schema: { type: 'string' }, description: 'Lọc bộ phận (contains)' },
{ in: 'query', name: 'position', schema: { type: 'string' }, description: 'Lọc vị trí (contains)' },
{ in: 'query', name: 'email', schema: { type: 'string' }, description: 'Tìm theo email (contains)' },
{ in: 'query', name: 'startDateFrom', schema: { type: 'string', format: 'date' }, description: 'Ngày bắt đầu từ' },
{ in: 'query', name: 'startDateTo', schema: { type: 'string', format: 'date' }, description: 'Ngày bắt đầu đến' },
{ in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'startDate', 'fullName', 'status'], default: 'createdAt' } },
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
], ],
responses: {
200: {
description: 'Danh sách đơn có phân trang',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Application' } }, meta: { $ref: '#/components/schemas/PaginationMeta' } } }] } } },
}, },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
}, },
}, },
}, },
'/applications/{id}': {
get: {
tags: ['Applications'],
summary: 'Chi tiết một đơn',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Thông tin đơn', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Application' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' }, 401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' }, 403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' }, 409: { $ref: '#/components/responses/Conflict' },
},
},
delete: {
tags: ['Applications'],
summary: 'Xoá mềm đơn (Admin only — chỉ PENDING hoặc REJECTED)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Đã xoá mềm', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { message: { type: 'string' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
409: { description: 'Không thể xoá đơn đã APPROVED', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
},
},
},
'/applications/{id}/review': {
patch: {
tags: ['Applications'],
summary: 'Phê duyệt hoặc từ chối đơn (Admin / Leader)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/ReviewApplicationBody' } } } },
responses: {
200: { description: 'Đơn đã được xử lý', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Application' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
409: { description: 'Đơn không còn ở trạng thái PENDING', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' }, 422: { $ref: '#/components/responses/Validation' },
}, },
}, },
}, },
'/users/{id}': {
// ─── Interns ─────────────────────────────────────────────────────────────
'/interns': {
get: { get: {
tags: ['Interns'], tags: ['Users'],
summary: 'Danh sách thực tập sinh (Admin / Leader)', summary: 'Chi tiết tài khoản theo ID',
security: [{ BearerAuth: [] }], security: [{ BearerAuth: [] }],
parameters: [ parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
{ in: 'query', name: 'fullName', schema: { type: 'string' }, description: 'Tìm theo tên (contains)' },
{ in: 'query', name: 'department', schema: { type: 'string' }, description: 'Lọc bộ phận (contains)' },
{ in: 'query', name: 'position', schema: { type: 'string' }, description: 'Lọc vị trí (contains)' },
{ in: 'query', name: 'status', schema: { type: 'string', enum: ['ACTIVE', 'COMPLETED', 'DROPPED'] } },
{ in: 'query', name: 'leaderId', schema: { type: 'string', format: 'uuid' }, description: 'Lọc theo Leader' },
{ in: 'query', name: 'discordRoleGranted', schema: { type: 'string', enum: ['true', 'false'] } },
{ in: 'query', name: 'startDateFrom', schema: { type: 'string', format: 'date' } },
{ in: 'query', name: 'startDateTo', schema: { type: 'string', format: 'date' } },
{ in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'fullName', 'startDate', 'status'], default: 'createdAt' } },
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: { responses: {
200: { 200: {
description: 'Danh sách hồ sơ có phân trang', description: 'Thông tin user',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Intern' } }, meta: { $ref: '#/components/schemas/PaginationMeta' } } }] } } }, content: {
}, 'application/json': {
401: { $ref: '#/components/responses/Unauthorized' }, schema: {
403: { $ref: '#/components/responses/Forbidden' }, allOf: [
}, { $ref: '#/components/schemas/SuccessResponse' },
}, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } },
post: { ],
tags: ['Interns'],
summary: 'Tạo hồ sơ thực tập sinh (Admin / Leader)',
description: 'Tạo sau khi đơn Application được phê duyệt. Mỗi userId chỉ có 1 hồ sơ.',
security: [{ BearerAuth: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateInternBody' } } } },
responses: {
201: {
description: 'Hồ sơ đã tạo',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Intern' } } }] } } },
}, },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
409: { description: 'User đã có hồ sơ thực tập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' },
}, },
}, },
}, },
'/interns/{id}': {
get: {
tags: ['Interns'],
summary: 'Chi tiết hồ sơ theo ID',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Thông tin hồ sơ', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Intern' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' }, 401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' }, 403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' }, 404: { $ref: '#/components/responses/NotFound' },
}, },
}, },
put: { put: {
tags: ['Interns'], tags: ['Users'],
summary: 'Cập nhật hồ sơ (Admin / Leader)', summary: 'Cập nhật tài khoản',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateInternBody' } } } },
responses: {
200: { description: 'Hồ sơ đã cập nhật', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Intern' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
delete: {
tags: ['Interns'],
summary: 'Xoá mềm hồ sơ (Admin only)',
security: [{ BearerAuth: [] }], security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }], parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: { requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateUserBody' } } } },
200: { description: 'Đã xoá mềm', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { message: { type: 'string' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
},
},
},
'/tasks': {
get: {
tags: ['Tasks'],
summary: 'Danh sách công việc (Admin / Leader / Intern)',
security: [{ BearerAuth: [] }],
parameters: [
{ in: 'query', name: 'title', schema: { type: 'string' } },
{ in: 'query', name: 'priority', schema: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] } },
{ in: 'query', name: 'createdBy', schema: { type: 'string', format: 'uuid' } },
{ in: 'query', name: 'deadlineFrom', schema: { type: 'string', format: 'date-time' } },
{ in: 'query', name: 'deadlineTo', schema: { type: 'string', format: 'date-time' } },
{ in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'title', 'deadline', 'priority'], default: 'createdAt' } },
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: { responses: {
200: { 200: {
description: 'Danh sách công việc có phân trang', description: 'Cập nhật thành công',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Task' } }, meta: { $ref: '#/components/schemas/PaginationMeta' } } }] } } }, content: {
}, 'application/json': {
401: { $ref: '#/components/responses/Unauthorized' }, schema: {
403: { $ref: '#/components/responses/Forbidden' }, allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } },
],
}, },
}, },
post: {
tags: ['Tasks'],
summary: 'Tạo công việc mới (Admin / Leader)',
security: [{ BearerAuth: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateTaskBody' } } } },
responses: {
201: {
description: 'Công việc đã được tạo',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Task' } } }] } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
422: { $ref: '#/components/responses/Validation' },
}, },
}, },
},
'/tasks/{id}': {
get: {
tags: ['Tasks'],
summary: 'Chi tiết công việc theo ID',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Thông tin công việc', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Task' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
},
},
put: {
tags: ['Tasks'],
summary: 'Cập nhật công việc (Admin / Leader)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateTaskBody' } } } },
responses: {
200: { description: 'Công việc đã được cập nhật', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Task' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' }, 401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' }, 403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' }, 404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' }, 422: { $ref: '#/components/responses/Validation' },
}, },
}, },
delete: {
tags: ['Tasks'],
summary: 'Xoá mềm công việc (Admin / Leader)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Đã xoá mềm', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { message: { type: 'string' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
},
},
}, },
}, },
}; };
\ No newline at end of file
...@@ -3,7 +3,7 @@ import { Request, Response, NextFunction } from 'express'; ...@@ -3,7 +3,7 @@ import { Request, Response, NextFunction } from 'express';
const requestCounts = new Map<string, { count: number; resetAt: number }>(); const requestCounts = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 15 * 60 * 1000; const WINDOW_MS = 15 * 60 * 1000;
const MAX_REQUESTS = 100; const MAX_REQUESTS = 1000;
export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void { export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void {
const ip = req.ip || req.socket.remoteAddress || 'unknown'; 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 { Request, Response, NextFunction } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginDto } from './auth.dto';
export class AuthController { export class AuthController {
private readonly service = new AuthService(); private readonly service = new AuthService();
login = async (req: Request, res: Response, next: NextFunction) => { login = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { email, password } = req.body; const body = req.body as LoginDto;
const userAgent = req.headers['user-agent']; const userAgent = req.headers['user-agent'];
const ipAddress = req.ip; 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({ res.json({
success: true, success: true,
......
...@@ -16,3 +16,14 @@ export interface MeDto { ...@@ -16,3 +16,14 @@ export interface MeDto {
isActive: boolean; isActive: boolean;
createdAt: Date; 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'; ...@@ -4,11 +4,13 @@ import { AuthRepository } from './auth.repository';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { jwtConfig } from '../../config/jwt.config'; import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, LoginResponseDto, AuthTokensDto, MeDto } from './auth.dto';
export class AuthService { export class AuthService {
private readonly repository = new AuthRepository(); 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); const user = await this.repository.findByEmail(email);
if (!user) { if (!user) {
...@@ -51,7 +53,7 @@ export class AuthService { ...@@ -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; let payload: any;
try { try {
payload = jwt.verify(token, jwtConfig.refreshSecret); payload = jwt.verify(token, jwtConfig.refreshSecret);
...@@ -104,7 +106,7 @@ export class AuthService { ...@@ -104,7 +106,7 @@ export class AuthService {
} }
} }
async getMe(userId: string) { async getMe(userId: string): Promise<MeDto> {
const user = await this.repository.findById(userId); const user = await this.repository.findById(userId);
if (!user || !user.isActive) { 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 { Request, Response, NextFunction } from 'express';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { UserQueryDto } from './user.dto'; import { UserQueryDto, CreateUserDto, UpdateUserDto } from './user.dto';
export class UserController { export class UserController {
private readonly service = new UserService(); private readonly service = new UserService();
...@@ -31,7 +31,8 @@ export class UserController { ...@@ -31,7 +31,8 @@ export class UserController {
create = async (req: Request, res: Response, next: NextFunction) => { create = async (req: Request, res: Response, next: NextFunction) => {
try { 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({ res.status(201).json({
success: true, success: true,
...@@ -44,7 +45,8 @@ export class UserController { ...@@ -44,7 +45,8 @@ export class UserController {
update = async (req: Request, res: Response, next: NextFunction) => { update = async (req: Request, res: Response, next: NextFunction) => {
try { 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({ res.json({
success: true, success: true,
......
...@@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs'; ...@@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs';
import { UserRepository } from './user.repository'; import { UserRepository } from './user.repository';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { UserQueryDto } from './user.dto'; import { UserQueryDto, CreateUserDto, UpdateUserDto } from './user.dto';
export class UserService { export class UserService {
private readonly repository = new UserRepository(); private readonly repository = new UserRepository();
...@@ -21,7 +21,7 @@ export class UserService { ...@@ -21,7 +21,7 @@ export class UserService {
return user; return user;
} }
async create(data: { email: string; password: string; roleId: string }) { async create(data: CreateUserDto) {
const existing = await this.repository.findByEmail(data.email); const existing = await this.repository.findByEmail(data.email);
if (existing) { if (existing) {
...@@ -37,7 +37,7 @@ export class UserService { ...@@ -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); await this.findById(id);
return this.repository.update(id, { return this.repository.update(id, {
......
import { Router } from 'express'; import { Router } from 'express';
import authRoute from '../modules/auth/auth.route'; import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.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(); const router = Router();
...@@ -13,8 +10,5 @@ router.get('/health', (req, res) => { ...@@ -13,8 +10,5 @@ router.get('/health', (req, res) => {
router.use('/auth', authRoute); router.use('/auth', authRoute);
router.use('/users', userRoute); router.use('/users', userRoute);
router.use('/applications', applicationRoute);
router.use('/interns', internRoute);
router.use('/tasks', taskRoute);
export default router; 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