mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat: scheduled report digests + per-session capacity (M33)
POST /v1/internal/jobs/reports/dispatch emails each tenant owner a trailing-window digest (bookings, revenue, per-service and per-staff breakdown) localized to the tenant locale; zero-activity tenants are skipped. Metrics cores extracted to tenant-scoped variants so the job needs no principal. PATCH /v1/catalog/class-sessions/:id sets a session's seat count with a confirmed-bookings floor — shrinking below it needs allowOverCapacity, matching the waitlist-promotion convention. Session rows gain an inline capacity editor with an overbook confirm state. 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
e6f1ec38a4
commit
e680e0c2b8
14 files changed
+808
-10
No files matched your search
@@ -1595,6 +1595,76 @@ func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Princ
|
||||
return s.repo.DeleteClassSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
// ErrCapacityBelowBooked rejects shrinking a session's capacity under the
|
||||
// number of confirmed seats unless the caller passes allowOverCapacity.
|
||||
var ErrCapacityBelowBooked = errors.New("capacity below confirmed bookings")
|
||||
|
||||
// UpdateClassSession patches a single session — currently only the seat
|
||||
// count. Shrinking below confirmed bookings needs an explicit override so a
|
||||
// typo cannot silently overbook a session.
|
||||
func (s *Service) UpdateClassSession(ctx context.Context, principal domain.Principal, sessionID string, req domain.UpdateClassSessionRequest) (domain.ClassSession, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.ClassSession{}, ErrTenantMembership
|
||||
}
|
||||
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ClassSession{}, ErrClassSessionNotFound
|
||||
}
|
||||
return domain.ClassSession{}, err
|
||||
}
|
||||
if session.TenantID != membership.Tenant.ID {
|
||||
return domain.ClassSession{}, ErrClassSessionNotFound
|
||||
}
|
||||
if req.Capacity == nil {
|
||||
return domain.ClassSession{}, fmt.Errorf("%w: capacity is required", ErrInvalidInput)
|
||||
}
|
||||
if *req.Capacity < 1 || *req.Capacity > 500 {
|
||||
return domain.ClassSession{}, fmt.Errorf("%w: capacity must be 1-500", ErrInvalidInput)
|
||||
}
|
||||
|
||||
sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return domain.ClassSession{}, err
|
||||
}
|
||||
confirmed := 0
|
||||
for _, booking := range sessionBookings {
|
||||
if booking.Status == "confirmed" {
|
||||
confirmed++
|
||||
}
|
||||
}
|
||||
if *req.Capacity < confirmed && !req.AllowOverCapacity {
|
||||
return domain.ClassSession{}, fmt.Errorf("%w: %d confirmed seats", ErrCapacityBelowBooked, confirmed)
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateClassSessionCapacity(ctx, sessionID, *req.Capacity)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ClassSession{}, ErrClassSessionNotFound
|
||||
}
|
||||
return domain.ClassSession{}, err
|
||||
}
|
||||
|
||||
out := domain.ClassSession{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
TemplateID: rec.TemplateID,
|
||||
TemplateTitle: rec.Title,
|
||||
LocationID: rec.LocationID,
|
||||
StartsAt: rec.StartsAt,
|
||||
EndsAt: rec.EndsAt,
|
||||
Capacity: int(rec.Capacity),
|
||||
BookedCount: confirmed,
|
||||
}
|
||||
if rec.LocationID != nil {
|
||||
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
|
||||
out.LocationName = location.Name
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) requireTenantClassTemplate(ctx context.Context, tenantID, templateID string) error {
|
||||
template, err := s.repo.GetClassTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
|
||||
@@ -993,3 +993,61 @@ func TestUpdateClassScheduleWithoutCascade(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClassSessionCapacityFloor(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||
|
||||
session, err := repo.CreateClassSession(ctx, db.CreateClassSessionParams{
|
||||
TenantID: tenantID,
|
||||
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
|
||||
StartsAt: time.Now().UTC().Add(48 * time.Hour),
|
||||
EndsAt: time.Now().UTC().Add(49 * time.Hour),
|
||||
Capacity: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
for _, name := range []string{"A", "B", "C"} {
|
||||
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
TenantID: tenantID,
|
||||
ClassSessionID: &session.ID,
|
||||
BookingMode: "class",
|
||||
CustomerName: name,
|
||||
CustomerEmail: name + "@example.com",
|
||||
StartsAt: session.StartsAt,
|
||||
EndsAt: session.EndsAt,
|
||||
Status: "confirmed",
|
||||
Reference: "BK-CAP-" + name,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed booking %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shrinking to 2 with 3 confirmed seats → conflict without override.
|
||||
cap2 := 2
|
||||
if _, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap2}); !errors.Is(err, ErrCapacityBelowBooked) {
|
||||
t.Fatalf("expected ErrCapacityBelowBooked, got %v", err)
|
||||
}
|
||||
// With override it applies.
|
||||
updated, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap2, AllowOverCapacity: true})
|
||||
if err != nil {
|
||||
t.Fatalf("override update: %v", err)
|
||||
}
|
||||
if updated.Capacity != 2 || updated.BookedCount != 3 {
|
||||
t.Fatalf("expected capacity=2 booked=3, got %d/%d", updated.Capacity, updated.BookedCount)
|
||||
}
|
||||
// Growing always works.
|
||||
cap8 := 8
|
||||
updated, err = svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap8})
|
||||
if err != nil || updated.Capacity != 8 {
|
||||
t.Fatalf("grow update: %v cap=%d", err, updated.Capacity)
|
||||
}
|
||||
// Validation floor.
|
||||
cap0 := 0
|
||||
if _, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap0}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("expected ErrInvalidInput for capacity 0, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user