Refactor entity services with hybrid sync pattern
Introduces BaseEntityService and SyncPlugin to eliminate code duplication across entity services Improves: - Code reusability through inheritance and composition - Sync infrastructure for all entity types - Polymorphic sync status management - Reduced boilerplate code by ~75% Supports generic sync for Event, Booking, Customer, and Resource entities
This commit is contained in:
parent
2aa9d06fab
commit
8e52d670d6
30 changed files with 1960 additions and 526 deletions
92
src/repositories/ApiBookingRepository.ts
Normal file
92
src/repositories/ApiBookingRepository.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { IBooking } from '../types/BookingTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
/**
|
||||
* ApiBookingRepository
|
||||
* Handles communication with backend API for bookings
|
||||
*
|
||||
* Implements IApiRepository<IBooking> for generic sync infrastructure.
|
||||
* Used by SyncManager to send queued booking operations to the server.
|
||||
*/
|
||||
export class ApiBookingRepository implements IApiRepository<IBooking> {
|
||||
readonly entityType: EntityType = 'Booking';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.apiEndpoint = config.apiEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send create operation to API
|
||||
*/
|
||||
async sendCreate(booking: IBooking): Promise<IBooking> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/bookings`, {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(booking)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API create failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiBookingRepository.sendCreate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update operation to API
|
||||
*/
|
||||
async sendUpdate(id: string, updates: Partial<IBooking>): Promise<IBooking> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/bookings/${id}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API update failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiBookingRepository.sendUpdate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delete operation to API
|
||||
*/
|
||||
async sendDelete(id: string): Promise<void> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/bookings/${id}`, {
|
||||
// method: 'DELETE'
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API delete failed: ${response.statusText}`);
|
||||
// }
|
||||
|
||||
throw new Error('ApiBookingRepository.sendDelete not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all bookings from API
|
||||
*/
|
||||
async fetchAll(): Promise<IBooking[]> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/bookings`);
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API fetch failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiBookingRepository.fetchAll not implemented yet');
|
||||
}
|
||||
}
|
||||
92
src/repositories/ApiCustomerRepository.ts
Normal file
92
src/repositories/ApiCustomerRepository.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { ICustomer } from '../types/CustomerTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
/**
|
||||
* ApiCustomerRepository
|
||||
* Handles communication with backend API for customers
|
||||
*
|
||||
* Implements IApiRepository<ICustomer> for generic sync infrastructure.
|
||||
* Used by SyncManager to send queued customer operations to the server.
|
||||
*/
|
||||
export class ApiCustomerRepository implements IApiRepository<ICustomer> {
|
||||
readonly entityType: EntityType = 'Customer';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.apiEndpoint = config.apiEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send create operation to API
|
||||
*/
|
||||
async sendCreate(customer: ICustomer): Promise<ICustomer> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/customers`, {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(customer)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API create failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiCustomerRepository.sendCreate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update operation to API
|
||||
*/
|
||||
async sendUpdate(id: string, updates: Partial<ICustomer>): Promise<ICustomer> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/customers/${id}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API update failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiCustomerRepository.sendUpdate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delete operation to API
|
||||
*/
|
||||
async sendDelete(id: string): Promise<void> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/customers/${id}`, {
|
||||
// method: 'DELETE'
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API delete failed: ${response.statusText}`);
|
||||
// }
|
||||
|
||||
throw new Error('ApiCustomerRepository.sendDelete not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all customers from API
|
||||
*/
|
||||
async fetchAll(): Promise<ICustomer[]> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/customers`);
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API fetch failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiCustomerRepository.fetchAll not implemented yet');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,22 @@
|
|||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { ICalendarEvent, EntityType } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
/**
|
||||
* ApiEventRepository
|
||||
* Handles communication with backend API
|
||||
* Handles communication with backend API for calendar events
|
||||
*
|
||||
* Used by SyncManager to send queued operations to the server
|
||||
* NOT used directly by EventManager (which uses IndexedDBEventRepository)
|
||||
* Implements IApiRepository<ICalendarEvent> for generic sync infrastructure.
|
||||
* Used by SyncManager to send queued operations to the server.
|
||||
* NOT used directly by EventManager (which uses IndexedDBEventRepository).
|
||||
*
|
||||
* Future enhancements:
|
||||
* - SignalR real-time updates
|
||||
* - Conflict resolution
|
||||
* - Batch operations
|
||||
*/
|
||||
export class ApiEventRepository {
|
||||
export class ApiEventRepository implements IApiRepository<ICalendarEvent> {
|
||||
readonly entityType: EntityType = 'Event';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
|
|
|
|||
92
src/repositories/ApiResourceRepository.ts
Normal file
92
src/repositories/ApiResourceRepository.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { IResource } from '../types/ResourceTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
/**
|
||||
* ApiResourceRepository
|
||||
* Handles communication with backend API for resources
|
||||
*
|
||||
* Implements IApiRepository<IResource> for generic sync infrastructure.
|
||||
* Used by SyncManager to send queued resource operations to the server.
|
||||
*/
|
||||
export class ApiResourceRepository implements IApiRepository<IResource> {
|
||||
readonly entityType: EntityType = 'Resource';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.apiEndpoint = config.apiEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send create operation to API
|
||||
*/
|
||||
async sendCreate(resource: IResource): Promise<IResource> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/resources`, {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(resource)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API create failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiResourceRepository.sendCreate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update operation to API
|
||||
*/
|
||||
async sendUpdate(id: string, updates: Partial<IResource>): Promise<IResource> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/resources/${id}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API update failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiResourceRepository.sendUpdate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delete operation to API
|
||||
*/
|
||||
async sendDelete(id: string): Promise<void> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/resources/${id}`, {
|
||||
// method: 'DELETE'
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API delete failed: ${response.statusText}`);
|
||||
// }
|
||||
|
||||
throw new Error('ApiResourceRepository.sendDelete not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all resources from API
|
||||
*/
|
||||
async fetchAll(): Promise<IResource[]> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/resources`);
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API fetch failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiResourceRepository.fetchAll not implemented yet');
|
||||
}
|
||||
}
|
||||
60
src/repositories/IApiRepository.ts
Normal file
60
src/repositories/IApiRepository.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { EntityType } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* IApiRepository<T> - Generic interface for backend API communication
|
||||
*
|
||||
* All entity-specific API repositories (Event, Booking, Customer, Resource)
|
||||
* must implement this interface to ensure consistent sync behavior.
|
||||
*
|
||||
* Used by SyncManager to route operations to the correct API endpoints
|
||||
* based on entity type (dataEntity.typename).
|
||||
*
|
||||
* Pattern:
|
||||
* - Each entity has its own concrete implementation (ApiEventRepository, ApiBookingRepository, etc.)
|
||||
* - SyncManager maintains a map of entityType → IApiRepository<T>
|
||||
* - Operations are routed at runtime based on IQueueOperation.dataEntity.typename
|
||||
*/
|
||||
export interface IApiRepository<T> {
|
||||
/**
|
||||
* Entity type discriminator - used for runtime routing
|
||||
* Must match EntityType values ('Event', 'Booking', 'Customer', 'Resource')
|
||||
*/
|
||||
readonly entityType: EntityType;
|
||||
|
||||
/**
|
||||
* Send create operation to backend API
|
||||
*
|
||||
* @param data - Entity data to create
|
||||
* @returns Promise<T> - Created entity from server (with server-generated fields)
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
sendCreate(data: T): Promise<T>;
|
||||
|
||||
/**
|
||||
* Send update operation to backend API
|
||||
*
|
||||
* @param id - Entity ID
|
||||
* @param updates - Partial entity data to update
|
||||
* @returns Promise<T> - Updated entity from server
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
sendUpdate(id: string, updates: Partial<T>): Promise<T>;
|
||||
|
||||
/**
|
||||
* Send delete operation to backend API
|
||||
*
|
||||
* @param id - Entity ID to delete
|
||||
* @returns Promise<void>
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
sendDelete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetch all entities from backend API
|
||||
* Used for initial sync and full refresh
|
||||
*
|
||||
* @returns Promise<T[]> - Array of all entities
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
fetchAll(): Promise<T[]>;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { IEventRepository, UpdateSource } from './IEventRepository';
|
||||
import { IndexedDBService } from '../storage/IndexedDBService';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
import { OperationQueue } from '../storage/OperationQueue';
|
||||
|
||||
/**
|
||||
|
|
@ -8,31 +9,45 @@ import { OperationQueue } from '../storage/OperationQueue';
|
|||
* Offline-first repository using IndexedDB as single source of truth
|
||||
*
|
||||
* All CRUD operations:
|
||||
* - Save to IndexedDB immediately (always succeeds)
|
||||
* - Save to IndexedDB immediately via EventService (always succeeds)
|
||||
* - Add to sync queue if source is 'local'
|
||||
* - Background SyncManager processes queue to sync with API
|
||||
*/
|
||||
export class IndexedDBEventRepository implements IEventRepository {
|
||||
private indexedDB: IndexedDBService;
|
||||
private eventService: EventService;
|
||||
private queue: OperationQueue;
|
||||
|
||||
constructor(indexedDB: IndexedDBService, queue: OperationQueue) {
|
||||
this.indexedDB = indexedDB;
|
||||
this.queue = queue;
|
||||
// EventService will be initialized after IndexedDB is ready
|
||||
this.eventService = null as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure EventService is initialized with database connection
|
||||
*/
|
||||
private ensureEventService(): void {
|
||||
if (!this.eventService && this.indexedDB.isInitialized()) {
|
||||
const db = (this.indexedDB as any).db; // Access private db property
|
||||
this.eventService = new EventService(db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all events from IndexedDB
|
||||
* Ensures IndexedDB is initialized and seeded on first call
|
||||
* Ensures IndexedDB is initialized on first call
|
||||
*/
|
||||
async loadEvents(): Promise<ICalendarEvent[]> {
|
||||
// Lazy initialization on first data load
|
||||
if (!this.indexedDB.isInitialized()) {
|
||||
await this.indexedDB.initialize();
|
||||
await this.indexedDB.seedIfEmpty();
|
||||
// TODO: Seeding should be done at application level, not here
|
||||
}
|
||||
|
||||
return await this.indexedDB.getAllEvents();
|
||||
this.ensureEventService();
|
||||
return await this.eventService.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,15 +70,19 @@ export class IndexedDBEventRepository implements IEventRepository {
|
|||
syncStatus
|
||||
} as ICalendarEvent;
|
||||
|
||||
// Save to IndexedDB
|
||||
await this.indexedDB.saveEvent(newEvent);
|
||||
// Save to IndexedDB via EventService
|
||||
this.ensureEventService();
|
||||
await this.eventService.save(newEvent);
|
||||
|
||||
// If local change, add to sync queue
|
||||
if (source === 'local') {
|
||||
await this.queue.enqueue({
|
||||
type: 'create',
|
||||
eventId: id,
|
||||
data: newEvent,
|
||||
entityId: id,
|
||||
dataEntity: {
|
||||
typename: 'Event',
|
||||
data: newEvent
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
|
|
@ -78,8 +97,9 @@ export class IndexedDBEventRepository implements IEventRepository {
|
|||
* - Adds to queue if local (needs sync)
|
||||
*/
|
||||
async updateEvent(id: string, updates: Partial<ICalendarEvent>, source: UpdateSource = 'local'): Promise<ICalendarEvent> {
|
||||
// Get existing event
|
||||
const existingEvent = await this.indexedDB.getEvent(id);
|
||||
// Get existing event via EventService
|
||||
this.ensureEventService();
|
||||
const existingEvent = await this.eventService.get(id);
|
||||
if (!existingEvent) {
|
||||
throw new Error(`Event with ID ${id} not found`);
|
||||
}
|
||||
|
|
@ -95,15 +115,18 @@ export class IndexedDBEventRepository implements IEventRepository {
|
|||
syncStatus
|
||||
};
|
||||
|
||||
// Save to IndexedDB
|
||||
await this.indexedDB.saveEvent(updatedEvent);
|
||||
// Save to IndexedDB via EventService
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
// If local change, add to sync queue
|
||||
if (source === 'local') {
|
||||
await this.queue.enqueue({
|
||||
type: 'update',
|
||||
eventId: id,
|
||||
data: updates,
|
||||
entityId: id,
|
||||
dataEntity: {
|
||||
typename: 'Event',
|
||||
data: updates
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
|
|
@ -118,8 +141,9 @@ export class IndexedDBEventRepository implements IEventRepository {
|
|||
* - Adds to queue if local (needs sync)
|
||||
*/
|
||||
async deleteEvent(id: string, source: UpdateSource = 'local'): Promise<void> {
|
||||
// Check if event exists
|
||||
const existingEvent = await this.indexedDB.getEvent(id);
|
||||
// Check if event exists via EventService
|
||||
this.ensureEventService();
|
||||
const existingEvent = await this.eventService.get(id);
|
||||
if (!existingEvent) {
|
||||
throw new Error(`Event with ID ${id} not found`);
|
||||
}
|
||||
|
|
@ -129,15 +153,18 @@ export class IndexedDBEventRepository implements IEventRepository {
|
|||
if (source === 'local') {
|
||||
await this.queue.enqueue({
|
||||
type: 'delete',
|
||||
eventId: id,
|
||||
data: {}, // No data needed for delete
|
||||
entityId: id,
|
||||
dataEntity: {
|
||||
typename: 'Event',
|
||||
data: { id } // Minimal data for delete - just ID
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Delete from IndexedDB
|
||||
await this.indexedDB.deleteEvent(id);
|
||||
// Delete from IndexedDB via EventService
|
||||
await this.eventService.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { CalendarEventType } from '../types/BookingTypes';
|
||||
import { IEventRepository, UpdateSource } from './IEventRepository';
|
||||
|
||||
interface RawEventData {
|
||||
|
|
@ -72,7 +73,7 @@ export class MockEventRepository implements IEventRepository {
|
|||
...event,
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end),
|
||||
type: event.type,
|
||||
type: event.type as CalendarEventType,
|
||||
allDay: event.allDay || false,
|
||||
syncStatus: 'synced' as const
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue