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:
Tomas DvorakandDevin committed 2026-09-18 08:52:25 +02:00
1 parent 7a6223c07b
commit 09f780e7a6
21 files changed
+2422 -15

No files matched your search

+239
View File
@@ -589,6 +589,156 @@ paths:
description: Location deleted
"404":
description: Location not found
/v1/catalog/services:
get:
tags: [Catalog]
operationId: listServices
security:
- bearerAuth: []
responses:
"200":
description: Tenant services
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Service"
"401":
description: Unauthorized
post:
tags: [Catalog]
operationId: createService
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertServiceRequest"
responses:
"201":
description: Service created
content:
application/json:
schema:
$ref: "#/components/schemas/Service"
"400":
description: Invalid request
/v1/catalog/services/{serviceID}:
put:
tags: [Catalog]
operationId: updateService
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/serviceID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertServiceRequest"
responses:
"200":
description: Service updated
content:
application/json:
schema:
$ref: "#/components/schemas/Service"
"400":
description: Invalid request
"404":
description: Service not found
delete:
tags: [Catalog]
operationId: deleteService
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/serviceID"
responses:
"200":
description: Service deleted
"404":
description: Service not found
/v1/catalog/staff:
get:
tags: [Catalog]
operationId: listStaff
security:
- bearerAuth: []
responses:
"200":
description: Tenant staff
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/StaffMember"
"401":
description: Unauthorized
post:
tags: [Catalog]
operationId: createStaff
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertStaffRequest"
responses:
"201":
description: Staff member created
content:
application/json:
schema:
$ref: "#/components/schemas/StaffMember"
"400":
description: Invalid request
"403":
description: Plan limit reached
/v1/catalog/staff/{staffID}:
put:
tags: [Catalog]
operationId: updateStaff
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/staffID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertStaffRequest"
responses:
"200":
description: Staff member updated
content:
application/json:
schema:
$ref: "#/components/schemas/StaffMember"
"400":
description: Invalid request
"404":
description: Staff member not found
delete:
tags: [Catalog]
operationId: deleteStaff
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/staffID"
responses:
"200":
description: Staff member deleted
"404":
description: Staff member not found
/v1/catalog/bookings:
post:
tags: [Catalog]
@@ -1045,6 +1195,20 @@ components:
schema:
type: string
format: uuid
serviceID:
in: path
name: serviceID
required: true
schema:
type: string
format: uuid
staffID:
in: path
name: staffID
required: true
schema:
type: string
format: uuid
schemas:
HealthResponse:
type: object
@@ -1478,6 +1642,81 @@ components:
type: integer
address:
type: string
Service:
type: object
required: [id, tenantId, name, durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, priceCents]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
name:
type: string
durationMinutes:
type: integer
description: Appointment length in minutes (1-1440)
bufferBeforeMinutes:
type: integer
description: Blocked time before the appointment (0-480)
bufferAfterMinutes:
type: integer
description: Blocked time after the appointment (0-480)
priceCents:
type: integer
description: Price in the tenant's currency minor units
createdAt:
type: string
format: date-time
UpsertServiceRequest:
type: object
required: [name, durationMinutes]
properties:
name:
type: string
durationMinutes:
type: integer
bufferBeforeMinutes:
type: integer
bufferAfterMinutes:
type: integer
priceCents:
type: integer
StaffMember:
type: object
required: [id, tenantId, displayName, active]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
locationId:
type: string
format: uuid
nullable: true
displayName:
type: string
active:
type: boolean
createdAt:
type: string
format: date-time
UpsertStaffRequest:
type: object
required: [displayName]
properties:
displayName:
type: string
locationId:
type: string
format: uuid
nullable: true
description: Omit to keep the current assignment; empty string clears it
active:
type: boolean
BlockedDay:
type: object
required: [id, date, type]