Migrate IndexDB integration to use Dexie
This commit is contained in:
parent
3dac1d9570
commit
71b8d69020
6
bun.lock
6
bun.lock
|
|
@ -31,6 +31,8 @@
|
|||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"json-logic-js": "^2.0.5",
|
||||
|
|
@ -569,6 +571,10 @@
|
|||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"dexie": ["dexie@4.2.1", "", {}, "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg=="],
|
||||
|
||||
"dexie-react-hooks": ["dexie-react-hooks@4.2.0", "", { "peerDependencies": { "@types/react": ">=16", "dexie": ">=4.2.0-alpha.1 <5.0.0", "react": ">=16" } }, "sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@
|
|||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"json-logic-js": "^2.0.5",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { indexedDBService } from './indexeddb';
|
||||
import { db } from './dexie-db';
|
||||
|
||||
export async function checkDatabaseVersion(): Promise<{
|
||||
needsRefresh: boolean;
|
||||
|
|
@ -15,8 +15,8 @@ export async function checkDatabaseVersion(): Promise<{
|
|||
];
|
||||
|
||||
try {
|
||||
const db = await indexedDBService.init();
|
||||
const existingStores = Array.from(db.objectStoreNames);
|
||||
await db.open();
|
||||
const existingStores = db.tables.map((table) => table.name);
|
||||
const missingStores = requiredStores.filter(
|
||||
(store) => !existingStores.includes(store),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import Dexie, { type EntityTable } from 'dexie';
|
||||
import type { Account, Bank } from '@/types/account';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
import type { Transaction } from '@/services/transaction-sync';
|
||||
|
||||
export interface SyncMetadata {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PendingChange {
|
||||
id?: number;
|
||||
store: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const db = new Dexie('whisper_money') as Dexie & {
|
||||
transactions: EntityTable<Transaction, 'id'>;
|
||||
accounts: EntityTable<Account, 'id'>;
|
||||
categories: EntityTable<Category, 'id'>;
|
||||
banks: EntityTable<Bank, 'id'>;
|
||||
automation_rules: EntityTable<AutomationRule, 'id'>;
|
||||
sync_metadata: EntityTable<SyncMetadata, 'key'>;
|
||||
pending_changes: EntityTable<PendingChange, 'id'>;
|
||||
};
|
||||
|
||||
db.version(3).stores({
|
||||
transactions: 'id, user_id, account_id, updated_at',
|
||||
accounts: 'id, user_id, bank_id, updated_at',
|
||||
categories: 'id, user_id, updated_at',
|
||||
banks: 'id, user_id, updated_at',
|
||||
automation_rules: 'id, user_id, priority, updated_at',
|
||||
sync_metadata: 'key',
|
||||
pending_changes: '++id, store, timestamp',
|
||||
});
|
||||
|
||||
export { db };
|
||||
|
||||
|
|
@ -1,437 +0,0 @@
|
|||
const DB_NAME = 'whisper_money';
|
||||
const DB_VERSION = 3;
|
||||
|
||||
// Force database recreation if needed
|
||||
export async function resetDatabase(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deleteRequest = indexedDB.deleteDatabase(DB_NAME);
|
||||
|
||||
deleteRequest.onsuccess = () => {
|
||||
console.log('Database deleted successfully');
|
||||
resolve();
|
||||
};
|
||||
|
||||
deleteRequest.onerror = () => {
|
||||
reject(new Error('Failed to delete database'));
|
||||
};
|
||||
|
||||
deleteRequest.onblocked = () => {
|
||||
console.warn('Database deletion blocked. Please close all other tabs.');
|
||||
reject(new Error('Database deletion blocked'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface IndexedDBRecord {
|
||||
id: number | string;
|
||||
user_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type StoreName = 'categories' | 'accounts' | 'banks' | 'automation_rules' | 'transactions';
|
||||
|
||||
class IndexedDBService {
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
async checkStoreExists(storeName: StoreName): Promise<boolean> {
|
||||
const db = await this.init();
|
||||
return db.objectStoreNames.contains(storeName);
|
||||
}
|
||||
|
||||
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('automation_rules')) {
|
||||
const automationRuleStore = db.createObjectStore('automation_rules', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
automationRuleStore.createIndex('user_id', 'user_id', {
|
||||
unique: false,
|
||||
});
|
||||
automationRuleStore.createIndex('priority', 'priority', {
|
||||
unique: false,
|
||||
});
|
||||
automationRuleStore.createIndex('updated_at', 'updated_at', {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('transactions')) {
|
||||
const transactionStore = db.createObjectStore('transactions', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
transactionStore.createIndex('user_id', 'user_id', {
|
||||
unique: false,
|
||||
});
|
||||
transactionStore.createIndex('account_id', 'account_id', {
|
||||
unique: false,
|
||||
});
|
||||
transactionStore.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) => {
|
||||
try {
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
console.warn(`Object store '${storeName}' not found. Returning empty array.`);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
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}`));
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error accessing ${storeName}:`, error);
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getById<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
id: number | string,
|
||||
): 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 get<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
id: number | string,
|
||||
): Promise<T | null> {
|
||||
return this.getById<T>(storeName, id);
|
||||
}
|
||||
|
||||
async put<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
record: T,
|
||||
): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
reject(new Error(`Object store '${storeName}' not found. Please refresh the page.`));
|
||||
return;
|
||||
}
|
||||
|
||||
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}`));
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 | string): 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) => {
|
||||
try {
|
||||
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'));
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import axios from 'axios';
|
||||
import {
|
||||
indexedDBService,
|
||||
type StoreName,
|
||||
type IndexedDBRecord,
|
||||
} from './indexeddb';
|
||||
import { db } from './dexie-db';
|
||||
|
||||
export type StoreName = 'categories' | 'accounts' | 'banks' | 'automation_rules' | 'transactions';
|
||||
|
||||
export interface IndexedDBRecord {
|
||||
id: number | string;
|
||||
user_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
storeName: StoreName;
|
||||
|
|
@ -29,11 +35,15 @@ export class SyncManager {
|
|||
}
|
||||
|
||||
async getLastSyncTime(): Promise<string | null> {
|
||||
return await indexedDBService.getSyncMetadata(this.lastSyncKey);
|
||||
const metadata = await db.sync_metadata.get(this.lastSyncKey);
|
||||
return metadata?.value || null;
|
||||
}
|
||||
|
||||
async setLastSyncTime(timestamp: string): Promise<void> {
|
||||
await indexedDBService.setSyncMetadata(this.lastSyncKey, timestamp);
|
||||
await db.sync_metadata.put({
|
||||
key: this.lastSyncKey,
|
||||
value: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async sync(): Promise<SyncResult> {
|
||||
|
|
@ -61,9 +71,10 @@ export class SyncManager {
|
|||
await this.syncFromServer(result);
|
||||
await this.syncToServer(result);
|
||||
|
||||
await indexedDBService.clearPendingChanges(
|
||||
this.options.storeName,
|
||||
);
|
||||
await db.pending_changes
|
||||
.where('store')
|
||||
.equals(this.options.storeName)
|
||||
.delete();
|
||||
|
||||
await this.setLastSyncTime(new Date().toISOString());
|
||||
} catch (error) {
|
||||
|
|
@ -94,10 +105,9 @@ export class SyncManager {
|
|||
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]));
|
||||
const table = db[this.options.storeName];
|
||||
const localRecords = await table.toArray();
|
||||
const localMap = new Map(localRecords.map((r: any) => [r.id, r]));
|
||||
|
||||
for (const serverRecord of serverData) {
|
||||
const transformed = this.options.transformFromServer
|
||||
|
|
@ -107,20 +117,14 @@ export class SyncManager {
|
|||
const localRecord = localMap.get(transformed.id);
|
||||
|
||||
if (!localRecord) {
|
||||
await indexedDBService.put(
|
||||
this.options.storeName,
|
||||
transformed,
|
||||
);
|
||||
await table.put(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,
|
||||
);
|
||||
await table.put(transformed);
|
||||
result.updated++;
|
||||
}
|
||||
}
|
||||
|
|
@ -128,9 +132,10 @@ export class SyncManager {
|
|||
}
|
||||
|
||||
private async syncToServer(result: SyncResult): Promise<void> {
|
||||
const pendingChanges = await indexedDBService.getPendingChanges(
|
||||
this.options.storeName,
|
||||
);
|
||||
const pendingChanges = await db.pending_changes
|
||||
.where('store')
|
||||
.equals(this.options.storeName)
|
||||
.toArray();
|
||||
|
||||
for (const change of pendingChanges) {
|
||||
try {
|
||||
|
|
@ -178,12 +183,14 @@ export class SyncManager {
|
|||
updated_at: timestamp,
|
||||
} as T;
|
||||
|
||||
await indexedDBService.put(this.options.storeName, record);
|
||||
await indexedDBService.addPendingChange(
|
||||
this.options.storeName,
|
||||
'create',
|
||||
record,
|
||||
);
|
||||
const table = db[this.options.storeName];
|
||||
await table.put(record);
|
||||
await db.pending_changes.add({
|
||||
store: this.options.storeName,
|
||||
operation: 'create',
|
||||
data: record,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
|
@ -192,44 +199,49 @@ export class SyncManager {
|
|||
id: number,
|
||||
data: Partial<T>,
|
||||
): Promise<void> {
|
||||
const existing = await indexedDBService.getById<T>(
|
||||
this.options.storeName,
|
||||
id,
|
||||
);
|
||||
const table = db[this.options.storeName];
|
||||
const existing = await table.get(id);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Record ${id} not found in ${this.options.storeName}`);
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_at: timestamp,
|
||||
};
|
||||
|
||||
await indexedDBService.put(this.options.storeName, updated);
|
||||
await indexedDBService.addPendingChange(
|
||||
this.options.storeName,
|
||||
'update',
|
||||
updated,
|
||||
);
|
||||
await table.put(updated);
|
||||
await db.pending_changes.add({
|
||||
store: this.options.storeName,
|
||||
operation: 'update',
|
||||
data: updated,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteLocal(id: number): Promise<void> {
|
||||
await indexedDBService.delete(this.options.storeName, id);
|
||||
await indexedDBService.addPendingChange(
|
||||
this.options.storeName,
|
||||
'delete',
|
||||
{ id },
|
||||
);
|
||||
const timestamp = new Date().toISOString();
|
||||
const table = db[this.options.storeName];
|
||||
await table.delete(id);
|
||||
await db.pending_changes.add({
|
||||
store: this.options.storeName,
|
||||
operation: 'delete',
|
||||
data: { id },
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async getAll<T extends IndexedDBRecord>(): Promise<T[]> {
|
||||
return await indexedDBService.getAll<T>(this.options.storeName);
|
||||
const table = db[this.options.storeName];
|
||||
return await table.toArray() as T[];
|
||||
}
|
||||
|
||||
async getById<T extends IndexedDBRecord>(id: number): Promise<T | null> {
|
||||
return await indexedDBService.getById<T>(this.options.storeName, id);
|
||||
const table = db[this.options.storeName];
|
||||
return (await table.get(id) as T) || null;
|
||||
}
|
||||
|
||||
isSyncing(): boolean {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
|
||||
import { CreateAccountDialog } from '@/components/accounts/create-account-dialog';
|
||||
|
|
@ -38,10 +39,9 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type Account, formatAccountType } from '@/types/account';
|
||||
|
||||
|
|
@ -52,13 +52,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function AccountActions({
|
||||
account,
|
||||
onSuccess,
|
||||
}: {
|
||||
account: Account;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
function AccountActions({ account }: { account: Account }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
|
|
@ -89,43 +83,26 @@ function AccountActions({
|
|||
account={account}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
onSuccess={onSuccess}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
<DeleteAccountDialog
|
||||
account={account}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onSuccess={onSuccess}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const { syncStatus } = useSyncContext();
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const accounts = useLiveQuery(() => db.accounts.toArray(), []) || [];
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
await accountSyncService.sync();
|
||||
const data = await accountSyncService.getAll();
|
||||
setAccounts(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncStatus === 'success') {
|
||||
loadAccounts();
|
||||
}
|
||||
}, [syncStatus]);
|
||||
|
||||
const columns: ColumnDef<Account>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
|
||||
|
|
@ -38,10 +39,9 @@ import {
|
|||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
|
||||
import { getCategoryColorClasses } from '@/types/category';
|
||||
|
|
@ -53,13 +53,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function AutomationRuleActions({
|
||||
rule,
|
||||
onSuccess,
|
||||
}: {
|
||||
rule: AutomationRule;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
|
|
@ -90,44 +84,34 @@ function AutomationRuleActions({
|
|||
rule={rule}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
onSuccess={onSuccess}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
<DeleteAutomationRuleDialog
|
||||
rule={rule}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onSuccess={onSuccess}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutomationRules() {
|
||||
const { syncStatus } = useSyncContext();
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const [rules, setRules] = useState<AutomationRule[]>([]);
|
||||
const rawRules = useLiveQuery(() => db.automation_rules.toArray(), []) || [];
|
||||
const rules = rawRules.map((rule) => ({
|
||||
...rule,
|
||||
rules_json:
|
||||
typeof rule.rules_json === 'string'
|
||||
? JSON.parse(rule.rules_json)
|
||||
: rule.rules_json,
|
||||
}));
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
const loadRules = async () => {
|
||||
await automationRuleSyncService.sync();
|
||||
const data = await automationRuleSyncService.getAll();
|
||||
setRules(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncStatus === 'success') {
|
||||
loadRules();
|
||||
}
|
||||
}, [syncStatus]);
|
||||
|
||||
const columns: ColumnDef<AutomationRule>[] = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import {
|
|||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
import { CreateCategoryDialog } from '@/components/categories/create-category-dialog';
|
||||
|
|
@ -38,10 +39,9 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
|
||||
|
|
@ -52,13 +52,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function CategoryActions({
|
||||
category,
|
||||
onSuccess,
|
||||
}: {
|
||||
category: Category;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
function CategoryActions({ category }: { category: Category }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
|
|
@ -89,43 +83,26 @@ function CategoryActions({
|
|||
category={category}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
onSuccess={onSuccess}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
<DeleteCategoryDialog
|
||||
category={category}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onSuccess={onSuccess}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Categories() {
|
||||
const { syncStatus } = useSyncContext();
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const categories = useLiveQuery(() => db.categories.toArray(), []) || [];
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
const loadCategories = async () => {
|
||||
await categorySyncService.sync();
|
||||
const data = await categorySyncService.getAll();
|
||||
setCategories(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncStatus === 'success') {
|
||||
loadCategories();
|
||||
}
|
||||
}, [syncStatus]);
|
||||
|
||||
const columns: ColumnDef<Category>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
|
|
@ -179,7 +156,6 @@ export default function Categories() {
|
|||
cell: ({ row }) => (
|
||||
<CategoryActions
|
||||
category={row.original}
|
||||
onSuccess={loadCategories}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -229,7 +205,7 @@ export default function Categories() {
|
|||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<CreateCategoryDialog onSuccess={loadCategories} />
|
||||
<CreateCategoryDialog onSuccess={() => {}} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { isWithinInterval, parseISO } from 'date-fns';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
|||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
||||
import { consoleDebug } from '@/lib/debug';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
|
|
@ -80,6 +82,9 @@ function getInitialColumnVisibility(): VisibilityState {
|
|||
|
||||
export default function Transactions({ categories, accounts, banks }: Props) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
|
||||
const rawTransactions = useLiveQuery(() => db.transactions.toArray(), []);
|
||||
|
||||
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
|
||||
[],
|
||||
);
|
||||
|
|
@ -142,179 +147,183 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
[setTransactions],
|
||||
);
|
||||
|
||||
const loadTransactions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const rawTransactions = await transactionSyncService.getAll();
|
||||
const accountsMap = new Map(
|
||||
accounts.map((account) => [account.id, account]),
|
||||
);
|
||||
const categoriesMap = new Map(
|
||||
categories.map((category) => [category.id, category]),
|
||||
);
|
||||
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
|
||||
|
||||
const keyString = getStoredKey();
|
||||
let key: CryptoKey | null = null;
|
||||
|
||||
if (keyString && isKeySet) {
|
||||
try {
|
||||
key = await importKey(keyString);
|
||||
} catch (error) {
|
||||
console.error('Failed to import encryption key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
rawTransactions.map(async (transaction) => {
|
||||
try {
|
||||
let decryptedDescription = '';
|
||||
let decryptedNotes: string | null = null;
|
||||
|
||||
if (key) {
|
||||
try {
|
||||
decryptedDescription = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
|
||||
if (transaction.notes && transaction.notes_iv) {
|
||||
decryptedNotes = await decrypt(
|
||||
transaction.notes,
|
||||
key,
|
||||
transaction.notes_iv,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to decrypt transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const account = accountsMap.get(transaction.account_id);
|
||||
const category = transaction.category_id
|
||||
? categoriesMap.get(transaction.category_id)
|
||||
: null;
|
||||
const bank = account?.bank?.id
|
||||
? banksMap.get(account.bank.id)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes,
|
||||
account,
|
||||
category: category || null,
|
||||
bank,
|
||||
} as DecryptedTransaction;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to process transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const validTransactions = decrypted.filter(
|
||||
(transaction): transaction is DecryptedTransaction =>
|
||||
transaction !== null,
|
||||
);
|
||||
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
|
||||
if (rules.length > 0 && key) {
|
||||
const transactionsToUpdate: Array<{
|
||||
id: string;
|
||||
category_id: number | null;
|
||||
notes: string | null;
|
||||
notes_iv: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const transaction of validTransactions) {
|
||||
if (!transaction.category_id) {
|
||||
const result = evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
if (transaction.decryptedNotes) {
|
||||
const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`;
|
||||
const encrypted = await encrypt(
|
||||
combinedNote,
|
||||
key,
|
||||
);
|
||||
finalNotes = encrypted.encrypted;
|
||||
finalNotesIv = encrypted.iv;
|
||||
} else {
|
||||
finalNotes = result.note;
|
||||
finalNotesIv = result.noteIv;
|
||||
}
|
||||
}
|
||||
|
||||
transactionsToUpdate.push({
|
||||
id: transaction.id,
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
});
|
||||
|
||||
transaction.category_id = result.categoryId;
|
||||
if (result.categoryId) {
|
||||
transaction.category =
|
||||
categories.find(
|
||||
(c) => c.id === result.categoryId,
|
||||
) || null;
|
||||
}
|
||||
if (finalNotes && finalNotesIv) {
|
||||
transaction.notes = finalNotes;
|
||||
transaction.notes_iv = finalNotesIv;
|
||||
transaction.decryptedNotes = await decrypt(
|
||||
finalNotes,
|
||||
key,
|
||||
finalNotesIv,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transactionsToUpdate.length > 0) {
|
||||
for (const update of transactionsToUpdate) {
|
||||
await transactionSyncService.update(update.id, {
|
||||
category_id: update.category_id,
|
||||
notes: update.notes,
|
||||
notes_iv: update.notes_iv,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTransactions(validTransactions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [accounts, banks, categories, isKeySet]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions();
|
||||
}, [loadTransactions]);
|
||||
async function processTransactions() {
|
||||
if (!rawTransactions) {
|
||||
setIsLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const accountsMap = new Map(
|
||||
accounts.map((account) => [account.id, account]),
|
||||
);
|
||||
const categoriesMap = new Map(
|
||||
categories.map((category) => [category.id, category]),
|
||||
);
|
||||
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
|
||||
|
||||
const keyString = getStoredKey();
|
||||
let key: CryptoKey | null = null;
|
||||
|
||||
if (keyString && isKeySet) {
|
||||
try {
|
||||
key = await importKey(keyString);
|
||||
} catch (error) {
|
||||
console.error('Failed to import encryption key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
rawTransactions.map(async (transaction) => {
|
||||
try {
|
||||
let decryptedDescription = '';
|
||||
let decryptedNotes: string | null = null;
|
||||
|
||||
if (key) {
|
||||
try {
|
||||
decryptedDescription = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
|
||||
if (transaction.notes && transaction.notes_iv) {
|
||||
decryptedNotes = await decrypt(
|
||||
transaction.notes,
|
||||
key,
|
||||
transaction.notes_iv,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to decrypt transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const account = accountsMap.get(transaction.account_id);
|
||||
const category = transaction.category_id
|
||||
? categoriesMap.get(transaction.category_id)
|
||||
: null;
|
||||
const bank = account?.bank?.id
|
||||
? banksMap.get(account.bank.id)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes,
|
||||
account,
|
||||
category: category || null,
|
||||
bank,
|
||||
} as DecryptedTransaction;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to process transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const validTransactions = decrypted.filter(
|
||||
(transaction): transaction is DecryptedTransaction =>
|
||||
transaction !== null,
|
||||
);
|
||||
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
|
||||
if (rules.length > 0 && key) {
|
||||
const transactionsToUpdate: Array<{
|
||||
id: string;
|
||||
category_id: number | null;
|
||||
notes: string | null;
|
||||
notes_iv: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const transaction of validTransactions) {
|
||||
if (!transaction.category_id) {
|
||||
const result = evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
if (transaction.decryptedNotes) {
|
||||
const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`;
|
||||
const encrypted = await encrypt(
|
||||
combinedNote,
|
||||
key,
|
||||
);
|
||||
finalNotes = encrypted.encrypted;
|
||||
finalNotesIv = encrypted.iv;
|
||||
} else {
|
||||
finalNotes = result.note;
|
||||
finalNotesIv = result.noteIv;
|
||||
}
|
||||
}
|
||||
|
||||
transactionsToUpdate.push({
|
||||
id: transaction.id,
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
});
|
||||
|
||||
transaction.category_id = result.categoryId;
|
||||
if (result.categoryId) {
|
||||
transaction.category =
|
||||
categories.find(
|
||||
(c) => c.id === result.categoryId,
|
||||
) || null;
|
||||
}
|
||||
if (finalNotes && finalNotesIv) {
|
||||
transaction.notes = finalNotes;
|
||||
transaction.notes_iv = finalNotesIv;
|
||||
transaction.decryptedNotes = await decrypt(
|
||||
finalNotes,
|
||||
key,
|
||||
finalNotesIv,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transactionsToUpdate.length > 0) {
|
||||
for (const update of transactionsToUpdate) {
|
||||
await transactionSyncService.update(update.id, {
|
||||
category_id: update.category_id,
|
||||
notes: update.notes,
|
||||
notes_iv: update.notes_iv,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTransactions(validTransactions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
processTransactions();
|
||||
}, [rawTransactions, accounts, banks, categories, isKeySet]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { encrypt, importKey } from '@/lib/crypto';
|
||||
import { indexedDBService } from '@/lib/indexeddb';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { SyncManager } from '@/lib/sync-manager';
|
||||
import { uuidv7 } from 'uuidv7';
|
||||
|
|
@ -39,7 +39,7 @@ class TransactionSyncService {
|
|||
}
|
||||
|
||||
async getById(id: string): Promise<Transaction | null> {
|
||||
return await indexedDBService.get<Transaction>('transactions', id);
|
||||
return (await db.transactions.get(id)) || null;
|
||||
}
|
||||
|
||||
async getByAccountId(accountId: number): Promise<Transaction[]> {
|
||||
|
|
@ -79,12 +79,13 @@ class TransactionSyncService {
|
|||
updated_at: timestamp,
|
||||
} as Transaction;
|
||||
|
||||
await indexedDBService.put('transactions', record);
|
||||
await indexedDBService.addPendingChange(
|
||||
'transactions',
|
||||
'create',
|
||||
record,
|
||||
);
|
||||
await db.transactions.put(record);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'create',
|
||||
data: record,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
created.push(record);
|
||||
}
|
||||
|
|
@ -104,18 +105,20 @@ class TransactionSyncService {
|
|||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_at: timestamp,
|
||||
};
|
||||
|
||||
await indexedDBService.put('transactions', updated);
|
||||
await indexedDBService.addPendingChange(
|
||||
'transactions',
|
||||
'update',
|
||||
updated,
|
||||
);
|
||||
await db.transactions.put(updated);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'update',
|
||||
data: updated,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async updateMany(ids: string[], data: Partial<Transaction>): Promise<void> {
|
||||
|
|
@ -134,12 +137,13 @@ class TransactionSyncService {
|
|||
updated_at: timestamp,
|
||||
};
|
||||
|
||||
await indexedDBService.put('transactions', updated);
|
||||
await indexedDBService.addPendingChange(
|
||||
'transactions',
|
||||
'update',
|
||||
updated,
|
||||
);
|
||||
await db.transactions.put(updated);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'update',
|
||||
data: updated,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,15 +153,19 @@ class TransactionSyncService {
|
|||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
await indexedDBService.delete('transactions', id);
|
||||
await indexedDBService.addPendingChange(
|
||||
'transactions',
|
||||
'delete',
|
||||
transaction,
|
||||
);
|
||||
const timestamp = new Date().toISOString();
|
||||
await db.transactions.delete(id);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'delete',
|
||||
data: transaction,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMany(ids: string[]): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
for (const id of ids) {
|
||||
const transaction = await this.getById(id);
|
||||
if (!transaction) {
|
||||
|
|
@ -165,12 +173,13 @@ class TransactionSyncService {
|
|||
continue;
|
||||
}
|
||||
|
||||
await indexedDBService.delete('transactions', id);
|
||||
await indexedDBService.addPendingChange(
|
||||
'transactions',
|
||||
'delete',
|
||||
transaction,
|
||||
);
|
||||
await db.transactions.delete(id);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'delete',
|
||||
data: transaction,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue