Automation rules

This commit is contained in:
Víctor Falcón 2025-11-10 11:41:58 +00:00
parent ea9cec184f
commit d5ce7a24b4
29 changed files with 2965 additions and 104 deletions

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreAutomationRuleRequest;
use App\Http\Requests\Settings\UpdateAutomationRuleRequest;
use App\Models\AutomationRule;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AutomationRuleController extends Controller
{
use AuthorizesRequests;
/**
* Show the user's automation rules settings page.
*/
public function index(): Response
{
$rules = auth()->user()
->automationRules()
->with('category:id,name,icon,color')
->orderBy('priority')
->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']);
return Inertia::render('settings/automation-rules', [
'rules' => $rules,
]);
}
/**
* Store a newly created automation rule.
*/
public function store(StoreAutomationRuleRequest $request): RedirectResponse
{
auth()->user()->automationRules()->create($request->validated());
return to_route('automation-rules.index');
}
/**
* Update the specified automation rule.
*/
public function update(UpdateAutomationRuleRequest $request, AutomationRule $automationRule): RedirectResponse
{
$this->authorize('update', $automationRule);
$automationRule->update($request->validated());
return to_route('automation-rules.index');
}
/**
* Soft delete the specified automation rule.
*/
public function destroy(AutomationRule $automationRule): RedirectResponse
{
$this->authorize('delete', $automationRule);
$automationRule->delete();
return to_route('automation-rules.index');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class AutomationRuleSyncController extends Controller
{
/**
* Get all automation rules for the authenticated user.
*/
public function index(): JsonResponse
{
$rules = auth()->user()
->automationRules()
->with('category:id,name,icon,color')
->orderBy('priority')
->get();
return response()->json($rules);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreAutomationRuleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'priority' => ['required', 'integer', 'min:0'],
'rules_json' => ['required', 'json', function ($attribute, $value, $fail) {
$decoded = json_decode($value, true);
if (! is_array($decoded) || empty($decoded)) {
$fail('The rules JSON must be a valid JsonLogic object.');
}
}],
'action_category_id' => [
'nullable',
'integer',
Rule::exists('categories', 'id')->where(function ($query) {
$query->where('user_id', auth()->id());
}),
],
'action_note' => ['nullable', 'string'],
'action_note_iv' => ['nullable', 'string', 'required_with:action_note'],
];
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if (! $this->action_category_id && ! $this->action_note) {
$validator->errors()->add('action_category_id', 'At least one action (category or note) must be provided.');
}
});
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAutomationRuleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'priority' => ['required', 'integer', 'min:0'],
'rules_json' => ['required', 'json', function ($attribute, $value, $fail) {
$decoded = json_decode($value, true);
if (! is_array($decoded) || empty($decoded)) {
$fail('The rules JSON must be a valid JsonLogic object.');
}
}],
'action_category_id' => [
'nullable',
'integer',
Rule::exists('categories', 'id')->where(function ($query) {
$query->where('user_id', auth()->id());
}),
],
'action_note' => ['nullable', 'string'],
'action_note_iv' => ['nullable', 'string', 'required_with:action_note'],
];
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if (! $this->action_category_id && ! $this->action_note) {
$validator->errors()->add('action_category_id', 'At least one action (category or note) must be provided.');
}
});
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class AutomationRule extends Model
{
/** @use HasFactory<\Database\Factories\AutomationRuleFactory> */
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'title',
'priority',
'rules_json',
'action_category_id',
'action_note',
'action_note_iv',
];
protected function casts(): array
{
return [
'rules_json' => 'array',
'priority' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class, 'action_category_id');
}
}

View File

@ -72,4 +72,9 @@ class User extends Authenticatable
{
return $this->hasMany(Category::class);
}
public function automationRules(): HasMany
{
return $this->hasMany(AutomationRule::class);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Models\AutomationRule;
use App\Models\User;
class AutomationRulePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, AutomationRule $automationRule): bool
{
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, AutomationRule $automationRule): bool
{
return $user->id === $automationRule->user_id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, AutomationRule $automationRule): bool
{
return $user->id === $automationRule->user_id;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, AutomationRule $automationRule): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, AutomationRule $automationRule): bool
{
return false;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AutomationRule>
*/
class AutomationRuleFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'title' => fake()->sentence(3),
'priority' => fake()->numberBetween(0, 100),
'rules_json' => [
'and' => [
['>' => [['var' => 'amount'], 100]],
['in' => ['grocery', ['var' => 'description']]],
],
],
'action_category_id' => null,
'action_note' => null,
'action_note_iv' => null,
];
}
}

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('automation_rules', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->integer('priority')->default(0);
$table->json('rules_json');
$table->foreignId('action_category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->text('action_note')->nullable();
$table->string('action_note_iv')->nullable();
$table->softDeletes();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('automation_rules');
}
};

446
package-lock.json generated
View File

@ -11,13 +11,15 @@
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
@ -28,22 +30,29 @@
"@vitejs/plugin-react": "^5.0.0",
"class-variance-authority": "^0.7.1",
"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",
"json-logic-js": "^2.0.5",
"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",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2",
"vite": "^7.0.4"
"uuidv7": "^1.0.2",
"vaul": "^1.1.2",
"vite": "^7.0.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3",
"@types/json-logic-js": "^2.0.8",
"@types/node": "^22.13.5",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.17.0",
@ -1128,6 +1137,24 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@ -1259,6 +1286,24 @@
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@ -1322,6 +1367,24 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@ -1506,6 +1569,24 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
@ -1541,6 +1622,61 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@ -1640,6 +1776,56 @@
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
@ -1712,12 +1898,54 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@ -1735,9 +1963,10 @@
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
@ -1836,6 +2065,24 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@ -2753,6 +3000,13 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
},
"node_modules/@types/json-logic-js": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@types/json-logic-js/-/json-logic-js-2.0.8.tgz",
"integrity": "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -3097,6 +3351,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -3464,6 +3727,19 @@
}
]
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -3530,6 +3806,31 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -3592,6 +3893,18 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3662,6 +3975,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -4388,6 +4711,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -5177,6 +5509,12 @@
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true
},
"node_modules/json-logic-js": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz",
"integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==",
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -5524,9 +5862,10 @@
}
},
"node_modules/lucide-react": {
"version": "0.475.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz",
"integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==",
"version": "0.553.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@ -6603,6 +6942,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@ -7142,6 +7493,28 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuidv7": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.0.2.tgz",
"integrity": "sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA==",
"license": "Apache-2.0",
"bin": {
"uuidv7": "cli.js"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vite": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
@ -7351,6 +7724,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -7376,6 +7767,27 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -14,6 +14,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3",
"@types/json-logic-js": "^2.0.8",
"@types/node": "^22.13.5",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.17.0",
@ -56,6 +57,7 @@
"date-fns": "^4.1.0",
"globals": "^15.14.0",
"input-otp": "^1.4.2",
"json-logic-js": "^2.0.5",
"laravel-vite-plugin": "^2.0",
"lucide-react": "^0.553.0",
"react": "^19.2.0",

View File

@ -0,0 +1,316 @@
import { useState, useEffect } from 'react';
import { router } from '@inertiajs/react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
import { store } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { categorySyncService } from '@/services/category-sync';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import type { Category } from '@/types/category';
interface CreateAutomationRuleDialogProps {
disabled?: boolean;
}
export function CreateAutomationRuleDialog({
disabled = false,
}: CreateAutomationRuleDialogProps) {
const [open, setOpen] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('0');
const [rulesJson, setRulesJson] = useState('');
const [categoryId, setCategoryId] = useState<string>('');
const [note, setNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [helpOpen, setHelpOpen] = useState(false);
useEffect(() => {
const loadCategories = async () => {
const data = await categorySyncService.getAll();
setCategories(data);
};
loadCategories();
}, []);
const validateJsonLogic = (json: string): boolean => {
try {
const parsed = JSON.parse(json);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return false;
}
return Object.keys(parsed).length > 0;
} catch {
return false;
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
if (!title.trim()) {
setErrors((prev) => ({ ...prev, title: 'Title is required' }));
return;
}
if (!rulesJson.trim()) {
setErrors((prev) => ({ ...prev, rules_json: 'Rules JSON is required' }));
return;
}
if (!validateJsonLogic(rulesJson)) {
setErrors((prev) => ({
...prev,
rules_json: 'Invalid JsonLogic format',
}));
return;
}
if (!categoryId && !note.trim()) {
setErrors((prev) => ({
...prev,
action_category_id: 'At least one action is required',
}));
return;
}
setIsSubmitting(true);
try {
let encryptedNote: string | null = null;
let noteIv: string | null = null;
if (note.trim()) {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not available');
}
const key = await importKey(keyString);
const encrypted = await encrypt(note.trim(), key);
encryptedNote = encrypted.encrypted;
noteIv = encrypted.iv;
}
router.post(
store().url,
{
title: title.trim(),
priority: parseInt(priority, 10),
rules_json: rulesJson.trim(),
action_category_id: categoryId ? parseInt(categoryId, 10) : null,
action_note: encryptedNote,
action_note_iv: noteIv,
},
{
onSuccess: () => {
setOpen(false);
setTitle('');
setPriority('0');
setRulesJson('');
setCategoryId('');
setNote('');
setErrors({});
},
onError: (errors) => {
setErrors(errors as Record<string, string>);
},
onFinish: () => {
setIsSubmitting(false);
},
},
);
} catch (error) {
console.error('Failed to create automation rule:', error);
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button disabled={disabled}>Create Rule</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Create Automation Rule</DialogTitle>
<DialogDescription>
Create a rule to automatically categorize transactions or add notes.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Rule title"
required
/>
{errors.title && (
<p className="text-sm text-red-500">{errors.title}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Input
id="priority"
type="number"
min="0"
value={priority}
onChange={(e) => setPriority(e.target.value)}
placeholder="0"
required
/>
<p className="text-muted-foreground text-xs">
Lower numbers execute first
</p>
{errors.priority && (
<p className="text-sm text-red-500">{errors.priority}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="rules_json">Rules (JsonLogic)</Label>
<Textarea
id="rules_json"
value={rulesJson}
onChange={(e) => setRulesJson(e.target.value)}
placeholder='{"==": [{"var": "description"}, "GROCERY"]}'
rows={4}
className="font-mono text-sm"
required
/>
{errors.rules_json && (
<p className="text-sm text-red-500">{errors.rules_json}</p>
)}
</div>
<Collapsible open={helpOpen} onOpenChange={setHelpOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="flex w-full items-center justify-between"
>
<span>Available Fields & Examples</span>
<ChevronDown
className={`h-4 w-4 transition-transform ${helpOpen ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 rounded-md border p-3 text-sm">
<div>
<strong>Available fields:</strong>
<ul className="ml-4 mt-1 list-disc">
<li>description (string)</li>
<li>amount (number)</li>
<li>transaction_date (string)</li>
<li>bank_name (string)</li>
<li>account_name (string)</li>
<li>category (string or null)</li>
</ul>
</div>
<div>
<strong>Example rules:</strong>
<pre className="bg-muted mt-1 overflow-x-auto rounded p-2 text-xs">
{`{"in": ["GROCERY", {"var": "description"}]}
{"and": [
{">": [{"var": "amount"}, 100]},
{"==": [{"var": "bank_name"}, "Chase"]}
]}
{"==": [{"var": "category"}, null]}`}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
<div className="space-y-4 rounded-md border p-4">
<h4 className="font-medium">Actions</h4>
<p className="text-muted-foreground text-sm">
At least one action is required
</p>
<div className="space-y-2">
<Label htmlFor="category">Set Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger>
<SelectValue placeholder="Select a category (optional)" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem
key={category.id}
value={String(category.id)}
>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="note">Add Note</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Note to add (optional)"
rows={2}
/>
</div>
{errors.action_category_id && (
<p className="text-sm text-red-500">
{errors.action_category_id}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,65 @@
import { useState } from 'react';
import { router } from '@inertiajs/react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { destroy } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import type { AutomationRule } from '@/types/automation-rule';
interface DeleteAutomationRuleDialogProps {
rule: AutomationRule;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DeleteAutomationRuleDialog({
rule,
open,
onOpenChange,
}: DeleteAutomationRuleDialogProps) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = () => {
setIsDeleting(true);
router.delete(destroy(rule.id).url, {
onSuccess: () => {
onOpenChange(false);
},
onFinish: () => {
setIsDeleting(false);
},
});
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Automation Rule</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{rule.title}"? This action cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,343 @@
import { useState, useEffect } from 'react';
import { router } from '@inertiajs/react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
import { update } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { categorySyncService } from '@/services/category-sync';
import { encrypt, decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import type { Category } from '@/types/category';
import type { AutomationRule } from '@/types/automation-rule';
interface EditAutomationRuleDialogProps {
rule: AutomationRule;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditAutomationRuleDialog({
rule,
open,
onOpenChange,
}: EditAutomationRuleDialogProps) {
const [categories, setCategories] = useState<Category[]>([]);
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('0');
const [rulesJson, setRulesJson] = useState('');
const [categoryId, setCategoryId] = useState<string>('');
const [note, setNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [helpOpen, setHelpOpen] = useState(false);
useEffect(() => {
const loadCategories = async () => {
const data = await categorySyncService.getAll();
setCategories(data);
};
loadCategories();
}, []);
useEffect(() => {
if (rule && open) {
setTitle(rule.title);
setPriority(String(rule.priority));
setRulesJson(JSON.stringify(rule.rules_json, null, 2));
setCategoryId(rule.action_category_id ? String(rule.action_category_id) : '');
const decryptNote = async () => {
if (rule.action_note && rule.action_note_iv) {
try {
const keyString = getStoredKey();
if (keyString) {
const key = await importKey(keyString);
const decrypted = await decrypt(
rule.action_note,
key,
rule.action_note_iv,
);
setNote(decrypted);
}
} catch (error) {
console.error('Failed to decrypt note:', error);
}
} else {
setNote('');
}
};
decryptNote();
}
}, [rule, open]);
const validateJsonLogic = (json: string): boolean => {
try {
const parsed = JSON.parse(json);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return false;
}
return Object.keys(parsed).length > 0;
} catch {
return false;
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
if (!title.trim()) {
setErrors((prev) => ({ ...prev, title: 'Title is required' }));
return;
}
if (!rulesJson.trim()) {
setErrors((prev) => ({ ...prev, rules_json: 'Rules JSON is required' }));
return;
}
if (!validateJsonLogic(rulesJson)) {
setErrors((prev) => ({
...prev,
rules_json: 'Invalid JsonLogic format',
}));
return;
}
if (!categoryId && !note.trim()) {
setErrors((prev) => ({
...prev,
action_category_id: 'At least one action is required',
}));
return;
}
setIsSubmitting(true);
try {
let encryptedNote: string | null = null;
let noteIv: string | null = null;
if (note.trim()) {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not available');
}
const key = await importKey(keyString);
const encrypted = await encrypt(note.trim(), key);
encryptedNote = encrypted.encrypted;
noteIv = encrypted.iv;
}
router.patch(
update(rule.id).url,
{
title: title.trim(),
priority: parseInt(priority, 10),
rules_json: rulesJson.trim(),
action_category_id: categoryId ? parseInt(categoryId, 10) : null,
action_note: encryptedNote,
action_note_iv: noteIv,
},
{
onSuccess: () => {
onOpenChange(false);
setErrors({});
},
onError: (errors) => {
setErrors(errors as Record<string, string>);
},
onFinish: () => {
setIsSubmitting(false);
},
},
);
} catch (error) {
console.error('Failed to update automation rule:', error);
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Edit Automation Rule</DialogTitle>
<DialogDescription>
Update the rule to automatically categorize transactions or add notes.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Rule title"
required
/>
{errors.title && (
<p className="text-sm text-red-500">{errors.title}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Input
id="priority"
type="number"
min="0"
value={priority}
onChange={(e) => setPriority(e.target.value)}
placeholder="0"
required
/>
<p className="text-muted-foreground text-xs">
Lower numbers execute first
</p>
{errors.priority && (
<p className="text-sm text-red-500">{errors.priority}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="rules_json">Rules (JsonLogic)</Label>
<Textarea
id="rules_json"
value={rulesJson}
onChange={(e) => setRulesJson(e.target.value)}
placeholder='{"==": [{"var": "description"}, "GROCERY"]}'
rows={4}
className="font-mono text-sm"
required
/>
{errors.rules_json && (
<p className="text-sm text-red-500">{errors.rules_json}</p>
)}
</div>
<Collapsible open={helpOpen} onOpenChange={setHelpOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="flex w-full items-center justify-between"
>
<span>Available Fields & Examples</span>
<ChevronDown
className={`h-4 w-4 transition-transform ${helpOpen ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 rounded-md border p-3 text-sm">
<div>
<strong>Available fields:</strong>
<ul className="ml-4 mt-1 list-disc">
<li>description (string)</li>
<li>amount (number)</li>
<li>transaction_date (string)</li>
<li>bank_name (string)</li>
<li>account_name (string)</li>
<li>category (string or null)</li>
</ul>
</div>
<div>
<strong>Example rules:</strong>
<pre className="bg-muted mt-1 overflow-x-auto rounded p-2 text-xs">
{`{"in": ["GROCERY", {"var": "description"}]}
{"and": [
{">": [{"var": "amount"}, 100]},
{"==": [{"var": "bank_name"}, "Chase"]}
]}
{"==": [{"var": "category"}, null]}`}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
<div className="space-y-4 rounded-md border p-4">
<h4 className="font-medium">Actions</h4>
<p className="text-muted-foreground text-sm">
At least one action is required
</p>
<div className="space-y-2">
<Label htmlFor="category">Set Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger>
<SelectValue placeholder="Select a category (optional)" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem
key={category.id}
value={String(category.id)}
>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="note">Add Note</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Note to add (optional)"
rows={2}
/>
</div>
{errors.action_category_id && (
<p className="text-sm text-red-500">
{errors.action_category_id}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,18 +1,15 @@
import { X, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from '@/components/ui/button-group';
import { BulkCategorySelect } from '@/components/transactions/bulk-category-select';
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import { type Category } from '@/types/category';
import { RefreshCw, Trash2, X } from 'lucide-react';
interface BulkActionsBarProps {
selectedCount: number;
categories: Category[];
onCategoryChange: (categoryId: number | null) => void;
onDelete: () => void;
onReEvaluateRules: () => void;
onClear: () => void;
isUpdating?: boolean;
}
@ -22,6 +19,7 @@ export function BulkActionsBar({
categories,
onCategoryChange,
onDelete,
onReEvaluateRules,
onClear,
isUpdating = false,
}: BulkActionsBarProps) {
@ -30,9 +28,9 @@ export function BulkActionsBar({
}
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="flex flex-row justify-between items-center gap-10 bg-background py-2 px-4 border rounded-full shadow-lg">
<div className='text-sm'>
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 animate-in duration-300 fade-in slide-in-from-bottom-4">
<div className="flex flex-row items-center justify-between gap-10 rounded-full border bg-background px-4 py-2 shadow-lg">
<div className="text-sm">
{selectedCount} transaction{selectedCount !== 1 ? 's' : ''}{' '}
selected
</div>
@ -46,12 +44,23 @@ export function BulkActionsBar({
/>
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={onReEvaluateRules}
disabled={isUpdating}
>
<RefreshCw className="h-4 w-4" />
Re-evaluate Rules
</Button>
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={onDelete}
disabled={isUpdating}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950"
>
<Trash2 className="h-4 w-4" />
Delete
@ -74,4 +83,3 @@ export function BulkActionsBar({
</div>
);
}

View File

@ -1,7 +1,9 @@
import { ColumnDef } from '@tanstack/react-table';
import { ArrowDown, MoreHorizontal } from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { ArrowDown, MoreHorizontal } from 'lucide-react';
import { EncryptedText } from '@/components/encrypted-text';
import { CategoryCell } from '@/components/transactions/category-cell';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
@ -11,11 +13,9 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CategoryCell } from '@/components/transactions/category-cell';
import { EncryptedText } from '@/components/encrypted-text';
import { type DecryptedTransaction } from '@/types/transaction';
import { type Category } from '@/types/category';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
interface CreateColumnsOptions {
categories: Category[];
@ -24,6 +24,7 @@ interface CreateColumnsOptions {
onEdit: (transaction: DecryptedTransaction) => void;
onDelete: (transaction: DecryptedTransaction) => void;
onUpdate: (transaction: DecryptedTransaction) => void;
onReEvaluateRules: (transaction: DecryptedTransaction) => void;
}
export function createTransactionColumns({
@ -33,6 +34,7 @@ export function createTransactionColumns({
onEdit,
onDelete,
onUpdate,
onReEvaluateRules,
}: CreateColumnsOptions): ColumnDef<DecryptedTransaction>[] {
return [
{
@ -43,7 +45,9 @@ export function createTransactionColumns({
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
@ -75,8 +79,11 @@ export function createTransactionColumns({
},
cell: ({ row }) => {
return (
<div className="font-medium pl-3">
{format(parseISO(row.getValue('transaction_date')), 'PP')}
<div className="pl-3 font-medium">
{format(
parseISO(row.getValue('transaction_date')),
'PP',
)}
</div>
);
},
@ -105,7 +112,7 @@ export function createTransactionColumns({
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="max-w-[150px] md:max-w-[250px] lg:max-w-[500px] truncate">
<div className="max-w-[150px] truncate md:max-w-[250px] lg:max-w-[500px]">
<EncryptedText
encryptedText={transaction.description}
iv={transaction.description_iv}
@ -160,11 +167,7 @@ export function createTransactionColumns({
accessorKey: 'amount',
meta: { label: 'Amount' },
header: () => {
return (
<div className="w-full text-right">
Amount
</div>
);
return <div className="w-full text-right">Amount</div>;
},
cell: ({ row }) => {
const amount = parseFloat(row.getValue('amount'));
@ -176,10 +179,12 @@ export function createTransactionColumns({
}).format(amount);
return (
<div
className={`text-right`}
>
<span className={`${amount < 0 ? '' : 'bg-green-100/70 dark:bg-green-900'}`}>{formatted}</span>
<div className={`text-right`}>
<span
className={`${amount < 0 ? '' : 'bg-green-100/70 dark:bg-green-900'}`}
>
{formatted}
</span>
</div>
);
},
@ -200,11 +205,18 @@ export function createTransactionColumns({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onEdit(transaction)}>
<DropdownMenuItem
onClick={() => onEdit(transaction)}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant='destructive'
onClick={() => onReEvaluateRules(transaction)}
>
Re-evaluate Rules
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(transaction)}
>
Delete
@ -216,4 +228,3 @@ export function createTransactionColumns({
},
];
}

View File

@ -13,6 +13,8 @@ import { categorySyncService } from '@/services/category-sync';
import { accountSyncService } from '@/services/account-sync';
import { bankSyncService } from '@/services/bank-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { checkDatabaseVersion } from '@/lib/db-migration-helper';
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
@ -89,11 +91,12 @@ export function SyncProvider({
setError(null);
try {
const [categoriesResult, accountsResult, banksResult, transactionsResult] =
const [categoriesResult, accountsResult, banksResult, automationRulesResult, transactionsResult] =
await Promise.all([
categorySyncService.sync(),
accountSyncService.sync(),
bankSyncService.sync(),
automationRuleSyncService.sync(),
transactionSyncService.sync(),
]);
@ -101,6 +104,7 @@ export function SyncProvider({
...categoriesResult.errors,
...accountsResult.errors,
...banksResult.errors,
...automationRulesResult.errors,
...transactionsResult.errors,
];
@ -152,6 +156,16 @@ export function SyncProvider({
return;
}
checkDatabaseVersion().then(({ needsRefresh, missingStores }) => {
if (needsRefresh) {
console.warn(
'Database needs update. Missing stores:',
missingStores,
'\nPlease refresh the page with Ctrl+Shift+R (or Cmd+Shift+R on Mac)',
);
}
});
sync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]);

View File

@ -5,6 +5,7 @@ import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance';
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { edit as editAccount } from '@/routes/account';
import { edit as editDeleteAccount } from '@/routes/delete-account';
import { type NavItem } from '@/types';
@ -22,6 +23,11 @@ const sidebarNavItems: NavItem[] = [
href: accountsIndex(),
icon: null,
},
{
title: 'Automation rules',
href: automationRulesIndex(),
icon: null,
},
{
title: 'Categories',
href: categoriesIndex(),

View File

@ -0,0 +1,44 @@
import { indexedDBService } from './indexeddb';
export async function checkDatabaseVersion(): Promise<{
needsRefresh: boolean;
missingStores: string[];
}> {
const requiredStores = [
'categories',
'accounts',
'banks',
'automation_rules',
'transactions',
'sync_metadata',
'pending_changes',
];
try {
const db = await indexedDBService.init();
const existingStores = Array.from(db.objectStoreNames);
const missingStores = requiredStores.filter(
(store) => !existingStores.includes(store),
);
if (missingStores.length > 0) {
console.warn(
'Missing IndexedDB stores:',
missingStores,
'\nPlease refresh the page (Ctrl+Shift+R or Cmd+Shift+R)',
);
}
return {
needsRefresh: missingStores.length > 0,
missingStores,
};
} catch (error) {
console.error('Failed to check database version:', error);
return {
needsRefresh: false,
missingStores: [],
};
}
}

View File

@ -0,0 +1,9 @@
export function consoleDebug(...args: unknown[]): void {
const isDebugEnabled = localStorage.getItem('debug') === 'true';
const isLocalhost = window.location.hostname === 'localhost';
const isTestDomain = window.location.hostname.includes('.test');
if (isDebugEnabled || isLocalhost || isTestDomain) {
console.log('[DEBUG]', ...args);
}
}

View File

@ -1,5 +1,5 @@
const DB_NAME = 'whisper_money';
const DB_VERSION = 2;
const DB_VERSION = 3;
// Force database recreation if needed
export async function resetDatabase(): Promise<void> {
@ -30,7 +30,7 @@ export interface IndexedDBRecord {
[key: string]: any;
}
export type StoreName = 'categories' | 'accounts' | 'banks' | 'transactions';
export type StoreName = 'categories' | 'accounts' | 'banks' | 'automation_rules' | 'transactions';
class IndexedDBService {
private db: IDBDatabase | null = null;
@ -101,6 +101,21 @@ class IndexedDBService {
});
}
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',

View File

@ -0,0 +1,124 @@
import { consoleDebug } from '@/lib/debug';
import type { Account, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { DecryptedTransaction } from '@/types/transaction';
import jsonLogic from 'json-logic-js';
export interface RuleEvaluationResult {
rule: AutomationRule;
categoryId: number | null;
note: string | null;
noteIv: string | null;
}
export interface TransactionData {
description: string;
amount: number;
transaction_date: string;
bank_name: string;
account_name: string;
category: string | null;
}
export function prepareTransactionData(
transaction: DecryptedTransaction,
accounts: Account[],
banks: Bank[],
categories: Category[],
): TransactionData {
const account = accounts.find((a) => a.id === transaction.account_id);
const bank = account?.bank?.id
? banks.find((b) => b.id === account.bank.id)
: undefined;
const category = transaction.category_id
? categories.find((c) => c.id === transaction.category_id)
: null;
return {
description: transaction.decryptedDescription || '',
amount: parseFloat(transaction.amount),
transaction_date: transaction.transaction_date,
bank_name: bank?.name || '',
account_name: account?.name || '',
category: category?.name || null,
};
}
export function evaluateRules(
transaction: DecryptedTransaction,
rules: AutomationRule[],
categories: Category[],
accounts: Account[],
banks: Bank[],
): RuleEvaluationResult | null {
const sortedRules = [...rules].sort((a, b) => a.priority - b.priority);
const transactionData = prepareTransactionData(
transaction,
accounts,
banks,
categories,
);
consoleDebug('[Rule Engine] Transaction data prepared:', transactionData);
consoleDebug(`[Rule Engine] Evaluating ${sortedRules.length} rules`);
for (const rule of sortedRules) {
try {
consoleDebug(
`[Rule Engine] Evaluating rule #${rule.id}: "${rule.title}"`,
);
consoleDebug('[Rule Engine] Rule JSON:', rule.rules_json);
const result = jsonLogic.apply(rule.rules_json, transactionData);
consoleDebug(`[Rule Engine] Rule #${rule.id} result:`, result);
if (result === true) {
consoleDebug(`[Rule Engine] ✓ Rule #${rule.id} matched!`);
return {
rule,
categoryId: rule.action_category_id,
note: rule.action_note,
noteIv: rule.action_note_iv,
};
}
} catch (error) {
consoleDebug(
`[Rule Engine] ❌ Error evaluating rule ${rule.id}:`,
error,
);
console.error(`Error evaluating rule ${rule.id}:`, error);
}
}
consoleDebug('[Rule Engine] No rules matched');
return null;
}
export function evaluateRulesForTransactions(
transactions: DecryptedTransaction[],
rules: AutomationRule[],
categories: Category[],
accounts: Account[],
banks: Bank[],
): Map<string, RuleEvaluationResult> {
const results = new Map<string, RuleEvaluationResult>();
for (const transaction of transactions) {
const result = evaluateRules(
transaction,
rules,
categories,
accounts,
banks,
);
if (result) {
results.set(transaction.id, result);
}
}
return results;
}

View File

@ -0,0 +1,354 @@
import { useState, useEffect } from 'react';
import { Head } from '@inertiajs/react';
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import HeadingSmall from '@/components/heading-small';
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog';
import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog';
import {
type AutomationRule,
formatRuleActions,
getRuleActions,
} from '@/types/automation-rule';
import { getCategoryColorClasses } from '@/types/category';
import { type BreadcrumbItem } from '@/types';
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { useSyncContext } from '@/contexts/sync-context';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Automation rules settings',
href: automationRulesIndex().url,
},
];
function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setEditOpen(true)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditAutomationRuleDialog
rule={rule}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteAutomationRuleDialog
rule={rule}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</>
);
}
export default function AutomationRules() {
const { syncStatus } = useSyncContext();
const { isKeySet } = useEncryptionKey();
const [rules, setRules] = useState<AutomationRule[]>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
useEffect(() => {
const loadRules = async () => {
const data = await automationRuleSyncService.getAll();
setRules(data);
};
loadRules();
}, []);
useEffect(() => {
if (syncStatus === 'success') {
const reloadRules = async () => {
const data = await automationRuleSyncService.getAll();
setRules(data);
};
reloadRules();
}
}, [syncStatus]);
const columns: ColumnDef<AutomationRule>[] = [
{
accessorKey: 'title',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
Title
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<div className="pl-3 font-medium">
{row.getValue('title')}
</div>
);
},
},
{
id: 'actions_display',
header: 'Actions',
cell: ({ row }) => {
const rule = row.original;
const actions = getRuleActions(rule);
if (actions.type === 'both') {
const colorClasses = actions.category
? getCategoryColorClasses(actions.category.color)
: null;
return (
<div className="flex items-center gap-2">
{colorClasses && (
<Badge
className={`${colorClasses.bg} ${colorClasses.text}`}
>
{actions.category?.name}
</Badge>
)}
<span className="text-muted-foreground text-sm">
and add note
</span>
</div>
);
}
if (actions.type === 'category' && actions.category) {
const colorClasses = getCategoryColorClasses(
actions.category.color,
);
return (
<Badge
className={`${colorClasses.bg} ${colorClasses.text}`}
>
{actions.category.name}
</Badge>
);
}
return (
<span className="text-muted-foreground text-sm">
Add note
</span>
);
},
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => <AutomationRuleActions rule={row.original} />,
},
];
const table = useReactTable({
data: rules,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnFilters,
columnVisibility,
},
});
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Automation rules settings" />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall
title="Automation rules settings"
description="Manage your transaction automation rules"
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Input
placeholder="Filter rules..."
value={
(table
.getColumn('title')
?.getFilterValue() as string) ?? ''
}
onChange={(event) =>
table
.getColumn('title')
?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<CreateAutomationRuleDialog disabled={!isKeySet} />
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
return (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
},
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No automation rules found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredRowModel().rows.length} rule(s)
total.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</div>
</SettingsLayout>
</AppLayout>
);
}

View File

@ -1,4 +1,3 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Head } from '@inertiajs/react';
import {
ColumnFiltersState,
@ -9,18 +8,15 @@ import {
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { parseISO, isWithinInterval } from 'date-fns';
import { isWithinInterval, parseISO } from 'date-fns';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { DataTable } from '@/components/ui/data-table';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import HeadingSmall from '@/components/heading-small';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
import { Skeleton } from '@/components/ui/skeleton';
import { TransactionFilters } from '@/components/transactions/transaction-filters';
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
import { createTransactionColumns } from '@/components/transactions/transaction-columns';
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
import { TransactionFilters } from '@/components/transactions/transaction-filters';
import {
AlertDialog,
AlertDialogAction,
@ -31,18 +27,25 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { type Category } from '@/types/category';
import { DataTable } from '@/components/ui/data-table';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
import { Skeleton } from '@/components/ui/skeleton';
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 { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import { type BreadcrumbItem } from '@/types';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import {
type DecryptedTransaction,
type TransactionFilters as Filters,
} from '@/types/transaction';
import { type BreadcrumbItem } from '@/types';
import { transactionSyncService } from '@/services/transaction-sync';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
const breadcrumbs: BreadcrumbItem[] = [
{
@ -57,17 +60,36 @@ interface Props {
banks: Bank[];
}
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
function getInitialColumnVisibility(): VisibilityState {
try {
const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.error(
'Failed to load column visibility from localStorage:',
error,
);
}
return { account: false };
}
export default function Transactions({ categories, accounts, banks }: Props) {
const { isKeySet } = useEncryptionKey();
const [transactions, setTransactions] = useState<DecryptedTransaction[]>([]);
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
[],
);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'transaction_date', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
account: false,
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
getInitialColumnVisibility(),
);
const [rowSelection, setRowSelection] = useState({});
const [filters, setFilters] = useState<Filters>({
dateFrom: null,
@ -85,6 +107,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
const [isDeleting, setIsDeleting] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
const [isReEvaluating, setIsReEvaluating] = useState(false);
const [displayedCount, setDisplayedCount] = useState(25);
const observerTarget = useRef<HTMLDivElement>(null);
@ -109,7 +132,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
: updatedTransaction.bank,
category:
updatedTransaction.category === undefined
? transaction.category ?? null
? (transaction.category ?? null)
: updatedTransaction.category,
};
}),
@ -198,12 +221,89 @@ export default function Transactions({ categories, accounts, banks }: Props) {
}),
);
setTransactions(
decrypted.filter(
(transaction): transaction is DecryptedTransaction =>
transaction !== 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 {
@ -215,6 +315,20 @@ export default function Transactions({ categories, accounts, banks }: Props) {
loadTransactions();
}, [loadTransactions]);
useEffect(() => {
try {
localStorage.setItem(
COLUMN_VISIBILITY_KEY,
JSON.stringify(columnVisibility),
);
} catch (error) {
console.error(
'Failed to save column visibility to localStorage:',
error,
);
}
}, [columnVisibility]);
useEffect(() => {
async function reDecryptTransactions() {
if (transactions.length === 0) {
@ -335,10 +449,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
if (filters.searchText && isKeySet) {
const searchLower = filters.searchText.toLowerCase();
const matchesDescription =
transaction.decryptedDescription
.toLowerCase()
.includes(searchLower);
const matchesDescription = transaction.decryptedDescription
.toLowerCase()
.includes(searchLower);
const matchesNotes =
transaction.decryptedNotes
?.toLowerCase()
@ -357,6 +470,289 @@ export default function Transactions({ categories, accounts, banks }: Props) {
return filteredTransactions.slice(0, displayedCount);
}, [filteredTransactions, displayedCount]);
async function handleReEvaluateRules(transaction: DecryptedTransaction) {
consoleDebug('=== Re-evaluating rules for single transaction ===');
consoleDebug('Transaction:', {
id: transaction.id,
description: transaction.decryptedDescription,
amount: transaction.amount,
currentCategory: transaction.category?.name || 'None',
});
setIsReEvaluating(true);
try {
const keyString = getStoredKey();
if (!keyString || !isKeySet) {
consoleDebug('❌ Encryption key not set');
console.error('Encryption key not set');
return;
}
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) {
consoleDebug('❌ No rules to evaluate');
return;
}
consoleDebug('Evaluating rules against transaction...');
const result = evaluateRules(
transaction,
rules,
categories,
accounts,
banks,
);
consoleDebug('Rule evaluation result:', result);
if (result) {
consoleDebug('✓ Rule matched! Applying changes...');
let finalNotes = transaction.notes;
let finalNotesIv = transaction.notes_iv;
if (result.note && result.noteIv) {
consoleDebug('Adding note from rule');
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;
consoleDebug('Combined existing notes with rule note');
} else {
finalNotes = result.note;
finalNotesIv = result.noteIv;
consoleDebug('Set rule note as new note');
}
}
const updateData = {
category_id: result.categoryId,
notes: finalNotes,
notes_iv: finalNotesIv,
};
consoleDebug('Updating transaction with:', updateData);
await transactionSyncService.update(transaction.id, updateData);
consoleDebug('✓ Transaction updated in IndexedDB');
const selectedCategory = result.categoryId
? categories.find((c) => c.id === result.categoryId) || null
: null;
let decryptedNotes = transaction.decryptedNotes;
if (finalNotes && finalNotesIv) {
decryptedNotes = await decrypt(
finalNotes,
key,
finalNotesIv,
);
}
const updatedTransaction = {
...transaction,
category_id: result.categoryId,
category: selectedCategory,
notes: finalNotes,
notes_iv: finalNotesIv,
decryptedNotes,
};
consoleDebug('Updating UI state with:', {
id: updatedTransaction.id,
newCategory: selectedCategory?.name || 'None',
hasNotes: !!decryptedNotes,
});
updateTransaction(updatedTransaction);
consoleDebug('✓ UI state updated successfully');
} else {
consoleDebug('❌ No rules matched this transaction');
}
} catch (error) {
consoleDebug('❌ Error during re-evaluation:', error);
console.error('Failed to re-evaluate rules:', error);
} finally {
setIsReEvaluating(false);
consoleDebug('=== Re-evaluation complete ===');
}
}
async function handleBulkReEvaluateRules() {
const selectedIds = Object.keys(rowSelection);
consoleDebug('=== Re-evaluating rules for bulk transactions ===');
consoleDebug(`Selected ${selectedIds.length} transactions`);
if (selectedIds.length === 0) {
consoleDebug('❌ No transactions selected');
return;
}
setIsReEvaluating(true);
try {
const keyString = getStoredKey();
if (!keyString || !isKeySet) {
consoleDebug('❌ Encryption key not set');
console.error('Encryption key not set');
return;
}
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) {
consoleDebug('❌ No rules to evaluate');
return;
}
const selectedTransactions = transactions.filter((t) =>
selectedIds.includes(t.id),
);
consoleDebug(
'Processing transactions:',
selectedTransactions.map((t) => ({
id: t.id,
description: t.decryptedDescription,
currentCategory: t.category?.name || 'None',
})),
);
const updates: Array<{
transaction: DecryptedTransaction;
categoryId: number | null;
category: Category | null;
notes: string | null;
notesIv: string | null;
decryptedNotes: string | null;
}> = [];
for (const transaction of selectedTransactions) {
consoleDebug(`\nEvaluating transaction ${transaction.id}...`);
const result = evaluateRules(
transaction,
rules,
categories,
accounts,
banks,
);
consoleDebug('Rule evaluation result:', result);
if (result) {
consoleDebug('✓ Rule matched! Applying changes...');
let finalNotes = transaction.notes;
let finalNotesIv = transaction.notes_iv;
if (result.note && result.noteIv) {
consoleDebug('Adding note from rule');
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;
consoleDebug(
'Combined existing notes with rule note',
);
} else {
finalNotes = result.note;
finalNotesIv = result.noteIv;
consoleDebug('Set rule note as new note');
}
}
const updateData = {
category_id: result.categoryId,
notes: finalNotes,
notes_iv: finalNotesIv,
};
consoleDebug('Updating transaction with:', updateData);
await transactionSyncService.update(
transaction.id,
updateData,
);
consoleDebug('✓ Transaction updated in IndexedDB');
const selectedCategory = result.categoryId
? categories.find((c) => c.id === result.categoryId) ||
null
: null;
let decryptedNotes = transaction.decryptedNotes;
if (finalNotes && finalNotesIv) {
decryptedNotes = await decrypt(
finalNotes,
key,
finalNotesIv,
);
}
updates.push({
transaction,
categoryId: result.categoryId,
category: selectedCategory,
notes: finalNotes,
notesIv: finalNotesIv,
decryptedNotes,
});
consoleDebug(
`✓ Queued update for transaction ${transaction.id}`,
);
} else {
consoleDebug(
`❌ No rules matched transaction ${transaction.id}`,
);
}
}
consoleDebug(`\nApplying ${updates.length} updates to UI state...`);
if (updates.length > 0) {
setTransactions((previous) =>
previous.map((transaction) => {
const update = updates.find(
(u) => u.transaction.id === transaction.id,
);
if (update) {
consoleDebug(
`Updating UI for transaction ${transaction.id}:`,
{
newCategory:
update.category?.name || 'None',
hasNotes: !!update.decryptedNotes,
},
);
return {
...transaction,
category_id: update.categoryId,
category: update.category,
notes: update.notes,
notes_iv: update.notesIv,
decryptedNotes: update.decryptedNotes,
};
}
return transaction;
}),
);
consoleDebug('✓ UI state updated successfully');
} else {
consoleDebug('❌ No updates to apply');
}
consoleDebug('Clearing selection...');
setRowSelection({});
} catch (error) {
consoleDebug('❌ Error during bulk re-evaluation:', error);
console.error('Failed to re-evaluate rules:', error);
} finally {
setIsReEvaluating(false);
consoleDebug('=== Bulk re-evaluation complete ===');
}
}
const columns = useMemo(
() =>
createTransactionColumns({
@ -366,6 +762,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
onEdit: setEditTransaction,
onDelete: setDeleteTransaction,
onUpdate: updateTransaction,
onReEvaluateRules: handleReEvaluateRules,
}),
[accounts, banks, categories, updateTransaction],
);
@ -392,7 +789,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
const loadMore = useCallback(() => {
if (displayedCount < filteredTransactions.length) {
setDisplayedCount((prev) => Math.min(prev + 25, filteredTransactions.length));
setDisplayedCount((prev) =>
Math.min(prev + 25, filteredTransactions.length),
);
}
}, [displayedCount, filteredTransactions.length]);
@ -403,7 +802,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
loadMore();
}
},
{ threshold: 0.1 }
{ threshold: 0.1 },
);
const currentTarget = observerTarget.current;
@ -507,7 +906,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
try {
await transactionSyncService.deleteMany(selectedIds);
setTransactions((previous) =>
previous.filter((transaction) => !selectedIds.includes(transaction.id)),
previous.filter(
(transaction) => !selectedIds.includes(transaction.id),
),
);
setDeleteTransaction(null);
setRowSelection({});
@ -554,19 +955,21 @@ export default function Transactions({ categories, accounts, banks }: Props) {
<Skeleton className="h-5 w-16 justify-self-end" />
</div>
<div className="divide-y">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-6 gap-4 p-4"
>
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-20 justify-self-end" />
</div>
))}
{Array.from({ length: 6 }).map(
(_, index) => (
<div
key={index}
className="grid grid-cols-6 gap-4 p-4"
>
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-20 justify-self-end" />
</div>
),
)}
</div>
</div>
<div className="flex items-center justify-between">
@ -610,7 +1013,8 @@ export default function Transactions({ categories, accounts, banks }: Props) {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete Transaction{Object.keys(rowSelection).length > 1 ? 's' : ''}
Delete Transaction
{Object.keys(rowSelection).length > 1 ? 's' : ''}
</AlertDialogTitle>
<AlertDialogDescription>
{Object.keys(rowSelection).length > 1
@ -619,7 +1023,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting || isBulkDeleting}>
<AlertDialogCancel
disabled={isDeleting || isBulkDeleting}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
@ -631,7 +1037,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
disabled={isDeleting || isBulkDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting || isBulkDeleting ? 'Deleting...' : 'Delete'}
{isDeleting || isBulkDeleting
? 'Deleting...'
: 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -642,10 +1050,10 @@ export default function Transactions({ categories, accounts, banks }: Props) {
categories={categories}
onCategoryChange={handleBulkCategoryChange}
onDelete={handleBulkDeleteClick}
onReEvaluateRules={handleBulkReEvaluateRules}
onClear={handleClearSelection}
isUpdating={isBulkUpdating}
isUpdating={isBulkUpdating || isReEvaluating}
/>
</AppSidebarLayout>
);
}

View File

@ -0,0 +1,70 @@
import { SyncManager } from '@/lib/sync-manager';
import type { AutomationRule } from '@/types/automation-rule';
class AutomationRuleSyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'automation_rules',
endpoint: '/api/sync/automation-rules',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<AutomationRule[]> {
const rules = await this.syncManager.getAll<AutomationRule>();
return rules.map((rule) => ({
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
}));
}
async getById(id: number): Promise<AutomationRule | null> {
const rule = await this.syncManager.getById<AutomationRule>(id);
if (!rule) {
return null;
}
return {
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
};
}
async create(data: Omit<AutomationRule, 'id'>): Promise<AutomationRule> {
return await this.syncManager.createLocal<AutomationRule>(
data as Omit<AutomationRule, 'id'> & {
id?: number;
created_at?: string;
updated_at?: string;
},
);
}
async update(id: number, data: Partial<AutomationRule>): Promise<void> {
await this.syncManager.updateLocal<AutomationRule>(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 automationRuleSyncService = new AutomationRuleSyncService();

View File

@ -0,0 +1,63 @@
import type { Category } from './category';
export interface AutomationRule {
id: number;
user_id: number;
title: string;
priority: number;
rules_json: Record<string, any>;
action_category_id: number | null;
action_note: string | null;
action_note_iv: string | null;
category?: Category;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface RuleAction {
type: 'category' | 'note' | 'both';
category?: Category;
hasNote: boolean;
}
export function getRuleActions(rule: AutomationRule): RuleAction {
const hasCategory = rule.action_category_id !== null;
const hasNote = rule.action_note !== null;
if (hasCategory && hasNote) {
return {
type: 'both',
category: rule.category,
hasNote: true,
};
}
if (hasCategory) {
return {
type: 'category',
category: rule.category,
hasNote: false,
};
}
return {
type: 'note',
hasNote: true,
};
}
export function formatRuleActions(rule: AutomationRule): string {
const actions = getRuleActions(rule);
if (actions.type === 'both') {
return `${actions.category?.name} and add note`;
}
if (actions.type === 'category') {
return actions.category?.name || '';
}
return 'Add note';
}

View File

@ -34,6 +34,11 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
Route::delete('settings/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy');
Route::get('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'index'])->name('automation-rules.index');
Route::post('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'store'])->name('automation-rules.store');
Route::patch('settings/automation-rules/{automationRule}', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'update'])->name('automation-rules.update');
Route::delete('settings/automation-rules/{automationRule}', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'destroy'])->name('automation-rules.destroy');
Route::get('settings/appearance', function () {
return Inertia::render('settings/appearance');
})->name('appearance.edit');

View File

@ -27,6 +27,7 @@ Route::middleware(['auth'])->group(function () {
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::get('api/sync/automation-rules', [\App\Http\Controllers\Sync\AutomationRuleSyncController::class, 'index']);
Route::get('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'index']);
Route::post('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'store']);
Route::patch('api/sync/transactions/{transaction}', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'update']);

View File

@ -0,0 +1,205 @@
<?php
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\User;
test('user can view their automation rules', function () {
$user = User::factory()->create();
$rule = AutomationRule::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get(route('automation-rules.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('settings/automation-rules')
->has('rules', 1)
);
});
test('user can create an automation rule with category action', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Grocery Rule',
'priority' => 10,
'rules_json' => json_encode(['in' => ['GROCERY', ['var' => 'description']]]),
'action_category_id' => $category->id,
'action_note' => null,
'action_note_iv' => null,
]);
$response->assertRedirect(route('automation-rules.index'));
$this->assertDatabaseHas('automation_rules', [
'user_id' => $user->id,
'title' => 'Grocery Rule',
'priority' => 10,
'action_category_id' => $category->id,
]);
});
test('user can create an automation rule with note action', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Note Rule',
'priority' => 5,
'rules_json' => json_encode(['==' => [['var' => 'amount'], 100]]),
'action_category_id' => null,
'action_note' => 'encrypted_note',
'action_note_iv' => 'test_iv',
]);
$response->assertRedirect(route('automation-rules.index'));
$this->assertDatabaseHas('automation_rules', [
'user_id' => $user->id,
'title' => 'Note Rule',
'action_note' => 'encrypted_note',
]);
});
test('user can create an automation rule with both actions', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Combined Rule',
'priority' => 0,
'rules_json' => json_encode(['>' => [['var' => 'amount'], 50]]),
'action_category_id' => $category->id,
'action_note' => 'encrypted_note',
'action_note_iv' => 'test_iv',
]);
$response->assertRedirect(route('automation-rules.index'));
$this->assertDatabaseHas('automation_rules', [
'user_id' => $user->id,
'title' => 'Combined Rule',
'action_category_id' => $category->id,
'action_note' => 'encrypted_note',
]);
});
test('user cannot create rule without at least one action', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Invalid Rule',
'priority' => 0,
'rules_json' => json_encode(['>' => [['var' => 'amount'], 50]]),
'action_category_id' => null,
'action_note' => null,
'action_note_iv' => null,
]);
$response->assertSessionHasErrors('action_category_id');
});
test('user cannot create rule with invalid json logic', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Invalid JSON Rule',
'priority' => 0,
'rules_json' => 'invalid json',
'action_category_id' => $category->id,
]);
$response->assertSessionHasErrors('rules_json');
});
test('user can update their automation rule', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$rule = AutomationRule::factory()->create([
'user_id' => $user->id,
'title' => 'Old Title',
]);
$response = $this->actingAs($user)->patch(route('automation-rules.update', $rule), [
'title' => 'New Title',
'priority' => 20,
'rules_json' => json_encode(['==' => [['var' => 'bank_name'], 'Chase']]),
'action_category_id' => $category->id,
'action_note' => null,
'action_note_iv' => null,
]);
$response->assertRedirect(route('automation-rules.index'));
$this->assertDatabaseHas('automation_rules', [
'id' => $rule->id,
'title' => 'New Title',
'priority' => 20,
]);
});
test('user cannot update another users automation rule', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$rule = AutomationRule::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->patch(route('automation-rules.update', $rule), [
'title' => 'Hacked Title',
'priority' => 0,
'rules_json' => json_encode(['==' => [1, 1]]),
'action_category_id' => $category->id,
]);
$response->assertForbidden();
});
test('user can soft delete their automation rule', function () {
$user = User::factory()->create();
$rule = AutomationRule::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->delete(route('automation-rules.destroy', $rule));
$response->assertRedirect(route('automation-rules.index'));
$this->assertSoftDeleted('automation_rules', ['id' => $rule->id]);
});
test('user cannot delete another users automation rule', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$rule = AutomationRule::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->delete(route('automation-rules.destroy', $rule));
$response->assertForbidden();
$this->assertDatabaseHas('automation_rules', ['id' => $rule->id]);
});
test('rules are ordered by priority', function () {
$user = User::factory()->create();
AutomationRule::factory()->create(['user_id' => $user->id, 'priority' => 30]);
AutomationRule::factory()->create(['user_id' => $user->id, 'priority' => 10]);
AutomationRule::factory()->create(['user_id' => $user->id, 'priority' => 20]);
$response = $this->actingAs($user)->get(route('automation-rules.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->has('rules', 3)
->where('rules.0.priority', 10)
->where('rules.1.priority', 20)
->where('rules.2.priority', 30)
);
});
test('user cannot use another users category in rule', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$otherCategory = Category::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Invalid Category Rule',
'priority' => 0,
'rules_json' => json_encode(['==' => [1, 1]]),
'action_category_id' => $otherCategory->id,
]);
$response->assertSessionHasErrors('action_category_id');
});