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:
2026-02-21 18:48:29 +00:00
parent 88d8469180
commit 970cc5554d
48 changed files with 1984 additions and 175 deletions
+143
View File
@@ -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))
}
+253 -1
View File
@@ -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, &notes, &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)
}
+83
View File
@@ -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)
+216 -7
View File
@@ -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)
}
+34
View File
@@ -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
View File
@@ -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) {