Compare commits

..
6 Commits
21 changed files with 888 additions and 273 deletions
+1
View File
@@ -4,3 +4,4 @@ Dockerfile
docker-compose.yml docker-compose.yml
.env .env
*.exe *.exe
config/
-1
View File
@@ -1,7 +1,6 @@
# Server Port & Configuration # Server Port & Configuration
PORT=8080 PORT=8080
WEB_DIR=public WEB_DIR=public
BOOKED_DATES_FILE=booked_dates.json
# SMTP Configuration # SMTP Configuration
SMTP_HOST=mail.example.com SMTP_HOST=mail.example.com
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
@@ -0,0 +1,75 @@
---
agent: agent
description: Split Date Wizard JSON persistence into a fixed config directory, store complete structured bookings, and skip the name step when no names are configured.
---
Implement or review the Date Wizard configuration and booking persistence layout. Preserve the existing Go server, authenticated admin behavior, public booking flow, German user-facing language, and Docker deployment conventions.
## Fixed config directory
All JSON data files belong under one project config directory:
- `config/appsettings.json`: admin password and optional configured names.
- `config/activities.json`: activity definitions.
- `config/dates_available.json`: available date definitions.
- `config/dates_booked.json`: structured booking records.
The application must use these fixed paths and must not require one environment variable per JSON file. Do not add or retain `ACTIVITIES_FILE`, `AVAILABLE_DATES_FILE`, or `BOOKED_DATES_FILE` configuration requirements. The runtime paths should resolve to `/app/config/...` in the container and `config/...` during local execution.
Keep legacy read compatibility where practical for existing deployments, but new writes must use the new fixed files and names. The old root-level JSON files should not remain as the active project data source.
## Docker Compose deployment
The Compose deployment must expose one persistent volume only:
```yaml
volumes:
- /docker/data/date:/app/config
```
The container must not require individual file mounts or data-file environment variables. Startup must provision missing files from image defaults when the mounted config directory is empty. Preserve the existing `.env` usage for server and SMTP settings.
The Dockerfile must include the config templates in the image, and the startup script must initialize the four files beneath `/app/config` without overwriting existing host data.
Document the host initialization and migration process, including migration from the old `booked_dates.json` format.
## Structured booking records
When a booking is submitted, record one object per booked date in `config/dates_booked.json` with:
- `bookedAt`: UTC booking timestamp in a machine-readable format.
- `date`: the selected date.
- `dateType`: the selected date duration/type.
- `name`: the selected person name, or empty when the name step was skipped.
- `activities`: every selected activity with its `name`, `icon`, and `type`.
Do not persist `customDescription` in the booked activity records. Custom descriptions may remain transient in the submission payload and may still be used for the notification email and generated iCalendar content.
Avoid duplicate booking records for the same date. Existing date-only booked-date consumers may continue receiving a list of date strings through the public API. Preserve booked-date protection in the admin date editor.
## Name page shortcut
When `appsettings.json` contains an empty `names` array:
- Skip the name-selection page entirely after activity selection.
- Store an empty selected name and navigate directly to checkout.
- Checkout must accept an empty name and display a clear fallback such as `Nicht angegeben`.
When names are configured, preserve the existing name-selection page and selection behavior.
## Validation
Before editing, inspect the current settings model, file paths, booking submission payload, name-page navigation, Dockerfile, Compose file, startup script, and existing prompt conventions.
After editing:
1. Run `gofmt` on changed Go files.
2. Run the available Go test/build command.
3. Run JavaScript syntax checks for changed frontend scripts.
4. Validate all config JSON files.
5. Check diagnostics and whitespace errors.
6. Smoke-test the public settings and booked-date APIs with the fixed local config paths.
7. Verify that Compose has one config-directory volume and no per-file data environment variables.
8. Do not overwrite user-edited config data or unrelated fixtures.
When reporting the result, summarize the fixed paths, Docker contract, booking JSON shape, name-page behavior, migration compatibility, and validation results.
@@ -0,0 +1,77 @@
---
agent: agent
description: Manage booked dates in the authenticated Date Wizard admin page, including newest-first display, activity and booking metadata, and confirmation-gated deletion.
---
Implement or review booked-date management for the Date Wizard application. Preserve the existing Go server, JSON booking persistence, authenticated admin behavior, German user-facing language, and visual conventions.
## Admin navigation
Add a separate booking-management view to the authenticated `/admin` page. Keep it between the existing `Termine` and `Aktivitäten` navigation items. The view is reached through a visible navigation button and must remain responsive on narrow screens.
The booking view is initially read-only except for deleting a booking. Do not add editing controls for the booking date, duration, name, activities, or booking timestamp.
## Booking list
Load bookings through an authenticated admin endpoint and display them newest first using the booking creation timestamp `bookedAt`.
Each booking entry must:
- Display the selected date as the primary information, formatted in German.
- Display every selected activity when one or more activities are present.
- Display each activity's stored icon and name, including the single-activity case and multiple-activity case.
- Display the booker's name only when a non-empty name is available.
- Display the date and time at which the booking was created from `bookedAt`.
- Display a useful empty state when no bookings exist.
- Render all values as text content rather than injecting untrusted booking data as HTML.
Keep legacy records with missing optional values displayable. Do not fabricate a booking timestamp when `bookedAt` is empty.
## Backend API
Use the existing authenticated Go HTTP API and booking storage helpers.
- Add or preserve an authenticated `GET /api/admin/bookings` endpoint.
- Return structured booking records containing `bookedAt`, `date`, `dateType`, `name`, and `activities`.
- Sort records newest first by `bookedAt`, while keeping records with missing timestamps valid.
- Do not expose the full booking records through the public `/api/booked-dates` endpoint; that endpoint may continue returning only date strings.
- Reject unauthenticated requests with the existing JSON authentication response.
- Return appropriate errors for malformed requests and storage failures.
## Booking deletion
Provide one `Termin löschen` button for every booking entry.
- Ask for explicit confirmation before sending a delete request.
- Include the formatted booking date in the confirmation question.
- Add an authenticated `DELETE /api/admin/bookings` endpoint accepting `{ "date": "YYYY-MM-DD" }`.
- Delete only the matching booking record from `config/dates_booked.json`.
- Return a not-found response when the requested booking does not exist.
- Keep the delete button disabled while the request is in progress.
- Reload the booking list after successful deletion.
- Remove the deleted date from the admin calendar's booked-date state and refresh the affected date displays.
- Keep confirmation and deletion errors visible to the administrator.
- Do not delete or modify any other booking record.
## Persistence and safety
Preserve the existing structured booking format and booking uniqueness rule: at most one booking record exists for a date. Use the existing booking mutex and atomic JSON write helper for deletion. Do not modify `config/appsettings.json` or expose the admin password.
## Implementation and validation
Before editing, inspect the existing admin HTML, JavaScript, CSS, Go handlers, booking model, storage helpers, public booked-date endpoint, and prompt conventions. Reuse existing request helpers and styling patterns.
After editing:
1. Run `gofmt` on changed Go files.
2. Run the available Go test or build command.
3. Run a JavaScript syntax check for changed scripts when Node.js is available.
4. Check diagnostics for changed files.
5. Verify unauthenticated access still shows the login view.
6. Verify the booking tab appears between `Termine` and `Aktivitäten`.
7. Verify newest-first ordering, date display, single and multiple activity display with icons, optional name display, and booking timestamp display.
8. Verify the delete confirmation prevents deletion when cancelled and deletes only the confirmed booking when accepted.
9. Verify the calendar booked state updates after deletion.
10. Do not alter `config/appsettings.json` or unrelated fixtures when documenting or committing this feature.
When reporting the result, summarize changed files, user-visible behavior, API behavior, persistence effects, and validation commands that passed or failed.
+2 -3
View File
@@ -18,11 +18,10 @@ WORKDIR /app
# Copy binary and static front-end assets # Copy binary and static front-end assets
COPY --from=builder /app/server /app/server COPY --from=builder /app/server /app/server
COPY --from=builder /app/public /app/public COPY --from=builder /app/public /app/public
COPY --from=builder /app/appsettings.json /app/appsettings.default.json
COPY start.sh /app/start.sh COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh RUN mkdir -p /app/config && chmod +x /app/start.sh
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/app/start.sh"] ENTRYPOINT ["/bin/sh", "/app/start.sh"]
+22 -8
View File
@@ -8,8 +8,12 @@ Eine spielerische Web-App ("Choose Your Own Adventure") zum Planen gemeinsamer D
```text ```text
datetest/ datetest/
├── config/ # Persistente JSON-Konfiguration und Buchungen
│ ├── appsettings.json # Admin-Passwort und optionale Namen
│ ├── activities.json # Aktivitäten
│ ├── dates_available.json # Verfügbare Termine
│ └── dates_booked.json # Strukturierte Buchungen
├── public/ # Statische Webseiten & Assets ├── public/ # Statische Webseiten & Assets
│ ├── appsettings.json # Konfiguration der Aktivitäten & Termine
│ ├── index.html # Startseite ("Willst du mit mir gehen?") │ ├── index.html # Startseite ("Willst du mit mir gehen?")
│ ├── datetime.html/.js # Datumsauswahl (Kalender) │ ├── datetime.html/.js # Datumsauswahl (Kalender)
│ ├── activity.html/.js # Aktivitätsauswahl │ ├── activity.html/.js # Aktivitätsauswahl
@@ -41,7 +45,6 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `PORT` | HTTP-Port des Go-Servers | `8080` | | `PORT` | HTTP-Port des Go-Servers | `8080` |
| `WEB_DIR` | Verzeichnis der statischen Assets | `public` | | `WEB_DIR` | Verzeichnis der statischen Assets | `public` |
| `BOOKED_DATES_FILE` | Speicherpfad für bereits gebuchte Termine | `booked_dates.json` |
| `SMTP_HOST` | Postausgangsserver (SMTP) | `mail.example.com` | | `SMTP_HOST` | Postausgangsserver (SMTP) | `mail.example.com` |
| `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` | | `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` |
| `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` | | `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` |
@@ -51,18 +54,29 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
*(Hinweis: Wenn `SMTP_HOST` oder `TO_EMAIL` leer bleiben, läuft der Server im Testmodus ohne E-Mail-Versand.)* *(Hinweis: Wenn `SMTP_HOST` oder `TO_EMAIL` leer bleiben, läuft der Server im Testmodus ohne E-Mail-Versand.)*
### 2. Aktivitäten & Termine anpassen (`appsettings.json`) ### 2. Einstellungen und Daten
Passe in `appsettings.json` die zur Auswahl stehenden Aktivitäten, verfügbaren Tage und das Admin-Passwort an: Die persistenten Daten liegen in getrennten JSON-Dateien:
- **`adminPassword`**: Passwort für die Terminverwaltung unter `/admin`. Ändere den Standardwert `change-me`, bevor du die Anwendung veröffentlichst. - **`adminPassword`**: Passwort für die Terminverwaltung unter `/admin`. Ändere den Standardwert `change-me`, bevor du die Anwendung veröffentlichst.
- **`activities`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji), `type` (`short` oder `long`) und optional `disabled` (`true` blendet die Aktivität vorübergehend aus). - **`activities.json`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji), `type` (`short`, `long` oder `custom`) und optional `disabled` (`true` blendet die Aktivität vorübergehend aus).
- **`availableDates`**: Liste von verfügbaren Tagen mit `date` (`YYYY-MM-DD`) und `type` (`short` oder `long`). - **`dates_available.json`**: Liste von verfügbaren Tagen mit `date` (`YYYY-MM-DD`) und `type` (`short` oder `long`).
- **`names`**: Optional vordefinierte Namen zur Auswahl (ist die Liste leer, wird ein Standard-Fallback angeboten). - **`dates_booked.json`**: Strukturierte Buchungen mit `bookedAt`, `date`, `dateType`, `name` und den ausgewählten Aktivitäten inklusive Name, Typ und Icon.
- **`names`**: Optional vordefinierte Namen zur Auswahl (ist die Liste leer, wird die Namensseite übersprungen).
Die Termin- und Aktivitätenlisten lassen sich über die passwortgeschützte Verwaltung unter `http://localhost:8080/admin` bearbeiten. Das Passwort bleibt dabei auf dem Server und wird nicht an Besucher ausgeliefert. Die Termin- und Aktivitätenlisten lassen sich über die passwortgeschützte Verwaltung unter `http://localhost:8080/admin` bearbeiten. Das Passwort bleibt dabei auf dem Server und wird nicht an Besucher ausgeliefert.
Bei Docker Compose werden `appsettings.json` und `booked_dates.json` dauerhaft unter `/docker/data/date` gespeichert. Beim ersten Start legt der Container die Einstellungen dort aus der im Image enthaltenen Vorlage an. Ändere danach den Standardwert `change-me` in `/docker/data/date/appsettings.json` oder übernimm vor dem ersten Start eine vorbereitete Konfigurationsdatei. Bei Docker Compose wird `/docker/data/date` als ein Volume nach `/app/config` eingebunden. Die vier JSON-Dateien müssen dort vor dem ersten Start vorhanden sein. Du kannst sie aus dem Projektordner initialisieren:
```bash
mkdir -p /docker/data/date
cp config/appsettings.json /docker/data/date/appsettings.json
cp config/activities.json /docker/data/date/activities.json
cp config/dates_available.json /docker/data/date/dates_available.json
cp config/dates_booked.json /docker/data/date/dates_booked.json
```
Wenn bisher `/docker/data/date/booked_dates.json` verwendet wurde, übernimm die alten Buchungsdaten zuerst in `dates_booked.json`. Das neue Format speichert Buchungen als Objekte statt als reine Datumsstrings.
--- ---
-190
View File
@@ -1,190 +0,0 @@
{
"activities": [
{
"name": "Picknick auf der Wiese",
"icon": "🧺",
"type": "short"
},
{
"name": "Cocktailabend zu zweit",
"icon": "🍸",
"type": "long"
},
{
"name": "Massage zu Hause",
"icon": "💆",
"type": "short"
},
{
"name": "Ausgedehnte Wanderung",
"icon": "🥾",
"type": "long"
},
{
"name": "Kinofilm",
"icon": "🎬",
"type": "short"
},
{
"name": "Sushi selber machen",
"icon": "🍣",
"type": "short"
},
{
"name": "Untersetzer aus Ton basteln",
"icon": "🪨",
"type": "long"
},
{
"name": "Freizeitpark",
"icon": "🎢",
"type": "long"
},
{
"name": "Spaziergang im Wald",
"icon": "🌲",
"type": "short"
},
{
"name": "Kirschbier Tasting",
"icon": "🍒",
"type": "short"
},
{
"name": "Nudeln selber machen",
"icon": "🍜",
"type": "long"
},
{
"name": "Chillen im Freibad",
"icon": "🏊",
"type": "short"
},
{
"name": "Film zu Hause + Essen bestellen",
"icon": "🛋️",
"type": "long"
},
{
"name": "Squash spielen",
"icon": "🏸",
"type": "short"
},
{
"name": "Essen im Namaste",
"icon": "🍛",
"type": "short"
},
{
"name": "Museum für Gegenwartskunst",
"icon": "🖼️",
"type": "short"
},
{
"name": "Playstation Co-Op",
"icon": "🎮",
"type": "short"
},
{
"name": "Etwas eigenes",
"icon": "✏️",
"type": "custom"
}
],
"availableDates": [
{
"date": "2026-08-18",
"type": "short"
},
{
"date": "2026-08-20",
"type": "short"
},
{
"date": "2026-08-22",
"type": "long"
},
{
"date": "2026-08-23",
"type": "long"
},
{
"date": "2026-08-24",
"type": "short"
},
{
"date": "2026-08-26",
"type": "short"
},
{
"date": "2026-08-27",
"type": "short"
},
{
"date": "2026-08-28",
"type": "short"
},
{
"date": "2026-08-29",
"type": "long"
},
{
"date": "2026-08-31",
"type": "short"
},
{
"date": "2026-09-01",
"type": "short"
},
{
"date": "2026-09-07",
"type": "short"
},
{
"date": "2026-09-08",
"type": "short"
},
{
"date": "2026-09-14",
"type": "short"
},
{
"date": "2026-09-15",
"type": "short"
},
{
"date": "2026-09-17",
"type": "short"
},
{
"date": "2026-09-18",
"type": "short"
},
{
"date": "2026-09-25",
"type": "short"
},
{
"date": "2026-09-26",
"type": "long"
},
{
"date": "2026-09-27",
"type": "short"
},
{
"date": "2026-09-28",
"type": "short"
},
{
"date": "2026-09-29",
"type": "short"
},
{
"date": "2026-09-30",
"type": "short"
}
],
"names": [],
"adminPassword": "change-me"
}
+88
View File
@@ -0,0 +1,88 @@
[
{
"name": "Cocktailabend zu zweit",
"icon": "🍸",
"type": "long"
},
{
"name": "Massage zu Hause",
"icon": "💆",
"type": "short"
},
{
"name": "Ausgedehnte Wanderung",
"icon": "🌲",
"type": "long"
},
{
"name": "Kinofilm gucken",
"icon": "🎬",
"type": "short"
},
{
"name": "Sushi selber machen",
"icon": "🍣",
"type": "short"
},
{
"name": "Untersetzer aus Ton basteln",
"icon": "🪨",
"type": "long"
},
{
"name": "Freizeitpark",
"icon": "🎢",
"type": "long"
},
{
"name": "Spaziergang im Wald",
"icon": "🌲",
"type": "short"
},
{
"name": "Kirschbier Tasting",
"icon": "🍒",
"type": "short"
},
{
"name": "Nudeln selber machen",
"icon": "🍜",
"type": "long"
},
{
"name": "Chillen im Freibad",
"icon": "🏊",
"type": "short",
"disabled": true
},
{
"name": "Film zu Hause + Essen bestellen",
"icon": "🛋️",
"type": "long"
},
{
"name": "Squash spielen",
"icon": "🏸",
"type": "short"
},
{
"name": "Essen im Namaste",
"icon": "🍛",
"type": "short"
},
{
"name": "Museum für Gegenwartskunst",
"icon": "🖼️",
"type": "short"
},
{
"name": "Playstation Co-Op",
"icon": "🎮",
"type": "short"
},
{
"name": "Etwas eigenes",
"icon": "✏️",
"type": "custom"
}
]
+4
View File
@@ -0,0 +1,4 @@
{
"names": [],
"adminPassword": "change-me"
}
+226
View File
@@ -0,0 +1,226 @@
[
{
"date": "2026-08-18",
"type": "short"
},
{
"date": "2026-08-20",
"type": "short"
},
{
"date": "2026-08-22",
"type": "long"
},
{
"date": "2026-08-23",
"type": "long"
},
{
"date": "2026-08-24",
"type": "short"
},
{
"date": "2026-08-26",
"type": "short"
},
{
"date": "2026-08-27",
"type": "short"
},
{
"date": "2026-08-28",
"type": "short"
},
{
"date": "2026-08-29",
"type": "long"
},
{
"date": "2026-08-31",
"type": "short"
},
{
"date": "2026-09-01",
"type": "short"
},
{
"date": "2026-09-07",
"type": "short"
},
{
"date": "2026-09-08",
"type": "short"
},
{
"date": "2026-09-14",
"type": "short"
},
{
"date": "2026-09-15",
"type": "short"
},
{
"date": "2026-09-17",
"type": "short"
},
{
"date": "2026-09-18",
"type": "short"
},
{
"date": "2026-09-25",
"type": "short"
},
{
"date": "2026-09-26",
"type": "long"
},
{
"date": "2026-09-27",
"type": "short"
},
{
"date": "2026-09-28",
"type": "short"
},
{
"date": "2026-09-29",
"type": "short"
},
{
"date": "2026-09-30",
"type": "short"
},
{
"date": "2026-10-02",
"type": "short"
},
{
"date": "2026-10-06",
"type": "short"
},
{
"date": "2026-10-09",
"type": "short"
},
{
"date": "2026-10-11",
"type": "short"
},
{
"date": "2026-10-12",
"type": "short"
},
{
"date": "2026-10-13",
"type": "short"
},
{
"date": "2026-10-14",
"type": "short"
},
{
"date": "2026-10-16",
"type": "short"
},
{
"date": "2026-10-26",
"type": "short"
},
{
"date": "2026-10-27",
"type": "short"
},
{
"date": "2026-10-28",
"type": "short"
},
{
"date": "2026-10-29",
"type": "short"
},
{
"date": "2026-10-30",
"type": "short"
},
{
"date": "2026-11-02",
"type": "short"
},
{
"date": "2026-11-03",
"type": "short"
},
{
"date": "2026-11-04",
"type": "short"
},
{
"date": "2026-11-05",
"type": "short"
},
{
"date": "2026-11-06",
"type": "short"
},
{
"date": "2026-11-08",
"type": "long"
},
{
"date": "2026-11-09",
"type": "short"
},
{
"date": "2026-11-10",
"type": "short"
},
{
"date": "2026-11-11",
"type": "short"
},
{
"date": "2026-11-12",
"type": "short"
},
{
"date": "2026-11-17",
"type": "short"
},
{
"date": "2026-11-18",
"type": "short"
},
{
"date": "2026-11-23",
"type": "short"
},
{
"date": "2026-11-24",
"type": "short"
},
{
"date": "2026-11-25",
"type": "short"
},
{
"date": "2026-11-26",
"type": "short"
},
{
"date": "2026-11-27",
"type": "short"
},
{
"date": "2026-11-28",
"type": "long"
},
{
"date": "2026-11-29",
"type": "long"
},
{
"date": "2026-11-30",
"type": "short"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+1 -4
View File
@@ -9,11 +9,8 @@ services:
- proxy - proxy
env_file: env_file:
- .env - .env
environment:
SETTINGS_FILE: /data/appsettings.json
BOOKED_DATES_FILE: /data/booked_dates.json
volumes: volumes:
- /docker/data/date:/data - /docker/data/date:/app/config
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.date-wizard.rule=Host(`date.allen.beging.de`) || Host(`date.beging.de`)" - "traefik.http.routers.date-wizard.rule=Host(`date.allen.beging.de`) || Host(`date.beging.de`)"
+214 -61
View File
@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"net/smtp" "net/smtp"
"os" "os"
"path/filepath"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -44,10 +45,24 @@ type AvailableDate struct {
} }
type AppSettings struct { type AppSettings struct {
Activities []Activity `json:"activities"`
AvailableDates []AvailableDate `json:"availableDates"`
Names []string `json:"names"` Names []string `json:"names"`
AdminPassword string `json:"adminPassword"` AdminPassword string `json:"adminPassword"`
Activities []Activity `json:"-"`
AvailableDates []AvailableDate `json:"-"`
}
type Booking struct {
BookedAt string `json:"bookedAt"`
Date string `json:"date"`
DateType string `json:"dateType"`
Name string `json:"name"`
Activities []BookingActivity `json:"activities"`
}
type BookingActivity struct {
Name string `json:"name"`
Icon string `json:"icon"`
Type string `json:"type"`
} }
type PublicSettings struct { type PublicSettings struct {
@@ -61,6 +76,7 @@ type passwordRequest struct {
} }
var settingsMu sync.Mutex var settingsMu sync.Mutex
var bookingsMu sync.Mutex
var sessionsMu sync.Mutex var sessionsMu sync.Mutex
var adminSessions = make(map[string]time.Time) var adminSessions = make(map[string]time.Time)
@@ -85,6 +101,7 @@ func main() {
http.HandleFunc("/api/admin/dates", handleAdminDates) http.HandleFunc("/api/admin/dates", handleAdminDates)
http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates) http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates)
http.HandleFunc("/api/admin/activities", handleAdminActivities) http.HandleFunc("/api/admin/activities", handleAdminActivities)
http.HandleFunc("/api/admin/bookings", handleAdminBookings)
http.HandleFunc("/admin", handleAdminPage) http.HandleFunc("/admin", handleAdminPage)
// Serve static files from webDir directory // Serve static files from webDir directory
@@ -271,6 +288,65 @@ func handleAdminActivities(w http.ResponseWriter, r *http.Request) {
} }
} }
func handleAdminBookings(w http.ResponseWriter, r *http.Request) {
if !isAdminRequest(r) {
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
return
}
switch r.Method {
case http.MethodGet:
bookings, err := getBookings()
if err != nil {
log.Printf("Error reading bookings: %v", err)
http.Error(w, "Bookings could not be loaded", http.StatusInternalServerError)
return
}
sort.SliceStable(bookings, func(first, second int) bool {
return bookings[first].BookedAt > bookings[second].BookedAt
})
writeJSON(w, http.StatusOK, bookings)
case http.MethodDelete:
var request struct {
Date string `json:"date"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || strings.TrimSpace(request.Date) == "" {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte gib ein Datum an."})
return
}
request.Date = strings.TrimSpace(request.Date)
bookingsMu.Lock()
defer bookingsMu.Unlock()
bookings, err := readBookings()
if err != nil {
log.Printf("Error reading bookings: %v", err)
http.Error(w, "Bookings could not be loaded", http.StatusInternalServerError)
return
}
filtered := make([]Booking, 0, len(bookings))
deleted := false
for _, booking := range bookings {
if booking.Date == request.Date {
deleted = true
continue
}
filtered = append(filtered, booking)
}
if !deleted {
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Gebuchter Termin wurde nicht gefunden."})
return
}
if err := writeJSONFile(getBookedDatesFilePath(), filtered); err != nil {
log.Printf("Error deleting booking: %v", err)
http.Error(w, "Booking could not be deleted", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Gebuchter Termin gelöscht."})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func handleAdminLogin(w http.ResponseWriter, r *http.Request) { func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -486,11 +562,7 @@ func handleAdminBulkDates(w http.ResponseWriter, r *http.Request) {
} }
func getSettingsFilePath() string { func getSettingsFilePath() string {
path := os.Getenv("SETTINGS_FILE") return filepath.Join("config", "appsettings.json")
if path == "" {
path = "appsettings.json"
}
return path
} }
func readSettings() (AppSettings, error) { func readSettings() (AppSettings, error) {
@@ -508,9 +580,42 @@ func readSettingsFile() (AppSettings, error) {
if err := json.Unmarshal(data, &settings); err != nil { if err := json.Unmarshal(data, &settings); err != nil {
return settings, err return settings, err
} }
var legacy struct {
Activities []Activity `json:"activities"`
AvailableDates []AvailableDate `json:"availableDates"`
}
if err := json.Unmarshal(data, &legacy); err != nil {
return settings, err
}
settings.Activities = legacy.Activities
settings.AvailableDates = legacy.AvailableDates
if activities, found, err := readOptionalJSON(getActivitiesFilePath(), &[]Activity{}); err != nil {
return settings, err
} else if found {
settings.Activities = *activities.(*[]Activity)
}
if dates, found, err := readOptionalJSON(getAvailableDatesFilePath(), &[]AvailableDate{}); err != nil {
return settings, err
} else if found {
settings.AvailableDates = *dates.(*[]AvailableDate)
}
return settings, nil return settings, nil
} }
func readOptionalJSON(path string, target interface{}) (interface{}, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return target, false, nil
}
if err != nil {
return target, false, err
}
if err := json.Unmarshal(data, target); err != nil {
return target, false, err
}
return target, true, nil
}
func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error)) error { func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error)) error {
settingsMu.Lock() settingsMu.Lock()
defer settingsMu.Unlock() defer settingsMu.Unlock()
@@ -526,15 +631,7 @@ func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error))
sort.Slice(dates, func(i, j int) bool { return dates[i].Date < dates[j].Date }) sort.Slice(dates, func(i, j int) bool { return dates[i].Date < dates[j].Date })
settings.AvailableDates = dates settings.AvailableDates = dates
data, err := json.MarshalIndent(settings, "", " ") return writeJSONFile(getAvailableDatesFilePath(), dates)
if err != nil {
return err
}
temporaryPath := getSettingsFilePath() + ".tmp"
if err := os.WriteFile(temporaryPath, data, 0600); err != nil {
return err
}
return os.Rename(temporaryPath, getSettingsFilePath())
} }
func updateActivities(update func([]Activity) ([]Activity, error)) error { func updateActivities(update func([]Activity) ([]Activity, error)) error {
@@ -551,15 +648,27 @@ func updateActivities(update func([]Activity) ([]Activity, error)) error {
} }
settings.Activities = activities settings.Activities = activities
data, err := json.MarshalIndent(settings, "", " ") return writeJSONFile(getActivitiesFilePath(), activities)
}
func writeJSONFile(path string, value interface{}) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil { if err != nil {
return err return err
} }
temporaryPath := getSettingsFilePath() + ".tmp" temporaryPath := path + ".tmp"
if err := os.WriteFile(temporaryPath, data, 0600); err != nil { if err := os.WriteFile(temporaryPath, data, 0600); err != nil {
return err return err
} }
return os.Rename(temporaryPath, getSettingsFilePath()) return os.Rename(temporaryPath, path)
}
func getActivitiesFilePath() string {
return filepath.Join("config", "activities.json")
}
func getAvailableDatesFilePath() string {
return filepath.Join("config", "dates_available.json")
} }
func validateActivity(activity Activity) error { func validateActivity(activity Activity) error {
@@ -577,11 +686,22 @@ func validateActivity(activity Activity) error {
func enabledActivities(activities []Activity) []Activity { func enabledActivities(activities []Activity) []Activity {
enabled := make([]Activity, 0, len(activities)) enabled := make([]Activity, 0, len(activities))
var customActivity *Activity
for _, activity := range activities { for _, activity := range activities {
if activity.Type == "custom" {
if !activity.Disabled {
custom := activity
customActivity = &custom
}
continue
}
if !activity.Disabled { if !activity.Disabled {
enabled = append(enabled, activity) enabled = append(enabled, activity)
} }
} }
if customActivity != nil {
enabled = append(enabled, *customActivity)
}
return enabled return enabled
} }
@@ -665,7 +785,7 @@ func handleSubmit(w http.ResponseWriter, r *http.Request) {
log.Printf("Received survey submission from %s for date %s (%s)", payload.Name, payload.Date, payload.Activity) log.Printf("Received survey submission from %s for date %s (%s)", payload.Name, payload.Date, payload.Activity)
if err := recordBookedDate(payload.Date); err != nil { if err := recordBooking(payload); err != nil {
log.Printf("Error recording booked date: %v", err) log.Printf("Error recording booked date: %v", err)
} }
@@ -706,65 +826,98 @@ func handleBookedDates(w http.ResponseWriter, r *http.Request) {
} }
func getBookedDatesFilePath() string { func getBookedDatesFilePath() string {
path := os.Getenv("BOOKED_DATES_FILE") return filepath.Join("config", "dates_booked.json")
if path == "" {
path = "booked_dates.json"
}
return path
} }
func getBookedDates() ([]string, error) { func getBookedDates() ([]string, error) {
filePath := getBookedDatesFilePath() bookings, err := getBookings()
file, err := os.Open(filePath)
if err != nil { if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err return nil, err
} }
defer file.Close() dates := make([]string, 0, len(bookings))
for _, booking := range bookings {
var rawDates []string if trimmed := strings.TrimSpace(booking.Date); trimmed != "" {
if err := json.NewDecoder(file).Decode(&rawDates); err != nil {
return []string{}, nil
}
dates := make([]string, 0, len(rawDates))
for _, d := range rawDates {
trimmed := strings.TrimSpace(d)
if trimmed != "" {
dates = append(dates, trimmed) dates = append(dates, trimmed)
} }
} }
return dates, nil return dates, nil
} }
func recordBookedDate(dateStr string) error { func getBookings() ([]Booking, error) {
trimmedDate := strings.TrimSpace(dateStr) bookingsMu.Lock()
if trimmedDate == "" { defer bookingsMu.Unlock()
return nil return readBookings()
} }
dates, err := getBookedDates() func readBookings() ([]Booking, error) {
if err != nil {
dates = []string{}
}
for _, d := range dates {
if d == trimmedDate {
return nil
}
}
dates = append(dates, trimmedDate)
filePath := getBookedDatesFilePath() filePath := getBookedDatesFilePath()
data, err := json.MarshalIndent(dates, "", " ") data, err := os.ReadFile(filePath)
if os.IsNotExist(err) {
legacyPath := os.Getenv("LEGACY_BOOKED_DATES_FILE")
if legacyPath == "" {
legacyPath = filepath.Join("config", "booked_dates.json")
}
data, err = os.ReadFile(legacyPath)
if os.IsNotExist(err) {
return []Booking{}, nil
}
}
if err != nil { if err != nil {
return err return nil, err
}
var bookings []Booking
if err := json.Unmarshal(data, &bookings); err == nil {
return bookings, nil
} }
return os.WriteFile(filePath, data, 0644) var legacyDates []string
if err := json.Unmarshal(data, &legacyDates); err != nil {
return nil, err
}
bookings = make([]Booking, 0, len(legacyDates))
for _, date := range legacyDates {
if trimmed := strings.TrimSpace(date); trimmed != "" {
bookings = append(bookings, Booking{Date: trimmed})
}
}
return bookings, nil
}
func recordBooking(payload SurveyPayload) error {
date := strings.TrimSpace(payload.Date)
if date == "" {
return nil
}
bookingsMu.Lock()
defer bookingsMu.Unlock()
bookings, err := readBookings()
if err != nil {
bookings = []Booking{}
}
for _, booking := range bookings {
if booking.Date == date {
return nil
}
}
bookingActivities := make([]BookingActivity, 0, len(payload.Activities))
for _, activity := range payload.Activities {
bookingActivities = append(bookingActivities, BookingActivity{
Name: activity.Name,
Icon: activity.Icon,
Type: activity.Type,
})
}
bookings = append(bookings, Booking{
BookedAt: time.Now().UTC().Format(time.RFC3339),
Date: date,
DateType: payload.DateType,
Name: payload.Name,
Activities: bookingActivities,
})
return writeJSONFile(getBookedDatesFilePath(), bookings)
} }
func enableCORS(w http.ResponseWriter, r *http.Request) bool { func enableCORS(w http.ResponseWriter, r *http.Request) bool {
+62
View File
@@ -262,6 +262,68 @@
color: var(--color-text-light); color: var(--color-text-light);
} }
.bookings-list {
display: grid;
gap: 14px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.booking-row {
display: grid;
gap: 8px;
padding: 14px 0;
border-bottom: 1px solid #e5d8ce;
}
.booking-date {
font-family: var(--font-heading);
font-size: 1.08rem;
}
.booking-activities {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
color: var(--color-text);
}
.booking-activity {
font-weight: 600;
}
.booking-metadata {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
color: var(--color-text-light);
font-size: 0.88rem;
}
.delete-booking-btn {
justify-self: start;
min-height: 36px;
padding: 6px 10px;
border: 1px solid #c84a62;
border-radius: 5px;
background: transparent;
color: #a83049;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.delete-booking-btn:hover,
.delete-booking-btn:focus-visible {
background: #fff0f2;
}
.delete-booking-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.admin-calendar { .admin-calendar {
margin-top: 20px; margin-top: 20px;
padding: 16px; padding: 16px;
+12
View File
@@ -35,6 +35,7 @@
<nav class="admin-nav" aria-label="Verwaltung"> <nav class="admin-nav" aria-label="Verwaltung">
<button class="admin-nav-btn is-active" type="button" data-view="datesView">Termine</button> <button class="admin-nav-btn is-active" type="button" data-view="datesView">Termine</button>
<button class="admin-nav-btn" type="button" data-view="bookingsView">Gebuchte Termine</button>
<button class="admin-nav-btn" type="button" data-view="activitiesView">Aktivitäten</button> <button class="admin-nav-btn" type="button" data-view="activitiesView">Aktivitäten</button>
</nav> </nav>
@@ -73,6 +74,17 @@
</section> </section>
</div> </div>
<div id="bookingsView" class="admin-panel hidden">
<section class="admin-section" aria-labelledby="bookingsTitle">
<div class="section-heading">
<h2 id="bookingsTitle">Gebuchte Termine</h2>
<p>Neueste Buchungen stehen oben.</p>
</div>
<ul id="bookingsList" class="bookings-list" aria-live="polite"></ul>
<p id="emptyBookings" class="empty-dates hidden">Noch keine Termine gebucht.</p>
</section>
</div>
<div id="activitiesView" class="admin-panel hidden"> <div id="activitiesView" class="admin-panel hidden">
<section class="admin-section" aria-labelledby="activitiesTitle"> <section class="admin-section" aria-labelledby="activitiesTitle">
<div class="section-heading"> <div class="section-heading">
+74 -1
View File
@@ -19,6 +19,8 @@
const emptyActivities = document.getElementById("emptyActivities"); const emptyActivities = document.getElementById("emptyActivities");
const activityMessage = document.getElementById("activityMessage"); const activityMessage = document.getElementById("activityMessage");
const activityAddRow = document.getElementById("activityAddRow"); const activityAddRow = document.getElementById("activityAddRow");
const bookingsList = document.getElementById("bookingsList");
const emptyBookings = document.getElementById("emptyBookings");
const newActivityIcon = document.getElementById("newActivityIcon"); const newActivityIcon = document.getElementById("newActivityIcon");
const newActivityName = document.getElementById("newActivityName"); const newActivityName = document.getElementById("newActivityName");
const newActivityType = document.getElementById("newActivityType"); const newActivityType = document.getElementById("newActivityType");
@@ -33,6 +35,7 @@
let viewYear = today.getFullYear(); let viewYear = today.getFullYear();
let viewMonth = today.getMonth(); let viewMonth = today.getMonth();
let activities = []; let activities = [];
let bookings = [];
function setMessage(element, message, success) { function setMessage(element, message, success) {
element.textContent = message; element.textContent = message;
@@ -149,6 +152,76 @@
prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth(); prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth();
} }
function renderBookings() {
bookingsList.innerHTML = "";
emptyBookings.classList.toggle("hidden", bookings.length > 0);
bookings.forEach((booking) => {
const row = document.createElement("li");
row.className = "booking-row";
const date = document.createElement("strong");
date.className = "booking-date";
date.textContent = formatDate(booking.date);
row.appendChild(date);
if (booking.activities && booking.activities.length > 0) {
const activityList = document.createElement("div");
activityList.className = "booking-activities";
booking.activities.forEach((activity) => {
const activityItem = document.createElement("span");
activityItem.className = "booking-activity";
activityItem.textContent = `${activity.icon || ""} ${activity.name}`.trim();
activityList.appendChild(activityItem);
});
row.appendChild(activityList);
}
const metadata = document.createElement("div");
metadata.className = "booking-metadata";
if (booking.name && booking.name.trim()) {
const name = document.createElement("span");
name.textContent = booking.name;
metadata.appendChild(name);
}
if (booking.bookedAt) {
const created = document.createElement("time");
created.dateTime = booking.bookedAt;
created.textContent = `Gebucht am ${new Date(booking.bookedAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })}`;
metadata.appendChild(created);
}
if (metadata.childElementCount > 0) row.appendChild(metadata);
const deleteButton = document.createElement("button");
deleteButton.className = "delete-booking-btn";
deleteButton.type = "button";
deleteButton.textContent = "Termin löschen";
deleteButton.addEventListener("click", () => deleteBooking(booking, deleteButton));
row.appendChild(deleteButton);
bookingsList.appendChild(row);
});
}
async function deleteBooking(booking, deleteButton) {
if (!window.confirm(`Den gebuchten Termin am ${formatDate(booking.date)} wirklich löschen?`)) return;
deleteButton.disabled = true;
try {
await request("/api/admin/bookings", { method: "DELETE", body: JSON.stringify({ date: booking.date }) });
bookedDates.delete(booking.date);
await loadBookings();
renderCalendar();
renderDates();
} catch (error) {
deleteButton.disabled = false;
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
else window.alert(error.message);
}
}
async function loadBookings() {
bookings = await request("/api/admin/bookings", { method: "GET" });
renderBookings();
}
async function loadDates() { async function loadDates() {
try { try {
const [loadedDates, loadedBookedDates] = await Promise.all([ const [loadedDates, loadedBookedDates] = await Promise.all([
@@ -161,7 +234,7 @@
draftDates = new Map(dates.map((date) => [date.date, date.type])); draftDates = new Map(dates.map((date) => [date.date, date.type]));
loginView.classList.add("hidden"); loginView.classList.add("hidden");
adminView.classList.remove("hidden"); adminView.classList.remove("hidden");
await loadActivities(); await Promise.all([loadActivities(), loadBookings()]);
renderCalendar(); renderCalendar();
renderDates(); renderDates();
} catch (error) { } catch (error) {
+1 -1
View File
@@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<div class="summary-item"> <div id="summaryNameItem" class="summary-item">
<span class="summary-icon">👤</span> <span class="summary-icon">👤</span>
<div class="summary-details"> <div class="summary-details">
<span class="summary-label">Name</span> <span class="summary-label">Name</span>
+6 -1
View File
@@ -7,6 +7,7 @@
const summaryActivity = document.getElementById("summaryActivity"); const summaryActivity = document.getElementById("summaryActivity");
const summaryDate = document.getElementById("summaryDate"); const summaryDate = document.getElementById("summaryDate");
const summaryName = document.getElementById("summaryName"); const summaryName = document.getElementById("summaryName");
const summaryNameItem = document.getElementById("summaryNameItem");
const activityIcon = document.getElementById("activityIcon"); const activityIcon = document.getElementById("activityIcon");
const checkoutBtn = document.getElementById("checkoutBtn"); const checkoutBtn = document.getElementById("checkoutBtn");
@@ -19,13 +20,17 @@
const selectedDate = localStorage.getItem("selectedDate"); const selectedDate = localStorage.getItem("selectedDate");
const selectedName = localStorage.getItem("selectedName"); const selectedName = localStorage.getItem("selectedName");
if (!selectedActivity || !selectedDate || !selectedName) { if (!selectedActivity || !selectedDate) {
window.location.href = "index.html"; window.location.href = "index.html";
return; return;
} }
summaryActivity.textContent = selectedActivity; summaryActivity.textContent = selectedActivity;
if (selectedName && selectedName.trim()) {
summaryName.textContent = selectedName; summaryName.textContent = selectedName;
} else {
summaryNameItem.classList.add("hidden");
}
const dateObj = new Date(`${selectedDate}T00:00:00`); const dateObj = new Date(`${selectedDate}T00:00:00`);
const formattedDate = dateObj.toLocaleDateString("de-DE", { const formattedDate = dateObj.toLocaleDateString("de-DE", {
+10 -4
View File
@@ -30,15 +30,21 @@
fetch("/api/settings", { cache: "no-store" }) fetch("/api/settings", { cache: "no-store" })
.then((response) => response.json()) .then((response) => response.json())
.then((settings) => renderNames(settings.names || [])) .then((settings) => {
const names = settings.names || [];
if (names.length === 0) {
localStorage.setItem("selectedName", "");
window.location.href = "checkout.html";
return;
}
renderNames(names);
})
.catch(() => { .catch(() => {
nameList.textContent = "Namen konnten nicht geladen werden."; nameList.textContent = "Namen konnten nicht geladen werden.";
}); });
function renderNames(names) { function renderNames(names) {
const options = names.length > 0 ? names : ["Du weißt, wer ich bin"]; names.forEach((name) => {
options.forEach((name) => {
const card = document.createElement("button"); const card = document.createElement("button");
card.type = "button"; card.type = "button";
card.className = "name-card"; card.className = "name-card";
+15 -3
View File
@@ -1,11 +1,23 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
settings_file="${SETTINGS_FILE:-/app/appsettings.json}" config_dir="/app/config"
settings_file="$config_dir/appsettings.json"
if [ ! -f "$settings_file" ]; then if [ ! -f "$settings_file" ]; then
mkdir -p "$(dirname "$settings_file")" echo "Missing mounted config file: $settings_file" >&2
cp /app/appsettings.default.json "$settings_file" exit 1
fi fi
activities_file="$config_dir/activities.json"
available_dates_file="$config_dir/dates_available.json"
booked_dates_file="$config_dir/dates_booked.json"
for config_file in "$activities_file" "$available_dates_file" "$booked_dates_file"; do
if [ ! -f "$config_file" ]; then
echo "Missing mounted config file: $config_file" >&2
exit 1
fi
done
exec /app/server exec /app/server