email_digest 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.vscode/settings.json +3 -0
- data/ANSWERS_TO_YOUR_QUESTIONS.md +361 -0
- data/CHANGELOG.md +14 -0
- data/COMPLETE_SETUP_INSTRUCTIONS.md +298 -0
- data/CUSTOMIZATION_GUIDE.md +264 -0
- data/FAQ.md +133 -0
- data/LICENSE.txt +21 -0
- data/QUICK_START.md +62 -0
- data/README.md +253 -0
- data/REPOSITORY_READY.md +146 -0
- data/RUBYGEMS_PUBLISHING.md +272 -0
- data/SETUP_SEPARATE_GEM_REPO.md +131 -0
- data/email_digest.gemspec +52 -0
- data/lib/email_digest/concerns/digestable.rb +57 -0
- data/lib/email_digest/configuration.rb +40 -0
- data/lib/email_digest/generators/email_digest/install/install_generator.rb +50 -0
- data/lib/email_digest/generators/email_digest/install/templates/README +16 -0
- data/lib/email_digest/generators/email_digest/install/templates/create_email_digest_tables.rb +77 -0
- data/lib/email_digest/generators/email_digest/install/templates/email_digest.rb +25 -0
- data/lib/email_digest/models/digest_item.rb +49 -0
- data/lib/email_digest/models/digest_preference.rb +106 -0
- data/lib/email_digest/railtie.rb +9 -0
- data/lib/email_digest/services/digest_collector.rb +38 -0
- data/lib/email_digest/services/digest_scheduler.rb +55 -0
- data/lib/email_digest/services/digest_summarizer.rb +135 -0
- data/lib/email_digest/version.rb +5 -0
- data/lib/email_digest/workers/digest_processor_worker.rb +64 -0
- data/lib/email_digest/workers/digest_scheduler_worker.rb +26 -0
- data/lib/email_digest/workers/digest_sender_worker.rb +48 -0
- data/lib/email_digest.rb +37 -0
- metadata +241 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Setup Separate EmailDigest Gem Repository
|
|
2
|
+
|
|
3
|
+
This guide will help you create a completely separate gem repository that you can publish to RubyGems.
|
|
4
|
+
|
|
5
|
+
## Step 1: Create New Repository Directory
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Navigate to where you want the gem repo (outside your main app)
|
|
9
|
+
cd ~/workspace # or any location you prefer
|
|
10
|
+
mkdir email_digest
|
|
11
|
+
cd email_digest
|
|
12
|
+
git init
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Step 2: Copy All Files from gem_package
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Copy everything from gem_package to your new repo
|
|
19
|
+
cp -r /Users/rosharma/workspace/sll-la/eportfolio/gem_package/* ~/workspace/email_digest/
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Step 3: Update Gemspec Metadata
|
|
23
|
+
|
|
24
|
+
Edit `email_digest.gemspec` and update:
|
|
25
|
+
- `spec.authors` - Your name
|
|
26
|
+
- `spec.email` - Your email
|
|
27
|
+
- `spec.homepage` - Your GitHub repo URL
|
|
28
|
+
- `spec.metadata['source_code_uri']` - Your GitHub repo
|
|
29
|
+
- `spec.metadata['changelog_uri']` - Your CHANGELOG URL
|
|
30
|
+
|
|
31
|
+
## Step 4: Create GitHub Repository
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Create a new repo on GitHub (via web or CLI)
|
|
35
|
+
# Then connect it:
|
|
36
|
+
git remote add origin https://github.com/rohitsharma170801/email_digest.git
|
|
37
|
+
git add .
|
|
38
|
+
git commit -m "Initial commit"
|
|
39
|
+
git branch -M main
|
|
40
|
+
git push -u origin main
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Step 5: Test the Gem Locally
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Build the gem
|
|
47
|
+
gem build email_digest.gemspec
|
|
48
|
+
|
|
49
|
+
# Test installation
|
|
50
|
+
gem install ./email_digest-1.0.0.gem
|
|
51
|
+
|
|
52
|
+
# Or test in your app
|
|
53
|
+
# In your app's Gemfile:
|
|
54
|
+
# gem 'email_digest', path: '../email_digest'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Step 6: Publish to RubyGems
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# 1. Create account at https://rubygems.org
|
|
61
|
+
# 2. Get your API key from https://rubygems.org/profile/edit
|
|
62
|
+
|
|
63
|
+
# 3. Build and push
|
|
64
|
+
gem build email_digest.gemspec
|
|
65
|
+
gem push email_digest-1.0.0.gem
|
|
66
|
+
|
|
67
|
+
# Or use your API key
|
|
68
|
+
gem push email_digest-1.0.0.gem --key your_api_key_name
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Step 7: Use in Your Application
|
|
72
|
+
|
|
73
|
+
In your main app's `Gemfile`:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# From RubyGems (after publishing)
|
|
77
|
+
gem 'email_digest'
|
|
78
|
+
|
|
79
|
+
# Or from GitHub (before publishing)
|
|
80
|
+
gem 'email_digest', git: 'https://github.com/yourusername/email_digest.git'
|
|
81
|
+
|
|
82
|
+
# Or local development
|
|
83
|
+
gem 'email_digest', path: '../email_digest'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
bundle install
|
|
90
|
+
rails generate email_digest:install
|
|
91
|
+
rails db:migrate
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## File Checklist
|
|
95
|
+
|
|
96
|
+
Make sure your gem repo has:
|
|
97
|
+
- ✅ `email_digest.gemspec`
|
|
98
|
+
- ✅ `lib/email_digest.rb`
|
|
99
|
+
- ✅ `lib/email_digest/version.rb`
|
|
100
|
+
- ✅ `lib/email_digest/configuration.rb`
|
|
101
|
+
- ✅ `lib/email_digest/railtie.rb`
|
|
102
|
+
- ✅ All models, services, workers, concerns
|
|
103
|
+
- ✅ Generator files
|
|
104
|
+
- ✅ `README.md`
|
|
105
|
+
- ✅ `LICENSE.txt`
|
|
106
|
+
- ✅ `CHANGELOG.md`
|
|
107
|
+
- ✅ `.gitignore`
|
|
108
|
+
|
|
109
|
+
## Publishing Checklist
|
|
110
|
+
|
|
111
|
+
Before publishing:
|
|
112
|
+
- [ ] Update version in `lib/email_digest/version.rb`
|
|
113
|
+
- [ ] Update `CHANGELOG.md`
|
|
114
|
+
- [ ] Update gemspec metadata
|
|
115
|
+
- [ ] Test gem build: `gem build email_digest.gemspec`
|
|
116
|
+
- [ ] Test installation locally
|
|
117
|
+
- [ ] Test in a test Rails app
|
|
118
|
+
- [ ] Push to GitHub
|
|
119
|
+
- [ ] Publish to RubyGems
|
|
120
|
+
|
|
121
|
+
## Version Updates
|
|
122
|
+
|
|
123
|
+
When updating the gem:
|
|
124
|
+
|
|
125
|
+
1. Update version in `lib/email_digest/version.rb`
|
|
126
|
+
2. Update `CHANGELOG.md`
|
|
127
|
+
3. Commit changes
|
|
128
|
+
4. Create git tag: `git tag -a v1.0.1 -m "Version 1.0.1"`
|
|
129
|
+
5. Push tag: `git push origin v1.0.1`
|
|
130
|
+
6. Build: `gem build email_digest.gemspec`
|
|
131
|
+
7. Push: `gem push email_digest-1.0.1.gem`
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/email_digest/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'email_digest'
|
|
7
|
+
spec.version = EmailDigest::VERSION
|
|
8
|
+
spec.authors = ['Rohit sharma']
|
|
9
|
+
spec.email = ['rohitsharma170801@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'A flexible, production-ready email digest system for Rails applications'
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
EmailDigest is a comprehensive email digest system that allows you to collect,
|
|
14
|
+
summarize, and send batched email notifications to users. It supports multiple
|
|
15
|
+
digest types, customizable schedules, priority-based processing, and multi-tenant
|
|
16
|
+
architectures. Built with PostgreSQL and Sidekiq for reliability and scalability.
|
|
17
|
+
DESC
|
|
18
|
+
spec.homepage = 'https://github.com/rohitsharma170801/email_digest'
|
|
19
|
+
spec.license = 'MIT'
|
|
20
|
+
|
|
21
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
22
|
+
spec.metadata['source_code_uri'] = 'https://github.com/rohitsharma170801/email_digest'
|
|
23
|
+
spec.metadata['changelog_uri'] = 'https://github.com/rohitsharma170801/email_digest/blob/main/CHANGELOG.md'
|
|
24
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
25
|
+
|
|
26
|
+
# Specify which files should be added to the gem when it is released.
|
|
27
|
+
spec.files = Dir.chdir(__dir__) do
|
|
28
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
29
|
+
(File.expand_path(f) == __FILE__) ||
|
|
30
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
spec.bindir = 'exe'
|
|
35
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
36
|
+
spec.require_paths = ['lib']
|
|
37
|
+
|
|
38
|
+
# Dependencies
|
|
39
|
+
spec.add_dependency 'rails', '>= 6.0', '< 8.0'
|
|
40
|
+
spec.add_dependency 'sidekiq', '>= 5.0'
|
|
41
|
+
spec.add_dependency 'sidekiq-cron', '>= 1.0'
|
|
42
|
+
spec.add_dependency 'pg', '>= 1.0'
|
|
43
|
+
|
|
44
|
+
# Development dependencies
|
|
45
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
|
46
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
|
47
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
48
|
+
spec.add_development_dependency 'rspec-rails', '~> 6.0'
|
|
49
|
+
spec.add_development_dependency 'rubocop', '~> 1.0'
|
|
50
|
+
spec.add_development_dependency 'rubocop-rails', '~> 2.0'
|
|
51
|
+
spec.add_development_dependency 'simplecov', '~> 0.21'
|
|
52
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailDigest
|
|
4
|
+
module Concerns
|
|
5
|
+
module Digestable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
# This concern can be included in models that want to support digest notifications
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def digestable(digest_type, options = {})
|
|
14
|
+
@digest_type = digest_type
|
|
15
|
+
@digest_options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def digest_type
|
|
19
|
+
@digest_type
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def digest_options
|
|
23
|
+
@digest_options || {}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_to_digest(user, notification_type, options = {})
|
|
28
|
+
digest_type = self.class.digest_type || options[:digest_type]
|
|
29
|
+
return unless digest_type
|
|
30
|
+
|
|
31
|
+
# Get organization from user or options
|
|
32
|
+
organization = options[:organization] || user.organization
|
|
33
|
+
|
|
34
|
+
EmailDigest::Models::DigestItem.create_for_user(
|
|
35
|
+
user,
|
|
36
|
+
digest_type,
|
|
37
|
+
notification_type,
|
|
38
|
+
{
|
|
39
|
+
subject: options[:subject],
|
|
40
|
+
body: options[:body],
|
|
41
|
+
metadata: options[:metadata] || {},
|
|
42
|
+
source_id: id.to_s,
|
|
43
|
+
source_type: self.class.name,
|
|
44
|
+
priority: options[:priority] || 0,
|
|
45
|
+
organization: organization
|
|
46
|
+
}.merge(options)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_to_digest_for_users(users, notification_type, options = {})
|
|
51
|
+
users.each do |user|
|
|
52
|
+
add_to_digest(user, notification_type, options)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailDigest
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :default_frequency,
|
|
6
|
+
:default_time,
|
|
7
|
+
:default_timezone,
|
|
8
|
+
:queue_name,
|
|
9
|
+
:max_items_per_digest,
|
|
10
|
+
:enable_summarization,
|
|
11
|
+
:mailer_class,
|
|
12
|
+
:mailer_method,
|
|
13
|
+
:organization_class,
|
|
14
|
+
:user_class,
|
|
15
|
+
:digest_types
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@default_frequency = :daily
|
|
19
|
+
@default_time = '09:00'
|
|
20
|
+
@default_timezone = 'UTC'
|
|
21
|
+
@queue_name = :email_digest
|
|
22
|
+
@max_items_per_digest = 50
|
|
23
|
+
@enable_summarization = true
|
|
24
|
+
@mailer_class = 'UserMailer'
|
|
25
|
+
@mailer_method = 'digest_email'
|
|
26
|
+
@organization_class = 'Organization'
|
|
27
|
+
@user_class = 'User'
|
|
28
|
+
@digest_types = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def register_digest_type(type_name, options = {})
|
|
32
|
+
@digest_types[type_name.to_sym] = {
|
|
33
|
+
collector: options[:collector] || "EmailDigest::Collectors::#{type_name.to_s.classify}Collector",
|
|
34
|
+
summarizer: options[:summarizer] || "EmailDigest::Summarizers::#{type_name.to_s.classify}Summarizer",
|
|
35
|
+
mailer_method: options[:mailer_method] || "digest_#{type_name}_email"
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailDigest
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path('templates', __dir__)
|
|
7
|
+
|
|
8
|
+
desc 'Installs EmailDigest and generates the necessary configuration files'
|
|
9
|
+
|
|
10
|
+
def create_initializer
|
|
11
|
+
template 'email_digest.rb', 'config/initializers/email_digest.rb'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_migration
|
|
15
|
+
migration_template 'create_email_digest_tables.rb',
|
|
16
|
+
'db/migrate/create_email_digest_tables.rb'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add_sidekiq_queue
|
|
20
|
+
if File.exist?('config/sidekiq.yml')
|
|
21
|
+
inject_into_file 'config/sidekiq.yml', after: ":queues:\n" do
|
|
22
|
+
" - email_digest\n"
|
|
23
|
+
end
|
|
24
|
+
else
|
|
25
|
+
say_status 'warning', 'config/sidekiq.yml not found. Please add email_digest to your Sidekiq queues manually.', :yellow
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_sidekiq_cron_job
|
|
30
|
+
if File.exist?('config/sidekiq_cron_schedule.yml')
|
|
31
|
+
inject_into_file 'config/sidekiq_cron_schedule.yml', after: "# sidekiq-cron will make sure only one cron job will be inserted into queue\n" do
|
|
32
|
+
<<~YAML
|
|
33
|
+
|
|
34
|
+
email_digest_scheduler_worker:
|
|
35
|
+
cron: "*/15 * * * *" # every 15 minutes
|
|
36
|
+
class: "EmailDigest::Workers::DigestSchedulerWorker"
|
|
37
|
+
queue: email_digest
|
|
38
|
+
YAML
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
say_status 'warning', 'config/sidekiq_cron_schedule.yml not found. Please add the scheduler job manually.', :yellow
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def show_readme
|
|
46
|
+
readme 'README' if behavior == :invoke
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
EmailDigest has been successfully installed!
|
|
2
|
+
|
|
3
|
+
Next steps:
|
|
4
|
+
|
|
5
|
+
1. Run the migration:
|
|
6
|
+
rails db:migrate
|
|
7
|
+
|
|
8
|
+
2. Configure your digest types in config/initializers/email_digest.rb
|
|
9
|
+
|
|
10
|
+
3. Add digest_email method to your mailer (e.g., app/mailers/user_mailer.rb)
|
|
11
|
+
|
|
12
|
+
4. Include EmailDigest::Concerns::Digestable in models that should create digest items
|
|
13
|
+
|
|
14
|
+
5. Start Sidekiq to process digest jobs
|
|
15
|
+
|
|
16
|
+
For more information, visit: https://github.com/yourusername/email_digest
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateEmailDigestTables < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
create_table :email_digest_items do |t|
|
|
6
|
+
t.string :user_id, null: false
|
|
7
|
+
t.string :organization_id, null: false
|
|
8
|
+
t.string :digest_type, null: false
|
|
9
|
+
t.string :notification_type, null: false
|
|
10
|
+
t.string :subject
|
|
11
|
+
t.text :body
|
|
12
|
+
t.jsonb :metadata, default: {}
|
|
13
|
+
t.string :source_id # ID of the source object (e.g., time_log_id)
|
|
14
|
+
t.string :source_type # Type of source object
|
|
15
|
+
t.integer :priority, default: 0, null: false
|
|
16
|
+
t.boolean :processed, default: false, null: false
|
|
17
|
+
t.datetime :processed_at
|
|
18
|
+
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
create_table :email_digest_preferences do |t|
|
|
23
|
+
t.string :user_id, null: false
|
|
24
|
+
t.string :organization_id, null: false
|
|
25
|
+
t.string :digest_type, null: false
|
|
26
|
+
t.boolean :enabled, default: true, null: false
|
|
27
|
+
t.string :frequency, default: 'daily', null: false # daily, weekly, custom
|
|
28
|
+
t.integer :day_of_week # 0-6 (Sunday-Saturday) for weekly
|
|
29
|
+
t.string :time, default: '09:00', null: false # HH:MM format
|
|
30
|
+
t.string :timezone, default: 'UTC', null: false
|
|
31
|
+
t.jsonb :custom_schedule # For complex schedules
|
|
32
|
+
t.datetime :last_sent_at
|
|
33
|
+
t.datetime :next_send_at
|
|
34
|
+
|
|
35
|
+
t.timestamps
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Indexes for email_digest_items
|
|
39
|
+
add_index :email_digest_items, [:user_id, :digest_type, :processed, :organization_id],
|
|
40
|
+
name: 'index_digest_items_on_user_digest_processed_org'
|
|
41
|
+
add_index :email_digest_items, [:organization_id, :processed, :created_at],
|
|
42
|
+
name: 'index_digest_items_on_org_processed_created'
|
|
43
|
+
add_index :email_digest_items, [:source_id, :source_type],
|
|
44
|
+
name: 'index_digest_items_on_source'
|
|
45
|
+
# Optimized index for DigestCollector queries (pending items sorted by priority and created_at)
|
|
46
|
+
# Partial index for better performance (only indexes unprocessed items)
|
|
47
|
+
execute <<-SQL
|
|
48
|
+
CREATE INDEX index_digest_items_collector_query
|
|
49
|
+
ON email_digest_items (user_id, digest_type, organization_id, created_at DESC, priority DESC)
|
|
50
|
+
WHERE processed = false;
|
|
51
|
+
SQL
|
|
52
|
+
add_index :email_digest_items, :organization_id
|
|
53
|
+
add_index :email_digest_items, :user_id
|
|
54
|
+
|
|
55
|
+
# Indexes for email_digest_preferences
|
|
56
|
+
add_index :email_digest_preferences, [:user_id, :digest_type, :organization_id],
|
|
57
|
+
unique: true,
|
|
58
|
+
name: 'index_digest_preferences_on_user_digest_org_unique'
|
|
59
|
+
add_index :email_digest_preferences, [:organization_id, :next_send_at],
|
|
60
|
+
name: 'index_digest_preferences_on_org_next_send'
|
|
61
|
+
add_index :email_digest_preferences, [:organization_id, :enabled],
|
|
62
|
+
name: 'index_digest_preferences_on_org_enabled'
|
|
63
|
+
add_index :email_digest_preferences, :user_id
|
|
64
|
+
add_index :email_digest_preferences, :organization_id
|
|
65
|
+
|
|
66
|
+
# Add check constraints
|
|
67
|
+
add_check_constraint :email_digest_preferences,
|
|
68
|
+
"frequency IN ('daily', 'weekly', 'custom')",
|
|
69
|
+
name: 'check_digest_preferences_frequency'
|
|
70
|
+
add_check_constraint :email_digest_preferences,
|
|
71
|
+
"time ~ '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'",
|
|
72
|
+
name: 'check_digest_preferences_time_format'
|
|
73
|
+
add_check_constraint :email_digest_preferences,
|
|
74
|
+
"day_of_week IS NULL OR (day_of_week >= 0 AND day_of_week <= 6)",
|
|
75
|
+
name: 'check_digest_preferences_day_of_week'
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
EmailDigest.configure do |config|
|
|
4
|
+
# Default settings
|
|
5
|
+
config.default_frequency = :daily
|
|
6
|
+
config.default_time = '09:00'
|
|
7
|
+
config.default_timezone = 'UTC'
|
|
8
|
+
config.queue_name = :email_digest
|
|
9
|
+
config.max_items_per_digest = 50
|
|
10
|
+
config.enable_summarization = true
|
|
11
|
+
config.mailer_class = 'UserMailer'
|
|
12
|
+
config.mailer_method = 'digest_email'
|
|
13
|
+
config.organization_class = 'Organization'
|
|
14
|
+
config.user_class = 'User'
|
|
15
|
+
|
|
16
|
+
# Register digest types
|
|
17
|
+
# Example:
|
|
18
|
+
# config.register_digest_type(:time_log_notifications, {
|
|
19
|
+
# mailer_method: 'time_log_digest_email'
|
|
20
|
+
# })
|
|
21
|
+
#
|
|
22
|
+
# config.register_digest_type(:mentor_supervisor_notifications, {
|
|
23
|
+
# mailer_method: 'mentor_supervisor_digest_email'
|
|
24
|
+
# })
|
|
25
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailDigest
|
|
4
|
+
module Models
|
|
5
|
+
class DigestItem < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'email_digest_items'
|
|
7
|
+
|
|
8
|
+
# Associations
|
|
9
|
+
# Note: User and Organization are in MongoDB, so we use string IDs
|
|
10
|
+
# belongs_to :user would require the User model to be in PostgreSQL
|
|
11
|
+
# For now, we'll keep it as a string reference
|
|
12
|
+
|
|
13
|
+
# Validations
|
|
14
|
+
validates :user_id, presence: true
|
|
15
|
+
validates :organization_id, presence: true
|
|
16
|
+
validates :digest_type, presence: true
|
|
17
|
+
validates :notification_type, presence: true
|
|
18
|
+
|
|
19
|
+
# Scopes
|
|
20
|
+
scope :pending, -> { where(processed: false) }
|
|
21
|
+
scope :for_user, ->(user) { where(user_id: user.id.to_s) }
|
|
22
|
+
scope :for_type, ->(type) { where(digest_type: type.to_s) }
|
|
23
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
24
|
+
scope :for_organization, ->(org) { where(organization_id: org.id.to_s) }
|
|
25
|
+
|
|
26
|
+
# Class methods
|
|
27
|
+
def self.create_for_user(user, digest_type, notification_type, options = {})
|
|
28
|
+
organization = options[:organization] || user.organization
|
|
29
|
+
create(
|
|
30
|
+
user_id: user.id.to_s,
|
|
31
|
+
organization_id: organization.id.to_s,
|
|
32
|
+
digest_type: digest_type.to_s,
|
|
33
|
+
notification_type: notification_type.to_s,
|
|
34
|
+
subject: options[:subject],
|
|
35
|
+
body: options[:body],
|
|
36
|
+
metadata: options[:metadata] || {},
|
|
37
|
+
source_id: options[:source_id],
|
|
38
|
+
source_type: options[:source_type],
|
|
39
|
+
priority: options[:priority] || 0
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Instance methods
|
|
44
|
+
def mark_processed
|
|
45
|
+
update(processed: true, processed_at: Time.current)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailDigest
|
|
4
|
+
module Models
|
|
5
|
+
class DigestPreference < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'email_digest_preferences'
|
|
7
|
+
|
|
8
|
+
# Validations
|
|
9
|
+
validates :user_id, presence: true
|
|
10
|
+
validates :organization_id, presence: true
|
|
11
|
+
validates :digest_type, presence: true
|
|
12
|
+
validates :frequency, inclusion: { in: %w[daily weekly custom] }
|
|
13
|
+
validates :time, format: { with: /\A([0-1]?[0-9]|2[0-3]):[0-5][0-9]\z/ }
|
|
14
|
+
validates :day_of_week, presence: true, if: -> { frequency == 'weekly' }
|
|
15
|
+
validates :timezone, inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) }, allow_blank: true
|
|
16
|
+
validates :user_id, uniqueness: { scope: [:digest_type, :organization_id],
|
|
17
|
+
message: 'already has a preference for this digest type in this organization' }
|
|
18
|
+
|
|
19
|
+
# Callbacks
|
|
20
|
+
before_save :calculate_next_send_at
|
|
21
|
+
|
|
22
|
+
# Scopes
|
|
23
|
+
scope :for_organization, ->(org) { where(organization_id: org.id.to_s) }
|
|
24
|
+
scope :enabled, -> { where(enabled: true) }
|
|
25
|
+
|
|
26
|
+
# Class methods
|
|
27
|
+
def self.for_user_and_type(user, digest_type, organization)
|
|
28
|
+
find_or_create_by(
|
|
29
|
+
user_id: user.id.to_s,
|
|
30
|
+
digest_type: digest_type.to_s,
|
|
31
|
+
organization_id: organization.id.to_s
|
|
32
|
+
) do |pref|
|
|
33
|
+
pref.frequency = EmailDigest.configuration.default_frequency.to_s
|
|
34
|
+
pref.time = EmailDigest.configuration.default_time
|
|
35
|
+
pref.timezone = EmailDigest.configuration.default_timezone
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.ready_to_send(organization = nil)
|
|
40
|
+
now = Time.current
|
|
41
|
+
query = where(enabled: true).where('next_send_at <= ?', now)
|
|
42
|
+
query = query.where(organization_id: organization.id.to_s) if organization
|
|
43
|
+
query
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Instance methods
|
|
47
|
+
def calculate_next_send_at
|
|
48
|
+
return unless enabled
|
|
49
|
+
|
|
50
|
+
time_parts = time.split(':')
|
|
51
|
+
hour = time_parts[0].to_i
|
|
52
|
+
minute = time_parts[1].to_i
|
|
53
|
+
|
|
54
|
+
# Safely handle timezone, fallback to UTC if invalid
|
|
55
|
+
begin
|
|
56
|
+
base_time = Time.current.in_time_zone(timezone)
|
|
57
|
+
rescue ArgumentError, TZInfo::InvalidTimezoneIdentifier
|
|
58
|
+
base_time = Time.current.in_time_zone('UTC')
|
|
59
|
+
end
|
|
60
|
+
target_time = base_time.change(hour: hour, min: minute, sec: 0)
|
|
61
|
+
|
|
62
|
+
case frequency
|
|
63
|
+
when 'daily'
|
|
64
|
+
self.next_send_at = target_time < base_time ? target_time + 1.day : target_time
|
|
65
|
+
when 'weekly'
|
|
66
|
+
days_until_target = (day_of_week - base_time.wday) % 7
|
|
67
|
+
days_until_target = 7 if days_until_target == 0 && target_time < base_time
|
|
68
|
+
self.next_send_at = (base_time.beginning_of_week + days_until_target.days).change(hour: hour, min: minute)
|
|
69
|
+
when 'custom'
|
|
70
|
+
calculate_custom_schedule
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def calculate_custom_schedule
|
|
75
|
+
return unless custom_schedule.present?
|
|
76
|
+
|
|
77
|
+
# Custom schedule can be: { days: [1,3,5], time: "14:00" }
|
|
78
|
+
# Or cron-like: { cron: "0 9 * * 1,3,5" }
|
|
79
|
+
# For now, simple implementation
|
|
80
|
+
if custom_schedule['days'].present?
|
|
81
|
+
begin
|
|
82
|
+
base_time = Time.current.in_time_zone(timezone)
|
|
83
|
+
rescue ArgumentError, TZInfo::InvalidTimezoneIdentifier
|
|
84
|
+
base_time = Time.current.in_time_zone('UTC')
|
|
85
|
+
end
|
|
86
|
+
time_parts = (custom_schedule['time'] || time).split(':')
|
|
87
|
+
hour = time_parts[0].to_i
|
|
88
|
+
minute = time_parts[1].to_i
|
|
89
|
+
|
|
90
|
+
target_days = custom_schedule['days'].map(&:to_i)
|
|
91
|
+
current_day = base_time.wday
|
|
92
|
+
next_day = target_days.find { |d| d > current_day } || target_days.first
|
|
93
|
+
days_ahead = next_day > current_day ? next_day - current_day : (7 - current_day) + next_day
|
|
94
|
+
|
|
95
|
+
self.next_send_at = (base_time + days_ahead.days).change(hour: hour, min: minute)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def update_last_sent
|
|
100
|
+
self.last_sent_at = Time.current
|
|
101
|
+
calculate_next_send_at
|
|
102
|
+
save
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailDigest
|
|
4
|
+
module Services
|
|
5
|
+
class DigestCollector
|
|
6
|
+
attr_reader :user, :digest_type, :organization
|
|
7
|
+
|
|
8
|
+
def initialize(user, digest_type, organization = nil)
|
|
9
|
+
@user = user
|
|
10
|
+
@digest_type = digest_type.to_sym
|
|
11
|
+
@organization = organization || user.organization
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def collect_items(since: nil)
|
|
15
|
+
since ||= last_digest_sent_at || 7.days.ago
|
|
16
|
+
|
|
17
|
+
items = Models::DigestItem.pending
|
|
18
|
+
.for_user(user)
|
|
19
|
+
.for_type(digest_type)
|
|
20
|
+
.for_organization(organization)
|
|
21
|
+
.where('created_at >= ?', since)
|
|
22
|
+
.order(priority: :desc, created_at: :desc)
|
|
23
|
+
.limit(EmailDigest.configuration.max_items_per_digest)
|
|
24
|
+
|
|
25
|
+
items
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def last_digest_sent_at
|
|
29
|
+
preference = Models::DigestPreference.find_by(
|
|
30
|
+
user_id: user.id.to_s,
|
|
31
|
+
digest_type: digest_type.to_s,
|
|
32
|
+
organization_id: organization.id.to_s
|
|
33
|
+
)
|
|
34
|
+
preference&.last_sent_at
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|