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
@@ -18,6 +18,9 @@ var (
|
||||
ErrBlockedDayNotFound = errors.New("blocked day not found")
|
||||
ErrCustomerNotFound = errors.New("customer not found")
|
||||
ErrBookingNotFound = errors.New("booking not found")
|
||||
ErrServiceNotFound = errors.New("service not found")
|
||||
ErrStaffNotFound = errors.New("staff member not found")
|
||||
ErrInvalidInput = errors.New("invalid request")
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
|
||||
ErrTenantNotFound = errors.New("tenant not found")
|
||||
@@ -183,6 +186,310 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
|
||||
return s.repo.DeleteLocation(ctx, locationID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SERVICE CATALOG MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func serviceToDomain(rec db.ServiceRecord) domain.Service {
|
||||
return domain.Service{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
DurationMinutes: rec.DurationMinutes,
|
||||
BufferBeforeMinutes: rec.BufferBeforeMinutes,
|
||||
BufferAfterMinutes: rec.BufferAfterMinutes,
|
||||
PriceCents: rec.PriceCents,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func validateServiceValues(name string, duration, bufferBefore, bufferAfter, priceCents int) error {
|
||||
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
|
||||
return fmt.Errorf("%w: name must be between 1 and 120 characters", ErrInvalidInput)
|
||||
}
|
||||
if duration <= 0 || duration > 24*60 {
|
||||
return fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
|
||||
}
|
||||
if bufferBefore < 0 || bufferBefore > 480 {
|
||||
return fmt.Errorf("%w: bufferBeforeMinutes must be between 0 and 480", ErrInvalidInput)
|
||||
}
|
||||
if bufferAfter < 0 || bufferAfter > 480 {
|
||||
return fmt.Errorf("%w: bufferAfterMinutes must be between 0 and 480", ErrInvalidInput)
|
||||
}
|
||||
if priceCents < 0 {
|
||||
return fmt.Errorf("%w: priceCents cannot be negative", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListServices(ctx context.Context, principal domain.Principal) ([]domain.Service, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
services := make([]domain.Service, len(records))
|
||||
for i, rec := range records {
|
||||
services[i] = serviceToDomain(rec)
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateService(ctx context.Context, principal domain.Principal, req domain.CreateServiceRequest) (domain.Service, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Service{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil {
|
||||
return domain.Service{}, err
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
Name: name,
|
||||
DurationMinutes: req.DurationMinutes,
|
||||
BufferBeforeMinutes: req.BufferBeforeMinutes,
|
||||
BufferAfterMinutes: req.BufferAfterMinutes,
|
||||
PriceCents: req.PriceCents,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Service{}, err
|
||||
}
|
||||
return serviceToDomain(rec), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateService(ctx context.Context, principal domain.Principal, serviceID string, req domain.UpdateServiceRequest) (domain.Service, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Service{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetServiceByID(ctx, serviceID)
|
||||
if err != nil || existing.TenantID != membership.Tenant.ID {
|
||||
return domain.Service{}, ErrServiceNotFound
|
||||
}
|
||||
|
||||
params := db.UpdateServiceParams{}
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
params.Name = &name
|
||||
}
|
||||
if req.DurationMinutes != nil {
|
||||
params.DurationMinutes = req.DurationMinutes
|
||||
}
|
||||
if req.BufferBeforeMinutes != nil {
|
||||
params.BufferBeforeMinutes = req.BufferBeforeMinutes
|
||||
}
|
||||
if req.BufferAfterMinutes != nil {
|
||||
params.BufferAfterMinutes = req.BufferAfterMinutes
|
||||
}
|
||||
if req.PriceCents != nil {
|
||||
params.PriceCents = req.PriceCents
|
||||
}
|
||||
|
||||
// Validate against the merged result so partial updates cannot produce an
|
||||
// invalid record (e.g. setting duration to 0 while keeping the name).
|
||||
merged := existing
|
||||
if params.Name != nil {
|
||||
merged.Name = *params.Name
|
||||
}
|
||||
if params.DurationMinutes != nil {
|
||||
merged.DurationMinutes = *params.DurationMinutes
|
||||
}
|
||||
if params.BufferBeforeMinutes != nil {
|
||||
merged.BufferBeforeMinutes = *params.BufferBeforeMinutes
|
||||
}
|
||||
if params.BufferAfterMinutes != nil {
|
||||
merged.BufferAfterMinutes = *params.BufferAfterMinutes
|
||||
}
|
||||
if params.PriceCents != nil {
|
||||
merged.PriceCents = *params.PriceCents
|
||||
}
|
||||
if err := validateServiceValues(merged.Name, merged.DurationMinutes, merged.BufferBeforeMinutes, merged.BufferAfterMinutes, merged.PriceCents); err != nil {
|
||||
return domain.Service{}, err
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateService(ctx, serviceID, params)
|
||||
if err != nil {
|
||||
return domain.Service{}, err
|
||||
}
|
||||
return serviceToDomain(rec), nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteService(ctx context.Context, principal domain.Principal, serviceID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetServiceByID(ctx, serviceID)
|
||||
if err != nil || existing.TenantID != membership.Tenant.ID {
|
||||
return ErrServiceNotFound
|
||||
}
|
||||
|
||||
return s.repo.DeleteService(ctx, serviceID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// STAFF MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func staffToDomain(rec db.StaffRecord) domain.StaffMember {
|
||||
return domain.StaffMember{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
LocationID: rec.LocationID,
|
||||
DisplayName: rec.DisplayName,
|
||||
Active: rec.Active,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func validateStaffName(name string) error {
|
||||
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
|
||||
return fmt.Errorf("%w: displayName must be between 1 and 120 characters", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStaffLocation returns the normalized location reference: nil when the
|
||||
// input is empty, the id when it belongs to the tenant, or an error otherwise.
|
||||
func (s *Service) validateStaffLocation(ctx context.Context, tenantID string, locationID *string) (*string, error) {
|
||||
if locationID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*locationID)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
loc, err := s.repo.GetLocationByID(ctx, trimmed)
|
||||
if err != nil || loc.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("%w: locationId does not belong to this tenant", ErrInvalidInput)
|
||||
}
|
||||
return &trimmed, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListStaff(ctx context.Context, principal domain.Principal) ([]domain.StaffMember, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
staff := make([]domain.StaffMember, len(records))
|
||||
for i, rec := range records {
|
||||
staff[i] = staffToDomain(rec)
|
||||
}
|
||||
return staff, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateStaff(ctx context.Context, principal domain.Principal, req domain.CreateStaffRequest) (domain.StaffMember, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.StaffMember{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
if s.billingService != nil {
|
||||
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
|
||||
if err == nil && entitlements.MaxStaff >= 0 {
|
||||
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
|
||||
if err == nil && len(staff) >= entitlements.MaxStaff {
|
||||
return domain.StaffMember{}, fmt.Errorf("%w: staff limit reached (%d/%d). Upgrade your plan to add more staff.", ErrPlanLimitReached, len(staff), entitlements.MaxStaff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.DisplayName)
|
||||
if err := validateStaffName(name); err != nil {
|
||||
return domain.StaffMember{}, err
|
||||
}
|
||||
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
|
||||
if err != nil {
|
||||
return domain.StaffMember{}, err
|
||||
}
|
||||
|
||||
active := true
|
||||
if req.Active != nil {
|
||||
active = *req.Active
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateStaff(ctx, db.CreateStaffParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
LocationID: locationID,
|
||||
DisplayName: name,
|
||||
Active: active,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.StaffMember{}, err
|
||||
}
|
||||
return staffToDomain(rec), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStaff(ctx context.Context, principal domain.Principal, staffID string, req domain.UpdateStaffRequest) (domain.StaffMember, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.StaffMember{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetStaffByID(ctx, staffID)
|
||||
if err != nil || existing.TenantID != membership.Tenant.ID {
|
||||
return domain.StaffMember{}, ErrStaffNotFound
|
||||
}
|
||||
|
||||
params := db.UpdateStaffParams{Active: req.Active}
|
||||
if req.DisplayName != nil {
|
||||
name := strings.TrimSpace(*req.DisplayName)
|
||||
if err := validateStaffName(name); err != nil {
|
||||
return domain.StaffMember{}, err
|
||||
}
|
||||
params.DisplayName = &name
|
||||
}
|
||||
if req.LocationID != nil {
|
||||
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
|
||||
if err != nil {
|
||||
return domain.StaffMember{}, err
|
||||
}
|
||||
// Keep the tri-state: an explicit empty string must clear the value.
|
||||
if locationID == nil {
|
||||
empty := ""
|
||||
params.LocationID = &empty
|
||||
} else {
|
||||
params.LocationID = locationID
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateStaff(ctx, staffID, params)
|
||||
if err != nil {
|
||||
return domain.StaffMember{}, err
|
||||
}
|
||||
return staffToDomain(rec), nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteStaff(ctx context.Context, principal domain.Principal, staffID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetStaffByID(ctx, staffID)
|
||||
if err != nil || existing.TenantID != membership.Tenant.ID {
|
||||
return ErrStaffNotFound
|
||||
}
|
||||
|
||||
return s.repo.DeleteStaff(ctx, staffID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BLOCKED DAYS MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
Reference in new issue
Block a user