Add Student Management mini system (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password_here
|
||||
DB_NAME=student_db
|
||||
PORT=3000
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
scripts/
|
||||
@@ -1,3 +1,82 @@
|
||||
# bonpin-student-api-mw600
|
||||
# Student Management System (MW600)
|
||||
|
||||
Student Management System mini-system - Express + MySQL CRUD API (MW600)
|
||||
A mini system for managing student records, built with **Express** and **MySQL**.
|
||||
|
||||
Backend is a single file (`server.js`) exposing a CRUD API; the frontend is a
|
||||
plain HTML page (`index.html`) served from the same server.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20.6+ (uses `process.loadEnvFile`)
|
||||
- A running MySQL server
|
||||
|
||||
## Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cp .env.example .env # then fill in your MySQL credentials
|
||||
npm run db:push # creates the database and imports student_db.sql
|
||||
npm start
|
||||
```
|
||||
|
||||
Open http://localhost:3000
|
||||
|
||||
`npm run db:push` creates the `student_db` database and imports the schema and
|
||||
seed data from `student_db.sql`. It is safe to re-run — the script drops and
|
||||
recreates the `students` table.
|
||||
|
||||
## Database
|
||||
|
||||
`student_db.sql` holds the schema and seed rows for a single table:
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------ | ------------ | ---------------------------- |
|
||||
| `id` | `INT` | Primary key, assigned by app |
|
||||
| `name` | `VARCHAR(100)` | Required |
|
||||
| `course` | `VARCHAR(50)` | Required |
|
||||
| `year_level` | `INT` | Required, 1–6 |
|
||||
|
||||
### ID format
|
||||
|
||||
Ids are **8 digits**: a 2-digit year followed by a 6-digit counter that resets
|
||||
each year.
|
||||
|
||||
```
|
||||
26 000001 -> first student added in 2026
|
||||
26 000002 -> second student added in 2026
|
||||
27 000001 -> first student added in 2027
|
||||
```
|
||||
|
||||
The counter is per year, so ids never collide across years. Because MySQL cannot
|
||||
generate this pattern, `id` has no `AUTO_INCREMENT` — the application assigns it.
|
||||
Allocation is guarded by a MySQL advisory lock (`GET_LOCK`) so simultaneous
|
||||
requests cannot receive the same id.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Response |
|
||||
| -------- | -------------------- | -------------------------------------------- |
|
||||
| `GET` | `/api/students` | `200` list of students |
|
||||
| `GET` | `/api/students/:id` | `200` student, `400` bad id, `404` not found |
|
||||
| `POST` | `/api/students` | `201` created, `400` invalid, `415` not JSON |
|
||||
| `PUT` | `/api/students/:id` | `200` updated, `400` invalid, `404` not found |
|
||||
| `DELETE` | `/api/students/:id` | `204` deleted, `404` not found |
|
||||
|
||||
Errors use one envelope:
|
||||
|
||||
```json
|
||||
{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [] } }
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All connection settings live in `.env` (not committed):
|
||||
|
||||
| Variable | Default |
|
||||
| ------------- | ------------- |
|
||||
| `DB_HOST` | `127.0.0.1` |
|
||||
| `DB_PORT` | `3306` |
|
||||
| `DB_USER` | `root` |
|
||||
| `DB_PASSWORD` | *required* |
|
||||
| `DB_NAME` | `student_db` |
|
||||
| `PORT` | `3000` |
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<title>Student API</title>
|
||||
|
||||
<style>
|
||||
|
||||
body {
|
||||
font-family: Arial;
|
||||
width: 700px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 8px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 12px;
|
||||
margin: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
|
||||
<body>
|
||||
|
||||
<h1>Student Management</h1>
|
||||
|
||||
|
||||
|
||||
<!-- INSERT -->
|
||||
|
||||
|
||||
<h2>Add Student</h2>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Name"
|
||||
>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="course"
|
||||
placeholder="Course"
|
||||
>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
id="yearLevel"
|
||||
placeholder="Year Level"
|
||||
>
|
||||
|
||||
<button onclick="addStudent()">
|
||||
Add Student
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<!-- RETRIEVE -->
|
||||
|
||||
|
||||
<h2>Students</h2>
|
||||
|
||||
<button onclick="loadStudents()">
|
||||
Load Students
|
||||
</button>
|
||||
|
||||
|
||||
<table>
|
||||
|
||||
<thead>
|
||||
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Course</th>
|
||||
<th>Year Level</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody id="studentTable">
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
|
||||
<script>
|
||||
const API = "/api/students";
|
||||
|
||||
// Shared fetch helper: unwraps the { error: { code, message, details } }
|
||||
// envelope and turns any non-2xx into an Error with .status.
|
||||
async function request(url, options) {
|
||||
|
||||
const res = await fetch(url, options);
|
||||
|
||||
// 204 No Content has no body to parse.
|
||||
const payload = res.status === 204
|
||||
? null
|
||||
: await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = payload?.error;
|
||||
|
||||
const reason = error?.details?.length
|
||||
? error.details.map(d => `${d.field}: ${d.message}`).join("\n")
|
||||
: error?.message || `Request failed with status ${res.status}`;
|
||||
|
||||
const err = new Error(reason);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
||||
}
|
||||
|
||||
function reportError(err) {
|
||||
alert(`${err.message}\n\n(HTTP ${err.status})`);
|
||||
}
|
||||
|
||||
async function loadStudents() {
|
||||
|
||||
try {
|
||||
const students = await request(API);
|
||||
|
||||
const tbody = document.getElementById("studentTable");
|
||||
|
||||
tbody.innerHTML = students.map(s => `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${s.name}</td>
|
||||
<td>${s.course}</td>
|
||||
<td>${s.year_level}</td>
|
||||
<td>
|
||||
<button onclick="editStudent(${s.id})">Edit</button>
|
||||
<button onclick="deleteStudent(${s.id})">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function addStudent() {
|
||||
|
||||
const name = document.getElementById("name").value;
|
||||
const course = document.getElementById("course").value;
|
||||
const year_level = document.getElementById("yearLevel").value;
|
||||
|
||||
if (!name || !course || !year_level) {
|
||||
alert("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await request(API, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, course, year_level })
|
||||
});
|
||||
|
||||
document.getElementById("name").value = "";
|
||||
document.getElementById("course").value = "";
|
||||
document.getElementById("yearLevel").value = "";
|
||||
|
||||
loadStudents();
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function editStudent(id) {
|
||||
|
||||
try {
|
||||
const s = await request(`${API}/${id}`);
|
||||
|
||||
const name = prompt("Name", s.name);
|
||||
if (name === null) return;
|
||||
|
||||
const course = prompt("Course", s.course);
|
||||
if (course === null) return;
|
||||
|
||||
const year_level = prompt("Year Level", s.year_level);
|
||||
if (year_level === null) return;
|
||||
|
||||
await request(`${API}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, course, year_level })
|
||||
});
|
||||
|
||||
loadStudents();
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function deleteStudent(id) {
|
||||
|
||||
if (!confirm(`Delete student #${id}?`)) return;
|
||||
|
||||
try {
|
||||
await request(`${API}/${id}`, { method: "DELETE" });
|
||||
loadStudents();
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
loadStudents();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+1001
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "api-crud-demo",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"db:push": "node scripts/init-db.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"mysql2": "^3.24.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
const path = require("path");
|
||||
|
||||
try {
|
||||
process.loadEnvFile(path.join(__dirname, ".env"));
|
||||
} catch {
|
||||
// No .env — the defaults below are used instead.
|
||||
}
|
||||
|
||||
const express = require("express");
|
||||
const mysql = require("mysql2/promise");
|
||||
|
||||
if (!process.env.DB_PASSWORD) {
|
||||
console.error("DB_PASSWORD is not set. Copy .env.example to .env and fill it in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const IS_PROD = process.env.NODE_ENV === "production";
|
||||
|
||||
// Must match the column widths in student_db.sql.
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
const MAX_COURSE_LENGTH = 50;
|
||||
const MAX_YEAR_LEVEL = 6;
|
||||
|
||||
// Ids are 8 digits: 2-digit year added + a 6-digit counter that resets each
|
||||
// year, so the first student of 2026 is 26000001 and the first of 2027 is 27000001.
|
||||
const ID_PER_YEAR = 1_000_000;
|
||||
const ID_COUNTER_MAX = 999_999;
|
||||
const ID_LOCK = "students_id_alloc";
|
||||
|
||||
// Inclusive id range for a year, defaulting to the current one.
|
||||
function idRange(year = new Date().getFullYear()) {
|
||||
const prefix = (year % 100) * ID_PER_YEAR;
|
||||
return { year, first: prefix + 1, last: prefix + ID_COUNTER_MAX };
|
||||
}
|
||||
|
||||
// Connection settings come from .env (see .env.example). Defaults are generic
|
||||
// so no credentials live in the source.
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || "127.0.0.1",
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || "root",
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || "student_db",
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, code, message, details) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
// mysql2 driver error code -> [httpStatus, code, message]. Unlisted codes become 500.
|
||||
const MYSQL_ERRORS = {
|
||||
ER_DUP_ENTRY: [409, "DUPLICATE_ENTRY", "A record with these values already exists"],
|
||||
ER_DATA_TOO_LONG: [422, "VALUE_TOO_LONG", "One or more values exceed the maximum allowed length"],
|
||||
ER_WARN_DATA_OUT_OF_RANGE: [422, "VALUE_OUT_OF_RANGE", "One or more numeric values are out of range"],
|
||||
ER_TRUNCATED_WRONG_VALUE_FOR_FIELD: [400, "INVALID_FIELD_VALUE", "One or more values have an invalid type"],
|
||||
ER_TRUNCATED_WRONG_VALUE: [400, "INVALID_FIELD_VALUE", "One or more values have an invalid type"],
|
||||
ER_BAD_NULL_ERROR: [400, "MISSING_FIELD", "A required column was null"],
|
||||
ER_NO_DEFAULT_FOR_FIELD: [400, "MISSING_FIELD", "A required column has no default value"],
|
||||
ER_LOCK_DEADLOCK: [503, "DB_DEADLOCK", "Database deadlock detected, please retry"],
|
||||
ER_LOCK_WAIT_TIMEOUT: [503, "DB_BUSY", "Database lock wait timed out, please retry"],
|
||||
ER_CON_COUNT_ERROR: [503, "DB_UNAVAILABLE", "Database is at its connection limit"],
|
||||
PROTOCOL_CONNECTION_LOST: [503, "DB_UNAVAILABLE", "Lost connection to the database"],
|
||||
ECONNREFUSED: [503, "DB_UNAVAILABLE", "Cannot reach the database"],
|
||||
ENOTFOUND: [503, "DB_UNAVAILABLE", "Cannot resolve the database host"],
|
||||
ETIMEDOUT: [504, "DB_TIMEOUT", "Database connection timed out"],
|
||||
PROTOCOL_SEQUENCE_TIMEOUT: [504, "DB_TIMEOUT", "Database took too long to respond"],
|
||||
ER_ACCESS_DENIED_ERROR: [500, "DB_ACCESS_DENIED", "Database rejected the configured credentials"],
|
||||
ER_BAD_DB_ERROR: [500, "DB_MISCONFIGURED", "Configured database does not exist"],
|
||||
ER_NO_SUCH_TABLE: [500, "DB_MISCONFIGURED", "Expected table is missing from the database"],
|
||||
ER_BAD_FIELD_ERROR: [500, "DB_MISCONFIGURED", "Expected column is missing from the table"],
|
||||
};
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "100kb" }));
|
||||
|
||||
// Serve index.html explicitly. express.static(__dirname) would also expose
|
||||
// server.js and .env.
|
||||
app.get("/", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "index.html"));
|
||||
});
|
||||
|
||||
function parseId(raw) {
|
||||
if (!/^\d+$/.test(String(raw))) return null;
|
||||
const id = Number(raw);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
// Returns { student } when valid, otherwise { details: [...] }.
|
||||
function parseStudent(body) {
|
||||
const details = [];
|
||||
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
const course = typeof body.course === "string" ? body.course.trim() : "";
|
||||
const yearLevel = Number(body.year_level ?? body.yearLevel);
|
||||
|
||||
if (!name) {
|
||||
details.push({ field: "name", message: "name is required" });
|
||||
} else if (name.length > MAX_NAME_LENGTH) {
|
||||
details.push({
|
||||
field: "name",
|
||||
message: `name must be at most ${MAX_NAME_LENGTH} characters`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!course) {
|
||||
details.push({ field: "course", message: "course is required" });
|
||||
} else if (course.length > MAX_COURSE_LENGTH) {
|
||||
details.push({
|
||||
field: "course",
|
||||
message: `course must be at most ${MAX_COURSE_LENGTH} characters`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(yearLevel) ||
|
||||
yearLevel < 1 ||
|
||||
yearLevel > MAX_YEAR_LEVEL
|
||||
) {
|
||||
details.push({
|
||||
field: "year_level",
|
||||
message: `year_level must be an integer between 1 and ${MAX_YEAR_LEVEL}`,
|
||||
});
|
||||
}
|
||||
|
||||
return details.length ? { details } : { student: { name, course, yearLevel } };
|
||||
}
|
||||
|
||||
function requireJson(req, res, next) {
|
||||
if (!req.is("application/json")) {
|
||||
return next(
|
||||
new ApiError(
|
||||
415,
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
"Content-Type must be application/json"
|
||||
)
|
||||
);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function methodNotAllowed(allowed) {
|
||||
return (req, res) => {
|
||||
res.set("Allow", allowed.join(", "));
|
||||
res.status(405).json({
|
||||
error: {
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
message: `${req.method} is not allowed on ${req.originalUrl}`,
|
||||
allowed,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const SELECT_COLUMNS = "id, name, course, year_level";
|
||||
|
||||
// Express 5 forwards rejected promises to the error handler automatically.
|
||||
// Every route below can also return 500/503/504 via the MYSQL_ERRORS mapping.
|
||||
|
||||
/**
|
||||
* GET /api/students
|
||||
* List all students, ordered by id.
|
||||
* 200 — array of students
|
||||
*/
|
||||
app.get("/api/students", async (req, res) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ${SELECT_COLUMNS} FROM students ORDER BY id`
|
||||
);
|
||||
res.status(200).json(rows);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/students/:id
|
||||
* Fetch a single student.
|
||||
* 200 — the student
|
||||
* 400 — id is not a positive integer
|
||||
* 404 — no student with that id
|
||||
*/
|
||||
app.get("/api/students/:id", async (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) {
|
||||
throw new ApiError(400, "INVALID_ID", "id must be a positive integer");
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ${SELECT_COLUMNS} FROM students WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new ApiError(404, "NOT_FOUND", `No student with id ${id}`);
|
||||
}
|
||||
|
||||
res.status(200).json(rows[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/students
|
||||
* Create a student. The id is 2-digit year + a per-year counter, e.g. 26000001.
|
||||
* 201 — the created student, plus a Location header
|
||||
* 400 — validation failed
|
||||
* 409 — no ids left for the current year
|
||||
* 415 — Content-Type was not application/json
|
||||
* 503 — could not allocate an id in time
|
||||
*/
|
||||
app.post("/api/students", requireJson, async (req, res) => {
|
||||
// req.body is undefined in Express 5 when no parser matched.
|
||||
const { student, details } = parseStudent(req.body ?? {});
|
||||
|
||||
if (details) {
|
||||
throw new ApiError(400, "VALIDATION_ERROR", "Invalid student payload", details);
|
||||
}
|
||||
|
||||
const { year, first, last } = idRange();
|
||||
const conn = await pool.getConnection();
|
||||
let id;
|
||||
|
||||
try {
|
||||
// An advisory lock serialises id allocation. `SELECT ... FOR UPDATE` is not
|
||||
// usable here: InnoDB gap locks are mutually compatible, so concurrent
|
||||
// transactions all lock the same empty range and then deadlock on insert.
|
||||
const [[lock]] = await conn.query("SELECT GET_LOCK(?, 5) AS ok", [ID_LOCK]);
|
||||
if (!lock.ok) {
|
||||
throw new ApiError(503, "DB_BUSY", "Could not allocate a student id, please retry");
|
||||
}
|
||||
|
||||
const [rows] = await conn.query(
|
||||
"SELECT id FROM students WHERE id BETWEEN ? AND ? ORDER BY id DESC LIMIT 1",
|
||||
[first, last]
|
||||
);
|
||||
|
||||
id = rows.length ? rows[0].id + 1 : first;
|
||||
|
||||
if (id > last) {
|
||||
throw new ApiError(409, "ID_EXHAUSTED", `No student ids left for ${year}`);
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
"INSERT INTO students (id, name, course, year_level) VALUES (?, ?, ?, ?)",
|
||||
[id, student.name, student.course, student.yearLevel]
|
||||
);
|
||||
} finally {
|
||||
// No-op if GET_LOCK timed out; the server also drops it if the socket dies.
|
||||
await conn.query("SELECT RELEASE_LOCK(?)", [ID_LOCK]).catch(() => {});
|
||||
conn.release();
|
||||
}
|
||||
|
||||
res.status(201)
|
||||
.location(`/api/students/${id}`)
|
||||
.json({
|
||||
id,
|
||||
name: student.name,
|
||||
course: student.course,
|
||||
year_level: student.yearLevel,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/students/:id
|
||||
* Replace a student.
|
||||
* 200 — the updated student
|
||||
* 400 — id is not a positive integer, or validation failed
|
||||
* 404 — no student with that id
|
||||
*/
|
||||
app.put("/api/students/:id", requireJson, async (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) {
|
||||
throw new ApiError(400, "INVALID_ID", "id must be a positive integer");
|
||||
}
|
||||
|
||||
const { student, details } = parseStudent(req.body ?? {});
|
||||
|
||||
if (details) {
|
||||
throw new ApiError(400, "VALIDATION_ERROR", "Invalid student payload", details);
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
"UPDATE students SET name = ?, course = ?, year_level = ? WHERE id = ?",
|
||||
[student.name, student.course, student.yearLevel, id]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
throw new ApiError(404, "NOT_FOUND", `No student with id ${id}`);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
id,
|
||||
name: student.name,
|
||||
course: student.course,
|
||||
year_level: student.yearLevel,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/students/:id
|
||||
* Remove a student.
|
||||
* 204 — deleted
|
||||
* 400 — id is not a positive integer
|
||||
* 404 — no student with that id
|
||||
*/
|
||||
app.delete("/api/students/:id", async (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) {
|
||||
throw new ApiError(400, "INVALID_ID", "id must be a positive integer");
|
||||
}
|
||||
|
||||
const [result] = await pool.query("DELETE FROM students WHERE id = ?", [id]);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
throw new ApiError(404, "NOT_FOUND", `No student with id ${id}`);
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Any other method on the student paths → 405, with an Allow header.
|
||||
app.all("/api/students", methodNotAllowed(["GET", "POST"]));
|
||||
app.all("/api/students/:id", methodNotAllowed(["GET", "PUT", "DELETE"]));
|
||||
|
||||
// Fallback for any route not matched above.
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: {
|
||||
code: "NOT_FOUND",
|
||||
message: `Cannot ${req.method} ${req.originalUrl}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Turn anything a route throws into a status code plus an error envelope.
|
||||
* Order matters: parser errors first, then deliberate ApiErrors, then driver codes.
|
||||
*/
|
||||
function normalizeError(err) {
|
||||
if (err.type === "entity.too.large") {
|
||||
return { status: 413, code: "PAYLOAD_TOO_LARGE", message: "Request body is too large" };
|
||||
}
|
||||
if (err.type === "entity.parse.failed" || (err instanceof SyntaxError && "body" in err)) {
|
||||
return { status: 400, code: "INVALID_JSON", message: "Request body is not valid JSON" };
|
||||
}
|
||||
if (err instanceof ApiError) {
|
||||
return { status: err.status, code: err.code, message: err.message, details: err.details };
|
||||
}
|
||||
|
||||
const mapped = MYSQL_ERRORS[err?.code];
|
||||
if (mapped) {
|
||||
const [status, code, message] = mapped;
|
||||
return { status, code, message };
|
||||
}
|
||||
|
||||
return { status: 500, code: "INTERNAL_ERROR", message: "Internal server error" };
|
||||
}
|
||||
|
||||
// Must stay last so it catches everything above.
|
||||
app.use((err, req, res, next) => {
|
||||
if (res.headersSent) return next(err);
|
||||
|
||||
const { status, code, message, details } = normalizeError(err);
|
||||
|
||||
if (status >= 500) {
|
||||
console.error(`[${req.method} ${req.originalUrl}] ${status} ${code}`, err);
|
||||
}
|
||||
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code,
|
||||
// Keep driver text out of production 5xx responses.
|
||||
message: status >= 500 && IS_PROD ? "Internal server error" : message,
|
||||
...(details ? { details } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const server = app.listen(PORT, async () => {
|
||||
console.log(`Server running at http://localhost:${PORT}`);
|
||||
|
||||
try {
|
||||
const [[row]] = await pool.query("SELECT DATABASE() AS db");
|
||||
console.log(`Connected to MySQL database \`${row.db}\``);
|
||||
} catch (err) {
|
||||
console.error(`MySQL connection failed: ${err.code || ""} ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`\n${signal} received — shutting down`);
|
||||
server.close(async () => {
|
||||
await pool.end().catch(() => {});
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => process.exit(1), 10_000).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
@@ -0,0 +1,24 @@
|
||||
-- student_db schema and seed data.
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
SET time_zone = "+00:00";
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP TABLE IF EXISTS `students`;
|
||||
|
||||
CREATE TABLE `students` (
|
||||
`id` int NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`course` varchar(50) NOT NULL,
|
||||
`year_level` int NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- Ids are a 2-digit year + a per-year counter, so these two were added in 2026.
|
||||
-- The app assigns ids, so there is no AUTO_INCREMENT here.
|
||||
INSERT INTO `students` (`id`, `name`, `course`, `year_level`) VALUES
|
||||
(26000001, 'Juan Dela Cruz', 'BSIT', 2),
|
||||
(26000002, 'Maria Santos', 'BSCS', 3);
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user