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:
Tomas DvorakandDevin committed 2026-09-18 13:13:37 +02:00
1 parent 3892e9ce8c
commit 5884203e9d
11 files changed
+249 -21

No files matched your search

+20
View File
@@ -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 {
+48 -9
View File
@@ -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)
}
}
+18
View File
@@ -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
+13
View File
@@ -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 {
+25
View File
@@ -1099,6 +1099,31 @@ paths:
"400":
description: Invalid request
/v1/catalog/class-templates/{templateID}:
put:
tags: [Catalog]
operationId: updateClassTemplate
security:
- bearerAuth: []
parameters:
- in: path
name: templateID
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertClassTemplateRequest"
responses:
"200":
description: Updated class template
"400":
description: Invalid input
"404":
description: Template not found
delete:
tags: [Catalog]
operationId: deleteClassTemplate