mirror of
https://github.com/discourse/discourse.git
synced 2025-03-29 06:25:36 +08:00
FEATURE: change /invites.json api endpoint to optionally accept array of emails (#24853)
https://meta.discourse.org/t/feature-request-sending-bulk-invitations-via-api/272423/18
This commit is contained in:
parent
14269232ba
commit
ddd750cda7
app/controllers
config
spec/requests
@ -37,7 +37,10 @@ class InvitesController < ApplicationController
|
|||||||
render layout: "no_ember"
|
render layout: "no_ember"
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create_multiple
|
||||||
|
guardian.ensure_can_bulk_invite_to_forum!(current_user)
|
||||||
|
emails = params[:email]
|
||||||
|
# validate that topics and groups can accept invites.
|
||||||
if params[:topic_id].present?
|
if params[:topic_id].present?
|
||||||
topic = Topic.find_by(id: params[:topic_id])
|
topic = Topic.find_by(id: params[:topic_id])
|
||||||
raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
|
raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
|
||||||
@ -59,37 +62,107 @@ class InvitesController < ApplicationController
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
invite =
|
if emails.size > SiteSetting.max_api_invites
|
||||||
Invite.generate(
|
return(
|
||||||
current_user,
|
render_json_error(
|
||||||
email: params[:email],
|
I18n.t("invite.max_invite_emails_limit_exceeded", max: SiteSetting.max_api_invites),
|
||||||
domain: params[:domain],
|
422,
|
||||||
skip_email: params[:skip_email],
|
)
|
||||||
invited_by: current_user,
|
|
||||||
custom_message: params[:custom_message],
|
|
||||||
max_redemptions_allowed: params[:max_redemptions_allowed],
|
|
||||||
topic_id: topic&.id,
|
|
||||||
group_ids: groups&.map(&:id),
|
|
||||||
expires_at: params[:expires_at],
|
|
||||||
invite_to_topic: params[:invite_to_topic],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if invite.present?
|
|
||||||
render_serialized(
|
|
||||||
invite,
|
|
||||||
InviteSerializer,
|
|
||||||
scope: guardian,
|
|
||||||
root: nil,
|
|
||||||
show_emails: params.has_key?(:email),
|
|
||||||
show_warnings: true,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
render json: failed_json, status: 422
|
|
||||||
end
|
end
|
||||||
rescue Invite::UserExists => e
|
|
||||||
render_json_error(e.message)
|
success = []
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
fail = []
|
||||||
render_json_error(e.record.errors.full_messages.first)
|
|
||||||
|
emails.map do |email|
|
||||||
|
begin
|
||||||
|
invite =
|
||||||
|
Invite.generate(
|
||||||
|
current_user,
|
||||||
|
email: email,
|
||||||
|
domain: params[:domain],
|
||||||
|
skip_email: params[:skip_email],
|
||||||
|
invited_by: current_user,
|
||||||
|
custom_message: params["custom_message"],
|
||||||
|
max_redemptions_allowed: params[:max_redemptions_allowed],
|
||||||
|
topic_id: topic&.id,
|
||||||
|
group_ids: groups&.map(&:id),
|
||||||
|
expires_at: params[:expires_at],
|
||||||
|
invite_to_topic: params[:invite_to_topic],
|
||||||
|
)
|
||||||
|
success.push({ email: email, invite: invite }) if invite
|
||||||
|
rescue Invite::UserExists => e
|
||||||
|
fail.push({ email: email, error: e.message })
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
fail.push({ email: email, error: e.record.errors.full_messages.first })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
num_successfully_created_invitations: success.length,
|
||||||
|
num_failed_invitations: fail.length,
|
||||||
|
failed_invitations: fail,
|
||||||
|
successful_invitations:
|
||||||
|
success.map do |s| InviteSerializer.new(s[:invite], scope: guardian) end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
begin
|
||||||
|
if params[:topic_id].present?
|
||||||
|
topic = Topic.find_by(id: params[:topic_id])
|
||||||
|
raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
|
||||||
|
guardian.ensure_can_invite_to!(topic)
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:group_ids].present? || params[:group_names].present?
|
||||||
|
groups =
|
||||||
|
Group.lookup_groups(group_ids: params[:group_ids], group_names: params[:group_names])
|
||||||
|
end
|
||||||
|
|
||||||
|
guardian.ensure_can_invite_to_forum!(groups)
|
||||||
|
|
||||||
|
if !groups_can_see_topic?(groups, topic)
|
||||||
|
editable_topic_groups = topic.category.groups.filter { |g| guardian.can_edit_group?(g) }
|
||||||
|
return(
|
||||||
|
render_json_error(
|
||||||
|
I18n.t("invite.requires_groups", groups: editable_topic_groups.pluck(:name).join(", ")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
invite =
|
||||||
|
Invite.generate(
|
||||||
|
current_user,
|
||||||
|
email: params[:email],
|
||||||
|
domain: params[:domain],
|
||||||
|
skip_email: params[:skip_email],
|
||||||
|
invited_by: current_user,
|
||||||
|
custom_message: params[:custom_message],
|
||||||
|
max_redemptions_allowed: params[:max_redemptions_allowed],
|
||||||
|
topic_id: topic&.id,
|
||||||
|
group_ids: groups&.map(&:id),
|
||||||
|
expires_at: params[:expires_at],
|
||||||
|
invite_to_topic: params[:invite_to_topic],
|
||||||
|
)
|
||||||
|
|
||||||
|
if invite.present?
|
||||||
|
render_serialized(
|
||||||
|
invite,
|
||||||
|
InviteSerializer,
|
||||||
|
scope: guardian,
|
||||||
|
root: nil,
|
||||||
|
show_emails: params.has_key?(:email),
|
||||||
|
show_warnings: true,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
render json: failed_json, status: 422
|
||||||
|
end
|
||||||
|
rescue Invite::UserExists => e
|
||||||
|
render_json_error(e.message)
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render_json_error(e.record.errors.full_messages.first)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def retrieve
|
def retrieve
|
||||||
|
@ -297,6 +297,7 @@ en:
|
|||||||
discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled."
|
discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled."
|
||||||
invalid_access: "You are not permitted to view the requested resource."
|
invalid_access: "You are not permitted to view the requested resource."
|
||||||
requires_groups: "Invite was not saved because the specified topic is inaccessible. Add one of the following groups: %{groups}."
|
requires_groups: "Invite was not saved because the specified topic is inaccessible. Add one of the following groups: %{groups}."
|
||||||
|
max_invite_emails_limit_exceeded: "Request failed because number of emails exceeded the maximum (%{max})."
|
||||||
domain_not_allowed: "Your email cannot be used to redeem this invite."
|
domain_not_allowed: "Your email cannot be used to redeem this invite."
|
||||||
max_redemptions_allowed_one: "for email invites should be 1."
|
max_redemptions_allowed_one: "for email invites should be 1."
|
||||||
redemption_count_less_than_max: "should be less than %{max_redemptions_allowed}."
|
redemption_count_less_than_max: "should be less than %{max_redemptions_allowed}."
|
||||||
|
@ -1432,6 +1432,7 @@ Discourse::Application.routes.draw do
|
|||||||
|
|
||||||
resources :invites, only: %i[create update destroy]
|
resources :invites, only: %i[create update destroy]
|
||||||
get "/invites/:id" => "invites#show", :constraints => { format: :html }
|
get "/invites/:id" => "invites#show", :constraints => { format: :html }
|
||||||
|
post "invites/create-multiple" => "invites#create_multiple", :constraints => { format: :json }
|
||||||
|
|
||||||
post "invites/upload_csv" => "invites#upload_csv"
|
post "invites/upload_csv" => "invites#upload_csv"
|
||||||
post "invites/destroy-all-expired" => "invites#destroy_all_expired"
|
post "invites/destroy-all-expired" => "invites#destroy_all_expired"
|
||||||
|
@ -2787,6 +2787,10 @@ uncategorized:
|
|||||||
default: 50000
|
default: 50000
|
||||||
hidden: true
|
hidden: true
|
||||||
|
|
||||||
|
max_api_invites:
|
||||||
|
default: 200
|
||||||
|
hidden: true
|
||||||
|
|
||||||
overridden_robots_txt:
|
overridden_robots_txt:
|
||||||
default: ""
|
default: ""
|
||||||
hidden: true
|
hidden: true
|
||||||
|
122
spec/requests/api/multiple_invites_spec.rb
Normal file
122
spec/requests/api/multiple_invites_spec.rb
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
require "swagger_helper"
|
||||||
|
|
||||||
|
RSpec.describe "multiple invites" do
|
||||||
|
let(:"Api-Key") { Fabricate(:api_key).key }
|
||||||
|
let(:"Api-Username") { "system" }
|
||||||
|
|
||||||
|
path "/invites/create-multiple.json" do
|
||||||
|
post "Create multiple invites" do
|
||||||
|
tags "Invites"
|
||||||
|
operationId "createMultipleInvites"
|
||||||
|
consumes "application/json"
|
||||||
|
parameter name: "Api-Key", in: :header, type: :string, required: true
|
||||||
|
parameter name: "Api-Username", in: :header, type: :string, required: true
|
||||||
|
|
||||||
|
parameter name: :request_body,
|
||||||
|
in: :body,
|
||||||
|
schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
email: {
|
||||||
|
type: :string,
|
||||||
|
example: %w[not-a-user-yet-1@example.com not-a-user-yet-2@example.com],
|
||||||
|
description:
|
||||||
|
"pass 1 email per invite to be generated. other properties will be shared by each invite.",
|
||||||
|
},
|
||||||
|
skip_email: {
|
||||||
|
type: :boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
custom_message: {
|
||||||
|
type: :string,
|
||||||
|
description: "optional, for email invites",
|
||||||
|
},
|
||||||
|
max_redemptions_allowed: {
|
||||||
|
type: :integer,
|
||||||
|
example: 5,
|
||||||
|
default: 1,
|
||||||
|
description: "optional, for link invites",
|
||||||
|
},
|
||||||
|
topic_id: {
|
||||||
|
type: :integer,
|
||||||
|
},
|
||||||
|
group_ids: {
|
||||||
|
type: :string,
|
||||||
|
description:
|
||||||
|
"Optional, either this or `group_names`. Comma separated list for multiple ids.",
|
||||||
|
example: "42,43",
|
||||||
|
},
|
||||||
|
group_names: {
|
||||||
|
type: :string,
|
||||||
|
description:
|
||||||
|
"Optional, either this or `group_ids`. Comma separated list for multiple names.",
|
||||||
|
example: "foo,bar",
|
||||||
|
},
|
||||||
|
expires_at: {
|
||||||
|
type: :string,
|
||||||
|
description:
|
||||||
|
"optional, if not supplied, the invite_expiry_days site setting is used",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
produces "application/json"
|
||||||
|
response "200", "success response" do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
num_successfully_created_invitations: {
|
||||||
|
type: :integer,
|
||||||
|
example: 42,
|
||||||
|
},
|
||||||
|
num_failed_invitations: {
|
||||||
|
type: :integer,
|
||||||
|
example: 42,
|
||||||
|
},
|
||||||
|
failed_invitations: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
},
|
||||||
|
example: [],
|
||||||
|
},
|
||||||
|
successful_invitations: {
|
||||||
|
type: :array,
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
link: "http://example.com/invites/9045fd767efe201ca60c6658bcf14158",
|
||||||
|
email: "not-a-user-yet-1@example.com",
|
||||||
|
emailed: true,
|
||||||
|
custom_message: "Hello world!",
|
||||||
|
topics: [],
|
||||||
|
groups: [],
|
||||||
|
created_at: "2021-01-01T12:00:00.000Z",
|
||||||
|
updated_at: "2021-01-01T12:00:00.000Z",
|
||||||
|
expires_at: "2021-02-01T12:00:00.000Z",
|
||||||
|
expired: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
link: "http://example.com/invites/c6658bcf141589045fd767efe201ca60",
|
||||||
|
email: "not-a-user-yet-2@example.com",
|
||||||
|
emailed: true,
|
||||||
|
custom_message: "Hello world!",
|
||||||
|
topics: [],
|
||||||
|
groups: [],
|
||||||
|
created_at: "2021-01-01T12:00:00.000Z",
|
||||||
|
updated_at: "2021-01-01T12:00:00.000Z",
|
||||||
|
expires_at: "2021-02-01T12:00:00.000Z",
|
||||||
|
expired: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:request_body) do
|
||||||
|
{ email: %w[not-a-user-yet-1@example.com not-a-user-yet-2@example.com] }
|
||||||
|
end
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -505,6 +505,152 @@ RSpec.describe InvitesController do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#create-multiple" do
|
||||||
|
it "fails if you are not admin" do
|
||||||
|
sign_in(Fabricate(:user))
|
||||||
|
post "/invites/create-multiple.json",
|
||||||
|
params: {
|
||||||
|
email: %w[test@example.com test1@example.com bademail],
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "creates multiple invites for multiple emails" do
|
||||||
|
sign_in(admin)
|
||||||
|
post "/invites/create-multiple.json",
|
||||||
|
params: {
|
||||||
|
email: %w[test@example.com test1@example.com bademail],
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
json = JSON(response.body)
|
||||||
|
expect(json["failed_invitations"].length).to eq(1)
|
||||||
|
expect(json["successful_invitations"].length).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "creates many invite codes with one request" do #change to
|
||||||
|
sign_in(admin)
|
||||||
|
num_emails = 5 # increase manually for load testing
|
||||||
|
post "/invites/create-multiple.json",
|
||||||
|
params: {
|
||||||
|
email: 1.upto(num_emails).map { |i| "test#{i}@example.com" },
|
||||||
|
#email: %w[test+1@example.com test1@example.com]
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
json = JSON(response.body)
|
||||||
|
expect(json["failed_invitations"].length).to eq(0)
|
||||||
|
expect(json["successful_invitations"].length).to eq(num_emails)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invite to topic" do
|
||||||
|
fab!(:topic)
|
||||||
|
|
||||||
|
it "works" do
|
||||||
|
sign_in(admin)
|
||||||
|
|
||||||
|
post "/invites/create-multiple.json",
|
||||||
|
params: {
|
||||||
|
email: ["test@example.com"],
|
||||||
|
topic_id: topic.id,
|
||||||
|
invite_to_topic: true,
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(Jobs::InviteEmail.jobs.first["args"].first["invite_to_topic"]).to be_truthy
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails when topic_id is invalid" do
|
||||||
|
sign_in(admin)
|
||||||
|
|
||||||
|
post "/invites/create-multiple.json",
|
||||||
|
params: {
|
||||||
|
email: ["test@example.com"],
|
||||||
|
topic_id: -9999,
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invite to group" do
|
||||||
|
fab!(:group)
|
||||||
|
|
||||||
|
it "works for admins" do
|
||||||
|
sign_in(admin)
|
||||||
|
|
||||||
|
post "/invites/create-multiple.json",
|
||||||
|
params: {
|
||||||
|
email: ["test@example.com"],
|
||||||
|
group_ids: [group.id],
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(Invite.find_by(email: "test@example.com").invited_groups.count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "works with multiple groups" do
|
||||||
|
sign_in(admin)
|
||||||
|
group2 = Fabricate(:group)
|
||||||
|
|
||||||
|
post "/invites/create-multiple.json",
|
||||||
|
params: {
|
||||||
|
email: ["test@example.com"],
|
||||||
|
group_names: "#{group.name},#{group2.name}",
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(Invite.find_by(email: "test@example.com").invited_groups.count).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with email invite" do
|
||||||
|
subject(:create_multiple_invites) { post "/invites/create-multiple.json", params: params }
|
||||||
|
|
||||||
|
let(:params) { { email: [email] } }
|
||||||
|
let(:email) { "test@example.com" }
|
||||||
|
|
||||||
|
before { sign_in(admin) }
|
||||||
|
|
||||||
|
context "when doing successive calls" do
|
||||||
|
let(:invite) { Invite.last }
|
||||||
|
|
||||||
|
it "creates invite once and updates it after" do
|
||||||
|
create_multiple_invites
|
||||||
|
expect(response).to have_http_status :ok
|
||||||
|
expect(Jobs::InviteEmail.jobs.size).to eq(1)
|
||||||
|
|
||||||
|
create_multiple_invites
|
||||||
|
expect(response).to have_http_status :ok
|
||||||
|
expect(response.parsed_body["successful_invitations"][0]["invite"]["id"]).to eq(invite.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when "skip_email" parameter is provided' do
|
||||||
|
before { params[:skip_email] = true }
|
||||||
|
|
||||||
|
it "accepts the parameter" do
|
||||||
|
create_multiple_invites
|
||||||
|
expect(response).to have_http_status :ok
|
||||||
|
expect(Jobs::InviteEmail.jobs.size).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if asked to generate too many invites at once" do
|
||||||
|
SiteSetting.max_api_invites = 3
|
||||||
|
sign_in(admin)
|
||||||
|
post "/invites/create-multiple.json",
|
||||||
|
params: {
|
||||||
|
email: %w[
|
||||||
|
mail1@mailinator.com
|
||||||
|
mail2@mailinator.com
|
||||||
|
mail3@mailinator.com
|
||||||
|
mail4@mailinator.com
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(422)
|
||||||
|
expect(response.parsed_body["errors"][0]).to eq(
|
||||||
|
I18n.t("invite.max_invite_emails_limit_exceeded", max: SiteSetting.max_api_invites),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "#retrieve" do
|
describe "#retrieve" do
|
||||||
it "requires to be logged in" do
|
it "requires to be logged in" do
|
||||||
get "/invites/retrieve.json", params: { email: "test@example.com" }
|
get "/invites/retrieve.json", params: { email: "test@example.com" }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user