diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md index a85d9a74..8ef47523 100644 --- a/docs/mcp-tools-reference.md +++ b/docs/mcp-tools-reference.md @@ -308,14 +308,14 @@ Create a rule that fires during sync. Actions compose (`set_category` + `add_tag |-----------|------|-------------| | `name` | string | Human-readable rule name | | `conditions` | object | Condition tree. Omit or `{}` for match-all. Supports `and` / `or` / `not` nesting up to depth 10. Numeric fields (incl. `amount`) add `approx` (`value` + sibling `tolerance`) and `between` (sibling `min`/`max`). Date-part fields derived from the tz-naive `date` — `day_of_month`, `month`, `day_of_week` (`0`=Sun), `day_of_year` — are numeric; `day_of_month approx` is cyclic + clamped (31 matches Feb's last day). Encode annual cadence as `month` + `day_of_month` (leap-robust), not `day_of_year`. Leaf fields also include `metadata.` to read a key from the free-form metadata blob (ops: `eq`/`neq`/`contains`/`not_contains`/`matches`/`in`/`gt`/`gte`/`lt`/`lte`/`exists`/`not_exists`; an absent key matches only `not_exists`). Full grammar: `docs/rule-dsl.md`. | -| `actions` | array | Typed actions: `set_category`, `add_tag`, `remove_tag`, `add_comment`, `set_metadata` (`metadata_key` + `metadata_value`, any JSON), `remove_metadata` (`metadata_key`), `assign_series`. Either this or `category_slug` is required. | +| `actions` | array | Typed actions: `set_category`, `add_tag`, `remove_tag`, `add_comment`, `set_metadata` (`metadata_key` + `metadata_value`, any JSON), `remove_metadata` (`metadata_key`), `assign_series`, `flag` (no params — sets `flagged_at`, surfacing the txn for attention), `unflag` (no params — clears `flagged_at`). Either this or `category_slug` is required. | | `category_slug` | string | Shorthand for `actions=[{type:set_category,category_slug:...}]` | | `trigger` | string | `on_create` (default) / `on_change` / `always`. `on_update` accepted as legacy alias. | | `stage` | string | **Preferred.** Semantic pipeline stage: `baseline` / `standard` / `refinement` / `override`. Resolves to priority `0 / 10 / 50 / 100`. | | `priority` | int | Raw pipeline-stage integer, 0–1000. Use for fine-grained slotting within a stage. If both `stage` and `priority` are supplied, `priority` wins. Defaults to `10` (standard) if neither is provided. | | `enabled` | bool | Default true | | `expires_in` | string | Optional duration (e.g., `24h`, `30d`, `1w`) | -| `apply_retroactively` | bool | Also back-fill matching existing transactions (materializes `set_category` / `add_tag` / `remove_tag` / `set_metadata` / `remove_metadata` / `assign_series`; `add_comment` is sync-only) | +| `apply_retroactively` | bool | Also back-fill matching existing transactions (materializes `set_category` / `add_tag` / `remove_tag` / `set_metadata` / `remove_metadata` / `assign_series` / `flag` / `unflag`; `add_comment` is sync-only) | ### list_transaction_rules (Read) @@ -360,7 +360,7 @@ Delete a rule by ID. System-seeded rules can't be deleted — disable them via u ### apply_rules (Write) -Retroactive apply. Pass `rule_id` for a single rule (no chaining — that rule evaluates in isolation), or omit to run the full active-rule pipeline in priority-ASC order with the same chaining semantics as sync. Materializes `set_category` / `add_tag` / `remove_tag`; skips `add_comment`. Ignores `rule.trigger` (retroactive is a bulk op). +Retroactive apply. Pass `rule_id` for a single rule (no chaining — that rule evaluates in isolation), or omit to run the full active-rule pipeline in priority-ASC order with the same chaining semantics as sync. Materializes `set_category` / `add_tag` / `remove_tag` / `set_metadata` / `remove_metadata` / `assign_series` / `flag` / `unflag`; skips `add_comment`. Ignores `rule.trigger` (retroactive is a bulk op). | Parameter | Type | Description | |-----------|------|-------------| diff --git a/docs/rule-dsl.md b/docs/rule-dsl.md index c6943b5d..d153c69b 100644 --- a/docs/rule-dsl.md +++ b/docs/rule-dsl.md @@ -337,6 +337,26 @@ Deletes **one key** from the metadata blob. No-op if the key isn't present. Repe - **Net-diff with `set_metadata`** in a single pass: if an earlier-stage rule sets a key and a later-stage rule removes the same key, they cancel — neither hits the DB. A remove-then-set ends as a set. Mirrors `add_tag` / `remove_tag` net-diff semantics. +### `flag` + +```json +{ "type": "flag" } +``` + +Surfaces a matching transaction for human attention by setting `transactions.flagged_at = NOW()`. Takes no parameters. This is the declarative counterpart to the `flag_transaction` MCP tool (minus the tool's optional comment `reason`, which is a per-call affordance). Retrieve flagged rows with `query_transactions(flagged=true)`. + +- **Last-writer-wins** across the pipeline: a higher-priority rule's `flag` / `unflag` overrides a lower one (a transaction is flagged or it isn't). Within one rule a `flag` + `unflag` pair resolves to whichever is last. +- Materializes inside the sync transaction (and on retroactive apply). **No dedicated timeline annotation** is emitted (same as `assign_series` / `set_metadata`); the rule's `hit_count` and the `rule_applied` audit record the firing. +- Re-flagging an already-flagged row refreshes `flagged_at` (matches the `flag_transaction` tool). + +### `unflag` + +```json +{ "type": "unflag" } +``` + +Clears the flag (`flagged_at = NULL`) on a matching transaction. Takes no parameters. No-op-safe on an already-unflagged row. Use it to auto-retire flags once a follow-up rule's condition no longer holds, or to clear flags the agent raised after review. + ### Combining actions A rule can carry multiple actions of different types. Override (`category_override <> 'none'`) suppresses only the `set_category` part — `add_tag` and `add_comment` still fire. @@ -353,7 +373,7 @@ A rule can carry multiple actions of different types. Override (`category_overri #### Which combinations make sense -Only `set_category` is singleton per rule — repeating it is rejected at write time. `add_tag`, `remove_tag`, `add_comment`, `set_metadata`, and `remove_metadata` can appear multiple times in one rule (e.g. add two tags at once, or write two metadata keys). The admin UI disables a second `set_category` dropdown option once one is picked; tag and comment rows are freely repeatable. +Only `set_category` is singleton per rule — repeating it is rejected at write time. `add_tag`, `remove_tag`, `add_comment`, `set_metadata`, and `remove_metadata` can appear multiple times in one rule (e.g. add two tags at once, or write two metadata keys). `flag` / `unflag` carry no value, so a second copy is meaningless — the admin UI treats them as singleton (disables the dropdown option once picked) and warns if you add both. The admin UI also disables a second `set_category` dropdown option once one is picked; tag and comment rows are freely repeatable. Useful combinations: @@ -436,6 +456,9 @@ The rule engine has two entry points. They share condition evaluation and priori | `remove_tag` | Applied | Applied | | `set_metadata` | Applied | Applied | | `remove_metadata` | Applied | Applied | +| `assign_series` | Applied | Applied | +| `flag` | Applied (sets `flagged_at`) | Applied (sets `flagged_at`) | +| `unflag` | Applied (clears `flagged_at`) | Applied (clears `flagged_at`) | | `add_comment` | Applied | **Not applied** (by design) | | `hit_count` | +1 per condition match | +1 per condition match | | `rule_applied` annotation | Written | Written (with `applied_by = "retroactive"`) | diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 7d9f933a..79b04e1e 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -316,20 +316,20 @@ func (s *MCPServer) handleTransactionSummary(_ context.Context, _ *mcpsdk.CallTo type createTransactionRuleInput struct { Name string `json:"name" jsonschema:"required,Name for this rule (human-readable description)"` Conditions map[string]any `json:"conditions,omitempty" jsonschema:"JSON condition tree. Omit or pass {} to match every transaction. Leaf: {\"field\":\"...\",\"op\":\"...\",\"value\":...}. Combinators: {\"and\":[...]}, {\"or\":[...]}, {\"not\":{...}} (nest freely, max depth 10). Fields: provider_name provider_merchant_name amount provider_category_primary provider_category_detailed category(assigned slug, live-updated by earlier-stage rules) pending provider account_id account_name user_id user_name tags series in_series day_of_month month day_of_week(0=Sun..6=Sat) day_of_year (the four date-parts are numeric, derived from the tz-naive date), plus metadata. to read a key from the free-form metadata blob (e.g. metadata.tax_deductible). Ops: string/category=eq|neq|contains|not_contains|matches(RE2)|in; numeric=eq|neq|gt|gte|lt|lte|approx|between; bool=eq|neq; tags=contains|not_contains|in; metadata.=eq|neq|contains|not_contains|matches|in|gt|gte|lt|lte|exists|not_exists (an absent key matches only not_exists; eq/neq comparison type follows the value's type). approx needs a sibling tolerance: {\"field\":\"amount\",\"op\":\"approx\",\"value\":15.49,\"tolerance\":0.5}; between needs sibling min+max: {\"field\":\"day_of_month\",\"op\":\"between\",\"min\":1,\"max\":5}. day_of_month approx is cyclic (1 and the month's last day are 1 apart) and clamps a target past a short month to its last day; encode annual cadence as month + day_of_month (leap-robust): {\"and\":[{\"field\":\"month\",\"op\":\"eq\",\"value\":4},{\"field\":\"day_of_month\",\"op\":\"approx\",\"value\":15,\"tolerance\":2}]}. Nested example: {\"or\":[{\"and\":[{\"field\":\"provider_merchant_name\",\"op\":\"contains\",\"value\":\"starbucks\"},{\"field\":\"amount\",\"op\":\"gte\",\"value\":5}]},{\"field\":\"metadata.reimbursable\",\"op\":\"eq\",\"value\":true}]}. Full spec: docs/rule-dsl.md."` - Actions []map[string]any `json:"actions,omitempty" jsonschema:"Array of typed actions. {\"type\":\"set_category\",\"category_slug\":\"...\"} | {\"type\":\"add_tag\",\"tag_slug\":\"...\"} | {\"type\":\"remove_tag\",\"tag_slug\":\"...\"} | {\"type\":\"add_comment\",\"content\":\"...\"} | {\"type\":\"set_metadata\",\"metadata_key\":\"...\",\"metadata_value\":} | {\"type\":\"remove_metadata\",\"metadata_key\":\"...\"} | {\"type\":\"assign_series\",\"series_short_id\":\"...\"} or {\"type\":\"assign_series\",\"merchant_key\":\"...\",\"create_if_missing\":true}. Actions compose: a rule can set a category AND add a tag AND write metadata in the same match. set_metadata upserts one key (value may be any JSON type); remove_metadata deletes one key; both repeat freely. add_comment fires only at sync time (not on retroactive apply). remove_tag/remove_metadata net-diff against add_tag/set_metadata within the same sync pass. If omitted, use category_slug instead."` + Actions []map[string]any `json:"actions,omitempty" jsonschema:"Array of typed actions. {\"type\":\"set_category\",\"category_slug\":\"...\"} | {\"type\":\"add_tag\",\"tag_slug\":\"...\"} | {\"type\":\"remove_tag\",\"tag_slug\":\"...\"} | {\"type\":\"add_comment\",\"content\":\"...\"} | {\"type\":\"set_metadata\",\"metadata_key\":\"...\",\"metadata_value\":} | {\"type\":\"remove_metadata\",\"metadata_key\":\"...\"} | {\"type\":\"assign_series\",\"series_short_id\":\"...\"} or {\"type\":\"assign_series\",\"merchant_key\":\"...\",\"create_if_missing\":true} | {\"type\":\"flag\"} | {\"type\":\"unflag\"}. Actions compose: a rule can set a category AND add a tag AND write metadata in the same match. set_metadata upserts one key (value may be any JSON type); remove_metadata deletes one key; both repeat freely. flag sets the transaction's flagged_at (surface it for attention, like the flag_transaction tool); unflag clears it; both take no params. add_comment fires only at sync time (not on retroactive apply). remove_tag/remove_metadata net-diff against add_tag/set_metadata within the same sync pass. If omitted, use category_slug instead."` CategorySlug string `json:"category_slug,omitempty" jsonschema:"Shorthand for actions: [{\"type\":\"set_category\",\"category_slug\":\"\"}]. Either actions or category_slug is required."` Trigger string `json:"trigger,omitempty" jsonschema:"When the rule fires during sync: 'on_create' (default — first-synced transactions) | 'on_change' (existing transactions that changed on re-sync) | 'always' (both). 'on_update' is accepted as a legacy alias for 'on_change'. Retroactive apply ignores trigger."` Stage string `json:"stage,omitempty" jsonschema:"Semantic pipeline stage — preferred over raw priority for agent-authored rules. One of: 'baseline' (runs first, broad defaults), 'standard' (default), 'refinement' (reacts to earlier stages), 'override' (runs last, wins set_category). Resolves to priority 0/10/50/100. If both stage and priority are supplied, priority wins. Leave both unset for 'standard'."` Priority int `json:"priority,omitempty" jsonschema:"Raw pipeline-stage integer, 0..1000. Lower runs first. Prefer 'stage' for shared vocabulary. Canonical values: 0=baseline, 10=standard (default), 50=refinement, 100=override. Higher-priority rules observe earlier-stage rules' tag/category mutations via conditions, and win set_category under last-writer semantics."` ExpiresIn string `json:"expires_in,omitempty" jsonschema:"Optional expiry duration: 24h, 30d, 1w. Rule auto-disables after this period."` - ApplyRetroactively bool `json:"apply_retroactively,omitempty" jsonschema:"If true, immediately apply this rule to existing transactions after creation. Materializes set_category / add_tag / remove_tag / set_metadata / remove_metadata / assign_series; skips add_comment (sync-only). Hit count reflects every condition match, matching sync-time semantics."` + ApplyRetroactively bool `json:"apply_retroactively,omitempty" jsonschema:"If true, immediately apply this rule to existing transactions after creation. Materializes set_category / add_tag / remove_tag / set_metadata / remove_metadata / assign_series / flag / unflag; skips add_comment (sync-only). Hit count reflects every condition match, matching sync-time semantics."` } type updateTransactionRuleInput struct { ID string `json:"id" jsonschema:"required,UUID of the rule to update"` Name *string `json:"name,omitempty" jsonschema:"New name for the rule. Omit to leave unchanged."` Conditions map[string]any `json:"conditions,omitempty" jsonschema:"New condition tree (same format as create). Pass {} to explicitly change to match-all. Omit entirely to leave conditions unchanged."` - Actions *[]map[string]any `json:"actions,omitempty" jsonschema:"Replace the entire actions array with typed actions: {\"type\":\"set_category|add_tag|remove_tag|add_comment|set_metadata|remove_metadata|assign_series\", ...}. set_metadata takes metadata_key + metadata_value (any JSON); remove_metadata takes metadata_key. Pass an empty array to reject (rules must have at least one action). Omit to leave actions unchanged."` + Actions *[]map[string]any `json:"actions,omitempty" jsonschema:"Replace the entire actions array with typed actions: {\"type\":\"set_category|add_tag|remove_tag|add_comment|set_metadata|remove_metadata|assign_series|flag|unflag\", ...}. set_metadata takes metadata_key + metadata_value (any JSON); remove_metadata takes metadata_key; flag/unflag take no params (flag sets flagged_at, unflag clears it). Pass an empty array to reject (rules must have at least one action). Omit to leave actions unchanged."` CategorySlug *string `json:"category_slug,omitempty" jsonschema:"Shorthand: replace only the set_category action. Other action types on the rule are preserved. Omit to leave unchanged."` Trigger *string `json:"trigger,omitempty" jsonschema:"New trigger: on_create, on_change, or always. 'on_update' accepted as alias for on_change. Omit to leave unchanged."` Stage *string `json:"stage,omitempty" jsonschema:"New semantic pipeline stage: baseline | standard | refinement | override (resolves to priority 0/10/50/100). Preferred alias for agent-authored updates. If both stage and priority are supplied, priority wins. Omit to leave unchanged."` @@ -348,7 +348,7 @@ type batchCreateRulesInput struct { type batchRuleItem struct { Name string `json:"name" jsonschema:"required,Human-readable rule name"` - Actions []map[string]any `json:"actions,omitempty" jsonschema:"Actions array (typed — same format as create_transaction_rule, incl. set_metadata / remove_metadata)"` + Actions []map[string]any `json:"actions,omitempty" jsonschema:"Actions array (typed — same format as create_transaction_rule, incl. set_metadata / remove_metadata / flag / unflag)"` CategorySlug string `json:"category_slug,omitempty" jsonschema:"Shorthand for set_category action. Either actions or category_slug required."` Conditions map[string]any `json:"conditions,omitempty" jsonschema:"Condition tree as JSON object. Omit or {} for match-all."` Trigger string `json:"trigger,omitempty" jsonschema:"on_create (default), on_change, or always. 'on_update' accepted as alias for on_change."` @@ -358,7 +358,7 @@ type batchRuleItem struct { } type applyRulesInput struct { - RuleID string `json:"rule_id,omitempty" jsonschema:"Optional ID (UUID or short_id) of a specific rule to apply. When supplied, only that rule runs — no chaining. Omit to apply all active rules in pipeline-stage order (priority ASC); earlier rules' tag/category mutations feed later rules' conditions, exactly like sync-time. Materializes set_category / add_tag / remove_tag; add_comment stays sync-only. Ignores rule.trigger (retroactive is a bulk op)."` + RuleID string `json:"rule_id,omitempty" jsonschema:"Optional ID (UUID or short_id) of a specific rule to apply. When supplied, only that rule runs — no chaining. Omit to apply all active rules in pipeline-stage order (priority ASC); earlier rules' tag/category mutations feed later rules' conditions, exactly like sync-time. Materializes set_category / add_tag / remove_tag / set_metadata / remove_metadata / assign_series / flag / unflag; add_comment stays sync-only. Ignores rule.trigger (retroactive is a bulk op)."` } type previewRuleInput struct { diff --git a/internal/service/rules.go b/internal/service/rules.go index b246e614..e34cfa68 100644 --- a/internal/service/rules.go +++ b/internal/service/rules.go @@ -854,6 +854,8 @@ var validActionTypes = map[string]bool{ "assign_series": true, "set_metadata": true, "remove_metadata": true, + "flag": true, + "unflag": true, } // tagSlugPattern enforces the tag slug format: lowercase alphanumerics with @@ -872,7 +874,7 @@ func (s *Service) ValidateActions(ctx context.Context, actions []RuleAction) err seenCategory := false for _, a := range actions { if !validActionTypes[a.Type] { - return fmt.Errorf("%w: unknown action type %q (expected set_category|add_tag|remove_tag|add_comment|assign_series|set_metadata|remove_metadata)", ErrInvalidParameter, a.Type) + return fmt.Errorf("%w: unknown action type %q (expected set_category|add_tag|remove_tag|add_comment|assign_series|set_metadata|remove_metadata|flag|unflag)", ErrInvalidParameter, a.Type) } switch a.Type { case "set_category": @@ -926,6 +928,11 @@ func (s *Service) ValidateActions(ctx context.Context, actions []RuleAction) err if err := validateMetadataKey(a.MetadataKey); err != nil { return fmt.Errorf("%w (remove_metadata)", err) } + case "flag", "unflag": + // No parameters: flag sets transactions.flagged_at = NOW(), + // unflag clears it. Mirrors the flag_transaction MCP tool (sans the + // optional comment reason, which is a tool-only affordance). Last- + // writer-wins resolves a flag+unflag pair within one rule. } } return nil @@ -1695,6 +1702,11 @@ type retroTxnIntent struct { metadataSet map[string]any metadataRemove []string + // flag / unflag — last-writer-wins tri-state: "flag" sets flagged_at = NOW(), + // "unflag" clears it to NULL, "" leaves it untouched. Mirrors the + // flag_transaction MCP tool's flagged_at write (sans the optional comment). + flagIntent string + // ruleApplied drives the generic rule_applied audit annotations. Each path // populates this with its own granularity (per-action for the single-rule // path, per-matching-rule for the bulk path); the materializer just emits. @@ -1711,7 +1723,8 @@ type retroRuleApplied struct { // applyRetroTxnIntent materializes one transaction's net retroactive intent // within tx. It is THE shared writer for every state-mutating rule action // (set_category, add_tag, remove_tag, assign_series, set_metadata, -// remove_metadata), so coverage cannot drift between the retroactive paths. +// remove_metadata, flag, unflag), so coverage cannot drift between the +// retroactive paths. // // All writes use ruleapply.AppliedByRetroactive — this function is the // retroactive lane by construction. @@ -1755,6 +1768,24 @@ func (s *Service) applyRetroTxnIntent(ctx context.Context, tx pgx.Tx, it *retroT } } + // flag / unflag — set or clear flagged_at, mirroring the flag_transaction + // tool's write (transaction_flag.go). Last-writer-wins is already resolved + // upstream into a single flagIntent. + switch it.flagIntent { + case "flag": + if _, err := tx.Exec(ctx, + `UPDATE transactions SET flagged_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, + it.txnID); err != nil { + return fmt.Errorf("flag transaction: %w", err) + } + case "unflag": + if _, err := tx.Exec(ctx, + `UPDATE transactions SET flagged_at = NULL WHERE id = $1 AND deleted_at IS NULL`, + it.txnID); err != nil { + return fmt.Errorf("unflag transaction: %w", err) + } + } + for _, ra := range it.ruleApplied { if err := ruleapply.WriteRuleApplied(ctx, tx, it.txnID, ra.rule, ra.field, ra.value, ruleapply.AppliedByRetroactive); err != nil { return fmt.Errorf("annotate rule_applied: %w", err) @@ -1767,8 +1798,9 @@ func (s *Service) applyRetroTxnIntent(ctx context.Context, tx pgx.Tx, it *retroT // transactions matching its condition. Materialization flows through the shared // applyRetroTxnIntent, so it covers every state-mutating action — set_category // (skipped on category_override<>'none' rows), add_tag, remove_tag, -// assign_series, set_metadata, remove_metadata. add_comment stays sync-only by -// design — retroactive apply is bulk historical data, not narration. +// assign_series, set_metadata, remove_metadata, flag, unflag. add_comment stays +// sync-only by design — retroactive apply is bulk historical data, not +// narration. // // Returns the number of transactions that matched the condition (hit count — // not the number of rows physically modified). This matches sync-time @@ -1805,6 +1837,7 @@ func (s *Service) ApplyRuleRetroactively(ctx context.Context, ruleID string) (in var seriesAssign *RuleAction metadataSet := map[string]any{} var metadataRemove []string + var flagIntent string for _, a := range rule.Actions { switch a.Type { case "set_category": @@ -1843,10 +1876,14 @@ func (s *Service) ApplyRuleRetroactively(ctx context.Context, ruleID string) (in if indexExactStr(metadataRemove, a.MetadataKey) < 0 { metadataRemove = append(metadataRemove, a.MetadataKey) } + case "flag": + flagIntent = "flag" + case "unflag": + flagIntent = "unflag" } } hasWriteAction := categorySetCatID.Valid || len(tagAdds) > 0 || len(tagRemoves) > 0 || seriesAssign != nil || - len(metadataSet) > 0 || len(metadataRemove) > 0 + len(metadataSet) > 0 || len(metadataRemove) > 0 || flagIntent != "" if !hasWriteAction { return 0, fmt.Errorf("%w: rule has no applicable actions", ErrInvalidParameter) } @@ -1941,6 +1978,7 @@ func (s *Service) ApplyRuleRetroactively(ctx context.Context, ruleID string) (in seriesRule: appliedRule, metadataSet: metadataSet, metadataRemove: metadataRemove, + flagIntent: flagIntent, ruleApplied: ruleApplied, } for _, slug := range tagAdds { @@ -2005,6 +2043,10 @@ func actionAuditFields(a RuleAction) (string, string) { return "metadata", a.MetadataKey case "remove_metadata": return "metadata_remove", a.MetadataKey + case "flag": + return "flag", "" + case "unflag": + return "unflag", "" } return "", "" } @@ -2053,7 +2095,7 @@ func indexExactStr(slice []string, target string) int { // folds the rule set to a net intent and materializes it through the shared // applyRetroTxnIntent, covering every state-mutating action — set_category // (last-writer-wins), add_tag, remove_tag, assign_series, set_metadata, -// remove_metadata. add_comment stays sync-only. +// remove_metadata, flag, unflag. add_comment stays sync-only. // // Returns a map of rule_id → match_count. Hit counts include every condition // match, not just actions that caused a DB write (sync-time parity). @@ -2133,6 +2175,7 @@ func (s *Service) ApplyAllRulesRetroactively(ctx context.Context) (map[string]in seriesRule *compiledRule // rule that owns the winning series assignment metadataSet map[string]any // key → net value to write metadataRemove []string // net keys to delete + flagIntent string // "flag" | "unflag" | "" (last-writer-wins) matchingRules []*compiledRule // every rule whose condition matched (for rule_applied) } var intents []txnIntent @@ -2235,6 +2278,12 @@ func (s *Service) ApplyAllRulesRetroactively(ctx context.Context) (map[string]in aCopy := a intent.series = &aCopy intent.seriesRule = cr + case "flag": + // Last-writer-wins: a later-stage rule's flag/unflag + // overrides an earlier one. + intent.flagIntent = "flag" + case "unflag": + intent.flagIntent = "unflag" case "add_comment": // Narration authored at sync time; intentionally never // replayed on a historical backfill (see retroTxnIntent). @@ -2242,7 +2291,8 @@ func (s *Service) ApplyAllRulesRetroactively(ctx context.Context) (map[string]in } } if intent.catRule != nil || len(intent.tagAdds) > 0 || len(intent.tagRemoves) > 0 || - intent.series != nil || len(intent.metadataSet) > 0 || len(intent.metadataRemove) > 0 { + intent.series != nil || len(intent.metadataSet) > 0 || len(intent.metadataRemove) > 0 || + intent.flagIntent != "" { intents = append(intents, intent) } } @@ -2279,6 +2329,7 @@ func (s *Service) ApplyAllRulesRetroactively(ctx context.Context) (map[string]in tagRemoves: make(map[string]ruleapply.Rule, len(it.tagRemoves)), metadataSet: it.metadataSet, metadataRemove: it.metadataRemove, + flagIntent: it.flagIntent, } if it.catRule != nil { ri.catID = it.catID @@ -3036,6 +3087,10 @@ func ActionsSummary(actions []RuleAction, categoryName string) string { return "Add comment" case "assign_series": return "Assign to series" + case "flag": + return "Flag for attention" + case "unflag": + return "Clear flag" default: return a.Type } diff --git a/internal/service/rules_flag_retro_integration_test.go b/internal/service/rules_flag_retro_integration_test.go new file mode 100644 index 00000000..376f5d77 --- /dev/null +++ b/internal/service/rules_flag_retro_integration_test.go @@ -0,0 +1,117 @@ +//go:build integration && !lite + +// Retroactive-apply coverage for the flag / unflag rule actions (rules-as- +// universal-substrate, P1). Both retroactive entry points must materialize a +// flag: the single-rule path (ApplyRuleRetroactively) and the bulk path +// (ApplyAllRulesRetroactively, which flows through the shared +// applyRetroTxnIntent). unflag must clear flagged_at. +package service_test + +import ( + "context" + "testing" + + "breadbox/internal/service" + "breadbox/internal/testutil" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +// queryFlagged returns whether the transaction's flagged_at is non-NULL. +func queryFlagged(t *testing.T, pool *pgxpool.Pool, ctx context.Context, txnID pgtype.UUID) bool { + t.Helper() + var at pgtype.Timestamptz + if err := pool.QueryRow(ctx, `SELECT flagged_at FROM transactions WHERE id = $1`, txnID).Scan(&at); err != nil { + t.Fatalf("query flagged_at: %v", err) + } + return at.Valid +} + +// TestApplyRuleRetroactively_Flag — single-rule retroactive apply sets flagged_at. +func TestApplyRuleRetroactively_Flag(t *testing.T) { + svc, queries, pool := newService(t) + ctx := context.Background() + acctID := seedTxnFixture(t, queries) + + target := testutil.MustCreateTransaction(t, queries, acctID, "txn_retroflag", "RetroFlag Charge", 5000, "2025-02-01") + + rule, err := svc.CreateTransactionRule(ctx, service.CreateTransactionRuleParams{ + Name: "Flag retro", + Conditions: service.Condition{Field: "provider_name", Op: "contains", Value: "RetroFlag"}, + Actions: []service.RuleAction{{Type: "flag"}}, + Priority: 10, + Actor: service.Actor{Type: "system", Name: "test"}, + }) + if err != nil { + t.Fatalf("CreateTransactionRule: %v", err) + } + + if _, err := svc.ApplyRuleRetroactively(ctx, rule.ID); err != nil { + t.Fatalf("ApplyRuleRetroactively: %v", err) + } + if !queryFlagged(t, pool, ctx, target.ID) { + t.Errorf("single-rule retro flag: expected flagged_at set, got NULL") + } +} + +// TestApplyAllRulesRetroactively_Flag — bulk retroactive apply sets flagged_at +// (the shared applier path). +func TestApplyAllRulesRetroactively_Flag(t *testing.T) { + svc, queries, pool := newService(t) + ctx := context.Background() + acctID := seedTxnFixture(t, queries) + + target := testutil.MustCreateTransaction(t, queries, acctID, "txn_bulkflag", "BulkFlag Charge", 7500, "2025-02-02") + other := testutil.MustCreateTransaction(t, queries, acctID, "txn_nomatch", "Ordinary Coffee", 400, "2025-02-03") + + if _, err := svc.CreateTransactionRule(ctx, service.CreateTransactionRuleParams{ + Name: "Flag bulk", + Conditions: service.Condition{Field: "provider_name", Op: "contains", Value: "BulkFlag"}, + Actions: []service.RuleAction{{Type: "flag"}}, + Priority: 10, + Actor: service.Actor{Type: "system", Name: "test"}, + }); err != nil { + t.Fatalf("CreateTransactionRule: %v", err) + } + + if _, err := svc.ApplyAllRulesRetroactively(ctx); err != nil { + t.Fatalf("ApplyAllRulesRetroactively: %v", err) + } + if !queryFlagged(t, pool, ctx, target.ID) { + t.Errorf("bulk retro flag: expected flagged_at set on matched row, got NULL") + } + if queryFlagged(t, pool, ctx, other.ID) { + t.Errorf("bulk retro flag: non-matching row should remain unflagged") + } +} + +// TestApplyRuleRetroactively_Unflag — retroactive apply clears a pre-set flag. +func TestApplyRuleRetroactively_Unflag(t *testing.T) { + svc, queries, pool := newService(t) + ctx := context.Background() + acctID := seedTxnFixture(t, queries) + + target := testutil.MustCreateTransaction(t, queries, acctID, "txn_retrounflag", "RetroUnflag Charge", 5000, "2025-02-04") + if _, err := pool.Exec(ctx, `UPDATE transactions SET flagged_at = NOW() WHERE id = $1`, target.ID); err != nil { + t.Fatalf("pre-flag: %v", err) + } + + rule, err := svc.CreateTransactionRule(ctx, service.CreateTransactionRuleParams{ + Name: "Unflag retro", + Conditions: service.Condition{Field: "provider_name", Op: "contains", Value: "RetroUnflag"}, + Actions: []service.RuleAction{{Type: "unflag"}}, + Priority: 10, + Actor: service.Actor{Type: "system", Name: "test"}, + }) + if err != nil { + t.Fatalf("CreateTransactionRule: %v", err) + } + + if _, err := svc.ApplyRuleRetroactively(ctx, rule.ID); err != nil { + t.Fatalf("ApplyRuleRetroactively: %v", err) + } + if queryFlagged(t, pool, ctx, target.ID) { + t.Errorf("retro unflag: expected flagged_at cleared, still set") + } +} diff --git a/internal/service/rules_flag_test.go b/internal/service/rules_flag_test.go new file mode 100644 index 00000000..922d373e --- /dev/null +++ b/internal/service/rules_flag_test.go @@ -0,0 +1,41 @@ +//go:build !lite + +package service + +import ( + "context" + "testing" +) + +// flag / unflag take no parameters and validate cleanly; they also compose with +// other actions. actionAuditFields carries their semantic intent. +func TestValidateActions_Flag(t *testing.T) { + svc := &Service{} + ctx := context.Background() + cases := []struct { + name string + actions []RuleAction + wantErr bool + }{ + {"flag", []RuleAction{{Type: "flag"}}, false}, + {"unflag", []RuleAction{{Type: "unflag"}}, false}, + {"flag + add_tag composes", []RuleAction{{Type: "flag"}, {Type: "add_tag", TagSlug: "needs-review"}}, false}, + {"unknown still rejected", []RuleAction{{Type: "frobnicate"}}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := svc.ValidateActions(ctx, tc.actions); (err != nil) != tc.wantErr { + t.Errorf("ValidateActions err=%v wantErr=%v", err, tc.wantErr) + } + }) + } +} + +func TestActionAuditFields_Flag(t *testing.T) { + if f, v := actionAuditFields(RuleAction{Type: "flag"}); f != "flag" || v != "" { + t.Errorf("flag audit fields = (%q,%q), want (flag,\"\")", f, v) + } + if f, v := actionAuditFields(RuleAction{Type: "unflag"}); f != "unflag" || v != "" { + t.Errorf("unflag audit fields = (%q,%q), want (unflag,\"\")", f, v) + } +} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 2c477e1b..f53c2647 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -859,6 +859,26 @@ func (e *Engine) applyRulesToTransaction(ctx context.Context, tx pgx.Tx, txn *pr } } + // flag / unflag: set or clear flagged_at within the sync tx, mirroring the + // flag_transaction MCP tool's write (transaction_flag.go). The net decision + // is already resolved last-writer-wins. Like assign_series / metadata, no + // dedicated timeline annotation is emitted — the rule_applied source records + // the firing. + switch result.FlagIntent { + case "flag": + if _, err := tx.Exec(ctx, + `UPDATE transactions SET flagged_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, + dbTxn.ID); err != nil { + return result.Sources, fmt.Errorf("apply rule flag: %w", err) + } + case "unflag": + if _, err := tx.Exec(ctx, + `UPDATE transactions SET flagged_at = NULL WHERE id = $1 AND deleted_at IS NULL`, + dbTxn.ID); err != nil { + return result.Sources, fmt.Errorf("apply rule unflag: %w", err) + } + } + return result.Sources, nil } diff --git a/internal/sync/engine_flag_integration_test.go b/internal/sync/engine_flag_integration_test.go new file mode 100644 index 00000000..2d3f5afd --- /dev/null +++ b/internal/sync/engine_flag_integration_test.go @@ -0,0 +1,139 @@ +//go:build integration && !lite + +// Sync-path coverage for the flag / unflag rule actions (rules-as-universal- +// substrate, P1). A rule with a flag action must set transactions.flagged_at +// during sync (mirroring the flag_transaction MCP tool); unflag must clear it. +package sync_test + +import ( + "context" + "testing" + "time" + + "breadbox/internal/db" + "breadbox/internal/provider" + "breadbox/internal/testutil" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/shopspring/decimal" +) + +// A flag rule sets flagged_at on a freshly-synced matching transaction. +func TestRule_Flag_DuringSync(t *testing.T) { + pool, queries := testutil.ServicePool(t) + ctx := context.Background() + + seedCategories(t, queries) + + testutil.MustCreateTransactionRule( + t, queries, "Flag big charges", + []byte(`{"field": "provider_name","op":"contains","value":"FlagMe"}`), + []byte(`[{"type":"flag"}]`), + "on_create", + ) + + user := testutil.MustCreateUser(t, queries, "Alice") + conn := testutil.MustCreateConnection(t, queries, user.ID, "item_flag") + testutil.MustCreateAccount(t, queries, conn.ID, "ext_acct_flag", "Checking") + + mock := &mockProvider{ + syncResult: provider.SyncResult{ + Added: []provider.Transaction{{ + ExternalID: "txn_flagme", + AccountExternalID: "ext_acct_flag", + Amount: decimal.NewFromFloat(250.00), + Date: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + Name: "FlagMe Charge", + ISOCurrencyCode: "USD", + }}, + Cursor: "cursor_1", + }, + } + engine := newEngine(t, pool, queries, map[string]provider.Provider{"plaid": mock}) + if err := engine.Sync(ctx, conn.ID, db.SyncTriggerManual); err != nil { + t.Fatalf("sync: %v", err) + } + + var flaggedAt pgtype.Timestamptz + if err := pool.QueryRow(ctx, + "SELECT flagged_at FROM transactions WHERE provider_transaction_id = 'txn_flagme'", + ).Scan(&flaggedAt); err != nil { + t.Fatalf("query flagged_at: %v", err) + } + if !flaggedAt.Valid { + t.Errorf("flag: expected flagged_at to be set during sync, got NULL") + } +} + +// An unflag rule clears flagged_at on a re-synced (changed) transaction that +// was previously flagged. +func TestRule_Unflag_DuringSync(t *testing.T) { + pool, queries := testutil.ServicePool(t) + ctx := context.Background() + + seedCategories(t, queries) + + testutil.MustCreateTransactionRule( + t, queries, "Clear flag", + []byte(`{"field": "provider_name","op":"contains","value":"UnflagMe"}`), + []byte(`[{"type":"unflag"}]`), + "always", + ) + + user := testutil.MustCreateUser(t, queries, "Bob") + conn := testutil.MustCreateConnection(t, queries, user.ID, "item_unflag") + testutil.MustCreateAccount(t, queries, conn.ID, "ext_acct_unflag", "Checking") + + mock := &mockProvider{ + syncResult: provider.SyncResult{ + Added: []provider.Transaction{{ + ExternalID: "txn_unflagme", + AccountExternalID: "ext_acct_unflag", + Amount: decimal.NewFromFloat(12.00), + Date: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + Name: "UnflagMe Charge", + ISOCurrencyCode: "USD", + Pending: true, + }}, + Cursor: "cursor_1", + }, + } + engine := newEngine(t, pool, queries, map[string]provider.Provider{"plaid": mock}) + if err := engine.Sync(ctx, conn.ID, db.SyncTriggerManual); err != nil { + t.Fatalf("first sync: %v", err) + } + + // Pre-flag the row so the next sync's unflag has something to clear. + if _, err := pool.Exec(ctx, + "UPDATE transactions SET flagged_at = NOW() WHERE provider_transaction_id = 'txn_unflagme'", + ); err != nil { + t.Fatalf("pre-flag: %v", err) + } + + // Second sync modifies the txn (pending flips) so the "always" rule re-fires. + mock.syncResult = provider.SyncResult{ + Modified: []provider.Transaction{{ + ExternalID: "txn_unflagme", + AccountExternalID: "ext_acct_unflag", + Amount: decimal.NewFromFloat(12.00), + Date: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + Name: "UnflagMe Charge", + ISOCurrencyCode: "USD", + Pending: false, + }}, + Cursor: "cursor_2", + } + if err := engine.Sync(ctx, conn.ID, db.SyncTriggerManual); err != nil { + t.Fatalf("second sync: %v", err) + } + + var flaggedAt pgtype.Timestamptz + if err := pool.QueryRow(ctx, + "SELECT flagged_at FROM transactions WHERE provider_transaction_id = 'txn_unflagme'", + ).Scan(&flaggedAt); err != nil { + t.Fatalf("query flagged_at: %v", err) + } + if flaggedAt.Valid { + t.Errorf("unflag: expected flagged_at cleared during sync, still set: %v", flaggedAt.Time) + } +} diff --git a/internal/sync/rule_resolver.go b/internal/sync/rule_resolver.go index 915f8b23..d6a5c6fd 100644 --- a/internal/sync/rule_resolver.go +++ b/internal/sync/rule_resolver.go @@ -134,6 +134,12 @@ type RuleActions struct { // earlier-stage rule then removed by a later one cancels (appears in // neither map); a key removed then re-set ends up only in MetadataSet. MetadataRemove []string + // FlagIntent is the net flag/unflag decision for the transaction: + // "flag" sets flagged_at = NOW(), "unflag" clears it, "" leaves it + // untouched. Last-writer-wins across the pipeline (a higher-priority rule's + // flag/unflag overrides a lower one), mirroring the flag_transaction MCP + // tool's flagged_at write. + FlagIntent string // Sources records per-action provenance for the audit trail. For // set_category, only the winning (last) rule's source is retained. // For tag actions, only net-surviving adds/removes have sources. @@ -390,6 +396,9 @@ func parseTypedActions(raw []byte, ruleID pgtype.UUID, logger *slog.Logger) []ty case "remove_metadata": key, _ := m["metadata_key"].(string) out = append(out, typedAction{Type: t, MetadataKey: key}) + case "flag", "unflag": + // No parameters — surfaces / clears the transaction's flag. + out = append(out, typedAction{Type: t}) default: logger.Warn("skipping unknown rule action type", "rule_id", pgconv.FormatUUID(ruleID), "type", t) @@ -729,12 +738,43 @@ func (r *RuleResolver) ResolveWithContext(providerName string, txn TransactionCo ActionValue: a.MetadataKey, }) } + case "flag", "unflag": + // Last-writer-wins: a later-stage rule's flag/unflag supersedes + // an earlier one (a transaction is flagged or it isn't). Drop the + // superseded source so the rule_applied audit credits the winning + // rule only. ActionField is the action type so the dedup key and + // audit annotation distinguish flag from unflag. + result.FlagIntent = a.Type + result.Sources = dropFlagSource(result.Sources) + result.Sources = append(result.Sources, RuleActionSource{ + RuleID: rule.id, + RuleShortID: rule.shortID, + RuleName: rule.name, + ActionField: a.Type, + }) } } } return result } +// dropFlagSource removes any prior flag/unflag source so the final +// RuleActionSource slice records only the winning (last) rule's provenance for +// the flag decision. Non-flag sources are preserved. +func dropFlagSource(src []RuleActionSource) []RuleActionSource { + if len(src) == 0 { + return src + } + kept := src[:0] + for _, s := range src { + if s.ActionField == "flag" || s.ActionField == "unflag" { + continue + } + kept = append(kept, s) + } + return kept +} + // dropCategorySource removes any prior category source so the final // RuleActionSource slice records only the winning (last) rule's provenance // for set_category. Non-category sources are preserved. diff --git a/internal/sync/rule_resolver_flag_test.go b/internal/sync/rule_resolver_flag_test.go new file mode 100644 index 00000000..d417c516 --- /dev/null +++ b/internal/sync/rule_resolver_flag_test.go @@ -0,0 +1,114 @@ +//go:build !lite + +package sync + +import ( + "log/slog" + "testing" +) + +// A single flag action sets FlagIntent="flag" and records exactly one source +// keyed on the "flag" action field. +func TestResolveWithContext_Flag(t *testing.T) { + r := &RuleResolver{ + hitCounts: make(map[[16]byte]int), + rules: []compiledRule{{ + id: testUUID(1), + shortID: "rflag001", + name: "flag big charges", + actions: []typedAction{{Type: "flag"}}, + trigger: "always", + condition: mustCompile(t, &Condition{ + Field: "amount", Op: "gt", Value: float64(100), + }), + }}, + } + result := r.ResolveWithContext("plaid", TransactionContext{Name: "Big charge", Amount: 250}, true) + if result == nil { + t.Fatal("expected non-nil result") + } + if result.FlagIntent != "flag" { + t.Fatalf("expected FlagIntent=flag, got %q", result.FlagIntent) + } + flagSources := 0 + for _, s := range result.Sources { + if s.ActionField == "flag" { + flagSources++ + } + } + if flagSources != 1 { + t.Errorf("expected 1 flag source, got %d", flagSources) + } +} + +// unflag sets FlagIntent="unflag" with an "unflag" source. +func TestResolveWithContext_Unflag(t *testing.T) { + r := &RuleResolver{ + hitCounts: make(map[[16]byte]int), + rules: []compiledRule{{ + id: testUUID(1), + shortID: "runfl001", + name: "clear flag on small", + actions: []typedAction{{Type: "unflag"}}, + trigger: "always", + condition: mustCompile(t, &Condition{Field: "amount", Op: "lt", Value: float64(5)}), + }}, + } + result := r.ResolveWithContext("plaid", TransactionContext{Name: "tiny", Amount: 1}, true) + if result == nil || result.FlagIntent != "unflag" { + t.Fatalf("expected FlagIntent=unflag, got %+v", result) + } + for _, s := range result.Sources { + if s.ActionField == "flag" { + t.Errorf("did not expect a flag source for an unflag-only rule") + } + } +} + +// Last-writer-wins: a later-stage rule's unflag overrides an earlier flag, and +// only the winning rule's source survives. +func TestResolveWithContext_FlagLastWriterWins(t *testing.T) { + r := &RuleResolver{ + hitCounts: make(map[[16]byte]int), + rules: []compiledRule{ + { + id: testUUID(1), shortID: "a", name: "A", + actions: []typedAction{{Type: "flag"}}, + trigger: "always", + condition: mustCompile(t, &Condition{Field: "provider_name", Op: "contains", Value: "x"}), + }, + { + id: testUUID(2), shortID: "b", name: "B", + actions: []typedAction{{Type: "unflag"}}, + trigger: "always", + condition: mustCompile(t, &Condition{Field: "provider_name", Op: "contains", Value: "x"}), + }, + }, + } + result := r.ResolveWithContext("plaid", TransactionContext{Name: "x thing"}, true) + if result.FlagIntent != "unflag" { + t.Errorf("expected last-writer-wins FlagIntent=unflag, got %q", result.FlagIntent) + } + // Exactly one flag-family source survives (the winning unflag). + flagFamily := 0 + for _, s := range result.Sources { + if s.ActionField == "flag" || s.ActionField == "unflag" { + flagFamily++ + } + } + if flagFamily != 1 { + t.Errorf("expected 1 surviving flag-family source, got %d", flagFamily) + } +} + +// parseTypedActions decodes flag / unflag (no params) and tolerates them. +func TestParseTypedActions_Flag(t *testing.T) { + raw := []byte(`[{"type":"flag"},{"type":"unflag"}]`) + out := parseTypedActions(raw, testUUID(1), slog.Default()) + if len(out) != 2 { + t.Fatalf("expected 2 actions, got %d", len(out)) + } + if out[0].Type != "flag" || out[1].Type != "unflag" { + t.Errorf("unexpected parsed actions: %+v", out) + } +} diff --git a/internal/templates/components/pages/rule_form.templ b/internal/templates/components/pages/rule_form.templ index d4357dfd..d97cd57f 100644 --- a/internal/templates/components/pages/rule_form.templ +++ b/internal/templates/components/pages/rule_form.templ @@ -256,13 +256,13 @@ templ RuleForm(p RuleFormProps) { - - ± - - - - - + + ± + + + + + + + diff --git a/static/js/admin/components/rule_form.js b/static/js/admin/components/rule_form.js index ee0e34bc..553fd969 100644 --- a/static/js/admin/components/rule_form.js +++ b/static/js/admin/components/rule_form.js @@ -105,6 +105,8 @@ document.addEventListener('alpine:init', function () { { value: 'comment', label: 'Add comment' }, { value: 'metadata_set', label: 'Set metadata' }, { value: 'metadata_remove', label: 'Remove metadata' }, + { value: 'flag', label: 'Flag for attention' }, + { value: 'unflag', label: 'Clear flag' }, ]; // Tag slug regex must match the server-side validator in @@ -138,6 +140,10 @@ document.addEventListener('alpine:init', function () { if (a.type === 'remove_metadata') { return { field: 'metadata_remove', key: a.metadata_key || '', value: '', valueType: 'text', error: '' }; } + if (a.type === 'flag' || a.type === 'unflag') { + // No parameters — the action type alone carries the intent. + return { field: a.type, value: '', key: '', valueType: 'text', error: '' }; + } // Tolerate unknown types so the form still loads — validation happens at submit. return { field: a.type || a.field || '', value: a.category_slug || a.tag_slug || a.content || a.value || '', error: '' }; } @@ -322,7 +328,10 @@ document.addEventListener('alpine:init', function () { // Action helpers. Only set_category is singleton (backend enforces one // per rule); add_tag / remove_tag / add_comment may repeat. isActionUsed: function (field) { - if (field !== 'category') return false; + // set_category is backend-singleton; flag/unflag are singleton in the UI + // (a rule either flags or it doesn't — last-writer-wins makes a second + // row meaningless). + if (field !== 'category' && field !== 'flag' && field !== 'unflag') return false; return this.form.actions.some(function (a) { return a.field === field; }); }, // Block "Add action" only while there's an unpicked draft row. @@ -388,6 +397,11 @@ document.addEventListener('alpine:init', function () { } } } + var hasFlag = this.form.actions.some(function (a) { return a.field === 'flag'; }); + var hasUnflag = this.form.actions.some(function (a) { return a.field === 'unflag'; }); + if (hasFlag && hasUnflag) { + warnings.push('This rule both flags and clears the flag — last action wins, so one of them has no effect.'); + } return warnings; }, @@ -520,6 +534,8 @@ document.addEventListener('alpine:init', function () { var actions = this.form.actions .filter(function (a) { if (!a.field) return false; + // flag / unflag carry no value — keep them on the field alone. + if (a.field === 'flag' || a.field === 'unflag') return true; if (a.field === 'metadata_set' || a.field === 'metadata_remove') return !!(a.key && a.key.trim()); return a.value !== undefined && a.value !== ''; }) @@ -528,6 +544,8 @@ document.addEventListener('alpine:init', function () { if (a.field === 'tag') return { type: 'add_tag', tag_slug: a.value }; if (a.field === 'tag_remove') return { type: 'remove_tag', tag_slug: a.value }; if (a.field === 'comment') return { type: 'add_comment', content: a.value }; + if (a.field === 'flag') return { type: 'flag' }; + if (a.field === 'unflag') return { type: 'unflag' }; if (a.field === 'metadata_set') { // Coerce the value to its declared JSON type so the stored blob is // typed (boolean true, number 100) rather than always a string.