Commit 8e152a7a authored by ThinhNC's avatar ThinhNC

feat: CURD Intern

parent 54175469
-- 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;
......@@ -38,8 +38,8 @@ model User {
// Relations
approvedApplications Application[] @relation("ApprovedApplications")
internProfile InternProfile? @relation("InternUser")
leadedInterns InternProfile[] @relation("LeaderInterns")
intern Intern? @relation("InternUser")
leadedInterns Intern[] @relation("LeaderInterns")
notificationSetting NotificationSetting?
createdTasks Task[] @relation("CreatedTasks")
assignedTasks TaskAssignment[] @relation("AssignedTasks")
......@@ -81,14 +81,14 @@ model Application {
@@map("applications")
}
// 4. InternProfile
// 4. Intern
enum InternStatus {
ACTIVE
COMPLETED
DROPPED
}
model InternProfile {
model Intern {
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
leaderId String? @map("leader_id") @db.Uuid
......@@ -101,6 +101,7 @@ model InternProfile {
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")
updatedAt DateTime @updatedAt @map("updated_at")
......@@ -113,7 +114,7 @@ model InternProfile {
weeklyEvaluations WeeklyEvaluation[]
@@index([leaderId])
@@map("intern_profiles")
@@map("interns")
}
// 5. NotificationSetting
......@@ -173,7 +174,7 @@ model TaskAssignment {
updatedAt DateTime @updatedAt @map("updated_at")
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
intern InternProfile @relation(fields: [internId], 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[]
......@@ -221,7 +222,7 @@ model DailyReport {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
intern InternProfile @relation(fields: [internId], references: [id], onDelete: Cascade)
intern Intern @relation(fields: [internId], references: [id], onDelete: Cascade)
@@index([internId])
@@map("daily_reports")
......@@ -242,7 +243,7 @@ model WeeklyEvaluation {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
intern InternProfile @relation(fields: [internId], references: [id], onDelete: Cascade)
intern Intern @relation(fields: [internId], references: [id], onDelete: Cascade)
leader User @relation("LeaderEvaluations", fields: [leaderId], references: [id], onDelete: Cascade)
@@unique([internId, week])
......
......@@ -4,7 +4,6 @@ import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// 1. Seed Roles
const roles = ['ADMIN', 'LEADER', 'INTERN'];
const roleMap: Record<string, string> = {};
......@@ -18,26 +17,66 @@ async function main() {
console.log(`Role ${roleName} upserted with ID ${role.id}`);
}
// 2. Seed default admin User
const adminPassword = await bcrypt.hash('Admin@123456', 10);
const adminEmail = 'admin@nexcampus.local';
const adminPassword = await bcrypt.hash('Admin@123456', 10);
const leaderEmail = 'leader@nexcampus.local';
const leaderPassword = await bcrypt.hash('Leader@123456', 10);
const internEmail = 'intern@nexcampus.local';
const internPassword = await bcrypt.hash('Intern@123456', 10);
const adminUser = await prisma.user.upsert({
await prisma.user.upsert({
where: { email: adminEmail },
update: {
password: adminPassword,
fullName: 'Admin',
roleId: roleMap['ADMIN'],
isActive: true,
},
create: {
email: adminEmail,
password: adminPassword,
fullName: 'Admin',
roleId: roleMap['ADMIN'],
isActive: true,
},
});
console.log(`Default admin user seeded: ${adminUser.email}`);
await prisma.user.upsert({
where: { email: leaderEmail },
update: {
password: leaderPassword,
fullName: 'Leader',
roleId: roleMap['LEADER'],
isActive: true,
},
create: {
email: leaderEmail,
password: leaderPassword,
fullName: 'Leader',
roleId: roleMap['LEADER'],
isActive: true,
},
});
await prisma.user.upsert({
where: { email: internEmail },
update: {
password: internPassword,
fullName: 'Intern',
roleId: roleMap['INTERN'],
isActive: true,
},
create: {
email: internEmail,
password: internPassword,
fullName: 'Intern',
roleId: roleMap['INTERN'],
isActive: true,
},
});
console.log('Seed completed successfully');
}
......
This diff is collapsed.
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();
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(),
});
......@@ -2,6 +2,7 @@ import { Router } from 'express';
import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.route';
import applicationRoute from '../modules/applications/application.route';
import internRoute from '../modules/interns/intern.route';
const router = Router();
......@@ -12,5 +13,6 @@ router.get('/health', (req, res) => {
router.use('/auth', authRoute);
router.use('/users', userRoute);
router.use('/applications', applicationRoute);
router.use('/interns', internRoute);
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