From 4b5d65ba03371c7b85bab0b64ec4dc8d19b015b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 13 Dec 2025 13:02:19 +0100 Subject: [PATCH] feat: add transaction labels feature (#24) ## Summary Add a labeling system for transactions that differs from categories in that labels are ephemeral and created on-the-fly when assigned (no pre-creation required). A transaction can have multiple labels (many-to-many relationship), and labels can be used for filtering in the transaction table and as actions in automation rules. image image image ## Changes ### Backend (Laravel) - Add `labels` table with user_id, name, color, and soft deletes - Add `label_transaction` pivot table for transaction-label many-to-many - Add `automation_rule_labels` pivot table for automation rule-label many-to-many - Create Label model with relationships to User, Transaction, and AutomationRule - Add LabelController for CRUD operations (returns JSON for on-the-fly creation) - Add LabelSyncController for frontend sync - Add LabelPolicy for authorization - Update TransactionController to handle bulk label updates - Update AutomationRuleController to handle label actions - Add validation for label_ids in transactions and automation rules ### Frontend (React/TypeScript) - Add Label type and color utilities - Add labels table to IndexedDB (Dexie version 6) - Create label-sync service with findOrCreate functionality - Create LabelCombobox component with multi-select and create-on-type - Add labels column to transactions table - Add labels filter to transaction filters - Add bulk label assignment in bulk actions bar - Add labels action in create automation rule dialog - Update rule engine to include labels in evaluation results ## Testing - 11 feature tests for label CRUD - 3 tests for label sync - All 241 feature tests pass ## Screenshots N/A - UI changes can be reviewed in browser --- app/Enums/LabelColor.php | 27 ++ .../Settings/AutomationRuleController.php | 19 +- .../Controllers/Settings/LabelController.php | 71 ++++ .../Sync/AutomationRuleSyncController.php | 2 +- .../Controllers/Sync/LabelSyncController.php | 26 ++ .../Sync/TransactionSyncController.php | 18 +- .../Controllers/TransactionController.php | 34 +- .../BulkUpdateTransactionsRequest.php | 2 + .../Settings/StoreAutomationRuleRequest.php | 14 +- .../Requests/Settings/StoreLabelRequest.php | 43 +++ .../Settings/UpdateAutomationRuleRequest.php | 14 +- .../Requests/Settings/UpdateLabelRequest.php | 46 +++ app/Http/Requests/StoreTransactionRequest.php | 2 + app/Models/AutomationRule.php | 7 + app/Models/AutomationRuleLabel.php | 17 + app/Models/Label.php | 40 +++ app/Models/LabelTransaction.php | 17 + app/Models/Transaction.php | 8 + app/Models/User.php | 5 + app/Policies/LabelPolicy.php | 65 ++++ database/factories/LabelFactory.php | 26 ++ .../2025_12_12_092647_create_labels_table.php | 33 ++ ..._092650_create_label_transaction_table.php | 31 ++ ...51_create_automation_rule_labels_table.php | 30 ++ ...s_unique_constraint_include_deleted_at.php | 34 ++ .../create-automation-rule-dialog.tsx | 46 ++- .../components/labels/create-label-dialog.tsx | 120 +++++++ .../components/labels/delete-label-dialog.tsx | 69 ++++ .../components/labels/edit-label-dialog.tsx | 134 +++++++ .../onboarding/step-create-account.tsx | 1 + .../js/components/shared/label-combobox.tsx | 282 +++++++++++++++ .../transactions/bulk-actions-bar.tsx | 12 + .../transactions/bulk-label-select.tsx | 34 ++ .../transactions/edit-transaction-dialog.tsx | 51 ++- .../transactions/transaction-columns.tsx | 22 ++ .../transactions/transaction-filters.tsx | 126 ++++++- resources/js/contexts/sync-context.tsx | 4 + resources/js/layouts/settings/layout.tsx | 7 + resources/js/lib/dexie-db.ts | 14 + resources/js/lib/rule-engine.ts | 7 + resources/js/pages/settings/labels.tsx | 335 ++++++++++++++++++ resources/js/pages/transactions/index.tsx | 92 ++++- resources/js/services/label-sync.ts | 102 ++++++ resources/js/services/transaction-sync.ts | 121 ++++++- resources/js/types/automation-rule.ts | 45 ++- resources/js/types/label.ts | 156 ++++++++ resources/js/types/transaction.ts | 4 + routes/api.php | 2 + routes/settings.php | 6 + tests/Feature/LabelTest.php | 201 +++++++++++ tests/Feature/Sync/LabelSyncTest.php | 54 +++ 51 files changed, 2615 insertions(+), 63 deletions(-) create mode 100644 app/Enums/LabelColor.php create mode 100644 app/Http/Controllers/Settings/LabelController.php create mode 100644 app/Http/Controllers/Sync/LabelSyncController.php create mode 100644 app/Http/Requests/Settings/StoreLabelRequest.php create mode 100644 app/Http/Requests/Settings/UpdateLabelRequest.php create mode 100644 app/Models/AutomationRuleLabel.php create mode 100644 app/Models/Label.php create mode 100644 app/Models/LabelTransaction.php create mode 100644 app/Policies/LabelPolicy.php create mode 100644 database/factories/LabelFactory.php create mode 100644 database/migrations/2025_12_12_092647_create_labels_table.php create mode 100644 database/migrations/2025_12_12_092650_create_label_transaction_table.php create mode 100644 database/migrations/2025_12_12_092651_create_automation_rule_labels_table.php create mode 100644 database/migrations/2025_12_12_141955_update_labels_unique_constraint_include_deleted_at.php create mode 100644 resources/js/components/labels/create-label-dialog.tsx create mode 100644 resources/js/components/labels/delete-label-dialog.tsx create mode 100644 resources/js/components/labels/edit-label-dialog.tsx create mode 100644 resources/js/components/shared/label-combobox.tsx create mode 100644 resources/js/components/transactions/bulk-label-select.tsx create mode 100644 resources/js/pages/settings/labels.tsx create mode 100644 resources/js/services/label-sync.ts create mode 100644 resources/js/types/label.ts create mode 100644 tests/Feature/LabelTest.php create mode 100644 tests/Feature/Sync/LabelSyncTest.php diff --git a/app/Enums/LabelColor.php b/app/Enums/LabelColor.php new file mode 100644 index 00000000..e74a6a3f --- /dev/null +++ b/app/Enums/LabelColor.php @@ -0,0 +1,27 @@ +user() ->automationRules() - ->with('category:id,name,icon,color') + ->with(['category:id,name,icon,color', 'labels:id,name,color']) ->orderBy('priority') ->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']); @@ -36,7 +36,15 @@ class AutomationRuleController extends Controller */ public function store(StoreAutomationRuleRequest $request): RedirectResponse { - auth()->user()->automationRules()->create($request->validated()); + $validated = $request->validated(); + $labelIds = $validated['action_label_ids'] ?? []; + unset($validated['action_label_ids']); + + $rule = auth()->user()->automationRules()->create($validated); + + if (! empty($labelIds)) { + $rule->labels()->sync($labelIds); + } return back(); } @@ -48,7 +56,12 @@ class AutomationRuleController extends Controller { $this->authorize('update', $automationRule); - $automationRule->update($request->validated()); + $validated = $request->validated(); + $labelIds = $validated['action_label_ids'] ?? []; + unset($validated['action_label_ids']); + + $automationRule->update($validated); + $automationRule->labels()->sync($labelIds); return back(); } diff --git a/app/Http/Controllers/Settings/LabelController.php b/app/Http/Controllers/Settings/LabelController.php new file mode 100644 index 00000000..2e91deea --- /dev/null +++ b/app/Http/Controllers/Settings/LabelController.php @@ -0,0 +1,71 @@ +user() + ->labels() + ->orderBy('name') + ->get(['id', 'name', 'color']); + + return Inertia::render('settings/labels', [ + 'labels' => $labels, + ]); + } + + /** + * Store a newly created label. + */ + public function store(StoreLabelRequest $request): JsonResponse|RedirectResponse + { + $label = auth()->user()->labels()->create($request->validated()); + + if ($request->expectsJson()) { + return response()->json(['data' => $label], 201); + } + + return to_route('labels.index'); + } + + /** + * Update the specified label. + */ + public function update(UpdateLabelRequest $request, Label $label): RedirectResponse + { + $this->authorize('update', $label); + + $label->update($request->validated()); + + return to_route('labels.index'); + } + + /** + * Soft delete the specified label. + */ + public function destroy(Label $label): RedirectResponse + { + $this->authorize('delete', $label); + + $label->delete(); + + return to_route('labels.index'); + } +} diff --git a/app/Http/Controllers/Sync/AutomationRuleSyncController.php b/app/Http/Controllers/Sync/AutomationRuleSyncController.php index 5121ff51..a56741fd 100644 --- a/app/Http/Controllers/Sync/AutomationRuleSyncController.php +++ b/app/Http/Controllers/Sync/AutomationRuleSyncController.php @@ -14,7 +14,7 @@ class AutomationRuleSyncController extends Controller { $rules = auth()->user() ->automationRules() - ->with('category:id,name,icon,color') + ->with(['category:id,name,icon,color', 'labels:id,name,color']) ->orderBy('priority') ->get(); diff --git a/app/Http/Controllers/Sync/LabelSyncController.php b/app/Http/Controllers/Sync/LabelSyncController.php new file mode 100644 index 00000000..8ad10421 --- /dev/null +++ b/app/Http/Controllers/Sync/LabelSyncController.php @@ -0,0 +1,26 @@ +where('user_id', $request->user()->id); + + if ($request->has('since')) { + $query->where('updated_at', '>', $request->input('since')); + } + + $labels = $query->orderBy('name', 'asc')->get(); + + return response()->json([ + 'data' => $labels, + ]); + } +} diff --git a/app/Http/Controllers/Sync/TransactionSyncController.php b/app/Http/Controllers/Sync/TransactionSyncController.php index c2c33935..537f585b 100644 --- a/app/Http/Controllers/Sync/TransactionSyncController.php +++ b/app/Http/Controllers/Sync/TransactionSyncController.php @@ -20,6 +20,7 @@ class TransactionSyncController extends Controller } $transactions = $query + ->with('labels:id,name,color') ->orderBy('transaction_date', 'desc') ->orderBy('updated_at', 'desc') ->get(); @@ -32,6 +33,8 @@ class TransactionSyncController extends Controller public function store(StoreTransactionRequest $request): JsonResponse { $data = $request->validated(); + $labelIds = $data['label_ids'] ?? []; + unset($data['label_ids']); // Create transaction with provided ID if available $transaction = new Transaction([ @@ -47,8 +50,12 @@ class TransactionSyncController extends Controller $transaction->save(); + if (! empty($labelIds)) { + $transaction->labels()->sync($labelIds); + } + return response()->json([ - 'data' => $transaction, + 'data' => $transaction->load('labels:id,name,color'), ], 201); } @@ -62,10 +69,17 @@ class TransactionSyncController extends Controller $data = $request->validated(); unset($data['id']); // Don't allow ID changes + $labelIds = $data['label_ids'] ?? null; + unset($data['label_ids']); + $transaction->update($data); + if ($labelIds !== null) { + $transaction->labels()->sync($labelIds); + } + return response()->json([ - 'data' => $transaction->fresh(), + 'data' => $transaction->fresh()->load('labels:id,name,color'), ]); } diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 27ab2bdb..c52062ba 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -8,6 +8,7 @@ use App\Http\Requests\UpdateTransactionRequest; use App\Models\Account; use App\Models\Bank; use App\Models\Category; +use App\Models\Label; use App\Models\Transaction; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\JsonResponse; @@ -42,10 +43,16 @@ class TransactionController extends Controller ->orderBy('name') ->get(['id', 'name', 'logo']); + $labels = Label::query() + ->where('user_id', $user->id) + ->orderBy('name') + ->get(['id', 'name', 'color']); + return Inertia::render('transactions/index', [ 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, + 'labels' => $labels, ]); } @@ -72,10 +79,16 @@ class TransactionController extends Controller ->orderBy('name') ->get(['id', 'name', 'logo']); + $labels = Label::query() + ->where('user_id', $user->id) + ->orderBy('name') + ->get(['id', 'name', 'color']); + return Inertia::render('transactions/categorize', [ 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, + 'labels' => $labels, ]); } @@ -149,16 +162,27 @@ class TransactionController extends Controller $updateData['notes_iv'] = $request->input('notes_iv'); } - if (empty($updateData)) { + $labelIds = $request->input('label_ids'); + $hasLabelUpdate = $request->has('label_ids'); + + if (empty($updateData) && ! $hasLabelUpdate) { return response()->json([ 'message' => 'No update data provided.', ], 400); } - Transaction::query() - ->whereIn('id', $transactionIds) - ->where('user_id', $user->id) - ->update($updateData); + if (! empty($updateData)) { + Transaction::query() + ->whereIn('id', $transactionIds) + ->where('user_id', $user->id) + ->update($updateData); + } + + if ($hasLabelUpdate) { + foreach ($transactions as $transaction) { + $transaction->labels()->sync($labelIds ?? []); + } + } return response()->json([ 'message' => 'Transactions updated successfully', diff --git a/app/Http/Requests/BulkUpdateTransactionsRequest.php b/app/Http/Requests/BulkUpdateTransactionsRequest.php index 5738f6f6..c59ae390 100644 --- a/app/Http/Requests/BulkUpdateTransactionsRequest.php +++ b/app/Http/Requests/BulkUpdateTransactionsRequest.php @@ -19,6 +19,8 @@ class BulkUpdateTransactionsRequest extends FormRequest 'category_id' => ['nullable', 'exists:categories,id'], 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], + 'label_ids' => ['nullable', 'array'], + 'label_ids.*' => ['required', 'string', 'uuid', 'exists:labels,id'], ]; } diff --git a/app/Http/Requests/Settings/StoreAutomationRuleRequest.php b/app/Http/Requests/Settings/StoreAutomationRuleRequest.php index e05b5c62..ae75e43a 100644 --- a/app/Http/Requests/Settings/StoreAutomationRuleRequest.php +++ b/app/Http/Requests/Settings/StoreAutomationRuleRequest.php @@ -41,6 +41,15 @@ class StoreAutomationRuleRequest extends FormRequest ], 'action_note' => ['nullable', 'string'], 'action_note_iv' => ['nullable', 'string', 'required_with:action_note'], + 'action_label_ids' => ['nullable', 'array'], + 'action_label_ids.*' => [ + 'required', + 'string', + 'uuid', + Rule::exists('labels', 'id')->where(function ($query) { + $query->where('user_id', auth()->id()); + }), + ], ]; } @@ -50,8 +59,9 @@ class StoreAutomationRuleRequest extends FormRequest 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.'); + $hasLabels = ! empty($this->action_label_ids); + if (! $this->action_category_id && ! $this->action_note && ! $hasLabels) { + $validator->errors()->add('action_category_id', 'At least one action (category, note, or labels) must be provided.'); } }); } diff --git a/app/Http/Requests/Settings/StoreLabelRequest.php b/app/Http/Requests/Settings/StoreLabelRequest.php new file mode 100644 index 00000000..659a5558 --- /dev/null +++ b/app/Http/Requests/Settings/StoreLabelRequest.php @@ -0,0 +1,43 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + Rule::unique('labels', 'name') + ->where('user_id', auth()->id()) + ->whereNull('deleted_at'), + ], + 'color' => [ + 'required', + 'string', + Rule::enum(LabelColor::class), + ], + ]; + } +} diff --git a/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php b/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php index ed27d67e..2d605817 100644 --- a/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php +++ b/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php @@ -41,6 +41,15 @@ class UpdateAutomationRuleRequest extends FormRequest ], 'action_note' => ['nullable', 'string'], 'action_note_iv' => ['nullable', 'string', 'required_with:action_note'], + 'action_label_ids' => ['nullable', 'array'], + 'action_label_ids.*' => [ + 'required', + 'string', + 'uuid', + Rule::exists('labels', 'id')->where(function ($query) { + $query->where('user_id', auth()->id()); + }), + ], ]; } @@ -50,8 +59,9 @@ class UpdateAutomationRuleRequest extends FormRequest 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.'); + $hasLabels = ! empty($this->action_label_ids); + if (! $this->action_category_id && ! $this->action_note && ! $hasLabels) { + $validator->errors()->add('action_category_id', 'At least one action (category, note, or labels) must be provided.'); } }); } diff --git a/app/Http/Requests/Settings/UpdateLabelRequest.php b/app/Http/Requests/Settings/UpdateLabelRequest.php new file mode 100644 index 00000000..f0b5e48e --- /dev/null +++ b/app/Http/Requests/Settings/UpdateLabelRequest.php @@ -0,0 +1,46 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => [ + 'sometimes', + 'required', + 'string', + 'max:255', + Rule::unique('labels', 'name') + ->where('user_id', auth()->id()) + ->whereNull('deleted_at') + ->ignore($this->route('label')), + ], + 'color' => [ + 'sometimes', + 'required', + 'string', + Rule::enum(LabelColor::class), + ], + ]; + } +} diff --git a/app/Http/Requests/StoreTransactionRequest.php b/app/Http/Requests/StoreTransactionRequest.php index 5538b1b2..764aae12 100644 --- a/app/Http/Requests/StoreTransactionRequest.php +++ b/app/Http/Requests/StoreTransactionRequest.php @@ -27,6 +27,8 @@ class StoreTransactionRequest extends FormRequest 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], 'source' => ['required', Rule::enum(TransactionSource::class)], + 'label_ids' => ['nullable', 'array'], + 'label_ids.*' => ['string', 'uuid', 'exists:labels,id'], ]; } diff --git a/app/Models/AutomationRule.php b/app/Models/AutomationRule.php index 5720e6bc..faefea94 100644 --- a/app/Models/AutomationRule.php +++ b/app/Models/AutomationRule.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; class AutomationRule extends Model @@ -40,4 +41,10 @@ class AutomationRule extends Model { return $this->belongsTo(Category::class, 'action_category_id'); } + + public function labels(): BelongsToMany + { + return $this->belongsToMany(Label::class, 'automation_rule_labels') + ->using(AutomationRuleLabel::class); + } } diff --git a/app/Models/AutomationRuleLabel.php b/app/Models/AutomationRuleLabel.php new file mode 100644 index 00000000..fdafacea --- /dev/null +++ b/app/Models/AutomationRuleLabel.php @@ -0,0 +1,17 @@ + */ + use HasFactory, HasUuids, SoftDeletes; + + protected $fillable = [ + 'name', + 'color', + 'user_id', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function transactions(): BelongsToMany + { + return $this->belongsToMany(Transaction::class) + ->using(LabelTransaction::class) + ->withTimestamps(); + } + + public function automationRules(): BelongsToMany + { + return $this->belongsToMany(AutomationRule::class, 'automation_rule_labels') + ->using(AutomationRuleLabel::class); + } +} diff --git a/app/Models/LabelTransaction.php b/app/Models/LabelTransaction.php new file mode 100644 index 00000000..c563b8d2 --- /dev/null +++ b/app/Models/LabelTransaction.php @@ -0,0 +1,17 @@ +belongsTo(Category::class); } + + public function labels(): BelongsToMany + { + return $this->belongsToMany(Label::class) + ->using(LabelTransaction::class) + ->withTimestamps(); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 131f382d..75ae5fc3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -87,6 +87,11 @@ class User extends Authenticatable return $this->hasMany(AutomationRule::class); } + public function labels(): HasMany + { + return $this->hasMany(Label::class); + } + public function hasProPlan(): bool { if (! config('subscriptions.enabled')) { diff --git a/app/Policies/LabelPolicy.php b/app/Policies/LabelPolicy.php new file mode 100644 index 00000000..98403610 --- /dev/null +++ b/app/Policies/LabelPolicy.php @@ -0,0 +1,65 @@ +id === $label->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Label $label): bool + { + return $user->id === $label->user_id; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Label $label): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Label $label): bool + { + return false; + } +} diff --git a/database/factories/LabelFactory.php b/database/factories/LabelFactory.php new file mode 100644 index 00000000..08656762 --- /dev/null +++ b/database/factories/LabelFactory.php @@ -0,0 +1,26 @@ + + */ +class LabelFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->word(), + 'color' => fake()->randomElement(['amber', 'blue', 'cyan', 'emerald', 'gray', 'green', 'indigo', 'orange', 'pink', 'purple', 'red', 'slate', 'teal', 'yellow']), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2025_12_12_092647_create_labels_table.php b/database/migrations/2025_12_12_092647_create_labels_table.php new file mode 100644 index 00000000..cccb5f0a --- /dev/null +++ b/database/migrations/2025_12_12_092647_create_labels_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('color', 50); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['user_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('labels'); + } +}; diff --git a/database/migrations/2025_12_12_092650_create_label_transaction_table.php b/database/migrations/2025_12_12_092650_create_label_transaction_table.php new file mode 100644 index 00000000..0ffaaff8 --- /dev/null +++ b/database/migrations/2025_12_12_092650_create_label_transaction_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->foreignUuid('label_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('transaction_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['label_id', 'transaction_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('label_transaction'); + } +}; diff --git a/database/migrations/2025_12_12_092651_create_automation_rule_labels_table.php b/database/migrations/2025_12_12_092651_create_automation_rule_labels_table.php new file mode 100644 index 00000000..bfb2e4be --- /dev/null +++ b/database/migrations/2025_12_12_092651_create_automation_rule_labels_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignUuid('automation_rule_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('label_id')->constrained()->cascadeOnDelete(); + + $table->unique(['automation_rule_id', 'label_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('automation_rule_labels'); + } +}; diff --git a/database/migrations/2025_12_12_141955_update_labels_unique_constraint_include_deleted_at.php b/database/migrations/2025_12_12_141955_update_labels_unique_constraint_include_deleted_at.php new file mode 100644 index 00000000..dded1998 --- /dev/null +++ b/database/migrations/2025_12_12_141955_update_labels_unique_constraint_include_deleted_at.php @@ -0,0 +1,34 @@ +dropForeign(['user_id']); + $table->dropUnique(['user_id', 'name']); + $table->unique(['user_id', 'name', 'deleted_at']); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('labels', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropUnique(['user_id', 'name', 'deleted_at']); + $table->unique(['user_id', 'name']); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } +}; diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx index fedf3ddd..fa3fd4d3 100644 --- a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -1,6 +1,7 @@ import { store } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { RuleBuilder } from '@/components/automation-rules/rule-builder'; import { CategoryCombobox } from '@/components/shared/category-combobox'; +import { LabelCombobox } from '@/components/shared/label-combobox'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -11,7 +12,7 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; +import { Label as FormLabel } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; @@ -23,7 +24,9 @@ import { } from '@/lib/rule-builder-utils'; import { automationRuleSyncService } from '@/services/automation-rule-sync'; import { categorySyncService } from '@/services/category-sync'; +import { labelSyncService } from '@/services/label-sync'; import type { Category } from '@/types/category'; +import type { Label } from '@/types/label'; import { router } from '@inertiajs/react'; import { useEffect, useState } from 'react'; @@ -38,6 +41,7 @@ export function CreateAutomationRuleDialog({ }: CreateAutomationRuleDialogProps) { const [open, setOpen] = useState(false); const [categories, setCategories] = useState([]); + const [labels, setLabels] = useState([]); const [title, setTitle] = useState(''); const [priority, setPriority] = useState('10'); const [ruleStructure, setRuleStructure] = useState({ @@ -45,16 +49,21 @@ export function CreateAutomationRuleDialog({ groupOperator: 'or', }); const [categoryId, setCategoryId] = useState(''); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); const [note, setNote] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [errors, setErrors] = useState>({}); useEffect(() => { - const loadCategories = async () => { - const data = await categorySyncService.getAll(); - setCategories(data); + const loadData = async () => { + const [categoriesData, labelsData] = await Promise.all([ + categorySyncService.getAll(), + labelSyncService.getAll(), + ]); + setCategories(categoriesData); + setLabels(labelsData); }; - loadCategories(); + loadData(); }, []); const handleSubmit = async (e: React.FormEvent) => { @@ -74,7 +83,7 @@ export function CreateAutomationRuleDialog({ return; } - if (!categoryId && !note.trim()) { + if (!categoryId && !note.trim() && selectedLabelIds.length === 0) { setErrors((prev) => ({ ...prev, action_category_id: 'At least one action is required', @@ -110,6 +119,8 @@ export function CreateAutomationRuleDialog({ action_category_id: categoryId || null, action_note: encryptedNote, action_note_iv: noteIv, + action_label_ids: + selectedLabelIds.length > 0 ? selectedLabelIds : null, }, { preserveState: true, @@ -123,6 +134,7 @@ export function CreateAutomationRuleDialog({ groupOperator: 'and', }); setCategoryId(''); + setSelectedLabelIds([]); setNote(''); setErrors({}); await automationRuleSyncService.sync(); @@ -157,7 +169,7 @@ export function CreateAutomationRuleDialog({
- + Title
- + Priority
- + + Set Category +
- + Add Labels + +
+ +
+ Add Note