Implement basic API structure

This commit is contained in:
2026-07-06 18:13:06 +02:00
parent 4aa20a9481
commit 845086d9a5
14 changed files with 2184 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
database.sqlite
.env
npm-debug.log*
+203 -1
View File
@@ -1 +1,203 @@
# linux_distro_roguelite_promotion # Linux Distro Roguelite Promotion API
REST-API fuer eine Pre-Register-Page eines kostenlosen Games. Spieler registrieren sich mit Benutzername und E-Mail, waehlen Plattformen aus und schalten ueber Community-Meilensteine Belohnungen frei.
## Installation
```bash
npm install
```
## Start
```bash
ADMIN_KEY=secret npm start
```
Unter PowerShell:
```powershell
$env:ADMIN_KEY = "secret"
npm start
```
Der Server laeuft standardmaessig auf Port `3000`. Mit `PORT` kann der Port ueberschrieben werden.
```powershell
$env:PORT = "4000"
$env:ADMIN_KEY = "secret"
npm start
```
Beim ersten Start wird `database.sqlite` erstellt und automatisch mit diesen Daten befuellt:
- Plattformen: PC, PlayStation, Xbox, Nintendo Switch, Mobile
- Meilensteine: 1,000 und 5,000 Community-Registrierungen
- Je eine Beispiel-Belohnung pro Meilenstein
Alle Fehler haben das Format:
```json
{ "error": "beschreibende Meldung" }
```
## Endpunkte
### Health Check
```bash
curl http://localhost:3000/
```
### Benutzer
`POST /users` erstellt einen neuen vorregistrierten Nutzer.
```bash
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d "{\"username\":\"tuxrunner\",\"email\":\"tux@example.com\",\"newsletter_optin\":true}"
```
`GET /users/{id}` ruft ein Nutzerprofil ab.
```bash
curl http://localhost:3000/users/1
```
`PUT /users/{id}` aktualisiert beliebige Teilfelder.
```bash
curl -X PUT http://localhost:3000/users/1 \
-H "Content-Type: application/json" \
-d "{\"username\":\"tuxrunner2\",\"newsletter_optin\":false}"
```
`DELETE /users/{id}` loescht den Nutzer und dessen Plattform-Registrierungen.
```bash
curl -X DELETE http://localhost:3000/users/1
```
### Plattformen
`GET /platforms` ruft alle Plattformen ab.
```bash
curl http://localhost:3000/platforms
```
`GET /platforms/{id}` ruft eine einzelne Plattform ab.
```bash
curl http://localhost:3000/platforms/1
```
### Registrierungen
`POST /registrations` registriert einen Nutzer fuer eine Plattform. Nach jedem erfolgreichen Request werden erreichte Meilensteine automatisch aktualisiert.
```bash
curl -X POST http://localhost:3000/registrations \
-H "Content-Type: application/json" \
-d "{\"user_id\":1,\"platform_id\":1}"
```
`GET /registrations/count` liefert die Gesamtzahl aller Plattform-Registrierungen.
```bash
curl http://localhost:3000/registrations/count
```
`GET /users/{id}/registrations` ruft alle Plattform-Registrierungen eines Nutzers inklusive Plattformname ab.
```bash
curl http://localhost:3000/users/1/registrations
```
`DELETE /registrations/{id}` loescht eine einzelne Plattform-Registrierung.
```bash
curl -X DELETE http://localhost:3000/registrations/1
```
### Meilensteine
`GET /milestones` ruft alle Meilensteine ab. Jeder Eintrag enthaelt `current_count` und `progress`, wobei `progress` bei 100 gedeckelt ist.
```bash
curl http://localhost:3000/milestones
```
`POST /milestones` erstellt einen Meilenstein. Admin-Key erforderlich.
```bash
curl -X POST http://localhost:3000/milestones \
-H "Content-Type: application/json" \
-H "X-Admin-Key: secret" \
-d "{\"title\":\"10,000 community registrations\",\"target_count\":10000}"
```
`PUT /milestones/{id}` aktualisiert beliebige Teilfelder. Admin-Key erforderlich.
```bash
curl -X PUT http://localhost:3000/milestones/1 \
-H "Content-Type: application/json" \
-H "X-Admin-Key: secret" \
-d "{\"title\":\"1,000 pre-registrations\",\"reached\":false}"
```
`DELETE /milestones/{id}` loescht einen Meilenstein und dessen Belohnungen. Admin-Key erforderlich.
```bash
curl -X DELETE http://localhost:3000/milestones/1 \
-H "X-Admin-Key: secret"
```
### Belohnungen
`GET /rewards` ruft alle Belohnungen ab.
```bash
curl http://localhost:3000/rewards
```
`GET /milestones/{id}/rewards` ruft Belohnungen eines Meilensteins ab.
```bash
curl http://localhost:3000/milestones/1/rewards
```
`POST /rewards` erstellt eine Belohnung. Admin-Key erforderlich.
```bash
curl -X POST http://localhost:3000/rewards \
-H "Content-Type: application/json" \
-H "X-Admin-Key: secret" \
-d "{\"milestone_id\":1,\"name\":\"Beta Hat\",\"description\":\"Cosmetic launch hat\",\"image_url\":\"https://example.com/rewards/beta-hat.png\"}"
```
`PUT /rewards/{id}` aktualisiert beliebige Teilfelder. Admin-Key erforderlich.
```bash
curl -X PUT http://localhost:3000/rewards/1 \
-H "Content-Type: application/json" \
-H "X-Admin-Key: secret" \
-d "{\"description\":\"Updated reward description\"}"
```
`DELETE /rewards/{id}` loescht eine Belohnung. Admin-Key erforderlich.
```bash
curl -X DELETE http://localhost:3000/rewards/1 \
-H "X-Admin-Key: secret"
```
## Statuscodes
- `200`: Erfolgreiche Abfrage, Aktualisierung oder Loeschung
- `201`: Ressource erstellt
- `400`: Ungueltige Eingaben oder nicht existierende Fremdschluessel
- `401`: Fehlender oder falscher Admin-Key
- `404`: Ressource oder Route nicht gefunden
- `409`: Konflikt, z. B. doppelte E-Mail, doppelter Benutzername oder doppelte Nutzer-Plattform-Kombination
+1282
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "linux-distro-roguelite-promotion",
"version": "1.0.0",
"description": "REST API for a free game pre-register page.",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
"express": "^4.21.2"
}
}
+45
View File
@@ -0,0 +1,45 @@
const express = require("express");
const cors = require("cors");
const { initializeDatabase } = require("./db");
const usersRouter = require("./routes/users");
const platformsRouter = require("./routes/platforms");
const registrationsRouter = require("./routes/registrations");
const milestonesRouter = require("./routes/milestones");
const rewardsRouter = require("./routes/rewards");
initializeDatabase();
const app = express();
app.use(cors());
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "Pre-registration API is running." });
});
app.use("/users", usersRouter);
app.use("/platforms", platformsRouter);
app.use("/registrations", registrationsRouter);
app.use("/milestones", milestonesRouter);
app.use("/rewards", rewardsRouter);
app.use((req, res) => {
res.status(404).json({ error: "Route not found." });
});
app.use((err, req, res, next) => {
if (err instanceof SyntaxError && err.status === 400 && "body" in err) {
return res.status(400).json({ error: "Invalid JSON body." });
}
console.error(err);
res.status(500).json({ error: "Internal server error." });
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Pre-registration API listening on port ${port}`);
});
+111
View File
@@ -0,0 +1,111 @@
const path = require("path");
const Database = require("better-sqlite3");
const dbPath = path.join(__dirname, "..", "database.sqlite");
const db = new Database(dbPath);
db.pragma("foreign_keys = ON");
function boolToInt(value) {
return value ? 1 : 0;
}
function createSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
newsletter_optin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
platform_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (platform_id) REFERENCES platforms(id),
UNIQUE (user_id, platform_id)
);
CREATE TABLE IF NOT EXISTS milestones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
target_count INTEGER NOT NULL,
reached INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS rewards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
milestone_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
image_url TEXT,
FOREIGN KEY (milestone_id) REFERENCES milestones(id) ON DELETE CASCADE
);
`);
}
function seedDatabase() {
const platforms = ["PC", "PlayStation", "Xbox", "Nintendo Switch", "Mobile"];
const insertPlatform = db.prepare("INSERT OR IGNORE INTO platforms (name) VALUES (?)");
platforms.forEach((platform) => insertPlatform.run(platform));
// Seed example milestones only when the table is empty to avoid duplicate demo data.
const milestoneCount = db.prepare("SELECT COUNT(*) AS count FROM milestones").get().count;
if (milestoneCount === 0) {
const insertMilestone = db.prepare(
"INSERT INTO milestones (title, target_count, reached) VALUES (?, ?, 0)"
);
const insertReward = db.prepare(
"INSERT INTO rewards (milestone_id, name, description, image_url) VALUES (?, ?, ?, ?)"
);
const first = insertMilestone.run("1,000 community registrations", 1000);
insertReward.run(
first.lastInsertRowid,
"Founder Banner",
"Exclusive in-game profile banner for early supporters.",
"https://example.com/rewards/founder-banner.png"
);
const second = insertMilestone.run("5,000 community registrations", 5000);
insertReward.run(
second.lastInsertRowid,
"Launch Loot Crate",
"Free cosmetic crate unlocked for all players at launch.",
"https://example.com/rewards/launch-loot-crate.png"
);
}
}
function initializeDatabase() {
createSchema();
seedDatabase();
}
function getRegistrationCount() {
return db.prepare("SELECT COUNT(*) AS count FROM registrations").get().count;
}
function updateReachedMilestones() {
const currentCount = getRegistrationCount();
db.prepare(
"UPDATE milestones SET reached = 1 WHERE reached = 0 AND target_count <= ?"
).run(currentCount);
}
module.exports = {
db,
initializeDatabase,
getRegistrationCount,
updateReachedMilestones,
boolToInt
};
+12
View File
@@ -0,0 +1,12 @@
function requireAdminKey(req, res, next) {
const expectedKey = process.env.ADMIN_KEY;
const providedKey = req.get("X-Admin-Key");
if (!expectedKey || providedKey !== expectedKey) {
return res.status(401).json({ error: "Missing or invalid admin key." });
}
next();
}
module.exports = requireAdminKey;
+112
View File
@@ -0,0 +1,112 @@
const express = require("express");
const { db, boolToInt, getRegistrationCount } = require("../db");
const requireAdminKey = require("../middleware/adminAuth");
const { sendError } = require("../utils/errors");
const {
isBoolean,
isNonEmptyString,
isPositiveInteger,
normalizeString,
parseId
} = require("../utils/validation");
const router = express.Router();
function serializeMilestone(milestone, currentCount) {
return {
...milestone,
reached: Boolean(milestone.reached),
current_count: currentCount,
progress: Math.min(100, Math.round((currentCount / milestone.target_count) * 100))
};
}
router.get("/", (req, res) => {
const currentCount = getRegistrationCount();
const milestones = db.prepare("SELECT * FROM milestones ORDER BY target_count, id").all();
return res.json(milestones.map((milestone) => serializeMilestone(milestone, currentCount)));
});
router.get("/:id/rewards", (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid milestone id.");
const milestone = db.prepare("SELECT id FROM milestones WHERE id = ?").get(id);
if (!milestone) return sendError(res, 404, "Milestone not found.");
const rewards = db.prepare("SELECT * FROM rewards WHERE milestone_id = ? ORDER BY id").all(id);
return res.json(rewards);
});
router.post("/", requireAdminKey, (req, res) => {
const title = normalizeString(req.body.title);
const targetCount = req.body.target_count;
if (!isNonEmptyString(title)) return sendError(res, 400, "title is required.");
if (!isPositiveInteger(targetCount)) {
return sendError(res, 400, "target_count must be a positive integer.");
}
const currentCount = getRegistrationCount();
const result = db
.prepare("INSERT INTO milestones (title, target_count, reached) VALUES (?, ?, ?)")
.run(title, targetCount, boolToInt(currentCount >= targetCount));
const milestone = db.prepare("SELECT * FROM milestones WHERE id = ?").get(result.lastInsertRowid);
return res.status(201).json(serializeMilestone(milestone, currentCount));
});
router.put("/:id", requireAdminKey, (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid milestone id.");
const existing = db.prepare("SELECT * FROM milestones WHERE id = ?").get(id);
if (!existing) return sendError(res, 404, "Milestone not found.");
const updates = [];
const values = [];
if (Object.prototype.hasOwnProperty.call(req.body, "title")) {
const title = normalizeString(req.body.title);
if (!isNonEmptyString(title)) return sendError(res, 400, "title must not be empty.");
updates.push("title = ?");
values.push(title);
}
if (Object.prototype.hasOwnProperty.call(req.body, "target_count")) {
if (!isPositiveInteger(req.body.target_count)) {
return sendError(res, 400, "target_count must be a positive integer.");
}
updates.push("target_count = ?");
values.push(req.body.target_count);
}
if (Object.prototype.hasOwnProperty.call(req.body, "reached")) {
if (!isBoolean(req.body.reached)) return sendError(res, 400, "reached must be a boolean.");
updates.push("reached = ?");
values.push(boolToInt(req.body.reached));
}
if (updates.length === 0) {
return sendError(res, 400, "At least one updatable field is required.");
}
values.push(id);
db.prepare(`UPDATE milestones SET ${updates.join(", ")} WHERE id = ?`).run(...values);
const currentCount = getRegistrationCount();
const milestone = db.prepare("SELECT * FROM milestones WHERE id = ?").get(id);
return res.json(serializeMilestone(milestone, currentCount));
});
router.delete("/:id", requireAdminKey, (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid milestone id.");
const result = db.prepare("DELETE FROM milestones WHERE id = ?").run(id);
if (result.changes === 0) return sendError(res, 404, "Milestone not found.");
return res.json({ message: "Milestone deleted." });
});
module.exports = router;
+23
View File
@@ -0,0 +1,23 @@
const express = require("express");
const { db } = require("../db");
const { sendError } = require("../utils/errors");
const { parseId } = require("../utils/validation");
const router = express.Router();
router.get("/", (req, res) => {
const platforms = db.prepare("SELECT * FROM platforms ORDER BY id").all();
return res.json(platforms);
});
router.get("/:id", (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid platform id.");
const platform = db.prepare("SELECT * FROM platforms WHERE id = ?").get(id);
if (!platform) return sendError(res, 404, "Platform not found.");
return res.json(platform);
});
module.exports = router;
+54
View File
@@ -0,0 +1,54 @@
const express = require("express");
const { db, getRegistrationCount, updateReachedMilestones } = require("../db");
const { sendError, mapSqliteError } = require("../utils/errors");
const { isPositiveInteger, parseId } = require("../utils/validation");
const router = express.Router();
router.post("/", (req, res) => {
const userId = req.body.user_id;
const platformId = req.body.platform_id;
if (!isPositiveInteger(userId)) return sendError(res, 400, "user_id is required.");
if (!isPositiveInteger(platformId)) return sendError(res, 400, "platform_id is required.");
const user = db.prepare("SELECT id FROM users WHERE id = ?").get(userId);
if (!user) return sendError(res, 400, "User does not exist.");
const platform = db.prepare("SELECT id FROM platforms WHERE id = ?").get(platformId);
if (!platform) return sendError(res, 400, "Platform does not exist.");
try {
const result = db
.prepare("INSERT INTO registrations (user_id, platform_id) VALUES (?, ?)")
.run(userId, platformId);
// Every successful registration can unlock one or more community milestones.
updateReachedMilestones();
const registration = db
.prepare("SELECT * FROM registrations WHERE id = ?")
.get(result.lastInsertRowid);
return res.status(201).json(registration);
} catch (error) {
const mapped = mapSqliteError(error, "User is already registered for this platform.");
if (mapped) return sendError(res, mapped.status, mapped.message);
throw error;
}
});
router.get("/count", (req, res) => {
return res.json({ count: getRegistrationCount() });
});
router.delete("/:id", (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid registration id.");
const result = db.prepare("DELETE FROM registrations WHERE id = ?").run(id);
if (result.changes === 0) return sendError(res, 404, "Registration not found.");
return res.json({ message: "Registration deleted." });
});
module.exports = router;
+128
View File
@@ -0,0 +1,128 @@
const express = require("express");
const { db } = require("../db");
const requireAdminKey = require("../middleware/adminAuth");
const { sendError, mapSqliteError } = require("../utils/errors");
const { isNonEmptyString, isPositiveInteger, normalizeString, parseId } = require("../utils/validation");
const router = express.Router();
function validateOptionalString(value, fieldName, res) {
if (value !== null && value !== undefined && typeof value !== "string") {
sendError(res, 400, `${fieldName} must be a string or null.`);
return false;
}
return true;
}
router.get("/", (req, res) => {
const rewards = db.prepare("SELECT * FROM rewards ORDER BY id").all();
return res.json(rewards);
});
router.get("/milestone/:milestoneId", (req, res) => {
const milestoneId = parseId(req.params.milestoneId);
if (!milestoneId) return sendError(res, 400, "Invalid milestone id.");
const milestone = db.prepare("SELECT id FROM milestones WHERE id = ?").get(milestoneId);
if (!milestone) return sendError(res, 404, "Milestone not found.");
const rewards = db
.prepare("SELECT * FROM rewards WHERE milestone_id = ? ORDER BY id")
.all(milestoneId);
return res.json(rewards);
});
router.post("/", requireAdminKey, (req, res) => {
const name = normalizeString(req.body.name);
const description = req.body.description == null ? null : normalizeString(req.body.description);
const imageUrl = req.body.image_url == null ? null : normalizeString(req.body.image_url);
const milestoneId = req.body.milestone_id;
if (!isNonEmptyString(name)) return sendError(res, 400, "name is required.");
if (!validateOptionalString(description, "description", res)) return undefined;
if (!validateOptionalString(imageUrl, "image_url", res)) return undefined;
if (!isPositiveInteger(milestoneId)) {
return sendError(res, 400, "milestone_id must be a positive integer.");
}
const milestone = db.prepare("SELECT id FROM milestones WHERE id = ?").get(milestoneId);
if (!milestone) return sendError(res, 400, "Milestone does not exist.");
try {
const result = db
.prepare(
"INSERT INTO rewards (milestone_id, name, description, image_url) VALUES (?, ?, ?, ?)"
)
.run(milestoneId, name, description, imageUrl);
const reward = db.prepare("SELECT * FROM rewards WHERE id = ?").get(result.lastInsertRowid);
return res.status(201).json(reward);
} catch (error) {
const mapped = mapSqliteError(error, "Reward conflict.");
if (mapped) return sendError(res, mapped.status, mapped.message);
throw error;
}
});
router.put("/:id", requireAdminKey, (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid reward id.");
const existing = db.prepare("SELECT * FROM rewards WHERE id = ?").get(id);
if (!existing) return sendError(res, 404, "Reward not found.");
const updates = [];
const values = [];
if (Object.prototype.hasOwnProperty.call(req.body, "name")) {
const name = normalizeString(req.body.name);
if (!isNonEmptyString(name)) return sendError(res, 400, "name must not be empty.");
updates.push("name = ?");
values.push(name);
}
if (Object.prototype.hasOwnProperty.call(req.body, "description")) {
const description = req.body.description == null ? null : normalizeString(req.body.description);
if (!validateOptionalString(description, "description", res)) return undefined;
updates.push("description = ?");
values.push(description);
}
if (Object.prototype.hasOwnProperty.call(req.body, "image_url")) {
const imageUrl = req.body.image_url == null ? null : normalizeString(req.body.image_url);
if (!validateOptionalString(imageUrl, "image_url", res)) return undefined;
updates.push("image_url = ?");
values.push(imageUrl);
}
if (Object.prototype.hasOwnProperty.call(req.body, "milestone_id")) {
if (!isPositiveInteger(req.body.milestone_id)) {
return sendError(res, 400, "milestone_id must be a positive integer.");
}
const milestone = db.prepare("SELECT id FROM milestones WHERE id = ?").get(req.body.milestone_id);
if (!milestone) return sendError(res, 400, "Milestone does not exist.");
updates.push("milestone_id = ?");
values.push(req.body.milestone_id);
}
if (updates.length === 0) {
return sendError(res, 400, "At least one updatable field is required.");
}
values.push(id);
db.prepare(`UPDATE rewards SET ${updates.join(", ")} WHERE id = ?`).run(...values);
const reward = db.prepare("SELECT * FROM rewards WHERE id = ?").get(id);
return res.json(reward);
});
router.delete("/:id", requireAdminKey, (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid reward id.");
const result = db.prepare("DELETE FROM rewards WHERE id = ?").run(id);
if (result.changes === 0) return sendError(res, 404, "Reward not found.");
return res.json({ message: "Reward deleted." });
});
module.exports = router;
+140
View File
@@ -0,0 +1,140 @@
const express = require("express");
const { db, boolToInt } = require("../db");
const { sendError, mapSqliteError } = require("../utils/errors");
const {
isBoolean,
isNonEmptyString,
isValidEmail,
normalizeString,
parseId
} = require("../utils/validation");
const router = express.Router();
function serializeUser(user) {
return {
...user,
newsletter_optin: Boolean(user.newsletter_optin)
};
}
router.post("/", (req, res) => {
const username = normalizeString(req.body.username);
const email = normalizeString(req.body.email);
const newsletterOptin = req.body.newsletter_optin ?? false;
if (!isNonEmptyString(username)) {
return sendError(res, 400, "username is required.");
}
if (!isValidEmail(email)) {
return sendError(res, 400, "A valid email is required.");
}
if (!isBoolean(newsletterOptin)) {
return sendError(res, 400, "newsletter_optin must be a boolean.");
}
try {
const result = db
.prepare("INSERT INTO users (username, email, newsletter_optin) VALUES (?, ?, ?)")
.run(username, email, boolToInt(newsletterOptin));
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid);
return res.status(201).json(serializeUser(user));
} catch (error) {
const mapped = mapSqliteError(error, "username or email already exists.");
if (mapped) return sendError(res, mapped.status, mapped.message);
throw error;
}
});
router.get("/:id", (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid user id.");
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
if (!user) return sendError(res, 404, "User not found.");
return res.json(serializeUser(user));
});
router.put("/:id", (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid user id.");
const existing = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
if (!existing) return sendError(res, 404, "User not found.");
const updates = [];
const values = [];
if (Object.prototype.hasOwnProperty.call(req.body, "username")) {
const username = normalizeString(req.body.username);
if (!isNonEmptyString(username)) return sendError(res, 400, "username must not be empty.");
updates.push("username = ?");
values.push(username);
}
if (Object.prototype.hasOwnProperty.call(req.body, "email")) {
const email = normalizeString(req.body.email);
if (!isValidEmail(email)) return sendError(res, 400, "A valid email is required.");
updates.push("email = ?");
values.push(email);
}
if (Object.prototype.hasOwnProperty.call(req.body, "newsletter_optin")) {
if (!isBoolean(req.body.newsletter_optin)) {
return sendError(res, 400, "newsletter_optin must be a boolean.");
}
updates.push("newsletter_optin = ?");
values.push(boolToInt(req.body.newsletter_optin));
}
if (updates.length === 0) {
return sendError(res, 400, "At least one updatable field is required.");
}
try {
values.push(id);
db.prepare(`UPDATE users SET ${updates.join(", ")} WHERE id = ?`).run(...values);
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
return res.json(serializeUser(user));
} catch (error) {
const mapped = mapSqliteError(error, "username or email already exists.");
if (mapped) return sendError(res, mapped.status, mapped.message);
throw error;
}
});
router.delete("/:id", (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid user id.");
const result = db.prepare("DELETE FROM users WHERE id = ?").run(id);
if (result.changes === 0) return sendError(res, 404, "User not found.");
return res.json({ message: "User deleted." });
});
router.get("/:id/registrations", (req, res) => {
const id = parseId(req.params.id);
if (!id) return sendError(res, 400, "Invalid user id.");
const user = db.prepare("SELECT id FROM users WHERE id = ?").get(id);
if (!user) return sendError(res, 404, "User not found.");
const registrations = db
.prepare(
`SELECT registrations.id, registrations.user_id, registrations.platform_id,
platforms.name AS platform_name, registrations.created_at
FROM registrations
JOIN platforms ON platforms.id = registrations.platform_id
WHERE registrations.user_id = ?
ORDER BY registrations.id`
)
.all(id);
return res.json(registrations);
});
module.exports = router;
+20
View File
@@ -0,0 +1,20 @@
function sendError(res, status, message) {
return res.status(status).json({ error: message });
}
function mapSqliteError(error, conflictMessage) {
if (error && error.code === "SQLITE_CONSTRAINT_UNIQUE") {
return { status: 409, message: conflictMessage };
}
if (error && error.code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
return { status: 400, message: "Referenced resource does not exist." };
}
return null;
}
module.exports = {
sendError,
mapSqliteError
};
+35
View File
@@ -0,0 +1,35 @@
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}
function isValidEmail(value) {
return isNonEmptyString(value) && EMAIL_PATTERN.test(value.trim());
}
function isBoolean(value) {
return typeof value === "boolean";
}
function isPositiveInteger(value) {
return Number.isInteger(value) && value > 0;
}
function parseId(value) {
const id = Number(value);
return Number.isInteger(id) && id > 0 ? id : null;
}
function normalizeString(value) {
return typeof value === "string" ? value.trim() : value;
}
module.exports = {
isNonEmptyString,
isValidEmail,
isBoolean,
isPositiveInteger,
parseId,
normalizeString
};