churchcred 0.1.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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +3 -0
  4. data/app/blueprints/badge_blueprint.rb +36 -0
  5. data/app/blueprints/merch_item_blueprint.rb +5 -0
  6. data/app/blueprints/organization_blueprint.rb +11 -0
  7. data/app/blueprints/points_ledger_entry_blueprint.rb +18 -0
  8. data/app/blueprints/redemption_blueprint.rb +7 -0
  9. data/app/blueprints/sync_log_blueprint.rb +5 -0
  10. data/app/blueprints/user_blueprint.rb +32 -0
  11. data/app/channels/application_cable/channel.rb +4 -0
  12. data/app/channels/application_cable/connection.rb +4 -0
  13. data/app/controllers/api/v1/admin/members_controller.rb +20 -0
  14. data/app/controllers/api/v1/admin/stats_controller.rb +45 -0
  15. data/app/controllers/api/v1/badges_controller.rb +43 -0
  16. data/app/controllers/api/v1/base_controller.rb +27 -0
  17. data/app/controllers/api/v1/me_controller.rb +19 -0
  18. data/app/controllers/api/v1/merch_items_controller.rb +43 -0
  19. data/app/controllers/api/v1/organizations_controller.rb +29 -0
  20. data/app/controllers/api/v1/redemptions_controller.rb +35 -0
  21. data/app/controllers/api/v1/sessions_controller.rb +24 -0
  22. data/app/controllers/api/v1/sync_controller.rb +15 -0
  23. data/app/controllers/api/v1/sync_logs_controller.rb +15 -0
  24. data/app/controllers/application_controller.rb +6 -0
  25. data/app/controllers/auth/pco_controller.rb +59 -0
  26. data/app/jobs/application_job.rb +7 -0
  27. data/app/jobs/pco_sync_job.rb +204 -0
  28. data/app/mailers/application_mailer.rb +4 -0
  29. data/app/models/application_record.rb +3 -0
  30. data/app/models/badge.rb +31 -0
  31. data/app/models/badge_award.rb +7 -0
  32. data/app/models/merch_item.rb +24 -0
  33. data/app/models/organization.rb +17 -0
  34. data/app/models/points_ledger_entry.rb +11 -0
  35. data/app/models/redemption.rb +8 -0
  36. data/app/models/sync_log.rb +9 -0
  37. data/app/models/user.rb +27 -0
  38. data/app/views/layouts/mailer.html.erb +13 -0
  39. data/app/views/layouts/mailer.text.erb +1 -0
  40. data/config/application.rb +32 -0
  41. data/config/boot.rb +4 -0
  42. data/config/cable.yml +10 -0
  43. data/config/credentials.yml.enc +1 -0
  44. data/config/database.yml +17 -0
  45. data/config/environment.rb +5 -0
  46. data/config/environments/development.rb +77 -0
  47. data/config/environments/production.rb +97 -0
  48. data/config/environments/test.rb +67 -0
  49. data/config/initializers/cors.rb +11 -0
  50. data/config/initializers/filter_parameter_logging.rb +8 -0
  51. data/config/initializers/inflections.rb +16 -0
  52. data/config/initializers/rack_attack.rb +22 -0
  53. data/config/initializers/sidekiq.rb +7 -0
  54. data/config/initializers/watch_dirs.rb +6 -0
  55. data/config/locales/en.yml +31 -0
  56. data/config/master.key +1 -0
  57. data/config/puma.rb +34 -0
  58. data/config/routes.rb +42 -0
  59. data/config/storage.yml +34 -0
  60. data/config/vite.json +16 -0
  61. data/db/migrate/20260327000001_create_initial_schema.rb +92 -0
  62. data/db/schema.rb +133 -0
  63. data/db/seeds.rb +70 -0
  64. data/lib/churchcred/engine.rb +16 -0
  65. data/lib/churchcred/version.rb +3 -0
  66. data/lib/churchcred.rb +6 -0
  67. data/public/assets/index-BtxWON3O.js +7919 -0
  68. data/public/assets/index-DG2Eq1li.css +1 -0
  69. data/public/assets/index-DxBMnUi7.js +7919 -0
  70. data/public/assets/index-PoniqDpN.css +1 -0
  71. data/public/favicon.ico +0 -0
  72. data/public/index.html +27 -0
  73. data/public/placeholder.svg +40 -0
  74. data/public/robots.txt +14 -0
  75. metadata +285 -0
@@ -0,0 +1,67 @@
1
+ require "active_support/core_ext/integer/time"
2
+
3
+ # The test environment is used exclusively to run your application's
4
+ # test suite. You never need to work with it otherwise. Remember that
5
+ # your test database is "scratch space" for the test suite and is wiped
6
+ # and recreated between test runs. Don't rely on the data there!
7
+
8
+ Rails.application.configure do
9
+ # Settings specified here will take precedence over those in config/application.rb.
10
+
11
+ # While tests run files are not watched, reloading is not necessary.
12
+ config.enable_reloading = false
13
+
14
+ # Eager loading loads your entire application. When running a single test locally,
15
+ # this is usually not necessary, and can slow down your test suite. However, it's
16
+ # recommended that you enable it in continuous integration systems to ensure eager
17
+ # loading is working properly before deploying your code.
18
+ config.eager_load = ENV["CI"].present?
19
+
20
+ # Configure public file server for tests with Cache-Control for performance.
21
+ config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" }
22
+
23
+ # Show full error reports and disable caching.
24
+ config.consider_all_requests_local = true
25
+ config.action_controller.perform_caching = false
26
+ config.cache_store = :null_store
27
+
28
+ # Render exception templates for rescuable exceptions and raise for other exceptions.
29
+ config.action_dispatch.show_exceptions = :rescuable
30
+
31
+ # Disable request forgery protection in test environment.
32
+ config.action_controller.allow_forgery_protection = false
33
+
34
+ # Store uploaded files on the local file system in a temporary directory.
35
+ config.active_storage.service = :test
36
+
37
+ # Disable caching for Action Mailer templates even if Action Controller
38
+ # caching is enabled.
39
+ config.action_mailer.perform_caching = false
40
+
41
+ # Tell Action Mailer not to deliver emails to the real world.
42
+ # The :test delivery method accumulates sent emails in the
43
+ # ActionMailer::Base.deliveries array.
44
+ config.action_mailer.delivery_method = :test
45
+
46
+ # Unlike controllers, the mailer instance doesn't have any context about the
47
+ # incoming request so you'll need to provide the :host parameter yourself.
48
+ config.action_mailer.default_url_options = { host: "www.example.com" }
49
+
50
+ # Print deprecation notices to the stderr.
51
+ config.active_support.deprecation = :stderr
52
+
53
+ # Raise exceptions for disallowed deprecations.
54
+ config.active_support.disallowed_deprecation = :raise
55
+
56
+ # Tell Active Support which deprecation messages to disallow.
57
+ config.active_support.disallowed_deprecation_warnings = []
58
+
59
+ # Raises error for missing translations.
60
+ # config.i18n.raise_on_missing_translations = true
61
+
62
+ # Annotate rendered view with file names.
63
+ # config.action_view.annotate_rendered_view_with_filenames = true
64
+
65
+ # Raise error when a before_action's only/except options reference missing actions.
66
+ config.action_controller.raise_on_missing_callback_actions = true
67
+ end
@@ -0,0 +1,11 @@
1
+ Rails.application.config.middleware.insert_before 0, Rack::Cors do
2
+ allow do
3
+ origins ENV.fetch("ALLOWED_ORIGINS", "http://localhost:8080,http://localhost:3000,http://127.0.0.1:3000,http://127.0.0.1:8080").split(",")
4
+
5
+ resource "/api/*",
6
+ headers: :any,
7
+ methods: %i[get post put patch delete options head],
8
+ credentials: true,
9
+ expose: ["Authorization"]
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
4
+ # Use this to limit dissemination of sensitive information.
5
+ # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
6
+ Rails.application.config.filter_parameters += [
7
+ :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
8
+ ]
@@ -0,0 +1,16 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # Add new inflection rules using the following format. Inflections
4
+ # are locale specific, and you may define rules for as many different
5
+ # locales as you wish. All of these examples are active by default:
6
+ # ActiveSupport::Inflector.inflections(:en) do |inflect|
7
+ # inflect.plural /^(ox)$/i, "\\1en"
8
+ # inflect.singular /^(ox)en/i, "\\1"
9
+ # inflect.irregular "person", "people"
10
+ # inflect.uncountable %w( fish sheep )
11
+ # end
12
+
13
+ # These inflection rules are supported but not enabled by default:
14
+ # ActiveSupport::Inflector.inflections(:en) do |inflect|
15
+ # inflect.acronym "RESTful"
16
+ # end
@@ -0,0 +1,22 @@
1
+ Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
2
+ url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
3
+ )
4
+
5
+ # Throttle login attempts: 5 per minute per IP
6
+ Rack::Attack.throttle("logins/ip", limit: 5, period: 60) do |req|
7
+ req.ip if req.path == "/api/v1/sessions" && req.post?
8
+ end
9
+
10
+ # Throttle by email: 10 per hour per email address
11
+ Rack::Attack.throttle("logins/email", limit: 10, period: 3600) do |req|
12
+ if req.path == "/api/v1/sessions" && req.post?
13
+ body = JSON.parse(req.body.read) rescue {}
14
+ req.body.rewind
15
+ body["email"]&.downcase
16
+ end
17
+ end
18
+
19
+ Rack::Attack.throttled_responder = lambda do |_req|
20
+ [429, { "Content-Type" => "application/json" },
21
+ ['{"error":"Too many login attempts. Please try again later."}']]
22
+ end
@@ -0,0 +1,7 @@
1
+ Sidekiq.configure_server do |config|
2
+ config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
3
+ end
4
+
5
+ Sidekiq.configure_client do |config|
6
+ config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
7
+ end
@@ -0,0 +1,6 @@
1
+ # Prevent Rails from reloading when Vite rebuilds frontend files
2
+ Rails.application.configure do
3
+ config.watchable_dirs["app"] = [:rb]
4
+ config.watchable_dirs["config"] = [:rb, :yml]
5
+ config.watchable_dirs["lib"] = [:rb]
6
+ end
@@ -0,0 +1,31 @@
1
+ # Files in the config/locales directory are used for internationalization and
2
+ # are automatically loaded by Rails. If you want to use locales other than
3
+ # English, add the necessary files in this directory.
4
+ #
5
+ # To use the locales, use `I18n.t`:
6
+ #
7
+ # I18n.t "hello"
8
+ #
9
+ # In views, this is aliased to just `t`:
10
+ #
11
+ # <%= t("hello") %>
12
+ #
13
+ # To use a different locale, set it with `I18n.locale`:
14
+ #
15
+ # I18n.locale = :es
16
+ #
17
+ # This would use the information in config/locales/es.yml.
18
+ #
19
+ # To learn more about the API, please read the Rails Internationalization guide
20
+ # at https://guides.rubyonrails.org/i18n.html.
21
+ #
22
+ # Be aware that YAML interprets the following case-insensitive strings as
23
+ # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
24
+ # must be quoted to be interpreted as strings. For example:
25
+ #
26
+ # en:
27
+ # "yes": yup
28
+ # enabled: "ON"
29
+
30
+ en:
31
+ hello: "Hello world"
data/config/master.key ADDED
@@ -0,0 +1 @@
1
+ 56c8d0bb14ec111a4bf435d4f653ab49
data/config/puma.rb ADDED
@@ -0,0 +1,34 @@
1
+ # This configuration file will be evaluated by Puma. The top-level methods that
2
+ # are invoked here are part of Puma's configuration DSL. For more information
3
+ # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
4
+
5
+ # Puma starts a configurable number of processes (workers) and each process
6
+ # serves each request in a thread from an internal thread pool.
7
+ #
8
+ # The ideal number of threads per worker depends both on how much time the
9
+ # application spends waiting for IO operations and on how much you wish to
10
+ # to prioritize throughput over latency.
11
+ #
12
+ # As a rule of thumb, increasing the number of threads will increase how much
13
+ # traffic a given process can handle (throughput), but due to CRuby's
14
+ # Global VM Lock (GVL) it has diminishing returns and will degrade the
15
+ # response time (latency) of the application.
16
+ #
17
+ # The default is set to 3 threads as it's deemed a decent compromise between
18
+ # throughput and latency for the average Rails application.
19
+ #
20
+ # Any libraries that use a connection pool or another resource pool should
21
+ # be configured to provide at least as many connections as the number of
22
+ # threads. This includes Active Record's `pool` parameter in `database.yml`.
23
+ threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
24
+ threads threads_count, threads_count
25
+
26
+ # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
27
+ port ENV.fetch("PORT", 3000)
28
+
29
+ # Allow puma to be restarted by `bin/rails restart` command.
30
+ plugin :tmp_restart
31
+
32
+ # Specify the PID file. Defaults to tmp/pids/server.pid in development.
33
+ # In other environments, only set the PID file if requested.
34
+ pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
data/config/routes.rb ADDED
@@ -0,0 +1,42 @@
1
+ Rails.application.routes.draw do
2
+ namespace :api do
3
+ namespace :v1 do
4
+ # Auth
5
+ post "sessions", to: "sessions#create"
6
+ delete "sessions", to: "sessions#destroy"
7
+
8
+ # Current user
9
+ get "me", to: "me#show"
10
+ get "me/badges", to: "me#badges"
11
+ get "me/points_ledger", to: "me#points_ledger"
12
+
13
+ # Badges
14
+ resources :badges, only: %i[index create update destroy]
15
+
16
+ # Merchandise
17
+ resources :merch_items, only: %i[index create update destroy]
18
+ resources :redemptions, only: %i[create]
19
+
20
+ # Organization
21
+ resource :organization, only: %i[show update]
22
+
23
+ # PCO sync
24
+ resources :sync_logs, only: %i[index]
25
+ post "sync", to: "sync#create"
26
+
27
+ # Admin
28
+ namespace :admin do
29
+ get "stats", to: "stats#show"
30
+ resources :members, only: %i[index]
31
+ end
32
+ end
33
+ end
34
+
35
+ # PCO OAuth
36
+ get "auth/pco", to: "auth/pco#authorize"
37
+ get "auth/pco/callback", to: "auth/pco#callback"
38
+
39
+ # Catch-all: let Vite / React Router handle everything else
40
+ get "*path", to: "application#frontend", constraints: ->(req) { !req.xhr? && req.format.html? }
41
+ root to: "application#frontend"
42
+ end
@@ -0,0 +1,34 @@
1
+ test:
2
+ service: Disk
3
+ root: <%= Rails.root.join("tmp/storage") %>
4
+
5
+ local:
6
+ service: Disk
7
+ root: <%= Rails.root.join("storage") %>
8
+
9
+ # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10
+ # amazon:
11
+ # service: S3
12
+ # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13
+ # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14
+ # region: us-east-1
15
+ # bucket: your_own_bucket-<%= Rails.env %>
16
+
17
+ # Remember not to checkin your GCS keyfile to a repository
18
+ # google:
19
+ # service: GCS
20
+ # project: your_project
21
+ # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22
+ # bucket: your_own_bucket-<%= Rails.env %>
23
+
24
+ # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25
+ # microsoft:
26
+ # service: AzureStorage
27
+ # storage_account_name: your_account_name
28
+ # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29
+ # container: your_container_name-<%= Rails.env %>
30
+
31
+ # mirror:
32
+ # service: Mirror
33
+ # primary: local
34
+ # mirrors: [ amazon, google, microsoft ]
data/config/vite.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "all": {
3
+ "sourceCodeDir": "app/frontend",
4
+ "watchAdditionalPaths": []
5
+ },
6
+ "development": {
7
+ "autoBuild": true,
8
+ "publicOutputDir": "vite-dev",
9
+ "port": 3036
10
+ },
11
+ "test": {
12
+ "autoBuild": true,
13
+ "publicOutputDir": "vite-test",
14
+ "port": 3037
15
+ }
16
+ }
@@ -0,0 +1,92 @@
1
+ class CreateInitialSchema < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :organizations do |t|
4
+ t.string :name, null: false
5
+ t.string :subdomain, null: false, index: { unique: true }
6
+ t.integer :points_per_sunday, default: 25, null: false
7
+ t.integer :points_per_wednesday, default: 15, null: false
8
+ t.integer :points_per_volunteer_hour, default: 50, null: false
9
+ t.integer :points_per_special_event, default: 100, null: false
10
+ # PCO OAuth — stored encrypted via ActiveRecord::Encryption
11
+ t.string :pco_client_id
12
+ t.string :pco_client_secret
13
+ t.string :pco_access_token
14
+ t.string :pco_refresh_token
15
+ t.timestamps
16
+ end
17
+
18
+ create_table :users do |t|
19
+ t.references :organization, null: false, foreign_key: true
20
+ t.string :name, null: false
21
+ t.string :email, null: false
22
+ t.string :password_digest
23
+ t.string :role, default: "member", null: false
24
+ t.string :avatar_url
25
+ t.string :pco_person_id, index: true
26
+ t.timestamps
27
+ end
28
+ add_index :users, %i[organization_id email], unique: true
29
+
30
+ create_table :badges do |t|
31
+ t.references :organization, null: false, foreign_key: true
32
+ t.string :name, null: false
33
+ t.text :description
34
+ t.string :category, null: false
35
+ t.string :criteria
36
+ t.string :criteria_type, null: false
37
+ t.integer :threshold, default: 1, null: false
38
+ t.string :color
39
+ t.string :icon
40
+ t.boolean :active, default: true, null: false
41
+ t.integer :total_awarded, default: 0, null: false
42
+ t.timestamps
43
+ end
44
+
45
+ create_table :badge_awards do |t|
46
+ t.references :user, null: false, foreign_key: true
47
+ t.references :badge, null: false, foreign_key: true
48
+ t.date :earned_date, null: false
49
+ t.timestamps
50
+ end
51
+ add_index :badge_awards, %i[user_id badge_id], unique: true
52
+
53
+ create_table :merch_items do |t|
54
+ t.references :organization, null: false, foreign_key: true
55
+ t.string :name, null: false
56
+ t.text :description
57
+ t.integer :point_cost, null: false
58
+ t.integer :inventory_count, default: 0, null: false
59
+ t.string :image_url
60
+ t.string :status, default: "available", null: false
61
+ t.timestamps
62
+ end
63
+
64
+ create_table :redemptions do |t|
65
+ t.references :user, null: false, foreign_key: true
66
+ t.references :merch_item, null: false, foreign_key: true
67
+ t.string :status, default: "pending", null: false
68
+ t.timestamps
69
+ end
70
+
71
+ create_table :points_ledger_entries do |t|
72
+ t.references :user, null: false, foreign_key: true
73
+ t.date :date, null: false
74
+ t.string :source, null: false
75
+ t.string :transaction_type, null: false
76
+ t.integer :points, null: false
77
+ t.string :pco_event_id
78
+ t.timestamps
79
+ end
80
+ add_index :points_ledger_entries, %i[user_id pco_event_id], unique: true, where: "pco_event_id IS NOT NULL"
81
+
82
+ create_table :sync_logs do |t|
83
+ t.references :organization, null: false, foreign_key: true
84
+ t.datetime :timestamp, null: false
85
+ t.string :event_type, null: false
86
+ t.integer :members_affected, default: 0
87
+ t.string :status, null: false
88
+ t.text :details
89
+ t.timestamps
90
+ end
91
+ end
92
+ end
data/db/schema.rb ADDED
@@ -0,0 +1,133 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # This file is the source Rails uses to define your schema when running `bin/rails
6
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema[7.2].define(version: 2026_03_27_000001) do
14
+ # These are extensions that must be enabled in order to support this database
15
+ enable_extension "plpgsql"
16
+
17
+ create_table "badge_awards", force: :cascade do |t|
18
+ t.bigint "user_id", null: false
19
+ t.bigint "badge_id", null: false
20
+ t.date "earned_date", null: false
21
+ t.datetime "created_at", null: false
22
+ t.datetime "updated_at", null: false
23
+ t.index ["badge_id"], name: "index_badge_awards_on_badge_id"
24
+ t.index ["user_id", "badge_id"], name: "index_badge_awards_on_user_id_and_badge_id", unique: true
25
+ t.index ["user_id"], name: "index_badge_awards_on_user_id"
26
+ end
27
+
28
+ create_table "badges", force: :cascade do |t|
29
+ t.bigint "organization_id", null: false
30
+ t.string "name", null: false
31
+ t.text "description"
32
+ t.string "category", null: false
33
+ t.string "criteria"
34
+ t.string "criteria_type", null: false
35
+ t.integer "threshold", default: 1, null: false
36
+ t.string "color"
37
+ t.string "icon"
38
+ t.boolean "active", default: true, null: false
39
+ t.integer "total_awarded", default: 0, null: false
40
+ t.datetime "created_at", null: false
41
+ t.datetime "updated_at", null: false
42
+ t.index ["organization_id"], name: "index_badges_on_organization_id"
43
+ end
44
+
45
+ create_table "merch_items", force: :cascade do |t|
46
+ t.bigint "organization_id", null: false
47
+ t.string "name", null: false
48
+ t.text "description"
49
+ t.integer "point_cost", null: false
50
+ t.integer "inventory_count", default: 0, null: false
51
+ t.string "image_url"
52
+ t.string "status", default: "available", null: false
53
+ t.datetime "created_at", null: false
54
+ t.datetime "updated_at", null: false
55
+ t.index ["organization_id"], name: "index_merch_items_on_organization_id"
56
+ end
57
+
58
+ create_table "organizations", force: :cascade do |t|
59
+ t.string "name", null: false
60
+ t.string "subdomain", null: false
61
+ t.integer "points_per_sunday", default: 25, null: false
62
+ t.integer "points_per_wednesday", default: 15, null: false
63
+ t.integer "points_per_volunteer_hour", default: 50, null: false
64
+ t.integer "points_per_special_event", default: 100, null: false
65
+ t.string "pco_client_id"
66
+ t.string "pco_client_secret"
67
+ t.string "pco_access_token"
68
+ t.string "pco_refresh_token"
69
+ t.datetime "created_at", null: false
70
+ t.datetime "updated_at", null: false
71
+ t.index ["subdomain"], name: "index_organizations_on_subdomain", unique: true
72
+ end
73
+
74
+ create_table "points_ledger_entries", force: :cascade do |t|
75
+ t.bigint "user_id", null: false
76
+ t.date "date", null: false
77
+ t.string "source", null: false
78
+ t.string "transaction_type", null: false
79
+ t.integer "points", null: false
80
+ t.string "pco_event_id"
81
+ t.datetime "created_at", null: false
82
+ t.datetime "updated_at", null: false
83
+ t.index ["user_id", "pco_event_id"], name: "index_points_ledger_entries_on_user_id_and_pco_event_id", unique: true, where: "(pco_event_id IS NOT NULL)"
84
+ t.index ["user_id"], name: "index_points_ledger_entries_on_user_id"
85
+ end
86
+
87
+ create_table "redemptions", force: :cascade do |t|
88
+ t.bigint "user_id", null: false
89
+ t.bigint "merch_item_id", null: false
90
+ t.string "status", default: "pending", null: false
91
+ t.datetime "created_at", null: false
92
+ t.datetime "updated_at", null: false
93
+ t.index ["merch_item_id"], name: "index_redemptions_on_merch_item_id"
94
+ t.index ["user_id"], name: "index_redemptions_on_user_id"
95
+ end
96
+
97
+ create_table "sync_logs", force: :cascade do |t|
98
+ t.bigint "organization_id", null: false
99
+ t.datetime "timestamp", null: false
100
+ t.string "event_type", null: false
101
+ t.integer "members_affected", default: 0
102
+ t.string "status", null: false
103
+ t.text "details"
104
+ t.datetime "created_at", null: false
105
+ t.datetime "updated_at", null: false
106
+ t.index ["organization_id"], name: "index_sync_logs_on_organization_id"
107
+ end
108
+
109
+ create_table "users", force: :cascade do |t|
110
+ t.bigint "organization_id", null: false
111
+ t.string "name", null: false
112
+ t.string "email", null: false
113
+ t.string "password_digest"
114
+ t.string "role", default: "member", null: false
115
+ t.string "avatar_url"
116
+ t.string "pco_person_id"
117
+ t.datetime "created_at", null: false
118
+ t.datetime "updated_at", null: false
119
+ t.index ["organization_id", "email"], name: "index_users_on_organization_id_and_email", unique: true
120
+ t.index ["organization_id"], name: "index_users_on_organization_id"
121
+ t.index ["pco_person_id"], name: "index_users_on_pco_person_id"
122
+ end
123
+
124
+ add_foreign_key "badge_awards", "badges"
125
+ add_foreign_key "badge_awards", "users"
126
+ add_foreign_key "badges", "organizations"
127
+ add_foreign_key "merch_items", "organizations"
128
+ add_foreign_key "points_ledger_entries", "users"
129
+ add_foreign_key "redemptions", "merch_items"
130
+ add_foreign_key "redemptions", "users"
131
+ add_foreign_key "sync_logs", "organizations"
132
+ add_foreign_key "users", "organizations"
133
+ end
data/db/seeds.rb ADDED
@@ -0,0 +1,70 @@
1
+ org = Organization.find_or_create_by!(subdomain: "jubilee") do |o|
2
+ o.name = "Jubilee Christian Center"
3
+ o.points_per_sunday = 25
4
+ o.points_per_wednesday = 15
5
+ o.points_per_volunteer_hour = 50
6
+ o.points_per_special_event = 100
7
+ end
8
+
9
+ admin = User.find_or_create_by!(email: ENV.fetch("SEED_ADMIN_EMAIL", "admin@jubileechurch.com"), organization: org) do |u|
10
+ u.name = "Chad Smith"
11
+ u.password = ENV.fetch("SEED_ADMIN_PASSWORD") { raise "Set SEED_ADMIN_PASSWORD env var before seeding" }
12
+ u.role = "admin"
13
+ end
14
+
15
+ # Badges
16
+ badge_data = [
17
+ { name: "First Timer", category: "Attendance", criteria: "Attend 1 service", criteria_type: "attendance_count", threshold: 1, color: "#4CAF50", icon: "Sparkles", total_awarded: 342 },
18
+ { name: "Faithful Five", category: "Attendance", criteria: "Attend 5 services", criteria_type: "attendance_count", threshold: 5, color: "#2196F3", icon: "Star", total_awarded: 218 },
19
+ { name: "Decade of Devotion", category: "Attendance", criteria: "Attend 10 services", criteria_type: "attendance_count", threshold: 10, color: "#FF9800", icon: "Crown", total_awarded: 156 },
20
+ { name: "Volunteer Hero", category: "Volunteering", criteria: "Volunteer 5 times", criteria_type: "volunteer_hours", threshold: 5, color: "#E91E63", icon: "Heart", total_awarded: 89 },
21
+ { name: "Easter Champion", category: "Special", criteria: "Attend Easter service", criteria_type: "manual", threshold: 1, color: "#FFEB3B", icon: "Sun", total_awarded: 278 },
22
+ { name: "New Member", category: "Milestone", criteria: "Complete membership class", criteria_type: "manual", threshold: 1, color: "#607D8B", icon: "GraduationCap", total_awarded: 198 },
23
+ { name: "Community Builder", category: "Milestone", criteria: "Join a small group", criteria_type: "manual", threshold: 1, color: "#3F51B5", icon: "Users", total_awarded: 167 },
24
+ { name: "Gold Standard", category: "Attendance", criteria: "Attend 52 services in a year", criteria_type: "attendance_count", threshold: 52, color: "#FFC107", icon: "Trophy", total_awarded: 8 },
25
+ ]
26
+
27
+ badges = badge_data.map do |attrs|
28
+ org.badges.find_or_create_by!(name: attrs[:name]) do |b|
29
+ b.assign_attributes(attrs.merge(active: true))
30
+ end
31
+ end
32
+
33
+ # Award a few badges to the admin user so the dashboard isn't empty
34
+ [badges[0], badges[1], badges[5]].each do |badge|
35
+ admin.badge_awards.find_or_create_by!(badge: badge) do |a|
36
+ a.earned_date = rand(60).days.ago.to_date
37
+ end
38
+ end
39
+
40
+ # Points ledger entries
41
+ [
42
+ { date: 7.days.ago.to_date, source: "Sunday Service Attendance", transaction_type: "attendance", points: 25 },
43
+ { date: 14.days.ago.to_date, source: "Wednesday Bible Study", transaction_type: "attendance", points: 15 },
44
+ { date: 21.days.ago.to_date, source: "Sunday Service Attendance", transaction_type: "attendance", points: 25 },
45
+ { date: 22.days.ago.to_date, source: "Volunteer: Greeting Team", transaction_type: "volunteering", points: 50 },
46
+ { date: 28.days.ago.to_date, source: "Sunday Service Attendance", transaction_type: "attendance", points: 25 },
47
+ { date: 35.days.ago.to_date, source: "Special Event: Church Picnic", transaction_type: "special", points: 100 },
48
+ ].each do |attrs|
49
+ admin.points_ledger_entries.find_or_create_by!(
50
+ date: attrs[:date], source: attrs[:source]
51
+ ) { |e| e.assign_attributes(attrs) }
52
+ end
53
+
54
+ # Merch items
55
+ [
56
+ { name: "Church Hoodie", description: "Cozy branded hoodie in navy blue. Available in S-XXL.", point_cost: 500, inventory_count: 24, status: "available" },
57
+ { name: "Coffee Mug", description: "Ceramic mug with the Jubilee logo. 12oz capacity.", point_cost: 150, inventory_count: 50, status: "available" },
58
+ { name: "Branded Tote Bag", description: "Sturdy canvas tote bag with church branding.", point_cost: 200, inventory_count: 35, status: "available" },
59
+ { name: "Kids Ministry T-Shirt", description: "Fun colorful t-shirt for kids ministry volunteers.", point_cost: 175, inventory_count: 5, status: "low_stock" },
60
+ { name: "Devotional Journal", description: "Leather-bound journal with daily devotional prompts.", point_cost: 125, inventory_count: 42, status: "available" },
61
+ { name: "Premium Parking Pass", description: "Reserved parking spot for 3 months.", point_cost: 300, inventory_count: 0, status: "out_of_stock" },
62
+ ].each do |attrs|
63
+ org.merch_items.find_or_create_by!(name: attrs[:name]) { |i| i.assign_attributes(attrs) }
64
+ end
65
+
66
+ puts "Seeded org: #{org.name}"
67
+ puts "Admin: #{admin.email}"
68
+ puts "Badges: #{org.badges.count}"
69
+ puts "Merch items: #{org.merch_items.count}"
70
+ puts "Points entries: #{admin.points_ledger_entries.count}"
@@ -0,0 +1,16 @@
1
+ require "rails/engine"
2
+
3
+ module Churchcred
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Churchcred
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ # Make engine models, controllers, jobs etc. available to host app
12
+ initializer "churchcred.assets" do |app|
13
+ app.config.assets.paths << root.join("app/assets") if app.config.respond_to?(:assets)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ module Churchcred
2
+ VERSION = "0.1.0"
3
+ end
data/lib/churchcred.rb ADDED
@@ -0,0 +1,6 @@
1
+ require "churchcred/engine"
2
+
3
+ module Churchcred
4
+ # Host apps can override these configuration values
5
+ mattr_accessor :saas_mode, default: false
6
+ end