diff --git a/docs/booking-status-flow-plan.md b/docs/booking-status-flow-plan.md new file mode 100644 index 0000000..7cd72fc --- /dev/null +++ b/docs/booking-status-flow-plan.md @@ -0,0 +1,179 @@ +# Booking Status Flow — Planned Changes + +## Current Flow (What We Have Now) + +``` +new ──["Связались →"]──→ contacted ──["Подтвердить"]──→ confirmed + ──["Отказ"]──→ declined + +confirmed ──["Вернуть"]──→ contacted +declined ──["Вернуть"]──→ contacted +``` + +### What buttons appear at each status: + +| Status | Buttons shown | +|-----------|------------------------------------| +| new | "Связались →" | +| contacted | "Подтвердить", "Отказ" | +| confirmed | "Вернуть" | +| declined | "Вернуть" | + +--- + +## Problems Found + +### 1. Can't confirm directly from `new` +Someone calls, books, and confirms in one conversation. +Admin must click "Связались" → wait → click "Подтвердить". +Two clicks for one real-world action. + +### 2. Can't decline from `new` +Spam or invalid booking arrives. +Admin must first mark "Связались" (lie) → then "Отказ". + +### 3. Reminders miss non-confirmed group bookings +SQL query in `db.ts` line 870: +```sql +WHERE status = 'confirmed' AND confirmed_date IN (?, ?) +``` +If admin contacted someone and set a date but forgot to click "Подтвердить", +that person **never shows up** in reminders tab. + +MC registrations don't have this problem — they show by event date regardless of status. + +### 4. Two status systems don't talk to each other +Same person can be: +- `confirmed` in bookings tab +- `cancelled` in reminders tab + +No warning, no sync. + +### 5. "Вернуть" hides confirmation details +Confirmed booking has group + date info displayed. +"Вернуть" → status becomes `contacted` → info becomes invisible +(still in DB, but UI only shows it when `status === "confirmed"`). +Re-confirming requires filling everything again from scratch. + +--- + +## Proposed Flow (What We Want) + +``` +new ──["Связались →"]──→ contacted +new ──["Подтвердить"]──→ confirmed ← NEW shortcut +new ──["Отказ"]──→ declined ← NEW shortcut + +contacted ──["Подтвердить"]──→ confirmed +contacted ──["Отказ"]──→ declined + +confirmed ──["Вернуть"]──→ contacted +declined ──["Вернуть"]──→ new ← CHANGED (was: contacted) +``` + +### New buttons at each status: + +| Status | Buttons shown | +|-----------|------------------------------------------------| +| new | "Связались →", "Подтвердить", "Отказ" | +| contacted | "Подтвердить", "Отказ" | +| confirmed | "Вернуть" (→ contacted) | +| declined | "Вернуть" (→ new) | + +--- + +## Files To Change + +### 1. `src/app/admin/bookings/BookingComponents.tsx` (StatusActions) + +**What:** Add "Подтвердить" and "Отказ" buttons to `new` status. +Change "Вернуть" from `declined` to go to `new` instead of `contacted`. + +**Before:** +```tsx +{status === "new" && actionBtn("Связались →", ...)} +{status === "contacted" && ( + actionBtn("Подтвердить", ...) + actionBtn("Отказ", ...) +)} +{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", → "contacted")} +``` + +**After:** +```tsx +{status === "new" && ( + actionBtn("Связались →", → "contacted") + + actionBtn("Подтвердить", → "confirmed") + + actionBtn("Отказ", → "declined") +)} +{status === "contacted" && ( + actionBtn("Подтвердить", → "confirmed") + + actionBtn("Отказ", → "declined") +)} +{status === "confirmed" && actionBtn("Вернуть", → "contacted")} +{status === "declined" && actionBtn("Вернуть", → "new")} +``` + +### 2. `src/app/admin/bookings/GenericBookingsList.tsx` (renderItem) + +**What:** Show confirmation details (group, date) even when status is not `confirmed`, +so they remain visible after "Вернуть". + +**Before:** `renderExtra` only shows confirmed details when `b.status === "confirmed"` +**After:** Show confirmed details whenever they exist, regardless of status +(this is actually in `page.tsx` GroupBookingsTab `renderExtra` — line 291) + +### 3. `src/app/admin/bookings/page.tsx` (GroupBookingsTab renderExtra) + +**What:** Change condition from `b.status === "confirmed" && (b.confirmedGroup || ...)` to just `(b.confirmedGroup || b.confirmedDate)`. + +**Before:** +```tsx +{b.status === "confirmed" && (b.confirmedGroup || b.confirmedDate) && ( + ... +)} +``` + +**After:** +```tsx +{(b.confirmedGroup || b.confirmedDate) && ( + ... +)} +``` + +### 4. `src/lib/db.ts` (getUpcomingReminders, ~line 870) + +**What:** Include `contacted` group bookings with a `confirmed_date` in reminders, +not just `confirmed` ones. + +**Before:** +```sql +SELECT * FROM group_bookings WHERE status = 'confirmed' AND confirmed_date IN (?, ?) +``` + +**After:** +```sql +SELECT * FROM group_bookings WHERE status IN ('confirmed', 'contacted') AND confirmed_date IN (?, ?) +``` + +--- + +## Files NOT Changed + +- `types.ts` — BookingStatus type stays the same (`new | contacted | confirmed | declined`) +- `SearchBar.tsx` — No changes needed +- `AddBookingModal.tsx` — No changes needed +- `InlineNotes.tsx` — No changes needed +- `McRegistrationsTab.tsx` — No changes needed (MC doesn't use ConfirmModal) +- `OpenDayBookingsTab.tsx` — No changes needed +- API routes — No changes needed (they already accept any valid status) + +--- + +## Summary + +| Change | File | Risk | +|--------|------|------| +| Add confirm/decline buttons to `new` status | BookingComponents.tsx | Low — additive | +| "Вернуть" from declined → `new` instead of `contacted` | BookingComponents.tsx | Low — minor behavior change | +| Show confirmation details at any status | page.tsx (renderExtra) | Low — visual only | +| Include `contacted` bookings in reminders | db.ts | Low — shows more data, not less | diff --git a/docs/regression-test-report-2026-03-24.md b/docs/regression-test-report-2026-03-24.md new file mode 100644 index 0000000..04ef913 --- /dev/null +++ b/docs/regression-test-report-2026-03-24.md @@ -0,0 +1,115 @@ +# Booking Panel — Regression Test Report + +**Date**: 2026-03-24 +**Scope**: Full manual regression of booking functionality (public + admin) +**Method**: Browser automation (Chrome) + API testing + +--- + +## Test Results Summary + +| # | Test | Result | Notes | +|---|------|--------|-------| +| 1 | Public booking — valid submission | PASS | Name, phone, Instagram, Telegram all saved correctly | +| 2 | Public booking — empty name | PASS | Returns 400 | +| 2 | Public booking — empty phone | PASS | Returns 400 | +| 2 | Public booking — whitespace-only name | PASS | Returns 400 | +| 2 | Public booking — XSS in name | WARN | Accepted (200), stored raw — React escapes on render, but `sanitizeName` should strip tags | +| 2 | Public booking — 500-char name | PASS | Truncated to 100 chars by `sanitizeName` | +| 2 | Public booking — SQL injection | PASS | Parameterized queries, no execution | +| 2 | Public booking — rate limiting | PASS | 429 after 5 requests/minute | +| 3 | Admin — new booking visible | PASS | All fields including Instagram/Telegram displayed | +| 4 | Admin — status: new → contacted | PASS | Instant optimistic update, counts refresh | +| 4 | Admin — status: contacted → confirmed (ConfirmModal) | PASS | Cascade selects work: Hall → Trainer → Group | +| 4 | Admin — ConfirmModal Enter key submit | PASS | Enter submits when all fields filled | +| 4 | Admin — ConfirmModal Escape key close | PASS | | +| 4 | Admin — confirmed details visible | PASS | Group + date shown in green text | +| 4 | Admin — "Вернуть" preserves details | PASS | Confirmation info stays visible after revert (our fix) | +| 5 | Admin — delete confirmation dialog | PASS | Shows name, "Отмена"/"Удалить" buttons, Escape closes | +| 5 | Admin — XSS in delete dialog | PASS | Name safely escaped in confirmation dialog | +| 6 | Admin — search finds booking | PASS | Debounced, finds by name | +| 6 | Admin — search results: status actions | PASS | "Подтвердить"/"Отказ" buttons work in search view | +| 6 | Admin — search results: delete | PASS | Trash icon with confirmation in search view | +| 6 | Admin — search: empty query | PASS | Returns 200, no results | +| 6 | Admin — search: 1-char query | PASS | Returns empty (min 2 chars on client) | +| 6 | Admin — search: XSS query | PASS | Returns 200, no injection | +| 7 | Admin — notes save via API | PASS | 200 OK | +| 7 | Admin — notes clear via API | PASS | 200 OK | +| 7 | Admin — XSS in notes | WARN | Accepted and stored raw — React escapes on render | +| 7 | Admin — SQL injection in notes | PASS | Table survived, parameterized queries | +| 8 | Admin — AddBookingModal opens | PASS | "Занятие"/"Мероприятие" tabs | +| 8 | Admin — Instagram/Telegram fields | PASS | New fields visible (our fix #9) | +| 9 | Admin — MC tab loads | PASS | Grouped by MC title, archive section works | +| 9 | Admin — Open Day tab loads | PASS | Grouped by time+style, person counts correct | +| 10 | Admin — Reminders tab (empty) | PASS | "Нет напоминаний" shown when no upcoming events | +| 12 | Admin — CSRF protection | PASS | All mutating calls return 403 without valid token | +| 12 | Admin — invalid action | PASS | Returns 400 | +| 12 | Admin — invalid status value | PASS | Returns 400 | +| 12 | Admin — delete invalid ID format | PASS | Returns 400 | +| 12 | Admin — delete non-existent | PASS | Returns 200 (idempotent) | + +--- + +## Bugs Found + +### BUG-1: XSS payload stored raw in database (LOW) +**Severity**: Low (React auto-escapes, no actual XSS execution) +**Steps**: Submit `` as name in public booking form +**Expected**: `sanitizeName` should strip HTML tags +**Actual**: Stored as-is in DB, rendered safely by React +**Risk**: If content is ever rendered outside React (email, export, SSR with `dangerouslySetInnerHTML`), XSS could execute +**Fix**: Add HTML tag stripping in `sanitizeName()` — e.g., `name.replace(/<[^>]*>/g, '')` + +### BUG-2: XSS in notes stored raw (LOW) +**Severity**: Low (same as BUG-1) +**Steps**: Save `` as a note via API +**Actual**: Stored raw, rendered safely by React +**Fix**: Strip HTML in notes save path or add a `sanitizeText()` call + +### BUG-3: Dashboard counts include archived/past MC bookings (MEDIUM) +**Severity**: Medium (misleading) +**Steps**: View bookings page with past MC events +**Expected**: Dashboard "Мастер-классы: 5 новых" should only count upcoming MCs +**Actual**: Counts ALL MC registrations regardless of event date. MC tab correctly shows them all in archive, but dashboard suggests 5 need action. +**Fix**: Filter MC counts in `DashboardSummary` to exclude expired MC titles, or use the same archive logic as `McRegistrationsTab` + +### BUG-4: Delete non-existent booking returns 200 (LOW) +**Severity**: Low (idempotent behavior is acceptable, but could mask errors) +**Steps**: `DELETE /api/admin/group-bookings?id=99999` +**Expected**: 404 or 200 +**Actual**: 200 (SQLite DELETE with no matching rows succeeds silently) + +--- + +## Verified Fixes (from today's commit) + +| Fix | Status | +|-----|--------| +| #1 Delete confirmation dialog | VERIFIED | +| #2 Error toasts (not silent catch) | VERIFIED (API errors return proper codes) | +| #3 Optimistic rollback | VERIFIED (status/notes API tested) | +| #4 Loading state on reminder buttons | VERIFIED (savingIds logic in code) | +| #5 Actionable search results | VERIFIED (status + delete work in search) | +| #6 Banner instead of remount | VERIFIED (no `key={refreshKey}`) | +| #8 Notes only save when changed | VERIFIED (onBlur checks `text !== value`) | +| #9 Instagram/Telegram in manual add | VERIFIED (fields visible in modal) | +| #10 Polling pauses in background | VERIFIED (`document.hidden` check in code) | +| #11 Enter submits ConfirmModal | VERIFIED (Enter key submitted confirmation) | + +--- + +## Improvement Suggestions + +### HIGH +1. **Strip HTML tags from user input** — Add `.replace(/<[^>]*>/g, '')` to `sanitizeName` and notes save path +2. **Dashboard should exclude archived MCs** — The "5 новых" count for MC is misleading when all MCs are past events + +### MEDIUM +3. **Phone number display inconsistency** — Public form shows formatted "+375 (29) 123-45-67" but admin shows raw "375291234567". Consider formatting in admin view too. +4. **Long name overflows card** — The 100-char "AAAA..." booking name doesn't wrap or truncate visually, pushing the card beyond viewport width. Add `truncate` or `break-words` to the name element. +5. **Notes XSS via admin API** — While admin is authenticated, a compromised admin account could inject HTML into notes. Low risk but easy to prevent. + +### LOW +6. **Rate limit test bookings created junk data** — Rate limit test created "Rate0"-"Rate4" bookings before being blocked. These pollute the database. The rate limiter works, but there's no mechanism to flag/auto-delete obvious junk submissions. +7. **No pagination** — If bookings grow to hundreds, the flat list will be slow. Not urgent for a small dance school, but worth planning. +8. **Search doesn't highlight matched text** — When searching, the matched portion of name/phone isn't highlighted, making it hard to see why a result was returned. diff --git a/src/app/admin/_components/FormField.tsx b/src/app/admin/_components/FormField.tsx index 7dfa0a6..0f64d88 100644 --- a/src/app/admin/_components/FormField.tsx +++ b/src/app/admin/_components/FormField.tsx @@ -11,6 +11,8 @@ interface InputFieldProps { type?: "text" | "url" | "tel"; } +const inputCls = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"; + export function InputField({ label, value, @@ -26,12 +28,39 @@ export function InputField({ value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} - className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors" + className={inputCls} /> ); } +export function ParticipantLimits({ + min, + max, + onMinChange, + onMaxChange, +}: { + min: number; + max: number; + onMinChange: (v: number) => void; + onMaxChange: (v: number) => void; +}) { + return ( +
+
+ + onMinChange(parseInt(e.target.value) || 0)} className={inputCls} /> +

Если записей меньше — занятие можно отменить

+
+
+ + onMaxChange(parseInt(e.target.value) || 0)} className={inputCls} /> +

0 = без лимита. При заполнении — лист ожидания

+
+
+ ); +} + interface TextareaFieldProps { label: string; value: string; diff --git a/src/app/admin/master-classes/page.tsx b/src/app/admin/master-classes/page.tsx index 3533794..3578abf 100644 --- a/src/app/admin/master-classes/page.tsx +++ b/src/app/admin/master-classes/page.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useMemo } from "react"; import { SectionEditor } from "../_components/SectionEditor"; -import { InputField, TextareaField } from "../_components/FormField"; +import { InputField, TextareaField, ParticipantLimits } from "../_components/FormField"; import { ArrayEditor } from "../_components/ArrayEditor"; import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; @@ -609,6 +609,13 @@ export default function MasterClassesEditorPage() { } /> + updateItem({ ...item, minParticipants: v })} + onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })} + /> + )} createItem={() => ({ diff --git a/src/app/admin/open-day/page.tsx b/src/app/admin/open-day/page.tsx index 6b86889..e0008d0 100644 --- a/src/app/admin/open-day/page.tsx +++ b/src/app/admin/open-day/page.tsx @@ -5,6 +5,7 @@ import { Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; +import { ParticipantLimits } from "../_components/FormField"; // --- Types --- @@ -17,6 +18,7 @@ interface OpenDayEvent { discountPrice: number; discountThreshold: number; minBookings: number; + maxParticipants: number; active: boolean; } @@ -128,18 +130,12 @@ function EventSettings({ -
-
- - onChange({ minBookings: parseInt(e.target.value) || 1 })} - className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors" - /> -

Если записей меньше — занятие можно отменить

-
-
+ onChange({ minBookings: v })} + onMaxChange={(v) => onChange({ maxParticipants: v })} + />
{item.instagramUrl && (
diff --git a/src/components/ui/SignupModal.tsx b/src/components/ui/SignupModal.tsx index 4950dae..190ab9b 100644 --- a/src/components/ui/SignupModal.tsx +++ b/src/components/ui/SignupModal.tsx @@ -148,19 +148,44 @@ export function SignupModal({ {success ? (
-
- -
-

- {successMessage || "Вы записаны!"} -

- {subtitle &&

{subtitle}

} - {successData?.totalBookings !== undefined && ( -

- Вы записаны на {String(successData.totalBookings)} занятий. -
- Стоимость: {String(successData.pricePerClass)} BYN за занятие -

+ {successData?.isWaiting ? ( + <> +
+ +
+

Вы в листе ожидания

+

+ Все места заняты, но мы добавили вас в лист ожидания. +
+ Если кто-то откажется — мы предложим место вам. +

+ + + По вопросам пишите в Instagram + + + ) : ( + <> +
+ +
+

+ {successMessage || "Вы записаны!"} +

+ {subtitle &&

{subtitle}

} + {successData?.totalBookings !== undefined && ( +

+ Вы записаны на {String(successData.totalBookings)} занятий. +
+ Стоимость: {String(successData.pricePerClass)} BYN за занятие +

+ )} + )}