
- Introduced a new `notes.md` file to document critical tasks and progress for stabilizing the core functionality of the MitList application. - Documented the status and findings for key tasks, including backend financial logic fixes, frontend expense split settlement implementation, and core authentication flow reviews. - Outlined remaining work for production deployment, including secret management, CI/CD pipeline setup, and performance optimizations. - Updated the logging configuration to change the log level to WARNING for production readiness. - Enhanced the database connection settings to disable SQL query logging in production. - Added a new endpoint to list all chores for improved user experience and optimized database queries. - Implemented various CRUD operations for chore assignments, including creation, retrieval, updating, and deletion. - Updated frontend components and services to support new chore assignment features and improved error handling. - Enhanced the expense management system with new fields and improved API interactions for better user experience.
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
// src/stores/offline.ts (Example modification)
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
// import { LocalStorage } from 'quasar'; // REMOVE
|
|
import { useStorage } from '@vueuse/core' // VueUse alternative
|
|
import { useNotificationStore } from '@/stores/notifications' // Your custom notification store
|
|
import { api } from '@/services/api'
|
|
import { API_ENDPOINTS } from '@/config/api-config'
|
|
|
|
export type CreateListPayload = { name: string; description?: string /* other list properties */ }
|
|
export type UpdateListPayload = {
|
|
listId: string
|
|
data: Partial<CreateListPayload>
|
|
version?: number
|
|
}
|
|
export type DeleteListPayload = { listId: string }
|
|
export type CreateListItemPayload = {
|
|
listId: string
|
|
itemData: {
|
|
name: string
|
|
quantity?: number | string
|
|
completed?: boolean
|
|
price?: number | null /* other item properties */
|
|
}
|
|
}
|
|
export type UpdateListItemPayload = {
|
|
listId: string
|
|
itemId: string
|
|
data: Partial<CreateListItemPayload['itemData']>
|
|
version?: number
|
|
}
|
|
export type DeleteListItemPayload = { listId: string; itemId: string }
|
|
|
|
export type OfflineAction = {
|
|
id: string
|
|
timestamp: number
|
|
type:
|
|
| 'create_list'
|
|
| 'update_list'
|
|
| 'delete_list'
|
|
| 'create_list_item'
|
|
| 'update_list_item'
|
|
| 'delete_list_item'
|
|
payload:
|
|
| CreateListPayload
|
|
| UpdateListPayload
|
|
| DeleteListPayload
|
|
| CreateListItemPayload
|
|
| UpdateListItemPayload
|
|
| DeleteListItemPayload
|
|
}
|
|
|
|
export type ConflictData = {
|
|
localVersion: { data: Record<string, unknown>; timestamp: number }
|
|
serverVersion: { data: Record<string, unknown>; timestamp: number }
|
|
action: OfflineAction
|
|
}
|
|
|
|
interface ServerListData {
|
|
id: string
|
|
version: number
|
|
name: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface ServerItemData {
|
|
id: string
|
|
version: number
|
|
name: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
export const useOfflineStore = defineStore('offline', () => {
|
|
// const $q = useQuasar(); // REMOVE
|
|
const notificationStore = useNotificationStore()
|
|
const isOnline = ref(navigator.onLine)
|
|
|
|
// Use useStorage for reactive localStorage
|
|
const pendingActions = useStorage<OfflineAction[]>('offline-actions', [])
|
|
|
|
const isProcessingQueue = ref(false)
|
|
const showConflictDialog = ref(false) // You'll need to implement this dialog
|
|
const currentConflict = ref<ConflictData | null>(null)
|
|
|
|
// init is now handled by useStorage automatically loading the value
|
|
|
|
// saveToStorage is also handled by useStorage automatically saving on change
|
|
|
|
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
|
|
const newAction = {
|
|
...action,
|
|
id: crypto.randomUUID(),
|
|
timestamp: Date.now(),
|
|
} as OfflineAction
|
|
pendingActions.value.push(newAction)
|
|
}
|
|
|
|
const processQueue = async () => {
|
|
if (isProcessingQueue.value || !isOnline.value) return
|
|
isProcessingQueue.value = true
|
|
const actionsToProcess = [...pendingActions.value] // Create a copy to iterate
|
|
|
|
for (const action of actionsToProcess) {
|
|
try {
|
|
await processAction(action)
|
|
pendingActions.value = pendingActions.value.filter((a) => a.id !== action.id)
|
|
} catch (error: any) {
|
|
// Catch error as any to check for our custom flag
|
|
if (error && error.isConflict && error.serverVersionData) {
|
|
notificationStore.addNotification({
|
|
type: 'warning',
|
|
message: `Conflict detected for action ${action.type}. Please review.`,
|
|
})
|
|
|
|
let localData: Record<string, unknown>
|
|
// Extract local data based on action type
|
|
if (action.type === 'update_list' || action.type === 'update_list_item') {
|
|
localData = (action.payload as UpdateListPayload | UpdateListItemPayload).data
|
|
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
|
|
localData = action.payload as CreateListPayload | CreateListItemPayload
|
|
} else {
|
|
console.error(
|
|
'Conflict detected for unhandled action type for data extraction:',
|
|
action.type,
|
|
)
|
|
localData = {} // Fallback
|
|
}
|
|
|
|
currentConflict.value = {
|
|
localVersion: {
|
|
data: localData,
|
|
timestamp: action.timestamp,
|
|
},
|
|
serverVersion: {
|
|
data: error.serverVersionData, // Assumes API 409 response body is the server item
|
|
timestamp: error.serverVersionData.updated_at
|
|
? new Date(error.serverVersionData.updated_at).getTime()
|
|
: action.timestamp + 1, // Prefer server updated_at
|
|
},
|
|
action: action,
|
|
}
|
|
showConflictDialog.value = true
|
|
console.warn('Conflict detected by processQueue for action:', action.id, error)
|
|
// Stop processing queue on first conflict to await resolution
|
|
isProcessingQueue.value = false // Allow queue to be re-triggered after resolution
|
|
return // Stop processing further actions
|
|
} else {
|
|
console.error('processQueue: Action failed, remains in queue:', action.id, error)
|
|
}
|
|
}
|
|
}
|
|
isProcessingQueue.value = false
|
|
}
|
|
|
|
const processAction = async (action: OfflineAction) => {
|
|
try {
|
|
let request: Request
|
|
let endpoint: string
|
|
let method: 'POST' | 'PUT' | 'DELETE' = 'POST'
|
|
let body: any
|
|
|
|
switch (action.type) {
|
|
case 'create_list':
|
|
endpoint = API_ENDPOINTS.LISTS.BASE
|
|
body = action.payload
|
|
break
|
|
case 'update_list': {
|
|
const { listId, data } = action.payload as UpdateListPayload
|
|
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
|
|
method = 'PUT'
|
|
body = data
|
|
break
|
|
}
|
|
case 'delete_list': {
|
|
const { listId } = action.payload as DeleteListPayload
|
|
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
|
|
method = 'DELETE'
|
|
break
|
|
}
|
|
case 'create_list_item': {
|
|
const { listId, itemData } = action.payload as CreateListItemPayload
|
|
endpoint = API_ENDPOINTS.LISTS.ITEMS(listId)
|
|
body = itemData
|
|
break
|
|
}
|
|
case 'update_list_item': {
|
|
const { listId, itemId, data } = action.payload as UpdateListItemPayload
|
|
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId)
|
|
method = 'PUT'
|
|
body = data
|
|
break
|
|
}
|
|
case 'delete_list_item': {
|
|
const { listId, itemId } = action.payload as DeleteListItemPayload
|
|
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId)
|
|
method = 'DELETE'
|
|
break
|
|
}
|
|
default:
|
|
throw new Error(`Unknown action type: ${action.type}`)
|
|
}
|
|
|
|
// Create the request with the action metadata
|
|
request = new Request(endpoint, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Offline-Action': action.id,
|
|
},
|
|
body: method !== 'DELETE' ? JSON.stringify(body) : undefined,
|
|
})
|
|
|
|
// Use fetch with the request
|
|
const response = await fetch(request)
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 409) {
|
|
const error = new Error('Conflict detected') as any
|
|
error.isConflict = true
|
|
error.serverVersionData = await response.json()
|
|
throw error
|
|
}
|
|
throw new Error(`HTTP error! status: ${response.status}`)
|
|
}
|
|
|
|
// If successful, remove from pending actions
|
|
pendingActions.value = pendingActions.value.filter((a) => a.id !== action.id)
|
|
return await response.json()
|
|
} catch (error: any) {
|
|
if (error.isConflict) {
|
|
throw error
|
|
}
|
|
// For other errors, let Workbox handle the retry
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const setupNetworkListeners = () => {
|
|
window.addEventListener('online', () => {
|
|
isOnline.value = true
|
|
processQueue().catch((err) => console.error('Error processing queue on online event:', err))
|
|
})
|
|
window.addEventListener('offline', () => {
|
|
isOnline.value = false
|
|
})
|
|
}
|
|
|
|
setupNetworkListeners() // Call this once
|
|
|
|
const hasPendingActions = computed(() => pendingActions.value.length > 0)
|
|
const pendingActionCount = computed(() => pendingActions.value.length)
|
|
|
|
const handleConflictResolution = async (resolution: {
|
|
version: 'local' | 'server' | 'merge'
|
|
action: OfflineAction
|
|
mergedData?: Record<string, unknown>
|
|
}) => {
|
|
if (!resolution.action || !currentConflict.value) {
|
|
console.error('handleConflictResolution called without an action or active conflict.')
|
|
showConflictDialog.value = false
|
|
currentConflict.value = null
|
|
return
|
|
}
|
|
const { action, version, mergedData } = resolution
|
|
const serverVersionNumber = (currentConflict.value.serverVersion.data as any)?.version
|
|
|
|
try {
|
|
let success = false
|
|
if (version === 'local') {
|
|
let dataToPush: any
|
|
let endpoint: string
|
|
let method: 'post' | 'put' = 'put'
|
|
|
|
if (action.type === 'update_list') {
|
|
const payload = action.payload as UpdateListPayload
|
|
dataToPush = { ...payload.data, version: serverVersionNumber }
|
|
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId)
|
|
} else if (action.type === 'update_list_item') {
|
|
const payload = action.payload as UpdateListItemPayload
|
|
dataToPush = { ...payload.data, version: serverVersionNumber }
|
|
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId)
|
|
} else if (action.type === 'create_list') {
|
|
const serverData = currentConflict.value.serverVersion.data as ServerListData | null
|
|
if (serverData?.id) {
|
|
// Server returned existing list, update it instead
|
|
dataToPush = { ...action.payload, version: serverData.version }
|
|
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id)
|
|
} else {
|
|
// True conflict, need to modify the data
|
|
dataToPush = {
|
|
...action.payload,
|
|
name: `${(action.payload as CreateListPayload).name} (${new Date().toLocaleString()})`,
|
|
}
|
|
endpoint = API_ENDPOINTS.LISTS.BASE
|
|
method = 'post'
|
|
}
|
|
} else if (action.type === 'create_list_item') {
|
|
const serverData = currentConflict.value.serverVersion.data as ServerItemData | null
|
|
if (serverData?.id) {
|
|
// Server returned existing item, update it instead
|
|
dataToPush = { ...action.payload, version: serverData.version }
|
|
endpoint = API_ENDPOINTS.LISTS.ITEM(
|
|
(action.payload as CreateListItemPayload).listId,
|
|
serverData.id,
|
|
)
|
|
} else {
|
|
// True conflict, need to modify the data
|
|
dataToPush = {
|
|
...action.payload,
|
|
name: `${(action.payload as CreateListItemPayload).itemData.name} (${new Date().toLocaleString()})`,
|
|
}
|
|
endpoint = API_ENDPOINTS.LISTS.ITEMS((action.payload as CreateListItemPayload).listId)
|
|
method = 'post'
|
|
}
|
|
} else {
|
|
console.error("Unsupported action type for 'keep local' resolution:", action.type)
|
|
throw new Error("Unsupported action for 'keep local'")
|
|
}
|
|
|
|
if (method === 'put') {
|
|
await api.put(endpoint, dataToPush)
|
|
} else {
|
|
await api.post(endpoint, dataToPush)
|
|
}
|
|
success = true
|
|
notificationStore.addNotification({
|
|
type: 'success',
|
|
message: 'Your version was saved to the server.',
|
|
})
|
|
} else if (version === 'server') {
|
|
success = true
|
|
notificationStore.addNotification({
|
|
type: 'info',
|
|
message: 'Local changes discarded; server version kept.',
|
|
})
|
|
} else if (version === 'merge' && mergedData) {
|
|
let dataWithVersion: any
|
|
let endpoint: string
|
|
|
|
if (action.type === 'update_list') {
|
|
const payload = action.payload as UpdateListPayload
|
|
dataWithVersion = { ...mergedData, version: serverVersionNumber }
|
|
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId)
|
|
} else if (action.type === 'update_list_item') {
|
|
const payload = action.payload as UpdateListItemPayload
|
|
dataWithVersion = { ...mergedData, version: serverVersionNumber }
|
|
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId)
|
|
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
|
|
// For create actions, merging means updating the existing item
|
|
const serverData = currentConflict.value.serverVersion.data as
|
|
| (ServerListData | ServerItemData)
|
|
| null
|
|
if (!serverData?.id) {
|
|
throw new Error('Cannot merge create action: server data is missing or invalid')
|
|
}
|
|
|
|
if (action.type === 'create_list') {
|
|
dataWithVersion = { ...mergedData, version: serverData.version }
|
|
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id)
|
|
} else {
|
|
dataWithVersion = { ...mergedData, version: serverData.version }
|
|
endpoint = API_ENDPOINTS.LISTS.ITEM(
|
|
(action.payload as CreateListItemPayload).listId,
|
|
serverData.id,
|
|
)
|
|
}
|
|
} else {
|
|
console.error('Merge resolution for unsupported action type:', action.type)
|
|
throw new Error('Merge for this action type is not supported')
|
|
}
|
|
|
|
await api.put(endpoint, dataWithVersion)
|
|
success = true
|
|
notificationStore.addNotification({
|
|
type: 'success',
|
|
message: 'Merged version saved to the server.',
|
|
})
|
|
}
|
|
|
|
if (success) {
|
|
pendingActions.value = pendingActions.value.filter((a) => a.id !== action.id)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error during conflict resolution API call:', error)
|
|
notificationStore.addNotification({
|
|
type: 'error',
|
|
message: `Failed to resolve conflict for ${action.type}. Please try again.`,
|
|
})
|
|
} finally {
|
|
showConflictDialog.value = false
|
|
currentConflict.value = null
|
|
processQueue().catch((err) =>
|
|
console.error('Error processing queue after conflict resolution:', err),
|
|
)
|
|
}
|
|
}
|
|
|
|
return {
|
|
isOnline,
|
|
pendingActions,
|
|
isProcessingQueue,
|
|
showConflictDialog,
|
|
currentConflict,
|
|
addAction,
|
|
processAction,
|
|
processQueue,
|
|
handleConflictResolution,
|
|
hasPendingActions,
|
|
pendingActionCount,
|
|
}
|
|
})
|