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:
@@ -6,14 +6,13 @@ Crussell is a **full‑stack application** that powers a nail‑bar / salon book
|
|||||||
|
|
||||||
```
|
```
|
||||||
Crussell/
|
Crussell/
|
||||||
├─ backend/ # Go 1.22 + chi router API
|
├─ backend/ # Go 1.25 + chi router API
|
||||||
├─ frontend/ # SvelteKit 5 SPA (static build)
|
├─ frontend/ # SvelteKit 5 SPA (static build)
|
||||||
├─ sabredav/ # PHP + Composer for DAV
|
├─ sabredav/ # PHP + Composer for DAV
|
||||||
├─ nginx/ # Nginx reverse‑proxy for HTTP & HTTPS
|
├─ nginx/ # Nginx reverse‑proxy for HTTP & HTTPS
|
||||||
├─ init-scripts/ # PostgreSQL init SQL
|
├─ init-scripts/ # PostgreSQL init SQL
|
||||||
├─ compose.yml # Docker‑Compose definition
|
├─ compose.yml # Docker‑Compose definition
|
||||||
├─ local-dev.sh # Development helper using tmux
|
├─ local-dev-2.sh # Development helper using tmux (seeded with 8 services incl. 2 with patch tests)
|
||||||
├─ local-dev-2.sh # Enhanced seeding script with more test data
|
|
||||||
└─ README.md
|
└─ README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,13 +54,13 @@ docker compose up --build -d
|
|||||||
|
|
||||||
After the containers are running, the front‑end is reachable at `http://localhost`. The API is available at `http://localhost/api`. SabreDAV can be accessed via `http://localhost/dav`.
|
After the containers are running, the front‑end 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
|
```bash
|
||||||
chmod +x local-dev.sh
|
chmod +x local-dev-2.sh
|
||||||
./local-dev.sh
|
./local-dev-2.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script performs the following steps:
|
The script performs the following steps:
|
||||||
@@ -72,7 +71,8 @@ The script performs the following steps:
|
|||||||
* `psql` console
|
* `psql` console
|
||||||
* Go server (`go run -tags dev ./main.go`)
|
* Go server (`go run -tags dev ./main.go`)
|
||||||
* Svelte dev server (`npm run dev -- --host`)
|
* 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.
|
> **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
|
## 📊 Seeding Data
|
||||||
|
|
||||||
The `local-dev.sh` script automatically seeds:
|
The `local-dev-2.sh` script automatically seeds:
|
||||||
|
|
||||||
* Admin user (`admin@example.com` / `password`)
|
* Admin user (`admin@example.com` / `password`)
|
||||||
* Regular user (`user@example.com` / `password`)
|
* Regular user (`user@example.com` / `password`)
|
||||||
* Six example nail‑bar 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:
|
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 |
|
| User Referrals | ✅ | ❌ | `user_referrals` table, backend logic exists |
|
||||||
| Token Refresh | ✅ | ✅ | POST /api/refresh-token, auto-refresh in auth store |
|
| 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 |
|
| 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
|
### ⚠️ 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: 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 HSTS header** - add when HTTPS working
|
||||||
- ⚠️ **Gap: No Referrer-Policy** - for analytics tracking
|
- ⚠️ **Gap: No Referrer-Policy** - for analytics tracking
|
||||||
|
- ✅ **Image metadata stripping** - EXIF/GPS stripped on upload (security improvement)
|
||||||
|
|
||||||
**Input Validation:**
|
**Input Validation:**
|
||||||
- Backend validates all inputs against DB schema constraints
|
- Backend validates all inputs against DB schema constraints
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crussell/auth"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service represents a service in the system
|
// Service represents a service in the system
|
||||||
@@ -33,6 +36,8 @@ type ServiceResponse struct {
|
|||||||
DurationMinutes int `json:"duration_minutes"`
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
PatchTestDurationHours int `json:"patch_test_duration_hours"`
|
PatchTestDurationHours int `json:"patch_test_duration_hours"`
|
||||||
MinimumAgeRequired int `json:"minimum_age_required"`
|
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
|
// 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
|
// 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) {
|
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 := `
|
query := `
|
||||||
SELECT id, name, description, price, duration_minutes,
|
SELECT id, name, description, price, duration_minutes,
|
||||||
patch_test_duration_hours, minimum_age_required
|
patch_test_duration_hours, minimum_age_required
|
||||||
@@ -249,20 +276,260 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set response headers
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
// Return empty array instead of null if no services found
|
|
||||||
if services == nil {
|
if services == nil {
|
||||||
services = []ServiceResponse{}
|
services = []ServiceResponse{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode response
|
|
||||||
if err := json.NewEncoder(w).Encode(services); err != nil {
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
||||||
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
return
|
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)
|
// AllServicesHandler returns all services including inactive ones (useful for admin)
|
||||||
|
|||||||
+2
-1
@@ -81,10 +81,11 @@ func main() {
|
|||||||
// All API routes grouped under /api for clarity
|
// All API routes grouped under /api for clarity
|
||||||
r.Route("/api", func(r chi.Router) {
|
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.Group(func(r chi.Router) {
|
||||||
r.Use(mw.RateLimit(120, time.Minute))
|
r.Use(mw.RateLimit(120, time.Minute))
|
||||||
r.Get("/services", services.ServicesHandler)
|
r.Get("/services", services.ServicesHandler)
|
||||||
|
r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Registration: 10/min to prevent spam
|
// Registration: 10/min to prevent spam
|
||||||
|
|||||||
@@ -202,6 +202,13 @@
|
|||||||
wasOpen = open;
|
wasOpen = open;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refetch services when selected user changes (for eligibility)
|
||||||
|
$effect(() => {
|
||||||
|
if (open && selectedUserId) {
|
||||||
|
fetchServices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && currentStep === 4) {
|
if (open && currentStep === 4) {
|
||||||
const dateToCheck = selectedDate || placeholder;
|
const dateToCheck = selectedDate || placeholder;
|
||||||
@@ -265,7 +272,12 @@
|
|||||||
async function fetchServices() {
|
async function fetchServices() {
|
||||||
loadingServices = true;
|
loadingServices = true;
|
||||||
try {
|
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}` }
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -910,6 +922,7 @@
|
|||||||
selected={selectedServices}
|
selected={selectedServices}
|
||||||
loading={loadingServices}
|
loading={loadingServices}
|
||||||
ontoggle={toggleService}
|
ontoggle={toggleService}
|
||||||
|
showContactLink={false}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,13 @@
|
|||||||
wasOpen = open;
|
wasOpen = open;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refetch services when selected user changes (for eligibility)
|
||||||
|
$effect(() => {
|
||||||
|
if (open && selectedUserId) {
|
||||||
|
fetchServices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
currentStep = 1;
|
currentStep = 1;
|
||||||
userType = 'member';
|
userType = 'member';
|
||||||
@@ -159,7 +166,12 @@
|
|||||||
async function fetchServices() {
|
async function fetchServices() {
|
||||||
loadingServices = true;
|
loadingServices = true;
|
||||||
try {
|
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}` }
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -553,6 +565,7 @@
|
|||||||
selected={selectedServices}
|
selected={selectedServices}
|
||||||
loading={loadingServices}
|
loading={loadingServices}
|
||||||
ontoggle={toggleService}
|
ontoggle={toggleService}
|
||||||
|
showContactLink={false}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,24 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data: Service[] = await response.json();
|
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 {
|
} else {
|
||||||
console.error('Failed to fetch services:', response.status);
|
console.error('Failed to fetch services:', response.status);
|
||||||
toast.error('Failed to load services');
|
toast.error('Failed to load services');
|
||||||
|
|||||||
@@ -4,45 +4,49 @@
|
|||||||
let {
|
let {
|
||||||
service,
|
service,
|
||||||
selected = false,
|
selected = false,
|
||||||
onclick
|
onclick,
|
||||||
|
showContactLink = false
|
||||||
}: {
|
}: {
|
||||||
service: Service;
|
service: Service;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
|
showContactLink?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const isGrayedOut = $derived(
|
||||||
|
service.patch_test_status === 'required' || service.patch_test_status === 'expired'
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="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
|
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'
|
? 'bg-fuchsia-100'
|
||||||
: ''}"
|
: ''} {isGrayedOut ? 'opacity-50' : ''}"
|
||||||
onclick={() => onclick?.()}
|
onclick={() => !isGrayedOut && onclick?.()}
|
||||||
|
disabled={isGrayedOut}
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex flex-col justify-between h-full min-h-[6rem]">
|
||||||
<div class="flex-1">
|
<div>
|
||||||
<h3 class="font-semibold">{service.name}</h3>
|
<h3 class="font-semibold">{service.name}</h3>
|
||||||
<p class="text-sm text-gray-600">{service.description}</p>
|
{#if isGrayedOut}
|
||||||
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
<p class="text-sm text-amber-600">
|
||||||
<span>{service.duration_minutes} mins</span>
|
{#if service.patch_test_status === 'required'}
|
||||||
<span>£{service.price}</span>
|
Patch test required
|
||||||
</div>
|
{:else}
|
||||||
</div>
|
Patch test expired
|
||||||
<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}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
services = [],
|
services = [],
|
||||||
selected = [],
|
selected = [],
|
||||||
loading = true,
|
loading = true,
|
||||||
ontoggle
|
ontoggle,
|
||||||
|
showContactLink = false
|
||||||
}: {
|
}: {
|
||||||
services?: Service[];
|
services?: Service[];
|
||||||
selected?: Service[];
|
selected?: Service[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
ontoggle?: (service: Service) => void;
|
ontoggle?: (service: Service) => void;
|
||||||
|
showContactLink?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function isServiceSelected(service: Service): boolean {
|
function isServiceSelected(service: Service): boolean {
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
{service}
|
{service}
|
||||||
selected={isServiceSelected(service)}
|
selected={isServiceSelected(service)}
|
||||||
onclick={() => ontoggle?.(service)}
|
onclick={() => ontoggle?.(service)}
|
||||||
|
{showContactLink}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface Service {
|
|||||||
duration_minutes: number;
|
duration_minutes: number;
|
||||||
patch_test_duration_hours: number;
|
patch_test_duration_hours: number;
|
||||||
minimum_age_required: number;
|
minimum_age_required: number;
|
||||||
|
// Patch test status (present when user is authenticated, not admin)
|
||||||
|
patch_test_status?: 'ok' | 'required' | 'expired';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerInfo {
|
export interface CustomerInfo {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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":"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":"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":"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=()
|
SERVICE_IDS=()
|
||||||
|
|||||||
-455
@@ -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
|
|
||||||
@@ -21,6 +21,14 @@
|
|||||||
- [x] Login rate limiting (1 attempt per 5 seconds)
|
- [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] 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] 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.
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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
|
#### Booking Flow
|
||||||
- [x] Service selection with pricing/duration
|
- [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] Calendar with availability detection
|
||||||
- [x] Time slot generation with gap logic
|
- [x] Time slot generation with gap logic
|
||||||
- [x] Customer details form (guest or authenticated)
|
- [x] Customer details form (guest or authenticated)
|
||||||
- [x] Auth store with token refresh logic
|
- [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`
|
- [ ] **Customer booking submit** - `submitBooking()` only logs, needs `POST /api/bookings`
|
||||||
- [ ] Payment integration (Square placeholder)
|
- [ ] Payment integration (Square placeholder)
|
||||||
|
|
||||||
@@ -203,7 +213,8 @@ flowchart TD
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| 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/register` | Create new user account |
|
||||||
| POST | `/api/login` | Authenticate and receive JWT |
|
| POST | `/api/login` | Authenticate and receive JWT |
|
||||||
| GET | `/api/scheduling/default-hours` | Get weekly default hours |
|
| GET | `/api/scheduling/default-hours` | Get weekly default hours |
|
||||||
@@ -502,7 +513,7 @@ Running `local-dev-2.sh` creates:
|
|||||||
| Resource | Count | Details |
|
| Resource | Count | Details |
|
||||||
|----------|-------|---------|
|
|----------|-------|---------|
|
||||||
| Users | 18 | 1 admin, 17 regular users |
|
| 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) |
|
| Bookings | 45 | 8 past, 3 today, 4 tomorrow, 30 future (spread over 15 days) |
|
||||||
| Confirmed | ~50% | Random selection of upcoming bookings auto-confirmed |
|
| Confirmed | ~50% | Random selection of upcoming bookings auto-confirmed |
|
||||||
| Exceptional | 2 | November Break (closed), Christmas Holiday (reduced hours) |
|
| Exceptional | 2 | November Break (closed), Christmas Holiday (reduced hours) |
|
||||||
|
|||||||
Reference in New Issue
Block a user