Add Student Management mini system

Express + MySQL CRUD API for student records, with a plain HTML frontend.

- Single-file backend (server.js) with 5 CRUD routes
- Proper HTTP status codes and a consistent error envelope
- 8-digit ids: 2-digit year + per-year 6-digit counter (e.g. 26000001),
  allocated under a MySQL advisory lock so concurrent inserts cannot collide
- student_db.sql schema and seed data
- .env for credentials; only .env.example is committed
This commit is contained in:
2026-09-28 18:58:42 +08:00
parent 36ef7f0122
commit 45e4de3557
8 changed files with 1783 additions and 2 deletions
+6
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
scripts/
+81 -2
View File
@@ -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
View File
@@ -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>
+1001
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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"
}
}
+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"));
+24
View File
@@ -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;