mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(availability): per-staff working hours and class/session management
- Working-hours repo accepts nullable staff scope (pgx + memory); staff rules live in availability_rules and drive public slot generation - Protected routes for staff working hours, class templates, and class sessions with tenant-ownership checks and input validation - Sessions derive endsAt/capacity from templates; list resolves location names and live booked counts; template delete cascades sessions, session delete preserves bookings via SET NULL (mirrored in memory) - Zones page: staff-scope selector on working hours, new Classes tab with template/session CRUD, CS+EN strings, mobile tab wrapping - Normalize availability_rules times to HH:MM in the service layer Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
d9aa29dbcb
commit
0405106587
12 files changed
+2069
-58
No files matched your search
@@ -26,6 +26,13 @@ type Repository interface {
|
||||
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
|
||||
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error)
|
||||
ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error)
|
||||
GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error)
|
||||
CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error)
|
||||
DeleteClassSession(ctx context.Context, sessionID string) error
|
||||
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
|
||||
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
|
||||
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
||||
DeleteClassTemplate(ctx context.Context, templateID string) error
|
||||
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
||||
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
||||
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
|
||||
@@ -100,8 +107,8 @@ type Repository interface {
|
||||
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
|
||||
|
||||
// Working Hours
|
||||
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
|
||||
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
|
||||
ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error)
|
||||
UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error
|
||||
|
||||
// Email templates
|
||||
ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error)
|
||||
@@ -293,6 +300,22 @@ type AvailabilityRuleRecord struct {
|
||||
EndsLocal string
|
||||
}
|
||||
|
||||
type ClassTemplateRecord struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Title string
|
||||
DurationMinutes int
|
||||
Capacity int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateClassTemplateParams struct {
|
||||
TenantID string
|
||||
Title string
|
||||
DurationMinutes int
|
||||
Capacity int
|
||||
}
|
||||
|
||||
type ClassSessionRecord struct {
|
||||
ID string
|
||||
TenantID string
|
||||
@@ -304,6 +327,15 @@ type ClassSessionRecord struct {
|
||||
Capacity int32
|
||||
}
|
||||
|
||||
type CreateClassSessionParams struct {
|
||||
TenantID string
|
||||
TemplateID string
|
||||
LocationID *string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Capacity int32
|
||||
}
|
||||
|
||||
type BookingRecord struct {
|
||||
ID string
|
||||
TenantID string
|
||||
@@ -719,6 +751,7 @@ type MemoryRepository struct {
|
||||
brand BrandProfileRecord
|
||||
services []ServiceRecord
|
||||
rules []AvailabilityRuleRecord
|
||||
classTemplates []ClassTemplateRecord
|
||||
classSessions []ClassSessionRecord
|
||||
bookings []BookingRecord
|
||||
waitlistSize int
|
||||
@@ -810,6 +843,9 @@ func NewMemoryRepository() *MemoryRepository {
|
||||
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 4, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
|
||||
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
|
||||
},
|
||||
classTemplates: []ClassTemplateRecord{
|
||||
{ID: templateID, TenantID: tenantID, Title: "Small group mobility class", DurationMinutes: 60, Capacity: 4, CreatedAt: now},
|
||||
},
|
||||
classSessions: []ClassSessionRecord{
|
||||
{
|
||||
ID: sessionID,
|
||||
@@ -1179,6 +1215,97 @@ func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetClassSessionByID(_ context.Context, sessionID string) (ClassSessionRecord, error) {
|
||||
for _, session := range r.classSessions {
|
||||
if session.ID == sessionID {
|
||||
return session, nil
|
||||
}
|
||||
}
|
||||
return ClassSessionRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) {
|
||||
record := ClassSessionRecord{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: params.TenantID,
|
||||
TemplateID: params.TemplateID,
|
||||
LocationID: params.LocationID,
|
||||
StartsAt: params.StartsAt,
|
||||
EndsAt: params.EndsAt,
|
||||
Capacity: params.Capacity,
|
||||
}
|
||||
for _, template := range r.classTemplates {
|
||||
if template.ID == params.TemplateID {
|
||||
record.Title = template.Title
|
||||
break
|
||||
}
|
||||
}
|
||||
r.classSessions = append(r.classSessions, record)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID string) error {
|
||||
for i, session := range r.classSessions {
|
||||
if session.ID == sessionID {
|
||||
r.classSessions = append(r.classSessions[:i], r.classSessions[i+1:]...)
|
||||
for j := range r.bookings {
|
||||
if r.bookings[j].ClassSessionID != nil && *r.bookings[j].ClassSessionID == sessionID {
|
||||
r.bookings[j].ClassSessionID = nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil, nil
|
||||
}
|
||||
return append([]ClassTemplateRecord(nil), r.classTemplates...), nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetClassTemplateByID(_ context.Context, templateID string) (ClassTemplateRecord, error) {
|
||||
for _, template := range r.classTemplates {
|
||||
if template.ID == templateID {
|
||||
return template, nil
|
||||
}
|
||||
}
|
||||
return ClassTemplateRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
|
||||
record := ClassTemplateRecord{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: params.TenantID,
|
||||
Title: params.Title,
|
||||
DurationMinutes: params.DurationMinutes,
|
||||
Capacity: params.Capacity,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
r.classTemplates = append(r.classTemplates, record)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error {
|
||||
for i, template := range r.classTemplates {
|
||||
if template.ID == templateID {
|
||||
r.classTemplates = append(r.classTemplates[:i], r.classTemplates[i+1:]...)
|
||||
// class_sessions.template_id has ON DELETE CASCADE
|
||||
var remaining []ClassSessionRecord
|
||||
for _, session := range r.classSessions {
|
||||
if session.TemplateID != templateID {
|
||||
remaining = append(remaining, session)
|
||||
}
|
||||
}
|
||||
r.classSessions = remaining
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
||||
var out []BookingRecord
|
||||
for _, booking := range r.bookings {
|
||||
@@ -1699,29 +1826,114 @@ func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTe
|
||||
// WORKING HOURS METHODS - MEMORY REPOSITORY
|
||||
// ============================================
|
||||
|
||||
func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string) ([]WorkingHoursRecord, error) {
|
||||
// ListWorkingHoursByTenant returns tenant-wide hours from the dedicated
|
||||
// working-hours store, and staff-scoped hours from the availability rules that
|
||||
// slot generation consumes — mirroring PostgreSQL where both live in
|
||||
// availability_rules.
|
||||
func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil, pgx.ErrNoRows
|
||||
}
|
||||
return r.workingHours, nil
|
||||
var out []WorkingHoursRecord
|
||||
if staffID == nil {
|
||||
for _, wh := range r.workingHours {
|
||||
if wh.TenantID == tenantID && wh.StaffID == nil {
|
||||
out = append(out, wh)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
for _, rule := range r.rules {
|
||||
if rule.TenantID == tenantID && rule.StaffID != nil && *rule.StaffID == *staffID {
|
||||
out = append(out, WorkingHoursRecord{
|
||||
TenantID: rule.TenantID,
|
||||
StaffID: rule.StaffID,
|
||||
DayOfWeek: rule.DayOfWeek,
|
||||
StartsLocal: rule.StartsLocal,
|
||||
EndsLocal: rule.EndsLocal,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
|
||||
func staffIDPtrEqual(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
|
||||
if tenantID != r.tenant.ID {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
if staffID != nil {
|
||||
return r.updateStaffWorkingHours(tenantID, staffID, dayOfWeek, params)
|
||||
}
|
||||
if params.IsOpen != nil && !*params.IsOpen {
|
||||
filtered := r.workingHours[:0]
|
||||
for _, wh := range r.workingHours {
|
||||
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, wh)
|
||||
}
|
||||
r.workingHours = filtered
|
||||
return nil
|
||||
}
|
||||
if params.StartsLocal == nil || params.EndsLocal == nil {
|
||||
return nil
|
||||
}
|
||||
for i, wh := range r.workingHours {
|
||||
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek {
|
||||
if params.StartsLocal != nil {
|
||||
r.workingHours[i].StartsLocal = *params.StartsLocal
|
||||
}
|
||||
if params.EndsLocal != nil {
|
||||
r.workingHours[i].EndsLocal = *params.EndsLocal
|
||||
}
|
||||
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
|
||||
r.workingHours[i].StartsLocal = *params.StartsLocal
|
||||
r.workingHours[i].EndsLocal = *params.EndsLocal
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return pgx.ErrNoRows
|
||||
r.workingHours = append(r.workingHours, WorkingHoursRecord{
|
||||
TenantID: tenantID,
|
||||
DayOfWeek: dayOfWeek,
|
||||
StartsLocal: *params.StartsLocal,
|
||||
EndsLocal: *params.EndsLocal,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateStaffWorkingHours replaces the staff-scoped availability rule for a
|
||||
// day so edits actually change generated slots, matching PostgreSQL semantics.
|
||||
func (r *MemoryRepository) updateStaffWorkingHours(tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
|
||||
filtered := r.rules[:0]
|
||||
for _, rule := range r.rules {
|
||||
if rule.TenantID == tenantID && rule.DayOfWeek == dayOfWeek && rule.StaffID != nil && *rule.StaffID == *staffID {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
r.rules = filtered
|
||||
if params.IsOpen != nil && !*params.IsOpen {
|
||||
return nil
|
||||
}
|
||||
if params.StartsLocal == nil || params.EndsLocal == nil {
|
||||
return nil
|
||||
}
|
||||
r.rules = append(r.rules, AvailabilityRuleRecord{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: tenantID,
|
||||
StaffID: staffID,
|
||||
DayOfWeek: dayOfWeek,
|
||||
StartsLocal: normalizeLocalTime(*params.StartsLocal),
|
||||
EndsLocal: normalizeLocalTime(*params.EndsLocal),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLocalTime(value string) string {
|
||||
if len(value) == 5 {
|
||||
return value + ":00"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Auth methods for MemoryRepository
|
||||
|
||||
Reference in new issue
Block a user