forked from UKSOURCE/cms.hailearning.edu.vn
Compare commits
4 Commits
055ee69a71
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 43bfc117bf | |||
| 50332f2548 | |||
| fb64059193 | |||
| e3f9d7914f |
BIN
.env.example
BIN
.env.example
Binary file not shown.
343
README.md
343
README.md
@@ -1,185 +1,224 @@
|
|||||||
# Content Management System - GGCamp
|
# ULDP Degree Management System
|
||||||
|
|
||||||
## Mô Tả Dự Án
|
Backend quản lý văn bằng và chứng chỉ cho ULDP, cung cấp admin panel và public API để xác minh văn bằng.
|
||||||
|
|
||||||
Hệ thống quản lý nội dung (CMS) cho Swiss International Management School (SIMS), cung cấp giải pháp quản lý và hiển thị thông tin học viện một cách chuyên nghiệp và hiệu quả.
|
---
|
||||||
|
|
||||||
## Yêu Cầu Hệ Thống
|
## Yêu cầu hệ thống
|
||||||
|
|
||||||
- Node.js (v16 hoặc cao hơn)
|
- Node.js v16+
|
||||||
- MongoDB (v4.4 hoặc cao hơn)
|
- MongoDB v4.4+
|
||||||
- npm (v8 hoặc cao hơn)
|
- npm v8+
|
||||||
|
|
||||||
## Cài Đặt Ban Đầu
|
---
|
||||||
|
|
||||||
### 1. Clone Dự Án
|
## Cài đặt
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/sims-cms.git
|
# 1. Clone
|
||||||
cd sims-cms
|
git clone <repo-url>
|
||||||
```
|
cd uldp-degree-mangement-system
|
||||||
|
|
||||||
### 2. Cài Đặt Dependencies
|
# 2. Cài dependencies
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
# 3. Tạo file .env từ template
|
||||||
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Cấu Hình Môi Trường
|
Chỉnh sửa `.env`:
|
||||||
|
|
||||||
Tạo file `.env` trong thư mục gốc với các cấu hình sau:
|
```env
|
||||||
|
PORT=3001
|
||||||
|
HOST=0.0.0.0
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/uldp
|
||||||
|
SESSION_SECRET=your-session-secret
|
||||||
|
|
||||||
```
|
# API key cho public verification endpoints
|
||||||
PORT=3000
|
API_KEY=your-api-key-here
|
||||||
MONGODB_URI=mongodb://localhost:27017/SIMS
|
|
||||||
BACKEND_URL=http://localhost:3000
|
# Secret để ký URL tài liệu (HMAC-SHA256)
|
||||||
FRONTEND_URL=http://localhost:8080
|
FILE_SIGN_SECRET=your-file-sign-secret
|
||||||
SESSION_SECRET=your_secret_key
|
|
||||||
|
FRONTEND_URL=https://your-frontend-domain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## Hệ Thống Migration
|
---
|
||||||
|
|
||||||
Hệ thống migration sử dụng cơ chế tracking để quản lý các migration đã chạy, tương tự Laravel. Tất cả các file migration được tự động phát hiện trong thư mục `scripts/` (trừ các file quản lý migration).
|
## Chạy ứng dụng
|
||||||
|
|
||||||
### Các Lệnh Migration
|
|
||||||
|
|
||||||
#### 1. Chạy Migrations Chưa Chạy
|
|
||||||
|
|
||||||
Chạy tất cả các migrations chưa được thực thi (có tracking, tự động skip migrations đã chạy):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tính năng:**
|
|
||||||
|
|
||||||
- Tự động phát hiện tất cả file migration trong thư mục `scripts/`
|
|
||||||
- Chỉ chạy các migrations chưa được thực thi
|
|
||||||
- Tự động skip migrations đã chạy
|
|
||||||
- Tracking migrations trong database với batch number
|
|
||||||
- Hiển thị bảng kết quả với trạng thái: DONE, SKIPPED, FAIL
|
|
||||||
|
|
||||||
#### 2. Chạy Lại Tất Cả Migrations (Fresh)
|
|
||||||
|
|
||||||
Xóa tất cả tracking và chạy lại tất cả migrations từ đầu:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run migrate-fresh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lưu ý:** Lệnh này sẽ xóa tất cả records trong collection `migrations` và chạy lại tất cả migrations từ đầu. Sử dụng cẩn thận trong môi trường production.
|
|
||||||
|
|
||||||
#### 3. Xem Trạng Thái Migrations
|
|
||||||
|
|
||||||
Kiểm tra trạng thái của tất cả migrations:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run migrate-status
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hiển thị:**
|
|
||||||
|
|
||||||
- Danh sách tất cả migrations
|
|
||||||
- Trạng thái: Ran hoặc Pending
|
|
||||||
- Batch number và thời gian chạy (nếu đã chạy)
|
|
||||||
- Tóm tắt: số lượng migrations đã chạy và chưa chạy
|
|
||||||
|
|
||||||
#### 4. Rollback Migrations
|
|
||||||
|
|
||||||
**Rollback một migration cụ thể:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run migrate-rollback <migration-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rollback batch cuối cùng:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run migrate-rollback --batch
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ví dụ:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run migrate-rollback 2025_12_02_114125_home
|
|
||||||
npm run migrate-rollback --batch
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Tạo Migration Mới
|
|
||||||
|
|
||||||
Tạo file migration mới với format timestamp tự động:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run make-migration <migration-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ví dụ:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run make-migration create_users_table
|
|
||||||
```
|
|
||||||
|
|
||||||
File migration sẽ được tạo với format: `YYYY_MM_DD_HHMMSS_<migration-name>.js`
|
|
||||||
|
|
||||||
**Template migration:**
|
|
||||||
|
|
||||||
- Tự động kết nối database
|
|
||||||
- Có sẵn cấu trúc cơ bản
|
|
||||||
- Tự động disconnect sau khi hoàn thành
|
|
||||||
|
|
||||||
### Quy Tắc Migration
|
|
||||||
|
|
||||||
1. **Format tên file:** `YYYY_MM_DD_HHMMSS_migration_name.js`
|
|
||||||
2. **Tự động phát hiện:** Tất cả file `.js` trong thư mục `scripts/` (trừ các file quản lý)
|
|
||||||
3. **Tracking:** Migrations được track trong collection `migrations` của MongoDB
|
|
||||||
4. **Batch system:** Migrations được nhóm theo batch để dễ quản lý
|
|
||||||
5. **Thứ tự chạy:** Migrations được sắp xếp theo tên (alphabetical) để đảm bảo thứ tự nhất quán
|
|
||||||
|
|
||||||
## Chạy Ứng Dụng
|
|
||||||
|
|
||||||
### Chế Độ Phát Triển
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Development (nodemon)
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
|
||||||
|
|
||||||
Ứng dụng sẽ chạy trên `http://localhost:3000`
|
# Production
|
||||||
|
|
||||||
### Chế Độ Sản Xuất
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cấu Trúc Thư Mục
|
Server chạy tại `http://localhost:3001`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cấu trúc thư mục
|
||||||
|
|
||||||
```
|
```
|
||||||
sims-cms/
|
uldp-degree-mangement-system/
|
||||||
│
|
├── config/ # Kết nối database
|
||||||
├── config/ # Cấu hình ứng dụng
|
├── constants/ # Audit action enums
|
||||||
├── controllers/ # Logic xử lý
|
├── controllers/ # Logic xử lý
|
||||||
├── data/ # Dữ liệu JSON gốc
|
│ ├── qualificationController.js
|
||||||
├── middleware/ # Các middleware
|
│ ├── certificateController.js
|
||||||
├── models/ # Mongoose models
|
│ ├── departmentController.js
|
||||||
├── public/ # Tài nguyên tĩnh
|
│ ├── levelController.js
|
||||||
├── routes/ # Định nghĩa routes
|
│ ├── dashboardController.js
|
||||||
├── scripts/ # Migration scripts
|
│ └── auditLogController.js
|
||||||
├── utils/ # Các hàm tiện ích
|
├── middleware/
|
||||||
└── views/ # Giao diện EJS
|
│ ├── apiKey.js # Xác thực API key (header X-API-Key)
|
||||||
|
│ ├── auth.js # Session auth cho admin
|
||||||
|
│ └── upload.js # Multer upload config
|
||||||
|
├── models/
|
||||||
|
│ ├── qualification.js
|
||||||
|
│ ├── certificate.js
|
||||||
|
│ ├── department.js
|
||||||
|
│ ├── level.js
|
||||||
|
│ └── auditLog.js
|
||||||
|
├── private/uploads/ # File tài liệu (không public)
|
||||||
|
├── public/ # Static assets
|
||||||
|
├── routes/
|
||||||
|
│ ├── admin.js # Admin panel routes
|
||||||
|
│ ├── auth.js # Login/logout
|
||||||
|
│ └── index.js # Public API routes
|
||||||
|
├── utils/
|
||||||
|
│ └── signedUrl.js # Tạo và xác minh signed URLs
|
||||||
|
└── views/ # EJS templates
|
||||||
```
|
```
|
||||||
|
|
||||||
## Các Lệnh Quan Trọng
|
---
|
||||||
|
|
||||||
### Ứng Dụng
|
## Admin Panel
|
||||||
|
|
||||||
- `npm run dev`: Chạy ứng dụng ở chế độ phát triển (với nodemon)
|
Truy cập: `http://localhost:3001/admin/dashboard`
|
||||||
- `npm start`: Chạy ứng dụng ở chế độ sản xuất
|
|
||||||
|
|
||||||
### Migration
|
| Trang | URL |
|
||||||
|
|---|---|
|
||||||
|
| Dashboard | `/admin/dashboard` |
|
||||||
|
| Qualifications | `/admin/qualification` |
|
||||||
|
| Certificates | `/admin/certificate` |
|
||||||
|
| Departments | `/admin/department` |
|
||||||
|
| Levels | `/admin/level` |
|
||||||
|
| Audit Logs | `/admin/audit-logs` |
|
||||||
|
|
||||||
- `npm run migrate`: Chạy tất cả migrations chưa chạy
|
---
|
||||||
- `npm run migrate-fresh`: Chạy lại tất cả migrations từ đầu
|
|
||||||
- `npm run migrate-status`: Xem trạng thái tất cả migrations
|
## Public API
|
||||||
- `npm run migrate-rollback <name>`: Rollback một migration cụ thể
|
|
||||||
- `npm run migrate-rollback --batch`: Rollback batch cuối cùng
|
### Authentication
|
||||||
- `npm run make-migration <name>`: Tạo migration file mới
|
|
||||||
|
API key gửi qua header (bắt buộc):
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Fallback: `?api_key=your-api-key` (query param, không khuyến khích)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verify Degree (Qualification)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/verify-degree/{qualification_number}
|
||||||
|
X-API-Key: your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"full_name": "Nguyen Van A",
|
||||||
|
"program_name": "Doctor of Philosophy in Business Management",
|
||||||
|
"degree_id": "PHD-2024-001",
|
||||||
|
"passport_number": "P12345678",
|
||||||
|
"address": "123 Main Street",
|
||||||
|
"degree_image": ["https://host/secure-files/doc.jpg?token=xxx&expires=1712345678"],
|
||||||
|
"topic_name": "Research Topic Title",
|
||||||
|
"topic_short_desc": "Abstract..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `topic_name` có mặt → frontend hiển thị PhD view. Vắng mặt → MBA/Master view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verify Certificate
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/verify-certificate/{certification_number}
|
||||||
|
X-API-Key: your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"full_name": "Tran Thi B",
|
||||||
|
"certification_title": "Master of Business Administration",
|
||||||
|
"certificate_id": "MBA-2024-001",
|
||||||
|
"passport_number": "P98765432",
|
||||||
|
"address": "456 Lake Road",
|
||||||
|
"certificate_image": ["https://host/secure-files/cert.jpg?token=xxx&expires=1712345678"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTTP Status Codes
|
||||||
|
|
||||||
|
| Status | Ý nghĩa |
|
||||||
|
|---|---|
|
||||||
|
| `200` | Tìm thấy — trả về dữ liệu |
|
||||||
|
| `401` | API key không hợp lệ |
|
||||||
|
| `404` | Không tìm thấy hoặc đã bị thu hồi |
|
||||||
|
| `500` | Lỗi server |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Document URLs (Signed)
|
||||||
|
|
||||||
|
File tài liệu được serve qua signed URL, hết hạn sau **15 phút**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /secure-files/{filename}?token={hmac_token}&expires={unix_timestamp}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Không có API key trong URL
|
||||||
|
- Token được ký bằng `HMAC-SHA256(FILE_SIGN_SECRET, filename:expires)`
|
||||||
|
- Mỗi lần gọi verify API → nhận URL mới với token mới
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phân biệt Qualification vs Certificate
|
||||||
|
|
||||||
|
| | Qualification | Certificate |
|
||||||
|
|---|---|---|
|
||||||
|
| Lookup bằng | `qualification_number` | `certification_number` |
|
||||||
|
| Endpoint | `/api/verify-degree/:id` | `/api/verify-certificate/:id` |
|
||||||
|
| Field tên | `program_name` | `certification_title` |
|
||||||
|
| Field ID | `degree_id` | `certificate_id` |
|
||||||
|
| Field ảnh | `degree_image` | `certificate_image` |
|
||||||
|
| PhD fields | `topic_name`, `topic_short_desc` | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bảng màu (Design System)
|
||||||
|
|
||||||
|
| Token | Hex | Dùng cho |
|
||||||
|
|---|---|---|
|
||||||
|
| Primary | `#0a2347` | Buttons, sidebar, headings |
|
||||||
|
| Primary Light | `#1a3a6b` | Hover, gradients |
|
||||||
|
| Accent | `#bc9f69` | Active border, highlights |
|
||||||
|
| Success | `#1a7a4a` | Active status |
|
||||||
|
| Danger | `#c0392b` | Revoked, delete |
|
||||||
|
| Warning | `#d97706` | Warning states |
|
||||||
|
| Info | `#0e7490` | Certificate badges |
|
||||||
|
| Background | `#f0f2f7` | Page background |
|
||||||
|
| Text Main | `#1e293b` | Body text |
|
||||||
|
| Text Muted | `#64748b` | Secondary text |
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ exports.createForm = async (req, res) => {
|
|||||||
exports.create = async (req, res) => {
|
exports.create = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = { ...req.body };
|
const data = { ...req.body };
|
||||||
const imgPath = req.files?.certificate_image?.[0]?.path;
|
const imgFiles = req.files?.certificate_image;
|
||||||
if (imgPath) data.certificate_image = normalizePath(imgPath);
|
if (imgFiles?.length) data.certificate_image = imgFiles.map(f => normalizePath(f.path));
|
||||||
|
|
||||||
const cert = new Certificate(data);
|
const cert = new Certificate(data);
|
||||||
await cert.save();
|
await cert.save();
|
||||||
@@ -104,8 +104,8 @@ exports.update = async (req, res) => {
|
|||||||
'issued_date','status','passport_number','address'];
|
'issued_date','status','passport_number','address'];
|
||||||
fields.forEach(f => { if (req.body[f] !== undefined) cert[f] = req.body[f]; });
|
fields.forEach(f => { if (req.body[f] !== undefined) cert[f] = req.body[f]; });
|
||||||
|
|
||||||
const imgPath = req.files?.certificate_image?.[0]?.path;
|
const imgFiles = req.files?.certificate_image;
|
||||||
if (imgPath) cert.certificate_image = normalizePath(imgPath);
|
if (imgFiles?.length) cert.certificate_image = imgFiles.map(f => normalizePath(f.path));
|
||||||
|
|
||||||
await cert.save();
|
await cert.save();
|
||||||
await writeAuditLog({ model: 'Certificate', documentId: cert._id, action: AUDIT_ACTIONS.UPDATE_CERTIFICATE, before, after: cert.toObject(), req });
|
await writeAuditLog({ model: 'Certificate', documentId: cert._id, action: AUDIT_ACTIONS.UPDATE_CERTIFICATE, before, after: cert.toObject(), req });
|
||||||
@@ -141,8 +141,11 @@ exports.apiVerify = async (req, res) => {
|
|||||||
if (!cert) return res.status(404).json({ error: 'Certificate not found' });
|
if (!cert) return res.status(404).json({ error: 'Certificate not found' });
|
||||||
if (cert.status === 'revoked') return res.status(404).json({ error: 'Certificate has been revoked' });
|
if (cert.status === 'revoked') return res.status(404).json({ error: 'Certificate has been revoked' });
|
||||||
|
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get('host')}`;
|
||||||
const buildUrl = (f) => f ? [generateSignedUrl(baseUrl, path.basename(f))] : undefined;
|
const buildUrl = (files) => {
|
||||||
|
if (!files?.length) return undefined;
|
||||||
|
return files.map(f => generateSignedUrl(baseUrl, path.basename(f)));
|
||||||
|
};
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
full_name: cert.student_name,
|
full_name: cert.student_name,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ exports.destroy = async (req, res) => {
|
|||||||
|
|
||||||
function buildSecureUrl(req, filename) {
|
function buildSecureUrl(req, filename) {
|
||||||
if (!filename) return undefined;
|
if (!filename) return undefined;
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get('host')}`;
|
||||||
const name = path.basename(filename);
|
const name = path.basename(filename);
|
||||||
return `${baseUrl}/secure-files/${name}?api_key=${req.query.api_key}`;
|
return `${baseUrl}/secure-files/${name}?api_key=${req.query.api_key}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ exports.createForm = async (req, res) => {
|
|||||||
exports.create = async (req, res) => {
|
exports.create = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = { ...req.body };
|
const data = { ...req.body };
|
||||||
const imgPath = req.files?.degree_image?.[0]?.path;
|
const imgFiles = req.files?.degree_image;
|
||||||
if (imgPath) data.degree_image = normalizePath(imgPath);
|
if (imgFiles?.length) data.degree_image = imgFiles.map(f => normalizePath(f.path));
|
||||||
|
|
||||||
const qual = new Qualification(data);
|
const qual = new Qualification(data);
|
||||||
await qual.save();
|
await qual.save();
|
||||||
@@ -104,8 +104,8 @@ exports.update = async (req, res) => {
|
|||||||
'issued_date','status','passport_number','address','topic_name','topic_short_desc'];
|
'issued_date','status','passport_number','address','topic_name','topic_short_desc'];
|
||||||
fields.forEach(f => { if (req.body[f] !== undefined) qual[f] = req.body[f]; });
|
fields.forEach(f => { if (req.body[f] !== undefined) qual[f] = req.body[f]; });
|
||||||
|
|
||||||
const imgPath = req.files?.degree_image?.[0]?.path;
|
const imgFiles = req.files?.degree_image;
|
||||||
if (imgPath) qual.degree_image = normalizePath(imgPath);
|
if (imgFiles?.length) qual.degree_image = imgFiles.map(f => normalizePath(f.path));
|
||||||
|
|
||||||
await qual.save();
|
await qual.save();
|
||||||
await writeAuditLog({ model: 'Qualification', documentId: qual._id, action: AUDIT_ACTIONS.UPDATE_QUALIFICATION, before, after: qual.toObject(), req });
|
await writeAuditLog({ model: 'Qualification', documentId: qual._id, action: AUDIT_ACTIONS.UPDATE_QUALIFICATION, before, after: qual.toObject(), req });
|
||||||
@@ -141,8 +141,11 @@ exports.apiVerify = async (req, res) => {
|
|||||||
if (!qual) return res.status(404).json({ error: 'Degree not found' });
|
if (!qual) return res.status(404).json({ error: 'Degree not found' });
|
||||||
if (qual.status === 'revoked') return res.status(404).json({ error: 'Degree has been revoked' });
|
if (qual.status === 'revoked') return res.status(404).json({ error: 'Degree has been revoked' });
|
||||||
|
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get('host')}`;
|
||||||
const buildUrl = (f) => f ? [generateSignedUrl(baseUrl, path.basename(f))] : undefined;
|
const buildUrl = (files) => {
|
||||||
|
if (!files?.length) return undefined;
|
||||||
|
return files.map(f => generateSignedUrl(baseUrl, path.basename(f)));
|
||||||
|
};
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
full_name: qual.student_name,
|
full_name: qual.student_name,
|
||||||
|
|||||||
@@ -170,24 +170,24 @@ const degreeStorage = multer.diskStorage({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lọc file chỉ cho phép ảnh degree
|
// Lọc file cho phép ảnh và PDF
|
||||||
const degreeFileFilter = (req, file, cb) => {
|
const degreeFileFilter = (req, file, cb) => {
|
||||||
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
|
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||||
if (allowedMimes.includes(file.mimetype)) {
|
if (allowedMimes.includes(file.mimetype)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Only image/jpeg, image/png, image/webp files are allowed!'));
|
cb(new Error('Only image/jpeg, image/png, image/webp, application/pdf files are allowed!'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cấu hình upload degree
|
// Cấu hình upload degree — nhiều file, hỗ trợ PDF
|
||||||
const uploadDegree = multer({
|
const uploadDegree = multer({
|
||||||
storage: degreeStorage,
|
storage: degreeStorage,
|
||||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB per file
|
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB per file
|
||||||
fileFilter: degreeFileFilter
|
fileFilter: degreeFileFilter
|
||||||
}).fields([
|
}).fields([
|
||||||
{ name: 'degree_image', maxCount: 1 },
|
{ name: 'degree_image', maxCount: 10 },
|
||||||
{ name: 'certificate_image', maxCount: 1 }
|
{ name: 'certificate_image', maxCount: 10 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ const certificateSchema = new mongoose.Schema({
|
|||||||
// Optional personal info
|
// Optional personal info
|
||||||
passport_number: { type: String, trim: true },
|
passport_number: { type: String, trim: true },
|
||||||
address: { type: String, trim: true },
|
address: { type: String, trim: true },
|
||||||
// Document image
|
// Document images (array of filenames)
|
||||||
certificate_image: { type: String }
|
certificate_image: { type: [String], default: [] }
|
||||||
}, { timestamps: true });
|
}, { timestamps: true });
|
||||||
|
|
||||||
module.exports = mongoose.model('Certificate', certificateSchema);
|
module.exports = mongoose.model('Certificate', certificateSchema);
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ const qualificationSchema = new mongoose.Schema({
|
|||||||
// PhD fields — presence of topic_name signals PhD view on frontend
|
// PhD fields — presence of topic_name signals PhD view on frontend
|
||||||
topic_name: { type: String, trim: true },
|
topic_name: { type: String, trim: true },
|
||||||
topic_short_desc: { type: String, trim: true },
|
topic_short_desc: { type: String, trim: true },
|
||||||
// Document image
|
// Document images (array of filenames)
|
||||||
degree_image: { type: String }
|
degree_image: { type: [String], default: [] }
|
||||||
}, { timestamps: true });
|
}, { timestamps: true });
|
||||||
|
|
||||||
module.exports = mongoose.model('Qualification', qualificationSchema);
|
module.exports = mongoose.model('Qualification', qualificationSchema);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
function addBaseUrlToImages(data, baseUrl) {
|
function addBaseUrlToImages(data, baseUrl) {
|
||||||
// Use passed baseUrl, then env var, then default to localhost:3001
|
// Use passed baseUrl, then env var, then default to localhost:3001
|
||||||
const BACKEND_URL = (baseUrl || process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "");
|
const BACKEND_URL = (baseUrl ?? process.env.BACKEND_URL ?? "http://localhost:3001").replace(/\/$/, "");
|
||||||
|
|
||||||
// Tạo bản sao sâu để tránh thay đổi dữ liệu gốc
|
// Tạo bản sao sâu để tránh thay đổi dữ liệu gốc
|
||||||
const processedData = JSON.parse(JSON.stringify(data));
|
const processedData = JSON.parse(JSON.stringify(data));
|
||||||
|
|||||||
@@ -91,9 +91,10 @@
|
|||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card border-0 mb-3">
|
<div class="card border-0 mb-3">
|
||||||
<div class="card-header"><h5 class="card-header-title"><i class="fas fa-image"></i> Certificate Image</h5></div>
|
<div class="card-header"><h5 class="card-header-title"><i class="fas fa-file-upload"></i> Certificate Documents</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="file" class="form-control" name="certificate_image" accept="image/*">
|
<input type="file" class="form-control" name="certificate_image" accept="image/*,application/pdf" multiple>
|
||||||
|
<div class="form-text">Accepted: JPG, PNG, WEBP, PDF — max 20MB each. You can select multiple files.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card border-0">
|
<div class="card border-0">
|
||||||
|
|||||||
@@ -85,15 +85,25 @@
|
|||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card border-0 mb-3">
|
<div class="card border-0 mb-3">
|
||||||
<div class="card-header"><h5 class="card-header-title"><i class="fas fa-image"></i> Certificate Image</h5></div>
|
<div class="card-header"><h5 class="card-header-title"><i class="fas fa-file-upload"></i> Certificate Documents</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<% if (cert.certificate_image) { %>
|
<% if (cert.certificate_image && cert.certificate_image.length) { %>
|
||||||
<div class="mb-2">
|
<div class="mb-3 d-flex flex-wrap gap-2">
|
||||||
<img src="/admin/files/<%= cert.certificate_image %>" alt="Certificate image" class="img-thumbnail" style="max-height:110px;border-radius:var(--border-radius-sm);">
|
<% cert.certificate_image.forEach(function(f) { %>
|
||||||
|
<% if (f.match(/\.(pdf)$/i)) { %>
|
||||||
|
<a href="/admin/files/<%= f %>" target="_blank" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="fas fa-file-pdf"></i> <%= f %>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<a href="/admin/files/<%= f %>" target="_blank">
|
||||||
|
<img src="/admin/files/<%= f %>" alt="certificate" class="img-thumbnail" style="max-height:90px;border-radius:var(--border-radius-sm);">
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<% }); %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<input type="file" class="form-control" name="certificate_image" accept="image/*">
|
<input type="file" class="form-control" name="certificate_image" accept="image/*,application/pdf" multiple>
|
||||||
<div class="form-text">Leave empty to keep current image.</div>
|
<div class="form-text">Accepted: JPG, PNG, WEBP, PDF — max 20MB each. Uploading new files will replace existing ones.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card border-0">
|
<div class="card border-0">
|
||||||
|
|||||||
@@ -108,9 +108,10 @@
|
|||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card border-0 mb-3">
|
<div class="card border-0 mb-3">
|
||||||
<div class="card-header"><h5 class="card-header-title"><i class="fas fa-image"></i> Degree Image</h5></div>
|
<div class="card-header"><h5 class="card-header-title"><i class="fas fa-file-upload"></i> Degree Documents</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="file" class="form-control" name="degree_image" accept="image/*">
|
<input type="file" class="form-control" name="degree_image" accept="image/*,application/pdf" multiple>
|
||||||
|
<div class="form-text">Accepted: JPG, PNG, WEBP, PDF — max 20MB each. You can select multiple files.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card border-0">
|
<div class="card border-0">
|
||||||
|
|||||||
@@ -101,15 +101,25 @@
|
|||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card border-0 mb-3">
|
<div class="card border-0 mb-3">
|
||||||
<div class="card-header"><h5 class="card-header-title"><i class="fas fa-image"></i> Degree Image</h5></div>
|
<div class="card-header"><h5 class="card-header-title"><i class="fas fa-file-upload"></i> Degree Documents</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<% if (qual.degree_image) { %>
|
<% if (qual.degree_image && qual.degree_image.length) { %>
|
||||||
<div class="mb-2">
|
<div class="mb-3 d-flex flex-wrap gap-2">
|
||||||
<img src="/admin/files/<%= qual.degree_image %>" alt="Degree image" class="img-thumbnail" style="max-height:110px;border-radius:var(--border-radius-sm);">
|
<% qual.degree_image.forEach(function(f) { %>
|
||||||
|
<% if (f.match(/\.(pdf)$/i)) { %>
|
||||||
|
<a href="/admin/files/<%= f %>" target="_blank" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="fas fa-file-pdf"></i> <%= f %>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<a href="/admin/files/<%= f %>" target="_blank">
|
||||||
|
<img src="/admin/files/<%= f %>" alt="degree" class="img-thumbnail" style="max-height:90px;border-radius:var(--border-radius-sm);">
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<% }); %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<input type="file" class="form-control" name="degree_image" accept="image/*">
|
<input type="file" class="form-control" name="degree_image" accept="image/*,application/pdf" multiple>
|
||||||
<div class="form-text">Leave empty to keep current image.</div>
|
<div class="form-text">Accepted: JPG, PNG, WEBP, PDF — max 20MB each. Uploading new files will replace existing ones.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card border-0">
|
<div class="card border-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user