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