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
+248
-20
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -114,6 +114,12 @@ export const PlusIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PencilIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ScissorsIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<Input type="number" label={data.i18n.t("dashboard.class.durationMin")} value={newTemplateDuration()} onInput={(e) => setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} />
|
||||
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newTemplateCapacity()} onInput={(e) => setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} />
|
||||
<div class="flex items-end">
|
||||
<button onClick={() => void handleAddTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||
{templateSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addTemplate")}
|
||||
<button onClick={() => void handleSaveTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||
{templateSaving() ? data.i18n.t("dashboard.saving") : editingTemplateId() ? data.i18n.t("dashboard.class.saveChanges") : data.i18n.t("dashboard.class.addTemplate")}
|
||||
</button>
|
||||
<Show when={editingTemplateId()}>
|
||||
<button onClick={resetTemplateForm} class="text-sm text-ink-muted hover:text-ink px-3 py-2">
|
||||
{data.i18n.t("dashboard.cancel")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -509,8 +529,11 @@ function ZonesPage() {
|
||||
<p class="text-sm text-ink-muted">{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} • {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button onClick={() => handleEditTemplate(tpl)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors"><PencilIcon /></button>
|
||||
<button onClick={() => void handleDeleteTemplate(tpl.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user