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:
marstall 2023-12-28 10:16:04 -05:00 committed by GitHub
parent 14269232ba
commit ddd750cda7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 377 additions and 30 deletions

View File

@ -37,7 +37,10 @@ class InvitesController < ApplicationController
render layout: "no_ember"
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?
topic = Topic.find_by(id: params[:topic_id])
raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
@ -59,37 +62,107 @@ class InvitesController < ApplicationController
)
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 emails.size > SiteSetting.max_api_invites
return(
render_json_error(
I18n.t("invite.max_invite_emails_limit_exceeded", max: SiteSetting.max_api_invites),
422,
)
)
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)
success = []
fail = []
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
def retrieve

View File

@ -297,6 +297,7 @@ en:
discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled."
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}."
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."
max_redemptions_allowed_one: "for email invites should be 1."
redemption_count_less_than_max: "should be less than %{max_redemptions_allowed}."

View File

@ -1432,6 +1432,7 @@ Discourse::Application.routes.draw do
resources :invites, only: %i[create update destroy]
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/destroy-all-expired" => "invites#destroy_all_expired"

View File

@ -2787,6 +2787,10 @@ uncategorized:
default: 50000
hidden: true
max_api_invites:
default: 200
hidden: true
overridden_robots_txt:
default: ""
hidden: true

View 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

View File

@ -505,6 +505,152 @@ RSpec.describe InvitesController do
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
it "requires to be logged in" do
get "/invites/retrieve.json", params: { email: "test@example.com" }