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:
Janus C. H. Knudsen 2025-11-18 16:37:33 +01:00
parent 2aa9d06fab
commit 8e52d670d6
30 changed files with 1960 additions and 526 deletions

View file

@ -0,0 +1,211 @@
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
/**
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
*
* HYBRID PATTERN: Inheritance + Composition
* - Services EXTEND this base class (inheritance for structure)
* - Sync logic is COMPOSED via SyncPlugin (pluggable)
*
* PROVIDES:
* - Generic CRUD operations (get, getAll, save, delete)
* - Sync status management (delegates to SyncPlugin)
* - Serialization hooks (override in subclass if needed)
*
* SUBCLASSES MUST IMPLEMENT:
* - storeName: string (IndexedDB object store name)
* - entityType: EntityType (for runtime routing)
*
* SUBCLASSES MAY OVERRIDE:
* - serialize(entity: T): any (default: no serialization)
* - deserialize(data: any): T (default: no deserialization)
*
* BENEFITS:
* - DRY: Single source of truth for CRUD logic
* - Type safety: Generic T ensures compile-time checking
* - Pluggable: SyncPlugin can be swapped for testing/different implementations
* - Open/Closed: New entities just extend this class
*/
export abstract class BaseEntityService<T extends ISync> implements IEntityService<T> {
// Abstract properties - must be implemented by subclasses
abstract readonly storeName: string;
abstract readonly entityType: EntityType;
// Internal composition - sync functionality
private syncPlugin: SyncPlugin<T>;
// Protected database instance - accessible to subclasses
protected db: IDBDatabase;
/**
* @param db - IDBDatabase instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
this.syncPlugin = new SyncPlugin<T>(this);
}
/**
* Serialize entity before storing in IndexedDB
* Override in subclass if entity has Date fields or needs transformation
*
* @param entity - Entity to serialize
* @returns Serialized data (default: entity itself)
*/
protected serialize(entity: T): any {
return entity; // Default: no serialization
}
/**
* Deserialize data from IndexedDB back to entity
* Override in subclass if entity has Date fields or needs transformation
*
* @param data - Raw data from IndexedDB
* @returns Deserialized entity (default: data itself)
*/
protected deserialize(data: any): T {
return data as T; // Default: no deserialization
}
/**
* Get a single entity by ID
*
* @param id - Entity ID
* @returns Entity or null if not found
*/
async get(id: string): Promise<T | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
if (data) {
resolve(this.deserialize(data));
} else {
resolve(null);
}
};
request.onerror = () => {
reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`));
};
});
}
/**
* Get all entities
*
* @returns Array of all entities
*/
async getAll(): Promise<T[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
const data = request.result as any[];
const entities = data.map(item => this.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get all ${this.entityType}s: ${request.error}`));
};
});
}
/**
* Save an entity (create or update)
*
* @param entity - Entity to save
*/
async save(entity: T): Promise<void> {
const serialized = this.serialize(entity);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(serialized);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save ${this.entityType} ${(entity as any).id}: ${request.error}`));
};
});
}
/**
* Delete an entity
*
* @param id - Entity ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete ${this.entityType} ${id}: ${request.error}`));
};
});
}
// ============================================================================
// SYNC METHODS (IEntityService implementation) - Delegates to SyncPlugin
// ============================================================================
/**
* Mark entity as successfully synced (IEntityService implementation)
* Delegates to SyncPlugin
*
* @param id - Entity ID
*/
async markAsSynced(id: string): Promise<void> {
return this.syncPlugin.markAsSynced(id);
}
/**
* Mark entity as sync error (IEntityService implementation)
* Delegates to SyncPlugin
*
* @param id - Entity ID
*/
async markAsError(id: string): Promise<void> {
return this.syncPlugin.markAsError(id);
}
/**
* Get sync status for an entity (IEntityService implementation)
* Delegates to SyncPlugin
*
* @param id - Entity ID
* @returns SyncStatus or null if entity not found
*/
async getSyncStatus(id: string): Promise<SyncStatus | null> {
return this.syncPlugin.getSyncStatus(id);
}
/**
* Get entities by sync status
* Delegates to SyncPlugin - uses IndexedDB syncStatus index
*
* @param syncStatus - Sync status ('synced', 'pending', 'error')
* @returns Array of entities with this sync status
*/
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return this.syncPlugin.getBySyncStatus(syncStatus);
}
}

View file

@ -0,0 +1,46 @@
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
/**
* IEntityService<T> - Generic interface for entity services with sync capabilities
*
* All entity services (Event, Booking, Customer, Resource) implement this interface
* to enable polymorphic sync status management in SyncManager.
*
* ENCAPSULATION: Services encapsulate sync status manipulation.
* SyncManager does NOT directly manipulate entity.syncStatus - it delegates to the service.
*
* POLYMORFI: SyncManager works with Array<IEntityService<any>> and uses
* entityType property for runtime routing, avoiding switch statements.
*/
export interface IEntityService<T extends ISync> {
/**
* Entity type discriminator for runtime routing
* Must match EntityType values: 'Event', 'Booking', 'Customer', 'Resource'
*/
readonly entityType: EntityType;
/**
* Mark entity as successfully synced with backend
* Sets syncStatus = 'synced' and persists to IndexedDB
*
* @param id - Entity ID
*/
markAsSynced(id: string): Promise<void>;
/**
* Mark entity as sync error (max retries exceeded)
* Sets syncStatus = 'error' and persists to IndexedDB
*
* @param id - Entity ID
*/
markAsError(id: string): Promise<void>;
/**
* Get current sync status for an entity
* Used by SyncManager to check entity state
*
* @param id - Entity ID
* @returns SyncStatus or null if entity not found
*/
getSyncStatus(id: string): Promise<SyncStatus | null>;
}

View file

@ -1,14 +1,15 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { IDataEntity } from '../types/CalendarTypes';
import { IStore } from './IStore';
/**
* Operation for the sync queue
* Generic structure supporting all entity types (Event, Booking, Customer, Resource)
*/
export interface IQueueOperation {
id: string;
type: 'create' | 'update' | 'delete';
eventId: string;
data: Partial<ICalendarEvent> | ICalendarEvent;
entityId: string;
dataEntity: IDataEntity;
timestamp: number;
retryCount: number;
}
@ -116,7 +117,7 @@ export class IndexedDBService {
const db = this.ensureDB();
const queueItem: IQueueOperation = {
...operation,
id: `${operation.type}-${operation.eventId}-${Date.now()}`
id: `${operation.type}-${operation.entityId}-${Date.now()}`
};
return new Promise((resolve, reject) => {

View file

@ -77,23 +77,37 @@ export class OperationQueue {
}
/**
* Get operations for a specific event ID
* Get operations for a specific entity ID
*/
async getOperationsForEvent(eventId: string): Promise<IQueueOperation[]> {
async getOperationsForEntity(entityId: string): Promise<IQueueOperation[]> {
const queue = await this.getAll();
return queue.filter(op => op.eventId === eventId);
return queue.filter(op => op.entityId === entityId);
}
/**
* Remove all operations for a specific event ID
* Remove all operations for a specific entity ID
*/
async removeOperationsForEvent(eventId: string): Promise<void> {
const operations = await this.getOperationsForEvent(eventId);
async removeOperationsForEntity(entityId: string): Promise<void> {
const operations = await this.getOperationsForEntity(entityId);
for (const op of operations) {
await this.remove(op.id);
}
}
/**
* @deprecated Use getOperationsForEntity instead
*/
async getOperationsForEvent(eventId: string): Promise<IQueueOperation[]> {
return this.getOperationsForEntity(eventId);
}
/**
* @deprecated Use removeOperationsForEntity instead
*/
async removeOperationsForEvent(eventId: string): Promise<void> {
return this.removeOperationsForEntity(eventId);
}
/**
* Update retry count for an operation
*/

90
src/storage/SyncPlugin.ts Normal file
View file

@ -0,0 +1,90 @@
import { ISync, SyncStatus, EntityType } from '../types/CalendarTypes';
/**
* SyncPlugin<T extends ISync> - Pluggable sync functionality for entity services
*
* COMPOSITION PATTERN:
* - Encapsulates all sync-related logic in separate class
* - Composed into BaseEntityService (not inheritance)
* - Allows sync functionality to be swapped/mocked for testing
* - Single Responsibility: Only handles sync status management
*
* DESIGN:
* - Takes reference to BaseEntityService for calling get/save
* - Implements sync methods that delegate to service's CRUD
* - Uses IndexedDB syncStatus index for efficient queries
*/
export class SyncPlugin<T extends ISync> {
/**
* @param service - Reference to BaseEntityService for CRUD operations
*/
constructor(private service: any) {
// Type is 'any' to avoid circular dependency at compile time
// Runtime: service is BaseEntityService<T>
}
/**
* Mark entity as successfully synced
* Sets syncStatus = 'synced' and persists to IndexedDB
*
* @param id - Entity ID
*/
async markAsSynced(id: string): Promise<void> {
const entity = await this.service.get(id);
if (entity) {
entity.syncStatus = 'synced';
await this.service.save(entity);
}
}
/**
* Mark entity as sync error (max retries exceeded)
* Sets syncStatus = 'error' and persists to IndexedDB
*
* @param id - Entity ID
*/
async markAsError(id: string): Promise<void> {
const entity = await this.service.get(id);
if (entity) {
entity.syncStatus = 'error';
await this.service.save(entity);
}
}
/**
* Get current sync status for an entity
*
* @param id - Entity ID
* @returns SyncStatus or null if entity not found
*/
async getSyncStatus(id: string): Promise<SyncStatus | null> {
const entity = await this.service.get(id);
return entity ? entity.syncStatus : null;
}
/**
* Get entities by sync status
* Uses IndexedDB syncStatus index for efficient querying
*
* @param syncStatus - Sync status ('synced', 'pending', 'error')
* @returns Array of entities with this sync status
*/
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return new Promise((resolve, reject) => {
const transaction = this.service.db.transaction([this.service.storeName], 'readonly');
const store = transaction.objectStore(this.service.storeName);
const index = store.index('syncStatus');
const request = index.getAll(syncStatus);
request.onsuccess = () => {
const data = request.result as any[];
const entities = data.map(item => this.service.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get ${this.service.entityType}s by sync status ${syncStatus}: ${request.error}`));
};
});
}
}

View file

@ -1,115 +1,43 @@
import { IBooking } from '../../types/BookingTypes';
import { EntityType } from '../../types/CalendarTypes';
import { BookingStore } from './BookingStore';
import { BookingSerialization } from './BookingSerialization';
import { BaseEntityService } from '../BaseEntityService';
/**
* BookingService - CRUD operations for bookings in IndexedDB
*
* Handles all booking-related database operations.
* Part of modular storage architecture where each entity has its own service.
* ARCHITECTURE:
* - Extends BaseEntityService for shared CRUD and sync logic
* - Overrides serialize/deserialize for Date field conversion (createdAt)
* - Provides booking-specific query methods (by customer, by status)
*
* INHERITED METHODS (from BaseEntityService):
* - get(id), getAll(), save(entity), delete(id)
* - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status)
*
* BOOKING-SPECIFIC METHODS:
* - getByCustomer(customerId)
* - getByStatus(status)
*/
export class BookingService {
private db: IDBDatabase;
export class BookingService extends BaseEntityService<IBooking> {
readonly storeName = BookingStore.STORE_NAME;
readonly entityType: EntityType = 'Booking';
/**
* @param db - IDBDatabase instance (injected dependency)
* Serialize booking for IndexedDB storage
* Converts Date objects to ISO strings
*/
constructor(db: IDBDatabase) {
this.db = db;
protected serialize(booking: IBooking): any {
return BookingSerialization.serialize(booking);
}
/**
* Get a single booking by ID
*
* @param id - Booking ID
* @returns IBooking or null if not found
* Deserialize booking from IndexedDB
* Converts ISO strings back to Date objects
*/
async get(id: string): Promise<IBooking | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
if (data) {
resolve(BookingSerialization.deserialize(data));
} else {
resolve(null);
}
};
request.onerror = () => {
reject(new Error(`Failed to get booking ${id}: ${request.error}`));
};
});
}
/**
* Get all bookings
*
* @returns Array of all bookings
*/
async getAll(): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const data = request.result as any[];
const bookings = data.map(item => BookingSerialization.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get all bookings: ${request.error}`));
};
});
}
/**
* Save a booking (create or update)
*
* @param booking - IBooking to save
*/
async save(booking: IBooking): Promise<void> {
const serialized = BookingSerialization.serialize(booking);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const request = store.put(serialized);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save booking ${booking.id}: ${request.error}`));
};
});
}
/**
* Delete a booking
*
* @param id - Booking ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete booking ${id}: ${request.error}`));
};
});
protected deserialize(data: any): IBooking {
return BookingSerialization.deserialize(data);
}
/**
@ -120,14 +48,14 @@ export class BookingService {
*/
async getByCustomer(customerId: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('customerId');
const request = index.getAll(customerId);
request.onsuccess = () => {
const data = request.result as any[];
const bookings = data.map(item => BookingSerialization.deserialize(item));
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};
@ -145,14 +73,14 @@ export class BookingService {
*/
async getByStatus(status: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('status');
const request = index.getAll(status);
request.onsuccess = () => {
const data = request.result as any[];
const bookings = data.map(item => BookingSerialization.deserialize(item));
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};

View file

@ -29,6 +29,9 @@ export class BookingStore implements IStore {
// Index: status (for filtering by booking status)
store.createIndex('status', 'status', { unique: false });
// Index: syncStatus (for querying by sync status - used by SyncPlugin)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
// Index: createdAt (for sorting bookings chronologically)
store.createIndex('createdAt', 'createdAt', { unique: false });
}

View file

@ -1,108 +1,29 @@
import { ICustomer } from '../../types/CustomerTypes';
import { EntityType } from '../../types/CalendarTypes';
import { CustomerStore } from './CustomerStore';
import { BaseEntityService } from '../BaseEntityService';
/**
* CustomerService - CRUD operations for customers in IndexedDB
*
* Handles all customer-related database operations.
* Part of modular storage architecture where each entity has its own service.
* ARCHITECTURE:
* - Extends BaseEntityService for shared CRUD and sync logic
* - No serialization needed (ICustomer has no Date fields)
* - Provides customer-specific query methods (by phone, search by name)
*
* Note: No serialization needed - ICustomer has no Date fields.
* INHERITED METHODS (from BaseEntityService):
* - get(id), getAll(), save(entity), delete(id)
* - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status)
*
* CUSTOMER-SPECIFIC METHODS:
* - getByPhone(phone)
* - searchByName(searchTerm)
*/
export class CustomerService {
private db: IDBDatabase;
export class CustomerService extends BaseEntityService<ICustomer> {
readonly storeName = CustomerStore.STORE_NAME;
readonly entityType: EntityType = 'Customer';
/**
* @param db - IDBDatabase instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
}
/**
* Get a single customer by ID
*
* @param id - Customer ID
* @returns ICustomer or null if not found
*/
async get(id: string): Promise<ICustomer | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error(`Failed to get customer ${id}: ${request.error}`));
};
});
}
/**
* Get all customers
*
* @returns Array of all customers
*/
async getAll(): Promise<ICustomer[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result as ICustomer[]);
};
request.onerror = () => {
reject(new Error(`Failed to get all customers: ${request.error}`));
};
});
}
/**
* Save a customer (create or update)
*
* @param customer - ICustomer to save
*/
async save(customer: ICustomer): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const request = store.put(customer);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save customer ${customer.id}: ${request.error}`));
};
});
}
/**
* Delete a customer
*
* @param id - Customer ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete customer ${id}: ${request.error}`));
};
});
}
// No serialization override needed - ICustomer has no Date fields
/**
* Get customers by phone number
@ -112,8 +33,8 @@ export class CustomerService {
*/
async getByPhone(phone: string): Promise<ICustomer[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('phone');
const request = index.getAll(phone);

View file

@ -28,5 +28,8 @@ export class CustomerStore implements IStore {
// Index: phone (for customer lookup by phone)
store.createIndex('phone', 'phone', { unique: false });
// Index: syncStatus (for querying by sync status - used by SyncPlugin)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -1,115 +1,45 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
import { ICalendarEvent, EntityType } from '../../types/CalendarTypes';
import { EventStore } from './EventStore';
import { EventSerialization } from './EventSerialization';
import { BaseEntityService } from '../BaseEntityService';
/**
* EventService - CRUD operations for calendar events in IndexedDB
*
* Handles all event-related database operations.
* Part of modular storage architecture where each entity has its own service.
* ARCHITECTURE:
* - Extends BaseEntityService for shared CRUD and sync logic
* - Overrides serialize/deserialize for Date field conversion
* - Provides event-specific query methods (by date range, resource, customer, booking)
*
* INHERITED METHODS (from BaseEntityService):
* - get(id), getAll(), save(entity), delete(id)
* - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status)
*
* EVENT-SPECIFIC METHODS:
* - getByDateRange(start, end)
* - getByResource(resourceId)
* - getByCustomer(customerId)
* - getByBooking(bookingId)
* - getByResourceAndDateRange(resourceId, start, end)
*/
export class EventService {
private db: IDBDatabase;
export class EventService extends BaseEntityService<ICalendarEvent> {
readonly storeName = EventStore.STORE_NAME;
readonly entityType: EntityType = 'Event';
/**
* @param db - IDBDatabase instance (injected dependency)
* Serialize event for IndexedDB storage
* Converts Date objects to ISO strings
*/
constructor(db: IDBDatabase) {
this.db = db;
protected serialize(event: ICalendarEvent): any {
return EventSerialization.serialize(event);
}
/**
* Get a single event by ID
*
* @param id - Event ID
* @returns ICalendarEvent or null if not found
* Deserialize event from IndexedDB
* Converts ISO strings back to Date objects
*/
async get(id: string): Promise<ICalendarEvent | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
if (data) {
resolve(EventSerialization.deserialize(data));
} else {
resolve(null);
}
};
request.onerror = () => {
reject(new Error(`Failed to get event ${id}: ${request.error}`));
};
});
}
/**
* Get all events
*
* @returns Array of all events
*/
async getAll(): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get all events: ${request.error}`));
};
});
}
/**
* Save an event (create or update)
*
* @param event - ICalendarEvent to save
*/
async save(event: ICalendarEvent): Promise<void> {
const serialized = EventSerialization.serialize(event);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(EventStore.STORE_NAME);
const request = store.put(serialized);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save event ${event.id}: ${request.error}`));
};
});
}
/**
* Delete an event
*
* @param id - Event ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(EventStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete event ${id}: ${request.error}`));
};
});
protected deserialize(data: any): ICalendarEvent {
return EventSerialization.deserialize(data);
}
/**
@ -122,8 +52,8 @@ export class EventService {
*/
async getByDateRange(start: Date, end: Date): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('start');
// Get all events starting from start date
@ -135,7 +65,7 @@ export class EventService {
// Deserialize and filter in memory
const events = data
.map(item => EventSerialization.deserialize(item))
.map(item => this.deserialize(item))
.filter(event => event.start <= end);
resolve(events);
@ -155,14 +85,14 @@ export class EventService {
*/
async getByResource(resourceId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('resourceId');
const request = index.getAll(resourceId);
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
const events = data.map(item => this.deserialize(item));
resolve(events);
};
@ -180,14 +110,14 @@ export class EventService {
*/
async getByCustomer(customerId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('customerId');
const request = index.getAll(customerId);
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
const events = data.map(item => this.deserialize(item));
resolve(events);
};
@ -205,14 +135,14 @@ export class EventService {
*/
async getByBooking(bookingId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('bookingId');
const request = index.getAll(bookingId);
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
const events = data.map(item => this.deserialize(item));
resolve(events);
};
@ -222,31 +152,6 @@ export class EventService {
});
}
/**
* Get events by sync status
*
* @param syncStatus - Sync status ('synced', 'pending', 'error')
* @returns Array of events with this sync status
*/
async getBySyncStatus(syncStatus: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const index = store.index('syncStatus');
const request = index.getAll(syncStatus);
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events by sync status ${syncStatus}: ${request.error}`));
};
});
}
/**
* Get events for a resource within a date range
* Combines resource and date filtering

View file

@ -1,108 +1,30 @@
import { IResource } from '../../types/ResourceTypes';
import { EntityType } from '../../types/CalendarTypes';
import { ResourceStore } from './ResourceStore';
import { BaseEntityService } from '../BaseEntityService';
/**
* ResourceService - CRUD operations for resources in IndexedDB
*
* Handles all resource-related database operations.
* Part of modular storage architecture where each entity has its own service.
* ARCHITECTURE:
* - Extends BaseEntityService for shared CRUD and sync logic
* - No serialization needed (IResource has no Date fields)
* - Provides resource-specific query methods (by type, active/inactive)
*
* Note: No serialization needed - IResource has no Date fields.
* INHERITED METHODS (from BaseEntityService):
* - get(id), getAll(), save(entity), delete(id)
* - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status)
*
* RESOURCE-SPECIFIC METHODS:
* - getByType(type)
* - getActive()
* - getInactive()
*/
export class ResourceService {
private db: IDBDatabase;
export class ResourceService extends BaseEntityService<IResource> {
readonly storeName = ResourceStore.STORE_NAME;
readonly entityType: EntityType = 'Resource';
/**
* @param db - IDBDatabase instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
}
/**
* Get a single resource by ID
*
* @param id - Resource ID
* @returns IResource or null if not found
*/
async get(id: string): Promise<IResource | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error(`Failed to get resource ${id}: ${request.error}`));
};
});
}
/**
* Get all resources
*
* @returns Array of all resources
*/
async getAll(): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result as IResource[]);
};
request.onerror = () => {
reject(new Error(`Failed to get all resources: ${request.error}`));
};
});
}
/**
* Save a resource (create or update)
*
* @param resource - IResource to save
*/
async save(resource: IResource): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const request = store.put(resource);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save resource ${resource.id}: ${request.error}`));
};
});
}
/**
* Delete a resource
*
* @param id - Resource ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete resource ${id}: ${request.error}`));
};
});
}
// No serialization override needed - IResource has no Date fields
/**
* Get resources by type
@ -112,8 +34,8 @@ export class ResourceService {
*/
async getByType(type: string): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('type');
const request = index.getAll(type);
@ -134,10 +56,10 @@ export class ResourceService {
*/
async getActive(): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('isActive');
const request = index.getAll(true);
const request = index.getAll(IDBKeyRange.only(true));
request.onsuccess = () => {
resolve(request.result as IResource[]);
@ -156,10 +78,10 @@ export class ResourceService {
*/
async getInactive(): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('isActive');
const request = index.getAll(false);
const request = index.getAll(IDBKeyRange.only(false));
request.onsuccess = () => {
resolve(request.result as IResource[]);

View file

@ -28,5 +28,8 @@ export class ResourceStore implements IStore {
// Index: isActive (for showing/hiding inactive resources)
store.createIndex('isActive', 'isActive', { unique: false });
// Index: syncStatus (for querying by sync status - used by SyncPlugin)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}