From 5884203e9d30a738d0f3027e65da1cf6ca19c718 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:13:37 +0200 Subject: [PATCH] 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> --- apps/backend/internal/api/server.go | 20 +++++++ apps/backend/internal/catalog/service.go | 57 ++++++++++++++++--- apps/backend/internal/catalog/service_test.go | 40 +++++++++++++ apps/backend/internal/db/classes.go | 18 ++++++ apps/backend/internal/db/repository.go | 13 +++++ apps/backend/openapi/bookra.openapi.yaml | 25 ++++++++ .../src/components/dashboard/icons.tsx | 6 ++ apps/frontend/src/providers/i18n-provider.tsx | 2 + .../src/routes/dashboard/zones-page.tsx | 45 +++++++++++---- packages/api-client/src/generated/types.ts | 40 ++++++++++++- project.md | 4 ++ 11 files changed, 249 insertions(+), 21 deletions(-) diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 3a7fc75..6efc855 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -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 { diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 25684f4..21a7858 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -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 +} diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index 1d5adba..5f5b45a 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -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) + } +} diff --git a/apps/backend/internal/db/classes.go b/apps/backend/internal/db/classes.go index fbedebd..d3e76f0 100644 --- a/apps/backend/internal/db/classes.go +++ b/apps/backend/internal/db/classes.go @@ -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 diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index db62e96..d14e089 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -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 { diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 2866f3c..462f4ba 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -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 diff --git a/apps/frontend/src/components/dashboard/icons.tsx b/apps/frontend/src/components/dashboard/icons.tsx index c55580f..d05fd56 100644 --- a/apps/frontend/src/components/dashboard/icons.tsx +++ b/apps/frontend/src/components/dashboard/icons.tsx @@ -114,6 +114,12 @@ export const PlusIcon = () => ( ); +export const PencilIcon = () => ( + + + +); + export const ScissorsIcon = () => ( diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 61cbb0a..690e29b 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -411,6 +411,7 @@ const dictionaries = { "dashboard.class.templates": "Šablony lekcí", "dashboard.class.sessions": "Naplánované lekce", "dashboard.class.addTemplate": "Nová šablona", + "dashboard.class.saveChanges": "Uložit změny", "dashboard.class.addSession": "Naplánovat lekci", "dashboard.class.title": "Název", "dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky", @@ -986,6 +987,7 @@ const dictionaries = { "dashboard.class.templates": "Class templates", "dashboard.class.sessions": "Scheduled sessions", "dashboard.class.addTemplate": "New template", + "dashboard.class.saveChanges": "Save changes", "dashboard.class.addSession": "Schedule session", "dashboard.class.title": "Title", "dashboard.class.titlePlaceholder": "e.g. Beginner yoga", diff --git a/apps/frontend/src/routes/dashboard/zones-page.tsx b/apps/frontend/src/routes/dashboard/zones-page.tsx index e5c6f41..fe5b3b8 100644 --- a/apps/frontend/src/routes/dashboard/zones-page.tsx +++ b/apps/frontend/src/routes/dashboard/zones-page.tsx @@ -2,7 +2,7 @@ import { For, Show, createResource, createSignal } from "solid-js"; import { apiClient } from "../../lib/api-client"; import { Input } from "../../components/ui/input"; import { Select } from "../../components/ui/select"; -import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon } from "../../components/dashboard/icons"; +import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon } from "../../components/dashboard/icons"; import { DashboardLayout, useDashboardData } from "./layout"; function ZonesPage() { @@ -24,6 +24,7 @@ function ZonesPage() { const [newTemplateDuration, setNewTemplateDuration] = createSignal(60); const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8); const [templateSaving, setTemplateSaving] = createSignal(false); + const [editingTemplateId, setEditingTemplateId] = createSignal(null); const [newSessionTemplateId, setNewSessionTemplateId] = createSignal(""); const [newSessionLocationId, setNewSessionLocationId] = createSignal(""); const [newSessionStartsAt, setNewSessionStartsAt] = createSignal(""); @@ -211,18 +212,25 @@ function ZonesPage() { } }; - const handleAddTemplate = async () => { + const resetTemplateForm = () => { + setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); setEditingTemplateId(null); + }; + + const handleSaveTemplate = async () => { const bearer = data.token(); if (!newTemplateTitle().trim()) return; if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; } setTemplateSaving(true); try { - await (apiClient as any).POST("/v1/catalog/class-templates", { - ...authHeaders(), - body: { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() }, - }); - setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); - void refetchTemplates(); + const body = { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() }; + const editing = editingTemplateId(); + if (editing) { + await (apiClient as any).PUT(`/v1/catalog/class-templates/${editing}`, { ...authHeaders(), body }); + } else { + await (apiClient as any).POST("/v1/catalog/class-templates", { ...authHeaders(), body }); + } + resetTemplateForm(); + void refetchTemplates(); void refetchSessions(); } catch { data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed."); } finally { @@ -230,6 +238,13 @@ function ZonesPage() { } }; + const handleEditTemplate = (tpl: any) => { + setNewTemplateTitle(tpl.title); + setNewTemplateDuration(tpl.durationMinutes); + setNewTemplateCapacity(tpl.capacity); + setEditingTemplateId(tpl.id); + }; + const handleDeleteTemplate = async (id: string) => { const bearer = data.token(); if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; } @@ -487,9 +502,14 @@ function ZonesPage() {
- + + +
@@ -509,7 +529,10 @@ function ZonesPage() {

{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} • {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}

- +
+ + +
)} diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index b74e3ea..0f6b11b 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -359,7 +359,7 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; + put: operations["updateClassTemplate"]; post?: never; delete: operations["deleteClassTemplate"]; options?: never; @@ -2220,6 +2220,44 @@ export interface operations { }; }; }; + updateClassTemplate: { + parameters: { + query?: never; + header?: never; + path: { + templateID: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertClassTemplateRequest"]; + }; + }; + responses: { + /** @description Updated class template */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Template not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; deleteClassTemplate: { parameters: { query?: never; diff --git a/project.md b/project.md index 1cb8cd8..66c2577 100644 --- a/project.md +++ b/project.md @@ -112,6 +112,10 @@ - `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN - fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do) - remaining: live staging validation; manual waitlist promotion controls are still not exposed +- `[done/partial]` Milestone 17: Class template editing + - `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client + - classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance + - remaining: live staging validation ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.