Skip to content

Commit 83e05fa

Browse files
committed
feat: implement team chat
1 parent cd53cce commit 83e05fa

10 files changed

Lines changed: 337 additions & 116 deletions

app/channels/application_cable/connection.rb

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,47 @@ module ApplicationCable
1111
# wss://api.prostaff.gg/cable?token=<JWT_ACCESS_TOKEN>
1212
#
1313
# On success, sets:
14-
# - current_user → the authenticated User record
15-
# - current_org_id → organization_id extracted from the user
14+
# - current_user → the authenticated User record (nil for player tokens)
15+
# - current_player → the authenticated Player record (nil for user tokens)
16+
# - current_org_id → organization_id extracted from the token
1617
#
1718
# On failure, calls reject_unauthorized_connection.
1819
class Connection < ActionCable::Connection::Base
19-
identified_by :current_user, :current_org_id
20+
identified_by :current_user, :current_player, :current_org_id
2021

2122
def connect
22-
self.current_user = find_verified_user
23-
self.current_org_id = current_user.organization_id
23+
payload = decode_token
24+
route_by_token_type(payload)
2425
end
2526

2627
private
2728

28-
def find_verified_user # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
29+
def decode_token
2930
token = request.params[:token]
30-
3131
reject_unauthorized_connection if token.blank?
3232

3333
payload = JwtService.decode(token)
3434

35-
# Only accept access tokens — reject refresh tokens
3635
if payload[:type] != 'access'
3736
logger.warn "[ActionCable] Rejected non-access token type: #{payload[:type]}"
3837
reject_unauthorized_connection
3938
end
4039

40+
payload
41+
rescue JwtService::AuthenticationError => e
42+
logger.warn "[ActionCable] JWT rejected: #{e.message}"
43+
reject_unauthorized_connection
44+
end
45+
46+
def route_by_token_type(payload)
47+
if payload[:entity_type] == 'player'
48+
authenticate_player_connection(payload)
49+
else
50+
authenticate_user_connection(payload)
51+
end
52+
end
53+
54+
def authenticate_user_connection(payload)
4155
user = User.find_by(id: payload[:user_id])
4256

4357
if user.nil?
@@ -50,11 +64,29 @@ def find_verified_user # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
5064
reject_unauthorized_connection
5165
end
5266

67+
self.current_user = user
68+
self.current_player = nil
69+
self.current_org_id = user.organization_id
5370
logger.info "[ActionCable] Connected: user=#{user.id} org=#{user.organization_id}"
54-
user
55-
rescue JwtService::AuthenticationError => e
56-
logger.warn "[ActionCable] JWT rejected: #{e.message}"
57-
reject_unauthorized_connection
71+
end
72+
73+
def authenticate_player_connection(payload)
74+
player = Player.unscoped.find_by(id: payload[:player_id], player_access_enabled: true)
75+
76+
if player.nil?
77+
logger.warn "[ActionCable] Player not found or access disabled: player_id=#{payload[:player_id]}"
78+
reject_unauthorized_connection
79+
end
80+
81+
unless player.organization_id.present?
82+
logger.warn "[ActionCable] Player #{player.id} has no organization — rejected"
83+
reject_unauthorized_connection
84+
end
85+
86+
self.current_user = nil
87+
self.current_player = player
88+
self.current_org_id = player.organization_id
89+
logger.info "[ActionCable] Connected: player=#{player.id} org=#{player.organization_id}"
5890
end
5991
end
6092
end

app/modules/core/controllers/team_members_controller.rb

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,32 @@
22

33
module Core
44
module Controllers
5-
# TeamMembersController — lists users in the same organization.
5+
# TeamMembersController — lists all messageable members in the same organization.
66
#
7-
# Used by the frontend to populate the team member list in the chat widget.
8-
# Returns all users except the current user.
9-
# Player tokens are rejected — this endpoint is for staff only.
7+
# Returns staff users and players with player access enabled, used by
8+
# the frontend to populate the DM recipient list in the chat widget.
9+
# Player tokens are rejected — this endpoint requires a user token.
1010
#
1111
# GET /api/v1/team-members
1212
class TeamMembersController < Api::V1::BaseController
1313
before_action :require_user_auth!
1414

1515
def index
16-
members = current_organization
17-
.users
18-
.where.not(id: current_user.id)
19-
.order(:full_name)
20-
.select(:id, :full_name, :role, :last_login_at)
21-
22-
render_success(
23-
{ members: members.map { |u| serialize_member(u) } }
24-
)
16+
users = current_organization
17+
.users
18+
.where.not(id: current_user.id)
19+
.order(:full_name)
20+
.select(:id, :full_name, :role, :last_login_at, :avatar_url)
21+
.map { |u| serialize_member(u) }
22+
23+
players = current_organization
24+
.players
25+
.where(player_access_enabled: true)
26+
.order(:professional_name, :real_name)
27+
.select(:id, :professional_name, :real_name, :role, :last_login_at, :avatar_url)
28+
.map { |p| serialize_player(p) }
29+
30+
render_success({ members: users + players })
2531
end
2632

2733
private
@@ -31,9 +37,26 @@ def serialize_member(user)
3137
id: user.id,
3238
full_name: user.full_name,
3339
role: user.role,
34-
online: user.last_login_at.present? && user.last_login_at > 15.minutes.ago
40+
online: active_recently?(user.last_login_at),
41+
member_type: 'user',
42+
avatar_url: user.avatar_url.presence
43+
}
44+
end
45+
46+
def serialize_player(player)
47+
{
48+
id: player.id,
49+
full_name: player.professional_name.presence || player.real_name || 'Player',
50+
role: player.role || 'player',
51+
online: active_recently?(player.last_login_at),
52+
member_type: 'player',
53+
avatar_url: player.avatar_url.presence
3554
}
3655
end
56+
57+
def active_recently?(last_login_at)
58+
last_login_at.present? && last_login_at > 15.minutes.ago
59+
end
3760
end
3861
end
3962
end
Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,36 @@
11
# frozen_string_literal: true
22

3-
# DirectMessageChannel — real-time private messaging between two team members.
3+
# DirectMessageChannel — real-time private messaging between staff and players.
44
#
5-
# The frontend subscribes passing the recipient_id:
5+
# The frontend subscribes passing recipient_id and optionally recipient_type:
66
# consumer.subscriptions.create(
7-
# { channel: 'DirectMessageChannel', recipient_id: '<uuid>' },
7+
# { channel: 'DirectMessageChannel', recipient_id: '<uuid>', recipient_type: 'Player' },
88
# { received(data) { ... } }
99
# )
1010
#
1111
# Security guarantees:
12-
# 1. Sender identity comes from the verified JWT (current_user) — cannot be spoofed.
12+
# 1. Sender identity comes from the verified JWT (current_user or current_player) — cannot be spoofed.
1313
# 2. Recipient must belong to the same organization as the sender.
14-
# 3. Stream key is derived from sorted user IDs + org_id — impossible to subscribe
15-
# to a conversation you're not a party to.
14+
# 3. Stream key is derived from sorted participant IDs + org_id — impossible to subscribe
15+
# to a conversation you are not a party to.
1616
class DirectMessageChannel < ApplicationCable::Channel
1717
MAX_CONTENT_LENGTH = 2000
1818

1919
def subscribed
2020
recipient = find_and_validate_recipient
2121
return unless recipient
2222

23-
@recipient_id = recipient.id
24-
stream_from stream_key_for(recipient)
25-
logger.info "[DM] #{current_user.id} subscribed to DM with #{recipient.id}"
23+
@recipient_id = recipient[:record].id
24+
@recipient_type = recipient[:type]
25+
stream_from Message.dm_stream_key(current_sender_id, @recipient_id, current_org_id)
26+
logger.info "[DM] #{current_sender_id} subscribed to DM with #{@recipient_id}"
2627
end
2728

2829
def unsubscribed
2930
stop_all_streams
3031
end
3132

32-
# Receives { "content" => "...", "recipient_id" => "..." } from the frontend.
33+
# Receives { "content" => "...", "recipient_id" => "...", "recipient_type" => "..." } from client.
3334
def speak(data) # rubocop:disable Metrics/MethodLength
3435
content = data['content'].to_s.strip
3536
recipient_id = data['recipient_id'].to_s
@@ -47,12 +48,7 @@ def speak(data) # rubocop:disable Metrics/MethodLength
4748
recipient = find_recipient_by_id(recipient_id)
4849
return unless recipient
4950

50-
Message.create!(
51-
content: content,
52-
user: current_user,
53-
recipient: recipient,
54-
organization_id: current_org_id
55-
)
51+
create_message(content: content, recipient: recipient)
5652
rescue ActiveRecord::RecordInvalid => e
5753
logger.error "[DM] Failed to create message: #{e.message}"
5854
transmit({ error: 'Failed to send message' })
@@ -73,24 +69,54 @@ def find_and_validate_recipient
7369
end
7470

7571
def find_recipient_by_id(recipient_id)
76-
recipient = User.find_by(id: recipient_id, organization_id: current_org_id)
72+
recipient_type = resolve_recipient_type(params[:recipient_type])
73+
record = locate_recipient(recipient_id, recipient_type)
7774

78-
unless recipient
79-
logger.warn "[DM] Recipient #{recipient_id} not found in org #{current_org_id}"
75+
unless record
76+
logger.warn "[DM] Recipient #{recipient_id} (#{recipient_type}) not found in org #{current_org_id}"
8077
reject
8178
return nil
8279
end
8380

84-
if recipient.id == current_user.id
81+
if record.id == current_sender_id
8582
logger.warn '[DM] Cannot DM yourself'
8683
reject
8784
return nil
8885
end
8986

90-
recipient
87+
{ record: record, type: recipient_type }
88+
end
89+
90+
def locate_recipient(recipient_id, recipient_type)
91+
if recipient_type == 'Player'
92+
Player.find_by(id: recipient_id, organization_id: current_org_id, player_access_enabled: true)
93+
else
94+
User.find_by(id: recipient_id, organization_id: current_org_id)
95+
end
96+
end
97+
98+
def resolve_recipient_type(raw_type)
99+
Message::PARTICIPANT_TYPES.include?(raw_type.to_s) ? raw_type.to_s : 'User'
100+
end
101+
102+
def create_message(content:, recipient:)
103+
Message.create!(
104+
user_id: current_sender_id,
105+
sender_type: current_sender_type,
106+
recipient_id: recipient[:record].id,
107+
recipient_type: recipient[:type],
108+
organization_id: current_org_id,
109+
content: content
110+
)
111+
end
112+
113+
def current_sender_id
114+
return current_player.id if current_player.present?
115+
116+
current_user.id
91117
end
92118

93-
def stream_key_for(recipient)
94-
Message.dm_stream_key(current_user.id, recipient.id, current_org_id)
119+
def current_sender_type
120+
current_player.present? ? 'Player' : 'User'
95121
end
96122
end

app/modules/messaging/channels/team_channel.rb

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
# TeamChannel — Real-time messaging channel for team communication.
44
#
5-
# Each user subscribes to the stream of their own organization.
6-
# The stream key is derived from `current_org_id` (set in Connection),
7-
# so a user cannot subscribe to another organization's stream even by
5+
# Each member subscribes to the stream of their own organization.
6+
# The stream key is derived from current_org_id (set in Connection),
7+
# so a member cannot subscribe to another organization's stream even by
88
# manually crafting a subscription request.
99
#
1010
# Actions:
@@ -16,24 +16,23 @@
1616
# Broadcasting is done by the Message model's after_create callback,
1717
# not directly in this channel, to keep the channel thin and testable.
1818
class TeamChannel < ApplicationCable::Channel
19-
# Maximum message length — enforced at channel level before hitting the DB
2019
MAX_CONTENT_LENGTH = 2000
2120

2221
def subscribed
2322
if current_org_id.blank?
24-
logger.warn "[TeamChannel] Rejected subscription — no org_id for user #{current_user.id}"
23+
logger.warn "[TeamChannel] Rejected subscription — no org_id for sender #{current_sender_id}"
2524
reject
2625
return
2726
end
2827

2928
stream_name = "team_room_#{current_org_id}"
3029
stream_from stream_name
31-
logger.info "[TeamChannel] user=#{current_user.id} subscribed to #{stream_name}"
30+
logger.info "[TeamChannel] sender=#{current_sender_id} subscribed to #{stream_name}"
3231
end
3332

3433
def unsubscribed
3534
stop_all_streams
36-
logger.info "[TeamChannel] user=#{current_user.id} disconnected"
35+
logger.info "[TeamChannel] sender=#{current_sender_id} disconnected"
3736
end
3837

3938
# Receives a message sent by the frontend via cable.
@@ -52,14 +51,26 @@ def speak(data) # rubocop:disable Metrics/MethodLength
5251
return
5352
end
5453

55-
# Persist the message — broadcasting is triggered by after_create callback
5654
Message.create!(
57-
content: content,
58-
user: current_user,
59-
organization_id: current_org_id
55+
user_id: current_sender_id,
56+
sender_type: current_sender_type,
57+
organization_id: current_org_id,
58+
content: content
6059
)
6160
rescue ActiveRecord::RecordInvalid => e
6261
logger.error "[TeamChannel] Failed to create message: #{e.message}"
6362
transmit({ error: 'Failed to send message' })
6463
end
64+
65+
private
66+
67+
def current_sender_id
68+
return current_player.id if current_player.present?
69+
70+
current_user.id
71+
end
72+
73+
def current_sender_type
74+
current_player.present? ? 'Player' : 'User'
75+
end
6576
end

0 commit comments

Comments
 (0)