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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bd74f33e298a9cb487e6465ec99157867f8c2eccdb92aa8fca67dbab77d9527b
4
+ data.tar.gz: 2f60880eb8289cb3248ebacad4f817a7ea730e68641e91d5e1cb94f8f0f1f580
5
+ SHA512:
6
+ metadata.gz: 4a8e4f85b69bd8de2a64ec525ef128ada868352a2725bbdcff8120fda93d87a00114614dd4cea2275953c385af5c8fb68993ac5e5bb2228689603c81a7017c0d
7
+ data.tar.gz: 28f6f39f3e80b2411770648333ad96991b9a33030450a2c9bce4de09d6ce36fb321d69f061f0d59389b01d201cd11d14848e689761e4e2d08b0724abcaf62dbb
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chad Singleton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Welcome to your Lovable project
2
+
3
+ TODO: Document your project here
@@ -0,0 +1,36 @@
1
+ class BadgeBlueprint < Blueprinter::Base
2
+ identifier :id
3
+
4
+ fields :name, :description, :category, :criteria, :criteria_type,
5
+ :threshold, :color, :icon, :active, :total_awarded
6
+
7
+ # When rendering a member's earned badges, pass awards: BadgeAward collection
8
+ view :with_earned do
9
+ field :earned do |badge, options|
10
+ options[:awards]&.any? { |a| a.badge_id == badge.id }
11
+ end
12
+
13
+ field :earned_date do |badge, options|
14
+ options[:awards]&.find { |a| a.badge_id == badge.id }&.earned_date
15
+ end
16
+
17
+ field :progress do |badge, options|
18
+ options[:awards]&.any? { |a| a.badge_id == badge.id } ? badge.threshold : 0
19
+ end
20
+ end
21
+
22
+ # When rendering all badges for a member (index), pass user: User
23
+ view :with_progress do
24
+ field :earned do |badge, options|
25
+ badge.badge_awards.where(user: options[:user]).exists?
26
+ end
27
+
28
+ field :earned_date do |badge, options|
29
+ badge.badge_awards.find_by(user: options[:user])&.earned_date
30
+ end
31
+
32
+ field :progress do |badge, options|
33
+ badge.progress_for(options[:user])
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ class MerchItemBlueprint < Blueprinter::Base
2
+ identifier :id
3
+
4
+ fields :name, :description, :point_cost, :inventory_count, :image_url, :status
5
+ end
@@ -0,0 +1,11 @@
1
+ class OrganizationBlueprint < Blueprinter::Base
2
+ identifier :id
3
+
4
+ fields :name, :subdomain,
5
+ :points_per_sunday, :points_per_wednesday,
6
+ :points_per_volunteer_hour, :points_per_special_event
7
+
8
+ view :compact do
9
+ fields :name, :subdomain
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ class PointsLedgerEntryBlueprint < Blueprinter::Base
2
+ identifier :id
3
+
4
+ fields :date, :source, :points
5
+
6
+ field :type do |entry|
7
+ entry.transaction_type
8
+ end
9
+
10
+ # Running balance is computed client-side from ledger order;
11
+ # expose a balance field so the front-end doesn't have to change
12
+ field :balance do |entry|
13
+ entry.user.points_ledger_entries
14
+ .where("date <= ?", entry.date)
15
+ .where("id <= ?", entry.id)
16
+ .sum(:points)
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ class RedemptionBlueprint < Blueprinter::Base
2
+ identifier :id
3
+
4
+ field :merch_item_id
5
+ field :status
6
+ field :created_at
7
+ end
@@ -0,0 +1,5 @@
1
+ class SyncLogBlueprint < Blueprinter::Base
2
+ identifier :id
3
+
4
+ fields :timestamp, :event_type, :members_affected, :status, :details
5
+ end
@@ -0,0 +1,32 @@
1
+ class UserBlueprint < Blueprinter::Base
2
+ identifier :id
3
+
4
+ fields :name, :email, :role, :avatar_url, :pco_person_id
5
+
6
+ field :total_points do |user|
7
+ user.points_ledger_entries.sum(:points)
8
+ end
9
+
10
+ view :with_org do
11
+ field :org do |user|
12
+ OrganizationBlueprint.render_as_hash(user.organization, view: :compact)
13
+ end
14
+ end
15
+
16
+ # Used by /admin/members
17
+ view :admin_list do
18
+ fields :name, :email, :avatar_url, :pco_person_id
19
+
20
+ field :total_points do |user|
21
+ user.points_ledger_entries.sum(:points)
22
+ end
23
+
24
+ field :badges_earned do |user|
25
+ user.badge_awards.count
26
+ end
27
+
28
+ field :last_sync_date do |user|
29
+ user.points_ledger_entries.maximum(:date)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Channel < ActionCable::Channel::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Connection < ActionCable::Connection::Base
3
+ end
4
+ end
@@ -0,0 +1,20 @@
1
+ module Api
2
+ module V1
3
+ module Admin
4
+ class MembersController < BaseController
5
+ before_action :require_admin!
6
+
7
+ def index
8
+ members = current_user.organization.users
9
+ .order(:name)
10
+ .page(params[:page])
11
+ .per(params[:per_page] || 50)
12
+ render json: UserBlueprint.render(members, view: :admin_list)
13
+ end
14
+
15
+ private
16
+
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,45 @@
1
+ module Api
2
+ module V1
3
+ module Admin
4
+ class StatsController < BaseController
5
+ before_action :require_admin!
6
+
7
+ def show
8
+ org = current_user.organization
9
+
10
+ render json: {
11
+ total_members: org.users.count,
12
+ badges_awarded_this_month: BadgeAward.joins(:badge)
13
+ .where(badges: { organization: org })
14
+ .where(earned_date: Time.current.all_month)
15
+ .count,
16
+ points_distributed_this_month: PointsLedgerEntry.joins(:user)
17
+ .where(users: { organization: org })
18
+ .where(date: Time.current.all_month)
19
+ .sum(:points),
20
+ pending_redemptions: Redemption.joins(merch_item: :organization)
21
+ .where(merch_items: { organization: org })
22
+ .where(status: "pending")
23
+ .count,
24
+ attendance_trend: attendance_trend(org)
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def attendance_trend(org)
31
+ 6.downto(0).map do |months_ago|
32
+ date = months_ago.months.ago
33
+ count = PointsLedgerEntry.joins(:user)
34
+ .where(users: { organization: org })
35
+ .where(transaction_type: "attendance")
36
+ .where(date: date.all_month)
37
+ .count
38
+ { month: date.strftime("%b"), count: count }
39
+ end.reverse
40
+ end
41
+
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ module Api
2
+ module V1
3
+ class BadgesController < BaseController
4
+ before_action :require_admin!, only: %i[create update destroy]
5
+
6
+ def index
7
+ badges = current_user.organization.badges.active
8
+ render json: BadgeBlueprint.render(badges, view: :with_progress, user: current_user)
9
+ end
10
+
11
+ def create
12
+ badge = current_user.organization.badges.build(badge_params)
13
+ if badge.save
14
+ render json: BadgeBlueprint.render(badge), status: :created
15
+ else
16
+ render json: { errors: badge.errors.full_messages }, status: :unprocessable_entity
17
+ end
18
+ end
19
+
20
+ def update
21
+ badge = current_user.organization.badges.find(params[:id])
22
+ if badge.update(badge_params)
23
+ render json: BadgeBlueprint.render(badge)
24
+ else
25
+ render json: { errors: badge.errors.full_messages }, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def destroy
30
+ current_user.organization.badges.find(params[:id]).destroy
31
+ head :no_content
32
+ end
33
+
34
+ private
35
+
36
+ def badge_params
37
+ params.require(:badge).permit(:name, :description, :category, :criteria,
38
+ :criteria_type, :threshold, :color, :icon, :active)
39
+ end
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,27 @@
1
+ module Api
2
+ module V1
3
+ class BaseController < ActionController::API
4
+ before_action :authenticate!
5
+
6
+ private
7
+
8
+ def authenticate!
9
+ token = request.headers["Authorization"]&.split(" ")&.last
10
+ payload = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: "HS256").first
11
+ @current_user = User.find(payload["user_id"])
12
+ rescue JWT::DecodeError, ActiveRecord::RecordNotFound
13
+ render json: { error: "Unauthorized" }, status: :unauthorized
14
+ end
15
+
16
+ def current_user = @current_user
17
+
18
+ def require_admin!
19
+ render json: { error: "Forbidden" }, status: :forbidden unless current_user.admin?
20
+ end
21
+
22
+ def render_json(blueprint, object, **opts)
23
+ render json: blueprint.render(object, **opts)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ module Api
2
+ module V1
3
+ class MeController < BaseController
4
+ def show
5
+ render json: UserBlueprint.render(current_user, view: :with_org)
6
+ end
7
+
8
+ def badges
9
+ awards = current_user.badge_awards.includes(:badge).order(earned_date: :desc)
10
+ render json: BadgeBlueprint.render(awards.map(&:badge), view: :with_earned, awards: awards)
11
+ end
12
+
13
+ def points_ledger
14
+ entries = current_user.points_ledger_entries.order(date: :desc)
15
+ render json: PointsLedgerEntryBlueprint.render(entries)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,43 @@
1
+ module Api
2
+ module V1
3
+ class MerchItemsController < BaseController
4
+ before_action :require_admin!, only: %i[create update destroy]
5
+
6
+ def index
7
+ items = current_user.organization.merch_items.order(:name)
8
+ render json: MerchItemBlueprint.render(items)
9
+ end
10
+
11
+ def create
12
+ item = current_user.organization.merch_items.build(item_params)
13
+ if item.save
14
+ render json: MerchItemBlueprint.render(item), status: :created
15
+ else
16
+ render json: { errors: item.errors.full_messages }, status: :unprocessable_entity
17
+ end
18
+ end
19
+
20
+ def update
21
+ item = current_user.organization.merch_items.find(params[:id])
22
+ if item.update(item_params)
23
+ render json: MerchItemBlueprint.render(item)
24
+ else
25
+ render json: { errors: item.errors.full_messages }, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def destroy
30
+ current_user.organization.merch_items.find(params[:id]).destroy
31
+ head :no_content
32
+ end
33
+
34
+ private
35
+
36
+ def item_params
37
+ params.require(:merch_item).permit(:name, :description, :point_cost,
38
+ :inventory_count, :image_url, :status)
39
+ end
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,29 @@
1
+ module Api
2
+ module V1
3
+ class OrganizationsController < BaseController
4
+ before_action :require_admin!, only: %i[update]
5
+
6
+ def show
7
+ render json: OrganizationBlueprint.render(current_user.organization)
8
+ end
9
+
10
+ def update
11
+ org = current_user.organization
12
+ if org.update(org_params)
13
+ render json: OrganizationBlueprint.render(org)
14
+ else
15
+ render json: { errors: org.errors.full_messages }, status: :unprocessable_entity
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def org_params
22
+ params.require(:organization).permit(:name, :subdomain, :points_per_sunday,
23
+ :points_per_wednesday, :points_per_volunteer_hour,
24
+ :points_per_special_event)
25
+ end
26
+
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,35 @@
1
+ module Api
2
+ module V1
3
+ class RedemptionsController < BaseController
4
+ def create
5
+ item = current_user.organization.merch_items.find(redemption_params[:merch_item_id])
6
+
7
+ if item.out_of_stock?
8
+ return render json: { error: "Item is out of stock" }, status: :unprocessable_entity
9
+ end
10
+
11
+ if current_user.total_points < item.point_cost
12
+ return render json: { error: "Insufficient points" }, status: :unprocessable_entity
13
+ end
14
+
15
+ redemption = current_user.redemptions.build(merch_item: item, status: "pending")
16
+
17
+ ActiveRecord::Base.transaction do
18
+ redemption.save!
19
+ item.decrement!(:inventory_count)
20
+ item.update_status!
21
+ end
22
+
23
+ render json: RedemptionBlueprint.render(redemption), status: :created
24
+ rescue ActiveRecord::RecordInvalid => e
25
+ render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
26
+ end
27
+
28
+ private
29
+
30
+ def redemption_params
31
+ params.require(:redemption).permit(:merch_item_id)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ module Api
2
+ module V1
3
+ class SessionsController < ActionController::API
4
+ def create
5
+ user = User.find_by(email: params[:email])
6
+
7
+ if user&.authenticate(params[:password])
8
+ token = JWT.encode(
9
+ { user_id: user.id, exp: 24.hours.from_now.to_i },
10
+ Rails.application.secret_key_base,
11
+ "HS256"
12
+ )
13
+ render json: { token:, user: UserBlueprint.render_as_hash(user, view: :with_org) }
14
+ else
15
+ render json: { error: "Invalid email or password" }, status: :unauthorized
16
+ end
17
+ end
18
+
19
+ def destroy
20
+ head :no_content
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ module Api
2
+ module V1
3
+ class SyncController < BaseController
4
+ before_action :require_admin!
5
+
6
+ def create
7
+ PcoSyncJob.perform_later(current_user.organization_id)
8
+ render json: { status: "started", timestamp: Time.current.iso8601 }
9
+ end
10
+
11
+ private
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Api
2
+ module V1
3
+ class SyncLogsController < BaseController
4
+ before_action :require_admin!
5
+
6
+ def index
7
+ logs = current_user.organization.sync_logs.order(timestamp: :desc).limit(50)
8
+ render json: SyncLogBlueprint.render(logs)
9
+ end
10
+
11
+ private
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ class ApplicationController < ActionController::Base
2
+ # Serve the Vite/React SPA for any non-API HTML request
3
+ def frontend
4
+ render file: Rails.root.join("public/index.html")
5
+ end
6
+ end
@@ -0,0 +1,59 @@
1
+ module Auth
2
+ class PcoController < ActionController::Base
3
+ PCO_AUTH_URL = "https://api.planningcenteronline.com/oauth/authorize"
4
+ PCO_TOKEN_URL = "https://api.planningcenteronline.com/oauth/token"
5
+
6
+ before_action :require_admin_token!
7
+
8
+ # Step 1: redirect admin to PCO consent screen
9
+ def authorize
10
+ params = {
11
+ client_id: ENV["PCO_CLIENT_ID"],
12
+ redirect_uri: callback_url,
13
+ response_type: "code",
14
+ scope: "people check_ins services"
15
+ }
16
+ redirect_to "#{PCO_AUTH_URL}?#{params.to_query}", allow_other_host: true
17
+ end
18
+
19
+ # Step 2: PCO redirects back here with ?code=...
20
+ def callback
21
+ response = HTTParty.post(PCO_TOKEN_URL, body: {
22
+ grant_type: "authorization_code",
23
+ code: params[:code],
24
+ client_id: ENV["PCO_CLIENT_ID"],
25
+ client_secret: ENV["PCO_CLIENT_SECRET"],
26
+ redirect_uri: callback_url
27
+ })
28
+
29
+ if response.success?
30
+ body = response.parsed_response
31
+ Rails.logger.info "PCO token response keys: #{body.keys}"
32
+ org = Organization.find_by(subdomain: "jubilee") || Organization.first
33
+ org.update!(
34
+ pco_access_token: body["access_token"],
35
+ pco_refresh_token: body["refresh_token"]
36
+ )
37
+ render plain: "✅ PCO connected! Keys received: #{body.keys.join(', ')}. You can close this tab."
38
+ else
39
+ render plain: "❌ OAuth failed: #{response.body}", status: :unprocessable_entity
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def require_admin_token!
46
+ token = request.headers["Authorization"]&.split(" ")&.last ||
47
+ params[:token]
48
+ payload = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: "HS256").first
49
+ user = User.find(payload["user_id"])
50
+ render plain: "Forbidden", status: :forbidden unless user.admin?
51
+ rescue JWT::DecodeError, ActiveRecord::RecordNotFound
52
+ render plain: "Unauthorized", status: :unauthorized
53
+ end
54
+
55
+ def callback_url
56
+ "#{request.base_url}/auth/pco/callback"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,7 @@
1
+ class ApplicationJob < ActiveJob::Base
2
+ # Automatically retry jobs that encountered a deadlock
3
+ # retry_on ActiveRecord::Deadlocked
4
+
5
+ # Most jobs are safe to ignore if the underlying records are no longer available
6
+ # discard_on ActiveJob::DeserializationError
7
+ end