diff --git a/src/index.ts b/src/index.ts index 8d88d2b..04595de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { // Register configuration instance builder.registerInstance(config).as(); + // Register storage stores (IStore implementations) + // Open/Closed Principle: Adding new entity only requires adding one line here + builder.registerType(BookingStore).as(); + builder.registerType(CustomerStore).as(); + builder.registerType(ResourceStore).as(); + builder.registerType(EventStore).as(); + + // Resolve all IStore implementations and register as array + const stores = container.resolveTypeAll(); + builder.registerInstance(stores).as(); + // Register storage and repository services builder.registerType(IndexedDBService).as(); builder.registerType(OperationQueue).as(); diff --git a/src/storage/IStore.ts b/src/storage/IStore.ts new file mode 100644 index 0000000..d4ed3e7 --- /dev/null +++ b/src/storage/IStore.ts @@ -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; +} diff --git a/src/storage/IndexedDBService.ts b/src/storage/IndexedDBService.ts index 6a9546f..91a5d41 100644 --- a/src/storage/IndexedDBService.ts +++ b/src/storage/IndexedDBService.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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. } diff --git a/src/storage/bookings/BookingSerialization.ts b/src/storage/bookings/BookingSerialization.ts new file mode 100644 index 0000000..20f4828 --- /dev/null +++ b/src/storage/bookings/BookingSerialization.ts @@ -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 + }; + } +} diff --git a/src/storage/bookings/BookingService.ts b/src/storage/bookings/BookingService.ts new file mode 100644 index 0000000..e2ed600 --- /dev/null +++ b/src/storage/bookings/BookingService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`)); + }; + }); + } +} diff --git a/src/storage/bookings/BookingStore.ts b/src/storage/bookings/BookingStore.ts new file mode 100644 index 0000000..5c735af --- /dev/null +++ b/src/storage/bookings/BookingStore.ts @@ -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 }); + } +} diff --git a/src/storage/customers/CustomerService.ts b/src/storage/customers/CustomerService.ts new file mode 100644 index 0000000..39bdee4 --- /dev/null +++ b/src/storage/customers/CustomerService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const allCustomers = await this.getAll(); + const lowerSearch = searchTerm.toLowerCase(); + + return allCustomers.filter(customer => + customer.name.toLowerCase().includes(lowerSearch) + ); + } +} diff --git a/src/storage/customers/CustomerStore.ts b/src/storage/customers/CustomerStore.ts new file mode 100644 index 0000000..22420f5 --- /dev/null +++ b/src/storage/customers/CustomerStore.ts @@ -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 }); + } +} diff --git a/src/storage/events/EventSerialization.ts b/src/storage/events/EventSerialization.ts new file mode 100644 index 0000000..61b64d0 --- /dev/null +++ b/src/storage/events/EventSerialization.ts @@ -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 + }; + } +} diff --git a/src/storage/events/EventService.ts b/src/storage/events/EventService.ts new file mode 100644 index 0000000..ac3452f --- /dev/null +++ b/src/storage/events/EventService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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); + } +} diff --git a/src/storage/events/EventStore.ts b/src/storage/events/EventStore.ts new file mode 100644 index 0000000..a399a1c --- /dev/null +++ b/src/storage/events/EventStore.ts @@ -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 }); + } +} diff --git a/src/storage/resources/ResourceService.ts b/src/storage/resources/ResourceService.ts new file mode 100644 index 0000000..e6fd7fe --- /dev/null +++ b/src/storage/resources/ResourceService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`)); + }; + }); + } +} diff --git a/src/storage/resources/ResourceStore.ts b/src/storage/resources/ResourceStore.ts new file mode 100644 index 0000000..5110f67 --- /dev/null +++ b/src/storage/resources/ResourceStore.ts @@ -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 }); + } +}