Implement basic API structure
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
database.sqlite
|
||||
.env
|
||||
npm-debug.log*
|
||||
@@ -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
|
||||
|
||||
Generated
+1282
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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}`);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user