Each SERVICE_DOCS.md documents: Overview, API Endpoints, Commands, Queries, Domain Model, Database Schema, Integration Events, Dependencies, Configuration. Generated by 23 parallel audit agents reading actual source code. Key corrections from audit: - inventory-service: 12 commands/6 queries (was listed as scaffold) - promotion-service: 12 commands/10 queries (was listed as 0) - mission-service: 4 commands/7 queries (was listed as 0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
535 lines
23 KiB
Markdown
535 lines
23 KiB
Markdown
# BookingService - Service Documentation
|
|
|
|
> Auto-generated from source code audit on 2026-03-13
|
|
|
|
## Overview
|
|
|
|
BookingService is a microservice for managing appointment bookings, resource allocation, staff schedules, and spa/beauty therapists. It supports multi-vertical booking workflows (Spa, Karaoke, Restaurant, etc.) with slot-finding algorithms based on staff availability and resource capacity.
|
|
|
|
- **Framework**: .NET 10.0 (C# 14), ASP.NET Core Web API
|
|
- **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1)
|
|
- **Database**: PostgreSQL (Neon cloud) via EF Core 10 + Npgsql 10
|
|
- **Port**: 5020 (Development)
|
|
- **Database Name**: `booking_service`
|
|
- **Health Checks**: `/health`, `/health/live`, `/health/ready`
|
|
- **Auth**: JWT Bearer via IAM IdentityServer OIDC discovery (default authority: `http://localhost:5001`)
|
|
- **API Response Format**: `{ success: bool, data: T, message?: string, errors?: string[] }`
|
|
|
|
## API Endpoints
|
|
|
|
### Appointments (`/api/v1/appointments`)
|
|
|
|
| Method | Path | Auth | Description | Request | Response |
|
|
|--------|------|------|-------------|---------|----------|
|
|
| GET | `/api/v1/appointments?shopId=&customerId=&startDate=&endDate=&status=&page=&pageSize=` | No | Get appointments by shop or customer with filtering/pagination | Query params | `ApiResponse<PaginatedList<AppointmentDto>>` |
|
|
| GET | `/api/v1/appointments/{id}` | No | Get appointment by ID | - | `ApiResponse<AppointmentDto>` |
|
|
| POST | `/api/v1/appointments` | No | Create a new appointment | `CreateAppointmentRequest` | `ApiResponse<AppointmentDto>` (201) |
|
|
| PATCH | `/api/v1/appointments/{id}/status` | No | Update appointment status (confirm/start/complete/noshow) | `UpdateStatusRequest` | `ApiResponse<AppointmentDto>` |
|
|
| PATCH | `/api/v1/appointments/{id}/noshow` | No | Mark appointment as no-show | - | `ApiResponse<AppointmentDto>` |
|
|
| DELETE | `/api/v1/appointments/{id}` | No | Cancel appointment | `CancelRequest` | `ApiResponse<bool>` |
|
|
|
|
### Resources (`/api/v1/resources`)
|
|
|
|
| Method | Path | Auth | Description | Request | Response |
|
|
|--------|------|------|-------------|---------|----------|
|
|
| GET | `/api/v1/resources?shopId=&isActive=` | No | Get resources by shop | Query params | `ApiResponse<List<ResourceDto>>` |
|
|
| POST | `/api/v1/resources` | No | Create a new resource | `CreateResourceRequest` | `ApiResponse<ResourceDto>` (201) |
|
|
| PUT | `/api/v1/resources/{id}` | No | Update a resource | `UpdateResourceRequest` | `ApiResponse<ResourceDto>` |
|
|
|
|
### Slots (`/api/v1/slots`)
|
|
|
|
| Method | Path | Auth | Description | Request | Response |
|
|
|--------|------|------|-------------|---------|----------|
|
|
| POST | `/api/v1/slots/find` | No | Find available time slots | `FindSlotsRequest` | `ApiResponse<List<TimeSlotDto>>` |
|
|
|
|
### Staff Schedules (`/api/v1/staff/{staffId}/schedule`)
|
|
|
|
| Method | Path | Auth | Description | Request | Response |
|
|
|--------|------|------|-------------|---------|----------|
|
|
| GET | `/api/v1/staff/{staffId}/schedule?shopId=` | No | Get staff schedule | Query params | `ApiResponse<List<StaffScheduleDto>>` |
|
|
| PUT | `/api/v1/staff/{staffId}/schedule` | No | Update staff schedule (replace all) | `UpdateScheduleRequest` | `ApiResponse<List<StaffScheduleDto>>` |
|
|
|
|
### Schedules (`/api/v1/schedules`)
|
|
|
|
| Method | Path | Auth | Description | Request | Response |
|
|
|--------|------|------|-------------|---------|----------|
|
|
| GET | `/api/v1/schedules?shopId=` | No | Get all staff schedules for a shop | Query params | `ApiResponse<List<StaffScheduleDto>>` |
|
|
| POST | `/api/v1/schedules` | No | Create a single schedule entry | `CreateScheduleRequest` | `ApiResponse<StaffScheduleDto>` (201) |
|
|
| DELETE | `/api/v1/schedules/{id}` | No | Delete a schedule entry | - | `{ success, message }` |
|
|
|
|
### Therapists (`/api/v1/therapists`)
|
|
|
|
| Method | Path | Auth | Description | Request | Response |
|
|
|--------|------|------|-------------|---------|----------|
|
|
| GET | `/api/v1/therapists?shopId=&isActive=` | No | Get therapists by shop | Query params | `ApiResponse<List<TherapistDto>>` |
|
|
| POST | `/api/v1/therapists` | No | Create a new therapist | `CreateTherapistRequest` | `ApiResponse<TherapistDto>` (201) |
|
|
| PUT | `/api/v1/therapists/{id}` | No | Update a therapist | `UpdateTherapistRequest` | `ApiResponse<TherapistDto>` |
|
|
| DELETE | `/api/v1/therapists/{id}` | No | Deactivate therapist (soft delete) | - | `ApiResponse<bool>` |
|
|
|
|
### Admin Appointments (`/api/v1/admin/appointments`) - **Requires Auth: Admin, ShopOwner**
|
|
|
|
| Method | Path | Auth | Description | Request | Response |
|
|
|--------|------|------|-------------|---------|----------|
|
|
| GET | `/api/v1/admin/appointments?shopId=&startDate=&endDate=&status=&page=&pageSize=` | Admin/ShopOwner | Get all appointments with advanced filtering | Query params | `ApiResponse<PaginatedList<AppointmentDto>>` |
|
|
| GET | `/api/v1/admin/appointments/statistics?shopId=&startDate=&endDate=` | Admin/ShopOwner | Get appointment statistics | Query params | `ApiResponse<AppointmentStatisticsDto>` |
|
|
|
|
### Admin Resources (`/api/v1/admin/resources`) - **Requires Auth: Admin, ShopOwner**
|
|
|
|
| Method | Path | Auth | Description | Request | Response |
|
|
|--------|------|------|-------------|---------|----------|
|
|
| GET | `/api/v1/admin/resources/{shopId}` | Admin/ShopOwner | Get all resources including inactive | Path param | `ApiResponse<List<ResourceDto>>` |
|
|
|
|
---
|
|
|
|
## Commands
|
|
|
|
### CreateAppointmentCommand
|
|
- **Input**: `ShopId (Guid)`, `ServiceId (Guid)`, `StartTime (DateTime)`, `EndTime (DateTime)`, `CustomerId? (Guid)`, `StaffId? (Guid)`, `ResourceId? (Guid)`, `Notes? (string)`
|
|
- **Logic**: Creates Appointment domain entity, raises `AppointmentCreatedDomainEvent`, saves via repository
|
|
- **Validator**: `CreateAppointmentCommandValidator`
|
|
- ShopId: NotEmpty
|
|
- ServiceId: NotEmpty
|
|
- StartTime: NotEmpty, must be in future (with 5min tolerance)
|
|
- EndTime: NotEmpty, must be after StartTime
|
|
- Notes: MaxLength(1000)
|
|
|
|
### UpdateAppointmentStatusCommand
|
|
- **Input**: `AppointmentId (Guid)`, `Action (string)` - one of: "confirm", "start", "complete", "noshow"
|
|
- **Logic**: Loads appointment, executes domain action via switch (Confirm/MarkAsInProgress/Complete/MarkNoShow), saves
|
|
- **Validator**: `UpdateAppointmentStatusCommandValidator`
|
|
- AppointmentId: NotEmpty
|
|
- Action: NotEmpty, must be one of valid actions
|
|
|
|
### CancelAppointmentCommand
|
|
- **Input**: `AppointmentId (Guid)`, `Reason (string)`
|
|
- **Logic**: Loads appointment, calls `Cancel(reason)`, raises `AppointmentCancelledDomainEvent`
|
|
- **Validator**: `CancelAppointmentCommandValidator`
|
|
- AppointmentId: NotEmpty
|
|
- Reason: NotEmpty, MaxLength(500)
|
|
|
|
### MarkNoShowCommand
|
|
- **Input**: `AppointmentId (Guid)`
|
|
- **Logic**: Loads appointment, calls `MarkNoShow()`, saves
|
|
- **Validator**: `MarkNoShowCommandValidator`
|
|
- AppointmentId: NotEmpty
|
|
|
|
### CreateResourceCommand
|
|
- **Input**: `ShopId (Guid)`, `Name (string)`, `ResourceType (string)`, `Capacity (int, default 1)`
|
|
- **Logic**: Creates Resource entity, saves via repository
|
|
- **Validator**: `CreateResourceCommandValidator`
|
|
- ShopId: NotEmpty
|
|
- Name: NotEmpty, MaxLength(200)
|
|
- ResourceType: NotEmpty, MaxLength(50)
|
|
- Capacity: GreaterThan(0)
|
|
|
|
### UpdateResourceCommand
|
|
- **Input**: `ResourceId (Guid)`, `Name (string)`, `Capacity (int)`, `IsActive (bool)`
|
|
- **Logic**: Loads resource, toggles Activate/Deactivate, saves
|
|
- **Note**: Name and Capacity are accepted but NOT actually applied to the entity (only IsActive is used in handler). This is a potential bug.
|
|
|
|
### UpdateStaffScheduleCommand
|
|
- **Input**: `StaffId (Guid)`, `ShopId (Guid)`, `Schedule (List<ScheduleDay>)`
|
|
- **ScheduleDay**: `DayOfWeek (int, 0=Sunday..6=Saturday)`, `StartTime (TimeOnly)`, `EndTime (TimeOnly)`
|
|
- **Logic**: Deletes all existing schedules for staff+shop, creates new ones from input
|
|
- **Validator**: None
|
|
|
|
### CreateTherapistCommand
|
|
- **Input**: `ShopId (Guid)`, `Name (string)`, `Specialties (string[])`, `WorkingHours (WorkingHoursDto)`
|
|
- **Logic**: Serializes WorkingHours to JSON, creates Therapist entity, raises `TherapistCreatedDomainEvent`
|
|
- **Validator**: `CreateTherapistCommandValidator`
|
|
- ShopId: NotEmpty
|
|
- Name: NotEmpty, MaxLength(200)
|
|
- WorkingHours: NotNull
|
|
- WorkingHours.Days: NotEmpty
|
|
- Each Day: DayOfWeek 0-6, StartTime/EndTime required when IsWorking
|
|
|
|
### UpdateTherapistCommand
|
|
- **Input**: `TherapistId (Guid)`, `Name (string)`, `Specialties (string[])`, `WorkingHours (WorkingHoursDto)`
|
|
- **Logic**: Loads therapist, serializes WorkingHours to JSON, calls `Update(name, specialties, workingHoursJson)`, raises `TherapistUpdatedDomainEvent`
|
|
- **Validator**: `UpdateTherapistCommandValidator` (same rules as Create)
|
|
|
|
### DeactivateTherapistCommand
|
|
- **Input**: `TherapistId (Guid)`
|
|
- **Logic**: Loads therapist, calls `Deactivate()` (soft delete), saves
|
|
- **Validator**: `DeactivateTherapistCommandValidator`
|
|
- TherapistId: NotEmpty
|
|
|
|
---
|
|
|
|
## Queries
|
|
|
|
### GetAppointmentQuery
|
|
- **Input**: `AppointmentId (Guid)`
|
|
- **Logic**: Loads from repository by ID, returns null if not found
|
|
|
|
### GetAppointmentsByShopQuery
|
|
- **Input**: `ShopId (Guid)`, `StartDate? (DateTime)`, `EndDate? (DateTime)`, `Status? (string)`, `Page (int, default 1)`, `PageSize (int, default 20)`
|
|
- **Logic**: Filters by ShopId, optional date range and status, ordered by StartTime DESC, paginated
|
|
|
|
### GetAppointmentsByCustomerQuery
|
|
- **Input**: `CustomerId (Guid)`, `Page (int, default 1)`, `PageSize (int, default 20)`
|
|
- **Logic**: Filters by CustomerId, ordered by StartTime DESC, paginated
|
|
|
|
### FindAvailableSlotsQuery
|
|
- **Input**: `ShopId (Guid)`, `ServiceId (Guid)`, `Date (DateTime)`, `ServiceDurationMinutes (int)`, `StaffId? (Guid)`, `ResourceId? (Guid)`
|
|
- **Logic**: Algorithm: `Available Slots = Staff Schedule intersection Resource Availability - Booked Appointments`
|
|
1. Gets staff schedules for the day (filtered by specific staff if provided)
|
|
2. Gets existing non-cancelled appointments for the date
|
|
3. For each staff schedule, generates 15-minute interval slots
|
|
4. Excludes slots that conflict with existing appointments (overlap check)
|
|
5. Returns sorted by StartTime
|
|
- **Validator**: `FindAvailableSlotsQueryValidator`
|
|
- ShopId: NotEmpty
|
|
- ServiceId: NotEmpty
|
|
- Date: NotEmpty
|
|
- ServiceDurationMinutes: GreaterThan(0), LessThanOrEqualTo(480)
|
|
|
|
### GetResourcesByShopQuery
|
|
- **Input**: `ShopId (Guid)`, `IsActive? (bool)`
|
|
- **Logic**: Filters resources by ShopId, optional active filter
|
|
|
|
### GetStaffScheduleQuery
|
|
- **Input**: `StaffId (Guid)`, `ShopId (Guid)`
|
|
- **Logic**: Gets schedules for specific staff in a shop
|
|
|
|
### GetSchedulesByShopQuery
|
|
- **Input**: `ShopId (Guid)`
|
|
- **Logic**: Gets all staff schedules for a shop, ordered by StaffId then DayOfWeek
|
|
|
|
### GetTherapistsQuery
|
|
- **Input**: `ShopId (Guid)`, `IsActive? (bool)`
|
|
- **Logic**: Gets therapists for a shop, optional active filter, ordered by Name
|
|
|
|
---
|
|
|
|
## Domain Model
|
|
|
|
### Appointment (Aggregate Root)
|
|
|
|
**Fields**:
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| Id | Guid | Yes | Auto-generated UUID |
|
|
| ShopId | Guid | Yes | Shop this appointment belongs to |
|
|
| CustomerId | Guid? | No | Customer who booked |
|
|
| StaffId | Guid? | No | Assigned staff/therapist |
|
|
| ResourceId | Guid? | No | Assigned resource (room/bed) |
|
|
| ServiceId | Guid | Yes | Service being booked |
|
|
| StartTime | DateTime | Yes | Appointment start |
|
|
| EndTime | DateTime | Yes | Must be after StartTime |
|
|
| Status | string | Yes | Pending/Confirmed/InProgress/Completed/Cancelled/NoShow |
|
|
| Notes | string? | No | Max 1000 chars |
|
|
| CreatedAt | DateTime | Yes | UTC timestamp |
|
|
|
|
**Behavior Methods**:
|
|
- `Confirm()` - Pending -> Confirmed, raises `AppointmentConfirmedDomainEvent`
|
|
- `MarkAsInProgress()` - Confirmed -> InProgress
|
|
- `Complete()` - InProgress -> Completed, raises `AppointmentCompletedDomainEvent`
|
|
- `Cancel(reason)` - Any (except Completed/Cancelled) -> Cancelled, raises `AppointmentCancelledDomainEvent`
|
|
- `MarkNoShow()` - Pending/Confirmed -> NoShow
|
|
|
|
**State Machine**:
|
|
```
|
|
Pending --> Confirmed --> InProgress --> Completed
|
|
| |
|
|
| v
|
|
+--------> NoShow
|
|
|
|
|
v
|
|
Cancelled (from any state except Completed)
|
|
```
|
|
|
|
**Domain Events**:
|
|
- `AppointmentCreatedDomainEvent(Appointment)` - on creation
|
|
- `AppointmentConfirmedDomainEvent(Appointment)` - on confirm
|
|
- `AppointmentCompletedDomainEvent(Appointment)` - on complete
|
|
- `AppointmentCancelledDomainEvent(Appointment, Reason)` - on cancel
|
|
|
|
### AppointmentStatus (Enumeration)
|
|
|
|
Type-safe enum: Pending(1), Confirmed(2), InProgress(3), Completed(4), Cancelled(5), NoShow(6)
|
|
|
|
**Note**: The Appointment entity uses string-based status internally, not the Enumeration. The AppointmentStatus Enumeration is only used as a seed data reference table.
|
|
|
|
### Resource (Aggregate Root)
|
|
|
|
**Fields**:
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| Id | Guid | Yes | Auto-generated UUID |
|
|
| ShopId | Guid | Yes | Shop this resource belongs to |
|
|
| Name | string | Yes | Max 200 chars |
|
|
| ResourceType | string | Yes | Room/Bed/Equipment, max 50 chars |
|
|
| Capacity | int | Yes | Default 1 |
|
|
| IsActive | bool | Yes | Default true |
|
|
| CreatedAt | DateTime | Yes | UTC timestamp |
|
|
|
|
**Behavior Methods**:
|
|
- `Activate()` - Sets IsActive = true
|
|
- `Deactivate()` - Sets IsActive = false
|
|
|
|
### StaffSchedule (Entity, NOT Aggregate Root)
|
|
|
|
**Fields**:
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| Id | Guid | Yes | Auto-generated UUID |
|
|
| StaffId | Guid | Yes | Staff member ID |
|
|
| ShopId | Guid | Yes | Shop ID |
|
|
| DayOfWeek | int | Yes | 0=Sunday, 6=Saturday |
|
|
| StartTime | TimeOnly | Yes | Work start time |
|
|
| EndTime | TimeOnly | Yes | Work end time |
|
|
|
|
### Therapist (Aggregate Root)
|
|
|
|
**Fields**:
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| Id | Guid | Yes | Auto-generated UUID |
|
|
| ShopId | Guid | Yes | Shop this therapist belongs to |
|
|
| Name | string | Yes | Max 200 chars |
|
|
| Specialties | string[] | Yes | PostgreSQL text[] (e.g., massage, facial, nails) |
|
|
| WorkingHours | string | Yes | JSONB weekly schedule |
|
|
| IsActive | bool | Yes | Default true |
|
|
| CreatedAt | DateTime | Yes | UTC timestamp |
|
|
| UpdatedAt | DateTime? | No | Last update timestamp |
|
|
|
|
**Behavior Methods**:
|
|
- `Activate()` - Sets IsActive = true, raises error if already active
|
|
- `Deactivate()` - Sets IsActive = false, raises error if already inactive
|
|
- `UpdateSpecialties(specialties)` - Updates specialties, raises `TherapistUpdatedDomainEvent`
|
|
- `UpdateWorkingHours(workingHours)` - Updates working hours JSON, raises `TherapistUpdatedDomainEvent`
|
|
- `UpdateName(name)` - Updates name, raises `TherapistUpdatedDomainEvent`
|
|
- `Update(name, specialties, workingHours)` - Bulk update, raises `TherapistUpdatedDomainEvent`
|
|
|
|
**Domain Events**:
|
|
- `TherapistCreatedDomainEvent(Therapist)` - on creation
|
|
- `TherapistUpdatedDomainEvent(Therapist)` - on any update
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
**Database**: `booking_service` (PostgreSQL)
|
|
|
|
### Table: `appointments`
|
|
| Column | Type | Nullable | Description |
|
|
|--------|------|----------|-------------|
|
|
| id | uuid | NOT NULL | PK, ValueGeneratedNever |
|
|
| shop_id | uuid | NOT NULL | Shop FK |
|
|
| customer_id | uuid | NULL | Customer FK |
|
|
| staff_id | uuid | NULL | Staff FK |
|
|
| resource_id | uuid | NULL | Resource FK |
|
|
| service_id | uuid | NOT NULL | Service FK |
|
|
| start_time | timestamp with time zone | NOT NULL | |
|
|
| end_time | timestamp with time zone | NOT NULL | |
|
|
| status | varchar(50) | NOT NULL | |
|
|
| notes | varchar(1000) | NULL | Added in PhaseTwo migration |
|
|
| created_at | timestamp with time zone | NOT NULL | |
|
|
|
|
**Indexes**:
|
|
- `ix_appointments_shop_id` on (shop_id)
|
|
- `ix_appointments_customer_id` on (customer_id)
|
|
- `ix_appointments_staff_id` on (staff_id)
|
|
- `ix_appointments_start_time` on (start_time)
|
|
|
|
### Table: `appointment_statuses` (Reference/Seed)
|
|
| Column | Type | Nullable | Description |
|
|
|--------|------|----------|-------------|
|
|
| id | integer | NOT NULL | PK, ValueGeneratedNever |
|
|
| name | varchar(50) | NOT NULL | Status name |
|
|
|
|
**Seed Data**: Pending(1), Confirmed(2), InProgress(3), Completed(4), Cancelled(5), NoShow(6)
|
|
|
|
### Table: `resources`
|
|
| Column | Type | Nullable | Description |
|
|
|--------|------|----------|-------------|
|
|
| id | uuid | NOT NULL | PK, ValueGeneratedNever |
|
|
| shop_id | uuid | NOT NULL | Shop FK |
|
|
| name | varchar(200) | NOT NULL | |
|
|
| resource_type | varchar(50) | NOT NULL | Room/Bed/Equipment |
|
|
| capacity | integer | NOT NULL | |
|
|
| is_active | boolean | NOT NULL | |
|
|
| created_at | timestamp with time zone | NOT NULL | |
|
|
|
|
**Indexes**:
|
|
- `ix_resources_shop_id` on (shop_id)
|
|
|
|
### Table: `staff_schedules`
|
|
| Column | Type | Nullable | Description |
|
|
|--------|------|----------|-------------|
|
|
| id | uuid | NOT NULL | PK, ValueGeneratedNever |
|
|
| staff_id | uuid | NOT NULL | Staff member FK |
|
|
| shop_id | uuid | NOT NULL | Shop FK |
|
|
| day_of_week | integer | NOT NULL | 0=Sunday..6=Saturday |
|
|
| start_time | time without time zone | NOT NULL | |
|
|
| end_time | time without time zone | NOT NULL | |
|
|
|
|
**Indexes**:
|
|
- `ix_staff_schedules_staff_day` on (staff_id, day_of_week)
|
|
- `ix_staff_schedules_shop_id` on (shop_id)
|
|
|
|
### Table: `therapists`
|
|
| Column | Type | Nullable | Description |
|
|
|--------|------|----------|-------------|
|
|
| id | uuid | NOT NULL | PK, ValueGeneratedNever |
|
|
| shop_id | uuid | NOT NULL | Shop FK |
|
|
| name | varchar(200) | NOT NULL | |
|
|
| specialties | text[] | NOT NULL | PostgreSQL array |
|
|
| working_hours | jsonb | NOT NULL | Weekly schedule JSON |
|
|
| is_active | boolean | NOT NULL | |
|
|
| created_at | timestamp with time zone | NOT NULL | |
|
|
| updated_at | timestamp with time zone | NULL | |
|
|
|
|
**Indexes**:
|
|
- `ix_therapists_shop_id` on (shop_id)
|
|
- `ix_therapists_shop_active` on (shop_id, is_active)
|
|
|
|
### Migrations
|
|
1. `20260117181734_InitialCreate` - appointments, appointment_statuses (seeded), resources, staff_schedules + all indexes
|
|
2. `20260306175525_PhaseTwo` - Added `notes` column to appointments, added `therapists` table + indexes
|
|
|
|
---
|
|
|
|
## Dependencies
|
|
|
|
### NuGet Packages (API Layer)
|
|
| Package | Version |
|
|
|---------|---------|
|
|
| MediatR | 12.4.1 |
|
|
| FluentValidation | 11.11.0 |
|
|
| FluentValidation.DependencyInjectionExtensions | 11.11.0 |
|
|
| Microsoft.EntityFrameworkCore.Design | 10.0.0 |
|
|
| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.1 |
|
|
| Swashbuckle.AspNetCore | 7.2.0 |
|
|
| AspNetCore.HealthChecks.NpgSql | 8.0.2 |
|
|
| AspNetCore.HealthChecks.Redis | 8.0.1 |
|
|
| Hellang.Middleware.ProblemDetails | 6.5.1 |
|
|
| Serilog.AspNetCore | 8.0.3 |
|
|
| Serilog.Sinks.Console | 6.0.0 |
|
|
| Serilog.Sinks.Seq | 8.0.0 |
|
|
|
|
### NuGet Packages (Domain Layer)
|
|
| Package | Version |
|
|
|---------|---------|
|
|
| MediatR.Contracts | 2.0.1 |
|
|
|
|
### NuGet Packages (Infrastructure Layer)
|
|
| Package | Version |
|
|
|---------|---------|
|
|
| Microsoft.EntityFrameworkCore | 10.0.0 |
|
|
| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 |
|
|
| Microsoft.EntityFrameworkCore.Tools | 10.0.0 |
|
|
| MediatR | 12.4.1 |
|
|
| Dapper | 2.1.35 |
|
|
| Microsoft.Extensions.Http.Polly | 9.0.0 |
|
|
| Polly | 8.5.0 |
|
|
| StackExchange.Redis | 2.8.16 |
|
|
|
|
### NuGet Packages (Functional Tests)
|
|
| Package | Version |
|
|
|---------|---------|
|
|
| Microsoft.NET.Test.Sdk | 17.12.0 |
|
|
| xunit | 2.9.2 |
|
|
| Microsoft.AspNetCore.Mvc.Testing | 10.0.0 |
|
|
| Microsoft.EntityFrameworkCore.InMemory | 10.0.0 |
|
|
| FluentAssertions | 6.12.2 |
|
|
| Testcontainers.PostgreSql | 4.1.0 |
|
|
| coverlet.collector | 6.0.2 |
|
|
|
|
### Service Dependencies
|
|
- **IAM Service** (port 5001): JWT token validation via OIDC discovery
|
|
- **PostgreSQL**: `booking_service` database (Neon cloud for staging/prod, local for dev)
|
|
- **Redis**: Configured but not actively used in current code (connection string in appsettings)
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
### appsettings.json
|
|
```json
|
|
{
|
|
"ConnectionStrings": {
|
|
"DefaultConnection": "<Neon PostgreSQL connection string>"
|
|
},
|
|
"Redis": {
|
|
"ConnectionString": "localhost:6379"
|
|
},
|
|
"Jwt": {
|
|
"Secret": "...",
|
|
"Issuer": "goodgo-platform",
|
|
"Audience": "goodgo-services",
|
|
"AccessTokenExpiryMinutes": 15,
|
|
"RefreshTokenExpiryDays": 7
|
|
},
|
|
"Serilog": { ... }
|
|
}
|
|
```
|
|
|
|
### Environment Variables
|
|
- `ASPNETCORE_ENVIRONMENT` - Development/Staging/Production
|
|
- `DATABASE_URL` - Fallback connection string
|
|
- `Jwt:Authority` - IAM IdentityServer URL (default: `http://localhost:5001`)
|
|
|
|
### MediatR Pipeline
|
|
```
|
|
Request --> LoggingBehavior --> ValidatorBehavior --> TransactionBehavior --> Handler
|
|
```
|
|
- **LoggingBehavior**: Logs request name + elapsed time (Stopwatch)
|
|
- **ValidatorBehavior**: Runs FluentValidation, throws `ValidationException` on failure
|
|
- **TransactionBehavior**: Wraps Commands in DB transaction (skips Queries by name suffix check), uses `ExecutionStrategy` for retry
|
|
|
|
### DI Registrations (Infrastructure)
|
|
- `BookingContext` (DbContext + IUnitOfWork)
|
|
- `IAppointmentRepository` -> `AppointmentRepository`
|
|
- `IResourceRepository` -> `ResourceRepository`
|
|
- `IStaffScheduleRepository` -> `StaffScheduleRepository`
|
|
- `ITherapistRepository` -> `TherapistRepository`
|
|
- `IRequestManager` -> `RequestManager`
|
|
|
|
### Startup Behavior
|
|
- Auto-applies EF Core migrations on startup (catches and logs errors without crashing)
|
|
- Swagger UI available at `/swagger` in Development
|
|
|
|
---
|
|
|
|
## Tests
|
|
|
|
### Functional Tests (`tests/BookingService.FunctionalTests/`)
|
|
- **CustomWebApplicationFactory**: Swaps PostgreSQL DbContext for InMemoryDatabase
|
|
- **ResourcesControllerTests**: 2 tests
|
|
- `GetResources_ShouldReturnOk` - GET resources with random shopId returns 200
|
|
- `HealthCheck_ShouldReturnHealthy` - GET /health/live returns 200
|
|
|
|
### Test Gaps
|
|
- No unit tests exist (no `BookingService.UnitTests` project)
|
|
- No tests for appointment CRUD operations
|
|
- No tests for slot-finding algorithm
|
|
- No tests for therapist operations
|
|
- No tests for domain entity behavior methods
|
|
- No validator tests
|
|
|
|
---
|
|
|
|
## Known Issues / Observations
|
|
|
|
1. **UpdateResourceCommand handler bug**: The handler receives `Name` and `Capacity` fields but only uses `IsActive` to toggle Activate/Deactivate. Name and Capacity values are ignored.
|
|
|
|
2. **IAppointmentRepository in wrong layer**: `IAppointmentRepository` is defined in `BookingService.Infrastructure.Repositories` instead of `BookingService.Domain.AggregatesModel.AppointmentAggregate`. Same for `IResourceRepository` and `IStaffScheduleRepository`. Only `ITherapistRepository` correctly resides in the Domain layer. This violates Clean Architecture (Domain should define interfaces, Infrastructure implements them).
|
|
|
|
3. **String-based status instead of Enumeration**: The `Appointment` entity uses raw strings for status ("Pending", "Confirmed", etc.) rather than the `AppointmentStatus` Enumeration class. The Enumeration exists only as a seed data reference table.
|
|
|
|
4. **No [Authorize] on public endpoints**: Most controllers (Appointments, Resources, Schedules, Slots, StaffSchedules, Therapists) lack `[Authorize]` attributes. Only Admin controllers require authorization.
|
|
|
|
5. **SchedulesController bypasses CQRS**: The `CreateSchedule` and `DeleteSchedule` actions directly use `IStaffScheduleRepository` instead of dispatching commands through MediatR.
|
|
|
|
6. **No DomainEvents on Resource**: Resource entity has no domain events (unlike Appointment and Therapist).
|
|
|
|
7. **Redis configured but unused**: Redis connection string is in appsettings but no caching logic exists.
|
|
|
|
8. **Idempotency infrastructure exists but is unused**: `ClientRequest`, `IRequestManager`, `RequestManager` are registered but never called from any command handler.
|
|
|
|
9. **Dapper registered but unused**: Dapper is in the Infrastructure csproj but no Dapper queries exist.
|
|
|
|
10. **Missing `.AsNoTracking()` on read queries**: Query handlers directly use `BookingContext` but don't call `.AsNoTracking()` for read-only operations, which could impact performance.
|