diff --git a/bun.lock b/bun.lock index 06daca5..7b10398 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "astro-expressive-code": "^0.40.2", "astro-icon": "^1.1.5", "chart.js": "^4.4.7", + "dexie": "^4.0.11", "framer-motion": "^12.4.4", "highlight.js": "^11.11.1", "js-yaml": "^4.1.0", @@ -582,6 +583,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "dexie": ["dexie@4.0.11", "", {}, "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="], diff --git a/package.json b/package.json index b7ec99b..22baf45 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "astro-expressive-code": "^0.40.2", "astro-icon": "^1.1.5", "chart.js": "^4.4.7", + "dexie": "^4.0.11", "framer-motion": "^12.4.4", "highlight.js": "^11.11.1", "js-yaml": "^4.1.0", diff --git a/src/components/dashboard/EventsSection/EventCheckIn.tsx b/src/components/dashboard/EventsSection/EventCheckIn.tsx index 23dafd9..1287283 100644 --- a/src/components/dashboard/EventsSection/EventCheckIn.tsx +++ b/src/components/dashboard/EventsSection/EventCheckIn.tsx @@ -3,6 +3,8 @@ import { Get } from "../../../scripts/pocketbase/Get"; import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { Update } from "../../../scripts/pocketbase/Update"; import { SendLog } from "../../../scripts/pocketbase/SendLog"; +import { DataSyncService } from "../../../scripts/database/DataSyncService"; +import { Collections } from "../../../schemas/pocketbase/schema"; import { Icon } from "@iconify/react"; import type { Event, AttendeeEntry } from "../../../schemas/pocketbase"; @@ -95,6 +97,7 @@ const EventCheckIn = () => { try { const get = Get.getInstance(); const auth = Authentication.getInstance(); + const dataSync = DataSyncService.getInstance(); const currentUser = auth.getCurrentUser(); if (!currentUser) { @@ -102,11 +105,19 @@ const EventCheckIn = () => { return; } - // Find the event with the given code - const event = await get.getFirst( - "events", + // Find the event with the given code using IndexedDB + // Force sync to ensure we have the latest data + await dataSync.syncCollection(Collections.EVENTS, `event_code = "${eventCode}"`); + + // Get the event from IndexedDB + const events = await dataSync.getData( + Collections.EVENTS, + false, // Don't force sync again `event_code = "${eventCode}"` ); + + const event = events.length > 0 ? events[0] : null; + if (!event) { throw new Error("Invalid event code"); } @@ -149,6 +160,7 @@ const EventCheckIn = () => { const auth = Authentication.getInstance(); const update = Update.getInstance(); const logger = SendLog.getInstance(); + const dataSync = DataSyncService.getInstance(); const currentUser = auth.getCurrentUser(); if (!currentUser) { @@ -197,6 +209,9 @@ const EventCheckIn = () => { // Update attendees array with the new entry await update.updateField("events", event.id, "attendees", updatedAttendees); + // Force sync the events collection to update IndexedDB + await dataSync.syncCollection(Collections.EVENTS); + // If food selection was made, log it if (foodSelection) { await logger.send( @@ -216,6 +231,9 @@ const EventCheckIn = () => { userPoints + event.points_to_reward ); + // Force sync the users collection to update IndexedDB + await dataSync.syncCollection(Collections.USERS); + // Log the points award await logger.send( "update", diff --git a/src/components/dashboard/EventsSection/EventLoad.tsx b/src/components/dashboard/EventsSection/EventLoad.tsx index 5b009dc..96cebe6 100644 --- a/src/components/dashboard/EventsSection/EventLoad.tsx +++ b/src/components/dashboard/EventsSection/EventLoad.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; import { Icon } from "@iconify/react"; import { Get } from "../../../scripts/pocketbase/Get"; import { Authentication } from "../../../scripts/pocketbase/Authentication"; +import { DataSyncService } from "../../../scripts/database/DataSyncService"; +import { Collections } from "../../../schemas/pocketbase/schema"; import type { Event, AttendeeEntry } from "../../../schemas/pocketbase"; // Extended Event interface with additional properties needed for this component @@ -139,14 +141,17 @@ const EventLoad = () => { const loadEvents = async () => { try { const get = Get.getInstance(); - const allEvents = await get.getAll( - "events", + const dataSync = DataSyncService.getInstance(); + + // Force sync to ensure we have the latest data + await dataSync.syncCollection(Collections.EVENTS, "published = true", "-start_date"); + + // Get events from IndexedDB + const allEvents = await dataSync.getData( + Collections.EVENTS, + false, // Don't force sync again "published = true", - "-start_date", - { - fields: ["*"], - disableAutoCancellation: true - } + "-start_date" ); // Split events into upcoming, ongoing, and past based on start and end dates diff --git a/src/components/dashboard/Officer_EventManagement/Attendees.tsx b/src/components/dashboard/Officer_EventManagement/Attendees.tsx index 9ea30c9..a589b3f 100644 --- a/src/components/dashboard/Officer_EventManagement/Attendees.tsx +++ b/src/components/dashboard/Officer_EventManagement/Attendees.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useMemo, useCallback } from 'react'; import { Get } from '../../../scripts/pocketbase/Get'; import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { SendLog } from '../../../scripts/pocketbase/SendLog'; +import { DataSyncService } from '../../../scripts/database/DataSyncService'; +import { Collections } from '../../../schemas/pocketbase/schema'; import { Icon } from "@iconify/react"; import type { Event, AttendeeEntry, User as SchemaUser } from "../../../schemas/pocketbase"; @@ -160,10 +162,25 @@ export default function Attendees() { // Fetch uncached users try { - const users = await get.getMany('users', uncachedIds, { - fields: USER_FIELDS, - disableAutoCancellation: false - }); + const dataSync = DataSyncService.getInstance(); + + // Sync users collection for the uncached IDs + if (uncachedIds.length > 0) { + const idFilter = uncachedIds.map(id => `id = "${id}"`).join(' || '); + await dataSync.syncCollection(Collections.USERS, idFilter); + } + + // Get users from IndexedDB + const users = await Promise.all( + uncachedIds.map(async id => { + try { + return await dataSync.getItem(Collections.USERS, id); + } catch (error) { + console.error(`Failed to fetch user ${id}:`, error); + return null; + } + }) + ); // Update cache and merge with cached users users.forEach(user => { @@ -177,7 +194,7 @@ export default function Attendees() { } return cachedUsers; - }, [get]); + }, []); // Listen for the custom event useEffect(() => { @@ -226,13 +243,23 @@ export default function Attendees() { setLoading(true); setError(null); - const event = await get.getOne('events', eventId, { - fields: EVENT_FIELDS, - disableAutoCancellation: false - }); + const dataSync = DataSyncService.getInstance(); + + // Sync the event data + await dataSync.syncCollection(Collections.EVENTS, `id = "${eventId}"`); + + // Get the event from IndexedDB + const event = await dataSync.getItem(Collections.EVENTS, eventId); if (!isMounted) return; + if (!event) { + setError('Event not found'); + setAttendeesList([]); + setUsers(new Map()); + return; + } + if (!event.attendees?.length) { setAttendeesList([]); setUsers(new Map()); @@ -263,7 +290,7 @@ export default function Attendees() { fetchEventData(); return () => { isMounted = false; }; - }, [eventId, auth, get, fetchUserData]); + }, [eventId, auth, fetchUserData]); // Reset state when modal is closed useEffect(() => { diff --git a/src/components/dashboard/Officer_EventManagement/EventEditor.tsx b/src/components/dashboard/Officer_EventManagement/EventEditor.tsx index 6f55899..3e5eb58 100644 --- a/src/components/dashboard/Officer_EventManagement/EventEditor.tsx +++ b/src/components/dashboard/Officer_EventManagement/EventEditor.tsx @@ -7,6 +7,8 @@ import { FileManager } from "../../../scripts/pocketbase/FileManager"; import { SendLog } from "../../../scripts/pocketbase/SendLog"; import FilePreview from "../universal/FilePreview"; import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase"; +import { DataSyncService } from '../../../scripts/database/DataSyncService'; +import { Collections } from '../../../schemas/pocketbase/schema'; // Note: Date conversion is now handled automatically by the Get and Update classes. // When fetching events, UTC dates are converted to local time by the Get class. @@ -714,6 +716,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { await pb.collection("events").update(event.id, { files: remainingFiles }); + + // Sync the events collection to update IndexedDB + const dataSync = DataSyncService.getInstance(); + await dataSync.syncCollection(Collections.EVENTS); } // Handle file additions @@ -725,6 +731,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { // Use appendFiles to preserve existing files await services.fileManager.appendFiles("events", event.id, "files", filesToUpload); + + // Sync the events collection to update IndexedDB + const dataSync = DataSyncService.getInstance(); + await dataSync.syncCollection(Collections.EVENTS); } catch (error: any) { if (error.status === 413) { throw new Error("Files are too large. Please try uploading smaller files or fewer files at once."); @@ -738,6 +748,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { const newEvent = await pb.collection("events").create(eventData); console.log('New event created:', newEvent); + // Sync the events collection to update IndexedDB + const dataSync = DataSyncService.getInstance(); + await dataSync.syncCollection(Collections.EVENTS); + // Upload files if any if (selectedFiles.size > 0) { try { diff --git a/src/components/dashboard/Officer_EventRequestForm.astro b/src/components/dashboard/Officer_EventRequestForm.astro index 799d842..bf9ab8b 100644 --- a/src/components/dashboard/Officer_EventRequestForm.astro +++ b/src/components/dashboard/Officer_EventRequestForm.astro @@ -3,6 +3,7 @@ import { Authentication } from "../../scripts/pocketbase/Authentication"; import { Get } from "../../scripts/pocketbase/Get"; import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm"; import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests"; +import { Collections } from "../../schemas/pocketbase/schema"; // Import the EventRequest type from UserEventRequests to ensure consistency import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests"; @@ -19,141 +20,195 @@ let userEventRequests: EventRequest[] = []; let error: string | null = null; // Fetch user's event request submissions if authenticated +// This provides initial data for server-side rendering +// Client-side will use IndexedDB for data management if (auth.isAuthenticated()) { - try { - const userId = auth.getUserId(); - if (userId) { - userEventRequests = await get.getAll( - "event_request", - `requested_user="${userId}"`, - "-created", - ); + try { + const userId = auth.getUserId(); + if (userId) { + userEventRequests = await get.getAll( + Collections.EVENT_REQUESTS, + `requested_user="${userId}"`, + "-created" + ); + } + } catch (err) { + console.error("Failed to fetch user event requests:", err); + error = "Failed to load your event requests. Please try again later."; } - } catch (err) { - console.error("Failed to fetch user event requests:", err); - error = "Failed to load your event requests. Please try again later."; - } } ---
-
-

Event Request Form

-

- Submit your event request at least 6 weeks before your event. After - submitting, please notify PR and/or Coordinators in the #-events Slack - channel. -

-
-

This form includes sections for:

-
    -
  • PR Materials (if needed)
  • -
  • Event Details
  • -
  • TAP Form Information
  • -
  • AS Funding (if needed)
  • -
-

- Your progress is automatically saved as you fill out the form. -

+
+

Event Request Form

+

+ Submit your event request at least 6 weeks before your event. After + submitting, please notify PR and/or Coordinators in the #-events + Slack channel. +

+
+

This form includes sections for:

+
    +
  • PR Materials (if needed)
  • +
  • Event Details
  • +
  • TAP Form Information
  • +
  • AS Funding (if needed)
  • +
+

+ Your progress is automatically saved as you fill out the form. +

+
-
- - - - -
- - - diff --git a/src/components/dashboard/Officer_EventRequestForm/EventRequestForm.tsx b/src/components/dashboard/Officer_EventRequestForm/EventRequestForm.tsx index 2e990b3..5247084 100644 --- a/src/components/dashboard/Officer_EventRequestForm/EventRequestForm.tsx +++ b/src/components/dashboard/Officer_EventRequestForm/EventRequestForm.tsx @@ -5,6 +5,8 @@ import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Update } from '../../../scripts/pocketbase/Update'; import { FileManager } from '../../../scripts/pocketbase/FileManager'; import { Get } from '../../../scripts/pocketbase/Get'; +import { DataSyncService } from '../../../scripts/database/DataSyncService'; +import { Collections } from '../../../schemas/pocketbase/schema'; import type { EventRequest } from '../../../schemas/pocketbase'; import { EventRequestStatus } from '../../../schemas/pocketbase'; @@ -191,6 +193,7 @@ const EventRequestForm: React.FC = () => { const auth = Authentication.getInstance(); const update = Update.getInstance(); const fileManager = FileManager.getInstance(); + const dataSync = DataSyncService.getInstance(); if (!auth.isAuthenticated()) { toast.error('You must be logged in to submit an event request', { id: submittingToast }); @@ -245,9 +248,13 @@ const EventRequestForm: React.FC = () => { toast.loading('Creating event request record...', { id: submittingToast }); try { - // Create the record + // Create the record using the Update service + // This will send the data to the server const record = await update.create('event_request', submissionData); + // Force sync the event requests collection to update IndexedDB + await dataSync.syncCollection(Collections.EVENT_REQUESTS); + // Upload files if they exist if (formData.other_logos.length > 0) { toast.loading('Uploading logo files...', { id: submittingToast }); diff --git a/src/components/dashboard/Officer_EventRequestForm/UserEventRequests.tsx b/src/components/dashboard/Officer_EventRequestForm/UserEventRequests.tsx index 7a3b13f..4469543 100644 --- a/src/components/dashboard/Officer_EventRequestForm/UserEventRequests.tsx +++ b/src/components/dashboard/Officer_EventRequestForm/UserEventRequests.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Get } from '../../../scripts/pocketbase/Get'; import { Authentication } from '../../../scripts/pocketbase/Authentication'; +import { DataSyncService } from '../../../scripts/database/DataSyncService'; +import { Collections } from '../../../schemas/pocketbase/schema'; import toast from 'react-hot-toast'; import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase'; @@ -20,6 +22,7 @@ const UserEventRequests: React.FC = ({ eventRequests: in const [isModalOpen, setIsModalOpen] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [viewMode, setViewMode] = useState<'table' | 'cards'>('table'); + const dataSync = DataSyncService.getInstance(); // Refresh event requests const refreshEventRequests = async () => { @@ -27,7 +30,6 @@ const UserEventRequests: React.FC = ({ eventRequests: in const refreshToast = toast.loading('Refreshing submissions...'); try { - const get = Get.getInstance(); const auth = Authentication.getInstance(); if (!auth.isAuthenticated()) { @@ -41,8 +43,10 @@ const UserEventRequests: React.FC = ({ eventRequests: in return; } - const updatedRequests = await get.getAll( - 'event_request', + // Use DataSyncService to get data from IndexedDB with forced sync + const updatedRequests = await dataSync.getData( + Collections.EVENT_REQUESTS, + true, // Force sync `requested_user="${userId}"`, '-created' ); @@ -62,6 +66,22 @@ const UserEventRequests: React.FC = ({ eventRequests: in refreshEventRequests(); }, []); + // Listen for tab visibility changes and refresh data when tab becomes visible + useEffect(() => { + const handleTabVisible = () => { + console.log("Tab became visible, refreshing event requests..."); + refreshEventRequests(); + }; + + // Add event listener for custom dashboardTabVisible event + document.addEventListener("dashboardTabVisible", handleTabVisible); + + // Clean up event listener on component unmount + return () => { + document.removeEventListener("dashboardTabVisible", handleTabVisible); + }; + }, []); + // Format date for display const formatDate = (dateString: string) => { if (!dateString) return 'Not specified'; diff --git a/src/components/dashboard/Officer_EventRequestManagement.astro b/src/components/dashboard/Officer_EventRequestManagement.astro index 645221d..3a719e6 100644 --- a/src/components/dashboard/Officer_EventRequestManagement.astro +++ b/src/components/dashboard/Officer_EventRequestManagement.astro @@ -4,6 +4,7 @@ import { Get } from "../../scripts/pocketbase/Get"; import { Toaster } from "react-hot-toast"; import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable"; import type { EventRequest } from "../../schemas/pocketbase"; +import { Collections } from "../../schemas/pocketbase/schema"; // Get instances const get = Get.getInstance(); @@ -39,9 +40,14 @@ try { console.log("Fetching event requests in Astro component..."); // Expand the requested_user field to get user details allEventRequests = await get - .getAll("event_request", "", "-created", { - expand: ["requested_user"], - }) + .getAll( + Collections.EVENT_REQUESTS, + "", + "-created", + { + expand: ["requested_user"], + } + ) .catch((err) => { console.error("Error in get.getAll:", err); // Return empty array instead of throwing @@ -152,6 +158,10 @@ try {
- - -
- - - - -
- -
-
-
- -

IEEE UCSD

-
-
-
- - -
- -
-
-
-

Loading dashboard...

-
-
- - - - - - - - -
- { - Object.entries(dashboardConfig.sections).map( - ([sectionKey, section]: [string, any]) => { - // Skip if no component is defined - if (!section.component) return null; - - const Component = components[section.component]; - return ( - + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +

IEEE UCSD

+
+
+
+ + +
+ +
+
+
+
+

Loading dashboard...

+
+
+ + + + + + + + +
+ { + Object.entries(dashboardConfig.sections).map( + ([sectionKey, section]: [string, any]) => { + // Skip if no component is defined + if (!section.component) return null; + + const Component = + components[section.component]; + return ( + + ); + } + ) + } +
+
+
-
-
- + diff --git a/src/scripts/auth/RedirectHandler.ts b/src/scripts/auth/RedirectHandler.ts index 4c7e51a..a387e31 100644 --- a/src/scripts/auth/RedirectHandler.ts +++ b/src/scripts/auth/RedirectHandler.ts @@ -63,7 +63,7 @@ export class RedirectHandler { console.log("Auth successful:", authData); this.contentEl.innerHTML = `

Authentication Successful!

-

Redirecting to store...

+

Initializing your data...

@@ -75,11 +75,14 @@ export class RedirectHandler { last_login: new Date().toISOString(), }); + // Initialize data sync + await this.initializeDataSync(); + // Clean up and redirect localStorage.removeItem("provider"); window.location.href = "/dashboard"; } catch (err) { - console.error("Failed to update last login:", err); + console.error("Failed to update last login or sync data:", err); // Still redirect even if last_login update fails localStorage.removeItem("provider"); window.location.href = "/dashboard"; @@ -89,4 +92,27 @@ export class RedirectHandler { this.showError(`Failed to complete authentication: ${err.message}`); } } + + /** + * Initialize data synchronization after successful authentication + */ + private async initializeDataSync(): Promise { + try { + // Dynamically import the AuthSyncService to avoid circular dependencies + const { AuthSyncService } = await import('../database/AuthSyncService'); + + // Get the instance and trigger a full sync + const authSync = AuthSyncService.getInstance(); + const syncResult = await authSync.handleLogin(); + + if (syncResult) { + console.log('Initial data sync completed successfully'); + } else { + console.warn('Initial data sync completed with issues'); + } + } catch (error) { + console.error('Failed to initialize data sync:', error); + // Continue with login process even if sync fails + } + } } diff --git a/src/scripts/database/AuthSyncService.ts b/src/scripts/database/AuthSyncService.ts new file mode 100644 index 0000000..bba687b --- /dev/null +++ b/src/scripts/database/AuthSyncService.ts @@ -0,0 +1,284 @@ +import { Authentication } from '../pocketbase/Authentication'; +import { DataSyncService } from './DataSyncService'; +import { DexieService } from './DexieService'; +import { Collections } from '../../schemas/pocketbase/schema'; +import { SendLog } from '../pocketbase/SendLog'; + +/** + * Service to handle data synchronization during authentication flows + */ +export class AuthSyncService { + private static instance: AuthSyncService; + private auth: Authentication; + private dataSync: DataSyncService; + private dexieService: DexieService; + private logger: SendLog; + private isSyncing: boolean = false; + private syncErrors: Record = {}; + private syncQueue: string[] = []; + private syncPromise: Promise | null = null; + + // Collections to sync on login + private readonly collectionsToSync = [ + Collections.USERS, + Collections.EVENTS, + Collections.EVENT_REQUESTS, + Collections.LOGS, + Collections.OFFICERS, + Collections.REIMBURSEMENTS, + Collections.RECEIPTS, + Collections.SPONSORS + ]; + + private constructor() { + this.auth = Authentication.getInstance(); + this.dataSync = DataSyncService.getInstance(); + this.dexieService = DexieService.getInstance(); + this.logger = SendLog.getInstance(); + + // Listen for auth state changes + this.auth.onAuthStateChange(this.handleAuthStateChange.bind(this)); + } + + /** + * Get the singleton instance of AuthSyncService + */ + public static getInstance(): AuthSyncService { + if (!AuthSyncService.instance) { + AuthSyncService.instance = new AuthSyncService(); + } + return AuthSyncService.instance; + } + + /** + * Handle authentication state changes + */ + private async handleAuthStateChange(isAuthenticated: boolean): Promise { + if (isAuthenticated) { + // User just logged in + await this.handleLogin(); + } else { + // User just logged out + await this.handleLogout(); + } + } + + /** + * Handle login by syncing user data + */ + public async handleLogin(): Promise { + if (this.isSyncing) { + console.log('Sync already in progress, queueing login sync'); + if (this.syncPromise) { + this.syncPromise = this.syncPromise.then(() => this.performLoginSync()); + } else { + this.syncPromise = this.performLoginSync(); + } + return true; + } + + this.syncPromise = this.performLoginSync(); + return this.syncPromise.then(() => Object.keys(this.syncErrors).length === 0); + } + + /** + * Perform the actual login sync + */ + private async performLoginSync(): Promise { + if (!this.auth.isAuthenticated()) { + console.log('Not authenticated, skipping login sync'); + return; + } + + this.isSyncing = true; + this.syncErrors = {}; + + try { + console.log('Starting login sync process...'); + + // Display sync notification if in browser environment + this.showSyncNotification('Syncing your data...'); + + // Sync user-specific data first + const userId = this.auth.getUserId(); + if (userId) { + // First sync the current user's data + await this.dataSync.syncCollection(Collections.USERS, `id = "${userId}"`); + + // Log the sync operation + await this.logger.send('login', 'auth', 'User data synchronized on login'); + } + + // Sync all collections in parallel with conflict resolution + await Promise.all( + this.collectionsToSync.map(async (collection) => { + try { + await this.dataSync.syncCollection(collection); + console.log(`Successfully synced ${collection}`); + } catch (error) { + console.error(`Error syncing ${collection}:`, error); + this.syncErrors[collection] = error as Error; + } + }) + ); + + // Verify sync was successful + const syncVerification = await this.verifySyncSuccess(); + + if (syncVerification.success) { + console.log('Login sync completed successfully'); + this.showSyncNotification('Data sync complete!', 'success'); + } else { + console.warn('Login sync completed with issues:', syncVerification.errors); + this.showSyncNotification('Some data could not be synced', 'warning'); + } + + } catch (error) { + console.error('Error during login sync:', error); + this.showSyncNotification('Failed to sync data', 'error'); + } finally { + this.isSyncing = false; + + // Process any queued sync operations + if (this.syncQueue.length > 0) { + const nextSync = this.syncQueue.shift(); + if (nextSync === 'login') { + this.handleLogin(); + } else if (nextSync === 'logout') { + this.handleLogout(); + } + } + } + } + + /** + * Handle logout by clearing user data + */ + public async handleLogout(): Promise { + if (this.isSyncing) { + console.log('Sync already in progress, queueing logout cleanup'); + this.syncQueue.push('logout'); + return true; + } + + this.isSyncing = true; + + try { + console.log('Starting logout cleanup process...'); + + // Ensure any pending changes are synced before logout + await this.syncPendingChanges(); + + // Clear all data from IndexedDB + await this.dexieService.clearAllData(); + + console.log('Logout cleanup completed successfully'); + return true; + } catch (error) { + console.error('Error during logout cleanup:', error); + return false; + } finally { + this.isSyncing = false; + + // Process any queued sync operations + if (this.syncQueue.length > 0) { + const nextSync = this.syncQueue.shift(); + if (nextSync === 'login') { + this.handleLogin(); + } else if (nextSync === 'logout') { + this.handleLogout(); + } + } + } + } + + /** + * Sync any pending changes before logout + */ + private async syncPendingChanges(): Promise { + // This would be implemented if we had offline capabilities + // For now, we just log that we would sync pending changes + console.log('Checking for pending changes to sync before logout...'); + // In a real implementation, this would sync any offline changes + } + + /** + * Verify that sync was successful by checking data in IndexedDB + */ + private async verifySyncSuccess(): Promise<{ success: boolean; errors: Record }> { + const errors: Record = {}; + + // Check each collection that had errors + for (const [collection, error] of Object.entries(this.syncErrors)) { + errors[collection] = error.message; + } + + // Check if user data was synced properly + const userId = this.auth.getUserId(); + if (userId) { + try { + const user = await this.dataSync.getItem(Collections.USERS, userId, false); + if (!user) { + errors['user_verification'] = 'User data not found in IndexedDB after sync'; + } + } catch (error) { + errors['user_verification'] = `Error verifying user data: ${(error as Error).message}`; + } + } + + return { + success: Object.keys(errors).length === 0, + errors + }; + } + + /** + * Show a notification to the user about sync status + */ + private showSyncNotification(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info'): void { + // Only run in browser environment + if (typeof window === 'undefined') return; + + // Check if toast function exists (from react-hot-toast or similar) + if (typeof window.toast === 'function') { + window.toast(message, { type }); + } else { + // Fallback to console + console.log(`[${type.toUpperCase()}] ${message}`); + } + } + + /** + * Force a sync of all collections + */ + public async forceSyncAll(): Promise { + if (this.isSyncing) { + console.log('Sync already in progress, queueing full sync'); + this.syncQueue.push('login'); // Reuse login sync logic + return true; + } + + return this.handleLogin(); + } + + /** + * Check if a sync is currently in progress + */ + public isSyncInProgress(): boolean { + return this.isSyncing; + } + + /** + * Get any errors from the last sync operation + */ + public getSyncErrors(): Record { + return { ...this.syncErrors }; + } +} + +// Add toast type to window for TypeScript +declare global { + interface Window { + toast?: (message: string, options?: { type: 'info' | 'success' | 'warning' | 'error' }) => void; + } +} \ No newline at end of file diff --git a/src/scripts/database/DataSyncService.ts b/src/scripts/database/DataSyncService.ts new file mode 100644 index 0000000..71dcb6c --- /dev/null +++ b/src/scripts/database/DataSyncService.ts @@ -0,0 +1,531 @@ +import { DexieService } from './DexieService'; +import { Get } from '../pocketbase/Get'; +import { Update } from '../pocketbase/Update'; +import { Authentication } from '../pocketbase/Authentication'; +import { Collections, type BaseRecord } from '../../schemas/pocketbase/schema'; +import type Dexie from 'dexie'; + +// Interface for tracking offline changes +interface OfflineChange { + id: string; + collection: string; + recordId: string; + operation: 'create' | 'update' | 'delete'; + data?: any; + timestamp: number; + synced: boolean; + syncAttempts: number; +} + +export class DataSyncService { + private static instance: DataSyncService; + private dexieService: DexieService; + private get: Get; + private update: Update; + private auth: Authentication; + private syncInProgress: Record = {}; + private offlineMode: boolean = false; + private offlineChangesTable: Dexie.Table | null = null; + + private constructor() { + this.dexieService = DexieService.getInstance(); + this.get = Get.getInstance(); + this.update = Update.getInstance(); + this.auth = Authentication.getInstance(); + + // Initialize offline changes table + this.initOfflineChangesTable(); + + // Check for network status + if (typeof window !== 'undefined') { + window.addEventListener('online', this.handleOnline.bind(this)); + window.addEventListener('offline', this.handleOffline.bind(this)); + this.offlineMode = !navigator.onLine; + } + } + + public static getInstance(): DataSyncService { + if (!DataSyncService.instance) { + DataSyncService.instance = new DataSyncService(); + } + return DataSyncService.instance; + } + + /** + * Initialize the offline changes table + */ + private initOfflineChangesTable(): void { + try { + const db = this.dexieService.getDB(); + // Check if the table exists in the schema + if ('offlineChanges' in db) { + this.offlineChangesTable = db.offlineChanges as Dexie.Table; + } else { + console.warn('Offline changes table not found in schema'); + } + } catch (error) { + console.error('Error initializing offline changes table:', error); + } + } + + /** + * Handle device coming online + */ + private async handleOnline(): Promise { + console.log('Device is online, syncing pending changes...'); + this.offlineMode = false; + await this.syncOfflineChanges(); + } + + /** + * Handle device going offline + */ + private handleOffline(): void { + console.log('Device is offline, enabling offline mode...'); + this.offlineMode = true; + } + + /** + * Sync a specific collection from PocketBase to IndexedDB + */ + public async syncCollection( + collection: string, + filter: string = '', + sort: string = '-created', + expand: Record | string[] | string = {} + ): Promise { + // Prevent multiple syncs of the same collection at the same time + if (this.syncInProgress[collection]) { + console.log(`Sync already in progress for ${collection}`); + return []; + } + + this.syncInProgress[collection] = true; + + try { + // Check if we're authenticated + if (!this.auth.isAuthenticated()) { + console.log(`Not authenticated, skipping sync for ${collection}`); + return []; + } + + // Check if we're offline + if (this.offlineMode) { + console.log(`Device is offline, using cached data for ${collection}`); + const db = this.dexieService.getDB(); + const table = this.getTableForCollection(collection); + return table ? (await table.toArray() as T[]) : []; + } + + console.log(`Syncing ${collection}...`); + + // Normalize expand parameter to be an array of strings + let normalizedExpand: string[] | undefined; + + if (expand) { + if (typeof expand === 'string') { + // If expand is a string, convert it to an array + normalizedExpand = [expand]; + } else if (Array.isArray(expand)) { + // If expand is already an array, use it as is + normalizedExpand = expand; + } else if (typeof expand === 'object') { + // If expand is an object, extract the keys + normalizedExpand = Object.keys(expand); + } + } + + // Get data from PocketBase + const items = await this.get.getAll(collection, filter, sort, { + expand: normalizedExpand + }); + console.log(`Fetched ${items.length} items from ${collection}`); + + // Get the database table + const db = this.dexieService.getDB(); + const table = this.getTableForCollection(collection); + + if (!table) { + console.error(`No table found for collection ${collection}`); + return []; + } + + // Get existing items to handle conflicts + const existingItems = await table.toArray(); + const existingItemsMap = new Map(existingItems.map(item => [item.id, item])); + + // Handle conflicts and merge changes + const itemsToStore = await Promise.all(items.map(async (item) => { + const existingItem = existingItemsMap.get(item.id); + + if (existingItem) { + // Check for conflicts (local changes vs server changes) + const resolvedItem = await this.resolveConflict(collection, existingItem, item); + return resolvedItem; + } + + return item; + })); + + // Store in IndexedDB + await table.bulkPut(itemsToStore); + + // Update last sync timestamp + await this.dexieService.updateLastSync(collection); + + return itemsToStore as T[]; + } catch (error) { + console.error(`Error syncing ${collection}:`, error); + throw error; + } finally { + this.syncInProgress[collection] = false; + } + } + + /** + * Resolve conflicts between local and server data + */ + private async resolveConflict( + collection: string, + localItem: T, + serverItem: T + ): Promise { + // Check if there are pending offline changes for this item + const pendingChanges = await this.getPendingChangesForRecord(collection, localItem.id); + + if (pendingChanges.length > 0) { + console.log(`Found ${pendingChanges.length} pending changes for ${collection}:${localItem.id}`); + + // Server-wins strategy by default, but preserve local changes that haven't been synced + const mergedItem = { ...serverItem }; + + // Apply pending changes on top of server data + for (const change of pendingChanges) { + if (change.operation === 'update' && change.data) { + // Apply each field change individually + Object.entries(change.data).forEach(([key, value]) => { + (mergedItem as any)[key] = value; + }); + } + } + + return mergedItem; + } + + // No pending changes, use server data + return serverItem; + } + + /** + * Get pending changes for a specific record + */ + private async getPendingChangesForRecord(collection: string, recordId: string): Promise { + if (!this.offlineChangesTable) return []; + + try { + return await this.offlineChangesTable + .where('collection') + .equals(collection) + .and(item => item.recordId === recordId && !item.synced) + .toArray(); + } catch (error) { + console.error(`Error getting pending changes for ${collection}:${recordId}:`, error); + return []; + } + } + + /** + * Sync all pending offline changes + */ + public async syncOfflineChanges(): Promise { + if (!this.offlineChangesTable || this.offlineMode) return false; + + try { + // Get all unsynced changes + const pendingChanges = await this.offlineChangesTable + .where('synced') + .equals(0) // Use 0 instead of false for indexable type + .toArray(); + + if (pendingChanges.length === 0) { + console.log('No pending offline changes to sync'); + return true; + } + + console.log(`Syncing ${pendingChanges.length} offline changes...`); + + // Group changes by collection for more efficient processing + const changesByCollection = pendingChanges.reduce((groups, change) => { + const key = change.collection; + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(change); + return groups; + }, {} as Record); + + // Process each collection's changes + for (const [collection, changes] of Object.entries(changesByCollection)) { + // First sync the collection to get latest data + await this.syncCollection(collection); + + // Then apply each change + for (const change of changes) { + try { + if (change.operation === 'update' && change.data) { + await this.update.updateFields(collection, change.recordId, change.data); + + // Mark as synced + await this.offlineChangesTable.update(change.id, { + synced: true, + syncAttempts: change.syncAttempts + 1 + }); + } + // Add support for create and delete operations as needed + } catch (error) { + console.error(`Error syncing change ${change.id}:`, error); + + // Increment sync attempts + await this.offlineChangesTable.update(change.id, { + syncAttempts: change.syncAttempts + 1 + }); + } + } + + // Sync again to ensure we have the latest data + await this.syncCollection(collection); + } + + return true; + } catch (error) { + console.error('Error syncing offline changes:', error); + return false; + } + } + + /** + * Record an offline change + */ + public async recordOfflineChange( + collection: string, + recordId: string, + operation: 'create' | 'update' | 'delete', + data?: any + ): Promise { + if (!this.offlineChangesTable) return null; + + try { + const change: Omit = { + collection, + recordId, + operation, + data, + timestamp: Date.now(), + synced: false, + syncAttempts: 0 + }; + + const id = await this.offlineChangesTable.add(change as OfflineChange); + console.log(`Recorded offline change: ${operation} on ${collection}:${recordId}`); + + // Try to sync immediately if we're online + if (!this.offlineMode) { + this.syncOfflineChanges().catch(err => { + console.error('Error syncing after recording change:', err); + }); + } + + return id; + } catch (error) { + console.error(`Error recording offline change for ${collection}:${recordId}:`, error); + return null; + } + } + + /** + * Get data from IndexedDB, syncing from PocketBase if needed + */ + public async getData( + collection: string, + forceSync: boolean = false, + filter: string = '', + sort: string = '-created', + expand: Record | string[] | string = {} + ): Promise { + const db = this.dexieService.getDB(); + const table = this.getTableForCollection(collection); + + if (!table) { + console.error(`No table found for collection ${collection}`); + return []; + } + + // Check if we need to sync + const lastSync = await this.dexieService.getLastSync(collection); + const now = Date.now(); + const syncThreshold = 5 * 60 * 1000; // 5 minutes + + if (!this.offlineMode && (forceSync || (now - lastSync > syncThreshold))) { + try { + await this.syncCollection(collection, filter, sort, expand); + } catch (error) { + console.error(`Error syncing ${collection}, using cached data:`, error); + } + } + + // Get data from IndexedDB + let data = await table.toArray(); + + // Apply filter if provided + if (filter) { + // This is a simple implementation - in a real app, you'd want to parse the filter string + // and apply it properly. This is just a basic example. + data = data.filter((item: any) => { + // Split filter by logical operators + const conditions = filter.split(' && '); + return conditions.every(condition => { + // Parse condition (very basic implementation) + if (condition.includes('=')) { + const [field, value] = condition.split('='); + const cleanValue = value.replace(/"/g, ''); + return item[field] === cleanValue; + } + return true; + }); + }); + } + + // Apply sort if provided + if (sort) { + const isDesc = sort.startsWith('-'); + const field = isDesc ? sort.substring(1) : sort; + + data.sort((a: any, b: any) => { + if (a[field] < b[field]) return isDesc ? 1 : -1; + if (a[field] > b[field]) return isDesc ? -1 : 1; + return 0; + }); + } + + return data as T[]; + } + + /** + * Get a single item by ID + */ + public async getItem(collection: string, id: string, forceSync: boolean = false): Promise { + const db = this.dexieService.getDB(); + const table = this.getTableForCollection(collection); + + if (!table) { + console.error(`No table found for collection ${collection}`); + return undefined; + } + + // Try to get from IndexedDB first + let item = await table.get(id) as T | undefined; + + // If not found or force sync, try to get from PocketBase + if ((!item || forceSync) && !this.offlineMode) { + try { + const pbItem = await this.get.getOne(collection, id); + if (pbItem) { + await table.put(pbItem); + item = pbItem; + } + } catch (error) { + console.error(`Error fetching ${collection} item ${id}:`, error); + } + } + + return item; + } + + /** + * Update an item and handle offline changes + */ + public async updateItem( + collection: string, + id: string, + data: Partial + ): Promise { + const table = this.getTableForCollection(collection); + + if (!table) { + console.error(`No table found for collection ${collection}`); + return undefined; + } + + // Get the current item + const currentItem = await table.get(id) as T | undefined; + if (!currentItem) { + console.error(`Item ${id} not found in ${collection}`); + return undefined; + } + + // Update the item in IndexedDB + const updatedItem = { ...currentItem, ...data, updated: new Date().toISOString() }; + await table.put(updatedItem); + + // If offline, record the change for later sync + if (this.offlineMode) { + await this.recordOfflineChange(collection, id, 'update', data); + return updatedItem; + } + + // If online, update in PocketBase + try { + const result = await this.update.updateFields(collection, id, data); + return result as T; + } catch (error) { + console.error(`Error updating ${collection} item ${id}:`, error); + + // Record as offline change to retry later + await this.recordOfflineChange(collection, id, 'update', data); + return updatedItem; + } + } + + /** + * Clear all cached data + */ + public async clearCache(): Promise { + await this.dexieService.clearAllData(); + } + + /** + * Check if device is in offline mode + */ + public isOffline(): boolean { + return this.offlineMode; + } + + /** + * Get the appropriate Dexie table for a collection + */ + private getTableForCollection(collection: string): Dexie.Table | null { + const db = this.dexieService.getDB(); + + switch (collection) { + case Collections.USERS: + return db.users; + case Collections.EVENTS: + return db.events; + case Collections.EVENT_REQUESTS: + return db.eventRequests; + case Collections.LOGS: + return db.logs; + case Collections.OFFICERS: + return db.officers; + case Collections.REIMBURSEMENTS: + return db.reimbursements; + case Collections.RECEIPTS: + return db.receipts; + case Collections.SPONSORS: + return db.sponsors; + default: + console.error(`Unknown collection: ${collection}`); + return null; + } + } +} \ No newline at end of file diff --git a/src/scripts/database/DexieService.ts b/src/scripts/database/DexieService.ts new file mode 100644 index 0000000..00963e9 --- /dev/null +++ b/src/scripts/database/DexieService.ts @@ -0,0 +1,135 @@ +import Dexie from 'dexie'; +import type { + User, + Event, + EventRequest, + Log, + Officer, + Reimbursement, + Receipt, + Sponsor +} from '../../schemas/pocketbase/schema'; + +// Interface for tracking offline changes +interface OfflineChange { + id: string; + collection: string; + recordId: string; + operation: 'create' | 'update' | 'delete'; + data?: any; + timestamp: number; + synced: boolean; + syncAttempts: number; +} + +export class DashboardDatabase extends Dexie { + users!: Dexie.Table; + events!: Dexie.Table; + eventRequests!: Dexie.Table; + logs!: Dexie.Table; + officers!: Dexie.Table; + reimbursements!: Dexie.Table; + receipts!: Dexie.Table; + sponsors!: Dexie.Table; + offlineChanges!: Dexie.Table; + + // Store last sync timestamps + syncInfo!: Dexie.Table<{id: string, collection: string, lastSync: number}, string>; + + constructor() { + super('IEEEDashboardDB'); + + this.version(1).stores({ + users: 'id, email, name', + events: 'id, event_name, event_code, start_date, end_date, published', + eventRequests: 'id, name, status, requested_user, created, updated', + logs: 'id, user, type, created', + officers: 'id, user, role, type', + reimbursements: 'id, title, status, submitted_by, created', + receipts: 'id, created_by, date', + sponsors: 'id, user, company', + syncInfo: 'id, collection, lastSync' + }); + + // Add version 2 with offlineChanges table + this.version(2).stores({ + offlineChanges: 'id, collection, recordId, operation, timestamp, synced, syncAttempts' + }); + } + + // Initialize the database with default values + async initialize() { + const collections = [ + 'users', 'events', 'event_request', 'logs', + 'officers', 'reimbursement', 'receipts', 'sponsors' + ]; + + for (const collection of collections) { + const exists = await this.syncInfo.get(collection); + if (!exists) { + await this.syncInfo.put({ + id: collection, + collection, + lastSync: 0 + }); + } + } + } +} + +// Singleton pattern +export class DexieService { + private static instance: DexieService; + private db: DashboardDatabase; + + private constructor() { + this.db = new DashboardDatabase(); + this.db.initialize(); + } + + public static getInstance(): DexieService { + if (!DexieService.instance) { + DexieService.instance = new DexieService(); + } + return DexieService.instance; + } + + // Get the database instance + public getDB(): DashboardDatabase { + return this.db; + } + + // Update the last sync timestamp for a collection + public async updateLastSync(collection: string): Promise { + await this.db.syncInfo.update(collection, { lastSync: Date.now() }); + } + + // Get the last sync timestamp for a collection + public async getLastSync(collection: string): Promise { + const info = await this.db.syncInfo.get(collection); + return info?.lastSync || 0; + } + + // Clear all data (useful for logout) + public async clearAllData(): Promise { + await this.db.users.clear(); + await this.db.events.clear(); + await this.db.eventRequests.clear(); + await this.db.logs.clear(); + await this.db.officers.clear(); + await this.db.reimbursements.clear(); + await this.db.receipts.clear(); + await this.db.sponsors.clear(); + await this.db.offlineChanges.clear(); + + // Reset sync timestamps + const collections = [ + 'users', 'events', 'event_request', 'logs', + 'officers', 'reimbursement', 'receipts', 'sponsors' + ]; + + for (const collection of collections) { + await this.db.syncInfo.update(collection, { lastSync: 0 }); + } + } +} \ No newline at end of file diff --git a/src/scripts/database/initAuthSync.ts b/src/scripts/database/initAuthSync.ts new file mode 100644 index 0000000..b48ea9e --- /dev/null +++ b/src/scripts/database/initAuthSync.ts @@ -0,0 +1,35 @@ +import { Authentication } from '../pocketbase/Authentication'; + +/** + * Initialize authentication synchronization + * This function should be called when the application starts + * to ensure proper data synchronization during authentication flows + */ +export async function initAuthSync(): Promise { + try { + // Get Authentication instance + const auth = Authentication.getInstance(); + + // This will trigger the lazy loading of AuthSyncService + // through the onAuthStateChange mechanism + auth.onAuthStateChange(() => { + console.log('Auth sync initialized and listening for auth state changes'); + }); + + console.log('Auth sync initialization complete'); + } catch (error) { + console.error('Failed to initialize auth sync:', error); + } +} + +// Export a function to manually trigger a full sync +export async function forceFullSync(): Promise { + try { + const { AuthSyncService } = await import('./AuthSyncService'); + const authSync = AuthSyncService.getInstance(); + return await authSync.forceSyncAll(); + } catch (error) { + console.error('Failed to force full sync:', error); + return false; + } +} \ No newline at end of file diff --git a/src/scripts/pocketbase/Authentication.ts b/src/scripts/pocketbase/Authentication.ts index 429a29c..586cc50 100644 --- a/src/scripts/pocketbase/Authentication.ts +++ b/src/scripts/pocketbase/Authentication.ts @@ -21,6 +21,7 @@ export class Authentication { private static instance: Authentication; private authChangeCallbacks: ((isValid: boolean) => void)[] = []; private isUpdating: boolean = false; + private authSyncServiceInitialized: boolean = false; private constructor() { // Use the baseUrl from the config file @@ -82,8 +83,27 @@ export class Authentication { /** * Handle user logout */ - public logout(): void { - this.pb.authStore.clear(); + public async logout(): Promise { + try { + // Initialize AuthSyncService if needed (lazy loading) + await this.initAuthSyncService(); + + // Get AuthSyncService instance + const { AuthSyncService } = await import('../database/AuthSyncService'); + const authSync = AuthSyncService.getInstance(); + + // Handle data cleanup before actual logout + await authSync.handleLogout(); + + // Clear auth store + this.pb.authStore.clear(); + + console.log('Logout completed successfully with data cleanup'); + } catch (error) { + console.error('Error during logout:', error); + // Fallback to basic logout if sync fails + this.pb.authStore.clear(); + } } /** @@ -113,6 +133,11 @@ export class Authentication { */ public onAuthStateChange(callback: (isValid: boolean) => void): void { this.authChangeCallbacks.push(callback); + + // Initialize AuthSyncService when first callback is registered + if (!this.authSyncServiceInitialized && this.authChangeCallbacks.length === 1) { + this.initAuthSyncService(); + } } /** @@ -139,4 +164,32 @@ export class Authentication { const isValid = this.pb.authStore.isValid; this.authChangeCallbacks.forEach((callback) => callback(isValid)); } + + /** + * Initialize the AuthSyncService (lazy loading) + */ + private async initAuthSyncService(): Promise { + if (this.authSyncServiceInitialized) return; + + try { + // Dynamically import AuthSyncService to avoid circular dependencies + const { AuthSyncService } = await import('../database/AuthSyncService'); + + // Initialize the service + AuthSyncService.getInstance(); + + this.authSyncServiceInitialized = true; + console.log('AuthSyncService initialized successfully'); + + // If user is already authenticated, trigger initial sync + if (this.isAuthenticated()) { + const authSync = AuthSyncService.getInstance(); + authSync.handleLogin().catch(err => { + console.error('Error during initial data sync:', err); + }); + } + } catch (error) { + console.error('Failed to initialize AuthSyncService:', error); + } + } } diff --git a/src/scripts/pocketbase/Get.ts b/src/scripts/pocketbase/Get.ts index 20ae25e..2a2df3e 100644 --- a/src/scripts/pocketbase/Get.ts +++ b/src/scripts/pocketbase/Get.ts @@ -10,7 +10,7 @@ interface BaseRecord { interface RequestOptions { fields?: string[]; disableAutoCancellation?: boolean; - expand?: string[]; + expand?: string[] | string; } // Utility function to check if a value is a UTC date string @@ -130,21 +130,39 @@ export class Get { options?: RequestOptions, ): Promise { if (!this.auth.isAuthenticated()) { - throw new Error("User must be authenticated to retrieve records"); + console.warn( + `User not authenticated, but attempting to get record from ${collectionName} anyway`, + ); } try { const pb = this.auth.getPocketBase(); + + // Handle expand parameter + let expandString: string | undefined; + if (options?.expand) { + if (Array.isArray(options.expand)) { + expandString = options.expand.join(","); + } else if (typeof options.expand === 'string') { + expandString = options.expand; + } + } + const requestOptions = { ...(options?.fields && { fields: options.fields.join(",") }), + ...(expandString && { expand: expandString }), ...(options?.disableAutoCancellation && { requestKey: null }), }; + const result = await pb .collection(collectionName) .getOne(recordId, requestOptions); return convertUTCToLocal(result); } catch (err) { - console.error(`Failed to get record from ${collectionName}:`, err); + console.error( + `Failed to get record ${recordId} from ${collectionName}:`, + err, + ); throw err; } } @@ -162,29 +180,43 @@ export class Get { options?: RequestOptions, ): Promise { if (!this.auth.isAuthenticated()) { - throw new Error("User must be authenticated to retrieve records"); + console.warn( + `User not authenticated, but attempting to get records from ${collectionName} anyway`, + ); } try { + // Build filter for multiple IDs + const filter = recordIds.map((id) => `id="${id}"`).join(" || "); + const pb = this.auth.getPocketBase(); - const filter = `id ?~ "${recordIds.join("|")}"`; + + // Handle expand parameter + let expandString: string | undefined; + if (options?.expand) { + if (Array.isArray(options.expand)) { + expandString = options.expand.join(","); + } else if (typeof options.expand === 'string') { + expandString = options.expand; + } + } + const requestOptions = { filter, ...(options?.fields && { fields: options.fields.join(",") }), + ...(expandString && { expand: expandString }), ...(options?.disableAutoCancellation && { requestKey: null }), }; const result = await pb .collection(collectionName) - .getFullList(requestOptions); - - // Sort results to match the order of requested IDs and convert times - const recordMap = new Map( - result.map((record) => [record.id, convertUTCToLocal(record)]), - ); - return recordIds.map((id) => recordMap.get(id)).filter(Boolean) as T[]; + .getList(1, recordIds.length, requestOptions); + return result.items.map((item) => convertUTCToLocal(item)); } catch (err) { - console.error(`Failed to get records from ${collectionName}:`, err); + console.error( + `Failed to get records ${recordIds.join(", ")} from ${collectionName}:`, + err, + ); throw err; } } @@ -257,16 +289,33 @@ export class Get { sort?: string, options?: RequestOptions, ): Promise { + if (!this.auth.isAuthenticated()) { + console.warn( + `User not authenticated, but attempting to get records from ${collectionName} anyway`, + ); + } + // Try to get records even if authentication check fails // This is a workaround for cases where isAuthenticated() returns false // but the token is still valid for API requests try { const pb = this.auth.getPocketBase(); + + // Handle expand parameter + let expandString: string | undefined; + if (options?.expand) { + if (Array.isArray(options.expand)) { + expandString = options.expand.join(","); + } else if (typeof options.expand === 'string') { + expandString = options.expand; + } + } + const requestOptions = { ...(filter && { filter }), ...(sort && { sort }), ...(options?.fields && { fields: options.fields.join(",") }), - ...(options?.expand && { expand: options.expand.join(",") }), + ...(expandString && { expand: expandString }), ...(options?.disableAutoCancellation && { requestKey: null }), }; @@ -276,18 +325,6 @@ export class Get { return result.map((item) => convertUTCToLocal(item)); } catch (err) { console.error(`Failed to get all records from ${collectionName}:`, err); - - // If the error is authentication-related, check if we're actually authenticated - if ( - err instanceof Error && - (err.message.includes('auth') || err.message.includes('authentication')) - ) { - if (!this.auth.isAuthenticated()) { - console.error("Authentication check failed in getAll"); - throw new Error("User must be authenticated to retrieve records"); - } - } - throw err; } }