Files
Crussell/backend/handlers/today/today.go
T
popertots bffb984ebb feat(auth,security,scheduling): JWT revocation, S3 fix, notes validation, docs, tests
- JWT revocation with JTI (UUID v4): in-memory tracking, POST /api/logout,
  refresh handler revokes old JTI, RequireAuth rejects revoked tokens
- Fix extractKey for S3 portfolio deletion: extracts full key path from URLs
  instead of just filename, preventing orphaned storage files
- Notes validation: max=1000000 on all 13 Notes fields across 4 booking structs
- CharCounter: grapheme-aware counter (Intl.Segmenter), threshold 750K,
  color-coded, integrated into 6 booking/admin components
- loginInProgress: timestamp-based tracking, 30s staleness, 20-entry cap (429),
  ticker cleanup for stuck entries
- Profile picture 15MB client-side limit, portfolio 20MB backend limit
- Exceptional scheduling: expand query start to Monday of week
- TodayCalendar: week-range fetching, closing time indicator, short-day lunch skip
- NavBar: link reorder, mobile burger badge, slide transition, backdrop
- ImageUpload: 20MB limit with visual feedback
- formatDateISO: shared YYYY-MM-DD utility, shouldApplyLunchProtection helper
- Update README.md and all Obsidian docs (Overview, Technical, Admin, Future Work)
- Add 28 new tests: JWT (11), auth handlers (7), portfolio extractKey (5),
  notes validation (5). go build + go vet clean with test,dev tags
2026-06-03 11:17:41 +01:00

561 lines
14 KiB
Go

package today
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"time"
"crussell/db"
)
type ServiceInfo struct {
ServiceName *string `json:"service_name,omitempty"`
ServiceDescription *string `json:"service_description,omitempty"`
Price *float64 `json:"price,omitempty"`
DurationMinutes *int `json:"duration_minutes,omitempty"`
}
type UserInfo struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Phone *string `json:"phone,omitempty"`
Email *string `json:"email,omitempty"`
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
}
type AppointmentInfo struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
User *UserInfo `json:"user,omitempty"`
Services []ServiceInfo `json:"services"`
DurationMinutes int `json:"duration_minutes"`
TotalAmount float64 `json:"total_amount"`
}
type CurrentNextResponse struct {
Current *AppointmentInfo `json:"current"`
Next *AppointmentInfo `json:"next"`
ClosingTime *string `json:"closing_time,omitempty"` // "HH:MM" format
}
// GET /api/admin/today/current-next
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
// Step 1: Try to find current in-progress appointment
currentBooking, err := fetchAppointment(r, `
SELECT b.id, b.start_time, b.status, b.notes, b.user_id
FROM bookings b
WHERE b.status = 'in_progress'
ORDER BY b.start_time ASC
LIMIT 1
`)
if err != nil && err != sql.ErrNoRows {
log.Printf("Error querying current appointment: %v", err)
// Don't fail, continue to find next
}
if err == nil && currentBooking != nil {
current = currentBooking
// Also fetch the appointment AFTER this one for free time calculation
nextBooking, err := fetchAppointment(r, `
SELECT b.id, b.start_time, b.status, b.notes, b.user_id
FROM bookings b
WHERE b.start_time > $1
AND (b.status = 'confirmed' OR b.status = 'pending' OR b.status = 'in_progress')
ORDER BY b.start_time ASC
LIMIT 1
`, currentBooking.StartTime)
if err == nil && nextBooking != nil {
next = nextBooking
}
} else {
// No current in-progress appointment, find the next upcoming one
nextBooking, err := fetchAppointment(r, `
SELECT b.id, b.start_time, b.status, b.notes, b.user_id
FROM bookings b
WHERE b.start_time >= $1
AND (b.status = 'confirmed' OR b.status = 'pending')
ORDER BY b.start_time ASC
LIMIT 1
`, now)
if err != nil && err != sql.ErrNoRows {
log.Printf("Error querying next appointment: %v", err)
}
if err == nil && nextBooking != nil {
current = nextBooking
// Fetch the one after for free time calculation
afterNext, err := fetchAppointment(r, `
SELECT b.id, b.start_time, b.status, b.notes, b.user_id
FROM bookings b
WHERE b.start_time > $1
AND (b.status = 'confirmed' OR b.status = 'pending' OR b.status = 'in_progress')
ORDER BY b.start_time ASC
LIMIT 1
`, nextBooking.StartTime)
if err == nil && afterNext != nil {
next = afterNext
}
}
}
response := CurrentNextResponse{
Current: current,
Next: next,
}
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
var closingTime sql.NullString
_ = db.DB.QueryRow(r.Context(), `
SELECT end_time::text FROM working_hours WHERE weekday = $1 AND is_open = true
`, weekday).Scan(&closingTime)
if closingTime.Valid {
response.ClosingTime = &closingTime.String
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode current/next response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Helper function to fetch a single appointment with all details
func fetchAppointment(r *http.Request, query string, args ...interface{}) (*AppointmentInfo, error) {
var bookingID string
var startTime time.Time
var status string
var notes sql.NullString
var userID string
err := db.DB.QueryRow(r.Context(), query, args...).Scan(
&bookingID, &startTime, &status, &notes, &userID,
)
if err != nil {
return nil, err
}
appointment := &AppointmentInfo{
ID: bookingID,
StartTime: startTime,
Status: status,
}
if notes.Valid {
appointment.Notes = &notes.String
}
// Fetch user info
var user UserInfo
var phone sql.NullString
var email sql.NullString
var profilePicURL sql.NullString
err = db.DB.QueryRow(r.Context(), `
SELECT id, fn, phone, email, profile_pic_url
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.FullName, &phone, &email, &profilePicURL)
if err == nil {
if phone.Valid {
user.Phone = &phone.String
}
if profilePicURL.Valid {
user.ProfilePicURL = &profilePicURL.String
}
if email.Valid {
user.Email = &email.String
}
appointment.User = &user
} else {
log.Printf("Failed to fetch user %s: %v", userID, err)
}
// Fetch services
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
s.name,
s.description,
COALESCE(bs.override_price, s.price) as price,
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
return appointment, nil
}
defer serviceRows.Close()
var totalAmount float64
var totalDuration int
for serviceRows.Next() {
var service ServiceInfo
var name string
var description sql.NullString
var price float64
var duration int
if err := serviceRows.Scan(&name, &description, &price, &duration); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
service.ServiceName = &name
if description.Valid {
service.ServiceDescription = &description.String
}
service.Price = &price
service.DurationMinutes = &duration
totalAmount += price
totalDuration += duration
appointment.Services = append(appointment.Services, service)
}
appointment.TotalAmount = totalAmount
appointment.DurationMinutes = totalDuration
return appointment, nil
}
type TodayAppointment struct {
ID string `json:"id"`
StartTime string `json:"start_time"`
Status string `json:"status"`
UserName string `json:"user_name"`
UserID string `json:"user_id"`
Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"`
}
type TodayAppointmentsResponse struct {
Appointments []TodayAppointment `json:"appointments"`
}
// 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)
// Fetch all bookings for today
rows, err := db.DB.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
u.fn as user_name,
u.id as user_id
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.start_time >= $1
AND b.start_time < $2
ORDER BY b.start_time ASC
`, todayStart, todayEnd)
if err != nil {
log.Printf("Failed to fetch today's appointments: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var appointments []TodayAppointment
for rows.Next() {
var apt TodayAppointment
var startTime time.Time
err := rows.Scan(
&apt.ID,
&startTime,
&apt.Status,
&apt.UserName,
&apt.UserID,
)
if err != nil {
log.Printf("Failed to scan appointment row: %v", err)
continue
}
apt.StartTime = startTime.Format(time.RFC3339)
// Fetch services for this booking
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
s.name,
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
`, apt.ID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", apt.ID, err)
continue
}
var services []string
var totalDuration int
for serviceRows.Next() {
var serviceName string
var duration int
if err := serviceRows.Scan(&serviceName, &duration); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
services = append(services, serviceName)
totalDuration += duration
}
serviceRows.Close()
apt.Services = services
apt.DurationMinutes = totalDuration
appointments = append(appointments, apt)
}
if appointments == nil {
appointments = []TodayAppointment{}
}
response := TodayAppointmentsResponse{
Appointments: appointments,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode today's appointments response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
type PendingApproval struct {
ID string `json:"id"`
StartTime string `json:"start_time"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"`
CreatedAt string `json:"created_at"`
}
type PendingApprovalsResponse struct {
Approvals []PendingApproval `json:"approvals"`
}
// GET /api/admin/today/pending-approvals
func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.created_at,
u.id as user_id,
u.fn as user_name
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.status = 'pending'
ORDER BY b.created_at ASC
`)
if err != nil {
log.Printf("Failed to fetch pending approvals: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var approvals []PendingApproval
for rows.Next() {
var apt PendingApproval
var startTime time.Time
var createdAt time.Time
err := rows.Scan(
&apt.ID,
&startTime,
&createdAt,
&apt.UserID,
&apt.UserName,
)
if err != nil {
log.Printf("Failed to scan pending approval row: %v", err)
continue
}
apt.StartTime = startTime.Format(time.RFC3339)
apt.CreatedAt = createdAt.Format(time.RFC3339)
// Fetch services
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
s.name,
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
`, apt.ID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", apt.ID, err)
continue
}
var services []string
var totalDuration int
for serviceRows.Next() {
var name string
var duration int
if err := serviceRows.Scan(&name, &duration); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
services = append(services, name)
totalDuration += duration
}
serviceRows.Close()
apt.Services = services
apt.DurationMinutes = totalDuration
approvals = append(approvals, apt)
}
if approvals == nil {
approvals = []PendingApproval{}
}
response := PendingApprovalsResponse{
Approvals: approvals,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode pending approvals response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}