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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+10
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user