diff --git a/backend/auth/jwt.go b/backend/auth/jwt.go index d9ec1d1..d31d3c2 100644 --- a/backend/auth/jwt.go +++ b/backend/auth/jwt.go @@ -1,4 +1,3 @@ -// auth/jwt.go package auth import ( diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go new file mode 100644 index 0000000..ad7a916 --- /dev/null +++ b/backend/handlers/scheduling/default-hours.go @@ -0,0 +1,392 @@ +package scheduling + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "crussell/db" +) + +// --- Types --- +type DefaultHours struct { + Weekday int `json:"weekday"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + IsOpen bool `json:"isOpen"` +} + +type ExceptionalHours struct { + ID int `json:"id"` + GroupID int `json:"groupId"` + Weekday int `json:"weekday"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + IsOpen bool `json:"isOpen"` +} + +type ExceptionalGroup struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Hours []ExceptionalHours `json:"hours,omitempty"` // 7 entries +} + +type ExceptionalApplication struct { + ID int `json:"id"` + GroupID int `json:"groupId"` + WeekStart string `json:"weekStart"` // Monday date +} + +type DayWorkingHours struct { + Date string `json:"date"` + Weekday int `json:"weekday"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + IsOpen bool `json:"isOpen"` + Source string `json:"source"` // "default" or "exceptional" +} + +// --- Default Hours Handlers --- +func GetDefaultHours(w http.ResponseWriter, r *http.Request) { + rows, err := db.DB.Query(r.Context(), ` + SELECT weekday, start_time::text, end_time::text, is_open + FROM working_hours ORDER BY weekday + `) + if err != nil { + http.Error(w, "failed to fetch default hours", http.StatusInternalServerError) + return + } + defer rows.Close() + + var hours []DefaultHours + for rows.Next() { + var h DefaultHours + if err := rows.Scan(&h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err != nil { + http.Error(w, "failed to scan default hours", http.StatusInternalServerError) + return + } + hours = append(hours, h) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(hours) +} + +func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) { + var hours []DefaultHours + if err := json.NewDecoder(r.Body).Decode(&hours); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + http.Error(w, "failed to start tx", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + for _, h := range hours { + _, err := tx.Exec(r.Context(), ` + UPDATE working_hours + SET start_time=$1,end_time=$2,is_open=$3 + WHERE weekday=$4 + `, h.StartTime, h.EndTime, h.IsOpen, h.Weekday) + if err != nil { + http.Error(w, "failed to update default hours", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + http.Error(w, "failed to commit", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// --- Exceptional Groups & Hours --- +func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) { + rows, err := db.DB.Query(r.Context(), ` + SELECT id,name,description + FROM exceptional_working_hours_groups + ORDER BY id DESC + `) + if err != nil { + http.Error(w, "failed to fetch groups", http.StatusInternalServerError) + return + } + defer rows.Close() + + var groups []ExceptionalGroup + for rows.Next() { + var g ExceptionalGroup + if err := rows.Scan(&g.ID, &g.Name, &g.Description); err != nil { + http.Error(w, "failed to scan group", http.StatusInternalServerError) + return + } + // load 7-day hours + hoursRows, _ := db.DB.Query(r.Context(), ` + SELECT id, weekday, start_time::text, end_time::text, is_open + FROM exceptional_working_hours + WHERE group_id=$1 ORDER BY weekday + `, g.ID) + for hoursRows.Next() { + var h ExceptionalHours + if err := hoursRows.Scan(&h.ID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err == nil { + h.GroupID = g.ID + g.Hours = append(g.Hours, h) + } + } + hoursRows.Close() + groups = append(groups, g) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(groups) +} + +func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) { + var g ExceptionalGroup + if err := json.NewDecoder(r.Body).Decode(&g); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if len(g.Hours) != 7 { + http.Error(w, "must provide 7 weekday entries", http.StatusBadRequest) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + http.Error(w, "failed to start tx", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + err = tx.QueryRow(r.Context(), ` + INSERT INTO exceptional_working_hours_groups (name, description) + VALUES ($1,$2) RETURNING id + `, g.Name, g.Description).Scan(&g.ID) + if err != nil { + http.Error(w, "failed to create group", http.StatusInternalServerError) + return + } + + for _, h := range g.Hours { + _, err := tx.Exec(r.Context(), ` + INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) + VALUES ($1,$2,$3,$4,$5) + `, g.ID, h.Weekday, h.StartTime, h.EndTime, h.IsOpen) + if err != nil { + http.Error(w, "failed to insert group hours", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + http.Error(w, "failed to commit", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(g) +} + +// --- Exceptional Applications (assign a group to a week) --- +func ListExceptionalApplications(w http.ResponseWriter, r *http.Request) { + rows, err := db.DB.Query(r.Context(), ` + SELECT id, group_id, week_start + FROM exceptional_group_applications + ORDER BY week_start DESC + `) + if err != nil { + http.Error(w, "failed to fetch applications", http.StatusInternalServerError) + return + } + defer rows.Close() + + var list []ExceptionalApplication + for rows.Next() { + var a ExceptionalApplication + var weekStart time.Time + if err := rows.Scan(&a.ID, &a.GroupID, &weekStart); err != nil { + http.Error(w, "failed to scan application", http.StatusInternalServerError) + return + } + a.WeekStart = weekStart.Format("2006-01-02") + list = append(list, a) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(list) +} + +func CreateExceptionalApplication(w http.ResponseWriter, r *http.Request) { + var a ExceptionalApplication + if err := json.NewDecoder(r.Body).Decode(&a); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + weekStart, err := time.Parse("2006-01-02", a.WeekStart) + if err != nil { + http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest) + return + } + + err = db.DB.QueryRow(r.Context(), ` + INSERT INTO exceptional_group_applications (group_id, week_start) + VALUES ($1,$2) + RETURNING id + `, a.GroupID, weekStart).Scan(&a.ID) + if err != nil { + http.Error(w, "failed to create application", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(a) +} + +// --- GetWorkingHours (merged default + applied exceptions) --- +func GetWorkingHours(w http.ResponseWriter, r *http.Request) { + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") + if startStr == "" || endStr == "" { + http.Error(w, "start and end query params required", http.StatusBadRequest) + return + } + start, err := time.Parse("2006-01-02", startStr) + if err != nil { + http.Error(w, "invalid start date", http.StatusBadRequest) + return + } + end, err := time.Parse("2006-01-02", endStr) + if err != nil { + http.Error(w, "invalid end date", http.StatusBadRequest) + return + } + + // Load default hours + defaultMap := map[int]DefaultHours{} + defRows, _ := db.DB.Query(r.Context(), ` + SELECT weekday, start_time::text, end_time::text, is_open + FROM working_hours + `) + for defRows.Next() { + var d DefaultHours + if err := defRows.Scan(&d.Weekday, &d.StartTime, &d.EndTime, &d.IsOpen); err == nil { + defaultMap[d.Weekday] = d + } + } + defRows.Close() + + // Load exceptional applications in range + appRows, _ := db.DB.Query(r.Context(), ` + SELECT a.group_id, a.week_start + FROM exceptional_group_applications a + WHERE a.week_start <= $1 AND a.week_start >= $2 - INTERVAL '6 days' + `, end, start) // any week overlapping the range + type appEntry struct { + GroupID int + WeekStart time.Time + } + var apps []appEntry + for appRows.Next() { + var e appEntry + if err := appRows.Scan(&e.GroupID, &e.WeekStart); err == nil { + apps = append(apps, e) + } + } + appRows.Close() + + // Load exceptional + // Load exceptional hours for all relevant groups + groupIDs := []int{} + for _, a := range apps { + groupIDs = append(groupIDs, a.GroupID) + } + + exHoursMap := map[int]map[int]ExceptionalHours{} // groupID -> weekday -> hours + if len(groupIDs) > 0 { + query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time::text, is_open FROM exceptional_working_hours WHERE group_id IN (?)", groupIDs) + rows, _ := db.DB.Query(r.Context(), query, args...) + for rows.Next() { + var h ExceptionalHours + if err := rows.Scan(&h.GroupID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err == nil { + if _, ok := exHoursMap[h.GroupID]; !ok { + exHoursMap[h.GroupID] = map[int]ExceptionalHours{} + } + exHoursMap[h.GroupID][h.Weekday] = h + } + } + rows.Close() + } + + // Generate final result per day + var results []DayWorkingHours + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + weekday := int(d.Weekday()) + if weekday == 0 { + weekday = 6 // Go Sunday=0 -> our Sunday=6 + } else { + weekday -= 1 // shift Monday=0 ... Sunday=6 + } + + // find applied group for this week + var applied *ExceptionalHours + weekStart := d.AddDate(0, 0, -weekday) // Monday of current week + for _, a := range apps { + if a.WeekStart.Equal(weekStart) { + if dayHours, ok := exHoursMap[a.GroupID][weekday]; ok { + applied = &dayHours + } + break + } + } + + var day DayWorkingHours + day.Date = d.Format("2006-01-02") + day.Weekday = weekday + + if applied != nil { + day.StartTime = applied.StartTime + day.EndTime = applied.EndTime + day.IsOpen = applied.IsOpen + day.Source = "exceptional" + } else if def, ok := defaultMap[weekday]; ok { + day.StartTime = def.StartTime + day.EndTime = def.EndTime + day.IsOpen = def.IsOpen + day.Source = "default" + } else { + day.StartTime = "00:00" + day.EndTime = "00:00" + day.IsOpen = false + day.Source = "default" + } + + results = append(results, day) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(results) +} + +// --- helper: sqlIn generates IN queries dynamically for Postgres --- +func sqlIn(query string, args []int) (string, []interface{}, error) { + inArgs := []interface{}{} + placeholders := "" + for i, arg := range args { + if i > 0 { + placeholders += "," + } + placeholders += fmt.Sprintf("$%d", i+1) + inArgs = append(inArgs, arg) + } + query = fmt.Sprintf(query, placeholders) + return query, inArgs, nil +} diff --git a/backend/main.go b/backend/main.go index f9fa87f..4d3722a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -16,7 +16,8 @@ import ( "crussell/mw" authHandlers "crussell/handlers/auth" - userHandlers "crussell/handlers/user" + "crussell/handlers/scheduling" + "crussell/handlers/user" ) func init() { @@ -52,6 +53,7 @@ func main() { r := chi.NewRouter() + // --- Middleware --- r.Use(middleware.RequestID) // Add X-Request-ID header r.Use(middleware.RealIP) // Get real IP from headers r.Use(middleware.Logger) // Basic logging @@ -67,40 +69,52 @@ func main() { }) }) - // Public auth routes + // --- Public auth routes --- r.Post("/api/register", authHandlers.RegisterHandler) r.Post("/api/login", authHandlers.LoginHandler) - // Protected routes - any authenticated user + // --- Protected routes - any authenticated user --- r.Group(func(r chi.Router) { r.Use(mw.RequireAuth) - // Auth - r.Post("/api/refresh-token", authHandlers.RefreshTokenHandler) - // User profile - r.Get("/api/user/profile", userHandlers.GetProfileHandler) - r.Put("/api/user/profile", userHandlers.UpdateProfileHandler) - r.Delete("/api/user/account", userHandlers.DeleteAccountHandler) + r.Get("/api/user/profile", user.GetProfileHandler) + r.Put("/api/user/profile", user.UpdateProfileHandler) + r.Delete("/api/user/account", user.DeleteAccountHandler) // Loyalty - r.Get("/api/user/loyalty", userHandlers.GetLoyaltyHandler) + r.Get("/api/user/loyalty", user.GetLoyaltyHandler) }) - // Protected routes - verified users only - r.Group(func(r chi.Router) { - r.Use(mw.RequireAuth) - r.Use(mw.RequireVerified) + // --- Scheduling routes --- + r.Route("/api/scheduling", func(r chi.Router) { - // Add booking routes, etc. - }) + // Default hours + r.Get("/default-hours", scheduling.GetDefaultHours) + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + r.Use(mw.RequireAdmin) + r.Put("/default-hours", scheduling.UpdateDefaultHours) + }) - // Admin routes - r.Group(func(r chi.Router) { - r.Use(mw.RequireAuth) - r.Use(mw.RequireAdmin) + // Exceptional groups + r.Get("/exceptional-groups", scheduling.ListExceptionalGroups) + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + r.Use(mw.RequireAdmin) + r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup) + }) - // Add admin routes + // Exceptional applications (assign groups to weeks) + r.Get("/exceptional-applications", scheduling.ListExceptionalApplications) + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + r.Use(mw.RequireAdmin) + r.Post("/exceptional-applications", scheduling.CreateExceptionalApplication) + }) + + // Merged working hours by date range + r.Get("/working-hours", scheduling.GetWorkingHours) }) fmt.Println("Server is listening on :8080") diff --git a/frontend/src/lib/components/ui/skeleton/index.ts b/frontend/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..186db21 --- /dev/null +++ b/frontend/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/frontend/src/lib/components/ui/skeleton/skeleton.svelte b/frontend/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..c7e3d26 --- /dev/null +++ b/frontend/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 8f82bc5..92d965c 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -13,14 +13,18 @@ // Custom component import FileDropZone from '$lib/components/ui/file-drop-zone.svelte'; + import { browser } from '$app/environment'; + import { toast } from 'svelte-sonner'; + import { Skeleton } from '$lib/components/ui/skeleton'; - // Auth guard - onMount(() => { - const user = authStore.currentUser; + const user = authStore.currentUser; + if (browser) { if (!user || user.role !== 'admin') { - goto('/', { replaceState: true }); + setTimeout(() => { + goto('/', { replaceState: true }); + }, 5000); } - }); + } // =============== Image Upload =============== let uploading = $state(false); @@ -72,14 +76,61 @@ is_open: boolean; }; - let defaultHours = $stateManage portfolio images, working hours & user bookings
+{#if user?.role == 'admin'} +Manage portfolio images, working hours & user bookings
+Drop files here, or click to open the file picker
-Drop files here, or click to open the file picker
+| Day | -Status | -Start | -End | -
|---|---|---|---|
| {weekdayLabel(row.weekday)} | -- - {row.is_open ? 'Open' : 'Closed'} - - | -- {row.is_open ? row.start_time : '—'} - | -- {row.is_open ? row.end_time : '—'} - | -
No exception groups found.
- {/if} - - {#each exceptionGroups as g} -| Day | -Open | -Start | -End | -
|---|---|---|---|
| {weekdayLabel(row.weekday)} | -- - | -- - | -- - | -
No exception groups found.
+ {/if} + + {#each exceptionGroups as g} +| Day | +Status | +Start | +End | +
|---|---|---|---|
| {weekdayLabel(row.weekday)} | ++ + {row.is_open ? 'Open' : 'Closed'} + + | ++ {row.is_open ? row.start_time : '—'} + | ++ {row.is_open ? row.end_time : '—'} + | +
| Day | +Status | +Start | +End | +
|---|---|---|---|
| Day | +Open | +Start | +End | +
|---|---|---|---|
| {weekdayLabel(row.weekday)} | ++ + | ++ + | ++ + | +
You do not have permission to access this page.
++ Redirecting you to the homepage in 5 seconds… +
+