fix(categorizer): fetch uncategorized transactions from backend instead of IndexedDB (#165)
## Problem The categorizer was reading transactions exclusively from the browser's IndexedDB (Dexie), which is only populated via an on-demand sync triggered from the import drawer. When an account was deleted (soft-deleting all its transactions), those transactions remained in the local IndexedDB and kept appearing in the categorizer on subsequent visits. ## Solution - **`TransactionController::categorize()`** now queries uncategorized transactions directly from the database (`WHERE category_id IS NULL`) with their account and bank eager-loaded, and passes them as an Inertia prop alongside the existing `categories`, `accounts`, `banks`, and `labels`. - **`categorize.tsx`** drops all Dexie/`useLiveQuery` dependencies. The transaction list now comes from the Inertia prop; client-side decryption runs once on mount (the encryption key remains client-side only — the server never sees it). Category assignment (`transactionSyncService.update()`) is unchanged. ## Tests Added 5 new feature tests covering: - Categorize page is accessible to authenticated users and includes the `transactions` prop - Only uncategorized transactions are returned - Transactions from deleted accounts are excluded - Transactions from other users are not visible - Guests are redirected to login
This commit is contained in:
parent
e01d62ffd4
commit
9bb835e79b
|
|
@ -135,11 +135,20 @@ class TransactionController extends Controller
|
|||
->orderBy('name')
|
||||
->get(['id', 'name', 'color']);
|
||||
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('category_id')
|
||||
->with(['account.bank:id,name,logo', 'labels:id,name,color'])
|
||||
->orderBy('transaction_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get(['id', 'account_id', 'category_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code', 'notes', 'notes_iv']);
|
||||
|
||||
return Inertia::render('transactions/categorize', [
|
||||
'categories' => $categories,
|
||||
'accounts' => $accounts,
|
||||
'banks' => $banks,
|
||||
'labels' => $labels,
|
||||
'transactions' => $transactions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -25,12 +24,14 @@ import { transactionSyncService } from '@/services/transaction-sync';
|
|||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import {
|
||||
type DecryptedTransaction,
|
||||
type Transaction,
|
||||
} from '@/types/transaction';
|
||||
import { formatDateLong } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
|
|
@ -49,6 +50,7 @@ interface Props {
|
|||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
type AnimationState = 'idle' | 'exiting' | 'entering' | 'success';
|
||||
|
|
@ -98,6 +100,7 @@ export default function CategorizeTransactions({
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
transactions: initialTransactions,
|
||||
}: Props) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { automationRules: sharedAutomationRules } = usePage<{
|
||||
|
|
@ -105,18 +108,6 @@ export default function CategorizeTransactions({
|
|||
}>().props;
|
||||
const locale = useLocale();
|
||||
|
||||
const transactionIds = useLiveQuery(
|
||||
async () => {
|
||||
const txs = await db.transactions.toArray();
|
||||
return txs
|
||||
.map((t) => t.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
},
|
||||
[],
|
||||
'',
|
||||
);
|
||||
|
||||
const [uncategorizedTransactions, setUncategorizedTransactions] = useState<
|
||||
DecryptedTransaction[]
|
||||
>([]);
|
||||
|
|
@ -154,21 +145,11 @@ export default function CategorizeTransactions({
|
|||
}
|
||||
}, [isLoading, animationState, currentIndex]);
|
||||
|
||||
// Decrypt transactions received from the backend and build the initial list
|
||||
useEffect(() => {
|
||||
async function loadUncategorizedTransactions() {
|
||||
if (transactionIds === undefined) {
|
||||
setIsLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
async function decryptTransactions() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const rawTransactions = await db.transactions.toArray();
|
||||
|
||||
const uncategorized = rawTransactions.filter(
|
||||
(t) => !t.category_id,
|
||||
);
|
||||
|
||||
const accountsMap = new Map(
|
||||
accounts.map((account) => [account.id, account]),
|
||||
);
|
||||
|
|
@ -190,7 +171,7 @@ export default function CategorizeTransactions({
|
|||
}
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
uncategorized.map(async (transaction) => {
|
||||
initialTransactions.map(async (transaction) => {
|
||||
try {
|
||||
let decryptedDescription = '';
|
||||
let decryptedNotes: string | null = null;
|
||||
|
|
@ -273,8 +254,8 @@ export default function CategorizeTransactions({
|
|||
}
|
||||
}
|
||||
|
||||
loadUncategorizedTransactions();
|
||||
}, [transactionIds, accounts, banks, isKeySet]);
|
||||
decryptTransactions();
|
||||
}, [initialTransactions, accounts, banks, isKeySet]);
|
||||
|
||||
const currentTransaction = uncategorizedTransactions[currentIndex];
|
||||
const remainingCount = uncategorizedTransactions.length - currentIndex;
|
||||
|
|
@ -723,10 +704,6 @@ export default function CategorizeTransactions({
|
|||
account={
|
||||
currentTransaction.account
|
||||
}
|
||||
length={{
|
||||
min: 5,
|
||||
max: 20,
|
||||
}}
|
||||
className="text-sm text-zinc-600 dark:text-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -671,3 +671,98 @@ test('when budget with label exists, updating transaction with that label assign
|
|||
expect($budgetTransaction->transaction_id)->toBe($transaction->id);
|
||||
expect($budgetTransaction->amount)->toBe(5000); // Stored as absolute value
|
||||
});
|
||||
|
||||
test('categorize page is accessible to authenticated users', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$response = actingAs($user)->get(route('transactions.categorize'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/categorize')
|
||||
->has('categories')
|
||||
->has('accounts')
|
||||
->has('banks')
|
||||
->has('labels')
|
||||
->has('transactions')
|
||||
);
|
||||
});
|
||||
|
||||
test('categorize page only returns uncategorized transactions', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$uncategorized = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->get(route('transactions.categorize'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/categorize')
|
||||
->has('transactions', 1)
|
||||
->where('transactions.0.id', $uncategorized->id)
|
||||
);
|
||||
});
|
||||
|
||||
test('categorize page does not return transactions from deleted accounts', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$activeAccount = Account::factory()->create(['user_id' => $user->id]);
|
||||
$deletedAccount = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$visibleTransaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $activeAccount->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
// Delete the account and its transactions (as the controller does)
|
||||
$deletedAccount->transactions()->delete();
|
||||
$deletedAccount->delete();
|
||||
|
||||
$response = actingAs($user)->get(route('transactions.categorize'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/categorize')
|
||||
->has('transactions', 1)
|
||||
->where('transactions.0.id', $visibleTransaction->id)
|
||||
);
|
||||
});
|
||||
|
||||
test('categorize page does not return transactions from other users', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$otherUser = User::factory()->onboarded()->create();
|
||||
|
||||
$otherAccount = Account::factory()->create(['user_id' => $otherUser->id]);
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'account_id' => $otherAccount->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->get(route('transactions.categorize'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/categorize')
|
||||
->has('transactions', 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('guests are redirected from categorize page', function () {
|
||||
$response = $this->get(route('transactions.categorize'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue