diff --git a/.env.example b/.env.example index 9b97359..8c17cd9 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ S3_PUBLIC_URL=http://192.168.1.135:9000 S3_ACCESS_KEY=rustfsadmin S3_SECRET_KEY=rustfsadmin S3_BUCKET=crussell +S3_PROFILE_PICS_BUCKET=crussell-profile-pics AWS_REGION=eu-west-2 # Prod: Uncomment and fill these for R2 diff --git a/README.md b/README.md index 1131b11..0043f8a 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,10 @@ docker compose exec backend sh | 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 | +| Profile Pictures | ✅ | ✅ | Upload to separate bucket, cropper, circular display, CalDAV sync | +| Auto-Booking Status | ✅ | ✅ | Auto-transition: confirmed → in_progress → completed based on time | +| Simplified Deposits | ✅ | ❌ | `deposits_required` INT on users table (3 default), reduces on payment | +| Contact Page | ✅ | ✅ | Dynamic data from first admin user via `/api/contact` endpoint | ### ⚠️ Partially Complete @@ -196,7 +200,6 @@ docker compose exec backend sh | Location | Issue | Priority | |----------|-------|----------| | `BookingFlow.svelte:600` | `submitBooking()` only logs, needs POST implementation | High | -| `BookingCreateModal.svelte:224` | Remove `console.log(users)` debug statement | Low | | `/api/users/guest` | Guest endpoint for walk-ins not implemented | Medium | | GDPR Export | Need endpoint for `export_all_user_data()` | Medium | | Tax Export | Endpoint for VAT return data export | Medium | diff --git a/backend/handlers/auth/local.go b/backend/handlers/auth/local.go index 7820a57..9745fad 100644 --- a/backend/handlers/auth/local.go +++ b/backend/handlers/auth/local.go @@ -6,6 +6,8 @@ import ( "crussell/db" "crussell/internal/dav" "crussell/mw" + "crypto/rand" + "database/sql" "encoding/json" "fmt" "log" @@ -350,3 +352,144 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken}) } + +type VerificationCodeRequest struct { + Email string `json:"email"` +} + +type VerifyCodeRequest struct { + Code string `json:"code"` +} + +type VerificationResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) { + var req VerificationCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + email := strings.TrimSpace(strings.ToLower(req.Email)) + if email == "" { + http.Error(w, "email is required", http.StatusBadRequest) + return + } + + var userID string + err := db.DB.QueryRow(r.Context(), + "SELECT id FROM users WHERE LOWER(email) = $1", email, + ).Scan(&userID) + if err != nil { + if err == sql.ErrNoRows { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the email exists, a verification code will be sent"}) + return + } + log.Printf("Failed to look up user: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + expiresAt := time.Now().Add(24 * time.Hour) + + var code string + err = db.DB.QueryRow(r.Context(), + `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, + userID, expiresAt, + ).Scan(&code) + if err != nil { + log.Printf("Failed to insert verification code: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + log.Printf("DEBUG: Verification code for %s: %s (expires at %s)", email, code, expiresAt.Format(time.RFC3339)) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"}) +} + +func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) { + var req VerifyCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + code := strings.TrimSpace(req.Code) + if code == "" { + http.Error(w, "code is required", http.StatusBadRequest) + return + } + + var userID string + var purpose string + var expiresAt time.Time + + err := db.DB.QueryRow(r.Context(), + `SELECT user_id, purpose, expires_at FROM verification_codes + WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()`, + code, + ).Scan(&userID, &purpose, &expiresAt) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "invalid or expired code", http.StatusBadRequest) + return + } + log.Printf("Failed to verify code: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to start transaction: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + _, err = tx.Exec(r.Context(), + `UPDATE verification_codes SET used_at = NOW() WHERE code = $1`, + code, + ) + if err != nil { + log.Printf("Failed to mark code as used: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if purpose == "email_verify" { + _, err = tx.Exec(r.Context(), + `UPDATE users SET account_role = 'verified_email' WHERE id = $1 AND account_role = 'unverified_email'`, + userID, + ) + if err != nil { + log.Printf("Failed to update user role: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit verification: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"}) +} + +func generateSecureCode(length int) string { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + log.Printf("Failed to generate random code: %v", err) + return strings.ToLower(fmt.Sprintf("%x", time.Now().UnixNano())) + } + return strings.ToLower(fmt.Sprintf("%x", bytes)) +} diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 76a77ea..8740d4b 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -3,6 +3,7 @@ package bookings import ( "crussell/db" "crussell/handlers/notifications" + "crussell/internal/dav" "crussell/mw" "database/sql" "encoding/json" @@ -34,6 +35,12 @@ type Booking struct { UpdatedAt time.Time `json:"updated_at"` CreatedBy *string `json:"created_by,omitempty"` + // Deposit fields + DepositRequired bool `json:"deposit_required"` + DepositAmount float64 `json:"deposit_amount,omitempty"` + DepositPaid bool `json:"deposit_paid"` + DepositDeadline *string `json:"deposit_deadline,omitempty"` + // Joined fields User *UserSummary `json:"user,omitempty"` Services []BookingService `json:"services,omitempty"` @@ -1221,6 +1228,29 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Calculate total price and check deposit requirements + var totalPrice float64 + tx.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, booking.ID).Scan(&totalPrice) + + // Check if user needs to pay deposit (deposits_required > 0) + var depositsRequired int + tx.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired) + + // If deposits_required > 0, require min 48h notice + if depositsRequired > 0 { + minStartTime := time.Now().Add(48 * time.Hour) + if req.StartTime.Before(minStartTime) { + tx.Rollback(r.Context()) + http.Error(w, "You must book at least 48 hours in advance. Complete more appointments to remove this requirement.", http.StatusBadRequest) + return + } + } + // Insert booking services serviceQuery := ` INSERT INTO booking_services (booking_id, service_id) @@ -1427,6 +1457,31 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } } } + + // Add loyalty stamp when booking completed - max 1 per day per user + _, err = db.DB.Exec(r.Context(), + `UPDATE users + SET loyalty_stamps = loyalty_stamps + 1 + WHERE id = $1 + AND NOT EXISTS ( + SELECT 1 FROM bookings b + WHERE b.user_id = users.id + AND b.status = 'completed' + AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day' + AND b.id != $2 + )`, + booking.User.ID, bookingID, + ) + if err != nil { + log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err) + } + + // Reduce deposits_required if payment was made for this booking + var paymentCount int + db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount) + if paymentCount > 0 { + db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID) + } } // Return updated booking @@ -1447,7 +1502,6 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Parse and validate request var req ConfirmBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) @@ -1570,6 +1624,20 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Sync to CalDAV when booking confirmed - DB is source of truth + if dav.Service != nil { + var durationMinutes int + db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(override_duration_minutes), (SELECT SUM(duration_minutes) FROM booking_services WHERE booking_id = $1)) FROM booking_services WHERE booking_id = $1`, bookingID).Scan(&durationMinutes) + if durationMinutes == 0 { + durationMinutes = 60 + } + dav.Service.CreateEvent(1, dav.EventInput{ + Summary: "Crussell Booking", + Start: booking.StartTime, + End: booking.StartTime.Add(time.Duration(durationMinutes) * time.Minute), + }) + } + // Return confirmed booking w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -1580,6 +1648,69 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { } } +// POST /api/admin/bookings/{id}/cancel +func CancelBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to start transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + updateQuery := ` + UPDATE bookings + SET status = 'we_cancelled', updated_at = NOW() + WHERE id = $1 AND status NOT IN ('completed', 'cancelled', 'client_cancelled', 'we_cancelled') + RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by + ` + + var booking Booking + booking.User = &UserSummary{} + err = tx.QueryRow(r.Context(), updateQuery, bookingID).Scan( + &booking.ID, + &booking.User.ID, + &booking.StartTime, + &booking.Status, + &booking.Notes, + &booking.CreatedAt, + &booking.UpdatedAt, + &booking.CreatedBy, + ) + + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found or cannot be cancelled", http.StatusNotFound) + return + } + log.Printf("Failed to cancel booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + notificationQuery := ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + VALUES ('cancelled_booking', $1, $2) + ` + tx.Exec(r.Context(), notificationQuery, bookingID, booking.User.ID) + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit booking cancellation: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(booking) +} + // DELETE /api/bookings/{id} func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") @@ -1669,6 +1800,25 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { // Only notify on cancellation if booking was confirmed (not pending) if originalStatus == "confirmed" { + // Check notice period + var startTime time.Time + tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime) + + noticeHours := startTime.Sub(time.Now()).Hours() + + if noticeHours < 12 { + // Less than 12h notice = count as no-show, add 3 deposits required + tx.Exec(r.Context(), "UPDATE users SET deposits_required = deposits_required + 3 WHERE id = $1", userID) + tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID) + } else if noticeHours < 24 { + // Less than 24h notice - create admin notification about potential deposit requirement + notificationQuery := ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + VALUES ($1, $2, $3) + ` + tx.Exec(r.Context(), notificationQuery, "late_cancellation", bookingID, userID) + } + notificationQuery := ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) @@ -1976,3 +2126,105 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) } } + +// GET /api/bookings/{id}/calendar - returns standalone .ics file +func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + var bookingIDDB, userIDDB, status, notes, createdBy string + var startTime, createdAt, updatedAt time.Time + var durationMinutes int + + err := db.DB.QueryRow(r.Context(), ` + SELECT id, user_id, start_time, status, COALESCE(notes, ''), created_by, created_at, updated_at, + COALESCE((SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) + FROM booking_services bs JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id), 60) + FROM bookings + WHERE id = $1 AND user_id = $2 + `, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, ¬es, &createdBy, &createdAt, &updatedAt, &durationMinutes) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to fetch booking for calendar: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + rows, err := db.DB.Query(r.Context(), ` + SELECT s.name, COALESCE(bs.override_price, s.price) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, bookingID) + if err != nil { + log.Printf("Failed to fetch services: %v", err) + } + defer rows.Close() + + var services []string + var totalPrice float64 + for rows.Next() { + var name string + var price float64 + rows.Scan(&name, &price) + services = append(services, name) + totalPrice += price + } + + serviceList := strings.Join(services, ", ") + endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute) + + icalContent := generateICS(serviceList, startTime, endTime, status, notes, totalPrice) + + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"booking-%s.ics\"", bookingID)) + w.WriteHeader(http.StatusOK) + w.Write([]byte(icalContent)) +} + +func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string { + uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano()) + dtstamp := time.Now().UTC().Format("20060102T150405Z") + + dtstart := start.Format("20060102T150405") + dtend := end.Format("20060102T150405") + + summary := "Crussell Appointment" + if serviceList != "" { + summary = "Crussell: " + serviceList + } + + description := fmt.Sprintf("Status: %s\\nServices: %s\\nPrice: £%.2f", status, serviceList, price) + if notes != "" { + description += "\\nNotes: " + notes + } + + return fmt.Sprintf(`BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Crussell//Booking//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +UID:%s +DTSTAMP:%s +DTSTART:%s +DTEND:%s +SUMMARY:%s +DESCRIPTION:%s +STATUS:%s +END:VEVENT +END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status) +} diff --git a/backend/handlers/today/today.go b/backend/handlers/today/today.go index 46b70d2..b071648 100644 --- a/backend/handlers/today/today.go +++ b/backend/handlers/today/today.go @@ -45,6 +45,47 @@ type CurrentNextResponse struct { func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) { now := time.Now() + // Auto-transition confirmed bookings that have started but not ended to in_progress + _, err := db.DB.Exec(r.Context(), ` + UPDATE bookings + SET status = 'in_progress' + WHERE status = 'confirmed' + AND start_time <= $1 + AND ( + start_time + ( + COALESCE( + (SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) + FROM booking_services bs JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id), + 0 + ) || ' minutes' + )::interval + ) > $1 + `, now) + if err != nil { + log.Printf("Failed to auto-transition bookings to in_progress: %v", err) + } + + // Auto-transition in_progress bookings that have ended to completed + _, err = db.DB.Exec(r.Context(), ` + UPDATE bookings + SET status = 'completed' + WHERE status = 'in_progress' + AND ( + start_time + ( + COALESCE( + (SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) + FROM booking_services bs JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id), + 0 + ) || ' minutes' + )::interval + ) <= $1 + `, now) + if err != nil { + log.Printf("Failed to auto-transition bookings to completed: %v", err) + } + var current *AppointmentInfo var next *AppointmentInfo @@ -248,6 +289,48 @@ type TodayAppointmentsResponse struct { // GET /api/admin/today/appointments func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) { now := time.Now() + + // Auto-transition confirmed bookings that have started but not ended to in_progress + _, err := db.DB.Exec(r.Context(), ` + UPDATE bookings + SET status = 'in_progress' + WHERE status = 'confirmed' + AND start_time <= $1 + AND ( + start_time + ( + COALESCE( + (SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) + FROM booking_services bs JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id), + 0 + ) || ' minutes' + )::interval + ) > $1 + `, now) + if err != nil { + log.Printf("Failed to auto-transition bookings to in_progress: %v", err) + } + + // Auto-transition in_progress bookings that have ended to completed + _, err = db.DB.Exec(r.Context(), ` + UPDATE bookings + SET status = 'completed' + WHERE status = 'in_progress' + AND ( + start_time + ( + COALESCE( + (SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) + FROM booking_services bs JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id), + 0 + ) || ' minutes' + )::interval + ) <= $1 + `, now) + if err != nil { + log.Printf("Failed to auto-transition bookings to completed: %v", err) + } + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) todayEnd := todayStart.Add(24 * time.Hour) diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index 7cafae3..95f579c 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -5,23 +5,34 @@ import ( "database/sql" "encoding/json" "fmt" + "io" "log" "net/http" + "os" "regexp" "strconv" "strings" "time" "github.com/go-chi/chi/v5" + "github.com/kovidgoyal/imaging" "golang.org/x/crypto/bcrypt" "golang.org/x/text/cases" "golang.org/x/text/language" "crussell/db" "crussell/handlers/auth" + "crussell/internal/s3" "crussell/mw" ) +func getEnv(key, fallback string) string { + if val := os.Getenv(key); val != "" { + return val + } + return fallback +} + var titleCaser = cases.Title(language.English) type UserProfile struct { @@ -128,15 +139,18 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) { // PUT /api/user/profile // updateCardDAV updates an existing contact in SabreDAV using user ID -func updateCardDAV(userID, firstName, lastName, email, phone, dob string) error { - // Use user ID as filename - consistent with registration +func updateCardDAV(userID, firstName, lastName, email, phone, dob, profilePicURL string) error { filename := fmt.Sprintf("%s.vcf", userID) url := fmt.Sprintf("http://nginx/dav/addressbooks/principals/default/default/%s", filename) - // Create vCard with user ID as UID (no need to fetch existing) timestamp := time.Now().UTC().Format("20060102T150405Z") uid := fmt.Sprintf("%s@example.com", userID) + var photoLine string + if profilePicURL != "" { + photoLine = fmt.Sprintf("PHOTO;VALUE=URI:%s", profilePicURL) + } + vcard := fmt.Sprintf(`BEGIN:VCARD VERSION:3.0 UID:%s @@ -145,8 +159,9 @@ N:%s;%s;;; EMAIL;TYPE=INTERNET:%s TEL;TYPE=CELL:%s BDAY:%s +%s REV:%s -END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, timestamp) +END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, photoLine, timestamp) // PUT updated vCard req, err := http.NewRequest("PUT", url, bytes.NewBufferString(vcard)) @@ -233,9 +248,10 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { // Fetch user's email and DOB for CardDAV update var email string var dob time.Time + var profilePicURL sql.NullString err = db.DB.QueryRow(r.Context(), ` - SELECT email, date_of_birth FROM users WHERE id = $1 - `, userID).Scan(&email, &dob) + SELECT email, date_of_birth, profile_pic_url FROM users WHERE id = $1 + `, userID).Scan(&email, &dob, &profilePicURL) if err != nil { http.Error(w, "failed to fetch user data", http.StatusInternalServerError) @@ -257,7 +273,7 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { // Update CardDAV (non-blocking) go func() { dobStr := dob.Format("2006-01-02") - if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr); err != nil { + if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr, profilePicURL.String); err != nil { fmt.Printf("Warning: Failed to update CardDAV contact for user %s: %v\n", userID, err) } }() @@ -658,3 +674,196 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } + +type UserPatchTest struct { + ID string `json:"id"` + ServiceID string `json:"serviceId"` + ServiceName string `json:"serviceName"` + LastTime time.Time `json:"lastTime"` +} + +func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "user_id") + if userID == "" { + http.Error(w, "User ID is required", http.StatusBadRequest) + return + } + + rows, err := db.DB.Query(r.Context(), ` + SELECT p.id, p.service_id, s.name, p.last_time + FROM user_service_patch_tests p + JOIN services s ON p.service_id = s.id + WHERE p.user_id = $1 + ORDER BY p.last_time DESC + `, userID) + if err != nil { + log.Printf("Failed to get patch tests: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var tests []UserPatchTest + for rows.Next() { + var t UserPatchTest + if err := rows.Scan(&t.ID, &t.ServiceID, &t.ServiceName, &t.LastTime); err != nil { + log.Printf("Failed to scan patch test: %v", err) + continue + } + tests = append(tests, t) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tests) +} + +func DeletePatchTestHandler(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "user_id") + testID := chi.URLParam(r, "test_id") + if userID == "" || testID == "" { + http.Error(w, "User ID and Test ID are required", http.StatusBadRequest) + return + } + + result, err := db.DB.Exec(r.Context(), ` + DELETE FROM user_service_patch_tests WHERE id = $1 AND user_id = $2 + `, testID, userID) + if err != nil { + log.Printf("Failed to delete patch test: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + if result.RowsAffected() == 0 { + http.Error(w, "patch test not found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type UploadProfilePicResponse struct { + URL string `json:"url"` +} + +func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + if s3.Client == nil { + log.Printf("S3 client not initialized") + http.Error(w, "Storage not configured", http.StatusInternalServerError) + return + } + + r.ParseMultipartForm(10 << 20) + + file, header, err := r.FormFile("file") + if err != nil { + log.Printf("Failed to get file: %v", err) + http.Error(w, "No file provided", http.StatusBadRequest) + return + } + defer file.Close() + + ext := ".jpg" + if idx := strings.LastIndex(header.Filename, "."); idx != -1 { + ext = strings.ToLower(header.Filename[idx:]) + } + + key := fmt.Sprintf("profiles/%s%s", userID, ext) + + fileBytes, err := io.ReadAll(file) + if err != nil { + log.Printf("Failed to read file: %v", err) + http.Error(w, "Failed to read file", http.StatusInternalServerError) + return + } + + fileBytes, err = processProfileImage(fileBytes) + if err != nil { + log.Printf("Failed to process image: %v", err) + http.Error(w, "Failed to process image", http.StatusInternalServerError) + return + } + + bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics") + + if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(fileBytes)); err != nil { + log.Printf("Failed to upload profile picture to S3: %v", err) + http.Error(w, "Failed to upload image", http.StatusInternalServerError) + return + } + + url, err := s3.Client.GetURL(r.Context(), bucket, key) + if err != nil { + log.Printf("Failed to get URL: %v", err) + http.Error(w, "Failed to get image URL", http.StatusInternalServerError) + return + } + + _, err = db.DB.Exec(r.Context(), `UPDATE users SET profile_pic_url = $1 WHERE id = $2`, url, userID) + if err != nil { + log.Printf("Failed to update user profile pic: %v", err) + http.Error(w, "Failed to save profile picture", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(UploadProfilePicResponse{URL: url}) +} + +func processProfileImage(data []byte) ([]byte, error) { + img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + img = imaging.Thumbnail(img, 350, 350, imaging.Linear) + + var buf bytes.Buffer + err = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(85)) + if err != nil { + return nil, fmt.Errorf("failed to encode image: %w", err) + } + + return buf.Bytes(), nil +} + +type ContactInfo struct { + Name string `json:"name"` + Role string `json:"role"` + Phone string `json:"phone"` + Email string `json:"email"` + ProfilePicURL *string `json:"profilePicUrl,omitempty"` +} + +// GET /api/contact +func GetContactInfoHandler(w http.ResponseWriter, r *http.Request) { + var contact ContactInfo + err := db.DB.QueryRow(r.Context(), ` + SELECT + COALESCE(n_first_name, '') || ' ' || COALESCE(n_last_name, '') as name, + COALESCE(phone, ''), + COALESCE(email, ''), + profile_pic_url + FROM users + WHERE account_role = 'admin' + ORDER BY created_at ASC + LIMIT 1 + `).Scan(&contact.Name, &contact.Phone, &contact.Email, &contact.ProfilePicURL) + + if err != nil { + log.Printf("Failed to get contact info: %v", err) + http.Error(w, "contact not found", http.StatusNotFound) + return + } + + contact.Role = "Owner / Beauty Specialist" + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(contact) +} diff --git a/backend/internal/s3/s3_dev.go b/backend/internal/s3/s3_dev.go index 4e2fab9..37914c5 100644 --- a/backend/internal/s3/s3_dev.go +++ b/backend/internal/s3/s3_dev.go @@ -66,6 +66,11 @@ func Connect() error { bucket = "crussell" } + profilePicsBucket := os.Getenv("S3_PROFILE_PICS_BUCKET") + if profilePicsBucket == "" { + profilePicsBucket = "crussell-profile-pics" + } + region := os.Getenv("AWS_REGION") if region == "" { region = "eu-west-2" @@ -129,6 +134,35 @@ func Connect() error { log.Printf("Bucket policy: %v (may already exist)", err) } + // Create profile pics bucket if it doesn't exist + if profilePicsBucket != bucket { + _, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(profilePicsBucket), + }) + if err != nil { + log.Printf("Profile pics bucket creation: %v (may already exist)", err) + } + + // Set bucket policy for public read access + profilePolicy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::%s/*" + }] + }`, profilePicsBucket) + _, err = s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: aws.String(profilePicsBucket), + Policy: aws.String(profilePolicy), + }) + if err != nil { + log.Printf("Profile pics bucket policy: %v (may already exist)", err) + } + } + log.Printf("Connected to local S3 (Rustfs): bucket=%s, endpoint=%s", bucket, endpoint) return nil } diff --git a/backend/main.go b/backend/main.go index 66a7e0b..c6b5408 100644 --- a/backend/main.go +++ b/backend/main.go @@ -94,6 +94,13 @@ func main() { // Login: Has its own internal rate limiting r.Post("/login", authHandlers.LoginHandler) + // Email verification + r.Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler) + r.Post("/verify/check", authHandlers.VerifyCodeHandler) + + // Public contact info + r.Get("/contact", user.GetContactInfoHandler) + // Portfolio r.Route("/portfolio", func(r chi.Router) { r.Get("/images", portfolio.ListImages) @@ -139,6 +146,7 @@ func main() { r.Get("/user/profile", user.GetProfileHandler) r.Put("/user/profile", user.UpdateProfileHandler) + r.Post("/user/profile-picture", user.UploadProfilePictureHandler) r.Put("/user/change-password", user.ChangePasswordHandler) r.Delete("/user/account", user.DeleteAccountHandler) r.Get("/user/loyalty", user.GetLoyaltyHandler) @@ -147,6 +155,7 @@ func main() { r.Post("/", bookings.CreateBookingHandler) r.Get("/", bookings.GetAllUserBookingsHandler) r.Get("/{id}", bookings.GetBookingHandler) + r.Get("/{id}/calendar", bookings.GetBookingCalendarHandler) r.Put("/{id}", bookings.EditBookingHandler) r.Delete("/{id}", bookings.DeleteBookingHandler) }) @@ -172,7 +181,7 @@ func main() { r.Get("/{id}", bookings.GetAdminBookingHandler) r.Put("/{id}/progress", bookings.ProgressBookingHandler) r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) - r.Post("/{id}/cancel", bookings.ConfirmBookingHandler) + r.Post("/{id}/cancel", bookings.CancelBookingHandler) }) r.Route("/admin/users", func(r chi.Router) { diff --git a/frontend/package.json b/frontend/package.json index 4350c1e..b6ab2c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,10 @@ "private": true, "version": "0.0.1", "type": "module", + "overrides": { + "cookie": "^0.7.0", + "minimatch": "^10.2.1" + }, "scripts": { "dev": "vite dev", "build": "vite build", @@ -16,36 +20,38 @@ "devDependencies": { "@eslint/compat": "^1.2.5", "@eslint/js": "^9.22.0", - "@internationalized/date": "^3.9.0", - "@lucide/svelte": "^0.544.0", + "@internationalized/date": "^3.11.0", + "@lucide/svelte": "^0.562.0", "@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.22.0", - "@sveltejs/vite-plugin-svelte": "^6.0.0", - "@tailwindcss/vite": "^4.0.0", - "@types/node": "^22", - "@types/swiper": "^5.4.3", - "bits-ui": "^2.11.5", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.0", + "@types/node": "^22.19.11", + "bits-ui": "^2.16.1", "clsx": "^2.1.1", "eslint": "^9.22.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", "formsnap": "^2.0.1", - "globals": "^16.0.0", + "globals": "^17.3.0", "mode-watcher": "^1.1.0", - "prettier": "^3.4.2", - "prettier-plugin-svelte": "^3.3.3", - "prettier-plugin-tailwindcss": "^0.6.11", - "shadcn-svelte": "^1.0.8", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "runed": "^0.37.1", + "shadcn-svelte": "^1.1.1", "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "svelte-sonner": "^1.0.5", + "svelte-check": "^4.4.3", + "svelte-easy-crop": "^5.0.0", + "svelte-sonner": "^1.0.7", + "svelte-toolbelt": "^0.10.6", "sveltekit-superforms": "^2.27.1", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", - "tailwindcss": "^4.0.0", - "tw-animate-css": "^1.3.8", - "typescript": "^5.0.0", + "tailwindcss": "^4.2.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", "typescript-eslint": "^8.20.0", "vite": "^7.0.4" }, @@ -53,6 +59,6 @@ "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", - "swiper": "^10.3.1" + "cropperjs": "^1.6.2" } } diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index c5c4e38..a68e6f6 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -280,6 +280,15 @@ {/if} + {#if selectedBooking} + + {/if} diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index bdcc4ee..3be055f 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -35,7 +35,14 @@ overrideDurationMinutes?: number; }; - let notes = $state(booking.notes || ''); + let notes = $state(''); + + // Sync notes with booking.notes when booking changes + $effect(() => { + if (booking?.notes !== undefined) { + notes = booking.notes || ''; + } + }); let serviceOverrides = $state< Record< string, diff --git a/frontend/src/lib/components/layout/NavBar.svelte b/frontend/src/lib/components/layout/NavBar.svelte index e6ac668..2f353eb 100644 --- a/frontend/src/lib/components/layout/NavBar.svelte +++ b/frontend/src/lib/components/layout/NavBar.svelte @@ -8,12 +8,13 @@ const links = [ { href: '/', label: 'Home', showWhen: 'always', width: 'w-12' }, { href: '/prices', label: 'Price List', showWhen: 'guest', width: 'w-20' }, - { href: '/book', label: 'Book your appointment', showWhen: 'auth', width: 'w-36' }, + { href: '/schedule', label: 'My Schedule', showWhen: 'auth', width: 'w-24' }, + { href: '/book', label: 'Book an appointment', showWhen: 'auth', width: 'w-36' }, { href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' }, - { href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' }, - { href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' }, { href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' }, - { href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' } + { href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' }, + { href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' }, + { href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' } ]; let mobileMenuOpen = $state(false); diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index f0d4273..9920f9b 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -131,7 +131,7 @@ interval = setInterval(() => { calculateTimes(); - }, 60000); // Update every minute + }, 15000); // Update every 15 seconds return () => { if (interval) clearInterval(interval); @@ -193,18 +193,22 @@ {#if activeAppointment} {#if isInProgress} - - - - - - In Progress • {timeRemaining} min remaining - {#if freeTimeAfter > 0} - • {freeTimeAfter} min free +
+ + + + + + In Progress • {timeRemaining} min remaining + + {#if freeTimeAfter > 0 && timeRemaining > 29} + + {freeTimeAfter} min free afterwards + {/if} - +
{:else} Starts in {timeRemaining} min @@ -259,10 +263,11 @@ class="h-20 w-20 rounded-full object-cover ring-4 ring-blue-200" /> {:else} + {@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'}
- {activeAppointment.user?.full_name?.charAt(0) || '?'} + {initials}
{/if}
diff --git a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 0000000..b911baf --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/avatar-image.svelte b/frontend/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 0000000..7ccc3ce --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/avatar.svelte b/frontend/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 0000000..40feecd --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/index.ts b/frontend/src/lib/components/ui/avatar/index.ts new file mode 100644 index 0000000..9585f8a --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/index.ts @@ -0,0 +1,13 @@ +import Root from './avatar.svelte'; +import Image from './avatar-image.svelte'; +import Fallback from './avatar-fallback.svelte'; + +export { + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback +}; diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte index 2105474..9717657 100644 --- a/frontend/src/lib/components/ui/button/button.svelte +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -1,82 +1,124 @@ -{#if href} - - {@render children?.()} - -{:else} - -{/if} + + { + onclick?.(e); + + if (type === undefined) return; + + if (onClickPromise) { + loading = true; + + await onClickPromise(e); + + loading = false; + } + }} +> + {#if type !== undefined && loading} +
+ +
+ Loading + {/if} + {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts index fb585d7..29e85e3 100644 --- a/frontend/src/lib/components/ui/button/index.ts +++ b/frontend/src/lib/components/ui/button/index.ts @@ -2,8 +2,11 @@ import Root, { type ButtonProps, type ButtonSize, type ButtonVariant, - buttonVariants, -} from "./button.svelte"; + type AnchorElementProps, + type ButtonElementProps, + type ButtonPropsWithoutHTML, + buttonVariants +} from './button.svelte'; export { Root, @@ -14,4 +17,7 @@ export { type ButtonProps, type ButtonSize, type ButtonVariant, + type AnchorElementProps, + type ButtonElementProps, + type ButtonPropsWithoutHTML }; diff --git a/frontend/src/lib/components/ui/dialog/dialog-close.svelte b/frontend/src/lib/components/ui/dialog/dialog-close.svelte index 840b2f6..e8a96a7 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-close.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-close.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte index a647d56..c3f06bf 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,21 +1,21 @@ @@ -25,15 +25,15 @@ bind:ref data-slot="dialog-content" class={cn( - "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", + 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', className )} {...restProps} > {@render children?.()} - {#if showCloseButton} + {#if !hideClose} Close diff --git a/frontend/src/lib/components/ui/dialog/dialog-description.svelte b/frontend/src/lib/components/ui/dialog/dialog-description.svelte index 3845023..c658420 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-description.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-description.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts index dce1d9d..d9e5fb8 100644 --- a/frontend/src/lib/components/ui/dialog/index.ts +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -1,13 +1,13 @@ -import { Dialog as DialogPrimitive } from "bits-ui"; +import { Dialog as DialogPrimitive } from 'bits-ui'; -import Title from "./dialog-title.svelte"; -import Footer from "./dialog-footer.svelte"; -import Header from "./dialog-header.svelte"; -import Overlay from "./dialog-overlay.svelte"; -import Content from "./dialog-content.svelte"; -import Description from "./dialog-description.svelte"; -import Trigger from "./dialog-trigger.svelte"; -import Close from "./dialog-close.svelte"; +import Title from './dialog-title.svelte'; +import Footer from './dialog-footer.svelte'; +import Header from './dialog-header.svelte'; +import Overlay from './dialog-overlay.svelte'; +import Content from './dialog-content.svelte'; +import Description from './dialog-description.svelte'; +import Trigger from './dialog-trigger.svelte'; +import Close from './dialog-close.svelte'; const Root = DialogPrimitive.Root; const Portal = DialogPrimitive.Portal; @@ -33,5 +33,5 @@ export { Overlay as DialogOverlay, Content as DialogContent, Description as DialogDescription, - Close as DialogClose, + Close as DialogClose }; diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte new file mode 100644 index 0000000..00fe531 --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-controls.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-controls.svelte new file mode 100644 index 0000000..9adfaed --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-controls.svelte @@ -0,0 +1,19 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-crop.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-crop.svelte new file mode 100644 index 0000000..5a1e6c1 --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-crop.svelte @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte new file mode 100644 index 0000000..1092edc --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte @@ -0,0 +1,26 @@ + + + +
+ +
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte new file mode 100644 index 0000000..80322bd --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte @@ -0,0 +1,25 @@ + + + + +
+ {@render children?.()} +
+
+
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-preview.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-preview.svelte new file mode 100644 index 0000000..3361dfd --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-preview.svelte @@ -0,0 +1,25 @@ + + +{#if child} + {@render child({ src: previewState.rootState.src })} +{:else} + + + + + Upload image + + +{/if} diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte new file mode 100644 index 0000000..a57c7c7 --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte new file mode 100644 index 0000000..9043f6c --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte @@ -0,0 +1,43 @@ + + +{@render children?.()} + { + const file = e.currentTarget.files?.[0]; + if (!file) return; + rootState.onUpload(file); + // reset so that we can reupload the same file + (e.target! as HTMLInputElement).value = ''; + }} + type="file" + {id} + style="display: none;" +/> diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte.ts b/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte.ts new file mode 100644 index 0000000..de91870 --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte.ts @@ -0,0 +1,167 @@ +import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt'; +import { Context } from 'runed'; +import type { CropArea, DispatchEvents } from 'svelte-easy-crop'; +import { getCroppedImg } from './utils'; + +// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats +export const VALID_IMAGE_TYPES = [ + 'image/apng', + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp' +]; + +export type ImageCropperRootProps = WritableBoxedValues<{ + src: string; +}> & + ReadableBoxedValues<{ + id: string; + onCropped: (url: string) => void; + onUnsupportedFile: (file: File) => void; + }>; + +class ImageCropperRootState { + #createdUrls = $state([]); + open = $state(false); + tempUrl = $state(); + pixelCrop = $state(); + + constructor(readonly opts: ImageCropperRootProps) { + this.onUpload = this.onUpload.bind(this); + this.onCancel = this.onCancel.bind(this); + this.onCrop = this.onCrop.bind(this); + this.dispose = this.dispose.bind(this); + } + + onUpload(file: File) { + if (!VALID_IMAGE_TYPES.includes(file.type)) { + this.opts.onUnsupportedFile.current(file); + return; + } + + this.tempUrl = URL.createObjectURL(file); + this.#createdUrls.push(this.tempUrl); + this.open = true; + } + + onCancel() { + this.tempUrl = undefined; + this.open = false; + this.pixelCrop = undefined; + } + + async onCrop() { + if (!this.pixelCrop || !this.tempUrl) return; + + this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop); + + this.open = false; + + this.opts.onCropped.current(this.opts.src.current); + } + + get src() { + return this.opts.src.current; + } + + get id() { + return this.opts.id.current; + } + + dispose() { + for (const url of this.#createdUrls) { + URL.revokeObjectURL(url); + } + } +} + +export type ImageCropperTriggerProps = ReadableBoxedValues<{ + id?: string; +}>; + +class ImageCropperTriggerState { + constructor(readonly rootState: ImageCropperRootState) {} +} + +class ImageCropperPreviewState { + constructor(readonly rootState: ImageCropperRootState) {} +} + +class ImageCropperDialogState { + constructor(readonly rootState: ImageCropperRootState) {} +} + +class ImageCropperCropperState { + constructor(readonly rootState: ImageCropperRootState) { + this.onCropComplete = this.onCropComplete.bind(this); + } + + onCropComplete(e: DispatchEvents['cropcomplete']) { + this.rootState.pixelCrop = e.pixels; + } +} + +class ImageCropperCropState { + constructor(readonly rootState: ImageCropperRootState) { + this.onclick = this.onclick.bind(this); + } + + onclick() { + this.rootState.onCrop(); + } +} + +class ImageCropperCancelState { + constructor(readonly rootState: ImageCropperRootState) { + this.onclick = this.onclick.bind(this); + } + + onclick() { + this.rootState.onCancel(); + } +} + +const ImageCropperRootContext = new Context('ImageCropper.Root'); + +export const useImageCropperRoot = (props: ImageCropperRootProps) => { + return ImageCropperRootContext.set(new ImageCropperRootState(props)); +}; + +export const useImageCropperTrigger = () => { + const rootState = ImageCropperRootContext.get(); + + return new ImageCropperTriggerState(rootState); +}; + +export const useImageCropperPreview = () => { + const rootState = ImageCropperRootContext.get(); + + return new ImageCropperPreviewState(rootState); +}; + +export const useImageCropperDialog = () => { + const rootState = ImageCropperRootContext.get(); + + return new ImageCropperDialogState(rootState); +}; + +export const useImageCropperCropper = () => { + const rootState = ImageCropperRootContext.get(); + + return new ImageCropperCropperState(rootState); +}; + +export const useImageCropperCrop = () => { + const rootState = ImageCropperRootContext.get(); + + return new ImageCropperCropState(rootState); +}; + +export const useImageCropperCancel = () => { + const rootState = ImageCropperRootContext.get(); + + return new ImageCropperCancelState(rootState); +}; diff --git a/frontend/src/lib/components/ui/image-cropper/index.ts b/frontend/src/lib/components/ui/image-cropper/index.ts new file mode 100644 index 0000000..d81fbcd --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/index.ts @@ -0,0 +1,13 @@ +import Root from './image-cropper.svelte'; +import UploadTrigger from './image-cropper-upload-trigger.svelte'; +import Preview from './image-cropper-preview.svelte'; +import Dialog from './image-cropper-dialog.svelte'; +import Cropper from './image-cropper-cropper.svelte'; +import Controls from './image-cropper-controls.svelte'; +import Crop from './image-cropper-crop.svelte'; +import Cancel from './image-cropper-cancel.svelte'; +import { getFileFromUrl } from './utils'; + +export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl }; + +export type * from './types'; diff --git a/frontend/src/lib/components/ui/image-cropper/types.ts b/frontend/src/lib/components/ui/image-cropper/types.ts new file mode 100644 index 0000000..10659dc --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/types.ts @@ -0,0 +1,44 @@ +import type { + AvatarRootProps, + DialogContentProps, + WithChildren, + WithoutChild, + WithoutChildren +} from 'bits-ui'; +import type { Snippet } from 'svelte'; +import type { CropperProps } from 'svelte-easy-crop'; +import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements'; + +export type ImageCropperRootPropsWithoutHTML = WithChildren<{ + id?: string; + src?: string; + onCropped?: (url: string) => void; + onUnsupportedFile?: (file: File) => void; +}>; + +export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes; + +export type ImageCropperDialogProps = DialogContentProps; + +export type ImageCropperCropperProps = Omit, 'oncropcomplete' | 'image'>; + +export type ImageCropperControlsWithoutHTML = WithChildren<{ + ref?: HTMLDivElement | null; +}>; + +export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML & + WithoutChildren>; + +export type ImageCropperPreviewPropsWithoutHTML = { + child?: Snippet<[{ src: string }]>; +}; + +export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML & + WithoutChild; + +export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{ + ref?: HTMLLabelElement | null; +}>; + +export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML & + WithoutChildren>; diff --git a/frontend/src/lib/components/ui/image-cropper/utils.ts b/frontend/src/lib/components/ui/image-cropper/utils.ts new file mode 100644 index 0000000..44c02f3 --- /dev/null +++ b/frontend/src/lib/components/ui/image-cropper/utils.ts @@ -0,0 +1,85 @@ +import type { CropArea } from 'svelte-easy-crop'; + +export const getFileFromUrl = async (url: string, fileName = 'cropped.png'): Promise => { + // Fetch the file data from the URL + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch resource: ${response.status} ${response.statusText}`); + } + + // Convert the response into a Blob + const blob = await response.blob(); + + // Create and return a File. You can set a custom type if needed. + return new File([blob], fileName, { type: blob.type }); +}; + +const createImage = (url: string): Promise => { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', (error) => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox + image.src = url; + }); +}; + +const getRadianAngle = (degreeValue: number) => { + return (degreeValue * Math.PI) / 180; +}; + +/** Gets the cropped image from the src using the cropped area + * + * @param imageSrc + * @param pixelCrop + * @param rotation + * @returns + */ +export const getCroppedImg = async ( + imageSrc: string, + pixelCrop: CropArea, + rotation = 0 +): Promise => { + const image = await createImage(imageSrc); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Error getting 2d rendering context'); + } + + const maxSize = Math.max(image.width, image.height); + const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)); + + // set each dimensions to double largest dimension to allow for a safe area for the + // image to rotate in without being clipped by canvas context + canvas.width = safeArea; + canvas.height = safeArea; + + // translate canvas context to a central location on image to allow rotating around the center. + ctx.translate(safeArea / 2, safeArea / 2); + ctx.rotate(getRadianAngle(rotation)); + ctx.translate(-safeArea / 2, -safeArea / 2); + + // draw rotated image and store data. + ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5); + const data = ctx.getImageData(0, 0, safeArea, safeArea); + + // set canvas width to final desired crop size - this will clear existing context + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + // paste generated rotate image with correct offsets for x,y crop values. + ctx.putImageData( + data, + Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x), + Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y) + ); + + return new Promise((resolve) => { + canvas.toBlob((file) => { + resolve(URL.createObjectURL(file!)); + }, 'image/png'); + }); +}; diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts index aa6631b..423828a 100644 --- a/frontend/src/lib/stores/auth.svelte.ts +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -182,9 +182,9 @@ class AuthStore { return; } - // Refresh if token expires in less than 2 weeks - const threeDays = 2 * 7 * 24 * 60 * 60 * 1000; - if (decoded.exp * 1000 - Date.now() < threeDays) { + // Refresh if token expires in less than 14 days + const fourteenDays = 14 * 24 * 60 * 60 * 1000; + if (decoded.exp * 1000 - Date.now() < fourteenDays) { try { const response = await fetch('/api/refresh-token', { method: 'POST', diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 55b3a91..97525cc 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,13 +1,13 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChild = T extends { child?: any } ? Omit : T; +export type WithoutChild = T extends { child?: any } ? Omit : T; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildren = T extends { children?: any } ? Omit : T; export type WithoutChildrenOrChild = WithoutChildren>; export type WithElementRef = T & { ref?: U | null }; diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 4a354b0..e66b6dc 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -28,6 +28,8 @@ import { Separator } from '$lib/components/ui/separator'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Skeleton } from '$lib/components/ui/skeleton'; + import * as Dialog from '$lib/components/ui/dialog'; + import Cropper from 'svelte-easy-crop'; // =============== Auth & Page State =============== let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading'); @@ -80,6 +82,96 @@ let userData = $state(null); let loadingUser = $state(true); let stamps = $state(0); + let uploadingPic = $state(false); + + // Image cropper state + let cropDialogOpen = $state(false); + let cropImageUrl = $state(''); + let cropArea = $state<{ x: number; y: number; width: number; height: number } | null>(null); + let crop = $state({ x: 0, y: 0 }); + let zoom = $state(1); + let previewUrl = $state(''); + + function handleFileSelect(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) { + cropImageUrl = URL.createObjectURL(file); + cropDialogOpen = true; + } + } + + async function handleCropSave() { + if (!cropArea || !cropImageUrl) return; + + const img = new Image(); + img.src = cropImageUrl; + await new Promise(resolve => { img.onload = resolve; }); + + const canvas = document.createElement('canvas'); + canvas.width = 350; + canvas.height = 350; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.drawImage( + img, + cropArea.x, cropArea.y, cropArea.width, cropArea.height, + 0, 0, 350, 350 + ); + + canvas.toBlob((blob) => { + if (!blob) return; + + const url = URL.createObjectURL(blob); + previewUrl = url; + + handleProfilePicUpload(blob).then(() => { + URL.revokeObjectURL(cropImageUrl); + cropImageUrl = ''; + cropArea = null; + cropDialogOpen = false; + }); + }, 'image/jpeg', 0.9); + } + + function handleCropCancel() { + if (cropImageUrl) { + URL.revokeObjectURL(cropImageUrl); + } + cropImageUrl = ''; + cropArea = null; + cropDialogOpen = false; + } + + async function handleProfilePicUpload(blob: Blob) { + uploadingPic = true; + try { + const formData = new FormData(); + formData.append('file', blob, 'profile.jpg'); + const uploadResponse = await fetch('/api/user/profile-picture', { + method: 'POST', + headers: { + Authorization: `Bearer ${authStore.currentToken}` + }, + body: formData + }); + if (uploadResponse.ok) { + const data = await uploadResponse.json(); + if (userData) { + userData.profilePicUrl = data.url; + } + toast.success('Profile picture updated'); + } else { + toast.error('Failed to upload profile picture'); + } + } catch (err) { + console.error('Upload error:', err); + toast.error('Failed to upload profile picture'); + } finally { + uploadingPic = false; + } + } // =============== Phone Edit Mode =============== let editingPhone = $state(false); @@ -607,6 +699,66 @@ Your personal details and account information + {#if userData} + {@const initials = userData.firstName && userData.lastName ? userData.firstName.split(' ').map(n => n[0]).join('') + userData.lastName.split(' ').map(n => n[0]).join('') : ''} + {@const hasImage = !!userData.profilePicUrl || !!previewUrl} + {@const displayUrl = previewUrl || userData.profilePicUrl || ''} +
+ {#if hasImage || initials} + {#if hasImage} + Profile + {:else} +
+ {initials} +
+ {/if} + {:else} +
+ + + +
+ {/if} + + +
+ {/if} + + + + + Crop Profile Picture + +
+ {#if cropImageUrl} + { + cropArea = e.pixels; + }} + /> + {/if} +
+ + + + +
+
+ {#if loadingUser} {#each Array(6) as _, i (i)} diff --git a/frontend/src/routes/contact/+page.svelte b/frontend/src/routes/contact/+page.svelte index cd81707..97b70fa 100644 --- a/frontend/src/routes/contact/+page.svelte +++ b/frontend/src/routes/contact/+page.svelte @@ -1,15 +1,62 @@

Contact Me

- + {#if loading} +
+
+
+
+
+
+
+
+
+ {:else if contact} + + {:else} + + {/if}
diff --git a/frontend/src/routes/schedule/+page.svelte b/frontend/src/routes/schedule/+page.svelte new file mode 100644 index 0000000..d531499 --- /dev/null +++ b/frontend/src/routes/schedule/+page.svelte @@ -0,0 +1,167 @@ + + +{#if pageState === 'loading'} +
+
+
+
+
+
+{:else if pageState === 'unauthorized'} +
+

Please log in to view your schedule.

+
+{:else} +
+

My Schedule

+ + {#if loading} +
Loading...
+ {:else if bookings.length === 0} + + + No Upcoming Appointments + You don't have any upcoming appointments. + + + + + + {:else} +
+ {#each bookings as booking (booking.id)} + + +
+ + {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })} + + + {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { + hour: 'numeric', + minute: '2-digit' + })} + {#if booking.duration_minutes} + · {booking.duration_minutes} min + {/if} + +
+ + {booking.status} + +
+ +
+
+ {#if booking.services && booking.services.length > 0} +

+ {booking.services.map((s: any) => s.service_name).join(', ')} +

+ {/if} + {#if booking.total_amount} +

£{booking.total_amount.toFixed(2)}

+ {/if} +
+
+ +
+
+
+
+ {/each} +
+ {/if} +
+{/if} + + diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index ef67384..5584be2 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -51,6 +51,8 @@ CREATE OR REPLACE FUNCTION generate_service_id() RETURNS CHAR(12) AS $$ SELECT g CREATE OR REPLACE FUNCTION generate_booking_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('bookings'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_payment_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('payments'); $$ LANGUAGE sql; +CREATE OR REPLACE FUNCTION generate_verification_code() RETURNS CHAR(12) AS $$ SELECT substr(encode(gen_random_bytes(6), 'hex'), 1, 12); $$ LANGUAGE sql; + CREATE OR REPLACE FUNCTION generate_referral_code() RETURNS CHAR(12) AS $$ DECLARE @@ -107,6 +109,8 @@ CREATE TABLE users ( -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Deposit tracking: remaining deposits needed (0-3). Reduces by 1 when booking with payment completes. + deposits_required INT NOT NULL DEFAULT 3, -- staff fields notes TEXT ); @@ -134,7 +138,7 @@ CREATE TYPE verification_purpose AS ENUM ('email_verify', 'password_reset'); CREATE TABLE verification_codes ( id BIGSERIAL PRIMARY KEY, - code CHAR(32) NOT NULL UNIQUE, + code CHAR(12) NOT NULL UNIQUE DEFAULT generate_verification_code(), user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose verification_purpose NOT NULL, expires_at TIMESTAMPTZ NOT NULL, @@ -273,7 +277,8 @@ CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications -- ======================================= -- PAYMENTS TABLE -- ======================================= - +-- PAYMENTS TABLE +-- ======================================= CREATE SEQUENCE invoice_number_seq START WITH 1 INCREMENT BY 1 @@ -360,7 +365,7 @@ INSERT INTO business_settings ( 'https://www.website.co.uk' ); -CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim'); +CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid'); CREATE TABLE admin_notifications ( id SERIAL PRIMARY KEY, diff --git a/obsidian/Crussell/Crussell Nails.md b/obsidian/Crussell/Crussell Nails.md index f1aca00..93cf53d 100644 --- a/obsidian/Crussell/Crussell Nails.md +++ b/obsidian/Crussell/Crussell Nails.md @@ -46,8 +46,8 @@ - [x] `/api/admin/bookings/{id}/progress` - Progress booking status - [x] `/api/admin/bookings/{id}/confirm` - Confirm booking - [x] `/api/admin/bookings/{id}/cancel` - Cancel booking -- [ ] **In-progress auto-infer** - Status should auto-set based on time -- [ ] **Begin button on Today** - Manual start for early arrivals (gray out if >3hrs away) +- [x] **In-progress auto-infer** - Status auto-sets based on time (confirmed → in_progress → completed) +- [x] **Auto-complete** - Bookings auto-complete when duration elapses #### Admin Endpoints - [x] `/api/admin/services` - Create, delete, list, toggle @@ -69,11 +69,24 @@ #### User Endpoints - [x] `/api/user/profile` - GET, PUT +- [x] `/api/user/profile-picture` - POST upload profile picture (separate bucket) - [x] `/api/user/account` - DELETE (GDPR compliant) - [x] `/api/user/loyalty` - GET loyalty stamps +- [x] `/api/contact` - Public endpoint returning first admin's contact info (name, phone, email, profilePicUrl) - [ ] **GDPR data export** - `export_all_user_data()` exists but not wired to endpoint - [ ] **Tax data export** - Admin endpoint for tax-software-compatible format +#### Deposits System (Simplified) +- [x] `users.deposits_required` INT DEFAULT 3 +- [x] 48h notice required when `deposits_required > 0` +- [x] Reduces by 1 when booking completes with payment +- [x] Increases by 3 on <12h cancellation (bad behavior) +- [ ] Frontend display of deposits_required + +#### CalDAV Contact Sync +- [x] Profile photos synced to CardDAV contacts (PHOTO field in vCard) +- [x] Auto-updates when profile is changed + #### Not Yet Wired - [ ] Social auth (`handlers/auth/social.go` exists, not imported) - [ ] Analytics (`handlers/admin/analytics.go` exists, not imported) @@ -91,13 +104,15 @@ #### Core Pages - [x] Home (`/`) - [x] Prices (`/prices`) -- [x] Contact (`/contact`) +- [x] Contact (`/contact`) - Dynamic, fetches from `/api/contact` - [x] Book (`/book`) - Full wizard with service selection, date/time, customer details - [x] Portfolio (`/portfolio`) - S3/R2 storage with tag filtering, category filters, pagination, ?img= featured image, admin upload -- [x] Today (`/today`) - Admin only, real-time schedule view -- [x] Account (`/account`) +- [x] Today (`/today`) - Admin only, real-time schedule view with auto-status transitions +- [x] Schedule (`/schedule`) - User's upcoming bookings with .ics export +- [x] Account (`/account`) - Profile management, profile picture upload with cropper - [x] Login (`/login`) - [x] Manage (`/manage`) +- [x] Manage (`/manage`) #### Admin Dashboard (`/admin`) - [x] Auth guard with role check @@ -149,6 +164,7 @@ - [x] CardDAV sync for contacts (SabreDAV) - [x] CalDAV ready +- [x] Profile pics bucket - separate bucket `crussell-profile-pics` for user profile pictures - [ ] Email/SMS reminders - not yet implemented - [ ] Square payment - placeholder only - [x] S3/R2 image hosting - Rustfs for dev, Cloudflare R2 for prod via build tags @@ -356,17 +372,21 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo | Task | Description | Files Affected | | ------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------- | | **Customer booking submit** | `submitBooking()` at line 600 only logs, needs `POST /api/bookings` | `frontend/src/lib/components/booking/BookingFlow.svelte` | -| **Remove console.logs** | Debug logs left in: `BookingFlow.svelte:600`, `BookingCreateModal.svelte:224` | Frontend components | +| **Remove console.logs** | Debug logs left in: `BookingFlow.svelte:600` | Frontend components | | **Guest user endpoint** | Create `/api/users/guest` for walk-in bookings | `backend/handlers/user/` (new file) | -| **In-progress auto-infer** | Auto-set `in_progress` status based on time | Backend booking logic | +| ~~In-progress auto-infer~~ | ~~Auto-set `in_progress` status based on time~~ DONE | Backend booking logic | +| ~~Auto-complete~~ | ~~Auto-complete bookings when duration elapses~~ DONE | Backend today handlers | +| ~~Profile picture upload~~ | ~~Upload with cropper to separate bucket, sync to CalDAV~~ DONE | Backend + Account page | +| ~~Contact page dynamic~~ | ~~Fetch from `/api/contact` using first admin~~ DONE | Backend + Contact page | +| ~~Simplified deposits~~ | ~~`deposits_required` INT on users, 48h check, reduce on payment~~ DONE | Backend booking logic | | **Begin button (Today)** | Manual start for early arrivals, gray out if >3hrs away | `CurrentAppointment.svelte` + backend | -| **One-off custom services** | Admin creates custom service for single booking without adding to main list | Backend + frontend booking modals | -| **One-off exceptional hours** | Single-day exceptions (dentist, afternoon off) - not yearly/weekly | Backend scheduling + frontend HolidayHours | -| **Auto lunch protection** | Block bookings that remove lunch break (1h customer, 30min admin with warning) | Backend `available-hours` logic | -| **Walk-in slot blocking** | Properly block next available slot during walk-in intake | `WalkInCreateModal.svelte` | -| **Square payment integration** | Full Square SDK integration | Backend payment handlers + frontend payment step | -| **GDPR data export** | User button for "give me my data" using `export_all_user_data()` | Backend endpoint + account page | -| **Tax data export** | Admin button for tax-software-compatible format | Backend endpoint + admin page | +| **One-off custom services** | Admin creates custom service for single booking without adding to main list | Backend + frontend booking modals | +| **One-off exceptional hours** | Single-day exceptions (dentist, afternoon off) - not yearly/weekly | Backend scheduling + frontend HolidayHours | +| **Auto lunch protection** | Block bookings that remove lunch break (1h customer, 30min admin with warning) | Backend `available-hours` logic | +| **Walk-in slot blocking** | Properly block next available slot during walk-in intake | `WalkInCreateModal.svelte` | +| **Square payment integration** | Full Square SDK integration | Backend payment handlers + frontend payment step | +| **GDPR data export** | User button for "give me my data" using `export_all_user_data()` | Backend endpoint + account page | +| **Tax data export** | Admin button for tax-software-compatible format | Backend endpoint + admin page | ### Medium Priority @@ -402,6 +422,17 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo | `POSTGRES_USER` | Database username | Docker | | `POSTGRES_PASSWORD` | Database password | Docker | | `POSTGRES_DB` | Database name | Docker | +| `S3_BUCKET` | Main image bucket (portfolio) | No (default: crussell) | +| `S3_PROFILE_PICS_BUCKET` | Profile pictures bucket | No (default: crussell-profile-pics) | +| `S3_ENDPOINT` | S3/Rustfs endpoint | Dev | +| `S3_PUBLIC_URL` | Public URL for S3 bucket | Dev | +| `S3_ACCESS_KEY` | S3 access key | Dev | +| `S3_SECRET_KEY` | S3 secret key | Dev | +| `R2_ENDPOINT` | Cloudflare R2 endpoint | Prod | +| `R2_BUCKET` | R2 bucket name | Prod | +| `R2_PUBLIC_URL` | R2 public URL | Prod | +| `R2_ACCESS_KEY` | R2 access key | Prod | +| `R2_SECRET_KEY` | R2 secret key | Prod | --- diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0d80941 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Crussell", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}