mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(classes): weekly recurring schedules with lazy session materialization
class_schedules rules generate concrete session rows for a rolling 28-day horizon — no cron; materialize runs idempotently on dashboard and public reads via a partial unique index on (schedule_id, starts_at). Rule delete stops future sessions; existing sessions keep bookings and overrides. 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
7b9080eb1c
commit
ad60167a31
15 files changed
+1029
-7
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -1088,8 +1089,14 @@ func (s *Service) DeleteClassTemplate(ctx context.Context, principal domain.Prin
|
||||
return s.repo.DeleteClassTemplate(ctx, templateID)
|
||||
}
|
||||
|
||||
// scheduleHorizonDays is the rolling window that recurring class schedules
|
||||
// materialize concrete sessions for. Sessions are written idempotently on
|
||||
// read, so the horizon stays populated without a background job.
|
||||
const scheduleHorizonDays = 28
|
||||
|
||||
// ListClassSessions returns upcoming sessions enriched with location names and
|
||||
// live booked counts for the management UI.
|
||||
|
||||
func (s *Service) ListClassSessions(ctx context.Context, principal domain.Principal) ([]domain.ClassSession, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
@@ -1098,6 +1105,9 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
|
||||
tenantID := membership.Tenant.ID
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := s.repo.MaterializeScheduleSessions(ctx, tenantID, now, scheduleHorizonDays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1153,6 +1163,7 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
|
||||
Capacity: int(session.Capacity),
|
||||
BookedCount: booked,
|
||||
WaitlistCount: waitlisted,
|
||||
ScheduleID: session.ScheduleID,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -1287,6 +1298,145 @@ func (s *Service) CreateClassSession(ctx context.Context, principal domain.Princ
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListClassSchedules returns the tenant's weekly recurring rules enriched
|
||||
// with template titles and location names.
|
||||
func (s *Service) ListClassSchedules(ctx context.Context, principal domain.Principal) ([]domain.ClassSchedule, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
tenantID := membership.Tenant.ID
|
||||
|
||||
schedules, err := s.repo.ListClassSchedulesByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(schedules) == 0 {
|
||||
return []domain.ClassSchedule{}, nil
|
||||
}
|
||||
templates, err := s.repo.ListClassTemplatesByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templateTitles := make(map[string]string, len(templates))
|
||||
for _, tpl := range templates {
|
||||
templateTitles[tpl.ID] = tpl.Title
|
||||
}
|
||||
locations, err := s.repo.ListLocationsByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locationNames := make(map[string]string, len(locations))
|
||||
for _, loc := range locations {
|
||||
locationNames[loc.ID] = loc.Name
|
||||
}
|
||||
|
||||
out := make([]domain.ClassSchedule, len(schedules))
|
||||
for i, sch := range schedules {
|
||||
out[i] = domain.ClassSchedule{
|
||||
ID: sch.ID,
|
||||
TenantID: sch.TenantID,
|
||||
TemplateID: sch.TemplateID,
|
||||
TemplateTitle: templateTitles[sch.TemplateID],
|
||||
LocationID: sch.LocationID,
|
||||
LocationName: locationNames[deref(sch.LocationID)],
|
||||
DayOfWeek: sch.DayOfWeek,
|
||||
StartsLocal: sch.StartsLocal,
|
||||
Active: sch.Active,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateClassSchedule adds a weekly recurring rule for a class template, then
|
||||
// immediately materializes the horizon so the new sessions appear at once.
|
||||
func (s *Service) CreateClassSchedule(ctx context.Context, principal domain.Principal, req domain.CreateClassScheduleRequest) (domain.ClassSchedule, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.ClassSchedule{}, ErrTenantMembership
|
||||
}
|
||||
tenantID := membership.Tenant.ID
|
||||
|
||||
if err := s.requireTenantClassTemplate(ctx, tenantID, req.TemplateID); err != nil {
|
||||
return domain.ClassSchedule{}, err
|
||||
}
|
||||
if req.DayOfWeek < 0 || req.DayOfWeek > 6 {
|
||||
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
|
||||
}
|
||||
startsLocal := strings.TrimSpace(req.StartsLocal)
|
||||
parts := strings.SplitN(startsLocal, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
|
||||
}
|
||||
hour, errH := strconv.Atoi(parts[0])
|
||||
minute, errM := strconv.Atoi(parts[1])
|
||||
if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
|
||||
}
|
||||
startsLocal = fmt.Sprintf("%02d:%02d:00", hour, minute)
|
||||
|
||||
if req.LocationID != nil {
|
||||
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ClassSchedule{}, ErrLocationNotFound
|
||||
}
|
||||
return domain.ClassSchedule{}, err
|
||||
}
|
||||
if location.TenantID != tenantID {
|
||||
return domain.ClassSchedule{}, ErrLocationNotFound
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateClassSchedule(ctx, db.CreateClassScheduleParams{
|
||||
TenantID: tenantID,
|
||||
TemplateID: req.TemplateID,
|
||||
LocationID: req.LocationID,
|
||||
DayOfWeek: req.DayOfWeek,
|
||||
StartsLocal: startsLocal,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ClassSchedule{}, err
|
||||
}
|
||||
if err := s.repo.MaterializeScheduleSessions(ctx, tenantID, time.Now().UTC(), scheduleHorizonDays); err != nil {
|
||||
return domain.ClassSchedule{}, err
|
||||
}
|
||||
|
||||
template, _ := s.repo.GetClassTemplateByID(ctx, req.TemplateID)
|
||||
out := domain.ClassSchedule{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
TemplateID: rec.TemplateID,
|
||||
TemplateTitle: template.Title,
|
||||
LocationID: rec.LocationID,
|
||||
DayOfWeek: rec.DayOfWeek,
|
||||
StartsLocal: rec.StartsLocal,
|
||||
Active: rec.Active,
|
||||
}
|
||||
if rec.LocationID != nil {
|
||||
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
|
||||
out.LocationName = location.Name
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteClassSchedule removes a recurring rule. Sessions already materialized
|
||||
// stay in place; no new ones are written.
|
||||
func (s *Service) DeleteClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
if err := s.repo.DeleteClassSchedule(ctx, membership.Tenant.ID, scheduleID); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return ErrClassSessionNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Principal, sessionID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user