first commit
This commit is contained in:
commit
0e1ce93def
12 files changed
+1693
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
db/tachyon.db
|
||||||
|
db/tachyon.db-*
|
||||||
|
.env
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# tachyon-api
|
||||||
|
|
||||||
|
Minimal Express + SQLite API to back the TACHYON site's dynamic sections
|
||||||
|
(News, Media, and the Overview/Statistics panel — current users, recent
|
||||||
|
activity, general stats).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Express** — HTTP layer
|
||||||
|
- **better-sqlite3** — synchronous SQLite driver, no separate DB process to run
|
||||||
|
- Plain SQL (no ORM) — the schema is small enough that raw `.prepare()` calls
|
||||||
|
stay readable; swap in Drizzle/Prisma later if it grows
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run db:init # creates db/tachyon.db and applies schema.sql
|
||||||
|
npm run db:seed # (optional) fills it with placeholder rows matching the mockup
|
||||||
|
npm run dev # starts on http://localhost:3000, restarts on file changes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|---------------------------|-------------------------------------------------|
|
||||||
|
| GET | `/api/health` | Liveness check |
|
||||||
|
| GET | `/api/news?limit=20` | List news posts, newest first |
|
||||||
|
| POST | `/api/news` | Create a post — `{ title, content }` |
|
||||||
|
| DELETE | `/api/news/:id` | Delete a post |
|
||||||
|
| GET | `/api/media?limit=24` | List media items, newest first |
|
||||||
|
| POST | `/api/media` | Add media — `{ title, media_type, url }` |
|
||||||
|
| DELETE | `/api/media/:id` | Delete a media item |
|
||||||
|
| GET | `/api/stats/overview` | Bundled currentUsers + recentActivity + general |
|
||||||
|
| GET | `/api/stats/users` | Just the current-users list |
|
||||||
|
| POST | `/api/stats/session/start`| `{ username }` — opens a session (marks online) |
|
||||||
|
| POST | `/api/stats/session/end` | `{ sessionId }` — closes a session |
|
||||||
|
|
||||||
|
`/api/stats/overview` is the one the frontend's Overview panel would call —
|
||||||
|
it returns everything needed for the three stat columns in one round trip:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"currentUsers": [{ "id": 1, "username": "...", "started_at": "..." }],
|
||||||
|
"recentActivity": [{ "id": 1, "description": "...", "created_at": "..." }],
|
||||||
|
"general": {
|
||||||
|
"users": { "total": 3, "mostRecent": { "username": "...", "id": 3 } },
|
||||||
|
"reports": { "named": "00", "associated": "00", "logged": "00" },
|
||||||
|
"build": { "pipeline": "Completed", "buildNumber": "00000000", "version": "0.0.0" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema notes
|
||||||
|
|
||||||
|
- **"Current users"** isn't a boolean flag on `users` — it's derived from
|
||||||
|
`sessions` rows with `ended_at IS NULL`. That way "who's online right now"
|
||||||
|
falls naturally out of a query instead of needing a background job to flip
|
||||||
|
a flag, and you get session history for free.
|
||||||
|
- **`site_stats`** is a plain key/value table for the "General Statistics"
|
||||||
|
cards (build number, version, report counts) since those are free-form and
|
||||||
|
don't need their own table each.
|
||||||
|
- Timestamps are ISO-8601 `TEXT`, so `ORDER BY created_at DESC` sorts
|
||||||
|
correctly without needing SQLite's more limited native datetime type.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
// Creates tachyon.db (if it doesn't exist) and applies schema.sql.
|
||||||
|
// Safe to re-run: every statement uses CREATE TABLE IF NOT EXISTS.
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dbPath = path.join(__dirname, 'tachyon.db');
|
||||||
|
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
const schema = readFileSync(schemaPath, 'utf8');
|
||||||
|
db.exec(schema);
|
||||||
|
|
||||||
|
console.log(`Initialized database at ${dbPath}`);
|
||||||
|
db.close();
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- TACHYON site schema
|
||||||
|
-- SQLite. Timestamps stored as ISO-8601 strings (TEXT), sorted lexically = chronologically.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS news_posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
media_type TEXT NOT NULL CHECK (media_type IN ('image', 'video')),
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Registered/tracked users (distinct from "who is currently online" -- see sessions below)
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_seen_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per active session; presence of a row (or non-null ended_at) drives "current users"
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
ended_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Freeform key/value store for the "General Statistics" cards (build number, version, etc.)
|
||||||
|
CREATE TABLE IF NOT EXISTS site_stats (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_news_created_at ON news_posts(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_created_at ON activity_log(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(ended_at);
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
// Populates the DB with placeholder rows so the API has something to return
|
||||||
|
// immediately. Re-runnable: clears each table before inserting.
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const db = new Database(path.join(__dirname, 'tachyon.db'));
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
DELETE FROM sessions;
|
||||||
|
DELETE FROM users;
|
||||||
|
DELETE FROM news_posts;
|
||||||
|
DELETE FROM media_items;
|
||||||
|
DELETE FROM activity_log;
|
||||||
|
DELETE FROM site_stats;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertNews = db.prepare(
|
||||||
|
'INSERT INTO news_posts (title, content, created_at) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
insertNews.run('Placeholder announcement title', 'Short placeholder line describing an update or change.', '2026-07-06T12:00:00.000Z');
|
||||||
|
insertNews.run('Placeholder announcement title', 'Short placeholder line describing an update or change.', '2026-07-02T12:00:00.000Z');
|
||||||
|
insertNews.run('Placeholder announcement title', 'Short placeholder line describing an update or change.', '2026-06-27T12:00:00.000Z');
|
||||||
|
|
||||||
|
const insertMedia = db.prepare(
|
||||||
|
'INSERT INTO media_items (title, media_type, url) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
insertMedia.run('placeholder clip one', 'image', 'https://example.com/media/1.png');
|
||||||
|
insertMedia.run('placeholder clip two', 'image', 'https://example.com/media/2.png');
|
||||||
|
insertMedia.run('placeholder clip three', 'video', 'https://example.com/media/3.mp4');
|
||||||
|
insertMedia.run('placeholder clip four', 'image', 'https://example.com/media/4.png');
|
||||||
|
|
||||||
|
const insertUser = db.prepare(
|
||||||
|
'INSERT INTO users (username, joined_at, last_seen_at) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
const u1 = insertUser.run('placeholder_user_01', '2025-01-10T00:00:00.000Z', '2026-07-08T18:00:00.000Z');
|
||||||
|
const u2 = insertUser.run('placeholder_user_02', '2025-03-22T00:00:00.000Z', '2026-07-08T17:45:00.000Z');
|
||||||
|
const u3 = insertUser.run('placeholder_user_03', '2025-05-01T00:00:00.000Z', '2026-07-08T17:30:00.000Z');
|
||||||
|
|
||||||
|
const insertSession = db.prepare(
|
||||||
|
'INSERT INTO sessions (user_id, started_at) VALUES (?, ?)'
|
||||||
|
);
|
||||||
|
// open sessions (no ended_at) = "currently online" for the Current Users column
|
||||||
|
insertSession.run(u1.lastInsertRowid, '2026-07-08T18:00:00.000Z');
|
||||||
|
insertSession.run(u2.lastInsertRowid, '2026-07-08T17:45:00.000Z');
|
||||||
|
insertSession.run(u3.lastInsertRowid, '2026-07-08T17:30:00.000Z');
|
||||||
|
|
||||||
|
const insertActivity = db.prepare(
|
||||||
|
'INSERT INTO activity_log (description, created_at) VALUES (?, ?)'
|
||||||
|
);
|
||||||
|
insertActivity.run('placeholder event one', '2026-07-08T17:00:00.000Z');
|
||||||
|
insertActivity.run('placeholder event two', '2026-07-08T15:00:00.000Z');
|
||||||
|
insertActivity.run('placeholder event three', '2026-07-08T13:00:00.000Z');
|
||||||
|
insertActivity.run('placeholder event four', '2026-07-08T09:00:00.000Z');
|
||||||
|
|
||||||
|
const upsertStat = db.prepare(
|
||||||
|
'INSERT INTO site_stats (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = strftime(\'%Y-%m-%dT%H:%M:%fZ\', \'now\')'
|
||||||
|
);
|
||||||
|
upsertStat.run('build_pipeline', 'Completed');
|
||||||
|
upsertStat.run('build_number', '00000000');
|
||||||
|
upsertStat.run('version', '0.0.0');
|
||||||
|
upsertStat.run('reports_named', '00');
|
||||||
|
upsertStat.run('reports_associated', '00');
|
||||||
|
upsertStat.run('reports_logged', '00');
|
||||||
|
|
||||||
|
console.log('Seeded placeholder data.');
|
||||||
|
db.close();
|
||||||
Generated
+1279
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "tachyon-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Minimal Express + SQLite API backing the TACHYON site (news, media, stats/users)",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"db:init": "node db/init.js",
|
||||||
|
"db:seed": "node db/seed.js",
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dbPath = path.join(__dirname, '..', 'db', 'tachyon.db');
|
||||||
|
|
||||||
|
export const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { newsRouter } from './routes/news.js';
|
||||||
|
import { mediaRouter } from './routes/media.js';
|
||||||
|
import { statsRouter } from './routes/stats.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use('/api/news', newsRouter);
|
||||||
|
app.use('/api/media', mediaRouter);
|
||||||
|
app.use('/api/stats', statsRouter);
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`tachyon-api listening on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { db } from '../db.js';
|
||||||
|
|
||||||
|
export const mediaRouter = Router();
|
||||||
|
|
||||||
|
// GET /api/media?limit=12
|
||||||
|
mediaRouter.get('/', (req, res) => {
|
||||||
|
const limit = Math.min(Number(req.query.limit) || 24, 100);
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT id, title, media_type, url, created_at FROM media_items ORDER BY created_at DESC LIMIT ?')
|
||||||
|
.all(limit);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/media { title, media_type: 'image' | 'video', url }
|
||||||
|
mediaRouter.post('/', (req, res) => {
|
||||||
|
const { title, media_type, url } = req.body ?? {};
|
||||||
|
if (!title || !url || !['image', 'video'].includes(media_type)) {
|
||||||
|
return res.status(400).json({ error: 'title, url, and media_type (image|video) are required' });
|
||||||
|
}
|
||||||
|
const result = db
|
||||||
|
.prepare('INSERT INTO media_items (title, media_type, url) VALUES (?, ?, ?)')
|
||||||
|
.run(title, media_type, url);
|
||||||
|
const created = db
|
||||||
|
.prepare('SELECT id, title, media_type, url, created_at FROM media_items WHERE id = ?')
|
||||||
|
.get(result.lastInsertRowid);
|
||||||
|
res.status(201).json(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaRouter.delete('/:id', (req, res) => {
|
||||||
|
const result = db.prepare('DELETE FROM media_items WHERE id = ?').run(req.params.id);
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { db } from '../db.js';
|
||||||
|
|
||||||
|
export const newsRouter = Router();
|
||||||
|
|
||||||
|
// GET /api/news?limit=10
|
||||||
|
newsRouter.get('/', (req, res) => {
|
||||||
|
const limit = Math.min(Number(req.query.limit) || 20, 100);
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT id, title, content, created_at FROM news_posts ORDER BY created_at DESC LIMIT ?')
|
||||||
|
.all(limit);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/news { title, content }
|
||||||
|
newsRouter.post('/', (req, res) => {
|
||||||
|
const { title, content } = req.body ?? {};
|
||||||
|
if (!title || !content) {
|
||||||
|
return res.status(400).json({ error: 'title and content are required' });
|
||||||
|
}
|
||||||
|
const result = db
|
||||||
|
.prepare('INSERT INTO news_posts (title, content) VALUES (?, ?)')
|
||||||
|
.run(title, content);
|
||||||
|
const created = db
|
||||||
|
.prepare('SELECT id, title, content, created_at FROM news_posts WHERE id = ?')
|
||||||
|
.get(result.lastInsertRowid);
|
||||||
|
res.status(201).json(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/news/:id
|
||||||
|
newsRouter.delete('/:id', (req, res) => {
|
||||||
|
const result = db.prepare('DELETE FROM news_posts WHERE id = ?').run(req.params.id);
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { db } from '../db.js';
|
||||||
|
|
||||||
|
export const statsRouter = Router();
|
||||||
|
|
||||||
|
// GET /api/stats/overview
|
||||||
|
// Bundles everything the "Overview" panel needs in one call, since the
|
||||||
|
// frontend renders all three columns (users / activity / general) together.
|
||||||
|
statsRouter.get('/overview', (req, res) => {
|
||||||
|
const currentUsers = db
|
||||||
|
.prepare(`
|
||||||
|
SELECT u.id, u.username, s.started_at
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.ended_at IS NULL
|
||||||
|
ORDER BY s.started_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const recentActivity = db
|
||||||
|
.prepare('SELECT id, description, created_at FROM activity_log ORDER BY created_at DESC LIMIT 20')
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const statRows = db.prepare('SELECT key, value FROM site_stats').all();
|
||||||
|
const stats = Object.fromEntries(statRows.map((r) => [r.key, r.value]));
|
||||||
|
|
||||||
|
const totalUsers = db.prepare('SELECT COUNT(*) AS count FROM users').get().count;
|
||||||
|
const mostRecentUser = db
|
||||||
|
.prepare('SELECT username, id FROM users ORDER BY joined_at DESC LIMIT 1')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
currentUsers,
|
||||||
|
recentActivity,
|
||||||
|
general: {
|
||||||
|
users: { total: totalUsers, mostRecent: mostRecentUser ?? null },
|
||||||
|
reports: {
|
||||||
|
named: stats.reports_named ?? '0',
|
||||||
|
associated: stats.reports_associated ?? '0',
|
||||||
|
logged: stats.reports_logged ?? '0',
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
pipeline: stats.build_pipeline ?? 'unknown',
|
||||||
|
buildNumber: stats.build_number ?? '0',
|
||||||
|
version: stats.version ?? '0.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/stats/users (just the current-users list, if you want it standalone)
|
||||||
|
statsRouter.get('/users', (req, res) => {
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
SELECT u.id, u.username, s.started_at
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.ended_at IS NULL
|
||||||
|
ORDER BY s.started_at DESC
|
||||||
|
`)
|
||||||
|
.all();
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/stats/session/start { username }
|
||||||
|
statsRouter.post('/session/start', (req, res) => {
|
||||||
|
const { username } = req.body ?? {};
|
||||||
|
if (!username) return res.status(400).json({ error: 'username is required' });
|
||||||
|
|
||||||
|
let user = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||||
|
if (!user) {
|
||||||
|
const result = db.prepare('INSERT INTO users (username) VALUES (?)').run(username);
|
||||||
|
user = { id: result.lastInsertRowid };
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE users SET last_seen_at = strftime(\'%Y-%m-%dT%H:%M:%fZ\', \'now\') WHERE id = ?').run(user.id);
|
||||||
|
const session = db.prepare('INSERT INTO sessions (user_id) VALUES (?)').run(user.id);
|
||||||
|
res.status(201).json({ sessionId: session.lastInsertRowid, userId: user.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/stats/session/end { sessionId }
|
||||||
|
statsRouter.post('/session/end', (req, res) => {
|
||||||
|
const { sessionId } = req.body ?? {};
|
||||||
|
if (!sessionId) return res.status(400).json({ error: 'sessionId is required' });
|
||||||
|
const result = db
|
||||||
|
.prepare('UPDATE sessions SET ended_at = strftime(\'%Y-%m-%dT%H:%M:%fZ\', \'now\') WHERE id = ? AND ended_at IS NULL')
|
||||||
|
.run(sessionId);
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'no active session with that id' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user