Refactor storage architecture with modular IndexedDB services

Implements a more flexible, modular approach to IndexedDB storage:
- Introduces IStore interface for dynamic store creation
- Adds specialized services for each entity (BookingService, CustomerService, etc.)
- Moves serialization logic to entity-specific classes
- Improves dependency injection and extensibility

Enables easier addition of new entity types with minimal configuration
This commit is contained in:
Janus C. H. Knudsen 2025-11-17 17:36:51 +01:00
parent 88cccb3456
commit 2aa9d06fab
13 changed files with 1048 additions and 236 deletions

View file

@ -29,6 +29,11 @@ import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepositor
import { ApiEventRepository } from './repositories/ApiEventRepository';
import { IndexedDBService } from './storage/IndexedDBService';
import { OperationQueue } from './storage/OperationQueue';
import { IStore } from './storage/IStore';
import { BookingStore } from './storage/bookings/BookingStore';
import { CustomerStore } from './storage/customers/CustomerStore';
import { ResourceStore } from './storage/resources/ResourceStore';
import { EventStore } from './storage/events/EventStore';
// Import workers
import { SyncManager } from './workers/SyncManager';
@ -94,6 +99,17 @@ async function initializeCalendar(): Promise<void> {
// Register configuration instance
builder.registerInstance(config).as<Configuration>();
// Register storage stores (IStore implementations)
// Open/Closed Principle: Adding new entity only requires adding one line here
builder.registerType(BookingStore).as<IStore>();
builder.registerType(CustomerStore).as<IStore>();
builder.registerType(ResourceStore).as<IStore>();
builder.registerType(EventStore).as<IStore>();
// Resolve all IStore implementations and register as array
const stores = container.resolveTypeAll<IStore>();
builder.registerInstance(stores).as<IStore[]>();
// Register storage and repository services
builder.registerType(IndexedDBService).as<IndexedDBService>();
builder.registerType(OperationQueue).as<OperationQueue>();

25
src/storage/IStore.ts Normal file
View file

@ -0,0 +1,25 @@
/**
* IStore - Interface for IndexedDB ObjectStore definitions
*
* Each entity store (bookings, customers, resources, events) implements this interface
* to define its schema and creation logic.
*
* This enables Open/Closed Principle: IndexedDBService can work with any IStore
* implementation without modification. Adding new entities only requires:
* 1. Create new Store class implementing IStore
* 2. Register in DI container as IStore
*/
export interface IStore {
/**
* The name of the ObjectStore in IndexedDB
*/
readonly storeName: string;
/**
* Create the ObjectStore with its schema (indexes, keyPath, etc.)
* Called during database upgrade (onupgradeneeded event)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void;
}

View file

@ -1,5 +1,5 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { IBooking } from '../types/BookingTypes';
import { IStore } from './IStore';
/**
* Operation for the sync queue
@ -15,20 +15,30 @@ export interface IQueueOperation {
/**
* IndexedDB Service for Calendar App
* Handles local storage of events and sync queue
* Handles database connection management and core operations
*
* Entity-specific CRUD operations are handled by specialized services:
* - EventService for calendar events
* - BookingService for bookings
* - CustomerService for customers
* - ResourceService for resources
*/
export class IndexedDBService {
private static readonly DB_NAME = 'CalendarDB';
private static readonly DB_VERSION = 2;
private static readonly EVENTS_STORE = 'events';
private static readonly QUEUE_STORE = 'operationQueue';
private static readonly SYNC_STATE_STORE = 'syncState';
private static readonly BOOKINGS_STORE = 'bookings';
private static readonly CUSTOMERS_STORE = 'customers';
private static readonly RESOURCES_STORE = 'resources';
private db: IDBDatabase | null = null;
private initialized: boolean = false;
private stores: IStore[];
/**
* @param stores - Array of IStore implementations injected via DI
*/
constructor(stores: IStore[]) {
this.stores = stores;
}
/**
* Initialize and open the database
@ -49,69 +59,25 @@ export class IndexedDBService {
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const oldVersion = (event as IDBVersionChangeEvent).oldVersion;
// Create events store
if (!db.objectStoreNames.contains(IndexedDBService.EVENTS_STORE)) {
const eventsStore = db.createObjectStore(IndexedDBService.EVENTS_STORE, { keyPath: 'id' });
eventsStore.createIndex('start', 'start', { unique: false });
eventsStore.createIndex('end', 'end', { unique: false });
eventsStore.createIndex('syncStatus', 'syncStatus', { unique: false });
eventsStore.createIndex('resourceId', 'resourceId', { unique: false });
eventsStore.createIndex('customerId', 'customerId', { unique: false });
eventsStore.createIndex('bookingId', 'bookingId', { unique: false });
eventsStore.createIndex('startEnd', ['start', 'end'], { unique: false });
} else if (oldVersion < 2) {
// Upgrade from version 1: Add new indexes to existing events store
const transaction = (event.target as IDBOpenDBRequest).transaction!;
const eventsStore = transaction.objectStore(IndexedDBService.EVENTS_STORE);
// Create all entity stores via injected IStore implementations
// Open/Closed Principle: Adding new entity only requires DI registration
this.stores.forEach(store => {
if (!db.objectStoreNames.contains(store.storeName)) {
store.create(db);
}
});
if (!eventsStore.indexNames.contains('resourceId')) {
eventsStore.createIndex('resourceId', 'resourceId', { unique: false });
}
if (!eventsStore.indexNames.contains('customerId')) {
eventsStore.createIndex('customerId', 'customerId', { unique: false });
}
if (!eventsStore.indexNames.contains('bookingId')) {
eventsStore.createIndex('bookingId', 'bookingId', { unique: false });
}
if (!eventsStore.indexNames.contains('startEnd')) {
eventsStore.createIndex('startEnd', ['start', 'end'], { unique: false });
}
}
// Create operation queue store
// Create operation queue store (sync infrastructure)
if (!db.objectStoreNames.contains(IndexedDBService.QUEUE_STORE)) {
const queueStore = db.createObjectStore(IndexedDBService.QUEUE_STORE, { keyPath: 'id' });
queueStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// Create sync state store
// Create sync state store (sync metadata)
if (!db.objectStoreNames.contains(IndexedDBService.SYNC_STATE_STORE)) {
db.createObjectStore(IndexedDBService.SYNC_STATE_STORE, { keyPath: 'key' });
}
// Create bookings store (v2)
if (!db.objectStoreNames.contains(IndexedDBService.BOOKINGS_STORE)) {
const bookingsStore = db.createObjectStore(IndexedDBService.BOOKINGS_STORE, { keyPath: 'id' });
bookingsStore.createIndex('customerId', 'customerId', { unique: false });
bookingsStore.createIndex('status', 'status', { unique: false });
bookingsStore.createIndex('createdAt', 'createdAt', { unique: false });
}
// Create customers store (v2)
if (!db.objectStoreNames.contains(IndexedDBService.CUSTOMERS_STORE)) {
const customersStore = db.createObjectStore(IndexedDBService.CUSTOMERS_STORE, { keyPath: 'id' });
customersStore.createIndex('name', 'name', { unique: false });
customersStore.createIndex('phone', 'phone', { unique: false });
}
// Create resources store (v2)
if (!db.objectStoreNames.contains(IndexedDBService.RESOURCES_STORE)) {
const resourcesStore = db.createObjectStore(IndexedDBService.RESOURCES_STORE, { keyPath: 'id' });
resourcesStore.createIndex('type', 'type', { unique: false });
resourcesStore.createIndex('isActive', 'isActive', { unique: false });
}
};
});
}
@ -134,92 +100,10 @@ export class IndexedDBService {
}
// ========================================
// Event CRUD Operations
// Event CRUD Operations - MOVED TO EventService
// ========================================
/**
* Get a single event by ID
*/
async getEvent(id: string): Promise<ICalendarEvent | null> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBService.EVENTS_STORE);
const request = store.get(id);
request.onsuccess = () => {
const event = request.result as ICalendarEvent | undefined;
resolve(event ? this.deserializeEvent(event) : null);
};
request.onerror = () => {
reject(new Error(`Failed to get event ${id}: ${request.error}`));
};
});
}
/**
* Get all events
*/
async getAllEvents(): Promise<ICalendarEvent[]> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBService.EVENTS_STORE);
const request = store.getAll();
request.onsuccess = () => {
const events = request.result as ICalendarEvent[];
resolve(events.map(e => this.deserializeEvent(e)));
};
request.onerror = () => {
reject(new Error(`Failed to get all events: ${request.error}`));
};
});
}
/**
* Save an event (create or update)
*/
async saveEvent(event: ICalendarEvent): Promise<void> {
const db = this.ensureDB();
const serialized = this.serializeEvent(event);
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.EVENTS_STORE);
const request = store.put(serialized);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save event ${event.id}: ${request.error}`));
};
});
}
/**
* Delete an event
*/
async deleteEvent(id: string): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.EVENTS_STORE);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete event ${id}: ${request.error}`));
};
});
}
// Event operations have been moved to storage/events/EventService.ts
// for better modularity and separation of concerns.
// ========================================
// Queue Operations
@ -356,52 +240,6 @@ export class IndexedDBService {
});
}
// ========================================
// Serialization Helpers
// ========================================
/**
* Serialize event for IndexedDB storage (convert Dates to ISO strings)
*/
private serializeEvent(event: ICalendarEvent): any {
return {
...event,
start: event.start instanceof Date ? event.start.toISOString() : event.start,
end: event.end instanceof Date ? event.end.toISOString() : event.end
};
}
/**
* Deserialize event from IndexedDB (convert ISO strings to Dates)
*/
private deserializeEvent(event: any): ICalendarEvent {
return {
...event,
start: typeof event.start === 'string' ? new Date(event.start) : event.start,
end: typeof event.end === 'string' ? new Date(event.end) : event.end
};
}
/**
* Serialize booking for IndexedDB storage (convert Dates to ISO strings)
*/
private serializeBooking(booking: IBooking): any {
return {
...booking,
createdAt: booking.createdAt instanceof Date ? booking.createdAt.toISOString() : booking.createdAt
};
}
/**
* Deserialize booking from IndexedDB (convert ISO strings to Dates)
*/
private deserializeBooking(booking: any): IBooking {
return {
...booking,
createdAt: typeof booking.createdAt === 'string' ? new Date(booking.createdAt) : booking.createdAt
};
}
/**
* Close database connection
*/
@ -429,50 +267,10 @@ export class IndexedDBService {
});
}
/**
* Seed IndexedDB with mock data if empty
*/
async seedIfEmpty(mockDataUrl: string = 'data/mock-events.json'): Promise<void> {
try {
const existingEvents = await this.getAllEvents();
if (existingEvents.length > 0) {
console.log(`IndexedDB already has ${existingEvents.length} events - skipping seed`);
return;
}
console.log('IndexedDB is empty - seeding with mock data');
// Check if online to fetch mock data
if (!navigator.onLine) {
console.warn('Offline and IndexedDB empty - starting with no events');
return;
}
// Fetch mock events
const response = await fetch(mockDataUrl);
if (!response.ok) {
throw new Error(`Failed to fetch mock events: ${response.statusText}`);
}
const mockEvents = await response.json();
// Convert and save to IndexedDB
for (const event of mockEvents) {
const calendarEvent = {
...event,
start: new Date(event.start),
end: new Date(event.end),
allDay: event.allDay || false,
syncStatus: 'synced' as const
};
await this.saveEvent(calendarEvent);
}
console.log(`Seeded IndexedDB with ${mockEvents.length} mock events`);
} catch (error) {
console.error('Failed to seed IndexedDB:', error);
// Don't throw - allow app to start with empty calendar
}
}
// ========================================
// Seeding - REMOVED
// ========================================
// seedIfEmpty() has been removed.
// Seeding should be implemented at application level using EventService,
// BookingService, CustomerService, and ResourceService directly.
}

View file

@ -0,0 +1,42 @@
import { IBooking } from '../../types/BookingTypes';
/**
* BookingSerialization - Handles Date field serialization for IndexedDB
*
* IndexedDB doesn't store Date objects directly, so we convert:
* - Date ISO string (serialize) when writing to IndexedDB
* - ISO string Date (deserialize) when reading from IndexedDB
*/
export class BookingSerialization {
/**
* Serialize booking for IndexedDB storage
* Converts Date fields to ISO strings
*
* @param booking - IBooking with Date objects
* @returns Plain object with ISO string dates
*/
static serialize(booking: IBooking): any {
return {
...booking,
createdAt: booking.createdAt instanceof Date
? booking.createdAt.toISOString()
: booking.createdAt
};
}
/**
* Deserialize booking from IndexedDB storage
* Converts ISO string dates back to Date objects
*
* @param data - Plain object from IndexedDB with ISO string dates
* @returns IBooking with Date objects
*/
static deserialize(data: any): IBooking {
return {
...data,
createdAt: typeof data.createdAt === 'string'
? new Date(data.createdAt)
: data.createdAt
};
}
}

View file

@ -0,0 +1,164 @@
import { IBooking } from '../../types/BookingTypes';
import { BookingStore } from './BookingStore';
import { BookingSerialization } from './BookingSerialization';
/**
* 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.
*/
export class BookingService {
private db: IDBDatabase;
/**
* @param db - IDBDatabase instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
}
/**
* Get a single booking by ID
*
* @param id - Booking ID
* @returns IBooking or null if not found
*/
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}`));
};
});
}
/**
* Get bookings by customer ID
*
* @param customerId - Customer ID
* @returns Array of bookings for this customer
*/
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 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));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings for customer ${customerId}: ${request.error}`));
};
});
}
/**
* Get bookings by status
*
* @param status - Booking status
* @returns Array of bookings with this status
*/
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 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));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings with status ${status}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,35 @@
import { IStore } from '../IStore';
/**
* BookingStore - IndexedDB ObjectStore definition for bookings
*
* Defines schema, indexes, and store creation logic.
* Part of modular storage architecture where each entity has its own folder.
*/
export class BookingStore implements IStore {
/** ObjectStore name in IndexedDB (static for backward compatibility) */
static readonly STORE_NAME = 'bookings';
/** ObjectStore name in IndexedDB (instance property for IStore interface) */
readonly storeName = BookingStore.STORE_NAME;
/**
* Create the bookings ObjectStore with indexes
* Called during database upgrade (onupgradeneeded)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
// Create ObjectStore with 'id' as keyPath
const store = db.createObjectStore(BookingStore.STORE_NAME, { keyPath: 'id' });
// Index: customerId (for querying bookings by customer)
store.createIndex('customerId', 'customerId', { unique: false });
// Index: status (for filtering by booking status)
store.createIndex('status', 'status', { unique: false });
// Index: createdAt (for sorting bookings chronologically)
store.createIndex('createdAt', 'createdAt', { unique: false });
}
}

View file

@ -0,0 +1,144 @@
import { ICustomer } from '../../types/CustomerTypes';
import { CustomerStore } from './CustomerStore';
/**
* 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.
*
* Note: No serialization needed - ICustomer has no Date fields.
*/
export class CustomerService {
private db: IDBDatabase;
/**
* @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}`));
};
});
}
/**
* Get customers by phone number
*
* @param phone - Phone number
* @returns Array of customers with this phone
*/
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 index = store.index('phone');
const request = index.getAll(phone);
request.onsuccess = () => {
resolve(request.result as ICustomer[]);
};
request.onerror = () => {
reject(new Error(`Failed to get customers by phone ${phone}: ${request.error}`));
};
});
}
/**
* Search customers by name (partial match)
*
* @param searchTerm - Search term (case insensitive)
* @returns Array of customers matching search
*/
async searchByName(searchTerm: string): Promise<ICustomer[]> {
const allCustomers = await this.getAll();
const lowerSearch = searchTerm.toLowerCase();
return allCustomers.filter(customer =>
customer.name.toLowerCase().includes(lowerSearch)
);
}
}

View file

@ -0,0 +1,32 @@
import { IStore } from '../IStore';
/**
* CustomerStore - IndexedDB ObjectStore definition for customers
*
* Defines schema, indexes, and store creation logic.
* Part of modular storage architecture where each entity has its own folder.
*/
export class CustomerStore implements IStore {
/** ObjectStore name in IndexedDB (static for backward compatibility) */
static readonly STORE_NAME = 'customers';
/** ObjectStore name in IndexedDB (instance property for IStore interface) */
readonly storeName = CustomerStore.STORE_NAME;
/**
* Create the customers ObjectStore with indexes
* Called during database upgrade (onupgradeneeded)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
// Create ObjectStore with 'id' as keyPath
const store = db.createObjectStore(CustomerStore.STORE_NAME, { keyPath: 'id' });
// Index: name (for customer search/lookup)
store.createIndex('name', 'name', { unique: false });
// Index: phone (for customer lookup by phone)
store.createIndex('phone', 'phone', { unique: false });
}
}

View file

@ -0,0 +1,40 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* EventSerialization - Handles Date field serialization for IndexedDB
*
* IndexedDB doesn't store Date objects directly, so we convert:
* - Date ISO string (serialize) when writing to IndexedDB
* - ISO string Date (deserialize) when reading from IndexedDB
*/
export class EventSerialization {
/**
* Serialize event for IndexedDB storage
* Converts Date fields to ISO strings
*
* @param event - ICalendarEvent with Date objects
* @returns Plain object with ISO string dates
*/
static serialize(event: ICalendarEvent): any {
return {
...event,
start: event.start instanceof Date ? event.start.toISOString() : event.start,
end: event.end instanceof Date ? event.end.toISOString() : event.end
};
}
/**
* Deserialize event from IndexedDB storage
* Converts ISO string dates back to Date objects
*
* @param data - Plain object from IndexedDB with ISO string dates
* @returns ICalendarEvent with Date objects
*/
static deserialize(data: any): ICalendarEvent {
return {
...data,
start: typeof data.start === 'string' ? new Date(data.start) : data.start,
end: typeof data.end === 'string' ? new Date(data.end) : data.end
};
}
}

View file

@ -0,0 +1,264 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
import { EventStore } from './EventStore';
import { EventSerialization } from './EventSerialization';
/**
* 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.
*/
export class EventService {
private db: IDBDatabase;
/**
* @param db - IDBDatabase instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
}
/**
* Get a single event by ID
*
* @param id - Event ID
* @returns ICalendarEvent or null if not found
*/
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}`));
};
});
}
/**
* Get events within a date range
* Uses start index + in-memory filtering for simplicity and performance
*
* @param start - Start date (inclusive)
* @param end - End date (inclusive)
* @returns Array of events in range
*/
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 index = store.index('start');
// Get all events starting from start date
const range = IDBKeyRange.lowerBound(start.toISOString());
const request = index.getAll(range);
request.onsuccess = () => {
const data = request.result as any[];
// Deserialize and filter in memory
const events = data
.map(item => EventSerialization.deserialize(item))
.filter(event => event.start <= end);
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events by date range: ${request.error}`));
};
});
}
/**
* Get events for a specific resource
*
* @param resourceId - Resource ID
* @returns Array of events for this resource
*/
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 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));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for resource ${resourceId}: ${request.error}`));
};
});
}
/**
* Get events for a specific customer
*
* @param customerId - Customer ID
* @returns Array of events for this customer
*/
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 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));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for customer ${customerId}: ${request.error}`));
};
});
}
/**
* Get events for a specific booking
*
* @param bookingId - Booking ID
* @returns Array of events for this booking
*/
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 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));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for booking ${bookingId}: ${request.error}`));
};
});
}
/**
* 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
*
* @param resourceId - Resource ID
* @param start - Start date
* @param end - End date
* @returns Array of events for this resource in range
*/
async getByResourceAndDateRange(resourceId: string, start: Date, end: Date): Promise<ICalendarEvent[]> {
// Get events for resource, then filter by date in memory
const resourceEvents = await this.getByResource(resourceId);
return resourceEvents.filter(event => event.start >= start && event.start <= end);
}
}

View file

@ -0,0 +1,47 @@
import { IStore } from '../IStore';
/**
* EventStore - IndexedDB ObjectStore definition for calendar events
*
* Defines schema, indexes, and store creation logic.
* Part of modular storage architecture where each entity has its own folder.
*/
export class EventStore implements IStore {
/** ObjectStore name in IndexedDB (static for backward compatibility) */
static readonly STORE_NAME = 'events';
/** ObjectStore name in IndexedDB (instance property for IStore interface) */
readonly storeName = EventStore.STORE_NAME;
/**
* Create the events ObjectStore with indexes
* Called during database upgrade (onupgradeneeded)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
// Create ObjectStore with 'id' as keyPath
const store = db.createObjectStore(EventStore.STORE_NAME, { keyPath: 'id' });
// Index: start (for date range queries)
store.createIndex('start', 'start', { unique: false });
// Index: end (for date range queries)
store.createIndex('end', 'end', { unique: false });
// Index: syncStatus (for filtering by sync state)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
// Index: resourceId (CRITICAL for resource-mode filtering)
store.createIndex('resourceId', 'resourceId', { unique: false });
// Index: customerId (for customer-centric queries)
store.createIndex('customerId', 'customerId', { unique: false });
// Index: bookingId (for event-to-booking lookups)
store.createIndex('bookingId', 'bookingId', { unique: false });
// Compound index: startEnd (for optimized range queries)
store.createIndex('startEnd', ['start', 'end'], { unique: false });
}
}

View file

@ -0,0 +1,173 @@
import { IResource } from '../../types/ResourceTypes';
import { ResourceStore } from './ResourceStore';
/**
* 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.
*
* Note: No serialization needed - IResource has no Date fields.
*/
export class ResourceService {
private db: IDBDatabase;
/**
* @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}`));
};
});
}
/**
* Get resources by type
*
* @param type - Resource type (person, room, equipment, etc.)
* @returns Array of resources of this type
*/
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 index = store.index('type');
const request = index.getAll(type);
request.onsuccess = () => {
resolve(request.result as IResource[]);
};
request.onerror = () => {
reject(new Error(`Failed to get resources by type ${type}: ${request.error}`));
};
});
}
/**
* Get active resources only
*
* @returns Array of active resources (isActive = true)
*/
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 index = store.index('isActive');
const request = index.getAll(true);
request.onsuccess = () => {
resolve(request.result as IResource[]);
};
request.onerror = () => {
reject(new Error(`Failed to get active resources: ${request.error}`));
};
});
}
/**
* Get inactive resources
*
* @returns Array of inactive resources (isActive = false)
*/
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 index = store.index('isActive');
const request = index.getAll(false);
request.onsuccess = () => {
resolve(request.result as IResource[]);
};
request.onerror = () => {
reject(new Error(`Failed to get inactive resources: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,32 @@
import { IStore } from '../IStore';
/**
* ResourceStore - IndexedDB ObjectStore definition for resources
*
* Defines schema, indexes, and store creation logic.
* Part of modular storage architecture where each entity has its own folder.
*/
export class ResourceStore implements IStore {
/** ObjectStore name in IndexedDB (static for backward compatibility) */
static readonly STORE_NAME = 'resources';
/** ObjectStore name in IndexedDB (instance property for IStore interface) */
readonly storeName = ResourceStore.STORE_NAME;
/**
* Create the resources ObjectStore with indexes
* Called during database upgrade (onupgradeneeded)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
// Create ObjectStore with 'id' as keyPath
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
// Index: type (for filtering by resource category)
store.createIndex('type', 'type', { unique: false });
// Index: isActive (for showing/hiding inactive resources)
store.createIndex('isActive', 'isActive', { unique: false });
}
}