mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(catalog): class template editing
Templates were delete-and-recreate only. Adds PUT
/v1/catalog/class-templates/{id} across repository, service (sharing
the create-time validation), route, spec, and client; the classes tab
gets a pencil action that loads the row into the form with a
save/cancel affordance.
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
3892e9ce8c
commit
5884203e9d
11 files changed
+249
-21
No files matched your search
@@ -998,6 +998,26 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.PUT("/catalog/class-templates/:templateID", func(c *gin.Context) {
|
||||
var request domain.UpsertClassTemplateRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.UpdateClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, catalog.ErrInvalidInput):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/class-templates/:templateID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"))
|
||||
if err != nil {
|
||||
|
||||
@@ -1050,15 +1050,9 @@ func (s *Service) CreateClassTemplate(ctx context.Context, principal domain.Prin
|
||||
return domain.ClassTemplate{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if len(title) < 1 || len(title) > 120 {
|
||||
return domain.ClassTemplate{}, fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
|
||||
}
|
||||
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
|
||||
return domain.ClassTemplate{}, fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
|
||||
}
|
||||
if req.Capacity < 1 || req.Capacity > 500 {
|
||||
return domain.ClassTemplate{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
|
||||
title, err := validateClassTemplateInput(req)
|
||||
if err != nil {
|
||||
return domain.ClassTemplate{}, err
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{
|
||||
@@ -1372,3 +1366,48 @@ func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
|
||||
s.notificationService.SendBookingCancellation(ctx, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) UpdateClassTemplate(ctx context.Context, principal domain.Principal, templateID string, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.ClassTemplate{}, ErrTenantMembership
|
||||
}
|
||||
if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil {
|
||||
return domain.ClassTemplate{}, err
|
||||
}
|
||||
title, err := validateClassTemplateInput(req)
|
||||
if err != nil {
|
||||
return domain.ClassTemplate{}, err
|
||||
}
|
||||
rec, err := s.repo.UpdateClassTemplate(ctx, templateID, db.CreateClassTemplateParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
Title: title,
|
||||
DurationMinutes: req.DurationMinutes,
|
||||
Capacity: req.Capacity,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ClassTemplate{}, err
|
||||
}
|
||||
return domain.ClassTemplate{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Title: rec.Title,
|
||||
DurationMinutes: rec.DurationMinutes,
|
||||
Capacity: rec.Capacity,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateClassTemplateInput(req domain.UpsertClassTemplateRequest) (string, error) {
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if len(title) < 1 || len(title) > 120 {
|
||||
return "", fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
|
||||
}
|
||||
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
|
||||
return "", fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
|
||||
}
|
||||
if req.Capacity < 1 || req.Capacity > 500 {
|
||||
return "", fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
@@ -640,3 +640,43 @@ func TestCancelBookingPromotesWaitlist(t *testing.T) {
|
||||
t.Fatalf("expected second waiter promoted, got %q", booking.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClassTemplate(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{
|
||||
Title: "Yin Yoga", DurationMinutes: 75, Capacity: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create template: %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
|
||||
Title: "Yin Yoga (večer)", DurationMinutes: 90, Capacity: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update template: %v", err)
|
||||
}
|
||||
if updated.Title != "Yin Yoga (večer)" || updated.DurationMinutes != 90 || updated.Capacity != 12 {
|
||||
t.Fatalf("unexpected updated template: %+v", updated)
|
||||
}
|
||||
|
||||
if _, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
|
||||
Title: "", DurationMinutes: 90, Capacity: 12,
|
||||
}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
|
||||
if _, err := svc.UpdateClassTemplate(ctx, stranger, created.ID, domain.UpsertClassTemplateRequest{
|
||||
Title: "X", DurationMinutes: 60, Capacity: 5,
|
||||
}); !errors.Is(err, ErrTenantMembership) {
|
||||
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||
}
|
||||
if _, err := svc.UpdateClassTemplate(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.UpsertClassTemplateRequest{
|
||||
Title: "X", DurationMinutes: 60, Capacity: 5,
|
||||
}); !errors.Is(err, ErrClassTemplateNotFound) {
|
||||
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,24 @@ func (r *PGRepository) CreateClassTemplate(ctx context.Context, params CreateCla
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
|
||||
var record ClassTemplateRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
UPDATE class_templates
|
||||
SET title = $2, duration_minutes = $3, capacity = $4
|
||||
WHERE id = $1
|
||||
RETURNING `+classTemplateColumns+`
|
||||
`, templateID, params.Title, params.DurationMinutes, params.Capacity).Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.Title,
|
||||
&record.DurationMinutes,
|
||||
&record.Capacity,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) DeleteClassTemplate(ctx context.Context, templateID string) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM class_templates WHERE id = $1`, templateID)
|
||||
return err
|
||||
|
||||
@@ -32,6 +32,7 @@ type Repository interface {
|
||||
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
|
||||
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
|
||||
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
||||
UpdateClassTemplate(ctx context.Context, templateID string, 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)
|
||||
@@ -1311,6 +1312,18 @@ func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateC
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateClassTemplate(_ context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
|
||||
for index := range r.classTemplates {
|
||||
if r.classTemplates[index].ID == templateID {
|
||||
r.classTemplates[index].Title = params.Title
|
||||
r.classTemplates[index].DurationMinutes = params.DurationMinutes
|
||||
r.classTemplates[index].Capacity = params.Capacity
|
||||
return r.classTemplates[index], nil
|
||||
}
|
||||
}
|
||||
return ClassTemplateRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error {
|
||||
for i, template := range r.classTemplates {
|
||||
if template.ID == templateID {
|
||||
|
||||
Reference in new issue
Block a user