used indexdb and not cache

This commit is contained in:
chark1es 2025-03-01 04:19:32 -08:00
parent b0aa2020c4
commit 74b76ee053
24 changed files with 2417 additions and 966 deletions

View file

@ -18,6 +18,7 @@
"astro-expressive-code": "^0.40.2", "astro-expressive-code": "^0.40.2",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"dexie": "^4.0.11",
"framer-motion": "^12.4.4", "framer-motion": "^12.4.4",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"js-yaml": "^4.1.0", "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=="], "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=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="], "diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="],

View file

@ -25,6 +25,7 @@
"astro-expressive-code": "^0.40.2", "astro-expressive-code": "^0.40.2",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"dexie": "^4.0.11",
"framer-motion": "^12.4.4", "framer-motion": "^12.4.4",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

View file

@ -3,6 +3,8 @@ import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { Update } from "../../../scripts/pocketbase/Update"; import { Update } from "../../../scripts/pocketbase/Update";
import { SendLog } from "../../../scripts/pocketbase/SendLog"; import { SendLog } from "../../../scripts/pocketbase/SendLog";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import type { Event, AttendeeEntry } from "../../../schemas/pocketbase"; import type { Event, AttendeeEntry } from "../../../schemas/pocketbase";
@ -95,6 +97,7 @@ const EventCheckIn = () => {
try { try {
const get = Get.getInstance(); const get = Get.getInstance();
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
const dataSync = DataSyncService.getInstance();
const currentUser = auth.getCurrentUser(); const currentUser = auth.getCurrentUser();
if (!currentUser) { if (!currentUser) {
@ -102,11 +105,19 @@ const EventCheckIn = () => {
return; return;
} }
// Find the event with the given code // Find the event with the given code using IndexedDB
const event = await get.getFirst<Event>( // Force sync to ensure we have the latest data
"events", await dataSync.syncCollection(Collections.EVENTS, `event_code = "${eventCode}"`);
// Get the event from IndexedDB
const events = await dataSync.getData<Event>(
Collections.EVENTS,
false, // Don't force sync again
`event_code = "${eventCode}"` `event_code = "${eventCode}"`
); );
const event = events.length > 0 ? events[0] : null;
if (!event) { if (!event) {
throw new Error("Invalid event code"); throw new Error("Invalid event code");
} }
@ -149,6 +160,7 @@ const EventCheckIn = () => {
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
const update = Update.getInstance(); const update = Update.getInstance();
const logger = SendLog.getInstance(); const logger = SendLog.getInstance();
const dataSync = DataSyncService.getInstance();
const currentUser = auth.getCurrentUser(); const currentUser = auth.getCurrentUser();
if (!currentUser) { if (!currentUser) {
@ -197,6 +209,9 @@ const EventCheckIn = () => {
// Update attendees array with the new entry // Update attendees array with the new entry
await update.updateField("events", event.id, "attendees", updatedAttendees); 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 food selection was made, log it
if (foodSelection) { if (foodSelection) {
await logger.send( await logger.send(
@ -216,6 +231,9 @@ const EventCheckIn = () => {
userPoints + event.points_to_reward userPoints + event.points_to_reward
); );
// Force sync the users collection to update IndexedDB
await dataSync.syncCollection(Collections.USERS);
// Log the points award // Log the points award
await logger.send( await logger.send(
"update", "update",

View file

@ -2,6 +2,8 @@ import { useEffect, useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Get } from "../../../scripts/pocketbase/Get"; import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication"; 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"; import type { Event, AttendeeEntry } from "../../../schemas/pocketbase";
// Extended Event interface with additional properties needed for this component // Extended Event interface with additional properties needed for this component
@ -139,14 +141,17 @@ const EventLoad = () => {
const loadEvents = async () => { const loadEvents = async () => {
try { try {
const get = Get.getInstance(); const get = Get.getInstance();
const allEvents = await get.getAll<Event>( const dataSync = DataSyncService.getInstance();
"events",
// 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<Event>(
Collections.EVENTS,
false, // Don't force sync again
"published = true", "published = true",
"-start_date", "-start_date"
{
fields: ["*"],
disableAutoCancellation: true
}
); );
// Split events into upcoming, ongoing, and past based on start and end dates // Split events into upcoming, ongoing, and past based on start and end dates

View file

@ -2,6 +2,8 @@ import { useEffect, useState, useMemo, useCallback } from 'react';
import { Get } from '../../../scripts/pocketbase/Get'; import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { SendLog } from '../../../scripts/pocketbase/SendLog'; import { SendLog } from '../../../scripts/pocketbase/SendLog';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import type { Event, AttendeeEntry, User as SchemaUser } from "../../../schemas/pocketbase"; import type { Event, AttendeeEntry, User as SchemaUser } from "../../../schemas/pocketbase";
@ -160,10 +162,25 @@ export default function Attendees() {
// Fetch uncached users // Fetch uncached users
try { try {
const users = await get.getMany<User>('users', uncachedIds, { const dataSync = DataSyncService.getInstance();
fields: USER_FIELDS,
disableAutoCancellation: false // 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<User>(Collections.USERS, id);
} catch (error) {
console.error(`Failed to fetch user ${id}:`, error);
return null;
}
})
);
// Update cache and merge with cached users // Update cache and merge with cached users
users.forEach(user => { users.forEach(user => {
@ -177,7 +194,7 @@ export default function Attendees() {
} }
return cachedUsers; return cachedUsers;
}, [get]); }, []);
// Listen for the custom event // Listen for the custom event
useEffect(() => { useEffect(() => {
@ -226,13 +243,23 @@ export default function Attendees() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const event = await get.getOne<Event>('events', eventId, { const dataSync = DataSyncService.getInstance();
fields: EVENT_FIELDS,
disableAutoCancellation: false // Sync the event data
}); await dataSync.syncCollection(Collections.EVENTS, `id = "${eventId}"`);
// Get the event from IndexedDB
const event = await dataSync.getItem<Event>(Collections.EVENTS, eventId);
if (!isMounted) return; if (!isMounted) return;
if (!event) {
setError('Event not found');
setAttendeesList([]);
setUsers(new Map());
return;
}
if (!event.attendees?.length) { if (!event.attendees?.length) {
setAttendeesList([]); setAttendeesList([]);
setUsers(new Map()); setUsers(new Map());
@ -263,7 +290,7 @@ export default function Attendees() {
fetchEventData(); fetchEventData();
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [eventId, auth, get, fetchUserData]); }, [eventId, auth, fetchUserData]);
// Reset state when modal is closed // Reset state when modal is closed
useEffect(() => { useEffect(() => {

View file

@ -7,6 +7,8 @@ import { FileManager } from "../../../scripts/pocketbase/FileManager";
import { SendLog } from "../../../scripts/pocketbase/SendLog"; import { SendLog } from "../../../scripts/pocketbase/SendLog";
import FilePreview from "../universal/FilePreview"; import FilePreview from "../universal/FilePreview";
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase"; 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. // 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. // 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, { await pb.collection("events").update(event.id, {
files: remainingFiles files: remainingFiles
}); });
// Sync the events collection to update IndexedDB
const dataSync = DataSyncService.getInstance();
await dataSync.syncCollection(Collections.EVENTS);
} }
// Handle file additions // Handle file additions
@ -725,6 +731,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
// Use appendFiles to preserve existing files // Use appendFiles to preserve existing files
await services.fileManager.appendFiles("events", event.id, "files", filesToUpload); 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) { } catch (error: any) {
if (error.status === 413) { if (error.status === 413) {
throw new Error("Files are too large. Please try uploading smaller files or fewer files at once."); 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); const newEvent = await pb.collection("events").create(eventData);
console.log('New event created:', newEvent); 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 // Upload files if any
if (selectedFiles.size > 0) { if (selectedFiles.size > 0) {
try { try {

View file

@ -3,6 +3,7 @@ import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get"; import { Get } from "../../scripts/pocketbase/Get";
import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm"; import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm";
import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests"; import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests";
import { Collections } from "../../schemas/pocketbase/schema";
// Import the EventRequest type from UserEventRequests to ensure consistency // Import the EventRequest type from UserEventRequests to ensure consistency
import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests"; import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests";
@ -19,141 +20,195 @@ let userEventRequests: EventRequest[] = [];
let error: string | null = null; let error: string | null = null;
// Fetch user's event request submissions if authenticated // 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()) { if (auth.isAuthenticated()) {
try { try {
const userId = auth.getUserId(); const userId = auth.getUserId();
if (userId) { if (userId) {
userEventRequests = await get.getAll<EventRequest>( userEventRequests = await get.getAll<EventRequest>(
"event_request", Collections.EVENT_REQUESTS,
`requested_user="${userId}"`, `requested_user="${userId}"`,
"-created", "-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.";
}
} }
--- ---
<div class="w-full max-w-6xl mx-auto py-8 px-4"> <div class="w-full max-w-6xl mx-auto py-8 px-4">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1> <h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
<p class="text-gray-300 mb-4"> <p class="text-gray-300 mb-4">
Submit your event request at least 6 weeks before your event. After Submit your event request at least 6 weeks before your event. After
submitting, please notify PR and/or Coordinators in the #-events Slack submitting, please notify PR and/or Coordinators in the #-events
channel. Slack channel.
</p> </p>
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300"> <div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
<p class="font-medium mb-2">This form includes sections for:</p> <p class="font-medium mb-2">This form includes sections for:</p>
<ul class="list-disc list-inside space-y-1 ml-2"> <ul class="list-disc list-inside space-y-1 ml-2">
<li>PR Materials (if needed)</li> <li>PR Materials (if needed)</li>
<li>Event Details</li> <li>Event Details</li>
<li>TAP Form Information</li> <li>TAP Form Information</li>
<li>AS Funding (if needed)</li> <li>AS Funding (if needed)</li>
</ul> </ul>
<p class="mt-3"> <p class="mt-3">
Your progress is automatically saved as you fill out the form. Your progress is automatically saved as you fill out the form.
</p> </p>
</div>
</div> </div>
</div>
<!-- Tabs --> <!-- Tabs -->
<div class="tabs tabs-boxed mb-6"> <div class="tabs tabs-boxed mb-6">
<a class="tab tab-lg tab-active" id="form-tab">Submit Event Request</a> <a class="tab tab-lg tab-active" id="form-tab">Submit Event Request</a>
<a class="tab tab-lg" id="submissions-tab">View Your Submissions</a> <a class="tab tab-lg" id="submissions-tab">View Your Submissions</a>
</div>
<!-- Form Tab Content -->
<div
id="form-content"
class="bg-base-200 rounded-lg shadow-xl overflow-hidden"
>
<div class="p-6">
<EventRequestForm client:load />
</div> </div>
</div>
<!-- Submissions Tab Content --> <!-- Form Tab Content -->
<div id="submissions-content" class="hidden"> <div
<div class="bg-base-200 rounded-lg shadow-xl overflow-hidden p-6"> id="form-content"
<h2 class="text-2xl font-bold text-white mb-4"> class="bg-base-200 rounded-lg shadow-xl overflow-hidden"
Your Event Request Submissions >
</h2> <div class="p-6">
<EventRequestForm client:load />
{ </div>
error && ( </div>
<div class="alert alert-error mb-6">
<svg <!-- Submissions Tab Content -->
xmlns="http://www.w3.org/2000/svg" <div id="submissions-content" class="hidden">
class="h-6 w-6 stroke-current shrink-0" <div class="bg-base-200 rounded-lg shadow-xl overflow-hidden p-6">
fill="none" <h2 class="text-2xl font-bold text-white mb-4">
viewBox="0 0 24 24" Your Event Request Submissions
> </h2>
<path
stroke-linecap="round" {
stroke-linejoin="round" error && (
stroke-width="2" <div class="alert alert-error mb-6">
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" <svg
/> xmlns="http://www.w3.org/2000/svg"
</svg> class="h-6 w-6 stroke-current shrink-0"
<span>{error}</span> fill="none"
</div> viewBox="0 0 24 24"
) >
} <path
stroke-linecap="round"
{ stroke-linejoin="round"
!error && ( stroke-width="2"
<UserEventRequests client:load eventRequests={userEventRequests} /> d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
) />
} </svg>
<span>{error}</span>
</div>
)
}
{
!error && (
<UserEventRequests
client:load
eventRequests={userEventRequests}
/>
)
}
</div>
</div> </div>
</div>
</div> </div>
<script> <script>
// Tab switching logic // Import the DataSyncService for client-side use
document.addEventListener("DOMContentLoaded", () => { import { DataSyncService } from "../../scripts/database/DataSyncService";
const formTab = document.getElementById("form-tab"); import { Collections } from "../../schemas/pocketbase/schema";
const submissionsTab = document.getElementById("submissions-tab"); import { Authentication } from "../../scripts/pocketbase/Authentication";
const formContent = document.getElementById("form-content");
const submissionsContent = document.getElementById("submissions-content");
// Function to switch tabs // Tab switching logic
const switchTab = ( document.addEventListener("DOMContentLoaded", async () => {
activeTab: HTMLElement, // Initialize DataSyncService for client-side
activeContent: HTMLElement, const dataSync = DataSyncService.getInstance();
inactiveTab: HTMLElement, const auth = Authentication.getInstance();
inactiveContent: HTMLElement,
) => {
// Update tab classes
activeTab.classList.add("tab-active");
inactiveTab.classList.remove("tab-active");
// Show/hide content // Prefetch data into IndexedDB if authenticated
activeContent.classList.remove("hidden"); if (auth.isAuthenticated()) {
inactiveContent.classList.add("hidden"); try {
const userId = auth.getUserId();
if (userId) {
// Force sync to ensure we have the latest data
await dataSync.syncCollection(
Collections.EVENT_REQUESTS,
`requested_user="${userId}"`,
"-created"
);
console.log(
"Initial data sync complete for user event requests"
);
}
} catch (err) {
console.error("Error during initial data sync:", err);
}
}
// Dispatch event to refresh submissions when switching to submissions tab const formTab = document.getElementById("form-tab");
if (activeTab.id === "submissions-tab") { const submissionsTab = document.getElementById("submissions-tab");
const refreshEvent = new CustomEvent("refreshSubmissions"); const formContent = document.getElementById("form-content");
document.dispatchEvent(refreshEvent); const submissionsContent = document.getElementById(
} "submissions-content"
}; );
// Add click event listeners to tabs // Function to switch tabs
formTab?.addEventListener("click", (e) => { const switchTab = (
e.preventDefault(); activeTab: HTMLElement,
if (formContent && submissionsContent && submissionsTab) { activeContent: HTMLElement,
switchTab(formTab, formContent, submissionsTab, submissionsContent); inactiveTab: HTMLElement,
} inactiveContent: HTMLElement
) => {
// Update tab classes
activeTab.classList.add("tab-active");
inactiveTab.classList.remove("tab-active");
// Show/hide content
activeContent.classList.remove("hidden");
inactiveContent.classList.add("hidden");
// Dispatch event to refresh submissions when switching to submissions tab
if (activeTab.id === "submissions-tab") {
// Dispatch a custom event that the UserEventRequests component listens for
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
}
};
// Add click event listeners to tabs
formTab?.addEventListener("click", (e) => {
e.preventDefault();
if (formContent && submissionsContent && submissionsTab) {
switchTab(
formTab,
formContent,
submissionsTab,
submissionsContent
);
}
});
submissionsTab?.addEventListener("click", (e) => {
e.preventDefault();
if (formContent && submissionsContent && formTab) {
switchTab(
submissionsTab,
submissionsContent,
formTab,
formContent
);
}
});
// Listen for visibility changes
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
// Dispatch custom event that components can listen for
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
}
});
}); });
submissionsTab?.addEventListener("click", (e) => {
e.preventDefault();
if (formContent && submissionsContent && formTab) {
switchTab(submissionsTab, submissionsContent, formTab, formContent);
}
});
});
</script> </script>

View file

@ -5,6 +5,8 @@ import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update'; import { Update } from '../../../scripts/pocketbase/Update';
import { FileManager } from '../../../scripts/pocketbase/FileManager'; import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { Get } from '../../../scripts/pocketbase/Get'; 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 type { EventRequest } from '../../../schemas/pocketbase';
import { EventRequestStatus } from '../../../schemas/pocketbase'; import { EventRequestStatus } from '../../../schemas/pocketbase';
@ -191,6 +193,7 @@ const EventRequestForm: React.FC = () => {
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
const update = Update.getInstance(); const update = Update.getInstance();
const fileManager = FileManager.getInstance(); const fileManager = FileManager.getInstance();
const dataSync = DataSyncService.getInstance();
if (!auth.isAuthenticated()) { if (!auth.isAuthenticated()) {
toast.error('You must be logged in to submit an event request', { id: submittingToast }); 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 }); toast.loading('Creating event request record...', { id: submittingToast });
try { 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); 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 // Upload files if they exist
if (formData.other_logos.length > 0) { if (formData.other_logos.length > 0) {
toast.loading('Uploading logo files...', { id: submittingToast }); toast.loading('Uploading logo files...', { id: submittingToast });

View file

@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Get } from '../../../scripts/pocketbase/Get'; import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; 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 toast from 'react-hot-toast';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase'; import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase';
@ -20,6 +22,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false); const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table'); const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
const dataSync = DataSyncService.getInstance();
// Refresh event requests // Refresh event requests
const refreshEventRequests = async () => { const refreshEventRequests = async () => {
@ -27,7 +30,6 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
const refreshToast = toast.loading('Refreshing submissions...'); const refreshToast = toast.loading('Refreshing submissions...');
try { try {
const get = Get.getInstance();
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) { if (!auth.isAuthenticated()) {
@ -41,8 +43,10 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
return; return;
} }
const updatedRequests = await get.getAll<EventRequest>( // Use DataSyncService to get data from IndexedDB with forced sync
'event_request', const updatedRequests = await dataSync.getData<EventRequest>(
Collections.EVENT_REQUESTS,
true, // Force sync
`requested_user="${userId}"`, `requested_user="${userId}"`,
'-created' '-created'
); );
@ -62,6 +66,22 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
refreshEventRequests(); 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 // Format date for display
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
if (!dateString) return 'Not specified'; if (!dateString) return 'Not specified';

View file

@ -4,6 +4,7 @@ import { Get } from "../../scripts/pocketbase/Get";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable"; import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable";
import type { EventRequest } from "../../schemas/pocketbase"; import type { EventRequest } from "../../schemas/pocketbase";
import { Collections } from "../../schemas/pocketbase/schema";
// Get instances // Get instances
const get = Get.getInstance(); const get = Get.getInstance();
@ -39,9 +40,14 @@ try {
console.log("Fetching event requests in Astro component..."); console.log("Fetching event requests in Astro component...");
// Expand the requested_user field to get user details // Expand the requested_user field to get user details
allEventRequests = await get allEventRequests = await get
.getAll<ExtendedEventRequest>("event_request", "", "-created", { .getAll<ExtendedEventRequest>(
expand: ["requested_user"], Collections.EVENT_REQUESTS,
}) "",
"-created",
{
expand: ["requested_user"],
}
)
.catch((err) => { .catch((err) => {
console.error("Error in get.getAll:", err); console.error("Error in get.getAll:", err);
// Return empty array instead of throwing // Return empty array instead of throwing
@ -152,6 +158,10 @@ try {
</div> </div>
<script> <script>
// Import the DataSyncService for client-side use
import { DataSyncService } from "../../scripts/database/DataSyncService";
import { Collections } from "../../schemas/pocketbase/schema";
// Remove the visibilitychange event listener that causes full page refresh // Remove the visibilitychange event listener that causes full page refresh
// Instead, we'll use a more efficient approach to refresh data only when needed // Instead, we'll use a more efficient approach to refresh data only when needed
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
@ -163,7 +173,23 @@ try {
}); });
// Handle authentication errors // Handle authentication errors
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", async () => {
// Initialize DataSyncService for client-side
const dataSync = DataSyncService.getInstance();
// Prefetch data into IndexedDB
try {
await dataSync.syncCollection(
Collections.EVENT_REQUESTS,
"",
"-created",
{ expand: "requested_user" }
);
console.log("Initial data sync complete");
} catch (err) {
console.error("Error during initial data sync:", err);
}
// Check for error message in the UI // Check for error message in the UI
const errorElement = document.querySelector(".alert-error span"); const errorElement = document.querySelector(".alert-error span");
if ( if (

View file

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema'; import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
// Extended EventRequest interface with additional properties needed for this component // Extended EventRequest interface with additional properties needed for this component

View file

@ -3,9 +3,11 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Get } from '../../../scripts/pocketbase/Get'; import { Get } from '../../../scripts/pocketbase/Get';
import { Update } from '../../../scripts/pocketbase/Update'; import { Update } from '../../../scripts/pocketbase/Update';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import EventRequestDetails from './EventRequestDetails'; import EventRequestDetails from './EventRequestDetails';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema'; import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
import { Collections } from '../../../schemas/pocketbase/schema';
// Extended EventRequest interface with additional properties needed for this component // Extended EventRequest interface with additional properties needed for this component
interface ExtendedEventRequest extends SchemaEventRequest { interface ExtendedEventRequest extends SchemaEventRequest {
@ -41,6 +43,7 @@ const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: Ev
const [searchTerm, setSearchTerm] = useState<string>(''); const [searchTerm, setSearchTerm] = useState<string>('');
const [sortField, setSortField] = useState<string>('created'); const [sortField, setSortField] = useState<string>('created');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const dataSync = DataSyncService.getInstance();
// Refresh event requests // Refresh event requests
const refreshEventRequests = async () => { const refreshEventRequests = async () => {
@ -48,22 +51,22 @@ const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: Ev
const refreshToast = toast.loading('Refreshing event requests...'); const refreshToast = toast.loading('Refreshing event requests...');
try { try {
const get = Get.getInstance();
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
// Don't check authentication here - try to fetch anyway // Don't check authentication here - try to fetch anyway
// The token might be valid for the API even if isAuthenticated() returns false // The token might be valid for the API even if isAuthenticated() returns false
console.log("Fetching event requests..."); console.log("Fetching event requests...");
const updatedRequests = await get.getAll<ExtendedEventRequest>(
'event_request', // Use DataSyncService to get data from IndexedDB with forced sync
'', const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
Collections.EVENT_REQUESTS,
true, // Force sync
'', // No filter
'-created', '-created',
{ 'requested_user'
fields: ['*'],
expand: ['requested_user']
}
); );
console.log(`Fetched ${updatedRequests.length} event requests`); console.log(`Fetched ${updatedRequests.length} event requests`);
setEventRequests(updatedRequests); setEventRequests(updatedRequests);
@ -167,6 +170,9 @@ const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: Ev
setSelectedRequest({ ...selectedRequest, status }); setSelectedRequest({ ...selectedRequest, status });
} }
// Force sync to update IndexedDB
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
toast.success(`Status updated to ${status}`, { id: updateToast }); toast.success(`Status updated to ${status}`, { id: updateToast });
} catch (err) { } catch (err) {
console.error('Failed to update event request status:', err); console.error('Failed to update event request status:', err);
@ -195,6 +201,9 @@ const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: Ev
) )
); );
// Force sync to update IndexedDB
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
toast.success('Feedback saved successfully', { id: feedbackToast }); toast.success('Feedback saved successfully', { id: feedbackToast });
return true; return true;
} catch (err) { } catch (err) {

View file

@ -1,6 +1,8 @@
import { useEffect, useState, useCallback, useMemo } from "react"; import { useEffect, useState, useCallback, useMemo } from "react";
import { Get } from "../../../scripts/pocketbase/Get"; import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import type { Log } from "../../../schemas/pocketbase"; import type { Log } from "../../../schemas/pocketbase";
@ -65,29 +67,25 @@ export default function ShowProfileLogs() {
}; };
const fetchAllLogs = async (userId: string): Promise<Log[]> => { const fetchAllLogs = async (userId: string): Promise<Log[]> => {
const get = Get.getInstance(); const dataSync = DataSyncService.getInstance();
let allLogs: Log[] = []; let allLogs: Log[] = [];
let page = 1; let page = 1;
let hasMore = true; let hasMore = true;
while (hasMore) { // First, sync all logs for this user
try { await dataSync.syncCollection(
const response = await get.getList<Log>( Collections.LOGS,
"logs", `user_id = "${userId}"`,
page, "-created"
BATCH_SIZE, );
`user_id = "${userId}"`,
"-created"
);
allLogs = [...allLogs, ...response.items]; // Then get all logs from IndexedDB
hasMore = page * BATCH_SIZE < response.totalItems; allLogs = await dataSync.getData<Log>(
page++; Collections.LOGS,
} catch (error) { false, // Don't force sync again
console.error("Failed to fetch logs batch:", error); `user_id = "${userId}"`,
throw error; "-created"
} );
}
return allLogs; return allLogs;
}; };

View file

@ -1,7 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Get } from "../../../scripts/pocketbase/Get"; import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { Authentication } from "../../../scripts/pocketbase/Authentication";
import type { Event, Log } from "../../../schemas/pocketbase"; import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import type { Event, Log, User } from "../../../schemas/pocketbase";
// Extended User interface with points property
interface ExtendedUser extends User {
points?: number;
}
export function Stats() { export function Stats() {
const [eventsAttended, setEventsAttended] = useState(0); const [eventsAttended, setEventsAttended] = useState(0);
@ -17,6 +24,7 @@ export function Stats() {
setIsLoading(true); setIsLoading(true);
const get = Get.getInstance(); const get = Get.getInstance();
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
const dataSync = DataSyncService.getInstance();
const userId = auth.getCurrentUser()?.id; const userId = auth.getCurrentUser()?.id;
if (!userId) return; if (!userId) return;
@ -43,18 +51,30 @@ export function Stats() {
quarterStart = new Date(now.getFullYear(), 6, 1); // Jul 1 quarterStart = new Date(now.getFullYear(), 6, 1); // Jul 1
} }
// Get user's total points // Sync user data to ensure we have the latest
const user = await get.getOne("users", userId); await dataSync.syncCollection(Collections.USERS, `id = "${userId}"`);
const totalPoints = user.points || 0;
// Fetch quarterly points from logs // Get user from IndexedDB
const logs = await get.getList<Log>("logs", 1, 50, const user = await dataSync.getItem<ExtendedUser>(Collections.USERS, userId);
const totalPoints = user?.points || 0;
// Sync logs for the current quarter
await dataSync.syncCollection(
Collections.LOGS,
`user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`,
"-created"
);
// Get logs from IndexedDB
const logs = await dataSync.getData<Log>(
Collections.LOGS,
false, // Don't force sync again
`user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`, `user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`,
"-created" "-created"
); );
// Calculate quarterly points // Calculate quarterly points
const quarterlyPoints = logs.items.reduce((total, log) => { const quarterlyPoints = logs.reduce((total, log) => {
const pointsMatch = log.message.match(/Awarded (\d+) points/); const pointsMatch = log.message.match(/Awarded (\d+) points/);
if (pointsMatch) { if (pointsMatch) {
return total + parseInt(pointsMatch[1]); return total + parseInt(pointsMatch[1]);
@ -62,8 +82,12 @@ export function Stats() {
return total; return total;
}, 0); }, 0);
// Fetch all events for total count // Sync events collection
const events = await get.getAll<Event>("events"); await dataSync.syncCollection(Collections.EVENTS);
// Get events from IndexedDB
const events = await dataSync.getData<Event>(Collections.EVENTS);
const attendedEvents = events.filter(event => const attendedEvents = events.filter(event =>
event.attendees?.some(attendee => attendee.user_id === userId) event.attendees?.some(attendee => attendee.user_id === userId)
); );

View file

@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import ReceiptForm from './ReceiptForm'; import ReceiptForm from './ReceiptForm';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import FilePreview from '../universal/FilePreview'; import FilePreview from '../universal/FilePreview';
import ToastProvider from './ToastProvider'; import ToastProvider from './ToastProvider';
import type { ItemizedExpense, Reimbursement } from '../../../schemas/pocketbase'; import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
interface ReceiptFormData { interface ReceiptFormData {
field: File; field: File;
@ -194,6 +196,10 @@ export default function ReimbursementForm() {
const response = await pb.collection('receipts').create(formData); const response = await pb.collection('receipts').create(formData);
// Sync the receipts collection to update IndexedDB
const dataSync = DataSyncService.getInstance();
await dataSync.syncCollection(Collections.RECEIPTS);
// Add receipt to state // Add receipt to state
setReceipts(prev => [...prev, { ...receiptData, id: response.id }]); setReceipts(prev => [...prev, { ...receiptData, id: response.id }]);
@ -263,6 +269,10 @@ export default function ReimbursementForm() {
await pb.collection('reimbursement').create(formData); await pb.collection('reimbursement').create(formData);
// Sync the reimbursements collection to update IndexedDB
const dataSync = DataSyncService.getInstance();
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
// Reset form // Reset form
setRequest({ setRequest({
title: '', title: '',

View file

@ -8,6 +8,8 @@ import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import ToastProvider from './ToastProvider'; import ToastProvider from './ToastProvider';
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase'; import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
interface AuditNote { interface AuditNote {
note: string; note: string;
@ -97,12 +99,13 @@ const itemVariants = {
export default function ReimbursementList() { export default function ReimbursementList() {
const [requests, setRequests] = useState<ReimbursementRequest[]>([]); const [requests, setRequests] = useState<ReimbursementRequest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(''); const [error, setError] = useState('');
const [selectedRequest, setSelectedRequest] = useState<ReimbursementRequest | null>(null); const [selectedRequest, setSelectedRequest] = useState<ReimbursementRequest | null>(null);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [previewUrl, setPreviewUrl] = useState(''); const [previewUrl, setPreviewUrl] = useState('');
const [previewFilename, setPreviewFilename] = useState(''); const [previewFilename, setPreviewFilename] = useState('');
const [selectedReceipt, setSelectedReceipt] = useState<ReceiptDetails | null>(null); const [selectedReceipt, setSelectedReceipt] = useState<ReceiptDetails | null>(null);
const [receiptDetailsMap, setReceiptDetailsMap] = useState<Record<string, ReceiptDetails>>({});
const get = Get.getInstance(); const get = Get.getInstance();
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
@ -120,100 +123,122 @@ export default function ReimbursementList() {
}, [requests]); }, [requests]);
const fetchReimbursements = async () => { const fetchReimbursements = async () => {
setLoading(true);
setError('');
try { try {
setLoading(true);
const pb = auth.getPocketBase(); const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id; const userId = pb.authStore.model?.id;
console.log('Current user ID:', userId);
console.log('Auth store state:', {
isValid: pb.authStore.isValid,
token: pb.authStore.token,
model: pb.authStore.model
});
if (!userId) { if (!userId) {
toast.error('User not authenticated');
throw new Error('User not authenticated'); throw new Error('User not authenticated');
} }
const loadingToast = toast.loading('Loading reimbursements...'); const loadingToast = toast.loading('Loading reimbursements...');
// First try to get all reimbursements to see if the collection is accessible // Use DataSyncService to get data from IndexedDB with forced sync
try { const dataSync = DataSyncService.getInstance();
const allRecords = await pb.collection('reimbursement').getList(1, 50);
console.log('All reimbursements (no filter):', allRecords);
} catch (e) {
console.error('Error getting all reimbursements:', e);
}
// Now try with the filter // Sync reimbursements collection
console.log('Attempting to fetch with filter:', `submitted_by = "${userId}"`); await dataSync.syncCollection(
Collections.REIMBURSEMENTS,
`submitted_by="${userId}"`,
'-created',
'audit_notes'
);
try { // Get reimbursements from IndexedDB
const records = await pb.collection('reimbursement').getList(1, 50, { const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
filter: `submitted_by = "${userId}"`, Collections.REIMBURSEMENTS,
sort: '-created', false, // Don't force sync again
expand: 'audit_notes' `submitted_by="${userId}"`,
}); '-created'
);
console.log('Filtered records response:', records); console.log('Reimbursement records from IndexedDB:', reimbursementRecords);
console.log('Total items:', records.totalItems);
console.log('Items:', records.items);
if (records.items.length === 0) { // Process the records
console.log('No records found for user'); const processedRecords = reimbursementRecords.map(record => {
setRequests([]); // Process audit notes if they exist
toast.dismiss(loadingToast); let auditNotes = null;
setLoading(false); if (record.audit_notes) {
return; try {
// If it's a string, parse it
if (typeof record.audit_notes === 'string') {
auditNotes = JSON.parse(record.audit_notes);
} else {
// Otherwise use it directly
auditNotes = record.audit_notes;
}
} catch (e) {
console.error('Error parsing audit notes:', e);
}
} }
// Convert PocketBase records to ReimbursementRequest type return {
const reimbursements: ReimbursementRequest[] = records.items.map(record => { ...record,
console.log('Processing record:', record); audit_notes: auditNotes
const processedRequest = { };
id: record.id,
title: record.title,
total_amount: record.total_amount,
date_of_purchase: record.date_of_purchase,
payment_method: record.payment_method,
status: record.status,
submitted_by: record.submitted_by,
additional_info: record.additional_info || '',
receipts: record.receipts || [],
department: record.department,
created: record.created,
updated: record.updated,
audit_notes: record.audit_notes || null
};
console.log('Processed request:', processedRequest);
return processedRequest;
});
console.log('All processed reimbursements:', reimbursements);
console.log('Setting requests state with:', reimbursements.length, 'items');
// Update state with the new reimbursements
setRequests(reimbursements);
console.log('State updated with reimbursements');
toast.dismiss(loadingToast);
toast.success(`Loaded ${reimbursements.length} reimbursement${reimbursements.length === 1 ? '' : 's'}`);
} catch (e) {
console.error('Error with filtered query:', e);
throw e;
}
} catch (error: any) {
console.error('Error fetching reimbursements:', error);
console.error('Error details:', {
message: error?.message,
data: error?.data,
url: error?.url,
status: error?.status
}); });
toast.error('Failed to load reimbursement requests');
setError('Failed to load reimbursement requests. ' + (error?.message || '')); setRequests(processedRecords);
toast.success('Reimbursements loaded successfully', { id: loadingToast });
// Fetch receipt details for each reimbursement
for (const record of processedRecords) {
if (record.receipts && record.receipts.length > 0) {
for (const receiptId of record.receipts) {
try {
// Get receipt from IndexedDB
const receiptRecord = await dataSync.getItem<ReceiptDetails>(
Collections.RECEIPTS,
receiptId
);
if (receiptRecord) {
// Process itemized expenses
let itemizedExpenses: ItemizedExpense[] = [];
if (receiptRecord.itemized_expenses) {
try {
if (typeof receiptRecord.itemized_expenses === 'string') {
itemizedExpenses = JSON.parse(receiptRecord.itemized_expenses);
} else {
itemizedExpenses = receiptRecord.itemized_expenses as ItemizedExpense[];
}
} catch (e) {
console.error('Error parsing itemized expenses:', e);
}
}
// Add receipt to state
setReceiptDetailsMap(prevMap => ({
...prevMap,
[receiptId]: {
id: receiptRecord.id,
field: receiptRecord.field,
created_by: receiptRecord.created_by,
date: receiptRecord.date,
location_name: receiptRecord.location_name,
location_address: receiptRecord.location_address,
notes: receiptRecord.notes,
tax: receiptRecord.tax,
created: receiptRecord.created,
updated: receiptRecord.updated,
itemized_expenses: itemizedExpenses,
audited_by: receiptRecord.audited_by || []
}
}));
}
} catch (e) {
console.error(`Error fetching receipt ${receiptId}:`, e);
}
}
}
}
} catch (err) {
console.error('Error fetching reimbursements:', err);
setError('Failed to load reimbursements. Please try again.');
toast.error('Failed to load reimbursements');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -224,7 +249,22 @@ export default function ReimbursementList() {
const loadingToast = toast.loading('Loading receipt...'); const loadingToast = toast.loading('Loading receipt...');
const pb = auth.getPocketBase(); const pb = auth.getPocketBase();
// Get the receipt record using its ID // Check if we already have the receipt details in our map
if (receiptDetailsMap[receiptId]) {
// Use the cached receipt details
setSelectedReceipt(receiptDetailsMap[receiptId]);
// Get the file URL using the PocketBase URL and collection info
const url = `${pb.baseUrl}/api/files/receipts/${receiptId}/${receiptDetailsMap[receiptId].field}`;
setPreviewUrl(url);
setPreviewFilename(receiptDetailsMap[receiptId].field);
setShowPreview(true);
toast.dismiss(loadingToast);
toast.success('Receipt loaded successfully');
return;
}
// If not in the map, get the receipt record using its ID
const receiptRecord = await pb.collection('receipts').getOne(receiptId, { const receiptRecord = await pb.collection('receipts').getOne(receiptId, {
$autoCancel: false $autoCancel: false
}); });
@ -235,7 +275,7 @@ export default function ReimbursementList() {
? JSON.parse(receiptRecord.itemized_expenses) ? JSON.parse(receiptRecord.itemized_expenses)
: receiptRecord.itemized_expenses; : receiptRecord.itemized_expenses;
setSelectedReceipt({ const receiptDetails: ReceiptDetails = {
id: receiptRecord.id, id: receiptRecord.id,
field: receiptRecord.field, field: receiptRecord.field,
created_by: receiptRecord.created_by, created_by: receiptRecord.created_by,
@ -248,7 +288,15 @@ export default function ReimbursementList() {
audited_by: receiptRecord.audited_by || [], audited_by: receiptRecord.audited_by || [],
created: receiptRecord.created, created: receiptRecord.created,
updated: receiptRecord.updated updated: receiptRecord.updated
}); };
// Add to the map for future use
setReceiptDetailsMap(prevMap => ({
...prevMap,
[receiptId]: receiptDetails
}));
setSelectedReceipt(receiptDetails);
// Get the file URL using the PocketBase URL and collection info // Get the file URL using the PocketBase URL and collection info
const url = `${pb.baseUrl}/api/files/receipts/${receiptRecord.id}/${receiptRecord.field}`; const url = `${pb.baseUrl}/api/files/receipts/${receiptRecord.id}/${receiptRecord.field}`;

File diff suppressed because it is too large Load diff

View file

@ -63,7 +63,7 @@ export class RedirectHandler {
console.log("Auth successful:", authData); console.log("Auth successful:", authData);
this.contentEl.innerHTML = ` this.contentEl.innerHTML = `
<p class="text-3xl font-bold text-green-500 mb-4">Authentication Successful!</p> <p class="text-3xl font-bold text-green-500 mb-4">Authentication Successful!</p>
<p class="text-2xl font-medium">Redirecting to store...</p> <p class="text-2xl font-medium">Initializing your data...</p>
<div class="mt-4"> <div class="mt-4">
<div class="loading loading-spinner loading-lg"></div> <div class="loading loading-spinner loading-lg"></div>
</div> </div>
@ -75,11 +75,14 @@ export class RedirectHandler {
last_login: new Date().toISOString(), last_login: new Date().toISOString(),
}); });
// Initialize data sync
await this.initializeDataSync();
// Clean up and redirect // Clean up and redirect
localStorage.removeItem("provider"); localStorage.removeItem("provider");
window.location.href = "/dashboard"; window.location.href = "/dashboard";
} catch (err) { } 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 // Still redirect even if last_login update fails
localStorage.removeItem("provider"); localStorage.removeItem("provider");
window.location.href = "/dashboard"; window.location.href = "/dashboard";
@ -89,4 +92,27 @@ export class RedirectHandler {
this.showError(`Failed to complete authentication: ${err.message}`); this.showError(`Failed to complete authentication: ${err.message}`);
} }
} }
/**
* Initialize data synchronization after successful authentication
*/
private async initializeDataSync(): Promise<void> {
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
}
}
} }

View file

@ -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<string, Error> = {};
private syncQueue: string[] = [];
private syncPromise: Promise<void> | 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<void> {
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<boolean> {
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<void> {
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<boolean> {
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<void> {
// 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<string, string> }> {
const errors: Record<string, string> = {};
// 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<boolean> {
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<string, Error> {
return { ...this.syncErrors };
}
}
// Add toast type to window for TypeScript
declare global {
interface Window {
toast?: (message: string, options?: { type: 'info' | 'success' | 'warning' | 'error' }) => void;
}
}

View file

@ -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<string, boolean> = {};
private offlineMode: boolean = false;
private offlineChangesTable: Dexie.Table<OfflineChange, string> | 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<OfflineChange, string>;
} 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<void> {
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<T extends BaseRecord>(
collection: string,
filter: string = '',
sort: string = '-created',
expand: Record<string, any> | string[] | string = {}
): Promise<T[]> {
// 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<T>(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<T extends BaseRecord>(
collection: string,
localItem: T,
serverItem: T
): Promise<T> {
// 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<OfflineChange[]> {
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<boolean> {
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<string, OfflineChange[]>);
// 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<string | null> {
if (!this.offlineChangesTable) return null;
try {
const change: Omit<OfflineChange, 'id'> = {
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<T extends BaseRecord>(
collection: string,
forceSync: boolean = false,
filter: string = '',
sort: string = '-created',
expand: Record<string, any> | string[] | string = {}
): Promise<T[]> {
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<T>(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<T extends BaseRecord>(collection: string, id: string, forceSync: boolean = false): Promise<T | undefined> {
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<T>(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<T extends BaseRecord>(
collection: string,
id: string,
data: Partial<T>
): Promise<T | undefined> {
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<void> {
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<any, string> | 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;
}
}
}

View file

@ -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<User, string>;
events!: Dexie.Table<Event, string>;
eventRequests!: Dexie.Table<EventRequest, string>;
logs!: Dexie.Table<Log, string>;
officers!: Dexie.Table<Officer, string>;
reimbursements!: Dexie.Table<Reimbursement, string>;
receipts!: Dexie.Table<Receipt, string>;
sponsors!: Dexie.Table<Sponsor, string>;
offlineChanges!: Dexie.Table<OfflineChange, string>;
// 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<void> {
await this.db.syncInfo.update(collection, { lastSync: Date.now() });
}
// Get the last sync timestamp for a collection
public async getLastSync(collection: string): Promise<number> {
const info = await this.db.syncInfo.get(collection);
return info?.lastSync || 0;
}
// Clear all data (useful for logout)
public async clearAllData(): Promise<void> {
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 });
}
}
}

View file

@ -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<void> {
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<boolean> {
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;
}
}

View file

@ -21,6 +21,7 @@ export class Authentication {
private static instance: Authentication; private static instance: Authentication;
private authChangeCallbacks: ((isValid: boolean) => void)[] = []; private authChangeCallbacks: ((isValid: boolean) => void)[] = [];
private isUpdating: boolean = false; private isUpdating: boolean = false;
private authSyncServiceInitialized: boolean = false;
private constructor() { private constructor() {
// Use the baseUrl from the config file // Use the baseUrl from the config file
@ -82,8 +83,27 @@ export class Authentication {
/** /**
* Handle user logout * Handle user logout
*/ */
public logout(): void { public async logout(): Promise<void> {
this.pb.authStore.clear(); 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 { public onAuthStateChange(callback: (isValid: boolean) => void): void {
this.authChangeCallbacks.push(callback); 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; const isValid = this.pb.authStore.isValid;
this.authChangeCallbacks.forEach((callback) => callback(isValid)); this.authChangeCallbacks.forEach((callback) => callback(isValid));
} }
/**
* Initialize the AuthSyncService (lazy loading)
*/
private async initAuthSyncService(): Promise<void> {
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);
}
}
} }

View file

@ -10,7 +10,7 @@ interface BaseRecord {
interface RequestOptions { interface RequestOptions {
fields?: string[]; fields?: string[];
disableAutoCancellation?: boolean; disableAutoCancellation?: boolean;
expand?: string[]; expand?: string[] | string;
} }
// Utility function to check if a value is a UTC date string // Utility function to check if a value is a UTC date string
@ -130,21 +130,39 @@ export class Get {
options?: RequestOptions, options?: RequestOptions,
): Promise<T> { ): Promise<T> {
if (!this.auth.isAuthenticated()) { 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 { try {
const pb = this.auth.getPocketBase(); 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 = { const requestOptions = {
...(options?.fields && { fields: options.fields.join(",") }), ...(options?.fields && { fields: options.fields.join(",") }),
...(expandString && { expand: expandString }),
...(options?.disableAutoCancellation && { requestKey: null }), ...(options?.disableAutoCancellation && { requestKey: null }),
}; };
const result = await pb const result = await pb
.collection(collectionName) .collection(collectionName)
.getOne<T>(recordId, requestOptions); .getOne<T>(recordId, requestOptions);
return convertUTCToLocal(result); return convertUTCToLocal(result);
} catch (err) { } catch (err) {
console.error(`Failed to get record from ${collectionName}:`, err); console.error(
`Failed to get record ${recordId} from ${collectionName}:`,
err,
);
throw err; throw err;
} }
} }
@ -162,29 +180,43 @@ export class Get {
options?: RequestOptions, options?: RequestOptions,
): Promise<T[]> { ): Promise<T[]> {
if (!this.auth.isAuthenticated()) { 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 { try {
// Build filter for multiple IDs
const filter = recordIds.map((id) => `id="${id}"`).join(" || ");
const pb = this.auth.getPocketBase(); 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 = { const requestOptions = {
filter, filter,
...(options?.fields && { fields: options.fields.join(",") }), ...(options?.fields && { fields: options.fields.join(",") }),
...(expandString && { expand: expandString }),
...(options?.disableAutoCancellation && { requestKey: null }), ...(options?.disableAutoCancellation && { requestKey: null }),
}; };
const result = await pb const result = await pb
.collection(collectionName) .collection(collectionName)
.getFullList<T>(requestOptions); .getList<T>(1, recordIds.length, requestOptions);
return result.items.map((item) => convertUTCToLocal(item));
// 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[];
} catch (err) { } catch (err) {
console.error(`Failed to get records from ${collectionName}:`, err); console.error(
`Failed to get records ${recordIds.join(", ")} from ${collectionName}:`,
err,
);
throw err; throw err;
} }
} }
@ -257,16 +289,33 @@ export class Get {
sort?: string, sort?: string,
options?: RequestOptions, options?: RequestOptions,
): Promise<T[]> { ): Promise<T[]> {
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 // Try to get records even if authentication check fails
// This is a workaround for cases where isAuthenticated() returns false // This is a workaround for cases where isAuthenticated() returns false
// but the token is still valid for API requests // but the token is still valid for API requests
try { try {
const pb = this.auth.getPocketBase(); 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 = { const requestOptions = {
...(filter && { filter }), ...(filter && { filter }),
...(sort && { sort }), ...(sort && { sort }),
...(options?.fields && { fields: options.fields.join(",") }), ...(options?.fields && { fields: options.fields.join(",") }),
...(options?.expand && { expand: options.expand.join(",") }), ...(expandString && { expand: expandString }),
...(options?.disableAutoCancellation && { requestKey: null }), ...(options?.disableAutoCancellation && { requestKey: null }),
}; };
@ -276,18 +325,6 @@ export class Get {
return result.map((item) => convertUTCToLocal(item)); return result.map((item) => convertUTCToLocal(item));
} catch (err) { } catch (err) {
console.error(`Failed to get all records from ${collectionName}:`, 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; throw err;
} }
} }