feat: add email verification, profile pictures, deposits, and calendar
export Backend: - Add email verification code generation and verification endpoints - Add profile picture upload with S3 storage and image processing - Add deposit_required field to users with 48h advance booking requirement - Add loyalty stamps that accumulate on completed bookings - Auto-transition bookings: confirmed → in_progress → completed - Add booking cancellation handler with no-show detection - Add ICS calendar file download endpoint for bookings - Sync bookings to CalDAV on confirmation Frontend: - Add schedule page route - Add avatar and image-cropper UI components - Update shadcn-svelte components (button, dialog) - Add "Add to Calendar" button in booking modal Database: - Add verification_codes table - Add profile_pic_url, loyalty_stamps, deposits_required to users - Various schema updates
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user