Compare commits

..
2 Commits
11 changed files with 316 additions and 247 deletions
+1
View File
@@ -6,6 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"perf": "tsx watch --import ./tools/perf-hooks.mjs src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
+141 -78
View File
@@ -2,58 +2,93 @@ import express from 'express';
import Sessions from './Schema/sessions.schema.js' import Sessions from './Schema/sessions.schema.js'
import Teachers from '../Teachers/Schema/Teachers.schema.js'; import Teachers from '../Teachers/Schema/Teachers.schema.js';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { measureDb } from '../performance.js';
const SessionsRoutes = express.Router(); const SessionsRoutes = express.Router();
// Add New Session for teacher // Add New Session for teacher
SessionsRoutes.post('/', async (req, res) => { SessionsRoutes.post('/', async (req, res, next) => {
const { teacherId, startTime, endTime } = req.body; try {
const { teacherId, startTime, endTime } = req.body;
//Check for teacher existance if (!teacherId || !startTime || !endTime) {
const existingTeacher = await measureDb('sessions.create.checkTeacher', () => Teachers.findOne({ _id: teacherId })); return res.status(400).send({ message: 'teacherId, startTime and endTime are required.' })
}
if (!Types.ObjectId.isValid(teacherId)) {
return res.status(400).send({ message: 'Invalid teacherId.' })
}
if (!existingTeacher) { //Check for teacher existance
return res.status(400).send({ message: 'Teacher Does not exist please provide valid teacherId.' }) const existingTeacher = await Teachers.findOne({ _id: teacherId });
}
// Check endTime must be greater then startTime if (!existingTeacher) {
if (new Date(endTime) <= new Date(startTime)) { return res.status(400).send({ message: 'Teacher Does not exist please provide valid teacherId.' })
return res.status(400).send({ message: 'End time must be greater then start time.' }) }
}
const newSession = new Sessions({ teacherId, startTime, endTime }); // Reject invalid dates: NaN comparisons are always false, so the old
const result = await measureDb('sessions.create.insert', () => newSession.save()); // "end <= start" check silently accepted garbage dates
const start = new Date(startTime);
const end = new Date(endTime);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return res.status(400).send({ message: 'startTime and endTime must be valid dates.' })
}
// Check endTime must be greater then startTime
if (end <= start) {
return res.status(400).send({ message: 'End time must be greater then start time.' })
}
// Prevent overlapping sessions for the same teacher
const overlap = await Sessions.findOne({
teacherId: teacherId,
status: { $in: ['AVAILABLE', 'BOOKED'] },
startTime: { $lt: end },
endTime: { $gt: start }
});
if (overlap) {
return res.status(409).send({ message: 'Teacher already has a session overlapping this time range.' })
}
const newSession = new Sessions({ teacherId, startTime, endTime });
const result = await newSession.save();
if (result) {
return res.status(201).send({ message: 'Session Added Successfully', result }) return res.status(201).send({ message: 'Session Added Successfully', result })
} catch (error) {
next(error);
} }
}) })
// Get Available Sessions // Get Available Sessions
SessionsRoutes.get('/available', async (req: any, res) => { SessionsRoutes.get('/available', async (req: any, res, next) => {
const dateTimestamp = req.query.dateTimestamp; try {
const dateTimestamp = req.query.dateTimestamp;
const dateString = dateTimestamp.slice(0, 10); // Old code called .slice() unconditionally and crashed on a missing param
if (typeof dateTimestamp !== 'string' || dateTimestamp.length < 10 ||
// us standard dates we used here for simplicity isNaN(new Date(dateTimestamp.slice(0, 10)).getTime())) {
const startOfDay = new Date(`${dateString}T00:00:00.000Z`); return res.status(400).send({ message: 'A valid dateTimestamp query parameter (e.g. 2026-10-30) is required.' })
const endOfDay = new Date(`${dateString}T23:59:59.999Z`);
const availableSessions = await measureDb('sessions.available.aggregate', () => Sessions.aggregate([
{
$match: {
startTime: { $lte: endOfDay },
endTime: { $gte: startOfDay },
status: "AVAILABLE",
}
} }
]));
return res.status(200).send({ message: 'Available Session', availableSessions }) const dateString = dateTimestamp.slice(0, 10);
// us standard dates we used here for simplicity
const startOfDay = new Date(`${dateString}T00:00:00.000Z`);
const endOfDay = new Date(`${dateString}T23:59:59.999Z`);
const availableSessions = await Sessions.aggregate([
{
$match: {
startTime: { $lte: endOfDay },
endTime: { $gte: startOfDay },
status: "AVAILABLE",
}
}
]);
return res.status(200).send({ message: 'Available Session', availableSessions })
} catch (error) {
next(error);
}
}) })
@@ -90,24 +125,32 @@ SessionsRoutes.get('/available', async (req: any, res) => {
// }) // })
// Book Available Sessions atomically // Book Available Sessions atomically
SessionsRoutes.post('/:id/book', async (req: any, res) => { SessionsRoutes.post('/:id/book', async (req: any, res, next) => {
const { id } = req.params; try {
const { userId } = req.body; const { id } = req.params;
const { userId } = req.body;
// Atomically book only if the session is still AVAILABLE if (!Types.ObjectId.isValid(id)) {
const result = await measureDb('sessions.book.update', () => Sessions.findOneAndUpdate( return res.status(400).send({ message: 'Invalid session id.' })
{ _id: id, status: 'AVAILABLE' }, }
{ if (!userId || !Types.ObjectId.isValid(userId)) {
$set: { return res.status(400).send({ message: 'A valid userId is required in the request body.' })
status: 'BOOKED', }
userId: userId
} // Atomically book only if the session is still AVAILABLE
}, const result = await Sessions.findOneAndUpdate(
{ _id: id, status: 'AVAILABLE' },
{
$set: {
status: 'BOOKED',
userId: new Types.ObjectId(userId)
}
},
{ {
new: true, new: true,
runValidators: true runValidators: true
} }
)); );
if (result) { if (result) {
return res.status(200).send({ return res.status(200).send({
@@ -117,7 +160,7 @@ SessionsRoutes.post('/:id/book', async (req: any, res) => {
} }
// Determine why the booking failed // Determine why the booking failed
const existingSession = await measureDb('sessions.book.failureLookup', () => Sessions.findById(id)); const existingSession = await Sessions.findById(id);
if (!existingSession) { if (!existingSession) {
return res.status(400).send({ return res.status(400).send({
@@ -128,45 +171,63 @@ SessionsRoutes.post('/:id/book', async (req: any, res) => {
return res.status(400).send({ return res.status(400).send({
message: 'This session is not Available' message: 'This session is not Available'
}); });
} catch (error) {
next(error);
}
}); });
// Update Available Sessions To Complete : User Id also needed to update // Update Available Sessions To Complete : userId in the body must match the booking user
SessionsRoutes.patch('/:id/complete', async (req: any, res) => { SessionsRoutes.patch('/:id/complete', async (req: any, res, next) => {
try {
const { id } = req.params;
const { userId } = req.body;
const { id } = req.params; if (!Types.ObjectId.isValid(id)) {
return res.status(400).send({ message: 'Invalid session id.' })
}
if (!userId || !Types.ObjectId.isValid(userId)) {
return res.status(400).send({ message: 'A valid userId is required in the request body.' })
}
const existingSession = await measureDb('sessions.complete.find', () => Sessions.findOne({ _id: id })); // Atomically complete only if this exact session is BOOKED by this user
// (fixes both the find-then-update race and the missing ownership check)
const result = await Sessions.findOneAndUpdate(
{ _id: id, status: 'BOOKED', userId: new Types.ObjectId(userId) },
{ status: 'COMPLETED', completedAt: Date.now() },
{ new: true }
);
if (!existingSession) { if (result) {
return res.status(400).send({ message: 'Session does not exist.' }) return res.status(200).send({ message: 'Session Marked Completed', result })
}
// Update matched nothing: distinguish the failure reasons
const existingSession = await Sessions.findById(id);
if (!existingSession) {
return res.status(400).send({ message: 'Session does not exist.' })
}
if (existingSession.status !== 'BOOKED') {
return res.status(400).send({ message: 'This session is not BOOKED' });
}
return res.status(403).send({ message: 'This session was not booked by this user.' })
} catch (error) {
next(error);
} }
if (existingSession.status !== 'BOOKED') {
return res.status(400).send({ message: 'This session is not BOOKED' });
}
const result = await measureDb('sessions.complete.update', () => Sessions.findByIdAndUpdate(
id,
{ status: "COMPLETED", completedAt: Date.now() },
{ new: true }
));
if (result?.status === 'COMPLETED') {
return res.status(200).send({ message: 'Session Marked Completed', result })
} else {
return res.status(400).send({ message: 'Error during updating session.' })
}
}) })
// Get Sessions for User : With Seperation // Get Sessions for User : With Seperation
SessionsRoutes.get('/:id/sessions', async (req, res) => { SessionsRoutes.get('/:id/sessions', async (req, res, next) => {
try {
// Old code threw an unhandled CastError on a malformed id
if (!Types.ObjectId.isValid(req.params.id)) {
return res.status(400).send({ message: 'Invalid user id.' })
}
let userId = new Types.ObjectId(req.params.id); let userId = new Types.ObjectId(req.params.id);
const result = await measureDb('sessions.history.aggregate', () => Sessions.aggregate([ const result = await Sessions.aggregate([
{ {
$match: { $match: {
userId: userId userId: userId
@@ -190,7 +251,7 @@ SessionsRoutes.get('/:id/sessions', async (req, res) => {
] ]
} }
} }
])); ]);
res.status(200).json({ res.status(200).json({
success: true, success: true,
@@ -199,10 +260,12 @@ SessionsRoutes.get('/:id/sessions', async (req, res) => {
completedSessions: [] completedSessions: []
} }
}); });
} catch (error) {
next(error);
}
}) })
export default SessionsRoutes; export default SessionsRoutes;
+29 -12
View File
@@ -1,27 +1,44 @@
import express from 'express'; import express from 'express';
import Teachers from './Schema/Teachers.schema.js' import Teachers from './Schema/Teachers.schema.js'
import mongoose from 'mongoose';
const TeacherRoutes = express.Router(); const TeacherRoutes = express.Router();
// Get All Teachers // Get All Teachers
TeacherRoutes.get('/', async(req, res) => { TeacherRoutes.get('/', async (_req, res, next) => {
const teachers = await Teachers.find(); try {
return res.status(200).send({'messesage': 'success', data: {teachers}}) const teachers = await Teachers.find();
return res.status(200).send({ message: 'success', data: { teachers } })
} catch (error) {
next(error);
}
}) })
// Add New Teacher // Add New Teacher
TeacherRoutes.post('/', async(req, res) => { TeacherRoutes.post('/', async (req, res, next) => {
const {fullName, email, specialization, experience} = req.body; try {
const { fullName, email, specialization, experience } = req.body;
const newTeacher = new Teachers({fullName, email, specialization, experience}); if (!fullName || !email || !specialization || experience === undefined) {
const result = await newTeacher.save(); return res.status(400).send({ message: 'fullName, email, specialization and experience are required.' })
}
if(result) { const newTeacher = new Teachers({ fullName, email, specialization, experience });
return res.status(201).send({message: 'Teacher Added Successfully', result}) const result = await newTeacher.save();
return res.status(201).send({ message: 'Teacher Added Successfully', result })
} catch (error: unknown) {
if (
error instanceof mongoose.mongo.MongoServerError &&
error.code === 11000
) {
return res.status(400).send({
message: 'A teacher already exists with this email.'
});
}
next(error);
} }
}) })
export default TeacherRoutes; export default TeacherRoutes;
+6
View File
@@ -22,6 +22,12 @@ UsersRoutes.post('/', async (req, res, next) => {
try { try {
const { fullName, email, phone } = req.body; const { fullName, email, phone } = req.body;
if (!fullName || !email || !phone) {
return res.status(400).send({
message: 'fullName, email and phone are required.'
});
}
const result = await Users.create({ fullName, email, phone }); const result = await Users.create({ fullName, email, phone });
return res.status(201).send({ return res.status(201).send({
View File
-61
View File
@@ -1,7 +1,6 @@
import express from "express"; import express from "express";
import 'dotenv/config'; import 'dotenv/config';
import mongoose from "mongoose"; import mongoose from "mongoose";
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import TeachersRoutes from './Teachers/teachers.routes.js'; import TeachersRoutes from './Teachers/teachers.routes.js';
import UsersRoutes from "./Users/users.routes.js"; import UsersRoutes from "./Users/users.routes.js";
@@ -11,38 +10,6 @@ const app = express();
const PORT = 3000; const PORT = 3000;
const MONGO_URI = process.env.MONGO_URI!; const MONGO_URI = process.env.MONGO_URI!;
const eventLoop = monitorEventLoopDelay({ resolution: 20 });
eventLoop.enable();
const requestMetrics = new Map<string, {
count: number;
totalMs: number;
maxMs: number;
over500Ms: number;
}>();
app.use((req, res, next) => {
const started = performance.now();
res.on("finish", () => {
const durationMs = performance.now() - started;
const key = `${req.method} ${req.route?.path ?? req.path} ${res.statusCode}`;
let metric = requestMetrics.get(key);
if (!metric) {
metric = { count: 0, totalMs: 0, maxMs: 0, over500Ms: 0 };
requestMetrics.set(key, metric);
}
metric.count++;
metric.totalMs += durationMs;
metric.maxMs = Math.max(metric.maxMs, durationMs);
if (durationMs > 500) metric.over500Ms++;
});
next();
});
app.use(express.json()); app.use(express.json());
app.get("/", (_req, res) => { app.get("/", (_req, res) => {
@@ -53,34 +20,6 @@ app.use('/users', UsersRoutes);
app.use('/teachers', TeachersRoutes); app.use('/teachers', TeachersRoutes);
app.use('/sessions', SessionsRoutes); app.use('/sessions', SessionsRoutes);
setInterval(() => {
const requests = Object.fromEntries(
[...requestMetrics.entries()].map(([key, value]) => [
key,
{
count: value.count,
avgMs: Number((value.totalMs / value.count).toFixed(2)),
maxMs: Number(value.maxMs.toFixed(2)),
over500Ms: value.over500Ms,
},
]),
);
console.log(JSON.stringify({
type: "performance_summary",
timestamp: new Date().toISOString(),
requests,
eventLoopDelayMs: {
mean: Number((eventLoop.mean / 1e6 || 0).toFixed(2)),
p95: Number((eventLoop.percentile(95) / 1e6).toFixed(2)),
max: Number((eventLoop.max / 1e6).toFixed(2)),
},
}));
requestMetrics.clear();
eventLoop.reset();
}, 10_000).unref();
app.listen(PORT, async () => { app.listen(PORT, async () => {
try { try {
await mongoose.connect(MONGO_URI); await mongoose.connect(MONGO_URI);
-34
View File
@@ -1,34 +0,0 @@
import express from "express";
import 'dotenv/config'
import mongoose from "mongoose";
import TeachersRoutes from './Teachers/teachers.routes.js'
import UsersRoutes from "./Users/users.routes.js";
import SessionsRoutes from "./Sessions/sessions.routes.js";
const app = express();
const PORT = 3000;
app.use(express.json());
app.get("/", (_req, res) => {
res.json({ message: "Hello from Express + TypeScript!" });
});
const MONGO_URI = process.env.MONGO_URI!;
// Users
app.use('/users', UsersRoutes);
// Teachers
app.use('/teachers', TeachersRoutes);
// Sessions
app.use('/sessions', SessionsRoutes);
app.listen(PORT, async() => {
console.log(`Server running at http://localhost:${PORT}`);
await mongoose.connect(MONGO_URI);
});
-61
View File
@@ -1,61 +0,0 @@
import { performance } from "node:perf_hooks";
type DbMetric = {
count: number;
totalMs: number;
maxMs: number;
over100Ms: number;
};
const metrics = new Map<string, DbMetric>();
export async function measureDb<T>(
label: string,
operation: () => Promise<T>,
): Promise<T> {
const started = performance.now();
try {
return await operation();
} finally {
const durationMs = performance.now() - started;
let metric = metrics.get(label);
if (!metric) {
metric = { count: 0, totalMs: 0, maxMs: 0, over100Ms: 0 };
metrics.set(label, metric);
}
metric.count++;
metric.totalMs += durationMs;
metric.maxMs = Math.max(metric.maxMs, durationMs);
if (durationMs > 100) {
metric.over100Ms++;
}
}
}
setInterval(() => {
if (metrics.size === 0) return;
const operations = Object.fromEntries(
[...metrics.entries()].map(([label, metric]) => [
label,
{
count: metric.count,
avgMs: Number((metric.totalMs / metric.count).toFixed(2)),
maxMs: Number(metric.maxMs.toFixed(2)),
over100Ms: metric.over100Ms,
},
]),
);
console.log(JSON.stringify({
type: "database_performance",
timestamp: new Date().toISOString(),
operations,
}));
metrics.clear();
}, 10_000).unref();
+6
View File
@@ -0,0 +1,6 @@
// Opt-in loader: `node --import ./tools/perf-hooks.mjs src/index.ts` (see `yarn perf`).
// Keeps all performance instrumentation out of src/.
import { register, requestTimer } from './performance.ts';
register();
requestTimer();
+131
View File
@@ -0,0 +1,131 @@
/**
* Opt-in performance harness. Nothing in src/ imports this file, so the app
* runs without it. Load it with `yarn perf` (or `node --import ./tools/perf-hooks.mjs`).
*/
import mongoose from "mongoose";
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import express from "express";
type Metric = {
count: number;
totalMs: number;
maxMs: number;
over100Ms: number;
};
type RequestMetric = {
count: number;
totalMs: number;
maxMs: number;
over500Ms: number;
};
const dbMetrics = new Map<string, Metric>();
const requestMetrics = new Map<string, RequestMetric>();
const eventLoop = monitorEventLoopDelay({ resolution: 20 });
eventLoop.enable();
// --- Database timings -------------------------------------------------------
// Mongoose emits these for every query, so no route code has to be touched.
export function register() {
mongoose.connection.on('connected', () => {
const client = mongoose.connection.getClient();
client.on('commandStarted', (event: any) => {
(event as any).__startedAt = performance.now();
});
client.on('commandSucceeded', (event: any) => {
recordDb(`${event.commandName}.${Object.keys(event.command ?? {})[0] ?? 'unknown'}`, event);
});
client.on('commandFailed', (event: any) => {
recordDb(`${event.commandName}.failed`, event);
});
});
}
function recordDb(label: string, event: any) {
const startedAt = event.__startedAt;
if (typeof startedAt !== 'number') return;
const durationMs = performance.now() - startedAt;
let metric = dbMetrics.get(label);
if (!metric) {
metric = { count: 0, totalMs: 0, maxMs: 0, over100Ms: 0 };
dbMetrics.set(label, metric);
}
metric.count++;
metric.totalMs += durationMs;
metric.maxMs = Math.max(metric.maxMs, durationMs);
if (durationMs > 100) metric.over100Ms++;
}
// --- Request timings --------------------------------------------------------
// Installed by patching express.json() from the loader, so index.ts stays clean.
export function requestTimer() {
const originalJson = express.json.bind(express);
express.json = ((...args: any[]) => {
const parser = (originalJson as any)(...args);
return (req: any, res: any, next: any) => {
const started = performance.now();
res.on("finish", () => {
const durationMs = performance.now() - started;
const key = `${req.method} ${req.route?.path ?? req.path} ${res.statusCode}`;
let metric = requestMetrics.get(key);
if (!metric) {
metric = { count: 0, totalMs: 0, maxMs: 0, over500Ms: 0 };
requestMetrics.set(key, metric);
}
const current = metric;
current.count++;
current.totalMs += durationMs;
current.maxMs = Math.max(current.maxMs, durationMs);
if (durationMs > 500) current.over500Ms++;
});
parser(req, res, next);
};
}) as typeof express.json;
}
// --- Reporting --------------------------------------------------------------
function summarize<T extends { count: number; totalMs: number; maxMs: number }>(entries: [string, T][]) {
return Object.fromEntries(
entries.map(([key, value]) => [
key,
{
count: value.count,
avgMs: Number((value.totalMs / value.count).toFixed(2)),
maxMs: Number(value.maxMs.toFixed(2)),
},
]),
);
}
setInterval(() => {
if (requestMetrics.size === 0 && dbMetrics.size === 0) return;
console.log(JSON.stringify({
type: "performance_summary",
timestamp: new Date().toISOString(),
requests: summarize([...requestMetrics.entries()]),
database: summarize([...dbMetrics.entries()]),
eventLoopDelayMs: {
mean: Number((eventLoop.mean / 1e6 || 0).toFixed(2)),
p95: Number((eventLoop.percentile(95) / 1e6).toFixed(2)),
max: Number((eventLoop.max / 1e6).toFixed(2)),
},
}));
requestMetrics.clear();
dbMetrics.clear();
eventLoop.reset();
}, 10_000).unref();
+2 -1
View File
@@ -40,5 +40,6 @@
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
"moduleDetection": "force", "moduleDetection": "force",
"skipLibCheck": true, "skipLibCheck": true,
} },
"exclude": ["node_modules", "dist", "tools"]
} }