Skip to content

Commit 1a91333

Browse files
committed
feat: implement mailing and templates
1 parent af4dcc8 commit 1a91333

29 files changed

Lines changed: 647 additions & 252 deletions

app/mailers/application_mailer.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,11 @@
33
class ApplicationMailer < ActionMailer::Base
44
default from: ENV.fetch('MAILER_FROM_EMAIL', 'noreply@prostaff.gg')
55
layout 'mailer'
6+
7+
private
8+
9+
def frontend_url_for(record)
10+
source = record.source_app.presence || 'prostaff'
11+
Constants::SOURCE_APP_URLS.fetch(source, ENV.fetch('PROSTAFF_URL', 'https://prostaff.gg'))
12+
end
613
end

app/mailers/player_mailer.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
class PlayerMailer < ApplicationMailer
4+
def password_reset(player, reset_token, frontend_url_override = nil)
5+
@player = player
6+
base = frontend_url_override || frontend_url_for(player)
7+
parsed_uri = URI.parse(base)
8+
unless parsed_uri.is_a?(URI::HTTP)
9+
raise ArgumentError, "Frontend URL must use http or https (got: #{parsed_uri.scheme.inspect})"
10+
end
11+
12+
@reset_url = "#{base}/reset-password?token=#{reset_token.token}"
13+
@expires_in = ((reset_token.expires_at - Time.current) / 60).to_i
14+
15+
mail(to: @player.player_email, subject: 'Redefinicao de senha - ArenaBR')
16+
end
17+
18+
def password_reset_confirmation(player)
19+
@player = player
20+
@frontend_url = frontend_url_for(player)
21+
mail(to: @player.player_email, subject: 'Senha redefinida com sucesso - ArenaBR')
22+
end
23+
end

app/mailers/user_mailer.rb

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,42 @@
11
# frozen_string_literal: true
22

33
class UserMailer < ApplicationMailer
4-
def password_reset(user, reset_token)
4+
def password_reset(user, reset_token, frontend_url_override = nil)
55
@user = user
6-
@reset_token = reset_token
7-
frontend_url = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
8-
parsed_uri = URI.parse(frontend_url)
6+
base = frontend_url_override || frontend_url_for(user)
7+
parsed_uri = URI.parse(base)
98
unless parsed_uri.is_a?(URI::HTTP)
10-
raise ArgumentError, "FRONTEND_URL must use http or https scheme (got: #{parsed_uri.scheme.inspect})"
9+
raise ArgumentError, "Frontend URL must use http or https (got: #{parsed_uri.scheme.inspect})"
1110
end
1211

13-
@reset_url = "#{frontend_url}/reset-password?token=#{reset_token.token}"
14-
@expires_in = ((reset_token.expires_at - Time.current) / 60).to_i # minutes
12+
@reset_url = "#{base}/reset-password?token=#{reset_token.token}"
13+
@expires_in = ((reset_token.expires_at - Time.current) / 60).to_i
1514

16-
mail(
17-
to: @user.email,
18-
subject: 'Password Reset Request - ProStaff'
19-
)
15+
mail(to: @user.email, subject: 'Redefinicao de senha - ProStaff')
2016
end
2117

2218
def password_reset_confirmation(user)
2319
@user = user
24-
25-
mail(
26-
to: @user.email,
27-
subject: 'Password Successfully Reset - ProStaff'
28-
)
20+
@frontend_url = frontend_url_for(user)
21+
mail(to: @user.email, subject: 'Senha redefinida com sucesso - ProStaff')
2922
end
3023

3124
def welcome(user)
3225
@user = user
26+
@frontend_url = frontend_url_for(user)
27+
mail(to: @user.email, subject: "Bem-vindo ao ProStaff, #{user.full_name}!")
28+
end
29+
30+
def trial_expired(user)
31+
@user = user
32+
@organization = user.organization
33+
mail(to: @user.email, subject: 'Seu periodo de teste ProStaff encerrou')
34+
end
3335

34-
mail(
35-
to: @user.email,
36-
subject: 'Welcome to ProStaff!'
37-
)
36+
def trial_expiring_soon(user, days_remaining)
37+
@user = user
38+
@organization = user.organization
39+
@days_remaining = days_remaining
40+
mail(to: @user.email, subject: "Seu teste ProStaff expira em #{days_remaining} dia(s)")
3841
end
3942
end

app/models/concerns/constants.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ module Organization
2525
}.freeze
2626
end
2727

28+
# Source application — identifies which frontend originated the record
29+
SOURCE_APPS = %w[prostaff scrims arena_br].freeze
30+
31+
SOURCE_APP_URLS = {
32+
'prostaff' => ENV.fetch('PROSTAFF_URL', 'https://prostaff.gg'),
33+
'scrims' => ENV.fetch('SCRIMS_URL', 'https://scrims.lol'),
34+
'arena_br' => ENV.fetch('ARENA_BR_URL', 'https://arena-br.vercel.app')
35+
}.freeze
36+
2837
# User roles
2938
module User
3039
ROLES = %w[owner admin coach analyst viewer].freeze

app/models/password_reset_token.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
# frozen_string_literal: true
22

3-
# Secure, single-use expiring token for user password reset flows.
3+
# Secure, single-use expiring token for password reset flows.
4+
# Supports both User (staff) and Player (ArenaBR) via polymorphic owner.
45
class PasswordResetToken < ApplicationRecord
5-
belongs_to :user
6+
belongs_to :user, optional: true
7+
belongs_to :player, optional: true
68

79
validates :token, presence: true, uniqueness: true
810
validates :expires_at, presence: true
11+
validate :owner_present
912

1013
scope :valid, -> { where('expires_at > ? AND used_at IS NULL', Time.current) }
1114
scope :expired, -> { where('expires_at <= ?', Time.current) }
@@ -14,6 +17,10 @@ class PasswordResetToken < ApplicationRecord
1417
before_validation :generate_token, on: :create
1518
before_validation :set_expiration, on: :create
1619

20+
def owner
21+
user || player
22+
end
23+
1724
def mark_as_used!
1825
update!(used_at: Time.current)
1926
end
@@ -40,6 +47,10 @@ def self.cleanup_old_tokens
4047

4148
private
4249

50+
def owner_present
51+
errors.add(:base, 'must belong to a user or a player') if user_id.nil? && player_id.nil?
52+
end
53+
4354
def generate_token
4455
self.token ||= self.class.generate_secure_token
4556
end

app/models/user.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class User < ApplicationRecord
2525
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
2626
validates :full_name, presence: true, length: { maximum: 255 }
2727
validates :role, presence: true, inclusion: { in: Constants::User::ROLES }
28+
validates :source_app, inclusion: { in: Constants::SOURCE_APPS }
2829
validates :timezone, length: { maximum: 100 }
2930
validates :language, length: { maximum: 10 }
3031
validates :discord_user_id,

app/modules/authentication/controllers/auth_controller.rb

Lines changed: 83 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -321,25 +321,13 @@ def forgot_password
321321
)
322322
end
323323

324-
user = User.find_by(email: email)
324+
user = User.unscoped.find_by(email: email)
325+
player = Player.find_by(player_email: email) unless user
325326

326327
if user
327-
reset_token = user.password_reset_tokens.create!(
328-
ip_address: request.remote_ip,
329-
user_agent: request.user_agent
330-
)
331-
332-
deliver_email(UserMailer.password_reset(user, reset_token))
333-
334-
AuditLog.create!(
335-
organization: user.organization,
336-
user: user,
337-
action: 'password_reset_requested',
338-
entity_type: 'User',
339-
entity_id: user.id,
340-
ip_address: request.remote_ip,
341-
user_agent: request.user_agent
342-
)
328+
handle_user_password_reset(user)
329+
elsif player
330+
handle_player_password_reset(player)
343331
end
344332

345333
render_success(
@@ -382,24 +370,11 @@ def reset_password
382370

383371
reset_token = PasswordResetToken.valid.find_by(token: token)
384372

385-
if reset_token
386-
user = reset_token.user
387-
user.update!(password: new_password)
388-
389-
reset_token.mark_as_used!
390-
391-
deliver_email(UserMailer.password_reset_confirmation(user))
392-
393-
AuditLog.create!(
394-
organization: user.organization,
395-
user: user,
396-
action: 'password_reset_completed',
397-
entity_type: 'User',
398-
entity_id: user.id,
399-
ip_address: request.remote_ip,
400-
user_agent: request.user_agent
401-
)
402-
373+
if reset_token&.user
374+
complete_user_password_reset(reset_token, new_password)
375+
render_success({}, message: 'Password reset successful')
376+
elsif reset_token&.player
377+
complete_player_password_reset(reset_token, new_password)
403378
render_success({}, message: 'Password reset successful')
404379
else
405380
render_error(
@@ -442,7 +417,8 @@ def create_organization!
442417
def create_user!(organization)
443418
User.create!(user_params.merge(
444419
organization: organization,
445-
role: 'owner' # First user is always the owner
420+
role: 'owner',
421+
source_app: source_app_from_origin
446422
))
447423
end
448424

@@ -534,7 +510,8 @@ def build_free_agent_player(player_email, summoner_name, password, discord)
534510
discord_user_id: discord.presence,
535511
player_access_enabled: true,
536512
status: 'active',
537-
role: 'top'
513+
role: 'top',
514+
source_app: 'arena_br'
538515
# organization_id intentionally omitted (nil) — free agent
539516
)
540517
end
@@ -557,6 +534,75 @@ def serialize_new_free_agent(player)
557534
}
558535
end
559536

537+
def handle_user_password_reset(user)
538+
reset_token = user.password_reset_tokens.create!(
539+
ip_address: request.remote_ip,
540+
user_agent: request.user_agent
541+
)
542+
frontend_url = frontend_url_from_origin || frontend_base_for(user)
543+
deliver_email(UserMailer.password_reset(user, reset_token, frontend_url))
544+
AuditLog.create!(
545+
organization: user.organization,
546+
user: user,
547+
action: 'password_reset_requested',
548+
entity_type: 'User',
549+
entity_id: user.id,
550+
ip_address: request.remote_ip,
551+
user_agent: request.user_agent
552+
)
553+
end
554+
555+
def handle_player_password_reset(player)
556+
reset_token = player.password_reset_tokens.create!(
557+
ip_address: request.remote_ip,
558+
user_agent: request.user_agent
559+
)
560+
frontend_url = frontend_url_from_origin || frontend_base_for(player)
561+
deliver_email(PlayerMailer.password_reset(player, reset_token, frontend_url))
562+
end
563+
564+
def complete_user_password_reset(reset_token, new_password)
565+
user = reset_token.user
566+
user.update!(password: new_password)
567+
reset_token.mark_as_used!
568+
deliver_email(UserMailer.password_reset_confirmation(user))
569+
AuditLog.create!(
570+
organization: user.organization,
571+
user: user,
572+
action: 'password_reset_completed',
573+
entity_type: 'User',
574+
entity_id: user.id,
575+
ip_address: request.remote_ip,
576+
user_agent: request.user_agent
577+
)
578+
end
579+
580+
def complete_player_password_reset(reset_token, new_password)
581+
player = reset_token.player
582+
player.update!(player_password: new_password)
583+
reset_token.mark_as_used!
584+
deliver_email(PlayerMailer.password_reset_confirmation(player))
585+
end
586+
587+
def source_app_from_origin
588+
origin = request.headers['Origin']&.strip&.chomp('/')
589+
return 'prostaff' unless origin.present?
590+
591+
Constants::SOURCE_APP_URLS.find { |_src, url| url.chomp('/') == origin }&.first || 'prostaff'
592+
end
593+
594+
def frontend_url_from_origin
595+
origin = request.headers['Origin']&.strip&.chomp('/')
596+
return nil unless origin.present?
597+
598+
Constants::SOURCE_APP_URLS.values.find { |url| url.chomp('/') == origin }
599+
end
600+
601+
def frontend_base_for(record)
602+
source = record.source_app.presence || 'prostaff'
603+
Constants::SOURCE_APP_URLS.fetch(source, ENV.fetch('PROSTAFF_URL', 'https://prostaff.gg'))
604+
end
605+
560606
def authenticate_user!
561607
email = params[:email]&.downcase&.strip
562608
password = params[:password]

app/modules/players/models/player.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,27 +30,27 @@
3030
# @example Finding active players by role
3131
# mid_laners = Player.active.by_role("mid")
3232
#
33-
class Player < ApplicationRecord
34-
# Concerns
33+
class Player < ApplicationRecord # rubocop:disable Metrics/ClassLength
3534
include Constants
3635
include OrganizationScoped
3736
include SoftDeletable
3837
include Searchable
3938

4039
# Associations
41-
# optional: true — self-registered free agents (ArenaBR) can exist without an org
4240
belongs_to :organization, optional: true
4341
belongs_to :scouted_from, class_name: 'ScoutingTarget', optional: true
4442
has_many :player_match_stats, dependent: :destroy
4543
has_many :matches, through: :player_match_stats
4644
has_many :champion_pools, dependent: :destroy
4745
has_many :team_goals, dependent: :destroy
4846
has_many :vod_timestamps, foreign_key: 'target_player_id', dependent: :nullify
47+
has_many :password_reset_tokens, dependent: :destroy
4948

5049
# Password authentication for individual player access
5150
has_secure_password :player_password, validations: false
5251

5352
# Validations
53+
validates :source_app, inclusion: { in: Constants::SOURCE_APPS }
5454
validates :summoner_name, presence: true, length: { maximum: 100 }
5555
validates :real_name, length: { maximum: 255 }
5656
validates :role, presence: true, inclusion: { in: Constants::Player::ROLES }
Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
1-
<h2>New Contact Form Submission</h2>
1+
<h2 style="margin:0 0 16px 0;font-family:Arial,Helvetica,sans-serif;font-size:22px;color:#1a1a2e;">Nova mensagem de contato</h2>
22

3-
<p><strong>From:</strong> <%= @name %> &lt;<%= @email %>&gt;</p>
4-
<p><strong>Subject:</strong> <%= @subject %></p>
3+
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0;background-color:#f9f9f9;border:1px solid #e4e4e4;">
4+
<tr>
5+
<td style="padding:8px 16px;font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#555555;border-bottom:1px solid #e4e4e4;">
6+
<strong style="color:#333333;">De:</strong>&nbsp; <%= @name %> &lt;<%= @email %>&gt;
7+
</td>
8+
</tr>
9+
<tr>
10+
<td style="padding:8px 16px;font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#555555;">
11+
<strong style="color:#333333;">Assunto:</strong>&nbsp; <%= @subject %>
12+
</td>
13+
</tr>
14+
</table>
515

6-
<hr>
16+
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0;">
17+
<tr>
18+
<td style="padding:16px;background-color:#f9f9f9;border:1px solid #e4e4e4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333333;line-height:1.7;">
19+
<%= simple_format(@message) %>
20+
</td>
21+
</tr>
22+
</table>
723

8-
<p><%= simple_format(@message) %></p>
9-
10-
<hr>
11-
12-
<p style="color: #888; font-size: 12px;">
13-
This message was sent via the contact form at prostaff.gg/contact.<br>
14-
Reply directly to this email to respond to <%= @name %>.
24+
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#888888;">
25+
Mensagem enviada via formulario de contato em prostaff.gg/contact.<br>
26+
Responda diretamente a este email para responder a <%= @name %>.
1527
</p>

0 commit comments

Comments
 (0)