mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(catalog): add service and staff management end-to-end
Services and staff existed as schema + booking FK targets but had no management surface. Adds full CRUD: repository (pgx + in-memory with matching FK semantics), catalog service validation with tenant isolation, MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas, and dashboard screens for both entities. Dashboard summary now resolves staff names on bookings and computes setup completion from real configured state. Booking modal selects from the tenant's actual services; blocked days can be scoped per staff member. 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
7a6223c07b
commit
09f780e7a6
21 files changed
+2422
-15
No files matched your search
@@ -65,6 +65,19 @@ type Repository interface {
|
||||
UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error)
|
||||
DeleteLocation(ctx context.Context, locationID string) error
|
||||
|
||||
// Service Management
|
||||
GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error)
|
||||
CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error)
|
||||
UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error)
|
||||
DeleteService(ctx context.Context, serviceID string) error
|
||||
|
||||
// Staff Management
|
||||
ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error)
|
||||
GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error)
|
||||
CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error)
|
||||
UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error)
|
||||
DeleteStaff(ctx context.Context, staffID string) error
|
||||
|
||||
// Blocked Days / Availability Exceptions
|
||||
ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error)
|
||||
CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error)
|
||||
@@ -225,6 +238,50 @@ type ServiceRecord struct {
|
||||
BufferBeforeMinutes int
|
||||
BufferAfterMinutes int
|
||||
PriceCents int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateServiceParams struct {
|
||||
TenantID string
|
||||
Name string
|
||||
DurationMinutes int
|
||||
BufferBeforeMinutes int
|
||||
BufferAfterMinutes int
|
||||
PriceCents int
|
||||
}
|
||||
|
||||
// Pointer fields distinguish "not sent" (keep current value) from an explicit
|
||||
// zero value, which is meaningful for buffers and price.
|
||||
type UpdateServiceParams struct {
|
||||
Name *string
|
||||
DurationMinutes *int
|
||||
BufferBeforeMinutes *int
|
||||
BufferAfterMinutes *int
|
||||
PriceCents *int
|
||||
}
|
||||
|
||||
type StaffRecord struct {
|
||||
ID string
|
||||
TenantID string
|
||||
LocationID *string
|
||||
DisplayName string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateStaffParams struct {
|
||||
TenantID string
|
||||
LocationID *string
|
||||
DisplayName string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// LocationID tri-state: nil keeps the current assignment, a pointer to an
|
||||
// empty string clears it, a pointer to an id reassigns it.
|
||||
type UpdateStaffParams struct {
|
||||
DisplayName *string
|
||||
LocationID *string
|
||||
Active *bool
|
||||
}
|
||||
|
||||
type AvailabilityRuleRecord struct {
|
||||
@@ -670,6 +727,7 @@ type MemoryRepository struct {
|
||||
billingSnapshot BillingSnapshotRecord
|
||||
recordedEvents map[string]struct{}
|
||||
locations []LocationRecord
|
||||
staff []StaffRecord
|
||||
blockedDays []BlockedDayRecord
|
||||
customers []CustomerRecord
|
||||
workingHours []WorkingHoursRecord
|
||||
@@ -732,6 +790,17 @@ func NewMemoryRepository() *MemoryRepository {
|
||||
BufferBeforeMinutes: 0,
|
||||
BufferAfterMinutes: 15,
|
||||
PriceCents: 120000,
|
||||
CreatedAt: now,
|
||||
},
|
||||
},
|
||||
staff: []StaffRecord{
|
||||
{
|
||||
ID: staffID,
|
||||
TenantID: tenantID,
|
||||
LocationID: &locationID,
|
||||
DisplayName: "Demo Stylist",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
},
|
||||
},
|
||||
rules: []AvailabilityRuleRecord{
|
||||
@@ -857,6 +926,7 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT
|
||||
BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0),
|
||||
PriceCents: 0,
|
||||
}}
|
||||
r.staff = nil
|
||||
r.rules = nil
|
||||
blocks := params.AvailabilityBlocks
|
||||
if len(blocks) == 0 {
|
||||
@@ -937,6 +1007,153 @@ func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID stri
|
||||
return append([]ServiceRecord(nil), r.services...), nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetServiceByID(_ context.Context, serviceID string) (ServiceRecord, error) {
|
||||
for _, service := range r.services {
|
||||
if service.ID == serviceID {
|
||||
return service, nil
|
||||
}
|
||||
}
|
||||
return ServiceRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateService(_ context.Context, params CreateServiceParams) (ServiceRecord, error) {
|
||||
service := ServiceRecord{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: params.TenantID,
|
||||
Name: params.Name,
|
||||
DurationMinutes: params.DurationMinutes,
|
||||
BufferBeforeMinutes: params.BufferBeforeMinutes,
|
||||
BufferAfterMinutes: params.BufferAfterMinutes,
|
||||
PriceCents: params.PriceCents,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
r.services = append(r.services, service)
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
|
||||
for i, service := range r.services {
|
||||
if service.ID != serviceID {
|
||||
continue
|
||||
}
|
||||
if params.Name != nil {
|
||||
service.Name = *params.Name
|
||||
}
|
||||
if params.DurationMinutes != nil {
|
||||
service.DurationMinutes = *params.DurationMinutes
|
||||
}
|
||||
if params.BufferBeforeMinutes != nil {
|
||||
service.BufferBeforeMinutes = *params.BufferBeforeMinutes
|
||||
}
|
||||
if params.BufferAfterMinutes != nil {
|
||||
service.BufferAfterMinutes = *params.BufferAfterMinutes
|
||||
}
|
||||
if params.PriceCents != nil {
|
||||
service.PriceCents = *params.PriceCents
|
||||
}
|
||||
r.services[i] = service
|
||||
return service, nil
|
||||
}
|
||||
return ServiceRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteService(_ context.Context, serviceID string) error {
|
||||
for i, service := range r.services {
|
||||
if service.ID != serviceID {
|
||||
continue
|
||||
}
|
||||
r.services = append(r.services[:i], r.services[i+1:]...)
|
||||
// Mirror ON DELETE SET NULL for bookings referencing the service.
|
||||
for j, booking := range r.bookings {
|
||||
if booking.ServiceID != nil && *booking.ServiceID == serviceID {
|
||||
booking.ServiceID = nil
|
||||
r.bookings[j] = booking
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListStaffByTenant(_ context.Context, tenantID string) ([]StaffRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil, nil
|
||||
}
|
||||
return append([]StaffRecord(nil), r.staff...), nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetStaffByID(_ context.Context, staffID string) (StaffRecord, error) {
|
||||
for _, member := range r.staff {
|
||||
if member.ID == staffID {
|
||||
return member, nil
|
||||
}
|
||||
}
|
||||
return StaffRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateStaff(_ context.Context, params CreateStaffParams) (StaffRecord, error) {
|
||||
member := StaffRecord{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: params.TenantID,
|
||||
LocationID: params.LocationID,
|
||||
DisplayName: params.DisplayName,
|
||||
Active: params.Active,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
r.staff = append(r.staff, member)
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateStaff(_ context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
|
||||
for i, member := range r.staff {
|
||||
if member.ID != staffID {
|
||||
continue
|
||||
}
|
||||
if params.DisplayName != nil {
|
||||
member.DisplayName = *params.DisplayName
|
||||
}
|
||||
if params.LocationID != nil {
|
||||
locationID := strings.TrimSpace(*params.LocationID)
|
||||
if locationID == "" {
|
||||
member.LocationID = nil
|
||||
} else {
|
||||
member.LocationID = &locationID
|
||||
}
|
||||
}
|
||||
if params.Active != nil {
|
||||
member.Active = *params.Active
|
||||
}
|
||||
r.staff[i] = member
|
||||
return member, nil
|
||||
}
|
||||
return StaffRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteStaff(_ context.Context, staffID string) error {
|
||||
for i, member := range r.staff {
|
||||
if member.ID != staffID {
|
||||
continue
|
||||
}
|
||||
r.staff = append(r.staff[:i], r.staff[i+1:]...)
|
||||
// Mirror the FK behaviour: bookings SET NULL, availability rules CASCADE.
|
||||
for j, booking := range r.bookings {
|
||||
if booking.StaffID != nil && *booking.StaffID == staffID {
|
||||
booking.StaffID = nil
|
||||
r.bookings[j] = booking
|
||||
}
|
||||
}
|
||||
var rules []AvailabilityRuleRecord
|
||||
for _, rule := range r.rules {
|
||||
if rule.StaffID == nil || *rule.StaffID != staffID {
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
}
|
||||
r.rules = rules
|
||||
return nil
|
||||
}
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil, nil
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, created_at"
|
||||
|
||||
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents
|
||||
SELECT `+serviceColumns+`
|
||||
FROM services
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at ASC
|
||||
@@ -28,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
&record.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -36,6 +39,73 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error) {
|
||||
var record ServiceRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT `+serviceColumns+`
|
||||
FROM services
|
||||
WHERE id = $1
|
||||
`, serviceID).Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.Name,
|
||||
&record.DurationMinutes,
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) {
|
||||
var record ServiceRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING `+serviceColumns+`
|
||||
`, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.Name,
|
||||
&record.DurationMinutes,
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
|
||||
var record ServiceRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
UPDATE services
|
||||
SET name = COALESCE($2, name),
|
||||
duration_minutes = COALESCE($3, duration_minutes),
|
||||
buffer_before_minutes = COALESCE($4, buffer_before_minutes),
|
||||
buffer_after_minutes = COALESCE($5, buffer_after_minutes),
|
||||
price_cents = COALESCE($6, price_cents)
|
||||
WHERE id = $1
|
||||
RETURNING `+serviceColumns+`
|
||||
`, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.Name,
|
||||
&record.DurationMinutes,
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) DeleteService(ctx context.Context, serviceID string) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const staffColumns = "id, tenant_id, location_id, display_name, active, created_at"
|
||||
|
||||
func (r *PGRepository) ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+staffColumns+`
|
||||
FROM staff
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []StaffRecord
|
||||
for rows.Next() {
|
||||
var rec StaffRecord
|
||||
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error) {
|
||||
var rec StaffRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT `+staffColumns+`
|
||||
FROM staff
|
||||
WHERE id = $1
|
||||
`, staffID).Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error) {
|
||||
var rec StaffRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO staff (tenant_id, location_id, display_name, active)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+staffColumns+`
|
||||
`, params.TenantID, params.LocationID, params.DisplayName, params.Active).Scan(
|
||||
&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
|
||||
// Tri-state location update: untouched when the request omits the field,
|
||||
// NULL when it is explicitly empty, otherwise reassigned.
|
||||
locationSet := params.LocationID != nil
|
||||
var locationValue any
|
||||
if locationSet && strings.TrimSpace(*params.LocationID) != "" {
|
||||
locationValue = strings.TrimSpace(*params.LocationID)
|
||||
}
|
||||
|
||||
var rec StaffRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
UPDATE staff
|
||||
SET display_name = COALESCE($2, display_name),
|
||||
location_id = CASE WHEN $3 THEN $4::uuid ELSE location_id END,
|
||||
active = COALESCE($5, active)
|
||||
WHERE id = $1
|
||||
RETURNING `+staffColumns+`
|
||||
`, staffID, params.DisplayName, locationSet, locationValue, params.Active).Scan(
|
||||
&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) DeleteStaff(ctx context.Context, staffID string) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM staff WHERE id = $1`, staffID)
|
||||
return err
|
||||
}
|
||||
Reference in new issue
Block a user