Skip to content

Commit cc9e1dd

Browse files
committed
feat: implement scrims live chat popup
1 parent a5179d8 commit cc9e1dd

File tree

9 files changed

+239
-6
lines changed

9 files changed

+239
-6
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V1
5+
# Organizations Controller
6+
# Allows org admins/owners to update their own organization settings and logo
7+
class OrganizationsController < Api::V1::BaseController
8+
before_action :set_organization
9+
before_action :require_admin_or_owner
10+
11+
# PATCH /api/v1/organizations/:id
12+
def update
13+
if @organization.update(org_params)
14+
render json: {
15+
message: 'Organization updated successfully',
16+
organization: OrganizationSerializer.render_as_hash(@organization)
17+
}, status: :ok
18+
else
19+
render_error(
20+
message: 'Validation failed',
21+
code: 'VALIDATION_ERROR',
22+
status: :unprocessable_entity,
23+
details: @organization.errors.as_json
24+
)
25+
end
26+
end
27+
28+
# POST /api/v1/organizations/:id/logo
29+
def upload_logo
30+
file = params[:file]
31+
32+
unless file
33+
return render_error(
34+
message: 'No file provided',
35+
code: 'MISSING_FILE',
36+
status: :unprocessable_entity
37+
)
38+
end
39+
40+
service = S3UploadService.new
41+
result = service.upload(file, prefix: "orgs/#{@organization.id}/logo")
42+
logo_url = service.signed_url(result[:key], expires_in: 365 * 24 * 3600)
43+
44+
# Store the key so we can regenerate the URL later; expose via signed URL now
45+
@organization.update!(logo_url: logo_url)
46+
47+
render json: {
48+
message: 'Logo uploaded successfully',
49+
logo_url: logo_url
50+
}, status: :ok
51+
rescue ArgumentError => e
52+
render_error(
53+
message: e.message,
54+
code: 'INVALID_FILE',
55+
status: :unprocessable_entity
56+
)
57+
end
58+
59+
private
60+
61+
def set_organization
62+
@organization = current_organization
63+
render_not_found unless @organization
64+
end
65+
66+
def require_admin_or_owner
67+
allowed_roles = %w[admin owner]
68+
unless allowed_roles.include?(@current_user.role)
69+
return render_error(
70+
message: 'Only admins and owners can update organization settings',
71+
code: 'FORBIDDEN',
72+
status: :forbidden
73+
)
74+
end
75+
end
76+
77+
def org_params
78+
params.require(:organization).permit(:name, :region, :public_tagline)
79+
end
80+
end
81+
end
82+
end

app/controllers/api/v1/profile_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def update_notifications
9797
private
9898

9999
def profile_params
100-
params.require(:user).permit(:full_name, :email, :avatar_url, :timezone, :language)
100+
params.require(:user).permit(:full_name, :email, :avatar_url, :timezone, :language, :discord_user_id)
101101
end
102102

103103
def notification_params

app/models/user.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ class User < ApplicationRecord
2727
validates :role, presence: true, inclusion: { in: Constants::User::ROLES }
2828
validates :timezone, length: { maximum: 100 }
2929
validates :language, length: { maximum: 10 }
30+
validates :discord_user_id,
31+
uniqueness: { allow_blank: true },
32+
format: { with: /\A\d{17,20}\z/, message: 'must be a valid Discord user ID (17–20 digits)',
33+
allow_blank: true }
3034
validates :password,
3135
length: { minimum: 8, message: 'must be at least 8 characters' },
3236
format: {

app/modules/core/serializers/user_serializer.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ class UserSerializer < Blueprinter::Base
66
identifier :id
77

88
fields :email, :full_name, :role, :avatar_url, :timezone, :language,
9-
:notifications_enabled, :notification_preferences, :last_login_at,
10-
:created_at, :updated_at
9+
:notifications_enabled, :notification_preferences, :discord_user_id,
10+
:last_login_at, :created_at, :updated_at
1111

1212
field :role_display do |user|
1313
user.full_role_name

app/modules/matchmaking/controllers/scrim_requests_controller.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def create
7878
)
7979

8080
if request.save
81+
DiscordDmService.notify_new_invite(request)
8182
render_created({ scrim_request: ScrimRequestSerializer.render_as_hash(request) },
8283
message: 'Scrim request sent')
8384
else
@@ -158,8 +159,12 @@ def set_request
158159

159160
def notify_discord(event, scrim_request)
160161
case event
161-
when :accepted then DiscordNotificationService.notify_accepted(scrim_request)
162-
when :declined then DiscordNotificationService.notify_declined(scrim_request)
162+
when :accepted
163+
DiscordNotificationService.notify_accepted(scrim_request)
164+
DiscordDmService.notify_accepted(scrim_request)
165+
when :declined
166+
DiscordNotificationService.notify_declined(scrim_request)
167+
DiscordDmService.notify_declined(scrim_request)
163168
end
164169
rescue StandardError => e
165170
Rails.logger.warn "[DiscordNotification] Failed to notify #{event}: #{e.message}"

app/services/discord_dm_service.rb

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# frozen_string_literal: true
2+
3+
# Sends Discord DMs to ProStaff users via the prostaff-discord-bot webhook.
4+
#
5+
# Requires users to have their discord_user_id saved in their profile.
6+
# Only admins and owners of an org receive notifications.
7+
#
8+
# Called for:
9+
# - New scrim invite received → notify target org admins/owners
10+
# - Invite accepted → notify requesting org admins/owners
11+
# - Invite declined → notify requesting org admins/owners
12+
class DiscordDmService
13+
BOT_WEBHOOK_URL = ENV.fetch('DISCORD_BOT_WEBHOOK_URL', nil)
14+
BOT_WEBHOOK_SECRET = ENV.fetch('DISCORD_BOT_WEBHOOK_SECRET', nil)
15+
16+
GOLD = 0xC89B3C
17+
GREEN = 0x00D364
18+
RED = 0xFF4444
19+
20+
def self.notify_new_invite(scrim_request)
21+
notify_org(
22+
org: scrim_request.target_organization,
23+
embed: invite_embed(scrim_request)
24+
)
25+
end
26+
27+
def self.notify_accepted(scrim_request)
28+
notify_org(
29+
org: scrim_request.requesting_organization,
30+
embed: accepted_embed(scrim_request)
31+
)
32+
end
33+
34+
def self.notify_declined(scrim_request)
35+
notify_org(
36+
org: scrim_request.requesting_organization,
37+
embed: declined_embed(scrim_request)
38+
)
39+
end
40+
41+
# ── Private ────────────────────────────────────────────────────────────────
42+
43+
def self.notify_org(org:, embed:)
44+
return unless BOT_WEBHOOK_URL.present?
45+
46+
org.users
47+
.where(role: %w[owner admin])
48+
.where.not(discord_user_id: [nil, ''])
49+
.each do |user|
50+
send_dm(discord_user_id: user.discord_user_id, embed: embed)
51+
end
52+
end
53+
private_class_method :notify_org
54+
55+
def self.send_dm(discord_user_id:, embed:)
56+
payload = {
57+
secret: BOT_WEBHOOK_SECRET,
58+
discord_user_id: discord_user_id,
59+
embed: embed
60+
}
61+
62+
conn = Faraday.new do |f|
63+
f.request :json
64+
f.adapter Faraday.default_adapter
65+
f.options.timeout = 5
66+
end
67+
68+
conn.post("#{BOT_WEBHOOK_URL}/webhooks/dm", payload)
69+
rescue Faraday::Error => e
70+
Rails.logger.warn("[DiscordDmService] DM to #{discord_user_id} failed: #{e.message}")
71+
end
72+
private_class_method :send_dm
73+
74+
def self.invite_embed(req)
75+
proposed = req.proposed_at&.strftime('%d/%m/%Y às %H:%M UTC') || 'A combinar'
76+
77+
fields = [
78+
{ name: 'Adversário', value: req.requesting_organization.name, inline: true },
79+
{ name: 'Data Proposta', value: proposed, inline: true },
80+
{ name: 'Jogos', value: req.games_planned.to_s, inline: true }
81+
]
82+
fields << { name: 'Mensagem', value: req.message, inline: false } if req.message.present?
83+
84+
{
85+
title: '🎮 Novo Convite de Scrim',
86+
color: GOLD,
87+
description: "**#{req.requesting_organization.name}** quer fazer um scrim com vocês!",
88+
fields: fields,
89+
footer: { text: 'scrims.lol — Acesse a plataforma para aceitar ou recusar' },
90+
timestamp: Time.current.iso8601
91+
}
92+
end
93+
private_class_method :invite_embed
94+
95+
def self.accepted_embed(req)
96+
proposed = req.proposed_at&.strftime('%d/%m/%Y às %H:%M UTC') || 'A combinar'
97+
98+
{
99+
title: '✅ Scrim Aceito!',
100+
color: GREEN,
101+
description: "**#{req.target_organization.name}** aceitou seu pedido de scrim.",
102+
fields: [
103+
{ name: 'Adversário', value: req.target_organization.name, inline: true },
104+
{ name: 'Data', value: proposed, inline: true },
105+
{ name: 'Jogos', value: req.games_planned.to_s, inline: true }
106+
],
107+
footer: { text: 'scrims.lol' },
108+
timestamp: Time.current.iso8601
109+
}
110+
end
111+
private_class_method :accepted_embed
112+
113+
def self.declined_embed(req)
114+
{
115+
title: '❌ Scrim Recusado',
116+
color: RED,
117+
description: "**#{req.target_organization.name}** recusou seu pedido de scrim.",
118+
fields: [
119+
{ name: 'Adversário', value: req.target_organization.name, inline: true }
120+
],
121+
footer: { text: 'scrims.lol' },
122+
timestamp: Time.current.iso8601
123+
}
124+
end
125+
private_class_method :declined_embed
126+
end

config/routes.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@
5757
get 'me', to: '/authentication/controllers/auth#me'
5858
end
5959

60+
# Organization settings (for current user's org)
61+
scope 'organizations/:id', as: 'organization' do
62+
patch '', to: 'organizations#update', as: 'update'
63+
post 'logo', to: 'organizations#upload_logo', as: 'logo'
64+
end
65+
6066
# Profile -- stays in api/v1
6167
scope :profile do
6268
get '', to: 'profile#show'
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
class AddDiscordUserIdToUsers < ActiveRecord::Migration[7.1]
4+
def change
5+
add_column :users, :discord_user_id, :string
6+
add_index :users, :discord_user_id, unique: true, where: 'discord_user_id IS NOT NULL'
7+
end
8+
end

db/schema.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[7.2].define(version: 2026_04_06_200002) do
13+
ActiveRecord::Schema[7.2].define(version: 2026_04_06_300001) do
1414
create_schema "auth"
1515
create_schema "extensions"
1616
create_schema "graphql"
@@ -982,6 +982,8 @@
982982
t.datetime "created_at", null: false
983983
t.datetime "updated_at", null: false
984984
t.string "supabase_uid"
985+
t.string "discord_user_id"
986+
t.index ["discord_user_id"], name: "index_users_on_discord_user_id", unique: true, where: "(discord_user_id IS NOT NULL)"
985987
t.index ["email"], name: "index_users_on_email", unique: true
986988
t.index ["organization_id"], name: "index_users_on_organization_id"
987989
t.index ["role"], name: "index_users_on_role"

0 commit comments

Comments
 (0)