feat(booking): add service eligibility based on age and patch tests

- Add eligibility filtering to /api/services: exclude services below
  user's
  age, gray out services requiring patch tests that are missing/expired
- Add new endpoint /api/services/eligible-for/{user_id} for admin
  booking
  flows to check eligibility for a specific user
- Add image metadata stripping: uploads now strip all EXIF/GPS data
  via imaging library (security improvement)
- Update ServiceCard frontend: show grayed-out state for ineligible
  services with "contact us" link (public) or just warning (admin)
- Add 2 patch test services to seed data: Gel Polish Full Set,
  Luxury Gel Manicure (48h each)
- Remove deprecated local-dev.sh script
This commit is contained in:
2026-02-20 18:46:38 +00:00
parent eb1a719fc3
commit 41dc839830
13 changed files with 385 additions and 1647 deletions
+15 -10
View File
@@ -6,14 +6,13 @@ Crussell is a **fullstack application** that powers a nailbar / salon book
```
Crussell/
├─ backend/ # Go 1.22 + chi router API
├─ backend/ # Go 1.25 + chi router API
├─ frontend/ # SvelteKit 5 SPA (static build)
├─ sabredav/ # PHP + Composer for DAV
├─ nginx/ # Nginx reverseproxy for HTTP & HTTPS
├─ init-scripts/ # PostgreSQL init SQL
├─ compose.yml # DockerCompose definition
├─ local-dev.sh # Development helper using tmux
├─ local-dev-2.sh # Enhanced seeding script with more test data
├─ local-dev-2.sh # Development helper using tmux (seeded with 8 services incl. 2 with patch tests)
└─ README.md
```
@@ -55,13 +54,13 @@ docker compose up --build -d
After the containers are running, the frontend is reachable at `http://localhost`. The API is available at `http://localhost/api`. SabreDAV can be accessed via `http://localhost/dav`.
### Development with `local-dev.sh`
### Development with `local-dev-2.sh`
For a more interactive dev experience the repository ships a small helper script that launches Docker, starts a tmux session with three panes (PostgreSQL console, Go dev server, Svelte dev server) and seeds the database with an admin and a regular user plus a handful of sample services.
For a more interactive dev experience the repository ships a small helper script that launches Docker, starts a tmux session with four panes (PostgreSQL console, Go dev server, Svelte dev server, Rustfs logs) and seeds the database with an admin, regular users, and sample services including patch test services.
```bash
chmod +x local-dev.sh
./local-dev.sh
chmod +x local-dev-2.sh
./local-dev-2.sh
```
The script performs the following steps:
@@ -72,7 +71,8 @@ The script performs the following steps:
* `psql` console
* Go server (`go run -tags dev ./main.go`)
* Svelte dev server (`npm run dev -- --host`)
4. **Seeding** creates an admin (`admin@example.com`) and a regular user (`user@example.com`), updates the admin role, and registers six example services.
* Rustfs logs
4. **Seeding** creates admin (`admin@example.com`), regular users (`user@example.com`), 8 services (6 standard + 2 requiring patch tests), bookings, exceptional hours.
> **Note**: The script uses a temporary shell script to perform the HTTP calls, so no external tooling like `jq` is required.
@@ -117,11 +117,13 @@ Create a `.env` file in the project root based on the provided `.env.example`.
## 📊 Seeding Data
The `local-dev.sh` script automatically seeds:
The `local-dev-2.sh` script automatically seeds:
* Admin user (`admin@example.com` / `password`)
* Regular user (`user@example.com` / `password`)
* Six example nailbar services
* 8 services:
* 6 standard (no patch test)
* 2 with patch test requirement (48h) - Gel Polish Full Set, Luxury Gel Manicure
The enhanced `local-dev-2.sh` script provides additional test data including:
@@ -170,6 +172,8 @@ docker compose exec backend sh
| User Referrals | ✅ | ❌ | `user_referrals` table, backend logic exists |
| Token Refresh | ✅ | ✅ | POST /api/refresh-token, auto-refresh in auth store |
| Portfolio System | ✅ | ✅ | S3/R2 storage abstraction, tag-based filtering, category filters, admin upload, ?img= featured image param |
| Service Eligibility | ✅ | ✅ | Age + patch test filtering; `/api/services/eligible-for/{user_id}` for admin booking flows |
| Image Metadata Stripping | ✅ | ❌ | EXIF/GPS stripped on upload via `imaging` library |
### ⚠️ Partially Complete
@@ -278,6 +282,7 @@ grep -n "r\.\(Get\|Post\|Put\|Delete\|Patch\)" backend/main.go
- ⚠️ **Gap: Rate limiter doesn't read CF-Connecting-IP** - behind Cloudflare all users share one bucket
- ⚠️ **Gap: No HSTS header** - add when HTTPS working
- ⚠️ **Gap: No Referrer-Policy** - for analytics tracking
-**Image metadata stripping** - EXIF/GPS stripped on upload (security improvement)
**Input Validation:**
- Backend validates all inputs against DB schema constraints
+271 -4
View File
@@ -1,14 +1,17 @@
package services
import (
"crussell/auth"
"crussell/db"
"crussell/mw"
"database/sql"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
// Service represents a service in the system
@@ -33,6 +36,8 @@ type ServiceResponse struct {
DurationMinutes int `json:"duration_minutes"`
PatchTestDurationHours int `json:"patch_test_duration_hours"`
MinimumAgeRequired int `json:"minimum_age_required"`
// Patch test status for non-admin users
PatchTestStatus *string `json:"patch_test_status,omitempty"` // nil = not checked, "ok" = valid, "required" = no record, "expired" = record too old
}
// CreateServiceRequest represents the request payload for creating a new service
@@ -205,8 +210,30 @@ func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
}
// ServicesHandler returns all services from the database
// For non-admin logged-in users, filters based on age and patch test eligibility
func ServicesHandler(w http.ResponseWriter, r *http.Request) {
// Query all active services
// Check if user is authenticated - try context first, then optional token
userID, hasUser := r.Context().Value(mw.UserIDKey).(string)
role, _ := r.Context().Value(mw.UserRoleKey).(string)
// If no user in context, try to parse token from header
if !hasUser || userID == "" {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
var err error
userID, role, err = auth.VerifyToken(tokenString, r.Context())
if err != nil {
// Invalid token - treat as unauthenticated
userID = ""
role = ""
}
hasUser = userID != ""
}
}
// If not logged in or admin, return all services (current behavior)
if !hasUser || userID == "" || role == "admin" {
query := `
SELECT id, name, description, price, duration_minutes,
patch_test_duration_hours, minimum_age_required
@@ -249,20 +276,260 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Set response headers
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Return empty array instead of null if no services found
if services == nil {
services = []ServiceResponse{}
}
// Encode response
if err := json.NewEncoder(w).Encode(services); err != nil {
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
}
return
}
// User is logged in and not admin - check eligibility
// Get user's date of birth
var dob time.Time
err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
if err != nil {
http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError)
return
}
// Calculate age
now := time.Now()
age := now.Year() - dob.Year()
if now.YearDay() < dob.YearDay() {
age--
}
// Get all active services
query := `
SELECT id, name, description, price, duration_minutes,
patch_test_duration_hours, minimum_age_required
FROM services
WHERE is_active = TRUE
ORDER BY name
`
rows, err := db.DB.Query(r.Context(), query)
if err != nil {
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var services []ServiceResponse
var ineligibleServices []ServiceResponse
for rows.Next() {
var service ServiceResponse
err := rows.Scan(
&service.ID,
&service.Name,
&service.Description,
&service.Price,
&service.DurationMinutes,
&service.PatchTestDurationHours,
&service.MinimumAgeRequired,
)
if err != nil {
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
return
}
// Check age eligibility - EXCLUDE if user is too young (can't be fixed by user)
if age < service.MinimumAgeRequired {
continue
}
// Check patch test if required - GRAY OUT if not valid
if service.PatchTestDurationHours > 0 {
var lastTime time.Time
err := db.DB.QueryRow(r.Context(),
`SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`,
userID, service.ID).Scan(&lastTime)
if err == sql.ErrNoRows || err == pgx.ErrNoRows {
// No patch test record
status := "required"
service.PatchTestStatus = &status
ineligibleServices = append(ineligibleServices, service)
continue
} else if err != nil {
http.Error(w, "Failed to check patch test: "+err.Error(), http.StatusInternalServerError)
return
}
// Check if patch test is still valid
requiredSince := lastTime.Add(time.Duration(service.PatchTestDurationHours) * time.Hour)
if now.After(requiredSince) {
// Patch test expired - gray out
status := "expired"
service.PatchTestStatus = &status
ineligibleServices = append(ineligibleServices, service)
continue
}
// Patch test is valid - include normally
status := "ok"
service.PatchTestStatus = &status
services = append(services, service)
} else {
// No patch test required - include normally
services = append(services, service)
}
}
if err = rows.Err(); err != nil {
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
return
}
// Sort: eligible first (by name), then ineligible (by name)
// Combine: eligible + ineligible
services = append(services, ineligibleServices...)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if services == nil {
services = []ServiceResponse{}
}
if err := json.NewEncoder(w).Encode(services); err != nil {
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
}
}
// ServicesEligibleForUserHandler returns services with eligibility calculated for a specific user
// Used by admin booking flows when booking on behalf of a user
func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "user_id")
if userID == "" {
http.Error(w, "User ID required", http.StatusBadRequest)
return
}
// Get user's date of birth
var dob time.Time
err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
if err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError)
return
}
// Calculate age
now := time.Now()
age := now.Year() - dob.Year()
if now.YearDay() < dob.YearDay() {
age--
}
// Get all active services
query := `
SELECT id, name, description, price, duration_minutes,
patch_test_duration_hours, minimum_age_required
FROM services
WHERE is_active = TRUE
ORDER BY name
`
rows, err := db.DB.Query(r.Context(), query)
if err != nil {
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var services []ServiceResponse
var grayedOutServices []ServiceResponse
for rows.Next() {
var service ServiceResponse
err := rows.Scan(
&service.ID,
&service.Name,
&service.Description,
&service.Price,
&service.DurationMinutes,
&service.PatchTestDurationHours,
&service.MinimumAgeRequired,
)
if err != nil {
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
return
}
// Check age eligibility - EXCLUDE if user is too young
if age < service.MinimumAgeRequired {
continue
}
// Check patch test if required
if service.PatchTestDurationHours > 0 {
var lastTime time.Time
err := db.DB.QueryRow(r.Context(),
`SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`,
userID, service.ID).Scan(&lastTime)
if err == sql.ErrNoRows || err == pgx.ErrNoRows {
// No patch test record - gray out
status := "required"
service.PatchTestStatus = &status
grayedOutServices = append(grayedOutServices, service)
continue
} else if err != nil {
http.Error(w, "Failed to check patch test: "+err.Error(), http.StatusInternalServerError)
return
}
// Check if patch test is still valid
requiredSince := lastTime.Add(time.Duration(service.PatchTestDurationHours) * time.Hour)
if now.After(requiredSince) {
// Patch test expired - gray out
status := "expired"
service.PatchTestStatus = &status
grayedOutServices = append(grayedOutServices, service)
continue
}
// Patch test is valid
status := "ok"
service.PatchTestStatus = &status
services = append(services, service)
} else {
// No patch test required
services = append(services, service)
}
}
if err = rows.Err(); err != nil {
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
return
}
// Sort and combine: valid first, then grayed out
services = append(services, grayedOutServices...)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if services == nil {
services = []ServiceResponse{}
}
if err := json.NewEncoder(w).Encode(services); err != nil {
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
}
}
// AllServicesHandler returns all services including inactive ones (useful for admin)
+2 -1
View File
@@ -81,10 +81,11 @@ func main() {
// All API routes grouped under /api for clarity
r.Route("/api", func(r chi.Router) {
// Public read-only
// Public read-only (but check auth context if present for eligibility)
r.Group(func(r chi.Router) {
r.Use(mw.RateLimit(120, time.Minute))
r.Get("/services", services.ServicesHandler)
r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
})
// Registration: 10/min to prevent spam
@@ -202,6 +202,13 @@
wasOpen = open;
});
// Refetch services when selected user changes (for eligibility)
$effect(() => {
if (open && selectedUserId) {
fetchServices();
}
});
$effect(() => {
if (open && currentStep === 4) {
const dateToCheck = selectedDate || placeholder;
@@ -265,7 +272,12 @@
async function fetchServices() {
loadingServices = true;
try {
const response = await fetch('/api/services', {
let url = '/api/services';
// If a user is selected, get eligibility for that user
if (selectedUserId) {
url = `/api/services/eligible-for/${selectedUserId}`;
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
@@ -910,6 +922,7 @@
selected={selectedServices}
loading={loadingServices}
ontoggle={toggleService}
showContactLink={false}
/>
{/if}
@@ -116,6 +116,13 @@
wasOpen = open;
});
// Refetch services when selected user changes (for eligibility)
$effect(() => {
if (open && selectedUserId) {
fetchServices();
}
});
function resetState() {
currentStep = 1;
userType = 'member';
@@ -159,7 +166,12 @@
async function fetchServices() {
loadingServices = true;
try {
const response = await fetch('/api/services', {
let url = '/api/services';
// If a user is selected, get eligibility for that user
if (selectedUserId) {
url = `/api/services/eligible-for/${selectedUserId}`;
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
@@ -553,6 +565,7 @@
selected={selectedServices}
loading={loadingServices}
ontoggle={toggleService}
showContactLink={false}
/>
{/if}
@@ -61,7 +61,24 @@
if (response.ok) {
const data: Service[] = await response.json();
services = data;
// Sort: valid patch tests first (by name), then grayed out (by name)
const valid: Service[] = [];
const grayedOut: Service[] = [];
for (const service of data) {
if (service.patch_test_status === 'required' || service.patch_test_status === 'expired') {
grayedOut.push(service);
} else {
valid.push(service);
}
}
// Sort each group alphabetically
valid.sort((a, b) => a.name.localeCompare(b.name));
grayedOut.sort((a, b) => a.name.localeCompare(b.name));
// Combine: valid first, then grayed out
services = [...valid, ...grayedOut];
} else {
console.error('Failed to fetch services:', response.status);
toast.error('Failed to load services');
@@ -4,45 +4,49 @@
let {
service,
selected = false,
onclick
onclick,
showContactLink = false
}: {
service: Service;
selected?: boolean;
onclick?: () => void;
showContactLink?: boolean;
} = $props();
const isGrayedOut = $derived(
service.patch_test_status === 'required' || service.patch_test_status === 'expired'
);
</script>
<button
type="button"
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50 hover:text-accent-foreground {selected
? 'bg-fuchsia-100'
: ''}"
onclick={() => onclick?.()}
: ''} {isGrayedOut ? 'opacity-50' : ''}"
onclick={() => !isGrayedOut && onclick?.()}
disabled={isGrayedOut}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex flex-col justify-between h-full min-h-[6rem]">
<div>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description}</p>
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span>£{service.price}</span>
</div>
</div>
<div
class="ml-3 flex h-5 w-5 items-center justify-center rounded border-2 {selected
? 'border-primary bg-primary'
: 'border-gray-300'}"
aria-hidden="true"
>
{#if selected}
<svg class="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{#if isGrayedOut}
<p class="text-sm text-amber-600">
{#if service.patch_test_status === 'required'}
Patch test required
{:else}
Patch test expired
{/if}
{#if showContactLink}
- <a href="/contact" class="underline">contact us</a>
{/if}
</p>
{:else}
<p class="text-sm text-gray-600">{service.description}</p>
{/if}
</div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price}</span>
</div>
</div>
</button>
@@ -6,12 +6,14 @@
services = [],
selected = [],
loading = true,
ontoggle
ontoggle,
showContactLink = false
}: {
services?: Service[];
selected?: Service[];
loading?: boolean;
ontoggle?: (service: Service) => void;
showContactLink?: boolean;
} = $props();
function isServiceSelected(service: Service): boolean {
@@ -30,6 +32,7 @@
{service}
selected={isServiceSelected(service)}
onclick={() => ontoggle?.(service)}
{showContactLink}
/>
{/each}
{/if}
+2
View File
@@ -6,6 +6,8 @@ export interface Service {
duration_minutes: number;
patch_test_duration_hours: number;
minimum_age_required: number;
// Patch test status (present when user is authenticated, not admin)
patch_test_status?: 'ok' | 'required' | 'expired';
}
export interface CustomerInfo {
File diff suppressed because it is too large Load Diff
+2
View File
@@ -238,6 +238,8 @@ SERVICES=(
'{"name":"Express Mani & Pedi","description":"Quick file, shape, and polish for both hands and feet.","price":40.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
'{"name":"Gel Polish Removal","description":"Safe removal of existing gel polish.","price":10.00,"duration_minutes":20,"patch_test_duration_hours":0,"minimum_age_required":0}'
'{"name":"Nail Art Add-on","description":"Custom nail art, per two fingers.","price":5.00,"duration_minutes":15,"patch_test_duration_hours":0,"minimum_age_required":0}'
'{"name":"Gel Polish Full Set","description":"Full gel polish application - requires patch test 48h before.","price":45.00,"duration_minutes":60,"patch_test_duration_hours":48,"minimum_age_required":0}'
'{"name":"Luxury Gel Manicure","description":"Premium gel polish with extended massage - requires patch test 48h before.","price":55.00,"duration_minutes":75,"patch_test_duration_hours":48,"minimum_age_required":0}'
)
SERVICE_IDS=()
-455
View File
@@ -1,455 +0,0 @@
#!/usr/bin/env zsh
SESSION_NAME="crussell-dev"
BACKEND_PORT="8080"
# --- Load root .env into environment ---
set -a
source .env
set +a
echo "✅ Loaded environment from .env"
# --- Check if Docker is running ---
if ! docker info > /dev/null 2>&1; then
echo "🐳 Docker daemon is not running. Attempting to start it..."
sudo systemctl start docker
sleep 1
if ! docker info > /dev/null 2>&1; then
echo "❌ Failed to start Docker. Exiting."
exit 1
fi
echo "✅ Docker started successfully."
fi
# --- Reset PostgreSQL ---
echo "🗑️ Resetting PostgreSQL container..."
docker compose down -v postgres
docker compose up postgres -d
echo "⏳ Waiting 3 seconds for DB init..."
sleep 3
# --- Kill existing tmux session ---
tmux has-session -t $SESSION_NAME 2>/dev/null
if [ $? -eq 0 ]; then
echo "⚠️ Existing tmux session detected — killing it..."
tmux kill-session -t $SESSION_NAME
fi
# --- Start new tmux session ---
echo "🎛️ Starting tmux session: $SESSION_NAME"
tmux new-session -d -s $SESSION_NAME -n "DB"
# Pane 0: DB
tmux send-keys -t $SESSION_NAME "docker exec -it postgres psql -U myuser -d mydb -c \"\dt\"" C-m
# Split for Backend (pane 1) - split horizontally first
tmux split-window -v -t $SESSION_NAME
tmux send-keys -t $SESSION_NAME:0.1 "cd backend && go run -tags dev ./main.go" C-m
# Split for Frontend (pane 2) - split the backend pane vertically
tmux split-window -h -t $SESSION_NAME:0.1
tmux send-keys -t $SESSION_NAME:0.2 "cd frontend && npm run dev -- --host" C-m
# Create a temporary script for seeding with correct field names
cat > /tmp/seed_data.sh << 'EOF'
#!/bin/bash
ADMIN_EMAIL="admin@example.com"
ADMIN_PASS="password"
USER_EMAIL="user@example.com"
USER_PASS="password"
BASE_URL="http://localhost:8080/api"
echo "⏳ Waiting for backend to be ready..."
# Wait for backend to start responding
for i in {1..30}; do
if curl -s http://localhost:8080/health > /dev/null 2>&1 || curl -s http://localhost:8080/api/health > /dev/null 2>&1; then
echo "✅ Backend is ready!"
break
fi
if [ $i -eq 30 ]; then
echo "❌ Backend failed to start within 30 seconds"
exit 1
fi
echo "Waiting for backend... ($i/30)"
sleep 1
done
echo "1️⃣ Registering Admin User: $ADMIN_EMAIL"
REGISTER_JSON='{"firstName":"Admin","lastName":"User","email":"'$ADMIN_EMAIL'","password":"'$ADMIN_PASS'","phone":"+447000000000","dateOfBirth":"1985-01-01","agreedToPolicy":true}'
REGISTER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$REGISTER_JSON" $BASE_URL/register)
HTTP_CODE=$(echo "$REGISTER_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$REGISTER_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "201" ]; then
echo '✅ Registration successful.'
else
echo "❌ Registration failed with HTTP $HTTP_CODE"
echo "Response: $RESPONSE_BODY"
fi
echo "2️⃣ Registering Regular User: $USER_EMAIL"
USER_REGISTER_JSON='{"firstName":"Regular","lastName":"User","email":"'$USER_EMAIL'","password":"'$USER_PASS'","phone":"+447000000001","dateOfBirth":"1990-05-15","agreedToPolicy":true}'
USER_REGISTER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$USER_REGISTER_JSON" $BASE_URL/register)
HTTP_CODE=$(echo "$USER_REGISTER_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$USER_REGISTER_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "201" ]; then
echo '✅ User registration successful.'
else
echo "❌ User registration failed with HTTP $HTTP_CODE"
echo "Response: $RESPONSE_BODY"
fi
echo "3️⃣ Upgrading Admin Role via psql..."
docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET account_role = 'admin' WHERE email = '$ADMIN_EMAIL'"
echo "⏳ Waiting 1 seconds for role update to propagate..."
sleep 1
echo "4️⃣ Logging in as Admin to get JWT token..."
LOGIN_JSON='{"email":"'$ADMIN_EMAIL'","password":"'$ADMIN_PASS'"}'
LOGIN_RESPONSE=$(curl -s -X POST -H 'Content-Type: application/json' -d "$LOGIN_JSON" $BASE_URL/login)
# Extract token without jq (using grep and sed)
ADMIN_TOKEN=$(echo "$LOGIN_RESPONSE" | grep -o '"token":"[^"]*' | sed 's/"token":"//')
if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then
echo '❌ Admin login failed. Cannot proceed with service creation.'
echo "Login response: $LOGIN_RESPONSE"
exit 1
fi
echo '✅ Admin login successful. Token obtained.'
echo "Admin Token (first 30 chars): ${ADMIN_TOKEN:0:30}..."
echo "5️⃣ Logging in as User to get JWT token..."
USER_LOGIN_JSON='{"email":"'$USER_EMAIL'","password":"'$USER_PASS'"}'
USER_LOGIN_RESPONSE=$(curl -s -X POST -H 'Content-Type: application/json' -d "$USER_LOGIN_JSON" $BASE_URL/login)
# Extract token without jq (using grep and sed)
USER_TOKEN=$(echo "$USER_LOGIN_RESPONSE" | grep -o '"token":"[^"]*' | sed 's/"token":"//')
if [ -z "$USER_TOKEN" ] || [ "$USER_TOKEN" = "null" ]; then
echo '❌ User login failed. Cannot proceed with booking creation.'
echo "Login response: $USER_LOGIN_RESPONSE"
exit 1
fi
echo '✅ User login successful. Token obtained.'
echo "User Token (first 30 chars): ${USER_TOKEN:0:30}..."
# Add extra delay to ensure token is fully processed
echo "⏳ Waiting 1 seconds before creating services..."
sleep 1
echo 6️⃣ Creating 6 Nail Bar Services...
# Updated services with correct field names based on CreateServiceRequest struct
# Note: description is optional (omitempty), all other fields are required
SERVICES=(
'{"name":"Classic Manicure","description":"Nail shaping, cuticle care, hand massage, and polish.","price":25.00,"duration_minutes":45,"patch_test_duration_hours":0,"minimum_age_required":0}'
'{"name":"Gel Manicure (BIAB)","description":"Hard-wearing gel polish with Builder In A Bottle base.","price":35.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
'{"name":"Luxury Pedicure","description":"Foot soak, scrub, mask, extended massage, and polish.","price":45.00,"duration_minutes":75,"patch_test_duration_hours":0,"minimum_age_required":0}'
'{"name":"Express Mani & Pedi","description":"Quick file, shape, and polish for both hands and feet.","price":40.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
'{"name":"Gel Polish Removal","description":"Safe removal of existing gel polish.","price":10.00,"duration_minutes":20,"patch_test_duration_hours":0,"minimum_age_required":0}'
'{"name":"Nail Art Add-on","description":"Custom nail art, per two fingers.","price":5.00,"duration_minutes":15,"patch_test_duration_hours":0,"minimum_age_required":0}'
)
SERVICE_IDS=()
SUCCESS_COUNT=0
FAIL_COUNT=0
for SERVICE_JSON in "${SERVICES[@]}"; do
SERVICE_NAME=$(echo "$SERVICE_JSON" | grep -o '"name":"[^"]*' | cut -d'"' -f4)
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Creating: $SERVICE_NAME"
echo "Request JSON: $SERVICE_JSON"
CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d "$SERVICE_JSON" \
"$BASE_URL/admin/services")
HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "201" ]; then
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
echo "✅ Created: $SERVICE_NAME"
# Extract service ID from response
SERVICE_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":"[^"]*' | cut -d'"' -f4)
if [ -n "$SERVICE_ID" ]; then
SERVICE_IDS+=("$SERVICE_ID")
echo " Service ID: $SERVICE_ID"
fi
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "❌ Failed to create: $SERVICE_NAME (HTTP $HTTP_CODE)"
if [ -n "$RESPONSE_BODY" ]; then
echo "Response body: $RESPONSE_BODY"
fi
fi
sleep 0.15 # Small delay between requests
done
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ Successfully created: $SUCCESS_COUNT services"
echo "❌ Failed: $FAIL_COUNT services"
# Display collected service IDs
echo ""
echo "Created Service IDs:"
for i in "${!SERVICE_IDS[@]}"; do
echo " $((i+1)). ${SERVICE_IDS[$i]}"
done
# --- 7️⃣ Create Demo Bookings ---
echo ""
echo "7️⃣ Creating 41 Demo Bookings (35 past + 6 future)..."
# Function to create a booking
create_booking() {
local TOKEN=$1
local START_TIME=$2
local SERVICE_IDS_JSON=$3
local NOTES=$4
local BOOKING_NAME=$5
local BOOKING_JSON="{\"start_time\":\"$START_TIME\",\"service_ids\":$SERVICE_IDS_JSON"
if [ -n "$NOTES" ]; then
BOOKING_JSON="$BOOKING_JSON,\"notes\":\"$NOTES\""
fi
BOOKING_JSON="$BOOKING_JSON}"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Creating: $BOOKING_NAME"
echo "Time: $START_TIME"
echo "Services: $SERVICE_IDS_JSON"
CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d "$BOOKING_JSON" \
"$BASE_URL/bookings")
HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "201" ]; then
echo "✅ Created: $BOOKING_NAME"
BOOKING_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":"[^"]*' | cut -d'"' -f4)
echo " Booking ID: $BOOKING_ID"
else
echo "❌ Failed to create: $BOOKING_NAME (HTTP $HTTP_CODE)"
if [ -n "$RESPONSE_BODY" ]; then
echo "Response body: $RESPONSE_BODY"
fi
fi
sleep 0.2
}
# Use TZ=Europe/London to generate London-local times with correct offset
# Note: date command respects TZ for parsing and formatting
# Helper function to format a London time as RFC3339 with 'T' (required by Go backend)
format_london_time() {
local DATE_PART="$1"
local TIME_PART="$2"
TZ=Europe/London date -d "$DATE_PART $TIME_PART" +"%Y-%m-%dT%H:%M:%S%:z"
}
# --- TODAY BOOKINGS (4 bookings) ---
echo ""
echo "Creating 4 bookings for today..."
TODAY_DATE=$(TZ=Europe/London date +%Y-%m-%d)
create_booking "$USER_TOKEN" "$(format_london_time "$TODAY_DATE" "09:30:00")" \
"[\"${SERVICE_IDS[0]}\"]" "" "Today - Classic Manicure 09:30"
create_booking "$USER_TOKEN" "$(format_london_time "$TODAY_DATE" "11:00:00")" \
"[\"${SERVICE_IDS[1]}\"]" "" "Today - Gel Manicure 11:00"
create_booking "$USER_TOKEN" "$(format_london_time "$TODAY_DATE" "13:30:00")" \
"[\"${SERVICE_IDS[3]}\"]" "Lunch break slot" "Today - Express Mani & Pedi 13:30"
create_booking "$USER_TOKEN" "$(format_london_time "$TODAY_DATE" "16:00:00")" \
"[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" \
"End of day treat" "Today - Classic + Nail Art 16:00"
# --- PAST BOOKINGS (35 bookings from October-November 2025) ---
echo ""
echo "Creating 35 past bookings..."
# October 2025 bookings (15 bookings)
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-01" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 1 - Classic Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-03" "14:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 3 - Gel Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-05" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "Treating myself" "Oct 5 - Luxury Pedicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-08" "15:45:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Oct 8 - Express Mani & Pedi"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-10" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Simple nail art" "Oct 10 - Classic + Nail Art"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-12" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 12 - Gel Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-15" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Oct 15 - Gel Removal"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-17" "10:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 17 - Luxury Pedicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-19" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 19 - Classic Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-22" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "Quick refresh" "Oct 22 - Express Mani & Pedi"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-24" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Oct 24 - Gel + Nail Art"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-26" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 26 - Classic Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-28" "13:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 28 - Luxury Pedicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-29" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "" "Oct 29 - Removal + New Gel"
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-31" "12:00:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Halloween nails!" "Oct 31 - Classic + Nail Art"
# November 2025 bookings (20 bookings)
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-02" "10:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 2 - Gel Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-04" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 4 - Classic Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-05" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 5 - Express Mani & Pedi"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-07" "15:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 7 - Luxury Pedicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-09" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 9 - Classic + Nail Art"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-11" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 11 - Gel Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-12" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Nov 12 - Gel Removal"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-14" "10:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 14 - Classic Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-16" "14:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 16 - Express Mani & Pedi"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-18" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 18 - Luxury Pedicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-19" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 19 - Gel + Nail Art"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-21" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 21 - Classic Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-22" "13:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 22 - Gel Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-23" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[0]}\"]" "" "Nov 23 - Removal + Classic"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-25" "10:00:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 25 - Express Mani & Pedi"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-26" "14:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 26 - Luxury Pedicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-27" "11:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 27 - Classic + Nail Art"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-28" "15:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 28 - Gel Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "09:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 29 - Classic Manicure"
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "13:00:00")" "[\"${SERVICE_IDS[3]}\"]" "Last one before December!" "Nov 29 - Express Mani & Pedi"
# --- FUTURE BOOKINGS (6 bookings) ---
echo ""
echo "Creating 6 future bookings..."
# Get tomorrow at 08:00 London time as base (ensures future)
TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d)
# Calculate future dates in London time
NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d)
WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d)
# Create demo bookings using London time with proper offset
create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Classic Manicure - Tomorrow 10:00"
create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "14:30:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "Want French manicure with simple nail art on accent fingers" "Gel Manicure + Nail Art - Tomorrow 14:30"
create_booking "$USER_TOKEN" "$(format_london_time "$NEXT_WEEK_DATE" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "Special treat for myself" "Luxury Pedicure - Next Week 11:00"
create_booking "$USER_TOKEN" "$(format_london_time "$NEXT_WEEK_DATE" "15:45:00")" "[\"${SERVICE_IDS[3]}\"]" "Need quick refresh before event" "Express Mani & Pedi - Next Week 15:45"
create_booking "$USER_TOKEN" "$(format_london_time "$WEEK_AFTER_DATE" "13:15:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "Remove old gel and apply new BIAB" "Gel Removal + New Gel - Week After 13:15"
create_booking "$USER_TOKEN" "$(format_london_time "$WEEK_AFTER_DATE" "16:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Birthday celebration - want something special!" "Classic + Nail Art - Week After 16:30"
# --- 8️⃣ Create Holiday Exceptional Groups ---
echo ""
echo "8️⃣ Creating Holiday Exceptional Groups..."
# November Break (week of Nov 10-16, 2025) - Closed entirely
NOVEMBER_BREAK='{
"name": "November Break",
"description": "Short break period in November",
"hours": [
{"weekday": 0, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 1, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 2, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 3, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 4, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 5, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 6, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}
],
"weekStarts": ["2025-11-10"]
}'
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Creating: November Break"
NOV_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d "$NOVEMBER_BREAK" \
"$BASE_URL/scheduling/exceptional-groups")
HTTP_CODE=$(echo "$NOV_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$NOV_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "201" ]; then
echo "✅ Created: November Break"
else
echo "❌ Failed to create November Break (HTTP $HTTP_CODE)"
echo "Response: $RESPONSE_BODY"
fi
sleep 0.2
# Christmas Holiday (weeks of Dec 22-28 and Dec 29-Jan 4) - Limited hours
CHRISTMAS_BREAK='{
"name": "Christmas Holiday Period",
"description": "Reduced hours for Christmas and New Year",
"hours": [
{"weekday": 0, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 1, "startTime": "10:00:00", "endTime": "15:00:00", "isOpen": true},
{"weekday": 2, "startTime": "10:00:00", "endTime": "15:00:00", "isOpen": true},
{"weekday": 3, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 4, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 5, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
{"weekday": 6, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}
],
"weekStarts": ["2025-12-22", "2025-12-29"]
}'
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Creating: Christmas Holiday Period"
XMAS_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d "$CHRISTMAS_BREAK" \
"$BASE_URL/scheduling/exceptional-groups")
HTTP_CODE=$(echo "$XMAS_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$XMAS_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "201" ]; then
echo "✅ Created: Christmas Holiday Period"
else
echo "❌ Failed to create Christmas Holiday Period (HTTP $HTTP_CODE)"
echo "Response: $RESPONSE_BODY"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🎉 Seeding completed!"
EOF
chmod +x /tmp/seed_data.sh
echo "🌱 Starting data seeding in background..."
# Run seeding in a temporary window
tmux new-window -t $SESSION_NAME -n "Seeding" "bash /tmp/seed_data.sh; echo 'Seeding completed. Press any key to close...'; read -n1"
# Monitor the seeding window and close it when done
(
# Wait for the seeding window process to complete
while tmux list-windows -t $SESSION_NAME | grep -q "Seeding"; do
sleep 1
done
) &
# Set pane titles
tmux select-pane -t $SESSION_NAME:0.0 -T "DB"
tmux select-pane -t $SESSION_NAME:0.1 -T "Backend"
tmux select-pane -t $SESSION_NAME:0.2 -T "Frontend"
# Use even-vertical layout for better proportions
tmux select-layout -t $SESSION_NAME even-vertical
# Focus on the DB pane
tmux select-pane -t $SESSION_NAME:0.0
# Clean up temp file on exit
trap 'rm -f /tmp/seed_data.sh' EXIT
# Attach to session
tmux attach-session -t $SESSION_NAME
+13 -2
View File
@@ -21,6 +21,14 @@
- [x] Login rate limiting (1 attempt per 5 seconds)
- [x] Global rate limiting middleware (per-endpoint: 120/min public, 10/min register, 60/min filters, none admin)
- [x] Security headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection)
- [x] **Image metadata stripping** - All EXIF/GPS stripped on upload via `imaging` library (security)
- [x] Service eligibility system - Age and patch test filtering for bookings
- `/api/services` - Returns services with eligibility for authenticated users
- `/api/services/eligible-for/{user_id}` - Returns services with eligibility for specific user (admin booking flows)
- Age < minimum_age_required → Service EXCLUDED
- Patch test required + no record → Service GRAYED OUT
- Patch test expired → Service GRAYED OUT
- Patch test valid / not required → Normal
- [ ] **Strict-Transport-Security (HSTS)** - Tell browsers to only access via HTTPS, prevents downgrade attacks. Add after HTTPS is working in prod.
- [ ] **Referrer-Policy** - Track referrer sources for analytics (social media tracking). Use `strict-origin-when-cross-origin` to send origin but not full URLs.
- [ ] **Rate limiter + Cloudflare** - Currently doesn't read CF-Connecting-IP header, so behind Cloudflare all users share one rate limit bucket.
@@ -106,10 +114,12 @@
#### Booking Flow
- [x] Service selection with pricing/duration
- [x] **Service eligibility display** - Gray out services requiring patch test or below minimum age
- [x] Calendar with availability detection
- [x] Time slot generation with gap logic
- [x] Customer details form (guest or authenticated)
- [x] Auth store with token refresh logic
- [x] **Admin booking flows** - Call-in and walk-in use `/api/services/eligible-for/{user_id}` for user-specific eligibility
- [ ] **Customer booking submit** - `submitBooking()` only logs, needs `POST /api/bookings`
- [ ] Payment integration (Square placeholder)
@@ -203,7 +213,8 @@ flowchart TD
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/services` | List active services |
| GET | `/api/services` | List active services (with eligibility for authenticated users) |
| GET | `/api/services/eligible-for/{user_id}` | List services filtered by user's age and patch test status (admin only) |
| POST | `/api/register` | Create new user account |
| POST | `/api/login` | Authenticate and receive JWT |
| GET | `/api/scheduling/default-hours` | Get weekly default hours |
@@ -502,7 +513,7 @@ Running `local-dev-2.sh` creates:
| Resource | Count | Details |
|----------|-------|---------|
| Users | 18 | 1 admin, 17 regular users |
| Services | 6 | Classic Manicure, Gel Manicure (BIAB), Luxury Pedicure, Express Mani & Pedi, Gel Removal, Nail Art Add-on |
| Services | 8 | 6 standard (no patch test) + 2 requiring patch tests (Gel Polish Full Set 48h, Luxury Gel Manicure 48h) |
| Bookings | 45 | 8 past, 3 today, 4 tomorrow, 30 future (spread over 15 days) |
| Confirmed | ~50% | Random selection of upcoming bookings auto-confirmed |
| Exceptional | 2 | November Break (closed), Christmas Holiday (reduced hours) |