diff --git a/app/Http/Controllers/Settings/AutomationRuleController.php b/app/Http/Controllers/Settings/AutomationRuleController.php new file mode 100644 index 00000000..3e8c3e58 --- /dev/null +++ b/app/Http/Controllers/Settings/AutomationRuleController.php @@ -0,0 +1,67 @@ +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'); + } +} diff --git a/app/Http/Controllers/Sync/AutomationRuleSyncController.php b/app/Http/Controllers/Sync/AutomationRuleSyncController.php new file mode 100644 index 00000000..5121ff51 --- /dev/null +++ b/app/Http/Controllers/Sync/AutomationRuleSyncController.php @@ -0,0 +1,23 @@ +user() + ->automationRules() + ->with('category:id,name,icon,color') + ->orderBy('priority') + ->get(); + + return response()->json($rules); + } +} diff --git a/app/Http/Requests/Settings/StoreAutomationRuleRequest.php b/app/Http/Requests/Settings/StoreAutomationRuleRequest.php new file mode 100644 index 00000000..5c130b4b --- /dev/null +++ b/app/Http/Requests/Settings/StoreAutomationRuleRequest.php @@ -0,0 +1,58 @@ +|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.'); + } + }); + } +} diff --git a/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php b/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php new file mode 100644 index 00000000..0817e003 --- /dev/null +++ b/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php @@ -0,0 +1,58 @@ +|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.'); + } + }); + } +} diff --git a/app/Models/AutomationRule.php b/app/Models/AutomationRule.php new file mode 100644 index 00000000..56214956 --- /dev/null +++ b/app/Models/AutomationRule.php @@ -0,0 +1,42 @@ + */ + 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'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3de6f14e..1baa18a9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -72,4 +72,9 @@ class User extends Authenticatable { return $this->hasMany(Category::class); } + + public function automationRules(): HasMany + { + return $this->hasMany(AutomationRule::class); + } } diff --git a/app/Policies/AutomationRulePolicy.php b/app/Policies/AutomationRulePolicy.php new file mode 100644 index 00000000..8b31a29a --- /dev/null +++ b/app/Policies/AutomationRulePolicy.php @@ -0,0 +1,65 @@ +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; + } +} diff --git a/database/factories/AutomationRuleFactory.php b/database/factories/AutomationRuleFactory.php new file mode 100644 index 00000000..0973e56d --- /dev/null +++ b/database/factories/AutomationRuleFactory.php @@ -0,0 +1,33 @@ + + */ +class AutomationRuleFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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, + ]; + } +} diff --git a/database/migrations/2025_11_10_110100_create_automation_rules_table.php b/database/migrations/2025_11_10_110100_create_automation_rules_table.php new file mode 100644 index 00000000..03ef2ecd --- /dev/null +++ b/database/migrations/2025_11_10_110100_create_automation_rules_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/package-lock.json b/package-lock.json index 565bd6fd..b7b2f045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3b86c20a..5950e200 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx new file mode 100644 index 00000000..43181ba7 --- /dev/null +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -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([]); + const [title, setTitle] = useState(''); + const [priority, setPriority] = useState('0'); + const [rulesJson, setRulesJson] = useState(''); + const [categoryId, setCategoryId] = useState(''); + const [note, setNote] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + 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); + }, + onFinish: () => { + setIsSubmitting(false); + }, + }, + ); + } catch (error) { + console.error('Failed to create automation rule:', error); + setIsSubmitting(false); + } + }; + + return ( + + + + + + + Create Automation Rule + + Create a rule to automatically categorize transactions or add notes. + + +
+
+ + setTitle(e.target.value)} + placeholder="Rule title" + required + /> + {errors.title && ( +

{errors.title}

+ )} +
+ +
+ + setPriority(e.target.value)} + placeholder="0" + required + /> +

+ Lower numbers execute first +

+ {errors.priority && ( +

{errors.priority}

+ )} +
+ +
+ +