mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 20:22:16 +00:00
* Add send_email_notification rule action Adds a new rule action that emails a digest of transactions matching a rule. Re-syncs re-apply every active rule to all in-window matches, so a notification_deliveries table (unique on rule_id + transaction_id) backs deduplication and a per-rule watermark: - Rule::ActionExecutor::SendEmailNotification plucks candidate ids, drops ones already in notification_deliveries, records the remainder BEFORE enqueuing (fail-safe: a crash suppresses rather than double-sends), and returns the count of newly-notified transactions. - RuleEmailNotificationJob loads the rule + transactions and delivers the digest via RuleNotificationMailer. - Creating the action pre-seeds all currently-matching transactions as already-delivered (after_create_commit), so the rule only emails about transactions appearing after the action exists. Dedup keys on the DB row id, not provider identity, so a re-ingested transaction (new id) may re-notify; accepted as benign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add view transactions link to rule notification digest email Include a "View transactions" CTA (APP_DOMAIN/transactions) in both the HTML and text versions of the rule notification digest email. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review feedback on rule email notifications - Restrict digest delivery to family admins only (drop non-admin fallback) - Make NotificationDelivery.record_for return only inserted ids and enqueue off that result, preventing duplicate digests under concurrent rule runs - Seed the notification baseline when an existing action is changed to send_email_notification, so historical matches are not emailed - Strengthen tests: assert the transactions CTA/link in the digest and the enqueued job's transaction-id args Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Include super_admin owner as rule digest recipient The admin-only recipient lookup used find_by(role: :admin), which excluded super_admin owners. A self-hosted family is commonly a single super_admin, so the digest was silently skipped (NullMail no-op) and no email was sent. Match the recipient on %w[admin super_admin] (the same pattern used elsewhere, and consistent with User#admin?), while still excluding regular members/guests. Add tests for the super_admin recipient and the no-admin skip path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add mixed-role digest recipient test Cover the case where a family has both an admin and a super_admin. The recipient lookup uses find_by(role: %w[admin super_admin]) with no ORDER BY, so which one is returned is non-deterministic; the contract is only that the recipient is an admin-level user (never a member). Assert recipient.admin? rather than a brittle precedence between the two roles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Deliver rule digest via deliver_later and order in DB Use deliver_later so a slow/flaky SMTP connection doesn't tie up the Sidekiq worker, and push the entry-date sort into the query instead of materializing and sorting the result set in Ruby. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Replace raw hex colors with email-safe design tokens in digest template The rule digest email hardcoded Tailwind slate-100/200 hex values for table borders, which aren't part of this project's design system. Resolve to the actual border-primary/border-secondary token values and centralize them as a reusable .email-table class in the mailer layout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
5.4 KiB
Ruby
178 lines
5.4 KiB
Ruby
class Rule < ApplicationRecord
|
|
UnsupportedResourceTypeError = Class.new(StandardError)
|
|
|
|
belongs_to :family
|
|
has_many :conditions, dependent: :destroy
|
|
has_many :actions, dependent: :destroy
|
|
has_many :rule_runs, dependent: :destroy
|
|
|
|
accepts_nested_attributes_for :conditions, allow_destroy: true
|
|
accepts_nested_attributes_for :actions, allow_destroy: true
|
|
|
|
before_validation :normalize_name
|
|
|
|
validates :resource_type, presence: true
|
|
validates :name, length: { minimum: 1 }, allow_nil: true
|
|
validate :no_nested_compound_conditions
|
|
|
|
# Every rule must have at least 1 action
|
|
validate :min_actions
|
|
validate :no_duplicate_actions
|
|
|
|
def action_executors
|
|
registry.action_executors
|
|
end
|
|
|
|
def condition_filters
|
|
registry.condition_filters
|
|
end
|
|
|
|
def registry
|
|
@registry ||= case resource_type
|
|
when "transaction"
|
|
Rule::Registry::TransactionResource.new(self)
|
|
else
|
|
raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}"
|
|
end
|
|
end
|
|
|
|
def affected_resource_count
|
|
matching_resources_scope.count
|
|
end
|
|
|
|
# Public wrapper around the private matching scope so callers can read the
|
|
# currently-matching transaction ids WITHOUT running executors (e.g. the
|
|
# notification baseline pre-seed). Mirrors total_affected_resource_count,
|
|
# which also reaches matching_resources_scope.
|
|
def matching_transaction_ids
|
|
matching_resources_scope.pluck(:id)
|
|
end
|
|
|
|
# Creates a categorization rule for the Quick Categorize Wizard.
|
|
# Returns the saved rule, or nil if a duplicate or invalid rule already exists.
|
|
def self.create_from_grouping(family, grouping_key, category, transaction_type: nil)
|
|
rule = family.rules.build(name: grouping_key, resource_type: "transaction", active: true)
|
|
rule.conditions.build(condition_type: "transaction_name", operator: "like", value: grouping_key)
|
|
rule.conditions.build(condition_type: "transaction_type", operator: "=", value: transaction_type) if transaction_type.present?
|
|
rule.actions.build(action_type: "set_transaction_category", value: category.id.to_s)
|
|
rule.save!
|
|
rule
|
|
rescue ActiveRecord::RecordInvalid
|
|
nil
|
|
end
|
|
|
|
# Calculates total unique resources affected across multiple rules
|
|
# This handles overlapping rules by deduplicating transaction IDs
|
|
def self.total_affected_resource_count(rules)
|
|
return 0 if rules.empty?
|
|
|
|
# Collect all unique transaction IDs matched by any rule
|
|
transaction_ids = Set.new
|
|
rules.each do |rule|
|
|
transaction_ids.merge(rule.send(:matching_resources_scope).pluck(:id))
|
|
end
|
|
|
|
transaction_ids.size
|
|
end
|
|
|
|
def apply(ignore_attribute_locks: false, rule_run: nil)
|
|
total_modified = 0
|
|
total_async_jobs = 0
|
|
has_async = false
|
|
|
|
actions.each do |action|
|
|
result = action.apply(matching_resources_scope, ignore_attribute_locks: ignore_attribute_locks, rule_run: rule_run)
|
|
|
|
if result.is_a?(Hash) && result[:async]
|
|
has_async = true
|
|
total_async_jobs += result[:jobs_count] || 0
|
|
total_modified += result[:modified_count] || 0
|
|
elsif result.is_a?(Integer)
|
|
total_modified += result
|
|
else
|
|
# Log unexpected result type but don't fail
|
|
Rails.logger.warn("Rule#apply: Unexpected result type from action #{action.id}: #{result.class} (value: #{result.inspect})")
|
|
end
|
|
end
|
|
|
|
if has_async
|
|
{ modified_count: total_modified, async: true, jobs_count: total_async_jobs }
|
|
else
|
|
total_modified
|
|
end
|
|
end
|
|
|
|
def apply_later(ignore_attribute_locks: false)
|
|
RuleJob.perform_later(self, ignore_attribute_locks: ignore_attribute_locks)
|
|
end
|
|
|
|
def primary_condition_title
|
|
condition = displayed_condition
|
|
return I18n.t("rules.no_condition") if condition.blank?
|
|
|
|
"If #{condition.filter.label.downcase} #{condition.operator} #{condition.value_display}"
|
|
end
|
|
|
|
def displayed_condition
|
|
displayable_conditions.first
|
|
end
|
|
|
|
def additional_displayable_conditions_count
|
|
[ displayable_conditions.size - 1, 0 ].max
|
|
end
|
|
|
|
def displayable_conditions
|
|
conditions.filter_map do |condition|
|
|
condition.compound? ? condition.sub_conditions.first : condition
|
|
end
|
|
end
|
|
|
|
private
|
|
def matching_resources_scope
|
|
scope = registry.resource_scope
|
|
|
|
# 1. Prepare the query with joins required by conditions
|
|
conditions.each do |condition|
|
|
scope = condition.prepare(scope)
|
|
end
|
|
|
|
# 2. Apply the conditions to the query
|
|
conditions.each do |condition|
|
|
scope = condition.apply(scope)
|
|
end
|
|
|
|
scope
|
|
end
|
|
|
|
def min_actions
|
|
return if new_record? && !actions.empty?
|
|
|
|
if actions.reject(&:marked_for_destruction?).empty?
|
|
errors.add(:base, :min_actions)
|
|
end
|
|
end
|
|
|
|
def no_duplicate_actions
|
|
action_types = actions.reject(&:marked_for_destruction?).map(&:action_type)
|
|
|
|
errors.add(:base, :duplicate_actions, types: action_types.inspect) if action_types.uniq.count != action_types.count
|
|
end
|
|
|
|
# Validation: To keep rules simple and easy to understand, we don't allow nested compound conditions.
|
|
def no_nested_compound_conditions
|
|
return true if conditions.none? { |condition| condition.compound? }
|
|
|
|
conditions.each do |condition|
|
|
if condition.compound?
|
|
if condition.sub_conditions.any? { |sub_condition| sub_condition.compound? }
|
|
errors.add(:base, :nested_conditions)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def normalize_name
|
|
self.name = nil if name.is_a?(String) && name.strip.empty?
|
|
end
|
|
end
|