feat(core-api): implement abstract data transformer architecture

- **Core API**: Introduced `IDataTransformer` interface and `BaseDataTransformer` abstract class.
- **Data Services**: Updated `BaseRemoteDataServices` and `DataServicesConfig` to support optional transformer injection. CRUD methods now auto-transform data mapping (DTO <-> Entity) when a transformer is provided.
- **Generics**: Updated `CommonRemoteDataServices` to forward the `TDTO` generic for strict type safety. Fixed `ApiResponse<T>` casting and excess property type checks.
- **Showcase/Samples**: Created `BookingTransformer` (snake_case to camelCase mapping) and injected it into `booking.data-services.ts`.
- **Advanced Showcase**: Added `AdvancedBookingTransformer` and `AdvancedBookingService` to demonstrate extending base transformers with custom methods (e.g., `getAvailabilityChart`).
- **Documentation**: Added comprehensive developer guide at `apps/docs-dev/src/packages/core-api/transformers.md`.
- **Testing**: Added 16 new unit tests for transformer integrations (identity and mock scenarios).

Note: The implementation is 100% backward compatible. All 49/49 tests pass and the TypeScript typecheck is entirely clean.
This commit is contained in:
Firman Ramdhani
2026-07-01 11:13:09 +07:00
parent 7130eb3fe3
commit 1c2090f4fb
11 changed files with 1494 additions and 19 deletions
@@ -0,0 +1,118 @@
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { apiClient } from '../../../../../../core/lib/api-client';
import type { BookingEntity } from './booking.data-services';
import type { BookingDTO } from './booking.transformer';
import {
AdvancedBookingTransformer,
type AvailabilityChartRawData,
type AvailabilityChartData,
} from './advanced-booking.transformer';
// ─── Advanced Booking Data Services ─────────────────────────────
/**
* Extended booking data services with custom methods for
* advanced booking features beyond standard CRUD.
*
* Extends {@link BaseRemoteDataServices} directly (instead of using
* `CommonRemoteDataServices`) to add domain-specific methods like
* `getAvailabilityChart()`.
*
* Uses {@link AdvancedBookingTransformer} which provides:
* - All standard DTO ↔ Entity mappings (inherited from BookingTransformer)
* - Custom `transformAvailabilityChart()` for chart data
* - Enhanced `transformGetManyResponse()` with status normalization
*
* @example
* ```ts
* // Standard CRUD (inherited, with transformer)
* const { data: bookings } = await advancedBookingServices.getMany();
* const { data: booking } = await advancedBookingServices.getOne('42');
*
* // Custom method for chart data
* const { data: chartData } = await advancedBookingServices.getAvailabilityChart({
* startDate: '2026-07-01',
* endDate: '2026-07-31',
* });
* ```
*/
class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
/**
* The concrete advanced transformer instance.
*
* Stored separately from the base `transformer` property
* to access custom methods (like `transformAvailabilityChart`)
* that aren't part of the `IDataTransformer` interface.
*/
private readonly advancedTransformer: AdvancedBookingTransformer;
constructor() {
const advancedTransformer = new AdvancedBookingTransformer();
super(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer: advancedTransformer,
});
this.advancedTransformer = advancedTransformer;
}
// ─── Custom Methods ─────────────────────────────────────────
/**
* Fetch the availability chart data for a given date range.
*
* Calls the `/bookings/availability-chart` endpoint and transforms
* the raw API response into a UI-friendly chart format using
* {@link AdvancedBookingTransformer.transformAvailabilityChart}.
*
* @param params - Date range parameters for the chart query
* @param params.startDate - Start date (ISO format, e.g., '2026-07-01')
* @param params.endDate - End date (ISO format, e.g., '2026-07-31')
* @returns Transformed chart data ready for UI rendering
*
* @example
* ```ts
* const { data } = await advancedBookingServices.getAvailabilityChart({
* startDate: '2026-07-01',
* endDate: '2026-07-31',
* });
*
* // data.dataPoints → Array of chart-ready data points
* // data.summary → Aggregated metrics for the period
* ```
*/
async getAvailabilityChart(params: {
startDate: string;
endDate: string;
}): Promise<ApiResponse<AvailabilityChartData>> {
const response = await this.customRequest<AvailabilityChartRawData>({
url: '/bookings/availability-chart',
method: 'GET',
params: {
start_date: params.startDate,
end_date: params.endDate,
},
});
return {
data: this.advancedTransformer.transformAvailabilityChart(response.data),
status: response.status,
};
}
}
// ─── Singleton Export ────────────────────────────────────────────
/**
* Pre-configured advanced booking data services instance.
*
* Use this when you need both standard CRUD operations and
* custom methods like `getAvailabilityChart()`.
*
* For standard CRUD-only usage, prefer `bookingServices` from
* `booking.data-services.ts` instead.
*/
export const advancedBookingServices = new AdvancedBookingDataServices();
@@ -0,0 +1,159 @@
import { BookingTransformer } from './booking.transformer';
import type { BookingDTO } from './booking.transformer';
import type { BookingEntity } from './booking.data-services';
// ─── Advanced Types ─────────────────────────────────────────────
/**
* Raw availability chart data as returned by the API.
*
* The backend returns a flat structure with snake_case keys
* and ISO date strings. This needs to be transformed into
* a more UI-friendly shape for chart rendering.
*/
export interface AvailabilityChartRawData {
dates: Array<{
date_iso: string;
available_rooms: number;
total_rooms: number;
occupancy_rate: number;
revenue_per_room: number;
}>;
summary: {
avg_occupancy_rate: number;
total_revenue: number;
period_start: string;
period_end: string;
};
}
/**
* UI-friendly availability chart data.
*
* Pre-computed for direct rendering in chart components
* with camelCase fields, formatted labels, and derived metrics.
*/
export interface AvailabilityChartData {
/** Data points ready for chart rendering. */
dataPoints: Array<{
/** Formatted date label (e.g., 'Mon, Jul 1'). */
label: string;
/** ISO date string for programmatic use. */
dateISO: string;
/** Number of rooms available. */
availableRooms: number;
/** Total room capacity. */
totalRooms: number;
/** Occupancy rate as a percentage (0-100). */
occupancyRate: number;
/** Revenue per available room. */
revenuePerRoom: number;
/** Whether the day is a high-demand day (>80% occupancy). */
isHighDemand: boolean;
}>;
/** Aggregated summary metrics for the period. */
summary: {
averageOccupancy: number;
totalRevenue: number;
periodStart: string;
periodEnd: string;
/** Number of high-demand days in the period. */
highDemandDays: number;
};
}
// ─── Advanced Booking Transformer ───────────────────────────────
/**
* Extended booking transformer with additional custom methods
* for non-CRUD data transformations.
*
* Inherits all standard DTO ↔ Entity mapping from
* {@link BookingTransformer} and adds domain-specific
* transformations for advanced features like availability charts.
*
* **When to extend vs. create new:**
* - Extend when the new transformer shares the same entity/DTO pair
* and you need additional transformation methods
* - Create a new transformer when the entity/DTO types are different
*
* @example
* ```ts
* const transformer = new AdvancedBookingTransformer();
*
* // Standard CRUD mapping (inherited)
* const entity = transformer.transformToEntity(bookingDTO);
*
* // Custom chart transformation (new)
* const chartData = transformer.transformAvailabilityChart(rawChartData);
* ```
*/
export class AdvancedBookingTransformer extends BookingTransformer {
/**
* Transform raw availability chart data from the API into a
* UI-friendly format for chart rendering.
*
* Performs the following transformations:
* 1. Maps snake_case fields to camelCase
* 2. Formats date strings into human-readable labels
* 3. Computes derived `isHighDemand` flag (>80% occupancy)
* 4. Aggregates `highDemandDays` count in the summary
*
* @param rawData - Raw chart data from the `/bookings/availability-chart` endpoint
* @returns Transformed chart data ready for UI rendering
*/
transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
const HIGH_DEMAND_THRESHOLD = 80;
const dataPoints = rawData.dates.map((item) => {
const date = new Date(item.date_iso);
const isHighDemand = item.occupancy_rate > HIGH_DEMAND_THRESHOLD;
return {
label: date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
dateISO: item.date_iso,
availableRooms: item.available_rooms,
totalRooms: item.total_rooms,
occupancyRate: item.occupancy_rate,
revenuePerRoom: item.revenue_per_room,
isHighDemand,
};
});
const highDemandDays = dataPoints.filter((dp) => dp.isHighDemand).length;
return {
dataPoints,
summary: {
averageOccupancy: rawData.summary.avg_occupancy_rate,
totalRevenue: rawData.summary.total_revenue,
periodStart: rawData.summary.period_start,
periodEnd: rawData.summary.period_end,
highDemandDays,
},
};
}
/**
* Enhanced getMany response that also normalizes status values.
*
* Demonstrates overriding an inherited hook to add
* additional processing on top of the base transformation.
*
* @param dtos - Array of booking DTOs from the API
* @returns Transformed entities with normalized status
*/
override transformGetManyResponse(dtos: BookingDTO[]): BookingEntity[] {
return super.transformGetManyResponse(dtos).map((entity) => ({
...entity,
// Normalize 'cancelled' vs 'canceled' from different API versions
status: entity.status === ('canceled' as BookingEntity['status'])
? 'cancelled'
: entity.status,
}));
}
}
@@ -1,6 +1,8 @@
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../core/lib/api-client';
import { BookingTransformer } from './booking.transformer';
import type { BookingDTO } from './booking.transformer';
// ─── Domain Entity ──────────────────────────────────────────────
@@ -22,19 +24,35 @@ export interface BookingEntity extends BaseEntity {
// ─── Data Services Instance ─────────────────────────────────────
/**
* Booking data services — wired to the enterprise `apiClient`.
* Booking data services — wired to the enterprise `apiClient`
* with automatic DTO ↔ Entity transformation.
*
* All requests flow through the full interceptor chain:
* Faro tracing → Bearer token injection → ApiError normalization.
*
* The injected {@link BookingTransformer} automatically:
* - Maps snake_case API responses to camelCase entities on `getOne`/`getMany`
* - Maps camelCase entity payloads to snake_case DTOs on `create`/`edit`
* - Strips `id` from create payloads
* - Computes `durationNights` on `getOne` responses
*
* @example
* ```ts
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
* // data is BookingEntity[] with camelCase fields
*
* const { data: booking } = await bookingServices.getOne('42');
* // booking is BookingEntity with computed durationNights
*
* await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
* // Payload is automatically transformed to { booking_code: 'BK001', customer_name: 'Alice', ... }
*
* await bookingServices.confirmProcessTransaction('42');
* ```
*/
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(apiClient, {
export const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer: new BookingTransformer(),
});
@@ -0,0 +1,123 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import type { BookingEntity } from './booking.data-services';
// ─── Booking DTO (API Response Shape) ───────────────────────────
/**
* Raw booking data as returned by the API.
*
* Uses snake_case field names matching the backend's JSON serialization.
* This DTO is never used directly in UI components — it is transformed
* into a {@link BookingEntity} by the {@link BookingTransformer}.
*/
export interface BookingDTO {
id?: string;
booking_code: string;
customer_name: string;
check_in_date: string;
check_out_date: string;
status: 'pending' | 'confirmed' | 'cancelled';
total_amount: number;
}
// ─── Booking Transformer ────────────────────────────────────────
/**
* Transforms between the API's `BookingDTO` (snake_case) and
* the frontend's `BookingEntity` (camelCase).
*
* Handles:
* - Field name mapping (snake_case ↔ camelCase)
* - Computed field derivation (e.g., `durationNights` on `getOne`)
* - Payload sanitization (e.g., stripping `id` on create)
*
* @example
* ```ts
* const transformer = new BookingTransformer();
*
* // API response → Domain entity
* const entity = transformer.transformToEntity({
* id: '42',
* booking_code: 'BK042',
* customer_name: 'Alice',
* check_in_date: '2026-07-01',
* check_out_date: '2026-07-03',
* status: 'confirmed',
* total_amount: 500000,
* });
* // → { id: '42', bookingCode: 'BK042', customerName: 'Alice', ... }
* ```
*/
export class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
/**
* Map an API booking DTO to a frontend booking entity.
*
* @param dto - Raw booking data from the API
* @returns Mapped booking entity with camelCase fields
*/
override transformToEntity(dto: BookingDTO): BookingEntity {
return {
id: dto.id,
bookingCode: dto.booking_code,
customerName: dto.customer_name,
checkInDate: dto.check_in_date,
checkOutDate: dto.check_out_date,
status: dto.status,
totalAmount: dto.total_amount,
};
}
/**
* Map a frontend booking entity to an API booking DTO.
*
* @param entity - Booking entity from the frontend
* @returns Mapped booking DTO with snake_case fields
*/
override transformToDTO(entity: BookingEntity): BookingDTO {
return {
id: entity.id,
booking_code: entity.bookingCode,
customer_name: entity.customerName,
check_in_date: entity.checkInDate,
check_out_date: entity.checkOutDate,
status: entity.status,
total_amount: entity.totalAmount,
};
}
/**
* Transform a single booking response with computed fields.
*
* Adds `durationNights` as a derived convenience field
* that is only relevant when viewing a single booking detail.
*
* @param dto - Raw booking DTO from the API
* @returns Booking entity with computed fields
*/
override transformGetOneResponse(dto: BookingDTO): BookingEntity {
const entity = this.transformToEntity(dto);
const checkIn = new Date(dto.check_in_date);
const checkOut = new Date(dto.check_out_date);
const durationMs = checkOut.getTime() - checkIn.getTime();
const durationNights = Math.max(0, Math.ceil(durationMs / (1000 * 60 * 60 * 24)));
return {
...entity,
// Attach computed field via type assertion since
// durationNights is a view-layer convenience
...(durationNights > 0 ? { durationNights } : {}),
};
}
/**
* Strip `id` from create payloads since the backend generates IDs.
*
* @param entity - Partial booking entity from the create form
* @returns Sanitized DTO payload without `id`
*/
override transformCreatePayload(entity: Partial<BookingEntity>): Partial<BookingDTO> {
const dto = this.transformToDTO(entity as BookingEntity);
const { id: _, ...rest } = dto;
return rest;
}
}