Offline First: sync bank, accounts, and categories
This commit is contained in:
parent
80949a8ec8
commit
9d95f884a0
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sync;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountSyncController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Account::query()
|
||||
->with('bank')
|
||||
->where('user_id', $request->user()->id);
|
||||
|
||||
if ($request->has('since')) {
|
||||
$query->where('updated_at', '>', $request->input('since'));
|
||||
}
|
||||
|
||||
$accounts = $query->orderBy('updated_at', 'asc')->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $accounts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sync;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Bank;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BankSyncController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Bank::query()->where(function ($q) use ($request) {
|
||||
$q->whereNull('user_id')
|
||||
->orWhere('user_id', $request->user()->id);
|
||||
});
|
||||
|
||||
if ($request->has('since')) {
|
||||
$query->where('updated_at', '>', $request->input('since'));
|
||||
}
|
||||
|
||||
$banks = $query->orderBy('updated_at', 'asc')->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $banks,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sync;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategorySyncController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Category::query()->where('user_id', $request->user()->id);
|
||||
|
||||
if ($request->has('since')) {
|
||||
$query->where('updated_at', '>', $request->input('since'));
|
||||
}
|
||||
|
||||
$categories = $query->orderBy('updated_at', 'asc')->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
}
|
||||
7
bun.lock
7
bun.lock
|
|
@ -29,10 +29,11 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
|
|
@ -534,6 +535,8 @@
|
|||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
|
@ -806,7 +809,7 @@
|
|||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.475.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg=="],
|
||||
"lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -52,10 +52,11 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { EncryptionKeyProvider } from './contexts/encryption-key-context';
|
||||
import { SyncProvider } from './contexts/sync-context';
|
||||
import { initializeTheme } from './hooks/use-appearance';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
|
@ -22,7 +23,9 @@ createInertiaApp({
|
|||
root.render(
|
||||
<StrictMode>
|
||||
<EncryptionKeyProvider>
|
||||
<App {...props} />
|
||||
<SyncProvider>
|
||||
<App {...props} />
|
||||
</SyncProvider>
|
||||
</EncryptionKeyProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
|
|
@ -52,13 +52,7 @@ export function BankCombobox({
|
|||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await axios.get<{ banks: Bank[] }>(
|
||||
'/settings/banks/search',
|
||||
{
|
||||
params: { query },
|
||||
},
|
||||
);
|
||||
const results = response.data.banks;
|
||||
const results = await bankSyncService.search(query);
|
||||
bankCache.set(query, results);
|
||||
setBanks(results);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { EncryptionKeyButton } from '@/components/encryption-key-button';
|
||||
import { SyncStatusButton } from '@/components/sync-status-button';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
|
|
@ -14,7 +15,10 @@ export function AppSidebarHeader({
|
|||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
<EncryptionKeyButton />
|
||||
<div className="flex items-center gap-2">
|
||||
<SyncStatusButton />
|
||||
<EncryptionKeyButton />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { Cloud, CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export function SyncStatusButton() {
|
||||
const { syncStatus, lastSyncTime, isOnline, sync, error } =
|
||||
useSyncContext();
|
||||
|
||||
const getIcon = () => {
|
||||
if (syncStatus === 'syncing') {
|
||||
return <RefreshCw className="h-4 w-4 animate-spin" />;
|
||||
}
|
||||
|
||||
if (syncStatus === 'error') {
|
||||
return <CloudAlert className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (!isOnline) {
|
||||
return <CloudOff className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
return <CloudCheck className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getTooltipText = () => {
|
||||
if (syncStatus === 'syncing') {
|
||||
return 'Syncing...';
|
||||
}
|
||||
|
||||
if (!isOnline) {
|
||||
return 'Offline - Click to retry when online';
|
||||
}
|
||||
|
||||
if (syncStatus === 'error') {
|
||||
return error || 'Sync failed - Click to retry';
|
||||
}
|
||||
|
||||
if (lastSyncTime) {
|
||||
return `Synced ${formatDistanceToNow(lastSyncTime, { addSuffix: true })}`;
|
||||
}
|
||||
|
||||
return 'Not synced yet - Click to sync';
|
||||
};
|
||||
|
||||
const getVariant = () => {
|
||||
if (syncStatus === 'error' || !isOnline) {
|
||||
return 'destructive' as const;
|
||||
}
|
||||
return 'ghost' as const;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={getVariant()}
|
||||
size="icon"
|
||||
onClick={sync}
|
||||
disabled={syncStatus === 'syncing' || !isOnline}
|
||||
>
|
||||
{getIcon()}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getTooltipText()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useOnlineStatus } from '@/hooks/use-online-status';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
|
||||
|
||||
interface SyncContextType {
|
||||
syncStatus: SyncStatus;
|
||||
lastSyncTime: Date | null;
|
||||
isOnline: boolean;
|
||||
sync: () => Promise<void>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SyncContext = createContext<SyncContextType | undefined>(undefined);
|
||||
|
||||
const SYNC_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
export function SyncProvider({ children }: { children: ReactNode }) {
|
||||
const isOnline = useOnlineStatus();
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
|
||||
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [wasOffline, setWasOffline] = useState(!isOnline);
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
if (!isOnline) {
|
||||
setError('Cannot sync while offline');
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncStatus === 'syncing') {
|
||||
return;
|
||||
}
|
||||
|
||||
setSyncStatus('syncing');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [categoriesResult, accountsResult, banksResult] =
|
||||
await Promise.all([
|
||||
categorySyncService.sync(),
|
||||
accountSyncService.sync(),
|
||||
bankSyncService.sync(),
|
||||
]);
|
||||
|
||||
const allErrors = [
|
||||
...categoriesResult.errors,
|
||||
...accountsResult.errors,
|
||||
...banksResult.errors,
|
||||
];
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
setError(allErrors.join(', '));
|
||||
setSyncStatus('error');
|
||||
} else {
|
||||
setSyncStatus('success');
|
||||
setLastSyncTime(new Date());
|
||||
|
||||
setTimeout(() => {
|
||||
setSyncStatus('idle');
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Unknown sync error',
|
||||
);
|
||||
setSyncStatus('error');
|
||||
|
||||
setTimeout(() => {
|
||||
setSyncStatus('idle');
|
||||
}, 5000);
|
||||
}
|
||||
}, [isOnline, syncStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnline && wasOffline) {
|
||||
sync();
|
||||
}
|
||||
setWasOffline(!isOnline);
|
||||
}, [isOnline, wasOffline, sync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
sync();
|
||||
}, SYNC_INTERVAL);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOnline, sync]);
|
||||
|
||||
useEffect(() => {
|
||||
sync();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SyncContext.Provider
|
||||
value={{
|
||||
syncStatus,
|
||||
lastSyncTime,
|
||||
isOnline,
|
||||
sync,
|
||||
error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SyncContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSyncContext() {
|
||||
const context = useContext(SyncContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSyncContext must be used within a SyncProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
return navigator.onLine;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
const DB_NAME = 'whisper_money';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
export interface IndexedDBRecord {
|
||||
id: number;
|
||||
user_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type StoreName = 'categories' | 'accounts' | 'banks';
|
||||
|
||||
class IndexedDBService {
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
async init(): Promise<IDBDatabase> {
|
||||
if (this.db) {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
this.initPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve(this.db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains('categories')) {
|
||||
const categoryStore = db.createObjectStore('categories', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
categoryStore.createIndex('user_id', 'user_id', {
|
||||
unique: false,
|
||||
});
|
||||
categoryStore.createIndex('updated_at', 'updated_at', {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('accounts')) {
|
||||
const accountStore = db.createObjectStore('accounts', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
accountStore.createIndex('user_id', 'user_id', {
|
||||
unique: false,
|
||||
});
|
||||
accountStore.createIndex('updated_at', 'updated_at', {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('banks')) {
|
||||
const bankStore = db.createObjectStore('banks', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
bankStore.createIndex('user_id', 'user_id', {
|
||||
unique: false,
|
||||
});
|
||||
bankStore.createIndex('updated_at', 'updated_at', {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('sync_metadata')) {
|
||||
db.createObjectStore('sync_metadata', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('pending_changes')) {
|
||||
const pendingStore = db.createObjectStore('pending_changes', {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true,
|
||||
});
|
||||
pendingStore.createIndex('store', 'store', {
|
||||
unique: false,
|
||||
});
|
||||
pendingStore.createIndex('timestamp', 'timestamp', {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
async getAll<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
): Promise<T[]> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as T[]);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get all from ${storeName}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getById<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
id: number,
|
||||
): Promise<T | null> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as T | null);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get from ${storeName}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async put<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
record: T,
|
||||
): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.put(record);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to put to ${storeName}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async putMany<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
records: T[],
|
||||
): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
|
||||
let completed = 0;
|
||||
let hasError = false;
|
||||
|
||||
records.forEach((record) => {
|
||||
const request = store.put(record);
|
||||
|
||||
request.onsuccess = () => {
|
||||
completed++;
|
||||
if (completed === records.length && !hasError) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
if (!hasError) {
|
||||
hasError = true;
|
||||
reject(
|
||||
new Error(`Failed to put many to ${storeName}`),
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (records.length === 0) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(storeName: StoreName, id: number): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to delete from ${storeName}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async clear(storeName: StoreName): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to clear ${storeName}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getSyncMetadata(key: string): Promise<any> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('sync_metadata', 'readonly');
|
||||
const store = transaction.objectStore('sync_metadata');
|
||||
const request = store.get(key);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result?.value);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to get sync metadata'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async setSyncMetadata(key: string, value: any): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('sync_metadata', 'readwrite');
|
||||
const store = transaction.objectStore('sync_metadata');
|
||||
const request = store.put({ key, value });
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to set sync metadata'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addPendingChange(
|
||||
storeName: StoreName,
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('pending_changes', 'readwrite');
|
||||
const store = transaction.objectStore('pending_changes');
|
||||
const request = store.add({
|
||||
store: storeName,
|
||||
operation,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to add pending change'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingChanges(storeName?: StoreName): Promise<any[]> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('pending_changes', 'readonly');
|
||||
const store = transaction.objectStore('pending_changes');
|
||||
|
||||
let request: IDBRequest;
|
||||
if (storeName) {
|
||||
const index = store.index('store');
|
||||
request = index.getAll(storeName);
|
||||
} else {
|
||||
request = store.getAll();
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to get pending changes'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async clearPendingChanges(storeName?: StoreName): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('pending_changes', 'readwrite');
|
||||
const store = transaction.objectStore('pending_changes');
|
||||
|
||||
if (!storeName) {
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(new Error('Failed to clear pending changes'));
|
||||
return;
|
||||
}
|
||||
|
||||
const index = store.index('store');
|
||||
const request = index.openCursor(IDBKeyRange.only(storeName));
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to clear pending changes'));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const indexedDBService = new IndexedDBService();
|
||||
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
import axios from 'axios';
|
||||
import {
|
||||
indexedDBService,
|
||||
type StoreName,
|
||||
type IndexedDBRecord,
|
||||
} from './indexeddb';
|
||||
|
||||
export interface SyncOptions {
|
||||
storeName: StoreName;
|
||||
endpoint: string;
|
||||
transformFromServer?: (data: any) => any;
|
||||
transformToServer?: (data: any) => any;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class SyncManager {
|
||||
private syncInProgress = false;
|
||||
private lastSyncKey: string;
|
||||
|
||||
constructor(private options: SyncOptions) {
|
||||
this.lastSyncKey = `last_sync_${options.storeName}`;
|
||||
}
|
||||
|
||||
async getLastSyncTime(): Promise<string | null> {
|
||||
return await indexedDBService.getSyncMetadata(this.lastSyncKey);
|
||||
}
|
||||
|
||||
async setLastSyncTime(timestamp: string): Promise<void> {
|
||||
await indexedDBService.setSyncMetadata(this.lastSyncKey, timestamp);
|
||||
}
|
||||
|
||||
async sync(): Promise<SyncResult> {
|
||||
if (this.syncInProgress) {
|
||||
return {
|
||||
success: false,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
errors: ['Sync already in progress'],
|
||||
};
|
||||
}
|
||||
|
||||
this.syncInProgress = true;
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await this.syncFromServer(result);
|
||||
await this.syncToServer(result);
|
||||
|
||||
await indexedDBService.clearPendingChanges(
|
||||
this.options.storeName,
|
||||
);
|
||||
|
||||
await this.setLastSyncTime(new Date().toISOString());
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.errors.push(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async syncFromServer(result: SyncResult): Promise<void> {
|
||||
const lastSync = await this.getLastSyncTime();
|
||||
|
||||
const params: any = {};
|
||||
if (lastSync) {
|
||||
params.since = lastSync;
|
||||
}
|
||||
|
||||
const response = await axios.get(this.options.endpoint, { params });
|
||||
|
||||
const serverData = response.data.data || response.data;
|
||||
|
||||
if (!Array.isArray(serverData)) {
|
||||
throw new Error('Invalid server response format');
|
||||
}
|
||||
|
||||
const localRecords = await indexedDBService.getAll<IndexedDBRecord>(
|
||||
this.options.storeName,
|
||||
);
|
||||
const localMap = new Map(localRecords.map((r) => [r.id, r]));
|
||||
|
||||
for (const serverRecord of serverData) {
|
||||
const transformed = this.options.transformFromServer
|
||||
? this.options.transformFromServer(serverRecord)
|
||||
: serverRecord;
|
||||
|
||||
const localRecord = localMap.get(transformed.id);
|
||||
|
||||
if (!localRecord) {
|
||||
await indexedDBService.put(
|
||||
this.options.storeName,
|
||||
transformed,
|
||||
);
|
||||
result.inserted++;
|
||||
} else {
|
||||
const serverDate = new Date(transformed.updated_at);
|
||||
const localDate = new Date(localRecord.updated_at);
|
||||
|
||||
if (serverDate > localDate) {
|
||||
await indexedDBService.put(
|
||||
this.options.storeName,
|
||||
transformed,
|
||||
);
|
||||
result.updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncToServer(result: SyncResult): Promise<void> {
|
||||
const pendingChanges = await indexedDBService.getPendingChanges(
|
||||
this.options.storeName,
|
||||
);
|
||||
|
||||
for (const change of pendingChanges) {
|
||||
try {
|
||||
const data = this.options.transformToServer
|
||||
? this.options.transformToServer(change.data)
|
||||
: change.data;
|
||||
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
await axios.post(this.options.endpoint, data);
|
||||
result.inserted++;
|
||||
break;
|
||||
case 'update':
|
||||
await axios.patch(
|
||||
`${this.options.endpoint}/${change.data.id}`,
|
||||
data,
|
||||
);
|
||||
result.updated++;
|
||||
break;
|
||||
case 'delete':
|
||||
await axios.delete(
|
||||
`${this.options.endpoint}/${change.data.id}`,
|
||||
);
|
||||
result.deleted++;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push(
|
||||
`Failed to sync ${change.operation} for ${this.options.storeName}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createLocal<T extends IndexedDBRecord>(
|
||||
data: Omit<T, 'id' | 'created_at' | 'updated_at'>,
|
||||
): Promise<T> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const tempId = Date.now();
|
||||
|
||||
const record = {
|
||||
...data,
|
||||
id: tempId,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
} as T;
|
||||
|
||||
await indexedDBService.put(this.options.storeName, record);
|
||||
await indexedDBService.addPendingChange(
|
||||
this.options.storeName,
|
||||
'create',
|
||||
record,
|
||||
);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async updateLocal<T extends IndexedDBRecord>(
|
||||
id: number,
|
||||
data: Partial<T>,
|
||||
): Promise<void> {
|
||||
const existing = await indexedDBService.getById<T>(
|
||||
this.options.storeName,
|
||||
id,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Record ${id} not found in ${this.options.storeName}`);
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await indexedDBService.put(this.options.storeName, updated);
|
||||
await indexedDBService.addPendingChange(
|
||||
this.options.storeName,
|
||||
'update',
|
||||
updated,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteLocal(id: number): Promise<void> {
|
||||
await indexedDBService.delete(this.options.storeName, id);
|
||||
await indexedDBService.addPendingChange(
|
||||
this.options.storeName,
|
||||
'delete',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
async getAll<T extends IndexedDBRecord>(): Promise<T[]> {
|
||||
return await indexedDBService.getAll<T>(this.options.storeName);
|
||||
}
|
||||
|
||||
async getById<T extends IndexedDBRecord>(id: number): Promise<T | null> {
|
||||
return await indexedDBService.getById<T>(this.options.storeName, id);
|
||||
}
|
||||
|
||||
isSyncing(): boolean {
|
||||
return this.syncInProgress;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import {
|
||||
ColumnDef,
|
||||
|
|
@ -45,10 +45,8 @@ import {
|
|||
} from '@/types/account';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
|
||||
|
||||
interface AccountsProps {
|
||||
accounts: Account[];
|
||||
}
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -98,13 +96,34 @@ function AccountActions({ account }: { account: Account }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function Accounts({ accounts }: AccountsProps) {
|
||||
export default function Accounts() {
|
||||
const { syncStatus } = useSyncContext();
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAccounts = async () => {
|
||||
const data = await accountSyncService.getAll();
|
||||
setAccounts(data);
|
||||
};
|
||||
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncStatus === 'success') {
|
||||
const reloadAccounts = async () => {
|
||||
const data = await accountSyncService.getAll();
|
||||
setAccounts(data);
|
||||
};
|
||||
reloadAccounts();
|
||||
}
|
||||
}, [syncStatus]);
|
||||
|
||||
const columns: ColumnDef<Account>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import {
|
||||
ColumnDef,
|
||||
|
|
@ -45,10 +45,8 @@ import {
|
|||
} from '@/types/category';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
|
||||
interface CategoriesProps {
|
||||
categories: Category[];
|
||||
}
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -98,13 +96,34 @@ function CategoryActions({ category }: { category: Category }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function Categories({ categories }: CategoriesProps) {
|
||||
export default function Categories() {
|
||||
const { syncStatus } = useSyncContext();
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const data = await categorySyncService.getAll();
|
||||
setCategories(data);
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncStatus === 'success') {
|
||||
const reloadCategories = async () => {
|
||||
const data = await categorySyncService.getAll();
|
||||
setCategories(data);
|
||||
};
|
||||
reloadCategories();
|
||||
}
|
||||
}, [syncStatus]);
|
||||
|
||||
const columns: ColumnDef<Category>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { SyncManager } from '@/lib/sync-manager';
|
||||
import type { Account } from '@/types/account';
|
||||
|
||||
class AccountSyncService {
|
||||
private syncManager: SyncManager;
|
||||
|
||||
constructor() {
|
||||
this.syncManager = new SyncManager({
|
||||
storeName: 'accounts',
|
||||
endpoint: '/api/sync/accounts',
|
||||
});
|
||||
}
|
||||
|
||||
async sync() {
|
||||
return await this.syncManager.sync();
|
||||
}
|
||||
|
||||
async getAll(): Promise<Account[]> {
|
||||
return await this.syncManager.getAll<Account>();
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<Account | null> {
|
||||
return await this.syncManager.getById<Account>(id);
|
||||
}
|
||||
|
||||
async create(data: Omit<Account, 'id'>): Promise<Account> {
|
||||
return await this.syncManager.createLocal<Account>(data as any);
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<Account>): Promise<void> {
|
||||
await this.syncManager.updateLocal<Account>(id, data);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.syncManager.deleteLocal(id);
|
||||
}
|
||||
|
||||
async getLastSyncTime(): Promise<string | null> {
|
||||
return await this.syncManager.getLastSyncTime();
|
||||
}
|
||||
|
||||
isSyncing(): boolean {
|
||||
return this.syncManager.isSyncing();
|
||||
}
|
||||
}
|
||||
|
||||
export const accountSyncService = new AccountSyncService();
|
||||
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { SyncManager } from '@/lib/sync-manager';
|
||||
import type { Bank } from '@/types/account';
|
||||
|
||||
class BankSyncService {
|
||||
private syncManager: SyncManager;
|
||||
|
||||
constructor() {
|
||||
this.syncManager = new SyncManager({
|
||||
storeName: 'banks',
|
||||
endpoint: '/api/sync/banks',
|
||||
});
|
||||
}
|
||||
|
||||
async sync() {
|
||||
return await this.syncManager.sync();
|
||||
}
|
||||
|
||||
async getAll(): Promise<Bank[]> {
|
||||
return await this.syncManager.getAll<Bank>();
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<Bank | null> {
|
||||
return await this.syncManager.getById<Bank>(id);
|
||||
}
|
||||
|
||||
async search(query: string): Promise<Bank[]> {
|
||||
const allBanks = await this.getAll();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allBanks.filter((bank) =>
|
||||
bank.name.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}
|
||||
|
||||
async getLastSyncTime(): Promise<string | null> {
|
||||
return await this.syncManager.getLastSyncTime();
|
||||
}
|
||||
|
||||
isSyncing(): boolean {
|
||||
return this.syncManager.isSyncing();
|
||||
}
|
||||
}
|
||||
|
||||
export const bankSyncService = new BankSyncService();
|
||||
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { SyncManager } from '@/lib/sync-manager';
|
||||
import type { Category } from '@/types/category';
|
||||
|
||||
class CategorySyncService {
|
||||
private syncManager: SyncManager;
|
||||
|
||||
constructor() {
|
||||
this.syncManager = new SyncManager({
|
||||
storeName: 'categories',
|
||||
endpoint: '/api/sync/categories',
|
||||
});
|
||||
}
|
||||
|
||||
async sync() {
|
||||
return await this.syncManager.sync();
|
||||
}
|
||||
|
||||
async getAll(): Promise<Category[]> {
|
||||
return await this.syncManager.getAll<Category>();
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<Category | null> {
|
||||
return await this.syncManager.getById<Category>(id);
|
||||
}
|
||||
|
||||
async create(data: Omit<Category, 'id'>): Promise<Category> {
|
||||
return await this.syncManager.createLocal<Category>(data as any);
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<Category>): Promise<void> {
|
||||
await this.syncManager.updateLocal<Category>(id, data);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.syncManager.deleteLocal(id);
|
||||
}
|
||||
|
||||
async getLastSyncTime(): Promise<string | null> {
|
||||
return await this.syncManager.getLastSyncTime();
|
||||
}
|
||||
|
||||
isSyncing(): boolean {
|
||||
return this.syncManager.isSyncing();
|
||||
}
|
||||
}
|
||||
|
||||
export const categorySyncService = new CategorySyncService();
|
||||
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\EncryptionController;
|
||||
use App\Http\Controllers\Sync\AccountSyncController;
|
||||
use App\Http\Controllers\Sync\BankSyncController;
|
||||
use App\Http\Controllers\Sync\CategorySyncController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
|
|
@ -19,6 +22,10 @@ Route::middleware(['auth'])->group(function () {
|
|||
Route::post('api/encryption/setup', [EncryptionController::class, 'setup']);
|
||||
Route::get('api/encryption/message', [EncryptionController::class, 'getMessage']);
|
||||
Route::put('api/encryption/message', [EncryptionController::class, 'updateMessage']);
|
||||
|
||||
Route::get('api/sync/categories', [CategorySyncController::class, 'index']);
|
||||
Route::get('api/sync/accounts', [AccountSyncController::class, 'index']);
|
||||
Route::get('api/sync/banks', [BankSyncController::class, 'index']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
});
|
||||
|
||||
it('returns all accounts for authenticated user', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$bank = Bank::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$accounts = Account::factory()
|
||||
->count(3)
|
||||
->create([
|
||||
'user_id' => $this->user->id,
|
||||
'bank_id' => $bank->id,
|
||||
]);
|
||||
|
||||
Account::factory()
|
||||
->count(2)
|
||||
->create();
|
||||
|
||||
$response = getJson('/api/sync/accounts');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(3, 'data');
|
||||
});
|
||||
|
||||
it('returns only accounts updated after specified timestamp', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$bank = Bank::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$oldAccount = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'bank_id' => $bank->id,
|
||||
'updated_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$newAccount = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'bank_id' => $bank->id,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = getJson('/api/sync/accounts?since='.now()->subDay()->toIso8601String());
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(1, 'data');
|
||||
$response->assertJsonPath('data.0.id', $newAccount->id);
|
||||
});
|
||||
|
||||
it('includes bank relationship', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$bank = Bank::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'bank_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$response = getJson('/api/sync/accounts');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('data.0.bank.id', $bank->id);
|
||||
});
|
||||
|
||||
it('requires authentication', function () {
|
||||
$response = getJson('/api/sync/accounts');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
});
|
||||
|
||||
it('returns shared banks and user banks but not other users banks', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
$sharedBank = Bank::factory()->create(['user_id' => null]);
|
||||
$userBank = Bank::factory()->create(['user_id' => $this->user->id]);
|
||||
$otherUserBank = Bank::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$response = getJson('/api/sync/banks');
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$bankIds = collect($response->json('data'))->pluck('id')->toArray();
|
||||
|
||||
expect($bankIds)->toContain($sharedBank->id);
|
||||
expect($bankIds)->toContain($userBank->id);
|
||||
expect($bankIds)->not->toContain($otherUserBank->id);
|
||||
});
|
||||
|
||||
it('returns only banks updated after specified timestamp', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$oldBank = Bank::factory()->create([
|
||||
'user_id' => null,
|
||||
'updated_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$newBank = Bank::factory()->create([
|
||||
'user_id' => null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = getJson('/api/sync/banks?since='.now()->subDay()->toIso8601String());
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(1, 'data');
|
||||
$response->assertJsonPath('data.0.id', $newBank->id);
|
||||
});
|
||||
|
||||
it('requires authentication', function () {
|
||||
$response = getJson('/api/sync/banks');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
});
|
||||
|
||||
it('returns all categories for authenticated user', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$categories = Category::factory()
|
||||
->count(3)
|
||||
->create(['user_id' => $this->user->id]);
|
||||
|
||||
Category::factory()
|
||||
->count(2)
|
||||
->create();
|
||||
|
||||
$response = getJson('/api/sync/categories');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(3, 'data');
|
||||
});
|
||||
|
||||
it('returns only categories updated after specified timestamp', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$oldCategory = Category::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'updated_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$newCategory = Category::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = getJson('/api/sync/categories?since='.now()->subDay()->toIso8601String());
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(1, 'data');
|
||||
$response->assertJsonPath('data.0.id', $newCategory->id);
|
||||
});
|
||||
|
||||
it('requires authentication', function () {
|
||||
$response = getJson('/api/sync/categories');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
Loading…
Reference in New Issue