Update app and ignore local artifacts
This commit is contained in:
9
backend/Dockerfile
Normal file
9
backend/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY backend/server.mjs ./backend/server.mjs
|
||||
RUN mkdir -p /app/backend/data && chown -R node:node /app
|
||||
USER node
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4000
|
||||
EXPOSE 4000
|
||||
CMD ["node", "backend/server.mjs"]
|
||||
50
backend/README.md
Normal file
50
backend/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# College Kit API
|
||||
|
||||
A zero-dependency Node HTTP API backed by a persistent SQLite database. The database is created at `backend/data/college-kit.db` on first start with no subjects, timetable rules, sessions, or attendance records.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
npm run api
|
||||
```
|
||||
|
||||
The API listens on `http://localhost:4000`. In a second terminal, run the Expo app:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
|
||||
For a physical device, expose the API on your development machine's LAN IP:
|
||||
|
||||
```sh
|
||||
EXPO_PUBLIC_API_URL=http://192.168.1.20:4000/api/v1 npm start
|
||||
```
|
||||
|
||||
## Data model
|
||||
|
||||
- `users` — currently a seeded local user; this is the seam for authentication.
|
||||
- `subjects` — course name, code, and display colour.
|
||||
- `timetable_classes` — recurring weekly timetable rules.
|
||||
- `class_sessions` — dated generated classes plus one-off overrides.
|
||||
- `attendance` — one status per session: `pending`, `attended`, `absent`, or `cancelled`.
|
||||
|
||||
Sessions are generated from the weekly timetable when a date is requested. One-off sessions and edits are stored independently, so daily changes never alter the regular timetable. Cancelled sessions are excluded from attendance totals.
|
||||
|
||||
Recurring timetable edits are **versioned**: `PATCH /timetable/classes/:id` closes the existing rule and creates a replacement rule effective tomorrow (or a later supplied date). It only removes future, auto-generated sessions for the old rule. Past sessions, their times, and every attendance record are retained unchanged.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Route | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/health` | Service check |
|
||||
| GET | `/api/v1/profile` | Current user profile |
|
||||
| GET | `/api/v1/schedule?date=YYYY-MM-DD` | Get/create dated sessions |
|
||||
| POST | `/api/v1/schedule` | Add a one-off class |
|
||||
| PATCH / DELETE | `/api/v1/schedule/:id` | Edit/remove a dated class |
|
||||
| PUT | `/api/v1/schedule/:id/attendance` | Set `{ "status": "attended" }` |
|
||||
| GET / POST | `/api/v1/subjects` | List/create subjects |
|
||||
| GET / POST | `/api/v1/timetable/classes?date=YYYY-MM-DD` | List/create recurring classes active for a date |
|
||||
| PATCH | `/api/v1/timetable/classes/:id` | Version a recurring class change for tomorrow or later |
|
||||
| GET | `/api/v1/attendance/summary` | Per-subject attendance totals |
|
||||
|
||||
Timetable image OCR is deliberately not performed by this service. A mobile client should upload/select an image, run OCR with a provider of choice, let the student review the extracted classes, then send confirmed subjects and timetable classes through the endpoints above. This avoids silently creating an inaccurate timetable from an image.
|
||||
250
backend/server.mjs
Normal file
250
backend/server.mjs
Normal file
@@ -0,0 +1,250 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = join(__dirname, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const db = new DatabaseSync(join(dataDir, 'college-kit.db'));
|
||||
db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;');
|
||||
|
||||
const PORT = Number(process.env.PORT || 4000);
|
||||
const STUDENT_ID = 1; // Replace with the authenticated user's id when auth is added.
|
||||
const validStatuses = new Set(['attended', 'absent', 'cancelled', 'pending']);
|
||||
|
||||
function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL, initials TEXT NOT NULL,
|
||||
college TEXT NOT NULL DEFAULT '', programme TEXT NOT NULL DEFAULT '', semester TEXT NOT NULL DEFAULT '',
|
||||
lecture_minutes INTEGER NOT NULL DEFAULT 60, recess_enabled INTEGER NOT NULL DEFAULT 1, recess_start TEXT NOT NULL DEFAULT '13:00', recess_end TEXT NOT NULL DEFAULT '14:00', weekend_schedule INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS subjects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL, code TEXT NOT NULL, short_name TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '#0559FA',
|
||||
class_type TEXT NOT NULL DEFAULT 'Lecture', default_room TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(user_id, code)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS timetable_classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
subject_id INTEGER NOT NULL REFERENCES subjects(id), weekday INTEGER NOT NULL CHECK(weekday BETWEEN 0 AND 6),
|
||||
start_time TEXT NOT NULL, end_time TEXT NOT NULL, room TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1,
|
||||
effective_from TEXT NOT NULL DEFAULT '1970-01-01', effective_to TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS class_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
subject_id INTEGER NOT NULL REFERENCES subjects(id), timetable_class_id INTEGER REFERENCES timetable_classes(id),
|
||||
class_date TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, room TEXT NOT NULL,
|
||||
is_override INTEGER NOT NULL DEFAULT 0, is_removed INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, timetable_class_id, class_date)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attendance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL UNIQUE REFERENCES class_sessions(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'attended', 'absent', 'cancelled')) DEFAULT 'pending',
|
||||
marked_at TEXT, note TEXT
|
||||
);
|
||||
`);
|
||||
const subjectColumns = db.prepare('PRAGMA table_info(subjects)').all().map((column) => column.name);
|
||||
if (!subjectColumns.includes('short_name')) db.exec("ALTER TABLE subjects ADD COLUMN short_name TEXT NOT NULL DEFAULT ''");
|
||||
if (!subjectColumns.includes('class_type')) db.exec("ALTER TABLE subjects ADD COLUMN class_type TEXT NOT NULL DEFAULT 'Lecture'");
|
||||
if (!subjectColumns.includes('default_room')) db.exec("ALTER TABLE subjects ADD COLUMN default_room TEXT NOT NULL DEFAULT ''");
|
||||
const userColumns = db.prepare('PRAGMA table_info(users)').all().map((column) => column.name);
|
||||
if (!userColumns.includes('college')) db.exec("ALTER TABLE users ADD COLUMN college TEXT NOT NULL DEFAULT ''");
|
||||
if (!userColumns.includes('programme')) db.exec("ALTER TABLE users ADD COLUMN programme TEXT NOT NULL DEFAULT ''");
|
||||
if (!userColumns.includes('semester')) db.exec("ALTER TABLE users ADD COLUMN semester TEXT NOT NULL DEFAULT ''");
|
||||
if (!userColumns.includes('lecture_minutes')) db.exec('ALTER TABLE users ADD COLUMN lecture_minutes INTEGER NOT NULL DEFAULT 60');
|
||||
if (!userColumns.includes('recess_enabled')) db.exec('ALTER TABLE users ADD COLUMN recess_enabled INTEGER NOT NULL DEFAULT 1');
|
||||
if (!userColumns.includes('recess_start')) db.exec("ALTER TABLE users ADD COLUMN recess_start TEXT NOT NULL DEFAULT '13:00'");
|
||||
if (!userColumns.includes('recess_end')) db.exec("ALTER TABLE users ADD COLUMN recess_end TEXT NOT NULL DEFAULT '14:00'");
|
||||
if (!userColumns.includes('weekend_schedule')) db.exec('ALTER TABLE users ADD COLUMN weekend_schedule INTEGER NOT NULL DEFAULT 0');
|
||||
const sessionColumns = db.prepare('PRAGMA table_info(class_sessions)').all().map((column) => column.name);
|
||||
if (!sessionColumns.includes('is_removed')) db.exec('ALTER TABLE class_sessions ADD COLUMN is_removed INTEGER NOT NULL DEFAULT 0');
|
||||
const templateColumns = db.prepare('PRAGMA table_info(timetable_classes)').all().map((column) => column.name);
|
||||
if (!templateColumns.includes('effective_from')) db.exec("ALTER TABLE timetable_classes ADD COLUMN effective_from TEXT NOT NULL DEFAULT '1970-01-01'");
|
||||
if (!templateColumns.includes('effective_to')) db.exec('ALTER TABLE timetable_classes ADD COLUMN effective_to TEXT');
|
||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(STUDENT_ID);
|
||||
// Keep only an empty local account record for the unauthenticated prototype.
|
||||
// Courses, timetable rules, sessions, and attendance are never seeded.
|
||||
if (!user) db.prepare('INSERT INTO users (id, name, initials) VALUES (?, ?, ?)').run(STUDENT_ID, '', '');
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Methods': 'GET,POST,PATCH,PUT,DELETE,OPTIONS' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
function error(res, status, message) { json(res, status, { error: message }); }
|
||||
function dateIsValid(value) { return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(new Date(`${value}T12:00:00`).valueOf()); }
|
||||
function weekdayFor(date) { return new Date(`${date}T12:00:00`).getDay(); }
|
||||
function localDateKey(date = new Date()) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; }
|
||||
function nextDateKey(date) { const next = new Date(`${date}T12:00:00`); next.setDate(next.getDate() + 1); return localDateKey(next); }
|
||||
function previousDateKey(date) { const previous = new Date(`${date}T12:00:00`); previous.setDate(previous.getDate() - 1); return localDateKey(previous); }
|
||||
function readBody(req) { return new Promise((resolve, reject) => { let raw = ''; req.on('data', c => { raw += c; if (raw.length > 1_000_000) reject(new Error('Request body is too large')); }); req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('Body must be valid JSON')); } }); req.on('error', reject); }); }
|
||||
function sessionRows(date) {
|
||||
return db.prepare(`SELECT cs.id, cs.class_date AS date, cs.start_time AS time, cs.end_time AS endTime, cs.room, cs.is_override AS isOverride,
|
||||
s.id AS subjectId, s.name AS title, s.code, s.color, s.class_type AS classType, COALESCE(a.status, 'pending') AS status, a.note
|
||||
FROM class_sessions cs JOIN subjects s ON s.id = cs.subject_id LEFT JOIN attendance a ON a.session_id = cs.id
|
||||
WHERE cs.user_id = ? AND cs.class_date = ? AND cs.is_removed = 0 ORDER BY cs.start_time`).all(STUDENT_ID, date);
|
||||
}
|
||||
function ensureSessions(date) {
|
||||
const templates = db.prepare(`SELECT * FROM timetable_classes
|
||||
WHERE user_id = ? AND weekday = ? AND active = 1 AND effective_from <= ?
|
||||
AND (effective_to IS NULL OR effective_to >= ?)`).all(STUDENT_ID, weekdayFor(date), date, date);
|
||||
const create = db.prepare('INSERT OR IGNORE INTO class_sessions (user_id, subject_id, timetable_class_id, class_date, start_time, end_time, room) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const item of templates) create.run(STUDENT_ID, item.subject_id, item.id, date, item.start_time, item.end_time, item.room);
|
||||
}
|
||||
function getSubject(id) { return db.prepare('SELECT id FROM subjects WHERE id = ? AND user_id = ?').get(id, STUDENT_ID); }
|
||||
|
||||
migrate();
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') return json(res, 204, {});
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const path = url.pathname;
|
||||
try {
|
||||
if (req.method === 'GET' && path === '/health') return json(res, 200, { ok: true });
|
||||
if (req.method === 'GET' && path === '/api/v1/profile') return json(res, 200, db.prepare('SELECT id, name, initials, college, programme, semester, lecture_minutes AS lectureMinutes, recess_enabled AS recessEnabled, recess_start AS recessStart, recess_end AS recessEnd, weekend_schedule AS weekendSchedule FROM users WHERE id = ?').get(STUDENT_ID));
|
||||
if (req.method === 'PATCH' && path === '/api/v1/settings') {
|
||||
const body = await readBody(req); const minutes = Number(body.lectureMinutes); const recessEnabled = body.recessEnabled === false ? 0 : 1; const weekendSchedule = body.weekendSchedule ? 1 : 0;
|
||||
const validRecess = /^\d\d:\d\d$/.test(body.recessStart) && /^\d\d:\d\d$/.test(body.recessEnd) && (!recessEnabled || body.recessStart < body.recessEnd);
|
||||
if (!Number.isInteger(minutes) || minutes < 15 || minutes > 360 || !validRecess) return error(res, 400, 'Use a lecture length between 15 and 360 minutes and valid recess times');
|
||||
db.prepare('UPDATE users SET lecture_minutes = ?, recess_enabled = ?, recess_start = ?, recess_end = ?, weekend_schedule = ? WHERE id = ?').run(minutes, recessEnabled, body.recessStart, body.recessEnd, weekendSchedule, STUDENT_ID);
|
||||
return json(res, 200, { lectureMinutes: minutes, recessEnabled: Boolean(recessEnabled), recessStart: body.recessStart, recessEnd: body.recessEnd, weekendSchedule: Boolean(weekendSchedule) });
|
||||
}
|
||||
if (req.method === 'PUT' && path === '/api/v1/profile') {
|
||||
const body = await readBody(req);
|
||||
if (!body.name?.trim() || !body.college?.trim()) return error(res, 400, 'name and college are required');
|
||||
const initials = body.name.trim().split(/\s+/).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
||||
db.prepare('UPDATE users SET name = ?, initials = ?, college = ?, programme = ?, semester = ? WHERE id = ?').run(body.name.trim(), initials, body.college.trim(), body.programme?.trim() || '', body.semester?.trim() || '', STUDENT_ID);
|
||||
return json(res, 200, db.prepare('SELECT id, name, initials, college, programme, semester, lecture_minutes AS lectureMinutes, recess_enabled AS recessEnabled, recess_start AS recessStart, recess_end AS recessEnd, weekend_schedule AS weekendSchedule FROM users WHERE id = ?').get(STUDENT_ID));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && path === '/api/v1/schedule') {
|
||||
const date = url.searchParams.get('date');
|
||||
if (!dateIsValid(date)) return error(res, 400, 'date must be YYYY-MM-DD');
|
||||
ensureSessions(date);
|
||||
return json(res, 200, { date, sessions: sessionRows(date) });
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/schedule') {
|
||||
const body = await readBody(req);
|
||||
const { subjectId, date, startTime, endTime, room } = body;
|
||||
if (!getSubject(subjectId) || !dateIsValid(date) || !/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime) || !room?.trim()) return error(res, 400, 'subjectId, date, startTime, endTime and room are required');
|
||||
const record = db.prepare('INSERT INTO class_sessions (user_id, subject_id, class_date, start_time, end_time, room, is_override) VALUES (?, ?, ?, ?, ?, ?, 1)').run(STUDENT_ID, subjectId, date, startTime, endTime, room.trim());
|
||||
return json(res, 201, db.prepare('SELECT id FROM class_sessions WHERE id = ?').get(record.lastInsertRowid));
|
||||
}
|
||||
const scheduleMatch = path.match(/^\/api\/v1\/schedule\/(\d+)$/);
|
||||
if (req.method === 'DELETE' && scheduleMatch) {
|
||||
const id = Number(scheduleMatch[1]);
|
||||
const session = db.prepare('SELECT timetable_class_id AS timetableClassId FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
|
||||
if (!session) return error(res, 404, 'Class session not found');
|
||||
// Recurring classes are hidden only for this date, so the weekly rule remains intact.
|
||||
if (session.timetableClassId) db.prepare('UPDATE class_sessions SET is_removed = 1, is_override = 1 WHERE id = ?').run(id);
|
||||
else db.prepare('DELETE FROM class_sessions WHERE id = ? AND user_id = ?').run(id, STUDENT_ID);
|
||||
return json(res, 200, { id, deleted: true });
|
||||
}
|
||||
if (req.method === 'PATCH' && scheduleMatch) {
|
||||
const body = await readBody(req); const id = Number(scheduleMatch[1]);
|
||||
const existing = db.prepare('SELECT id FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
|
||||
if (!existing) return error(res, 404, 'Class session not found');
|
||||
const fields = []; const values = [];
|
||||
for (const [key, column] of Object.entries({ startTime: 'start_time', endTime: 'end_time', room: 'room' })) if (body[key] !== undefined) { fields.push(`${column} = ?`); values.push(typeof body[key] === 'string' ? body[key].trim() : body[key]); }
|
||||
if (!fields.length) return error(res, 400, 'No editable fields supplied');
|
||||
db.prepare(`UPDATE class_sessions SET ${fields.join(', ')}, is_override = 1 WHERE id = ?`).run(...values, id);
|
||||
return json(res, 200, { id });
|
||||
}
|
||||
const attendanceMatch = path.match(/^\/api\/v1\/schedule\/(\d+)\/attendance$/);
|
||||
if (req.method === 'PUT' && attendanceMatch) {
|
||||
const body = await readBody(req); const id = Number(attendanceMatch[1]);
|
||||
if (!validStatuses.has(body.status)) return error(res, 400, 'status must be pending, attended, absent, or cancelled');
|
||||
const session = db.prepare('SELECT id FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
|
||||
if (!session) return error(res, 404, 'Class session not found');
|
||||
db.prepare(`INSERT INTO attendance (session_id, status, marked_at, note) VALUES (?, ?, CURRENT_TIMESTAMP, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET status = excluded.status, marked_at = CURRENT_TIMESTAMP, note = excluded.note`).run(id, body.status, body.note?.trim() || null);
|
||||
return json(res, 200, { id, status: body.status });
|
||||
}
|
||||
if (req.method === 'GET' && path === '/api/v1/subjects') {
|
||||
return json(res, 200, db.prepare('SELECT id, name, code, short_name AS shortName, color, class_type AS classType, default_room AS defaultRoom FROM subjects WHERE user_id = ? ORDER BY name').all(STUDENT_ID));
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/subjects') {
|
||||
const body = await readBody(req);
|
||||
if (!body.name?.trim() || !body.code?.trim()) return error(res, 400, 'name and code are required');
|
||||
try {
|
||||
const result = db.prepare('INSERT INTO subjects (user_id, name, code, short_name, color, class_type, default_room) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, body.name.trim(), body.code.trim().toUpperCase(), body.shortName?.trim() || body.code.trim().toUpperCase(), body.color || '#0559FA', body.classType?.trim() || 'Lecture', body.defaultRoom?.trim() || '');
|
||||
return json(res, 201, { id: Number(result.lastInsertRowid) });
|
||||
} catch { return error(res, 409, 'A subject with that code already exists'); }
|
||||
}
|
||||
if (req.method === 'GET' && path === '/api/v1/timetable/classes') {
|
||||
const date = url.searchParams.get('date') || localDateKey();
|
||||
if (!dateIsValid(date)) return error(res, 400, 'date must be YYYY-MM-DD');
|
||||
const rows = db.prepare(`SELECT tc.id, tc.weekday, tc.start_time AS startTime, tc.end_time AS endTime, tc.room, tc.active,
|
||||
tc.effective_from AS effectiveFrom, tc.effective_to AS effectiveTo, s.id AS subjectId, s.name AS subjectName, s.code, s.color, s.class_type AS classType
|
||||
FROM timetable_classes tc JOIN subjects s ON s.id = tc.subject_id
|
||||
WHERE tc.user_id = ? AND tc.weekday = ? AND tc.active = 1 AND tc.effective_from <= ?
|
||||
AND (tc.effective_to IS NULL OR tc.effective_to >= ?) ORDER BY tc.start_time`).all(STUDENT_ID, weekdayFor(date), date, date);
|
||||
return json(res, 200, { date, classes: rows });
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/timetable/classes') {
|
||||
const body = await readBody(req);
|
||||
// A newly added recurring class must also begin in the future; past timetable history is immutable.
|
||||
const earliestFutureDate = nextDateKey(localDateKey());
|
||||
const effectiveFrom = body.effectiveFrom && dateIsValid(body.effectiveFrom) && body.effectiveFrom > localDateKey() ? body.effectiveFrom : earliestFutureDate;
|
||||
if (!getSubject(body.subjectId) || !Number.isInteger(body.weekday) || body.weekday < 0 || body.weekday > 6 || !dateIsValid(effectiveFrom) || !/^\d\d:\d\d$/.test(body.startTime) || !/^\d\d:\d\d$/.test(body.endTime) || !body.room?.trim()) return error(res, 400, 'subjectId, weekday, startTime, endTime, room and a valid effectiveFrom date are required');
|
||||
const result = db.prepare('INSERT INTO timetable_classes (user_id, subject_id, weekday, start_time, end_time, room, effective_from) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, body.subjectId, body.weekday, body.startTime, body.endTime, body.room.trim(), effectiveFrom);
|
||||
return json(res, 201, { id: Number(result.lastInsertRowid), effectiveFrom });
|
||||
}
|
||||
const timetableMatch = path.match(/^\/api\/v1\/timetable\/classes\/(\d+)$/);
|
||||
if (req.method === 'PATCH' && timetableMatch) {
|
||||
const body = await readBody(req); const id = Number(timetableMatch[1]);
|
||||
const existing = db.prepare('SELECT * FROM timetable_classes WHERE id = ? AND user_id = ? AND active = 1').get(id, STUDENT_ID);
|
||||
if (!existing) return error(res, 404, 'Timetable class not found');
|
||||
// A recurring timetable edit is versioned. It can only start tomorrow or later,
|
||||
// and past sessions are never updated or deleted.
|
||||
const earliestFutureDate = nextDateKey(localDateKey());
|
||||
const effectiveFrom = body.effectiveFrom && dateIsValid(body.effectiveFrom) && body.effectiveFrom > earliestFutureDate ? body.effectiveFrom : earliestFutureDate;
|
||||
const nextSubjectId = body.subjectId === undefined ? existing.subject_id : body.subjectId;
|
||||
const nextWeekday = body.weekday === undefined ? existing.weekday : body.weekday;
|
||||
const nextStart = body.startTime === undefined ? existing.start_time : body.startTime;
|
||||
const nextEnd = body.endTime === undefined ? existing.end_time : body.endTime;
|
||||
const nextRoom = body.room === undefined ? existing.room : body.room.trim();
|
||||
if (!getSubject(nextSubjectId) || !Number.isInteger(nextWeekday) || nextWeekday < 0 || nextWeekday > 6 || !/^\d\d:\d\d$/.test(nextStart) || !/^\d\d:\d\d$/.test(nextEnd) || !nextRoom) return error(res, 400, 'Invalid timetable change');
|
||||
db.prepare('UPDATE timetable_classes SET effective_to = ? WHERE id = ?').run(previousDateKey(effectiveFrom), id);
|
||||
// Remove only generated, unmodified FUTURE sessions. Historical sessions and daily overrides stay intact.
|
||||
db.prepare('DELETE FROM class_sessions WHERE timetable_class_id = ? AND class_date >= ? AND is_override = 0').run(id, effectiveFrom);
|
||||
const result = db.prepare('INSERT INTO timetable_classes (user_id, subject_id, weekday, start_time, end_time, room, effective_from) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, nextSubjectId, nextWeekday, nextStart, nextEnd, nextRoom, effectiveFrom);
|
||||
return json(res, 201, { id: Number(result.lastInsertRowid), replacesId: id, effectiveFrom });
|
||||
}
|
||||
const subjectMatch = path.match(/^\/api\/v1\/subjects\/(\d+)$/);
|
||||
if (req.method === 'GET' && subjectMatch) {
|
||||
const id = Number(subjectMatch[1]);
|
||||
const subject = db.prepare('SELECT id, name, code, short_name AS shortName, color, class_type AS classType, default_room AS defaultRoom FROM subjects WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
|
||||
if (!subject) return error(res, 404, 'Subject not found');
|
||||
const sessions = db.prepare(`SELECT cs.id, cs.class_date AS date, cs.start_time AS time, cs.end_time AS endTime, cs.room,
|
||||
COALESCE(a.status, 'pending') AS status FROM class_sessions cs LEFT JOIN attendance a ON a.session_id = cs.id
|
||||
WHERE cs.subject_id = ? AND cs.user_id = ? ORDER BY cs.class_date DESC, cs.start_time DESC`).all(id, STUDENT_ID);
|
||||
const eligible = sessions.filter((session) => session.status !== 'cancelled');
|
||||
const attended = eligible.filter((session) => session.status === 'attended').length;
|
||||
return json(res, 200, { ...subject, summary: { total: eligible.length, attended, absent: eligible.filter((session) => session.status === 'absent').length, percentage: eligible.length ? Math.round(attended / eligible.length * 100) : 0 }, sessions });
|
||||
}
|
||||
if (req.method === 'PATCH' && subjectMatch) {
|
||||
const id = Number(subjectMatch[1]); const body = await readBody(req);
|
||||
const existing = getSubject(id); if (!existing) return error(res, 404, 'Subject not found');
|
||||
if (!body.name?.trim() || !body.code?.trim()) return error(res, 400, 'name and code are required');
|
||||
try {
|
||||
db.prepare('UPDATE subjects SET name = ?, code = ?, short_name = ?, color = ?, class_type = ?, default_room = ? WHERE id = ? AND user_id = ?').run(body.name.trim(), body.code.trim().toUpperCase(), body.shortName?.trim() || body.code.trim().toUpperCase(), body.color || '#0559FA', body.classType?.trim() || 'Lecture', body.defaultRoom?.trim() || '', id, STUDENT_ID);
|
||||
return json(res, 200, { id });
|
||||
} catch { return error(res, 409, 'A subject with that code already exists'); }
|
||||
}
|
||||
if (req.method === 'GET' && path === '/api/v1/attendance/summary') {
|
||||
const rows = db.prepare(`SELECT s.id, s.name, s.code, s.color,
|
||||
COUNT(cs.id) AS total, SUM(CASE WHEN a.status = 'attended' THEN 1 ELSE 0 END) AS attended,
|
||||
SUM(CASE WHEN a.status = 'absent' THEN 1 ELSE 0 END) AS absent,
|
||||
SUM(CASE WHEN a.status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
|
||||
FROM subjects s LEFT JOIN class_sessions cs ON cs.subject_id = s.id LEFT JOIN attendance a ON a.session_id = cs.id
|
||||
WHERE s.user_id = ? GROUP BY s.id ORDER BY s.name`).all(STUDENT_ID);
|
||||
return json(res, 200, { subjects: rows.map(r => ({ ...r, total: Number(r.total) - Number(r.cancelled), percentage: Number(r.total) - Number(r.cancelled) ? Math.round(Number(r.attended) / (Number(r.total) - Number(r.cancelled)) * 100) : 0 })) });
|
||||
}
|
||||
return error(res, 404, 'Route not found');
|
||||
} catch (err) { console.error(err); return error(res, 400, err.message || 'Bad request'); }
|
||||
});
|
||||
server.listen(PORT, '0.0.0.0', () => console.log(`College Kit API listening on http://localhost:${PORT}`));
|
||||
Reference in New Issue
Block a user