Commit 4f023fe9 authored by Blockchain-vn's avatar Blockchain-vn

feat/api-term

parent 01fc82c4
# KHOÁN HOCH DUNG TERM API
## I. HIUU V CAU TRC MOI
### 1. Cau tru du lieu truoc khi thay i:
```
Student Collection:
{
"_id": "student123",
"fullName": "Nguyen Van A",
"email": "a@example.com",
"courseId": "course789",
"courseName": "Node.js",
"semester": "2026-1", // String thuan
"startDate": "2026-01-15", // Ngay bat dau hoc ky
"endDate": "2026-05-31", // Ngay ket thuc hoc ky
"status": "active"
}
```
### 2. Cau tru du lieu SAU khi thay i:
```
Student Collection:
{
"_id": "student123",
"fullName": "Nguyen Van A",
"email": "a@example.com",
"courseId": "course789",
"courseName": "Node.js",
"termId": "term456", // Reference n Term collection
"status": "active"
}
Term Collection:
{
"_id": "term456",
"name": "2026-1",
"description": "Hoc ky 1 nam 2026",
"startDate": "2026-01-15", // Chuyen len Term
"endDate": "2026-05-31", // Chuyen len Term
"status": "active",
"courseIds": ["course789"] // Danh sach khoa hoc trong ky
}
```
## II. KHOANH S DUNG
### Bc 1: Quan ly Hoc ky (Admin)
```bash
# 1. Tao hoc ky moi
POST /api/v1.0/terms
{
"name": "2026-2",
"description": "Hoc ky 2 nam 2026",
"startDate": "2026-06-01",
"endDate": "2026-10-31",
"status": "active"
}
# 2. L danh sach hoc ky
GET /api/v1.0/terms
# 3. Cap nhat hoc ky
PUT /api/v1.0/terms/{termId}
{
"status": "completed"
}
```
### Bc 2: ng ky Sinh vien (Admin/Staff)
```bash
# 1. Lay danh sach hoc ky dang active
GET /api/v1.0/terms?status=active
# 2. Chon hoc ky va lay termId
# Response: [{"_id": "term456", "name": "2026-1", ...}]
# 3. Tao sinh vien voi termId
POST /api/v1.0/students
{
"fullName": "Nguyen Van B",
"email": "b@example.com",
"phone": "0912345678",
"courseId": "course789",
"courseName": "Node.js",
"termId": "term456", // <-- QUAN TRNG: Lay tu Bc 1
"status": "active"
}
```
### Bc 3: Xem thng tin Sinh vien kèm Term
```bash
# Lay sinh vien voi populate term
GET /api/v1.0/students/{studentId}
# Response se bao gm thng tin term:
{
"_id": "student123",
"fullName": "Nguyen Van A",
"termId": "term456",
"term": { // <-- Term info du populate
"_id": "term456",
"name": "2026-1",
"startDate": "2026-01-15",
"endDate": "2026-05-31"
}
}
```
## III. WORKFLOW NG K SINH VIEN
### Frontend Workflow:
1. **Form ng ky sinh vien:**
- [ ] Thng tin c bn: Tn, Email, Phone
- [ ] Chn kho hc: Dropdown (Course API)
- [ ] Chn hc ky: Dropdown (Term API)
- [ ] Submit
2. **Backend Workflow:**
```javascript
// 1. Frontend gi form data
{
fullName: "Nguyen Van B",
email: "b@example.com",
phone: "0912345678",
courseId: "course789",
courseName: "Node.js",
termId: "term456" // <-- User chn hc ky
}
// 2. Backend luu student
await studentProvider.create({
fullName, email, phone,
courseId, courseName,
termId, // <-- Luu reference
status: 'active'
});
```
## IV. L ICH GI SAU KHI THAY I
### 1. Cch truy vn truoc i:
```javascript
// Lay sinh vien theo hoc ky
Student.find({ semester: "2026-1" })
// Lay thng tin hc ky
// Phai lu trong memory ho query lai
```
### 2. Cch truy vqn sau i:
```javascript
// Lay sinh vien theo hc ky
Student.find({ termId: "term456" })
// Lay thng tin hc ky
const term = await Term.findById(termId);
// Ho populate trong 1 query
Student.find({ termId: "term456" }).populate('termId');
```
## V. CAC API CN CN NH
### 1. Student API cn cp nht:
```javascript
// Trong StudentProvider.ts
async getAll(opts) {
// Them filter theo termId
if (opts.termId) filter.termId = opts.termId;
// Populate term info khi cn
const query = Student.find(filter).populate('termId');
}
// Trong Student validation
const studentSchema = Joi.object({
// ... cacs field khc
termId: Joi.string().required().messages({
"any.required": "Term ID is required"
}),
// Xoa semester, startDate, endDate
});
```
### 2. Frontend cn thay i:
- Form ng ky: Thm dropdown chn hc ky
- List sinh vien: Hi thng tin hc ky
- Filter: Thm filter theo hc ky
## VI. MIGRATION PROCESS
### 1. Chy migration script:
```bash
npm run build
node scripts/migrate-student-semester-to-term.js
```
### 2. Script se lm:
1. Scan c student records
2. Tao Term cho mi unique semester
3. Cp nh student.termId = term._id
4. Xoa semester, startDate, endDate
### 3. Kt qu:
- Student collection: Ch c termId
- Term collection: C thng tin hc ky
- Dng tin: 100%
## VII. FAQ
### Q: Tm sao khng gi semester string?
A:
- Semester string khng normalize ("2026-1", "2026-HK1", "Fall2026")
- Khng lu thng tin chi tit (ngy bt/u, ktt)
- Khi cn thay i, ph update N student
### Q: Lm sao ly danh sch hc ky?
A:
```bash
GET /api/v1.0/terms?status=active
# Response: danh sch hc ky dang active
```
### Q: Lm sao bt student thuoc hc ky no?
A:
```bash
GET /api/v1.0/students?termId=term456
# Ho populate:
GET /api/v1.0/students/{id} // se co term info
```
## VIII. KET LUN
1. **Term l collection rieng bi:** Qun ly hc ky trung tam
2. **Student ch reference termId:** Lin kt qua termId
3. **Workflow ng ky:** Chn hc ky trc, sau i ng ky sinh vien
4. **Benefit:** Dng tin, d quun ly, d m rng
**VIU C: Khi dng form ng ký sinh viên, bn s có dropdown chn hc ký!**
const mongoose = require('mongoose');
const { Student } = require('../dist/models/Student');
const { Term } = require('../dist/models/Term');
/**
* Migration script to convert student semester strings to term references
* This script will:
* 1. Create Term records from unique semester values in students
* 2. Update student records to reference termId instead of semester string
* 3. Remove semester, startDate, endDate fields from student records
*/
async function migrateStudentSemesterToTerm() {
try {
console.log('Starting migration: Student semester to Term reference...');
// Connect to database
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/trainee-schedule');
console.log('Connected to database');
// Step 1: Get all unique semesters from students
console.log('Step 1: Finding unique semesters...');
const uniqueSemesters = await Student.distinct('semester');
console.log(`Found ${uniqueSemesters.length} unique semesters:`, uniqueSemesters);
// Step 2: Create Term records for each unique semester
console.log('Step 2: Creating Term records...');
const termMap = new Map(); // semester -> termId mapping
for (const semesterName of uniqueSemesters) {
// Extract year and semester number from semester string (e.g., "2026-1")
const match = semesterName.match(/(\d{4})-?(\d+)/);
if (match) {
const [, year, semesterNum] = match;
// Create default dates for the term
const startDate = new Date(`${year}-${semesterNum === '1' ? '01' : semesterNum === '2' ? '06' : '09'}-15`);
const endDate = new Date(`${year}-${semesterNum === '1' ? '05' : semesterNum === '2' ? '10' : '12'}-31`);
// Check if term already exists
let term = await Term.findOne({ name: semesterName });
if (!term) {
term = new Term({
name: semesterName,
description: `Hoc ky ${semesterNum} nam ${year}`,
startDate,
endDate,
status: 'active'
});
await term.save();
console.log(`Created term: ${semesterName} with ID: ${term._id}`);
} else {
console.log(`Term already exists: ${semesterName} with ID: ${term._id}`);
}
termMap.set(semesterName, term._id.toString());
}
}
// Step 3: Update student records
console.log('Step 3: Updating student records...');
let updatedCount = 0;
for (const [semesterName, termId] of termMap) {
const result = await Student.updateMany(
{ semester: semesterName },
{
$set: { termId },
$unset: { semester: 1, startDate: 1, endDate: 1 }
}
);
updatedCount += result.modifiedCount;
console.log(`Updated ${result.modifiedCount} students for semester ${semesterName}`);
}
console.log(`Migration completed! Updated ${updatedCount} student records.`);
console.log('Created/verified terms:', Array.from(termMap.keys()));
} catch (error) {
console.error('Migration failed:', error);
throw error;
} finally {
await mongoose.disconnect();
console.log('Disconnected from database');
}
}
// Run migration if this file is executed directly
if (require.main === module) {
migrateStudentSemesterToTerm()
.then(() => {
console.log('Migration completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
});
}
module.exports = { migrateStudentSemesterToTerm };
...@@ -33,10 +33,10 @@ export default (_express: Application) => { ...@@ -33,10 +33,10 @@ export default (_express: Application) => {
* type: string * type: string
* description: Filter by course ID * description: Filter by course ID
* - in: query * - in: query
* name: semester * name: termId
* schema: * schema:
* type: string * type: string
* description: Filter by semester * description: Filter by term ID
* - in: query * - in: query
* name: search * name: search
* schema: * schema:
......
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { TermProvider, TermFindManyOptions } from "#providers/TermProvider";
import { Req, Res } from "#interfaces/IApi";
import { queryModifier } from "#middlewares/query-modifier";
import { validateTermCreate, validateTermUpdate } from "#middlewares/validators/term";
export default (_express: Application) => {
const termProvider = new TermProvider();
return <Resource>{
/**
* @openapi
* /terms:
* get:
* tags: [Terms]
* summary: Get all terms
* description: Retrieve a list of terms with pagination, filtering and sorting
* parameters:
* - $ref: '#/components/parameters/filters'
* - $ref: '#/components/parameters/sortField'
* - $ref: '#/components/parameters/sortOrder'
* - $ref: '#/components/parameters/page'
* - $ref: '#/components/parameters/pageSize'
* - in: query
* name: status
* schema:
* type: string
* enum: [active, inactive, completed]
* description: Filter by status
* - in: query
* name: year
* schema:
* type: string
* description: Filter by year (e.g., "2026")
* - in: query
* name: search
* schema:
* type: string
* description: Search by name or description
* responses:
* 200:
* description: Successfully retrieved terms
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/responseGetAllData"
*/
get: {
middleware: [queryModifier],
handler: async (req: Req, res: Res) => {
try {
const options: TermFindManyOptions = {
...req.payload,
sortOrder: (req.payload?.sortOrder as 'asc' | 'desc' | undefined) || 'desc'
};
const data = await termProvider.getAll(options);
return res.sendOk({ data });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /terms:
* post:
* tags: [Terms]
* summary: Create a term
* description: Create a new term record
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/TermMutate"
* responses:
* 200:
* description: Term created successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/Term"
*/
post: {
middleware: [queryModifier, validateTermCreate],
handler: async (req: Req, res: Res) => {
try {
const createOptions = req.user?.id ? { createdBy: req.user.id } : {};
const data = await termProvider.create({ ...req.body }, createOptions);
return res.sendOk({ data, message: "Term created successfully" });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
};
};
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { TermProvider } from "#providers/TermProvider";
import { Req, Res } from "#interfaces/IApi";
import { queryModifier } from "#middlewares/query-modifier";
import { validateId } from "#middlewares/validators";
import { validateTermUpdate } from "#middlewares/validators/term";
export default (_express: Application) => {
const termProvider = new TermProvider();
return <Resource>{
/**
* @openapi
* /terms/{id}:
* get:
* tags: [Terms]
* summary: Get term by ID
* description: Retrieve a single term record by its ID
* parameters:
* - name: id
* in: path
* required: true
* description: Term ID
* schema:
* type: string
* responses:
* 200:
* description: Term retrieved successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/Term"
* 404:
* description: Term not found
*/
get: {
middleware: [queryModifier, validateId],
handler: async (req: Req, res: Res) => {
try {
const data = await termProvider.getById(req.params.id!);
return res.sendOk({ data });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /terms/{id}:
* put:
* tags: [Terms]
* summary: Update term by ID
* description: Update a single term record by its ID
* parameters:
* - name: id
* in: path
* required: true
* description: Term ID
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/TermMutate"
* responses:
* 200:
* description: Term updated successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/Term"
* 404:
* description: Term not found
*/
put: {
middleware: [queryModifier, validateId, validateTermUpdate],
handler: async (req: Req, res: Res) => {
try {
const updateOptions = req.user?.id ? { updatedBy: req.user.id } : {};
const data = await termProvider.update(req.params.id!, { ...req.body }, updateOptions);
return res.sendOk({ data, message: "Term updated successfully" });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /terms/{id}:
* delete:
* tags: [Terms]
* summary: Delete term by ID
* description: Delete a single term record by its ID
* parameters:
* - name: id
* in: path
* required: true
* description: Term ID
* schema:
* type: string
* responses:
* 200:
* description: Term deleted successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* type: boolean
* example: true
* 404:
* description: Term not found
*/
delete: {
middleware: [queryModifier, validateId],
handler: async (req: Req, res: Res) => {
try {
const data = await termProvider.delete(req.params.id!);
return res.sendOk({ data, message: "Term deleted successfully" });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
};
};
...@@ -31,25 +31,15 @@ const studentSchema = Joi.object({ ...@@ -31,25 +31,15 @@ const studentSchema = Joi.object({
"any.required": "Course name is required", "any.required": "Course name is required",
}), }),
semester: Joi.string().required().messages({ termId: Joi.string().required().messages({
"string.empty": "Semester is required", "string.empty": "Term ID is required",
"any.required": "Semester is required", "any.required": "Term ID is required",
}), }),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({ status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"any.only": "Status must be active, inactive, or completed", "any.only": "Status must be active, inactive, or completed",
}), }),
startDate: Joi.date().iso().required().messages({
"date.format": "Invalid start date format",
"any.required": "Start date is required",
}),
endDate: Joi.date().iso().required().messages({
"date.format": "Invalid end date format",
"any.required": "End date is required",
}),
scheduleIds: Joi.array().items(Joi.string()).optional().messages({ scheduleIds: Joi.array().items(Joi.string()).optional().messages({
"array.base": "Schedule IDs must be an array", "array.base": "Schedule IDs must be an array",
}), }),
...@@ -76,22 +66,14 @@ const studentUpdateSchema = Joi.object({ ...@@ -76,22 +66,14 @@ const studentUpdateSchema = Joi.object({
"string.max": "Course name cannot exceed 200 characters", "string.max": "Course name cannot exceed 200 characters",
}), }),
semester: Joi.string().optional().messages({ termId: Joi.string().optional().messages({
"string.empty": "Semester cannot be empty", "string.empty": "Term ID cannot be empty",
}), }),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({ status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"any.only": "Status must be active, inactive, or completed", "any.only": "Status must be active, inactive, or completed",
}), }),
startDate: Joi.date().iso().optional().messages({
"date.format": "Invalid start date format",
}),
endDate: Joi.date().iso().optional().messages({
"date.format": "Invalid end date format",
}),
scheduleIds: Joi.array().items(Joi.string()).optional().messages({ scheduleIds: Joi.array().items(Joi.string()).optional().messages({
"array.base": "Schedule IDs must be an array", "array.base": "Schedule IDs must be an array",
}), }),
......
import Joi from "joi";
import { validateJoi } from ".";
const termSchema = Joi.object({
name: Joi.string().required().max(50).messages({
"string.empty": "Term name is required",
"string.max": "Term name cannot exceed 50 characters",
"any.required": "Term name is required",
}),
description: Joi.string().max(500).optional().allow("").messages({
"string.max": "Description cannot exceed 500 characters",
}),
startDate: Joi.date().iso().required().messages({
"date.format": "Invalid start date format",
"any.required": "Start date is required",
}),
endDate: Joi.date().iso().required().messages({
"date.format": "Invalid end date format",
"any.required": "End date is required",
}),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"any.only": "Status must be active, inactive, or completed",
}),
courseIds: Joi.array().items(Joi.string()).optional().messages({
"array.base": "Course IDs must be an array",
}),
}).custom((value, helpers) => {
// Custom validation: endDate must be after startDate
if (value.startDate && value.endDate) {
const start = new Date(value.startDate);
const end = new Date(value.endDate);
if (end <= start) {
return helpers.error('custom.endDateAfterStart');
}
}
return value;
}).messages({
'custom.endDateAfterStart': 'End date must be after start date',
});
const termUpdateSchema = Joi.object({
name: Joi.string().max(50).optional().messages({
"string.max": "Term name cannot exceed 50 characters",
}),
description: Joi.string().max(500).optional().allow("").messages({
"string.max": "Description cannot exceed 500 characters",
}),
startDate: Joi.date().iso().optional().messages({
"date.format": "Invalid start date format",
}),
endDate: Joi.date().iso().optional().messages({
"date.format": "Invalid end date format",
}),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"any.only": "Status must be active, inactive, or completed",
}),
courseIds: Joi.array().items(Joi.string()).optional().messages({
"array.base": "Course IDs must be an array",
}),
}).custom((value, helpers) => {
// Custom validation for update: if both dates are provided, endDate must be after startDate
if (value.startDate && value.endDate) {
const start = new Date(value.startDate);
const end = new Date(value.endDate);
if (end <= start) {
return helpers.error('custom.endDateAfterStart');
}
}
return value;
}).messages({
'custom.endDateAfterStart': 'End date must be after start date',
});
export const validateTermCreate = validateJoi(
Joi.object({
body: termSchema,
params: Joi.object(),
query: Joi.object(),
})
);
export const validateTermUpdate = validateJoi(
Joi.object({
body: termUpdateSchema,
params: Joi.object({
id: Joi.string().required().messages({
"any.required": "Term ID is required",
}),
}),
query: Joi.object(),
})
);
export const validateTermGetById = validateJoi(
Joi.object({
body: Joi.object(),
params: Joi.object({
id: Joi.string().required().messages({
"any.required": "Term ID is required",
}),
}),
query: Joi.object(),
})
);
...@@ -24,10 +24,8 @@ export interface IStudent extends Document { ...@@ -24,10 +24,8 @@ export interface IStudent extends Document {
phone: string; phone: string;
courseId: string; courseId: string;
courseName: string; courseName: string;
semester: string; termId: string;
status: 'active' | 'inactive' | 'completed'; status: 'active' | 'inactive' | 'completed';
startDate: Date;
endDate: Date;
scheduleIds: string[]; scheduleIds: string[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
...@@ -65,10 +63,10 @@ const StudentSchema: Schema = new Schema({ ...@@ -65,10 +63,10 @@ const StudentSchema: Schema = new Schema({
required: [true, 'Týn khßa hýc lý bßt buýc'], required: [true, 'Týn khßa hýc lý bßt buýc'],
trim: true trim: true
}, },
semester: { termId: {
type: String, type: String,
required: [true, 'Ký hýc lý bßt buýc'], required: [true, 'ID ký hýc lý bßt buýc'],
trim: true ref: 'Term'
}, },
status: { status: {
type: String, type: String,
...@@ -76,20 +74,6 @@ const StudentSchema: Schema = new Schema({ ...@@ -76,20 +74,6 @@ const StudentSchema: Schema = new Schema({
default: 'active', default: 'active',
required: true required: true
}, },
startDate: {
type: Date,
required: [true, 'Ngýy bßt áßu lý bßt buýc']
},
endDate: {
type: Date,
required: [true, 'Ngýy kýt thýc lý bßt buýc'],
validate: {
validator: function (this: IStudent, value: Date) {
return value > this.startDate;
},
message: 'Ngýy kýt thýc phßi sau ngýy bßt áßu'
}
},
scheduleIds: [{ scheduleIds: [{
type: String, type: String,
ref: 'Schedule' ref: 'Schedule'
...@@ -110,7 +94,7 @@ const StudentSchema: Schema = new Schema({ ...@@ -110,7 +94,7 @@ const StudentSchema: Schema = new Schema({
// Index for better query performance // Index for better query performance
StudentSchema.index({ email: 1 }); StudentSchema.index({ email: 1 });
StudentSchema.index({ courseId: 1, semester: 1 }); StudentSchema.index({ courseId: 1, termId: 1 });
StudentSchema.index({ status: 1 }); StudentSchema.index({ status: 1 });
StudentSchema.index({ fullName: 'text' }); StudentSchema.index({ fullName: 'text' });
......
import mongoose, { Document, Schema } from 'mongoose';
/**
* @typedef {Object} Term
* @property {string} name - Tên ký hýc (ví dß: "2026-1", "2026-HK1")
* @property {string} description - Mô t ký hýc
* @property {Date} startDate - Ngýy bßt áßu ký hýc
* @property {Date} endDate - Ngýy kýt thýc ký hýc
* @property {string} status - Trýng thýi ký hýc (active, inactive, completed)
* @property {string[]} courseIds - Danh sých ID các khóa hýc trong ký
* @property {Date} createdAt - Ngýy tßo
* @property {Date} updatedAt - Ngýy cßp nhßt
* @property {string} createdBy - Ngýi tßo
* @property {string} updatedBy - Ngýi cßp nhßt
*/
export interface ITerm extends Document {
name: string;
description?: string;
startDate: Date;
endDate: Date;
status: 'active' | 'inactive' | 'completed';
courseIds: string[];
createdAt: Date;
updatedAt: Date;
createdBy?: string;
updatedBy?: string;
}
const TermSchema: Schema = new Schema({
name: {
type: String,
required: [true, 'Tên ký hýc lý bßt buýc'],
trim: true,
unique: true,
maxlength: [50, 'Tên ký hýc khýng áßß výt quß 50 ký tß']
},
description: {
type: String,
trim: true,
maxlength: [500, 'Mô t ký hýc khýng áßß výt quß 500 ký tß']
},
startDate: {
type: Date,
required: [true, 'Ngýy bßt áßu lý bßt buýc']
},
endDate: {
type: Date,
required: [true, 'Ngýy kýt thýc lý bßt buýc'],
validate: {
validator: function (this: ITerm, value: Date) {
return value > this.startDate;
},
message: 'Ngýy kýt thýc phßi sau ngýy bßt áßu'
}
},
status: {
type: String,
enum: ['active', 'inactive', 'completed'],
default: 'active',
required: true
},
courseIds: [{
type: String,
ref: 'Course'
}],
createdBy: {
type: String,
ref: 'User'
},
updatedBy: {
type: String,
ref: 'User'
}
}, {
timestamps: true,
toJSON: { virtuals: false },
toObject: { virtuals: false }
});
// Index for better query performance
TermSchema.index({ name: 1 }, { unique: true });
TermSchema.index({ status: 1 });
TermSchema.index({ startDate: 1, endDate: 1 });
TermSchema.index({ name: 'text' });
export const Term = mongoose.model<ITerm>('Term', TermSchema);
import { Term, ITerm } from "#models/Term";
import LoggingService from "#services/file-system-handlers/logService";
import { MeUError } from "#interfaces/IApi";
export interface TermFindManyOptions {
page?: number;
pageSize?: number;
sortField?: string;
sortOrder?: 'asc' | 'desc';
status?: string;
search?: string;
year?: string;
}
export interface TermFindManyReturnModel {
count: number;
rows: ITerm[];
page?: number;
pageSize?: number;
}
export class TermProvider {
private logger: LoggingService;
constructor() {
this.logger = new LoggingService();
}
async getAll(opts: TermFindManyOptions): Promise<TermFindManyReturnModel> {
try {
const { page, pageSize, sortField, sortOrder, status, search, year, ...baseQuery } = opts;
// Build filter
const filter: any = {};
if (status) filter.status = status;
if (year) {
// Filter by year in term name (e.g., "2026-1", "2026-HK1")
filter.name = { $regex: `^${year}`, $options: 'i' };
}
if (search) {
filter.$or = [
{ name: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
];
}
// Build query
let query = Term.find(filter);
// Add sorting
if (sortField && sortOrder) {
const sort: any = {};
sort[sortField] = sortOrder === 'asc' ? 1 : -1;
query = query.sort(sort);
} else {
query = query.sort({ startDate: -1 });
}
// Add pagination
if (page !== undefined && pageSize !== undefined && pageSize > 0) {
const skip = ((page || 1) - 1) * pageSize;
query = query.skip(skip).limit(pageSize);
}
// Execute query
const [rows, count] = await Promise.all([
query.exec(),
Term.countDocuments(filter)
]);
const result: TermFindManyReturnModel = {
count,
rows,
};
if (page !== undefined) result.page = page;
if (pageSize !== undefined) result.pageSize = pageSize;
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async getById(id: string): Promise<ITerm> {
try {
const result = await Term.findById(id);
if (!result) {
throw new MeUError(404, "DB", `Term with id ${id} not found`);
}
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async getOne(where: any): Promise<ITerm | null> {
try {
return await Term.findOne(where);
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async create(body: Partial<ITerm>, options?: { createdBy?: string | undefined }): Promise<ITerm> {
try {
const termData = {
...body,
createdBy: options?.createdBy
};
const result = new Term(termData);
const saved = await result.save();
await this.logger.logInfoAsync("TermProvider", new Error(`Created term with id ${saved._id}`), null);
return saved;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async update(id: string, body: Partial<ITerm>, options?: { updatedBy?: string }): Promise<ITerm> {
try {
const updateData = {
...body,
updatedBy: options?.updatedBy,
updatedAt: new Date()
};
const result = await Term.findByIdAndUpdate(
id,
updateData,
{ new: true, runValidators: true }
);
if (!result) {
throw new MeUError(404, "DB", `Term with id ${id} not found`);
}
await this.logger.logInfoAsync("TermProvider", new Error(`Updated term with id ${id}`), null);
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async delete(id: string): Promise<string> {
try {
const result = await Term.findByIdAndDelete(id);
if (!result) {
throw new MeUError(404, "DB", `Term with id ${id} not found`);
}
await this.logger.logInfoAsync("TermProvider", new Error(`Deleted term with id ${id}`), null);
return "Successfully deleted term";
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async count(where?: any): Promise<number> {
try {
return await Term.countDocuments(where || {});
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async exists(where: any): Promise<boolean> {
try {
const count = await Term.countDocuments(where);
return count > 0;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async getActiveTerms(): Promise<ITerm[]> {
try {
return await Term.find({ status: 'active' }).sort({ startDate: -1 });
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async getTermsByDateRange(startDate: Date, endDate: Date): Promise<ITerm[]> {
try {
return await Term.find({
$or: [
{ startDate: { $gte: startDate, $lte: endDate } },
{ endDate: { $gte: startDate, $lte: endDate } },
{ startDate: { $lte: startDate }, endDate: { $gte: endDate } }
]
}).sort({ startDate: 1 });
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async logError(err: MeUError | Error) {
await this.logger.logErrorAsync("TermProvider", err, null);
return;
}
}
module.exports = { module.exports = {
Student: { Student: {
type: "object", type: "object",
required: ["fullName", "email", "phone", "courseId", "courseName", "semester", "startDate", "endDate"], required: ["fullName", "email", "phone", "courseId", "courseName", "termId"],
properties: { properties: {
_id: { _id: {
type: "string", type: "string",
...@@ -34,10 +34,10 @@ module.exports = { ...@@ -34,10 +34,10 @@ module.exports = {
description: "Course name", description: "Course name",
example: "Advanced JavaScript" example: "Advanced JavaScript"
}, },
semester: { termId: {
type: "string", type: "string",
description: "Semester", description: "Term ID (reference to Term collection)",
example: "Fall 2024" example: "507f1f77bcf86cd799439013"
}, },
status: { status: {
type: "string", type: "string",
...@@ -45,18 +45,6 @@ module.exports = { ...@@ -45,18 +45,6 @@ module.exports = {
default: "active", default: "active",
description: "Student status" description: "Student status"
}, },
startDate: {
type: "string",
format: "date",
description: "Course start date",
example: "2024-09-01"
},
endDate: {
type: "string",
format: "date",
description: "Course end date",
example: "2024-12-31"
},
scheduleIds: { scheduleIds: {
type: "array", type: "array",
items: { items: {
...@@ -86,7 +74,7 @@ module.exports = { ...@@ -86,7 +74,7 @@ module.exports = {
}, },
StudentMutate: { StudentMutate: {
type: "object", type: "object",
required: ["fullName", "email", "phone", "courseId", "courseName", "semester", "startDate", "endDate"], required: ["fullName", "email", "phone", "courseId", "courseName", "termId"],
properties: { properties: {
fullName: { fullName: {
type: "string", type: "string",
...@@ -114,10 +102,10 @@ module.exports = { ...@@ -114,10 +102,10 @@ module.exports = {
description: "Course name", description: "Course name",
example: "Advanced JavaScript" example: "Advanced JavaScript"
}, },
semester: { termId: {
type: "string", type: "string",
description: "Semester", description: "Term ID (reference to Term collection)",
example: "Fall 2024" example: "507f1f77bcf86cd799439013"
}, },
status: { status: {
type: "string", type: "string",
...@@ -125,18 +113,6 @@ module.exports = { ...@@ -125,18 +113,6 @@ module.exports = {
default: "active", default: "active",
description: "Student status" description: "Student status"
}, },
startDate: {
type: "string",
format: "date",
description: "Course start date",
example: "2024-09-01"
},
endDate: {
type: "string",
format: "date",
description: "Course end date",
example: "2024-12-31"
},
scheduleIds: { scheduleIds: {
type: "array", type: "array",
items: { items: {
......
module.exports = {
Term: {
type: "object",
required: ["name", "startDate", "endDate"],
properties: {
_id: {
type: "string",
description: "Term ID",
example: "507f1f77bcf86cd799439011"
},
name: {
type: "string",
description: "Term name (e.g., '2026-1', '2026-HK1')",
example: "2026-1"
},
description: {
type: "string",
description: "Term description",
example: "Hoc ky 1 nam 2026"
},
startDate: {
type: "string",
format: "date",
description: "Term start date",
example: "2026-01-15"
},
endDate: {
type: "string",
format: "date",
description: "Term end date",
example: "2026-05-31"
},
status: {
type: "string",
enum: ["active", "inactive", "completed"],
default: "active",
description: "Term status"
},
courseIds: {
type: "array",
items: {
type: "string"
},
description: "Array of course IDs in this term"
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp"
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp"
},
createdBy: {
type: "string",
description: "User who created this record"
},
updatedBy: {
type: "string",
description: "User who last updated this record"
}
}
},
TermMutate: {
type: "object",
required: ["name", "startDate", "endDate"],
properties: {
name: {
type: "string",
description: "Term name (e.g., '2026-1', '2026-HK1')",
example: "2026-1"
},
description: {
type: "string",
description: "Term description",
example: "Hoc ky 1 nam 2026"
},
startDate: {
type: "string",
format: "date",
description: "Term start date",
example: "2026-01-15"
},
endDate: {
type: "string",
format: "date",
description: "Term end date",
example: "2026-05-31"
},
status: {
type: "string",
enum: ["active", "inactive", "completed"],
default: "active",
description: "Term status"
},
courseIds: {
type: "array",
items: {
type: "string"
},
description: "Array of course IDs in this term"
}
}
}
};
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