feat: customer relationship view, idempotency keys, approval decline, seed payments, backlog cleanup
- #3: Wire ApprovalModal handleDecline to POST /api/admin/bookings/{id}/cancel - #25: New GET /api/admin/users/{id}/relationship endpoint with spend, tips, visits, customer-for duration, top services - #25: UserModal reorganized — Personal Info, Booking History, Customer Relationship, Loyalty, Patch Tests - #36: Idempotency keys on user and admin booking creation (UUID header, duplicate detection) - local-dev-2.sh: seed payments via PL/pgSQL for completed bookings (5 randomized scenarios) - local-dev-2.sh: shrink guest/time-blocker output, add payments to summary - Backlog: mark #3/#25/#35/#36/#49 done, plan #36/#45, remove #46/#48, update #45 with milestone campaigns - Remove notes history table, avg visits/year metric, Account Information, Privacy & Consent from UserModal
This commit is contained in:
+114
-102
@@ -704,7 +704,6 @@ echo "${C_GREEN}✅ Confirmed $confirmed_count bookings, left $skipped_count pen
|
||||
# ===========================================================================
|
||||
echo -e "\n${C_BLUE}👤 Creating Guest Accounts & Bookings...${C_RESET}"
|
||||
|
||||
# Create guest users via the public guest endpoint
|
||||
create_guest() {
|
||||
local name="$1" email="$2" phone="$3"
|
||||
curl -s -X POST -H 'Content-Type: application/json' \
|
||||
@@ -717,90 +716,27 @@ create_guest() {
|
||||
GUEST1_ID=$(create_guest "Nina" "nina.guest@example.com" "+447000000020")
|
||||
GUEST2_ID=$(create_guest "Bob" "bob.guest@example.com" "+447000000021")
|
||||
GUEST3_ID=$(create_guest "Carol" "carol.guest@example.com" "+447000000022")
|
||||
# Same email as GUEST1 — should create a separate account (duplicate emails allowed for guests)
|
||||
GUEST4_ID=$(create_guest "Diana" "nina.guest@example.com" "+447000000023")
|
||||
# Same email as registered user — should fail (blocked)
|
||||
GUEST5_ID=$(create_guest "Evil" "$USER_EMAIL" "+447000000024")
|
||||
|
||||
count_guest=0
|
||||
|
||||
if [[ -n "$GUEST1_ID" ]]; then
|
||||
echo "${C_GREEN}✅ Created guest: Nina ($GUEST1_ID)${C_RESET}"
|
||||
# Book 16 days out — beyond the upcoming loop range (day_offset 2-15)
|
||||
D_G1=$(open_day "$(TZ=Europe/London date -d "$TODAY +16 days" +%Y-%m-%d)")
|
||||
GUEST1_TIME=$(format_london_time "$D_G1" "$SLOT_B")
|
||||
# Step 1: Reserve the slot (matches frontend flow)
|
||||
GUEST1_RESERVE_JSON="{\"start_time\":\"$GUEST1_TIME\",\"service_ids\":[\"$(get_svc 0)\"]}"
|
||||
G1_RES_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST1_RESERVE_JSON" "$BASE_URL/bookings/reserve")
|
||||
G1_RES_CODE=$(echo "$G1_RES_RESP" | tail -n1)
|
||||
if [[ ! "$G1_RES_CODE" =~ ^2 ]]; then
|
||||
G1_RES_BODY=$(echo "$G1_RES_RESP" | sed '$d')
|
||||
echo "${C_RED}❌ Guest reserve failed: Nina (HTTP $G1_RES_CODE) — $G1_RES_BODY${C_RESET}"
|
||||
else
|
||||
# Step 2: Create the booking
|
||||
GUEST1_JSON="{\"user_id\":\"$GUEST1_ID\",\"start_time\":\"$GUEST1_TIME\",\"service_ids\":[\"$(get_svc 0)\"]}"
|
||||
G1_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST1_JSON" "$BASE_URL/bookings")
|
||||
G1_CODE=$(echo "$G1_RESP" | tail -n1)
|
||||
if [[ "$G1_CODE" =~ ^2 ]]; then count_guest=$((count_guest+1)); echo "${C_GREEN}✅ Guest booking: Nina - Classic Manicure ($D_G1)${C_RESET}";
|
||||
else echo "${C_RED}❌ Guest booking failed: Nina (HTTP $G1_CODE) — $(echo "$G1_RESP" | sed '$d')${C_RESET}"; fi
|
||||
fi
|
||||
fi
|
||||
guest_book() {
|
||||
local gid="$1" time="$2" svc_idx="$3"
|
||||
[[ -z "$gid" ]] && return
|
||||
local reserve_json="{\"start_time\":\"$time\",\"service_ids\":[\"$(get_svc $svc_idx)\"]}"
|
||||
local res_resp=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$reserve_json" "$BASE_URL/bookings/reserve")
|
||||
local res_code=$(echo "$res_resp" | tail -n1)
|
||||
[[ ! "$res_code" =~ ^2 ]] && return
|
||||
local book_json="{\"user_id\":\"$gid\",\"start_time\":\"$time\",\"service_ids\":[\"$(get_svc $svc_idx)\"]}"
|
||||
local book_resp=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$book_json" "$BASE_URL/bookings")
|
||||
local book_code=$(echo "$book_resp" | tail -n1)
|
||||
[[ "$book_code" =~ ^2 ]] && count_guest=$((count_guest+1))
|
||||
}
|
||||
|
||||
if [[ -n "$GUEST2_ID" ]]; then
|
||||
echo "${C_GREEN}✅ Created guest: Bob ($GUEST2_ID)${C_RESET}"
|
||||
# 20 days out — beyond the upcoming loop range
|
||||
D_G2=$(open_day "$(TZ=Europe/London date -d "$TODAY +20 days" +%Y-%m-%d)")
|
||||
GUEST2_TIME=$(format_london_time "$D_G2" "$SLOT_C")
|
||||
# Step 1: Reserve the slot
|
||||
GUEST2_RESERVE_JSON="{\"start_time\":\"$GUEST2_TIME\",\"service_ids\":[\"$(get_svc 3)\"]}"
|
||||
G2_RES_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST2_RESERVE_JSON" "$BASE_URL/bookings/reserve")
|
||||
G2_RES_CODE=$(echo "$G2_RES_RESP" | tail -n1)
|
||||
if [[ ! "$G2_RES_CODE" =~ ^2 ]]; then
|
||||
G2_RES_BODY=$(echo "$G2_RES_RESP" | sed '$d')
|
||||
echo "${C_RED}❌ Guest reserve failed: Bob (HTTP $G2_RES_CODE) — $G2_RES_BODY${C_RESET}"
|
||||
else
|
||||
# Step 2: Create the booking
|
||||
GUEST2_JSON="{\"user_id\":\"$GUEST2_ID\",\"start_time\":\"$GUEST2_TIME\",\"service_ids\":[\"$(get_svc 3)\"]}"
|
||||
G2_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST2_JSON" "$BASE_URL/bookings")
|
||||
G2_CODE=$(echo "$G2_RESP" | tail -n1)
|
||||
if [[ "$G2_CODE" =~ ^2 ]]; then count_guest=$((count_guest+1)); echo "${C_GREEN}✅ Guest booking: Bob - Express Mani & Pedi ($D_G2)${C_RESET}";
|
||||
else echo "${C_RED}❌ Guest booking failed: Bob (HTTP $G2_CODE) — $(echo "$G2_RESP" | sed '$d')${C_RESET}"; fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$GUEST3_ID" ]]; then
|
||||
echo "${C_GREEN}✅ Created guest: Carol ($GUEST3_ID)${C_RESET}"
|
||||
# 22 days out — beyond the upcoming loop range
|
||||
D_G3=$(open_day "$(TZ=Europe/London date -d "$TODAY +22 days" +%Y-%m-%d)")
|
||||
GUEST3_TIME=$(format_london_time "$D_G3" "$SLOT_D")
|
||||
# Step 1: Reserve the slot
|
||||
GUEST3_RESERVE_JSON="{\"start_time\":\"$GUEST3_TIME\",\"service_ids\":[\"$(get_svc 1)\"]}"
|
||||
G3_RES_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST3_RESERVE_JSON" "$BASE_URL/bookings/reserve")
|
||||
G3_RES_CODE=$(echo "$G3_RES_RESP" | tail -n1)
|
||||
if [[ ! "$G3_RES_CODE" =~ ^2 ]]; then
|
||||
G3_RES_BODY=$(echo "$G3_RES_RESP" | sed '$d')
|
||||
echo "${C_RED}❌ Guest reserve failed: Carol (HTTP $G3_RES_CODE) — $G3_RES_BODY${C_RESET}"
|
||||
else
|
||||
# Step 2: Create the booking
|
||||
GUEST3_JSON="{\"user_id\":\"$GUEST3_ID\",\"start_time\":\"$GUEST3_TIME\",\"service_ids\":[\"$(get_svc 1)\"]}"
|
||||
G3_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST3_JSON" "$BASE_URL/bookings")
|
||||
G3_CODE=$(echo "$G3_RESP" | tail -n1)
|
||||
if [[ "$G3_CODE" =~ ^2 ]]; then count_guest=$((count_guest+1)); echo "${C_GREEN}✅ Guest booking: Carol - Gel Manicure ($D_G3)${C_RESET}";
|
||||
else echo "${C_RED}❌ Guest booking failed: Carol (HTTP $G3_CODE) — $(echo "$G3_RESP" | sed '$d')${C_RESET}"; fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$GUEST4_ID" && "$GUEST4_ID" != "$GUEST1_ID" ]]; then
|
||||
echo "${C_GREEN}✅ Created guest: Diana ($GUEST4_ID) — shares email with Nina (separate account)${C_RESET}"
|
||||
else
|
||||
echo "${C_YELLOW}⚠️ Diana guest creation — should be separate from Nina${C_RESET}"
|
||||
fi
|
||||
|
||||
if [[ -z "$GUEST5_ID" ]]; then
|
||||
echo "${C_GREEN}✅ Blocked guest booking with registered email ($USER_EMAIL) — correct behaviour${C_RESET}"
|
||||
else
|
||||
echo "${C_YELLOW}⚠️ Guest booking with registered email should have been blocked${C_RESET}"
|
||||
fi
|
||||
[[ -n "$GUEST1_ID" ]] && guest_book "$GUEST1_ID" "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +16 days" +%Y-%m-%d)")" "$SLOT_B")" 0
|
||||
[[ -n "$GUEST2_ID" ]] && guest_book "$GUEST2_ID" "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +20 days" +%Y-%m-%d)")" "$SLOT_C")" 3
|
||||
[[ -n "$GUEST3_ID" ]] && guest_book "$GUEST3_ID" "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +22 days" +%Y-%m-%d)")" "$SLOT_D")" 1
|
||||
|
||||
echo "${C_GREEN}✅ Created $count_guest Guest Bookings${C_RESET}"
|
||||
|
||||
@@ -810,27 +746,17 @@ echo "${C_GREEN}✅ Created $count_guest Guest Bookings${C_RESET}"
|
||||
echo -e "\n${C_BLUE}🚫 Creating Time Blockers...${C_RESET}"
|
||||
count_blockers=0
|
||||
|
||||
# Staff meeting block — tomorrow 14:00-15:00
|
||||
TB1_TIME=$(format_london_time "$TOMORROW" "14:00:00")
|
||||
TB1_JSON="{\"start_time\":\"$TB1_TIME\",\"duration_minutes\":60,\"description\":\"Staff meeting\"}"
|
||||
TB1_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_TOKEN" -d "$TB1_JSON" "$BASE_URL/admin/time-blockers")
|
||||
TB1_CODE=$(echo "$TB1_RESP" | tail -n1)
|
||||
if [[ "$TB1_CODE" =~ ^2 ]]; then count_blockers=$((count_blockers+1)); echo "${C_GREEN}✅ Time blocker: Staff meeting (tomorrow 14:00)${C_RESET}"; fi
|
||||
tb() {
|
||||
local time="$1" dur="$2" desc="$3"
|
||||
local json="{\"start_time\":\"$time\",\"duration_minutes\":$dur,\"description\":\"$desc\"}"
|
||||
local resp=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_TOKEN" -d "$json" "$BASE_URL/admin/time-blockers")
|
||||
local code=$(echo "$resp" | tail -n1)
|
||||
[[ "$code" =~ ^2 ]] && count_blockers=$((count_blockers+1))
|
||||
}
|
||||
|
||||
# Holiday block — 7 days out, all day (just block a slot to demonstrate)
|
||||
D_TB2=$(open_day "$(TZ=Europe/London date -d "$TODAY +7 days" +%Y-%m-%d)")
|
||||
TB2_TIME=$(format_london_time "$D_TB2" "$SLOT_A")
|
||||
TB2_JSON="{\"start_time\":\"$TB2_TIME\",\"duration_minutes\":120,\"description\":\"Holiday — closed morning\"}"
|
||||
TB2_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_TOKEN" -d "$TB2_JSON" "$BASE_URL/admin/time-blockers")
|
||||
TB2_CODE=$(echo "$TB2_RESP" | tail -n1)
|
||||
if [[ "$TB2_CODE" =~ ^2 ]]; then count_blockers=$((count_blockers+1)); echo "${C_GREEN}✅ Time blocker: Holiday ($D_TB2 morning)${C_RESET}"; fi
|
||||
|
||||
# Late start block — block tomorrow morning until 11am
|
||||
TB3_TIME=$(format_london_time "$TOMORROW" "09:00:00")
|
||||
TB3_JSON="{\"start_time\":\"$TB3_TIME\",\"duration_minutes\":120,\"description\":\"Late start — closed until 11am\"}"
|
||||
TB3_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_TOKEN" -d "$TB3_JSON" "$BASE_URL/admin/time-blockers")
|
||||
TB3_CODE=$(echo "$TB3_RESP" | tail -n1)
|
||||
if [[ "$TB3_CODE" =~ ^2 ]]; then count_blockers=$((count_blockers+1)); echo "${C_GREEN}✅ Time blocker: Late start (tomorrow until 11am)${C_RESET}"; fi
|
||||
tb "$(format_london_time "$TOMORROW" "14:00:00")" 60 "Staff meeting"
|
||||
tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +7 days" +%Y-%m-%d)")" "$SLOT_A")" 120 "Holiday — closed morning"
|
||||
tb "$(format_london_time "$TOMORROW" "09:00:00")" 120 "Late start — closed until 11am"
|
||||
|
||||
echo "${C_GREEN}✅ Created $count_blockers Time Blockers${C_RESET}"
|
||||
|
||||
@@ -846,14 +772,11 @@ cancel_count=0
|
||||
# Discover actual booking_status enum values
|
||||
ENUM_VALUES=$(docker exec postgres psql -U myuser -d mydb -tAc \
|
||||
"SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = 'booking_status';" 2>/dev/null | tr -d '\r\n\t ')
|
||||
echo " ℹ️ booking_status enum: $ENUM_VALUES"
|
||||
|
||||
CLIENT_CANCEL_STATUS=$(echo "$ENUM_VALUES" | tr ',' '\n' | grep -i 'client' | head -1)
|
||||
ADMIN_CANCEL_STATUS=$(echo "$ENUM_VALUES" | tr ',' '\n' | grep -iE 'we_|admin' | head -1)
|
||||
# If no distinct admin status, fall back to the first cancel-looking value
|
||||
[[ -z "$CLIENT_CANCEL_STATUS" ]] && CLIENT_CANCEL_STATUS=$(echo "$ENUM_VALUES" | tr ',' '\n' | grep -i 'cancel' | head -1)
|
||||
[[ -z "$ADMIN_CANCEL_STATUS" ]] && ADMIN_CANCEL_STATUS="$CLIENT_CANCEL_STATUS"
|
||||
echo " ℹ️ Cancel statuses — client: '$CLIENT_CANCEL_STATUS' admin: '$ADMIN_CANCEL_STATUS'"
|
||||
|
||||
db_cancel() {
|
||||
local booking_id="$1" status="$2" reason="$3"
|
||||
@@ -910,6 +833,94 @@ fi
|
||||
|
||||
echo "${C_GREEN}✅ Simulated $cancel_count cancellations${C_RESET}"
|
||||
|
||||
# ===========================================================================
|
||||
# 6b. PAYMENTS (seeded via SQL — no Square integration yet)
|
||||
# Payment over the booking total counts as a tip.
|
||||
# ===========================================================================
|
||||
echo -e "\n${C_BLUE}💳 Creating Payments...${C_RESET}"
|
||||
|
||||
# Mark past confirmed bookings as completed so payments can be seeded
|
||||
docker exec postgres psql -U myuser -d mydb -c \
|
||||
"UPDATE bookings SET status = 'completed'
|
||||
WHERE status = 'confirmed' AND start_time < NOW() - INTERVAL '1 hour'" > /dev/null 2>&1
|
||||
completed_count=$(docker exec postgres psql -U myuser -d mydb -tAc \
|
||||
"SELECT COUNT(*) FROM bookings WHERE status = 'completed'" 2>/dev/null)
|
||||
|
||||
# Pure SQL payment seeding — randomized per booking, no bash loops
|
||||
docker exec -i postgres psql -U myuser -d mydb << 'PAYMENT_SQL' > /dev/null 2>&1
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
booking_total NUMERIC(10,2);
|
||||
scenario INT;
|
||||
deposit NUMERIC(10,2);
|
||||
partial NUMERIC(10,2);
|
||||
balance NUMERIC(10,2);
|
||||
tip NUMERIC(10,2);
|
||||
paid NUMERIC(10,2);
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT b.id,
|
||||
COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) as total
|
||||
FROM bookings b
|
||||
JOIN booking_services bs ON bs.booking_id = b.id
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE b.status = 'completed'
|
||||
GROUP BY b.id
|
||||
LOOP
|
||||
booking_total := rec.total;
|
||||
scenario := floor(random() * 5)::INT;
|
||||
|
||||
CASE scenario
|
||||
WHEN 0 THEN
|
||||
deposit := ROUND(booking_total * 0.25, 2);
|
||||
balance := ROUND(booking_total - deposit, 2);
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'deposit', 'in_person_card', deposit, 'completed');
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'balance', 'in_person_card', balance, 'completed');
|
||||
WHEN 1 THEN
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'full', 'in_person_card', booking_total, 'completed');
|
||||
WHEN 2 THEN
|
||||
tip := ROUND((random() * 15 + 5)::NUMERIC, 2);
|
||||
paid := ROUND(booking_total + tip, 2);
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'full', 'in_person_card', paid, 'completed');
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'tip', 'in_person_card', tip, 'completed');
|
||||
WHEN 3 THEN
|
||||
partial := ROUND(booking_total * 0.5, 2);
|
||||
balance := ROUND(booking_total - partial, 2);
|
||||
tip := ROUND((random() * 10 + 3)::NUMERIC, 2);
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'partial', 'in_person_card', partial, 'completed');
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'balance', 'in_person_card', balance, 'completed');
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'tip', 'in_person_card', tip, 'completed');
|
||||
WHEN 4 THEN
|
||||
deposit := ROUND(booking_total * 0.20, 2);
|
||||
partial := ROUND(booking_total * 0.30, 2);
|
||||
balance := ROUND(booking_total - deposit - partial, 2);
|
||||
tip := ROUND((random() * 20 + 5)::NUMERIC, 2);
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'deposit', 'in_person_card', deposit, 'completed');
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'partial', 'in_person_card', partial, 'completed');
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'balance', 'in_person_card', balance, 'completed');
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES (rec.id, 'tip', 'in_person_card', tip, 'completed');
|
||||
END CASE;
|
||||
END LOOP;
|
||||
END $$;
|
||||
PAYMENT_SQL
|
||||
|
||||
payment_count=$(docker exec postgres psql -U myuser -d mydb -tAc \
|
||||
"SELECT COUNT(DISTINCT booking_id) FROM payments" 2>/dev/null)
|
||||
echo "${C_GREEN}✅ Created payments for $payment_count completed bookings${C_RESET}"
|
||||
|
||||
# ===========================================================================
|
||||
# 5. EXCEPTIONAL SCHEDULING GROUPS
|
||||
# ===========================================================================
|
||||
@@ -993,6 +1004,7 @@ echo -e " Bookings — total : $TOTAL_BOOKINGS"
|
||||
echo -e " Cancellations : $cancel_count"
|
||||
echo -e " Confirmed : $confirmed_count | Still pending: $skipped_count"
|
||||
echo -e " Bookings — guest : $count_guest"
|
||||
echo -e " Payments : $payment_count completed bookings"
|
||||
echo -e " Time blockers : $count_blockers"
|
||||
echo -e " Schedule groups : $sched_success/3"
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user