Add chat_channel_membership roles and invite functionality (#541)

This commit is contained in:
Ben Halpern 2018-07-03 13:16:48 -04:00 committed by GitHub
parent b45dd391b6
commit 424be43865
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 163 additions and 28 deletions

View file

@ -21,7 +21,7 @@ gem "bourbon", "~> 1.4"
gem "buffer", github: "bufferapp/buffer-ruby"
gem "carrierwave", "~> 1.2"
gem "carrierwave-bombshelter", "~> 0.2"
gem "cloudinary", "~> 1.8"
gem "cloudinary", "~> 1.9"
gem "counter_culture", "~> 1.9"
gem "csv_shaper", "~> 1.3"
gem "dalli", "~> 2.7"

View file

@ -169,7 +169,7 @@ GEM
chromedriver-helper (1.2.0)
archive-zip (~> 0.10)
nokogiri (~> 1.8)
cloudinary (1.8.3)
cloudinary (1.9.1)
aws_cf_signer
rest-client
coderay (1.1.2)
@ -930,7 +930,7 @@ DEPENDENCIES
carrierwave (~> 1.2)
carrierwave-bombshelter (~> 0.2)
chromedriver-helper (~> 1.2)
cloudinary (~> 1.8)
cloudinary (~> 1.9)
counter_culture (~> 1.9)
csv_shaper (~> 1.3)
dalli (~> 2.7)

View file

@ -20,7 +20,6 @@ module Admin
request.origin.nil? || request.origin.gsub("https", "http") == request.base_url.gsub("https", "http")
end
private
def authorize_admin

View file

@ -1,26 +1,34 @@
class ChatChannelMembershipsController < ApplicationController
after_action :verify_authorized
def create
@chat_channel = ChatChannel.find(params[:chat_channel_id])
@chat_channel = ChatChannel.find(permitted_params[:chat_channel_id])
authorize @chat_channel, :update?
ChatChannelMembership.create(
user_id: params[:user_id],
user_id: permitted_params[:user_id],
chat_channel_id: @chat_channel.id,
status: "pending"
)
@chat_channel.index!
end
def update
@chat_channel_membership = ChatChannelMembership.find(params[:id])
authorize @chat_channel_membership
if params[:user_action] == "accept"
if permitted_params[:user_action] == "accept"
@chat_channel_membership.update(status: "active")
else
@chat_channel_membership.update(status: "rejected")
end
@chat_channel_membership.chat_channel.index!
@chat_channels_memberships = current_user.
chat_channel_memberships.includes(:chat_channel).
where(status: "pending").
order("chat_channel_memberships.updated_at DESC")
render "chat_channels/index.json"
end
def permitted_params
params.require(:chat_channel_membership).permit(:user_id, :chat_channel_id, :user_action, :id)
end
end

View file

@ -91,7 +91,7 @@ class ChatChannelsController < ApplicationController
if current_user
@chat_channels_memberships = current_user.
chat_channel_memberships.includes(:chat_channel).
where(chat_channels: {channel_type: "direct"}, has_unopened_messages: true).
where(has_unopened_messages: true).
order("chat_channel_memberships.updated_at DESC")
else
@chat_channels_memberships = []

View file

@ -164,7 +164,9 @@ export function sendChannelInviteAction(id, action, successCb, failureCb) {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_action: action,
chat_channel_membership: {
user_action: action,
}
}),
credentials: 'same-origin',
})

View file

@ -2,7 +2,72 @@
import { h, Component } from 'preact';
const algoliaId = document.querySelector("meta[name='algolia-public-id']").content
const algoliaKey = document.querySelector("meta[name='algolia-public-key']").content
const env = document.querySelector("meta[name='environment']").content
const client = algoliasearch(algoliaId, algoliaKey);
const index = client.initIndex('searchables_'+env);
export default class ChannelDetails extends Component {
constructor(props) {
super(props);
this.state = {
searchedUsers: [],
invitations: []
}
}
triggerUserSearch = e => {
const component = this;
const query = e.target.value;
const filters = {
hitsPerPage: 20,
attributesToRetrieve: [
'id',
'title',
'path'
],
attributesToHighlight: [],
filters: 'class_name:User',
}
if (query.length > 0) {
index.search(query, filters)
.then(function(content) {
component.setState({searchedUsers:content.hits})
});
} else {
component.setState({searchedUsers:[]})
}
}
triggerInvite = e => {
const component = this;
const id = e.target.dataset.content;
console.log(e.target)
e.target.style.display = 'none'
fetch(`/chat_channel_memberships`, {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_channel_membership: {
user_id: id,
chat_channel_id: component.props.channel.id,
}
}),
credentials: 'same-origin',
})
.then(response => response)
.then(this.handleInvitationSuccess)
.catch(null);
}
handleInvitationSuccess = response => {
console.log(response)
}
render() {
const channel = this.props.channel
@ -21,10 +86,30 @@ export default class ChannelDetails extends Component {
if (users.length === 80) {
subHeader = <h3>Recently Active Members</h3>
}
let modSection = ''
let searchedUsers = [];
let pendingInvites = [];
if (channel.channel_mod_ids.includes(window.currentUser.id)) {
searchedUsers = this.state.searchedUsers.map((user) => {
return <div><a href={user.path} target='_blank'>{user.title}</a> <button onClick={this.triggerInvite} data-content={user.id}>Invite</button></div>
})
pendingInvites = channel.pending_usernames.map((username) => {
return <div><a href={'/'+username} target='_blank'>@{username}</a></div>
})
modSection = <div>
<h2>Invite Members</h2>
<input onKeyUp={this.triggerUserSearch} placeholder="Find users"/>
{searchedUsers}
<h2>Pending Invites:</h2>
{pendingInvites}
<div>Channels can have a maximum of 128 members, including outstanding invites. All functionality is early beta. Contact us if you need help with anything or to have any restrictions lifted.</div>
</div>
}
return <div>
<h1>{channel.channel_name}</h1>
{subHeader}
{users}
{modSection}
</div>
}

View file

@ -81,6 +81,7 @@ export default class Chat extends Component {
document.getElementById('chatchannels__channelslist').addEventListener('scroll', this.handleChannelScroll);
}
getChannelInvites(this.handleChannelInvites,null);
}
componentDidUpdate() {
@ -674,6 +675,7 @@ export default class Chat extends Component {
render() {
console.log(this.state.activeChannel)
let channelHeader = <div className="activechatchannel__header">&nbsp;</div>
let channelHeaderInner = ''
const currentChannel = this.state.activeChannel

View file

@ -1,4 +1,5 @@
class ProfileImage
include CloudinaryHelper
attr_accessor :resource, :image_link, :backup_link
def initialize(resource)
@ -8,7 +9,7 @@ class ProfileImage
end
def get(width = 120)
CloudinaryHelper.cl_image_path(get_link,
cl_image_path(get_link,
type: "fetch",
crop: "fill",
width: width,

View file

@ -9,9 +9,11 @@ class ChatChannel < ApplicationRecord
has_many :active_memberships, -> { where status: "active" }, class_name: 'ChatChannelMembership'
has_many :pending_memberships, -> { where status: "pending" }, class_name: 'ChatChannelMembership'
has_many :rejected_memberships, -> { where status: "rejected" }, class_name: 'ChatChannelMembership'
has_many :mod_memberships, -> { where role: "mod" }, class_name: 'ChatChannelMembership'
has_many :active_users, :through => :active_memberships, class_name: "User", :source => :user
has_many :pending_users, :through => :pending_memberships, class_name: "User", :source => :user
has_many :rejected_users, :through => :rejected_memberships, class_name: "User", :source => :user
has_many :mod_users, :through => :mod_memberships, class_name: "User", :source => :user
validates :channel_type, presence: true, inclusion: { in: %w(open invite_only direct) }
validates :status, presence: true, inclusion: { in: %w(active inactive) }
@ -20,7 +22,7 @@ class ChatChannel < ApplicationRecord
algoliasearch index_name: "SecuredChatChannel_#{Rails.env}" do
attribute :id, :viewable_by, :slug, :channel_type,
:channel_name, :channel_users, :last_message_at, :status,
:messages_count, :channel_human_names
:messages_count, :channel_human_names, :channel_mod_ids, :pending_usernames
searchableAttributes [:channel_name, :channel_slug, :channel_human_names]
attributesForFaceting ["filterOnly(viewable_by)", "filterOnly(status)", "filterOnly(channel_type)"]
ranking ["desc(last_message_at)"]
@ -114,17 +116,30 @@ class ChatChannel < ApplicationRecord
def channel_users
# Purely for algolia indexing
pics_obj = {}
obj = {}
active_memberships.
order("last_opened_at DESC").limit(80).includes(:user).each_with_index do |m, i|
pics_obj[m.user.username] = {
profile_image: i < 11 ? ProfileImage.new(m.user).get(90) : nil,
darker_color: m.user.decorate.darker_color,
name: m.user.name,
last_opened_at: m.last_opened_at,
username: m.user.username,
}
obj[m.user.username] = user_obj(m, i)
end
pics_obj
obj
end
def pending_usernames
pending_users.pluck(:username)
end
def channel_mod_ids
mod_users.pluck(:id)
end
def user_obj(m, i)
{
profile_image: i < 11 ? ProfileImage.new(m.user).get(90) : nil,
darker_color: m.user.decorate.darker_color,
name: m.user.name,
last_opened_at: m.last_opened_at,
username: m.user.username,
id: m.user_id,
}
end
end

View file

@ -5,6 +5,7 @@ class ChatChannelMembership < ApplicationRecord
validates :user_id, presence: true, uniqueness: { scope: :chat_channel_id }
validates :chat_channel_id, presence: true, uniqueness: { scope: :user_id }
validates :status, inclusion: { in: %w{active inactive pending rejected left_channel} }
validates :role, inclusion: { in: %w{member mod} }
validate :permission
private

View file

@ -32,7 +32,8 @@ class ChatChannelPolicy < ApplicationPolicy
private
def user_can_edit_channel
record.present? && user.has_role?(:super_admin)
record.present? &&
(user.has_role?(:super_admin) || record.channel_mod_ids.include?(user.id))
end
def user_part_of_channel_or_open

View file

@ -13,6 +13,10 @@
<% else %>
<meta name="last-updated" content="<%= Time.now %>">
<meta name="user-signed-in" content="<%= user_signed_in? %>">
<meta name="algolia-public-id" content="<%= ENV["ALGOLIASEARCH_APPLICATION_ID"] %>">
<meta name="algolia-public-key" content="<%= ENV["ALGOLIASEARCH_PUBLIC_SEARCH_ONLY_KEY"] %>">
<meta name="environment" content="<%= Rails.env %>">
<%= render "layouts/styles" %>
<style>
.home {

View file

@ -0,0 +1,5 @@
class AddRoleToChatChannelMemberships < ActiveRecord::Migration[5.1]
def change
add_column :chat_channel_memberships, :role, :string, default: "member"
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180629201047) do
ActiveRecord::Schema.define(version: 20180703142743) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -167,6 +167,7 @@ ActiveRecord::Schema.define(version: 20180629201047) do
t.datetime "created_at", null: false
t.boolean "has_unopened_messages", default: false
t.datetime "last_opened_at", default: "2017-01-01 05:00:00"
t.string "role", default: "member"
t.string "status", default: "active"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false

View file

@ -14,13 +14,19 @@ RSpec.describe "ChatChannelMemberships", type: :request do
it "creates chat channel invitation" do
user.add_role(:super_admin)
mems_num = ChatChannelMembership.all.size
post "/chat_channel_memberships", params: {user_id: second_user.id, chat_channel_id: chat_channel.id}
post "/chat_channel_memberships", params: {
chat_channel_membership: {
user_id: second_user.id, chat_channel_id: chat_channel.id}
}
expect(ChatChannelMembership.all.size).to eq(mems_num + 1)
expect(ChatChannelMembership.last.status).to eq("pending")
end
it "denies chat channel invitation to non-authorized user" do
expect do
post "/chat_channel_memberships", params: {user_id: second_user.id, chat_channel_id: chat_channel.id}
post "/chat_channel_memberships", params: {
chat_channel_membership: {
user_id: second_user.id, chat_channel_id: chat_channel.id}
}
end.to raise_error(Pundit::NotAuthorizedError)
end
end
@ -28,24 +34,29 @@ RSpec.describe "ChatChannelMemberships", type: :request do
describe "PUT /chat_channel_memberships/:id" do
before do
user.add_role(:super_admin)
post "/chat_channel_memberships", params: {user_id: second_user.id, chat_channel_id: chat_channel.id}
post "/chat_channel_memberships", params: {
chat_channel_membership: {user_id: second_user.id, chat_channel_id: chat_channel.id}}
end
it "accepts chat channel invitation" do
membership = ChatChannelMembership.last
sign_in second_user
put "/chat_channel_memberships/#{membership.id}", params: {user_action: "accept"}
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: {
user_action: "accept"}}
expect(ChatChannelMembership.find(membership.id).status).to eq("active")
end
it "rejects chat channel invitation" do
membership = ChatChannelMembership.last
sign_in second_user
put "/chat_channel_memberships/#{membership.id}", params: {user_action: "reject"}
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: {user_action: "reject"}}
expect(ChatChannelMembership.find(membership.id).status).to eq("rejected")
end
it "disallows non-logged-user" do
membership = ChatChannelMembership.last
expect do
put "/chat_channel_memberships/#{membership.id}", params: {user_action: "accept"}
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: {user_action: "accept"}}
expect(ChatChannelMembership.find(membership.id).status).to eq("active")
end.to raise_error(Pundit::NotAuthorizedError)
end