Add Student Management mini system (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-09-28 10:58:48 +00:00
parent 36ef7f0122
commit f51ea18ad0
8 changed files with 1783 additions and 2 deletions
+405
View File
@@ -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"));