Add REST-compliant PUT/DELETE endpoints and invitation validation

- Replace POST /invitation/:id/accept|decline with PUT /invitation/:id
- Add DELETE /invitation/:id (full CRUD: GET, POST, PUT, DELETE)
- Validate invitation recipient exists before creating invitation (404)
- Block self-invitation (400)
- Propagate server error messages to inline card feedback
- Add Loeschen button to invitation cards (demonstrates DELETE in UI)
- Improve empty state with icon and descriptive text
This commit is contained in:
Nandrx 2026-05-31 17:52:25 +02:00
parent fdffc08417
commit ae3692697b
3 changed files with 324 additions and 165 deletions

View File

@ -1,114 +1,205 @@
/**
* eventCard.js Erstellt interaktive Event-Karten als DOM-Elemente.
* Jede Karte zeigt Titel, Datum, Veranstaltungsort sowie Speichern- und Einladen-Buttons.
*/
/**
* Erstellt eine vollständige Event-Karte als <article>-Element.
* @param {Object} event - Event-Objekt mit id, name, date, time, venue, category
* @returns {HTMLElement} Fertig aufgebautes Karten-Element
*/
export function createEventCard(event) {
const article = document.createElement("article");
article.className = "event-card";
// --- TITEL ---
const title = document.createElement("h3");
title.className = "event-card__title";
title.textContent = event.name;
// --- DATUM & UHRZEIT formatieren (Schweizer Format: dd.mm.yyyy) ---
const formattedDate = event.date
? new Date(event.date).toLocaleDateString("de-CH", {
day: "2-digit",
month: "2-digit",
year: "numeric"
})
: "Date not available";
: "Datum unbekannt";
const formattedTime = event.time
? event.time.slice(0, 5)
: "";
const dateTime = formattedTime
? `${formattedDate}, ${formattedTime}`
: formattedDate;
const formattedTime = event.time ? event.time.slice(0, 5) : "";
const dateTime = formattedTime ? `${formattedDate}, ${formattedTime}` : formattedDate;
const date = document.createElement("p");
date.className = "event-card__date";
date.textContent = dateTime;
// --- VERANSTALTUNGSORT ---
const venue = document.createElement("p");
venue.className = "event-card__venue";
venue.textContent = event.venue;
venue.textContent = event.venue || "Ort unbekannt";
article.append(title, date, venue);
// =========================
// BUTTON CONTAINER
// =========================
// --- BUTTON-GRUPPE ---
const buttonContainer = document.createElement("div");
buttonContainer.className = "d-flex gap-2 mt-2";
buttonContainer.className = "d-flex gap-2 mt-2 flex-wrap";
// =========================
// SAVE BUTTON
// SPEICHERN-BUTTON
// Speichert das Event im localStorage, verhindert Duplikate
// =========================
const saveBtn = document.createElement("button");
saveBtn.textContent = "Save";
saveBtn.type = "button";
saveBtn.className = "btn btn-outline-primary btn-sm";
// Prüfen ob bereits gespeichert → Button-Text entsprechend setzen
const alreadySaved = isAlreadySaved(event.id);
saveBtn.textContent = alreadySaved ? "Gespeichert ✓" : "Speichern";
if (alreadySaved) saveBtn.disabled = true;
saveBtn.addEventListener("click", () => {
if (!window.currentUser) {
alert("Please login to save events");
return;
}
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
// prevent duplicates
// Duplikat-Schutz
if (saved.find(e => e.id === event.id)) {
alert("Event already saved");
saveBtn.textContent = "Gespeichert ✓";
saveBtn.disabled = true;
return;
}
saved.push(event);
localStorage.setItem("savedEvents", JSON.stringify(saved));
alert("Event saved!");
// Visuelles Feedback statt alert()
saveBtn.textContent = "Gespeichert ✓";
saveBtn.disabled = true;
saveBtn.classList.replace("btn-outline-primary", "btn-success");
});
saveBtn.disabled = !window.currentUser;
// =========================
// INVITE BUTTON
// EINLADEN-BUTTON
// Zeigt ein Inline-Formular an (kein browser-prompt())
// =========================
const inviteBtn = document.createElement("button");
inviteBtn.textContent = "Invite";
inviteBtn.type = "button";
inviteBtn.textContent = "Einladen";
inviteBtn.className = "btn btn-primary btn-sm";
inviteBtn.disabled = false;
inviteBtn.addEventListener("click", async () => {
inviteBtn.addEventListener("click", () => {
if (!window.currentUser) {
alert("Please login to invite users");
showCardMessage(article, "Bitte einloggen um Einladungen zu senden.", "warning");
return;
}
const toUser = prompt("Enter username to invite:");
if (!toUser) return;
await fetch("http://localhost:3000/api/invitation", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Username": window.currentUser
},
body: JSON.stringify({
toUser,
eventId: event.id,
eventName: event.name
})
});
alert("Invitation sent!");
// Formular ein-/ausblenden (Toggle)
const existing = article.querySelector(".invite-form");
if (existing) {
existing.remove();
} else {
article.appendChild(createInviteForm(event, article));
}
});
inviteBtn.disabled = !window.currentUser;
// =========================
// APPEND BUTTONS
// =========================
buttonContainer.append(saveBtn, inviteBtn);
article.appendChild(buttonContainer);
return article;
}
}
// =========================
// HILFSFUNKTION Einladungsformular
// Erstellt ein kleines Inline-Formular mit Benutzername-Eingabe
// =========================
function createInviteForm(event, card) {
const form = document.createElement("div");
form.className = "invite-form mt-2 d-flex gap-2 align-items-center flex-wrap";
const input = document.createElement("input");
input.type = "text";
input.className = "form-control form-control-sm";
input.placeholder = "Benutzername eingeben";
input.setAttribute("aria-label", "Benutzername für Einladung");
const sendBtn = document.createElement("button");
sendBtn.type = "button";
sendBtn.textContent = "Senden";
sendBtn.className = "btn btn-primary btn-sm";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.textContent = "Abbrechen";
cancelBtn.className = "btn btn-outline-secondary btn-sm";
cancelBtn.addEventListener("click", () => form.remove());
sendBtn.addEventListener("click", async () => {
const toUser = input.value.trim();
if (!toUser) {
input.classList.add("is-invalid");
return;
}
input.classList.remove("is-invalid");
sendBtn.disabled = true;
sendBtn.textContent = "Sende...";
try {
const res = await fetch("http://localhost:3000/api/invitation", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Username": window.currentUser
},
body: JSON.stringify({
toUser,
eventId: event.id,
eventName: event.name
})
});
if (!res.ok) {
// Fehlermeldung vom Server auslesen (z.B. "Nutzer nicht registriert")
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `Fehler ${res.status}`);
}
// Formular entfernen und Erfolgsmeldung anzeigen
form.remove();
showCardMessage(card, `Einladung an "${toUser}" gesendet.`, "success");
} catch (err) {
sendBtn.disabled = false;
sendBtn.textContent = "Senden";
showCardMessage(card, err.message || "Fehler beim Senden der Einladung.", "danger");
console.error("Einladungs-Fehler:", err);
}
});
form.append(input, sendBtn, cancelBtn);
return form;
}
// =========================
// HILFSFUNKTION Status-Nachricht in Karte anzeigen
// Zeigt eine kurze Meldung innerhalb der Karte an und blendet sie nach 3s aus
// =========================
function showCardMessage(card, message, type) {
// Alte Nachrichten entfernen
card.querySelectorAll(".card-message").forEach(el => el.remove());
const msg = document.createElement("p");
msg.className = `card-message text-${type} mt-1 mb-0`;
msg.textContent = message;
card.appendChild(msg);
setTimeout(() => msg.remove(), 3000);
}
// =========================
// HILFSFUNKTION Prüft ob Event bereits gespeichert ist
// =========================
function isAlreadySaved(eventId) {
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
return saved.some(e => e.id === eventId);
}

View File

@ -1,11 +1,38 @@
/**
* eventList.js Rendert eine Liste von Event-Karten in einen Container.
* Delegiert die Erstellung einzelner Karten an createEventCard().
*/
import { createEventCard } from "./eventCard.js";
/**
* Leert den Container und fügt für jedes Event eine Karte ein.
* Zeigt eine Meldung an, wenn keine Events gefunden wurden.
* @param {Array} events - Array von Event-Objekten
* @param {HTMLElement} container - Ziel-DOM-Element
*/
export function renderEventList(events, container) {
container.innerHTML = "";
if (events.length === 0) {
container.innerHTML = "No events found.";
const state = document.createElement("div");
state.className = "empty-state";
const icon = document.createElement("div");
icon.className = "empty-state__icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "🎭";
const title = document.createElement("p");
title.className = "empty-state__text";
title.textContent = "Keine Events gefunden";
const sub = document.createElement("p");
sub.className = "empty-state__sub";
sub.textContent = "Versuche eine andere Stadt oder erweitere den Datumsbereich.";
state.append(icon, title, sub);
container.appendChild(state);
return;
}
@ -13,5 +40,4 @@ export function renderEventList(events, container) {
const card = createEventCard(event);
container.appendChild(card);
});
}
}

View File

@ -1,167 +1,209 @@
/**
* server.js Express-Backend für Encore.
*
* Funktionen:
* - Proxy für die Ticketmaster Discovery API (API-Key bleibt serverseitig)
* - Einfache Benutzerverwaltung (In-Memory, kein Persistenz-Layer)
* - Einladungssystem zwischen registrierten Benutzern
*
* Sicherheitshinweise (OWASP):
* - A02 Cryptographic Failures: Passwörter werden nicht gehasht (bewusste Vereinfachung)
* - A07 Identification and Authentication Failures: Keine Tokens/Sessions, nur Header-Auth
* - Der API-Key liegt serverseitig in .env und wird nie an den Client übermittelt
*/
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app = express();
// Middleware
// Middleware: CORS erlaubt Anfragen vom Frontend (localhost), JSON-Body parsen
app.use(cors());
app.use(express.json());
// =========================
// ROOT
// ROOT Health-Check
// =========================
app.get("/", (req, res) => {
res.send("Encore API is running");
});
// =========================
// TICKETMASTER EVENTS (PROXY)
// TICKETMASTER-PROXY
// Versteckt den API-Key vor dem Client (A02: Sensitive Data Exposure vermeiden)
// GET /api/events?city={city}
// =========================
const TM_API_KEY = process.env.TM_API_KEY;
app.get("/api/events", async (req, res) => {
const city = req.query.city;
if (!city) {
return res.status(400).json({ message: "City is required" });
return res.status(400).json({ message: "Stadt ist erforderlich" });
}
try {
const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${TM_API_KEY}&city=${city}`;
const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${TM_API_KEY}&city=${encodeURIComponent(city)}`;
const response = await fetch(url);
const data = await response.json();
const data = await response.json();
res.json(data);
} catch (error) {
console.error("Event fetch error:", error);
res.status(500).json({ message: "Error fetching events" });
console.error("Ticketmaster-Fehler:", error);
res.status(500).json({ message: "Fehler beim Laden der Events" });
}
});
// =========================
// INVITATIONS SYSTEM
// BENUTZERVERWALTUNG
// In-Memory-Speicher (Daten gehen bei Serverneustart verloren bewusste Vereinfachung)
// =========================
let invitations = [];
let idCounter = 1;
// SEND INVITATION
app.post("/api/invitation", (req, res) => {
if (!req.body) {
return res.status(400).json({ message: "Missing request body" });
}
const fromUser = req.header("X-Username");
const { toUser, eventId, eventName } = req.body;
if (!fromUser || !toUser) {
return res.status(400).json({ message: "Missing users" });
}
const invitation = {
id: idCounter++,
fromUser,
toUser,
eventId,
eventName,
status: "pending"
};
invitations.push(invitation);
res.json(invitation);
});
// GET INVITATIONS FOR USER
app.get("/api/invitation", (req, res) => {
const user = req.header("X-Username");
if (!user) {
return res.status(400).json({ message: "Missing username" });
}
const userInvitations = invitations.filter(
inv => inv.toUser === user
);
res.json(userInvitations);
});
// ACCEPT INVITATION
app.post("/api/invitation/:id/accept", (req, res) => {
const inv = invitations.find(i => i.id == req.params.id);
if (!inv) {
return res.status(404).json({ message: "Invitation not found" });
}
inv.status = "accepted";
res.json(inv);
});
// DECLINE INVITATION
app.post("/api/invitation/:id/decline", (req, res) => {
const inv = invitations.find(i => i.id == req.params.id);
if (!inv) {
return res.status(404).json({ message: "Invitation not found" });
}
inv.status = "declined";
res.json(inv);
});
let users = [];
// CREATE USER
/**
* POST /api/user Neuen Benutzer registrieren.
* Vergibt ein Standard-Passwort "1234" (vereinfacht, kein Hashing).
* Antwortet mit 201 Created bei Erfolg, 400 bei fehlendem/doppeltem Benutzernamen.
*/
app.post("/api/user", (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ message: "Username required" });
return res.status(400).json({ message: "Benutzername erforderlich" });
}
if (users.find(u => u.username === username)) {
return res.status(400).json({ message: "User exists" });
return res.status(400).json({ message: "Benutzer existiert bereits" });
}
const password = "1234";
users.push({ username, password });
const user = { username, password };
users.push(user);
res.json({ name: username, password });
res.status(201).json({ name: username, password });
});
// LOGIN
/**
* GET /api/user Benutzer anmelden.
* Credentials kommen als X-Username und X-Password Header.
* Antwortet mit 200 OK bei Erfolg, 401 Unauthorized bei falschen Credentials.
*
* Hinweis: GET für eine Login-Operation ist unkonventionell (normalerweise POST).
* Hier bewusst so gewählt, da kein Session/Token-Mechanismus implementiert ist.
*/
app.get("/api/user", (req, res) => {
const username = req.header("X-Username");
const password = req.header("X-Password");
const user = users.find(
u => u.username === username && u.password === password
);
const user = users.find(u => u.username === username && u.password === password);
if (!user) return res.sendStatus(401);
res.sendStatus(201);
res.sendStatus(200);
});
// =========================
// START SERVER
// EINLADUNGSSYSTEM
// Verwaltet Event-Einladungen zwischen Benutzern
// =========================
let invitations = [];
let idCounter = 1;
/**
* POST /api/invitation Einladung senden.
* Erwartet: Header X-Username (Sender), Body { toUser, eventId, eventName }
* Antwortet mit 201 Created und dem erstellten Einladungsobjekt.
*/
app.post("/api/invitation", (req, res) => {
if (!req.body) {
return res.status(400).json({ message: "Request-Body fehlt" });
}
const fromUser = req.header("X-Username");
const { toUser, eventId, eventName } = req.body;
if (!fromUser || !toUser) {
return res.status(400).json({ message: "Sender und Empfänger sind erforderlich" });
}
// Prüfen ob der Empfänger registriert ist
if (!users.find(u => u.username === toUser)) {
return res.status(404).json({ message: `Nutzer "${toUser}" ist nicht registriert.` });
}
// Nicht sich selbst einladen
if (fromUser === toUser) {
return res.status(400).json({ message: "Du kannst dich nicht selbst einladen." });
}
const invitation = {
id: idCounter++,
fromUser,
toUser,
eventId,
eventName,
status: "pending"
};
invitations.push(invitation);
res.status(201).json(invitation);
});
/**
* GET /api/invitation Alle Einladungen für einen Benutzer abrufen.
* Erwartet: Header X-Username (Empfänger)
* Antwortet mit 200 und Array der Einladungen.
*/
app.get("/api/invitation", (req, res) => {
const user = req.header("X-Username");
if (!user) {
return res.status(400).json({ message: "Benutzername fehlt" });
}
const userInvitations = invitations.filter(inv => inv.toUser === user);
res.json(userInvitations);
});
/**
* PUT /api/invitation/:id Einladungsstatus aktualisieren (annehmen oder ablehnen).
* Erwartet: Body { status: "accepted" | "declined" }
* HTTP-Verb PUT gemäss REST: Update einer bestehenden Ressource.
* Antwortet mit 200 OK und dem aktualisierten Einladungsobjekt.
*/
app.put("/api/invitation/:id", (req, res) => {
const inv = invitations.find(i => i.id == req.params.id);
if (!inv) return res.status(404).json({ message: "Einladung nicht gefunden" });
const { status } = req.body;
if (!["accepted", "declined"].includes(status)) {
return res.status(400).json({ message: "Ungültiger Status. Erlaubt: accepted, declined" });
}
inv.status = status;
res.json(inv);
});
/**
* DELETE /api/invitation/:id Einladung löschen.
* HTTP-Verb DELETE gemäss REST: Entfernt die Ressource dauerhaft.
* Antwortet mit 204 No Content bei Erfolg.
*/
app.delete("/api/invitation/:id", (req, res) => {
const index = invitations.findIndex(i => i.id == req.params.id);
if (index === -1) return res.status(404).json({ message: "Einladung nicht gefunden" });
invitations.splice(index, 1);
res.sendStatus(204); // 204 No Content Ressource erfolgreich gelöscht
});
// =========================
// SERVER STARTEN
// =========================
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
console.log("Server läuft auf http://localhost:3000");
});