diff --git a/app/controllers/api/v1/audience_segments_controller.rb b/app/controllers/api/v1/audience_segments_controller.rb new file mode 100644 index 000000000..3b5b8c3f8 --- /dev/null +++ b/app/controllers/api/v1/audience_segments_controller.rb @@ -0,0 +1,58 @@ +module Api + module V1 + class AudienceSegmentsController < ApiController + MAX_USER_IDS = 10_000 + + before_action :authenticate_with_api_key! + before_action :require_admin + before_action :restrict_user_ids, only: %i[add_users remove_users] + + def create + @segment = AudienceSegment.create!(type_of: "manual") + + render json: @segment, status: :created + end + + def destroy + @segment = scope.find(params[:id]) + + if DisplayAd.where(audience_segment_id: @segment.id).any? + render json: { error: "Segments cannot be deleted while in use by any billboards" }, status: :conflict + else + @segment.segmented_users.in_batches.delete_all + result = @segment.destroy + render json: @segment, status: (result ? :ok : :conflict) + end + end + + def add_users + @segment = scope.find(params[:id]) + + render json: SegmentedUsers::BulkUpsert.call(@segment, user_ids: @user_ids) + end + + def remove_users + @segment = scope.find(params[:id]) + + render json: SegmentedUsers::BulkDelete.call(@segment, user_ids: @user_ids) + end + + private + + def require_admin + authorize AudienceSegment, :access?, policy_class: InternalPolicy + end + + def restrict_user_ids + @user_ids = params.permit(user_ids: []).require(:user_ids) + return unless @user_ids.size > MAX_USER_IDS + + render json: { error: "Too many user IDs provided" }, status: :unprocessable_entity + end + + def scope + AudienceSegment.manual + end + end + end +end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 156885013..d4c5768b4 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -34,7 +34,7 @@ module NotificationsHelper ) else I18n.t(action, user: key_to_link.call("user")) - end.html_safe # rubocop:disable Rails/OutputSafety + end.html_safe end def mod_comment_user(data) diff --git a/app/models/notification.rb b/app/models/notification.rb index 1413c2354..6b011b714 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -103,8 +103,8 @@ class Notification < ApplicationRecord def send_moderation_notification(notifiable) return unless [Comment, Article].include?(notifiable.class) - if notifiable.instance_of?(Comment) - return if UserBlock.blocking?(notifiable.commentable.user_id, notifiable.user_id) + if notifiable.instance_of?(Comment) && UserBlock.blocking?(notifiable.commentable.user_id, notifiable.user_id) + return end Notifications::CreateRoundRobinModerationNotificationsWorker.perform_async(notifiable.id, notifiable.class.to_s) diff --git a/app/services/segmented_users/bulk_delete.rb b/app/services/segmented_users/bulk_delete.rb new file mode 100644 index 000000000..0008012b3 --- /dev/null +++ b/app/services/segmented_users/bulk_delete.rb @@ -0,0 +1,35 @@ +module SegmentedUsers + # Preferred way to quickly remove a large number of users from an AudienceSegment + class BulkDelete + Result = Struct.new(:succeeded, :failed, keyword_init: true) + + def self.call(audience_segment, user_ids:) + new(audience_segment).call(user_ids) + end + + # @param audience_segment [AudienceSegment] the segment to remove users from + def initialize(audience_segment) + @audience_segment = audience_segment + end + + # Deletes the provided users from the AudienceSegment in batches. + # It touches the segment if any users were successfully deleted. + # + # Warning: the joining `SegmentedUsers` records are deleted without triggering any + # application-defined callbacks. Doing so is the responsibility of the caller. + # + # @param user_ids [Array] a list of `User` ids to process + # @return [SegmentedUsers::BulkDelete::Result] + def call(user_ids) + return unless @audience_segment.persisted? + + segmented_users = @audience_segment.segmented_users.where(user_id: user_ids) + valid_user_ids = segmented_users.pluck(:user_id) + deleted_count = segmented_users.in_batches.delete_all + + @audience_segment.touch if deleted_count.positive? + + Result.new(succeeded: valid_user_ids, failed: user_ids - valid_user_ids) + end + end +end diff --git a/app/services/segmented_users/bulk_upsert.rb b/app/services/segmented_users/bulk_upsert.rb new file mode 100644 index 000000000..82d8bc153 --- /dev/null +++ b/app/services/segmented_users/bulk_upsert.rb @@ -0,0 +1,80 @@ +module SegmentedUsers + # Preferred way to add a large number of users to an AudienceSegment + # Normal ActiveRecord usage emits one INSERT statement per row. + class BulkUpsert + Result = Struct.new(:succeeded, :failed, keyword_init: true) + + def self.call(audience_segment, user_ids:) + new(audience_segment).call(user_ids) + end + + # @param audience_segment [AudienceSegment] the segment to add users to + def initialize(audience_segment) + @audience_segment = audience_segment + end + + # Upserts the provided users into the AudienceSegment in batches. + # It touches the `SegmentedUser` record of any users already in the list, as + # well as the segment itself if any users were successfully upserted. + # + # Warning: the joining `SegmentedUsers` records are created without triggering any + # application-defined callbacks. Doing so is the responsibility of the caller. + # + # @param user_ids [Array] a list of `User` ids to process + # @return [SegmentedUsers::BulkUpsert::Result] + def call(user_ids) + return unless audience_segment.persisted? + + @upsert_time = Time.current + + valid_user_ids = User.where(id: user_ids).ids + upserted_user_ids = upsert_in_batches(valid_user_ids) + + audience_segment.touch unless upserted_user_ids.empty? + + Result.new(succeeded: upserted_user_ids, failed: user_ids - upserted_user_ids) + end + + private + + attr_reader :audience_segment, :upsert_time + + def upsert_in_batches(user_ids) + result = [] + + user_ids.in_groups_of(1000, false) do |ids| + succeeded = perform_upsert(ids).rows.flatten + result.concat(succeeded) + end + + result + end + + def perform_upsert(user_ids_batch) + segmented_users = build_records(user_ids_batch) + + # We only want to touch the `updated_at` column of records that already exist, + # but specifying that causes ActiveRecord to emit malformed SQL (multiple assignments to the same column). + # Turning off Rails' automatic timestamp management via `record_timestamps` + # and managing timestamps ourselves yields the correct query. + SegmentedUser.upsert_all( + segmented_users, + unique_by: :index_segmented_users_on_audience_segment_and_user, + update_only: [:updated_at], + returning: ["user_id"], + record_timestamps: false, + ) + end + + def build_records(user_ids_batch) + user_ids_batch.map do |user_id| + { + audience_segment_id: audience_segment.id, + user_id: user_id, + created_at: upsert_time, + updated_at: upsert_time + } + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 110d45e87..7206fe2eb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,6 +55,11 @@ Rails.application.routes.draw do put "unpublish", on: :member end + resources :segments, controller: "audience_segments", only: %i[create destroy] do + put "add_users", on: :member + put "remove_users", on: :member + end + resources :pages, only: %i[index show create update destroy] draw :api diff --git a/db/migrate/20230511194704_add_composite_index_to_segmented_users.rb b/db/migrate/20230511194704_add_composite_index_to_segmented_users.rb new file mode 100644 index 000000000..44d18efdc --- /dev/null +++ b/db/migrate/20230511194704_add_composite_index_to_segmented_users.rb @@ -0,0 +1,11 @@ +class AddCompositeIndexToSegmentedUsers < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + add_index :segmented_users, + %i[audience_segment_id user_id], + name: "index_segmented_users_on_audience_segment_and_user", + unique: true, + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index ee900efa9..ffaad0a6d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -998,6 +998,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_17_132219) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "user_id", null: false + t.index ["audience_segment_id", "user_id"], name: "index_segmented_users_on_audience_segment_and_user", unique: true t.index ["audience_segment_id"], name: "index_segmented_users_on_audience_segment_id" t.index ["user_id"], name: "index_segmented_users_on_user_id" end diff --git a/spec/requests/api/v1/audience_segments_spec.rb b/spec/requests/api/v1/audience_segments_spec.rb new file mode 100644 index 000000000..25a121be8 --- /dev/null +++ b/spec/requests/api/v1/audience_segments_spec.rb @@ -0,0 +1,218 @@ +require "rails_helper" + +RSpec.describe "Api::V1::AudienceSegments" do + let(:v1_headers) { { "content-type" => "application/json", "Accept" => "application/vnd.forem.api-v1+json" } } + + let(:api_secret) { create(:api_secret) } + let(:admin) { api_secret.user } + let(:headers) { v1_headers.merge({ "api-key" => api_secret.secret }) } + + before do + admin.add_role(:admin) + end + + shared_examples "an admin-only protected resource" do + context "when no API secret is provided" do + let(:headers) { v1_headers } + + it "returns unauthorized" do + make_request + expect(response).to have_http_status(:unauthorized) + end + end + + context "when the authenticated user is not an admin" do + let(:regular_api_secret) { create(:api_secret) } + let(:headers) { v1_headers.merge({ "api-key" => regular_api_secret.secret }) } + + it "returns unauthorized" do + make_request + expect(response).to have_http_status(:unauthorized) + end + end + end + + shared_examples "an endpoint for only manual audience segments" do + it "returns not found if the segment is automatic" do + segment.update!(type_of: "trusted") + make_request + expect(response).to have_http_status(:not_found) + end + + it "returns not found if the segment has been deleted" do + segment.destroy + make_request + expect(response).to have_http_status(:not_found) + end + end + + describe "POST /api/segments" do + subject(:make_request) { post api_segments_path, headers: headers } + + it_behaves_like "an admin-only protected resource" + + it "creates a new manual audience segment" do + make_request + + expect(response).to have_http_status(:created) + expect(response.media_type).to eq("application/json") + + segment = AudienceSegment.last + expect(segment.manual?).to be(true) + expect(response.parsed_body).to include( + "id" => segment.id, + "type_of" => "manual", + ) + end + end + + describe "PUT /api/segments/:id/add_users" do + subject(:make_request) do + put add_users_api_segment_path(segment.id), params: { user_ids: user_ids }, headers: headers, as: :json + end + + let(:segment) { AudienceSegment.create!(type_of: "manual") } + let(:users) { create_list(:user, 3) } + let(:user_ids) { users.map(&:id) } + + it_behaves_like "an admin-only protected resource" + + it_behaves_like "an endpoint for only manual audience segments" + + it "adds the provided users" do + make_request + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + + expect(segment.users).to match_array(users) + + data = response.parsed_body + expect(data["succeeded"]).to match_array(user_ids) + expect(data["failed"]).to be_empty + end + + it "returns user ids that failed to be added" do + fake_user_ids = [999_999, 777_777] + params = { user_ids: user_ids + fake_user_ids } + put add_users_api_segment_path(segment.id), params: params, headers: headers, as: :json + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + + expect(segment.users).to match_array(users) + + data = response.parsed_body + expect(data["succeeded"]).to match_array(user_ids) + expect(data["failed"]).to match_array(fake_user_ids) + end + + it "handles empty, missing or too large user_ids" do + put add_users_api_segment_path(segment.id), headers: headers + expect(response).to have_http_status(:unprocessable_entity) + + put add_users_api_segment_path(segment.id), params: { user_ids: [] }, headers: headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + + params = { user_ids: (1..10_100).to_a } + put add_users_api_segment_path(segment.id), params: params, headers: headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe "PUT /api/segments/:id/remove_users" do + subject(:make_request) do + put remove_users_api_segment_path(segment.id), params: { user_ids: user_ids }, headers: headers, as: :json + end + + let(:segment) { AudienceSegment.create!(type_of: "manual") } + let(:users) { create_list(:user, 3) } + let(:user_ids) { users.map(&:id) } + let(:retained_users) { create_list(:user, 3) } + let(:users_not_in_segment) { create_list(:user, 3) } + + before do + segment.users << users + segment.users << retained_users + end + + it_behaves_like "an admin-only protected resource" + + it_behaves_like "an endpoint for only manual audience segments" + + it "removes only the provided users if they are part of the segment" do + make_request + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + + expect(segment.users).to match_array(retained_users) + + data = response.parsed_body + expect(data["succeeded"]).to match_array(user_ids) + expect(data["failed"]).to be_empty + end + + it "returns user ids that failed to be removed" do + ids_to_fail = [*users_not_in_segment.map(&:id), 123_456] + params = { user_ids: user_ids + ids_to_fail } + put remove_users_api_segment_path(segment.id), params: params, headers: headers, as: :json + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + + expect(segment.users).to match_array(retained_users) + + data = response.parsed_body + expect(data["succeeded"]).to match_array(user_ids) + expect(data["failed"]).to match_array(ids_to_fail) + end + + it "handles empty, missing or too large user_ids" do + put remove_users_api_segment_path(segment.id), headers: headers + expect(response).to have_http_status(:unprocessable_entity) + + put remove_users_api_segment_path(segment.id), params: { user_ids: [] }, headers: headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + + params = { user_ids: (1..10_100).to_a } + put remove_users_api_segment_path(segment.id), params: params, headers: headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe "DELETE /api/segments/:id" do + subject(:make_request) { delete api_segment_path(segment.id), headers: headers } + + let(:segment) { AudienceSegment.create!(type_of: "manual") } + let(:users) { create_list(:user, 3) } + let(:billboard) { create(:display_ad, published: true, approved: true, type_of: "community") } + + it_behaves_like "an admin-only protected resource" + + it_behaves_like "an endpoint for only manual audience segments" + + it "destroys the segment and cleans up its list of users" do + segment.users << users + + make_request + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + expect(AudienceSegment.exists?(segment.id)).to be(false) + expect(SegmentedUser.where(audience_segment_id: segment.id)).to be_empty + end + + it "does not destroy the segment or its user list if it is associated with a billboard" do + segment.users << users + billboard.update!(audience_segment: segment) + + make_request + + expect(response).to have_http_status(:conflict) + expect(response.media_type).to eq("application/json") + expect(response.parsed_body.keys).to include("error") + expect(segment.reload.users).to match_array(users) + end + end +end diff --git a/spec/requests/api/v1/docs/audience_segments_spec.rb b/spec/requests/api/v1/docs/audience_segments_spec.rb new file mode 100644 index 000000000..00fbc75f7 --- /dev/null +++ b/spec/requests/api/v1/docs/audience_segments_spec.rb @@ -0,0 +1,289 @@ +require "rails_helper" +require "swagger_helper" + +def id_schema + { + type: :integer, + format: :int32, + minimum: 1 + } +end + +# rubocop:disable RSpec/VariableName +# rubocop:disable RSpec/EmptyExampleGroup +# rubocop:disable Layout/LineLength + +RSpec.describe "Api::V1::Docs::AudienceSegments" do + let(:Accept) { "application/vnd.forem.api-v1+json" } + let(:admin_api_secret) { create(:api_secret) } + let(:regular_api_secret) { create(:api_secret) } + let(:segment) { AudienceSegment.create!(type_of: "manual") } + let(:automatic_segment) { AudienceSegment.create!(type_of: "trusted") } + let(:users) { create_list(:user, 3) } + + before do + admin_api_secret.user.add_role(:admin) + end + + describe "POST /segments" do + path "/api/segments" do + post "Create a manually managed audience segment" do + tags "segments" + description "This endpoint allows the client to create a new audience segment.\n\nAn audience segment is a group of users that can be targeted by a DisplayAd/Billboard/Widget. This API only permits managing segments you create and maintain yourself." + operationId "createSegment" + produces "application/json" + consumes "application/json" + + response "201", "A manually managed audience segment" do + let(:"api-key") { admin_api_secret.secret } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { nil } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { regular_api_secret.secret } + + add_examples + + run_test! + end + end + end + end + + describe "DELETE /segments/:id" do + path "/api/segments/{id}" do + delete "Delete a manually managed audience segment" do + tags "segments" + description "This endpoint allows the client to delete an audience segment specified by ID.\n\nAudience segments cannot be deleted if there are still any DisplayAds/Billboards/Widgets using them." + operationId "deleteSegment" + produces "application/json" + consumes "application/json" + + parameter name: :id, in: :path, required: true, schema: id_schema + + response "200", "The deleted audience segment" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { nil } + let(:id) { segment.id } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { regular_api_secret.secret } + let(:id) { segment.id } + + add_examples + + run_test! + end + + response "404", "Audience Segment Not Found" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { automatic_segment.id } + + add_examples + + run_test! + end + + response "409", "Audience segment could not be deleted" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + let(:billboard) { create(:display_ad, published: true, approved: true) } + + before do + billboard.update!(audience_segment: segment) + end + + add_examples + + run_test! + end + end + end + end + + describe "PUT /segments/:id/add_users" do + path "/api/segments/{id}/add_users" do + put "Add users to a manually managed audience segment" do + tags "segments" + description "This endpoint allows the client to add users in bulk to an audience segment specified by ID.\n\nSuccesses are users that were included in the segment (even if they were already in it), and failures are users that could not be added to the segment." + operationId "addUsersToSegment" + produces "application/json" + consumes "application/json" + + parameter name: :id, in: :path, required: true, schema: id_schema + parameter name: :user_ids, + in: :body, + schema: { "$ref": "#/components/schemas/SegmentUserIds" } + + response "200", "Result of adding the users to the segment." do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + let(:user_ids) { { user_ids: users.map(&:id) } } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { nil } + let(:id) { segment.id } + let(:user_ids) { { user_ids: users.map(&:id) } } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { regular_api_secret.secret } + let(:id) { segment.id } + let(:user_ids) { { user_ids: users.map(&:id) } } + + add_examples + + run_test! + end + + response "404", "Audience Segment Not Found" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { automatic_segment.id } + let(:user_ids) { { user_ids: users.map(&:id) } } + + add_examples + + run_test! + end + + response "422", "Unprocessable Entity" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + let(:user_ids) { { user_ids: [] } } + + add_examples + + run_test! + end + + response "422", "Unprocessable Entity" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + let(:user_ids) { (1..10_100).to_a } + + add_examples + + run_test! + end + end + end + end + + describe "PUT /segments/:id/remove_users" do + path "/api/segments/{id}/remove_users" do + put "Remove users from a manually managed audience segment" do + tags "segments" + description "This endpoint allows the client to remove users in bulk from an audience segment specified by ID.\n\nSuccesses are users that were removed; failures are users that weren't a part of the segment." + operationId "removeUsersFromSegment" + produces "application/json" + consumes "application/json" + + parameter name: :id, in: :path, required: true, schema: id_schema + parameter name: :user_ids, + in: :body, + schema: { "$ref": "#/components/schemas/SegmentUserIds" } + + before do + segment.users << users + end + + response "200", "Result of removing the users to the segment." do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + let(:user_ids) { { user_ids: users.map(&:id) } } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { nil } + let(:id) { segment.id } + let(:user_ids) { { user_ids: users.map(&:id) } } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { regular_api_secret.secret } + let(:id) { segment.id } + let(:user_ids) { { user_ids: users.map(&:id) } } + + add_examples + + run_test! + end + + response "404", "Audience Segment Not Found" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { automatic_segment.id } + let(:user_ids) { { user_ids: users.map(&:id) } } + + add_examples + + run_test! + end + + response "422", "Unprocessable Entity" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + let(:user_ids) { { user_ids: [] } } + + add_examples + + run_test! + end + + response "422", "Unprocessable Entity" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + let(:user_ids) { (1..10_100).to_a } + + add_examples + + run_test! + end + end + end + end +end + +# rubocop:enable RSpec/VariableName +# rubocop:enable RSpec/EmptyExampleGroup +# rubocop:enable Layout/LineLength diff --git a/spec/services/segmented_users/bulk_delete_spec.rb b/spec/services/segmented_users/bulk_delete_spec.rb new file mode 100644 index 000000000..78652edba --- /dev/null +++ b/spec/services/segmented_users/bulk_delete_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe SegmentedUsers::BulkDelete, type: :service do + let(:audience_segment) { AudienceSegment.create!(type_of: "manual") } + let(:users) { create_list(:user, 5) } + + it "removes only the passed users from the segment" do + retained_users = create_list(:user, 3) + + audience_segment.users << users + audience_segment.users << retained_users + + user_ids = users.map(&:id) + result = described_class.call(audience_segment, user_ids: user_ids) + expect(result.succeeded).to match_array(user_ids) + expect(result.failed).to be_empty + expect(audience_segment.users).to match_array(retained_users) + end + + it "gracefully handles users that don't exist or aren't part of the segment" do + user_in_segment = users.first + audience_segment.users << user_in_segment + + user_not_in_segment = create(:user) + user_ids = [user_in_segment.id, user_not_in_segment.id, "foo", 1_234_567_890] + + result = described_class.call(audience_segment, user_ids: user_ids) + expect(result.succeeded).to contain_exactly(user_in_segment.id) + expect(result.failed).to contain_exactly(user_not_in_segment.id, "foo", 1_234_567_890) + expect(audience_segment.reload.users).to be_empty + end +end diff --git a/spec/services/segmented_users/bulk_upsert_spec.rb b/spec/services/segmented_users/bulk_upsert_spec.rb new file mode 100644 index 000000000..61cf42ed2 --- /dev/null +++ b/spec/services/segmented_users/bulk_upsert_spec.rb @@ -0,0 +1,86 @@ +require "rails_helper" + +RSpec.describe SegmentedUsers::BulkUpsert, type: :service do + let(:audience_segment) { AudienceSegment.create!(type_of: "manual") } + let(:users) { create_list(:user, 5) } + + it "adds the users to the segment" do + user_ids = users.map(&:id) + result = described_class.call(audience_segment, user_ids: user_ids) + expect(result.succeeded).to match_array(user_ids) + expect(result.failed).to be_empty + expect(audience_segment.users).to match_array(users) + end + + it "retains any existing users in the segment" do + existing_user = create(:user) + audience_segment.users << [existing_user] + + user_ids = users.map(&:id) + result = described_class.call(audience_segment, user_ids: user_ids) + expect(result.succeeded).to match_array(user_ids) + expect(result.failed).to be_empty + expect(audience_segment.users).to contain_exactly(*users, existing_user) + end + + it "only touches segmented users already in the list" do + created_time = Time.current + upsert_time = 3.days.from_now + existing_users = create_list(:user, 3) + existing_user_ids = existing_users.map(&:id) + + Timecop.freeze(created_time) do + audience_segment.users << existing_users + end + + Timecop.freeze(upsert_time) do + user_ids = users.map(&:id) + existing_user_ids + result = described_class.call(audience_segment, user_ids: user_ids) + expect(result.succeeded).to match_array(user_ids) + expect(result.failed).to be_empty + + existing_segmented_users = audience_segment.segmented_users.where(user_id: existing_user_ids) + expect(existing_segmented_users.map(&:created_at)).to all(be_within(1.second).of(created_time)) + expect(existing_segmented_users.map(&:updated_at)).to all(be_within(1.second).of(upsert_time)) + end + end + + it "only touches the segment if any users were successfully upserted" do + user = create(:user) + time = Time.current + + Timecop.freeze(time) do + described_class.call(audience_segment, user_ids: [user.id]) + expect(audience_segment.updated_at).to be_within(1.second).of(time) + + Timecop.travel(1.day.from_now) do + described_class.call(audience_segment, user_ids: []) + expect(audience_segment.updated_at).to be_within(1.second).of(time) + end + + Timecop.travel(2.days.from_now) do + described_class.call(audience_segment, user_ids: [user.id]) + expect(audience_segment.updated_at).to be_within(1.second).of(Time.current) + end + end + end + + it "gracefully handles users that don't exist" do + real_user = create(:user) + user_ids = [real_user.id, "foo", 1_234_567_890] + result = described_class.call(audience_segment, user_ids: user_ids) + + expect(result.succeeded).to contain_exactly(real_user.id) + expect(result.failed).to contain_exactly("foo", 1_234_567_890) + expect(audience_segment.users).to contain_exactly(real_user) + end + + it "returns immediately if the segment is not persisted" do + audience_segment.destroy + + user_ids = users.map(&:id) + result = described_class.call(audience_segment, user_ids: user_ids) + expect(result).to be_nil + expect(SegmentedUser.where(audience_segment_id: audience_segment.id)).to be_empty + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 7bac1d85e..2e6ab3e89 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -430,6 +430,25 @@ The default maximum value can be overridden by \"API_PER_PAGE_MAX\" environment } }, required: %w[name body_markdown placement_area] + }, + Segment: { + description: "A manually managed audience segment", + type: "object", + properties: { + id: { type: :integer, description: "The ID of the segment" }, + type_of: { type: :string, enum: ["manual"], default: "manual", description: "Marks the segment as manually managed (other types are internal)" }, + user_count: { type: :integer, description: "The current number of users in the segment" } + } + }, + SegmentUserIds: { + type: "object", + properties: { + user_ids: { + type: :array, + items: { type: :integer }, + maxItems: 10_000 + } + } } } } diff --git a/swagger/v1/api_v1.json b/swagger/v1/api_v1.json index 2cffb0d99..dce343612 100644 --- a/swagger/v1/api_v1.json +++ b/swagger/v1/api_v1.json @@ -20,40 +20,40 @@ "application/json": { "example": { "type_of": "article", - "id": 251, + "id": 205, "title": "New article", "description": "New post example", - "readable_publish_date": "Apr 14", - "slug": "new-article-4aca", - "path": "/username383/new-article-4aca", - "url": "http://localhost:3000/username383/new-article-4aca", + "readable_publish_date": "May 19", + "slug": "new-article-8dk", + "path": "/username492/new-article-8dk", + "url": "http://localhost:3000/username492/new-article-8dk", "comments_count": 0, "public_reactions_count": 0, "collection_id": 11, - "published_timestamp": "2023-04-14T14:45:32Z", + "published_timestamp": "2023-05-19T18:29:17Z", "positive_reactions_count": 0, "cover_image": "https://thepracticaldev.s3.amazonaws.com/i/5wfo25724gzgk5e5j50g.jpg", "social_image": "https://thepracticaldev.s3.amazonaws.com/i/5wfo25724gzgk5e5j50g.jpg", "canonical_url": "https://dev.to/fdocr/headless-chrome-dual-mode-tests-for-ruby-on-rails-4p6g", - "created_at": "2023-04-14T14:45:32Z", + "created_at": "2023-05-19T18:29:17Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-04-14T14:45:32Z", - "last_comment_at": "2023-04-14T14:45:32Z", + "published_at": "2023-05-19T18:29:17Z", + "last_comment_at": "2023-05-19T18:29:17Z", "reading_time_minutes": 1, "tag_list": "", "tags": [], "body_html": "

New body for the article

\n\n", "body_markdown": "**New** body for the article", "user": { - "name": "Nu \"Otis\" \\:/ Stehr", - "username": "username383", - "twitter_username": "twitter383", - "github_username": "github383", - "user_id": 1304, + "name": "Mackenzie \"Phillip\" \\:/ Denesik", + "username": "username492", + "twitter_username": "twitter492", + "github_username": "github492", + "user_id": 971, "website_url": null, - "profile_image": "/uploads/user/profile_image/1304/ceaf627b-1551-4771-8d35-a93b40989584.jpeg", - "profile_image_90": "/uploads/user/profile_image/1304/ceaf627b-1551-4771-8d35-a93b40989584.jpeg" + "profile_image": "/uploads/user/profile_image/971/5f4e2603-98df-4bd4-bee5-255b15db88b2.jpeg", + "profile_image_90": "/uploads/user/profile_image/971/5f4e2603-98df-4bd4-bee5-255b15db88b2.jpeg" } } } @@ -188,45 +188,45 @@ "example": [ { "type_of": "article", - "id": 254, - "title": "All the King's Men175", - "description": "Typewriter crucifix forage. Pug put a bird on it art party taxidermy asymmetrical xoxo. Sustainable...", - "readable_publish_date": "Apr 14", - "slug": "all-the-kings-men175-794", - "path": "/username387/all-the-kings-men175-794", - "url": "http://localhost:3000/username387/all-the-kings-men175-794", + "id": 208, + "title": "It's a Battlefield188", + "description": "Occupy bespoke church-key green juice yolo pitchfork cleanse skateboard. Artisan tattooed wes...", + "readable_publish_date": "May 19", + "slug": "its-a-battlefield188-40dk", + "path": "/username496/its-a-battlefield188-40dk", + "url": "http://localhost:3000/username496/its-a-battlefield188-40dk", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-04-14T14:45:32Z", + "published_timestamp": "2023-05-19T18:29:17Z", "positive_reactions_count": 0, "cover_image": "http://localhost:3000/assets/36-83d24fbff858b9dd4035d1e7d2df14090946ae4fed631055fc1d5862e7018348.png", "social_image": "http://localhost:3000/assets/36-83d24fbff858b9dd4035d1e7d2df14090946ae4fed631055fc1d5862e7018348.png", - "canonical_url": "http://localhost:3000/username387/all-the-kings-men175-794", - "created_at": "2023-04-14T14:45:32Z", + "canonical_url": "http://localhost:3000/username496/its-a-battlefield188-40dk", + "created_at": "2023-05-19T18:29:17Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-04-14T14:45:32Z", - "last_comment_at": "2023-04-14T14:45:32Z", + "published_at": "2023-05-19T18:29:17Z", + "last_comment_at": "2023-05-19T18:29:17Z", "reading_time_minutes": 1, "tag_list": ["discuss"], "tags": "discuss", "user": { - "name": "Versie \"Luana\" \\:/ Runolfsson", - "username": "username387", - "twitter_username": "twitter387", - "github_username": "github387", - "user_id": 1308, + "name": "Renea \"Darell\" \\:/ Quigley", + "username": "username496", + "twitter_username": "twitter496", + "github_username": "github496", + "user_id": 975, "website_url": null, - "profile_image": "/uploads/user/profile_image/1308/dfa25219-dfea-4d9a-93ec-403a5f51a29e.jpeg", - "profile_image_90": "/uploads/user/profile_image/1308/dfa25219-dfea-4d9a-93ec-403a5f51a29e.jpeg" + "profile_image": "/uploads/user/profile_image/975/b332eb63-0a86-43ee-8fc1-c2db1dc17742.jpeg", + "profile_image_90": "/uploads/user/profile_image/975/b332eb63-0a86-43ee-8fc1-c2db1dc17742.jpeg" }, "organization": { - "name": "Ledner, Jaskolski and Bednar", - "username": "org70", - "slug": "org70", - "profile_image": "/uploads/organization/profile_image/295/216d3fb5-bd3c-459a-a9d8-572e8332fd88.png", - "profile_image_90": "/uploads/organization/profile_image/295/216d3fb5-bd3c-459a-a9d8-572e8332fd88.png" + "name": "Medhurst-Leannon", + "username": "org73", + "slug": "org73", + "profile_image": "/uploads/organization/profile_image/75/dcf65feb-872c-4d11-a44e-a48ef8bfe649.png", + "profile_image_90": "/uploads/organization/profile_image/75/dcf65feb-872c-4d11-a44e-a48ef8bfe649.png" }, "flare_tag": { "name": "discuss", @@ -270,38 +270,38 @@ "example": [ { "type_of": "article", - "id": 257, - "title": "East of Eden178", - "description": "Readymade diy ennui humblebrag 8-bit. Brooklyn hoodie pickled art party vinyl small batch roof. 8-bit...", - "readable_publish_date": "Apr 14", - "slug": "east-of-eden178-3on4", - "path": "/username390/east-of-eden178-3on4", - "url": "http://localhost:3000/username390/east-of-eden178-3on4", + "id": 211, + "title": "Everything is Illuminated191", + "description": "Fingerstache chia kickstarter celiac distillery disrupt. Pour-over sriracha direct trade tumblr...", + "readable_publish_date": "May 19", + "slug": "everything-is-illuminated191-31kc", + "path": "/username499/everything-is-illuminated191-31kc", + "url": "http://localhost:3000/username499/everything-is-illuminated191-31kc", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-04-14T14:45:32Z", + "published_timestamp": "2023-05-19T18:29:17Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/16-77521848e7b5fcc073ac3e0bb004826e97f737238194e4c79330f662cc946ab2.png", - "social_image": "http://localhost:3000/assets/16-77521848e7b5fcc073ac3e0bb004826e97f737238194e4c79330f662cc946ab2.png", - "canonical_url": "http://localhost:3000/username390/east-of-eden178-3on4", - "created_at": "2023-04-14T14:45:32Z", + "cover_image": "http://localhost:3000/assets/10-56ac1726da8a3bcbe4f93b48752287ea41bb79199cd8a8a61a9e4280ce9ae5b8.png", + "social_image": "http://localhost:3000/assets/10-56ac1726da8a3bcbe4f93b48752287ea41bb79199cd8a8a61a9e4280ce9ae5b8.png", + "canonical_url": "http://localhost:3000/username499/everything-is-illuminated191-31kc", + "created_at": "2023-05-19T18:29:17Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-04-14T14:45:32Z", - "last_comment_at": "2023-04-14T14:45:32Z", + "published_at": "2023-05-19T18:29:17Z", + "last_comment_at": "2023-05-19T18:29:17Z", "reading_time_minutes": 1, "tag_list": ["javascript", "html", "discuss"], "tags": "javascript, html, discuss", "user": { - "name": "Starla \"Eric\" \\:/ Kunde", - "username": "username390", - "twitter_username": "twitter390", - "github_username": "github390", - "user_id": 1311, + "name": "Arnold \"Bridgette\" \\:/ Hegmann", + "username": "username499", + "twitter_username": "twitter499", + "github_username": "github499", + "user_id": 978, "website_url": null, - "profile_image": "/uploads/user/profile_image/1311/11a4ee42-e02e-4b2f-87c1-6123579350b7.jpeg", - "profile_image_90": "/uploads/user/profile_image/1311/11a4ee42-e02e-4b2f-87c1-6123579350b7.jpeg" + "profile_image": "/uploads/user/profile_image/978/1775dd15-8975-4027-974d-f1bde7777343.jpeg", + "profile_image_90": "/uploads/user/profile_image/978/1775dd15-8975-4027-974d-f1bde7777343.jpeg" }, "flare_tag": { "name": "discuss", @@ -311,38 +311,38 @@ }, { "type_of": "article", - "id": 256, - "title": "A Many-Splendoured Thing177", - "description": "Letterpress neutra vice raw denim mumblecore organic small batch. Bushwick viral freegan photo booth...", - "readable_publish_date": "Apr 14", - "slug": "a-many-splendoured-thing177-314b", - "path": "/username389/a-many-splendoured-thing177-314b", - "url": "http://localhost:3000/username389/a-many-splendoured-thing177-314b", + "id": 210, + "title": "Jesting Pilate190", + "description": "Drinking carry put a bird on it tacos banjo chambray wolf yuccie. Selfies hashtag lomo hammock...", + "readable_publish_date": "May 19", + "slug": "jesting-pilate190-57nh", + "path": "/username498/jesting-pilate190-57nh", + "url": "http://localhost:3000/username498/jesting-pilate190-57nh", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-04-14T14:45:32Z", + "published_timestamp": "2023-05-19T18:29:17Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/27-441873f471d98b5358beff7d47a211e58b9979c6453794f9a7abfd5709c33322.png", - "social_image": "http://localhost:3000/assets/27-441873f471d98b5358beff7d47a211e58b9979c6453794f9a7abfd5709c33322.png", - "canonical_url": "http://localhost:3000/username389/a-many-splendoured-thing177-314b", - "created_at": "2023-04-14T14:45:32Z", + "cover_image": "http://localhost:3000/assets/6-52ee7e994a1369d112d656dfffbf324d1f209a7372a761776f77aeea47b0e4a8.png", + "social_image": "http://localhost:3000/assets/6-52ee7e994a1369d112d656dfffbf324d1f209a7372a761776f77aeea47b0e4a8.png", + "canonical_url": "http://localhost:3000/username498/jesting-pilate190-57nh", + "created_at": "2023-05-19T18:29:17Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-04-14T14:45:32Z", - "last_comment_at": "2023-04-14T14:45:32Z", + "published_at": "2023-05-19T18:29:17Z", + "last_comment_at": "2023-05-19T18:29:17Z", "reading_time_minutes": 1, "tag_list": ["javascript", "html", "discuss"], "tags": "javascript, html, discuss", "user": { - "name": "Jeanne \"Donnette\" \\:/ Waelchi", - "username": "username389", - "twitter_username": "twitter389", - "github_username": "github389", - "user_id": 1310, + "name": "Pa \"Dianne\" \\:/ Schaefer", + "username": "username498", + "twitter_username": "twitter498", + "github_username": "github498", + "user_id": 977, "website_url": null, - "profile_image": "/uploads/user/profile_image/1310/67d345a8-f64b-407f-a802-6f5286b5c4a2.jpeg", - "profile_image_90": "/uploads/user/profile_image/1310/67d345a8-f64b-407f-a802-6f5286b5c4a2.jpeg" + "profile_image": "/uploads/user/profile_image/977/42990b84-0983-47c6-a653-8fd726833712.jpeg", + "profile_image_90": "/uploads/user/profile_image/977/42990b84-0983-47c6-a653-8fd726833712.jpeg" }, "flare_tag": { "name": "discuss", @@ -352,38 +352,38 @@ }, { "type_of": "article", - "id": 255, - "title": "What's Become of Waring176", - "description": "Whatever church-key irony next level tacos banh mi. Brooklyn wayfarers occupy tacos austin marfa...", - "readable_publish_date": "Apr 14", - "slug": "whats-become-of-waring176-2lgk", - "path": "/username388/whats-become-of-waring176-2lgk", - "url": "http://localhost:3000/username388/whats-become-of-waring176-2lgk", + "id": 209, + "title": "The Grapes of Wrath189", + "description": "Chicharrones shabby chic vegan chia banh mi godard cliche. Polaroid master pork belly dreamcatcher...", + "readable_publish_date": "May 19", + "slug": "the-grapes-of-wrath189-3j12", + "path": "/username497/the-grapes-of-wrath189-3j12", + "url": "http://localhost:3000/username497/the-grapes-of-wrath189-3j12", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-04-14T14:45:32Z", + "published_timestamp": "2023-05-19T18:29:17Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/31-2a89a91581ce9080fed8d62dd9c70a3fd5f92472da8c023e7b29256e04811b2e.png", - "social_image": "http://localhost:3000/assets/31-2a89a91581ce9080fed8d62dd9c70a3fd5f92472da8c023e7b29256e04811b2e.png", - "canonical_url": "http://localhost:3000/username388/whats-become-of-waring176-2lgk", - "created_at": "2023-04-14T14:45:32Z", + "cover_image": "http://localhost:3000/assets/11-f4a704eef06d25d2d2fa2026ef08f1089754beaf5b6ee01160115d3c36ed3d34.png", + "social_image": "http://localhost:3000/assets/11-f4a704eef06d25d2d2fa2026ef08f1089754beaf5b6ee01160115d3c36ed3d34.png", + "canonical_url": "http://localhost:3000/username497/the-grapes-of-wrath189-3j12", + "created_at": "2023-05-19T18:29:17Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-04-14T14:45:32Z", - "last_comment_at": "2023-04-14T14:45:32Z", + "published_at": "2023-05-19T18:29:17Z", + "last_comment_at": "2023-05-19T18:29:17Z", "reading_time_minutes": 1, "tag_list": ["javascript", "html", "discuss"], "tags": "javascript, html, discuss", "user": { - "name": "Jerrell \"Essie\" \\:/ Runolfsdottir", - "username": "username388", - "twitter_username": "twitter388", - "github_username": "github388", - "user_id": 1309, + "name": "Celinda \"Arnetta\" \\:/ Marvin", + "username": "username497", + "twitter_username": "twitter497", + "github_username": "github497", + "user_id": 976, "website_url": null, - "profile_image": "/uploads/user/profile_image/1309/eaae0231-0ff6-49da-960c-cd30e2486bed.jpeg", - "profile_image_90": "/uploads/user/profile_image/1309/eaae0231-0ff6-49da-960c-cd30e2486bed.jpeg" + "profile_image": "/uploads/user/profile_image/976/fa74d790-af45-4a78-835d-dae9b6ed557e.jpeg", + "profile_image_90": "/uploads/user/profile_image/976/fa74d790-af45-4a78-835d-dae9b6ed557e.jpeg" }, "flare_tag": { "name": "discuss", @@ -428,40 +428,40 @@ "application/json": { "example": { "type_of": "article", - "id": 258, - "title": "Pale Kings and Princes179", - "description": "Etsy you probably haven't heard of them carry humblebrag 90's try-hard. Distillery asymmetrical...", - "readable_publish_date": "Apr 14", - "slug": "pale-kings-and-princes179-381c", - "path": "/username391/pale-kings-and-princes179-381c", - "url": "http://localhost:3000/username391/pale-kings-and-princes179-381c", + "id": 212, + "title": "Shall not Perish192", + "description": "Shabby chic cardigan forage dreamcatcher flexitarian farm-to-table health drinking. Tilde cliche...", + "readable_publish_date": "May 19", + "slug": "shall-not-perish192-5elm", + "path": "/username500/shall-not-perish192-5elm", + "url": "http://localhost:3000/username500/shall-not-perish192-5elm", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-04-14T14:45:32Z", + "published_timestamp": "2023-05-19T18:29:17Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/19-ed58d3e8defcefc445020631589697a05e725243e834b5192aee4e6b91a3e927.png", - "social_image": "http://localhost:3000/assets/19-ed58d3e8defcefc445020631589697a05e725243e834b5192aee4e6b91a3e927.png", - "canonical_url": "http://localhost:3000/username391/pale-kings-and-princes179-381c", - "created_at": "2023-04-14T14:45:32Z", + "cover_image": "http://localhost:3000/assets/28-45c03a7ee6bce4cd3ddd541bd942a0c7995fa77e4b680563681529e9dd14a676.png", + "social_image": "http://localhost:3000/assets/28-45c03a7ee6bce4cd3ddd541bd942a0c7995fa77e4b680563681529e9dd14a676.png", + "canonical_url": "http://localhost:3000/username500/shall-not-perish192-5elm", + "created_at": "2023-05-19T18:29:18Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-04-14T14:45:32Z", - "last_comment_at": "2023-04-14T14:45:32Z", + "published_at": "2023-05-19T18:29:17Z", + "last_comment_at": "2023-05-19T18:29:17Z", "reading_time_minutes": 1, "tag_list": "discuss", "tags": ["discuss"], - "body_html": "

Etsy you probably haven't heard of them carry humblebrag 90's try-hard. Distillery asymmetrical godard trust fund quinoa pug paleo. Letterpress green juice plaid.

\n\n

Organic +1 pour-over banh mi disrupt listicle. Cronut offal flexitarian twee health poutine cred. Hashtag godard church-key etsy put a bird on it.

\n\n", - "body_markdown": "---\ntitle: Pale Kings and Princes179\npublished: true\ntags: discuss\ndate: \nseries: \ncanonical_url: \n\n---\n\nEtsy you probably haven't heard of them carry humblebrag 90's try-hard. Distillery asymmetrical godard trust fund quinoa pug paleo. Letterpress green juice plaid.\n\n\nOrganic +1 pour-over banh mi disrupt listicle. Cronut offal flexitarian twee health poutine cred. Hashtag godard church-key etsy put a bird on it.\n\n", + "body_html": "

Shabby chic cardigan forage dreamcatcher flexitarian farm-to-table health drinking. Tilde cliche echo. Sriracha health farm-to-table pabst. Chia roof blue bottle lomo meggings.

\n\n

Craft beer shoreditch 90's aesthetic. Actually retro lo-fi loko next level venmo deep v fanny pack. Distillery actually church-key loko beard listicle lo-fi normcore.

\n\n", + "body_markdown": "---\ntitle: Shall not Perish192\npublished: true\ntags: discuss\ndate: \nseries: \ncanonical_url: \n\n---\n\nShabby chic cardigan forage dreamcatcher flexitarian farm-to-table health drinking. Tilde cliche echo. Sriracha health farm-to-table pabst. Chia roof blue bottle lomo meggings.\n\n\nCraft beer shoreditch 90's aesthetic. Actually retro lo-fi loko next level venmo deep v fanny pack. Distillery actually church-key loko beard listicle lo-fi normcore.\n\n", "user": { - "name": "Val \"Antonina\" \\:/ Gleichner", - "username": "username391", - "twitter_username": "twitter391", - "github_username": "github391", - "user_id": 1312, + "name": "Deneen \"Cary\" \\:/ Klein", + "username": "username500", + "twitter_username": "twitter500", + "github_username": "github500", + "user_id": 979, "website_url": null, - "profile_image": "/uploads/user/profile_image/1312/2eec3cd5-e7fe-42ac-bbfa-27c84d847596.jpeg", - "profile_image_90": "/uploads/user/profile_image/1312/2eec3cd5-e7fe-42ac-bbfa-27c84d847596.jpeg" + "profile_image": "/uploads/user/profile_image/979/b7150e73-9c6b-49f0-8c58-1d67c2c5825a.jpeg", + "profile_image_90": "/uploads/user/profile_image/979/b7150e73-9c6b-49f0-8c58-1d67c2c5825a.jpeg" }, "flare_tag": { "name": "discuss", @@ -517,40 +517,40 @@ "application/json": { "example": { "type_of": "article", - "id": 259, - "title": "Noli Me Tangere180", - "description": "Hoodie meh knausgaard bespoke actually shabby chic polaroid. Tumblr ennui semiotics freegan diy...", - "readable_publish_date": "Apr 14", - "slug": "noli-me-tangere180-55bp", - "path": "/username392/noli-me-tangere180-55bp", - "url": "http://localhost:3000/username392/noli-me-tangere180-55bp", + "id": 213, + "title": "Down to a Sunless Sea193", + "description": "Wayfarers you probably haven't heard of them ethical fingerstache tote bag locavore raw denim green...", + "readable_publish_date": "May 19", + "slug": "down-to-a-sunless-sea193-3f6k", + "path": "/username501/down-to-a-sunless-sea193-3f6k", + "url": "http://localhost:3000/username501/down-to-a-sunless-sea193-3f6k", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-04-14T14:45:33Z", + "published_timestamp": "2023-05-19T18:29:18Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/3-93b6b57b5a6115cffe5d63d29a22825eb9e65f647bfef57a88244bc2b98186f0.png", - "social_image": "http://localhost:3000/assets/3-93b6b57b5a6115cffe5d63d29a22825eb9e65f647bfef57a88244bc2b98186f0.png", - "canonical_url": "http://localhost:3000/username392/noli-me-tangere180-55bp", - "created_at": "2023-04-14T14:45:33Z", - "edited_at": "2023-04-14T14:45:33Z", + "cover_image": "http://localhost:3000/assets/20-92231c1d2ddb3b707b8c1b5cb711ef17632ff2a64495970a58518ce33c3a4f76.png", + "social_image": "http://localhost:3000/assets/20-92231c1d2ddb3b707b8c1b5cb711ef17632ff2a64495970a58518ce33c3a4f76.png", + "canonical_url": "http://localhost:3000/username501/down-to-a-sunless-sea193-3f6k", + "created_at": "2023-05-19T18:29:18Z", + "edited_at": "2023-05-19T18:29:18Z", "crossposted_at": null, - "published_at": "2023-04-14T14:45:33Z", - "last_comment_at": "2023-04-14T14:45:33Z", + "published_at": "2023-05-19T18:29:18Z", + "last_comment_at": "2023-05-19T18:29:18Z", "reading_time_minutes": 1, "tag_list": "", "tags": [], "body_html": "

New body for the article

\n\n", "body_markdown": "**New** body for the article", "user": { - "name": "Lai \"Aide\" \\:/ Will", - "username": "username392", - "twitter_username": "twitter392", - "github_username": "github392", - "user_id": 1313, + "name": "Curtis \"Denyse\" \\:/ Spinka", + "username": "username501", + "twitter_username": "twitter501", + "github_username": "github501", + "user_id": 980, "website_url": null, - "profile_image": "/uploads/user/profile_image/1313/731805e8-95f2-4260-89f1-489df0c9e945.jpeg", - "profile_image_90": "/uploads/user/profile_image/1313/731805e8-95f2-4260-89f1-489df0c9e945.jpeg" + "profile_image": "/uploads/user/profile_image/980/f6afcce8-8d57-47bf-b17a-f7110750d0c9.jpeg", + "profile_image_90": "/uploads/user/profile_image/980/f6afcce8-8d57-47bf-b17a-f7110750d0c9.jpeg" } } } @@ -633,40 +633,40 @@ "application/json": { "example": { "type_of": "article", - "id": 262, - "title": "Noli Me Tangere183", - "description": "Normcore williamsburg try-hard artisan. Vinyl park shoreditch gastropub vegan knausgaard. Ethical...", - "readable_publish_date": "Apr 14", - "slug": "noli-me-tangere183-1lj5", - "path": "/username396/noli-me-tangere183-1lj5", - "url": "http://localhost:3000/username396/noli-me-tangere183-1lj5", + "id": 216, + "title": "The Daffodil Sky196", + "description": "Lo-fi butcher yolo tofu pbr&b. Hammock bitters 8-bit listicle locavore flannel. Swag thundercats...", + "readable_publish_date": "May 19", + "slug": "the-daffodil-sky196-4172", + "path": "/username505/the-daffodil-sky196-4172", + "url": "http://localhost:3000/username505/the-daffodil-sky196-4172", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-04-14T14:45:33Z", + "published_timestamp": "2023-05-19T18:29:18Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/37-94b286825ffd9f2b47c9842cf4f262b7c89e789797eba40196bc14b5c2359e75.png", - "social_image": "http://localhost:3000/assets/37-94b286825ffd9f2b47c9842cf4f262b7c89e789797eba40196bc14b5c2359e75.png", - "canonical_url": "http://localhost:3000/username396/noli-me-tangere183-1lj5", - "created_at": "2023-04-14T14:45:33Z", + "cover_image": "http://localhost:3000/assets/39-20fc2599938fbd7c41146577147aa7c6925e3b8ff069aba58c9304bc1b944cf1.png", + "social_image": "http://localhost:3000/assets/39-20fc2599938fbd7c41146577147aa7c6925e3b8ff069aba58c9304bc1b944cf1.png", + "canonical_url": "http://localhost:3000/username505/the-daffodil-sky196-4172", + "created_at": "2023-05-19T18:29:18Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-04-14T14:45:33Z", - "last_comment_at": "2023-04-14T14:45:33Z", + "published_at": "2023-05-19T18:29:18Z", + "last_comment_at": "2023-05-19T18:29:18Z", "reading_time_minutes": 1, "tag_list": "discuss", "tags": ["discuss"], - "body_html": "

Normcore williamsburg try-hard artisan. Vinyl park shoreditch gastropub vegan knausgaard.

\n\n

Ethical trust fund intelligentsia pbr&b. Brunch seitan pug waistcoat farm-to-table flexitarian health. Tumblr banh mi goth retro diy.

\n\n", - "body_markdown": "---\ntitle: Noli Me Tangere183\npublished: true\ntags: discuss\ndate: \nseries: \ncanonical_url: \n\n---\n\nNormcore williamsburg try-hard artisan. Vinyl park shoreditch gastropub vegan knausgaard.\n\n\nEthical trust fund intelligentsia pbr&b. Brunch seitan pug waistcoat farm-to-table flexitarian health. Tumblr banh mi goth retro diy.\n\n", + "body_html": "

Lo-fi butcher yolo tofu pbr&b. Hammock bitters 8-bit listicle locavore flannel. Swag thundercats kitsch plaid.

\n\n

Selfies cleanse pitchfork small batch. Goth fixie pinterest loko plaid. Tousled heirloom iphone chartreuse next level hoodie shabby chic.

\n\n", + "body_markdown": "---\ntitle: The Daffodil Sky196\npublished: true\ntags: discuss\ndate: \nseries: \ncanonical_url: \n\n---\n\nLo-fi butcher yolo tofu pbr&b. Hammock bitters 8-bit listicle locavore flannel. Swag thundercats kitsch plaid.\n\n\nSelfies cleanse pitchfork small batch. Goth fixie pinterest loko plaid. Tousled heirloom iphone chartreuse next level hoodie shabby chic.\n\n", "user": { - "name": "Ashely \"Erich\" \\:/ Waters", - "username": "username396", - "twitter_username": "twitter396", - "github_username": "github396", - "user_id": 1317, + "name": "Arvilla \"Terresa\" \\:/ Torphy", + "username": "username505", + "twitter_username": "twitter505", + "github_username": "github505", + "user_id": 984, "website_url": null, - "profile_image": "/uploads/user/profile_image/1317/e7282d93-954d-4b13-805e-9e5636ad9d63.jpeg", - "profile_image_90": "/uploads/user/profile_image/1317/e7282d93-954d-4b13-805e-9e5636ad9d63.jpeg" + "profile_image": "/uploads/user/profile_image/984/0e9fdf52-9e90-4593-acc1-cae001b6a03b.jpeg", + "profile_image_90": "/uploads/user/profile_image/984/0e9fdf52-9e90-4593-acc1-cae001b6a03b.jpeg" }, "flare_tag": { "name": "discuss", @@ -928,6 +928,257 @@ } } }, + "/api/segments": { + "post": { + "summary": "Create a manually managed audience segment", + "tags": ["segments"], + "description": "This endpoint allows the client to create a new audience segment.\n\nAn audience segment is a group of users that can be targeted by a DisplayAd/Billboard/Widget. This API only permits managing segments you create and maintain yourself.", + "operationId": "createSegment", + "responses": { + "201": { + "description": "A manually managed audience segment", + "content": { + "application/json": { + "example": { + "id": 129, + "created_at": "2023-05-19T13:29:19.245-05:00", + "type_of": "manual", + "updated_at": "2023-05-19T13:29:19.245-05:00" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + } + } + } + }, + "/api/segments/{id}": { + "delete": { + "summary": "Delete a manually managed audience segment", + "tags": ["segments"], + "description": "This endpoint allows the client to delete an audience segment specified by ID.\n\nAudience segments cannot be deleted if there are still any DisplayAds/Billboards/Widgets using them.", + "operationId": "deleteSegment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "The deleted audience segment", + "content": { + "application/json": { + "example": { + "id": 130, + "created_at": "2023-05-19T13:29:19.392-05:00", + "type_of": "manual", + "updated_at": "2023-05-19T13:29:19.392-05:00" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + }, + "404": { + "description": "Audience Segment Not Found", + "content": { + "application/json": { + "example": { + "error": "not found", + "status": 404 + } + } + } + }, + "409": { + "description": "Audience segment could not be deleted", + "content": { + "application/json": { + "example": { + "error": "Segments cannot be deleted while in use by any billboards" + } + } + } + } + } + } + }, + "/api/segments/{id}/add_users": { + "put": { + "summary": "Add users to a manually managed audience segment", + "tags": ["segments"], + "description": "This endpoint allows the client to add users in bulk to an audience segment specified by ID.\n\nSuccesses are users that were included in the segment (even if they were already in it), and failures are users that could not be added to the segment.", + "operationId": "addUsersToSegment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Result of adding the users to the segment.", + "content": { + "application/json": { + "example": { + "succeeded": [1009, 1010, 1008], + "failed": [] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + }, + "404": { + "description": "Audience Segment Not Found", + "content": { + "application/json": { + "example": { + "error": "not found", + "status": 404 + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "example": { + "error": "param is missing or the value is empty: user_ids", + "status": 422 + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentUserIds" + } + } + } + } + } + }, + "/api/segments/{id}/remove_users": { + "put": { + "summary": "Remove users from a manually managed audience segment", + "tags": ["segments"], + "description": "This endpoint allows the client to remove users in bulk from an audience segment specified by ID.\n\nSuccesses are users that were removed; failures are users that weren't a part of the segment.", + "operationId": "removeUsersFromSegment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Result of removing the users to the segment.", + "content": { + "application/json": { + "example": { + "succeeded": [1027, 1028, 1029], + "failed": [] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + }, + "404": { + "description": "Audience Segment Not Found", + "content": { + "application/json": { + "example": { + "error": "not found", + "status": 404 + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "example": { + "error": "param is missing or the value is empty: user_ids", + "status": 422 + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentUserIds" + } + } + } + } + } + }, "/api/comments": { "get": { "summary": "Comments", @@ -966,17 +1217,17 @@ { "type_of": "comment", "id_code": "52", - "created_at": "2023-04-14T14:45:34Z", - "body_html": "

Pop-up salvia vhs gluten-free tilde cleanse. Gastropub offal next level. Gluten-free health stumptown kale chips wolf.

\n\n", + "created_at": "2023-05-19T18:29:21Z", + "body_html": "

Narwhal cold-pressed literally polaroid migas shoreditch offal meh. Williamsburg ugh kickstarter butcher bushwick. Street mumblecore flexitarian.

\n\n", "user": { - "name": "Luigi \"Ryann\" \\:/ Wuckert", - "username": "username410", - "twitter_username": "twitter410", - "github_username": "github410", - "user_id": 1332, + "name": "Winston \"Tad\" \\:/ McCullough", + "username": "username573", + "twitter_username": "twitter573", + "github_username": "github573", + "user_id": 1052, "website_url": null, - "profile_image": "/uploads/user/profile_image/1332/241b2d6f-ec6d-4590-b57f-3a2ce673f9de.jpeg", - "profile_image_90": "/uploads/user/profile_image/1332/241b2d6f-ec6d-4590-b57f-3a2ce673f9de.jpeg" + "profile_image": "/uploads/user/profile_image/1052/af0c2ef1-a5a9-4af6-9fd6-5f1cf97b9929.jpeg", + "profile_image_90": "/uploads/user/profile_image/1052/af0c2ef1-a5a9-4af6-9fd6-5f1cf97b9929.jpeg" }, "children": [] } @@ -1031,17 +1282,17 @@ "example": { "type_of": "comment", "id_code": "54", - "created_at": "2023-04-14T14:45:35Z", - "body_html": "

Ramps tacos jean shorts humblebrag street bicycle rights paleo. Actually green juice chartreuse tattooed cliche. 8-bit bushwick diy aesthetic.

\n\n", + "created_at": "2023-05-19T18:29:21Z", + "body_html": "

Blog schlitz carry 90's raw denim.

\n\n", "user": { - "name": "Santos \"Broderick\" \\:/ Lemke", - "username": "username414", - "twitter_username": "twitter414", - "github_username": "github414", - "user_id": 1339, + "name": "Rosemarie \"Darcie\" \\:/ Wisoky", + "username": "username577", + "twitter_username": "twitter577", + "github_username": "github577", + "user_id": 1056, "website_url": null, - "profile_image": "/uploads/user/profile_image/1339/08f5cd26-743e-4957-af23-540b7ded60d6.jpeg", - "profile_image_90": "/uploads/user/profile_image/1339/08f5cd26-743e-4957-af23-540b7ded60d6.jpeg" + "profile_image": "/uploads/user/profile_image/1056/f11696f2-fe4d-4c45-a16c-ad4a4eb1c64e.jpeg", + "profile_image_90": "/uploads/user/profile_image/1056/f11696f2-fe4d-4c45-a16c-ad4a4eb1c64e.jpeg" }, "children": [] } @@ -1106,12 +1357,12 @@ "content": { "application/json": { "example": { - "id": 193, + "id": 14, "approved": true, "body_markdown": "# Hi, this is ad\nYep, it's an ad", "cached_tag_list": "", "clicks_count": 0, - "created_at": "2023-04-14T18:45:35.606+04:00", + "created_at": "2023-05-19T13:29:21.652-05:00", "creator_id": null, "display_to": "all", "exclude_article_ids": "", @@ -1123,7 +1374,7 @@ "published": true, "success_rate": 0.0, "type_of": "in_house", - "updated_at": "2023-04-14T18:45:35.606+04:00", + "updated_at": "2023-05-19T13:29:21.652-05:00", "audience_segment_type": null, "tag_list": "" }, @@ -1216,24 +1467,24 @@ "content": { "application/json": { "example": { - "id": 196, + "id": 15, "approved": false, - "body_markdown": "Hello _hey_ Hey hey 9", + "body_markdown": "Hello _hey_ Hey hey 11", "cached_tag_list": "", "clicks_count": 0, - "created_at": "2023-04-14T18:45:35.813+04:00", + "created_at": "2023-05-19T13:29:21.794-05:00", "creator_id": null, "display_to": "all", "exclude_article_ids": "", "impressions_count": 0, - "name": "Display Ad 196", - "organization_id": 304, + "name": "Display Ad 15", + "organization_id": 77, "placement_area": "sidebar_left", - "processed_html": "

Hello hey Hey hey 9

", + "processed_html": "

Hello hey Hey hey 11

", "published": false, "success_rate": 0.0, "type_of": "in_house", - "updated_at": "2023-04-14T18:45:35.817+04:00", + "updated_at": "2023-05-19T13:29:21.797-05:00", "audience_segment_type": null, "tag_list": "" } @@ -1289,23 +1540,23 @@ "application/json": { "example": { "approved": false, - "body_markdown": "Hello _hey_ Hey hey 10", + "body_markdown": "Hello _hey_ Hey hey 12", "creator_id": null, "display_to": "all", - "name": "Display Ad 198", - "organization_id": 306, + "name": "Display Ad 16", + "organization_id": 78, "placement_area": "sidebar_left", "published": false, "type_of": "in_house", "exclude_article_ids": "", "cached_tag_list": "", - "id": 198, + "id": 16, "clicks_count": 0, - "created_at": "2023-04-14T18:45:36.047+04:00", + "created_at": "2023-05-19T13:29:21.954-05:00", "impressions_count": 0, - "processed_html": "

Hello hey Hey hey 10

", + "processed_html": "

Hello hey Hey hey 12

", "success_rate": 0.0, - "updated_at": "2023-04-14T18:45:36.051+04:00", + "updated_at": "2023-05-19T13:29:21.957-05:00", "audience_segment_type": null, "tag_list": "" }, @@ -1427,12 +1678,12 @@ "application/json": { "example": [ { - "id": 701, + "id": 439, "name": "tag3", "points": 1.0 }, { - "id": 702, + "id": 440, "name": "tag4", "points": 1.0 } @@ -1481,23 +1732,23 @@ "example": [ { "type_of": "user_follower", - "id": 72, - "created_at": "2023-04-14T14:45:36Z", - "user_id": 1375, - "name": "Taylor \"Chrystal\" \\:/ Pfannerstill", - "path": "/username435", - "username": "username435", - "profile_image": "/uploads/user/profile_image/1375/11fa0607-0d22-4c3c-b339-490ff1e25e8d.jpeg" + "id": 6, + "created_at": "2023-05-19T18:29:22Z", + "user_id": 1077, + "name": "Elmer \"Donald\" \\:/ Altenwerth", + "path": "/username598", + "username": "username598", + "profile_image": "/uploads/user/profile_image/1077/6b452143-b80c-4e79-8b41-1c195e989cbd.jpeg" }, { "type_of": "user_follower", - "id": 71, - "created_at": "2023-04-14T14:45:36Z", - "user_id": 1372, - "name": "Carisa \"Thurman\" \\:/ Senger", - "path": "/username433", - "username": "username433", - "profile_image": "/uploads/user/profile_image/1372/bb3c148a-71d4-467d-b25f-ddeb78a6ed63.jpeg" + "id": 5, + "created_at": "2023-05-19T18:29:22Z", + "user_id": 1075, + "name": "Lavonia \"Norbert\" \\:/ Anderson", + "path": "/username596", + "username": "username596", + "profile_image": "/uploads/user/profile_image/1075/751c0f15-7702-4542-ab9d-e6e68e3bfad4.jpeg" } ], "schema": { @@ -1575,19 +1826,19 @@ "application/json": { "example": { "type_of": "organization", - "id": 323, - "username": "org77", - "name": "Skiles-Frami", - "summary": "Franzen seitan mustache cred. Gluten-free flannel gastropub hoodie vinegar wolf mixtape.", - "twitter_username": "org26", - "github_username": "org5524", - "url": "http://jacobs.com/assunta.rau", + "id": 83, + "username": "org81", + "name": "Stracke, Mayert and Rolfson", + "summary": "3 wolf moon marfa pug.", + "twitter_username": "org7", + "github_username": "org4912", + "url": "http://sawayn.org/viola", "location": null, "tech_stack": null, "tag_line": null, "story": null, - "joined_at": "2023-04-14T14:45:37Z", - "profile_image": "/uploads/organization/profile_image/323/d1677329-759a-44e1-866e-c5be50e9593b.png" + "joined_at": "2023-05-19T18:29:22Z", + "profile_image": "/uploads/organization/profile_image/83/7e9cd184-d584-45e2-96d2-3c898132258a.png" }, "schema": { "type": "object", @@ -1643,29 +1894,29 @@ "example": [ { "type_of": "user", - "id": 1390, - "username": "username445", - "name": "Ute \"Doreen\" \\:/ Tillman", - "twitter_username": "twitter445", - "github_username": "github445", + "id": 1087, + "username": "username608", + "name": "Garland \"Luna\" \\:/ Willms", + "twitter_username": "twitter608", + "github_username": "github608", "summary": null, "location": null, "website_url": null, - "joined_at": "Apr 14, 2023", - "profile_image": "/uploads/user/profile_image/1390/7b885cdf-c1c6-481a-a6d8-19f0404da87a.jpeg" + "joined_at": "May 19, 2023", + "profile_image": "/uploads/user/profile_image/1087/4f9bf6a6-a197-4b04-bcc8-00dcdfbb0637.jpeg" }, { "type_of": "user", - "id": 1391, - "username": "username446", - "name": "Abraham \"Colton\" \\:/ Ritchie", - "twitter_username": "twitter446", - "github_username": "github446", + "id": 1088, + "username": "username609", + "name": "Isidra \"Kieth\" \\:/ Raynor", + "twitter_username": "twitter609", + "github_username": "github609", "summary": null, "location": null, "website_url": null, - "joined_at": "Apr 14, 2023", - "profile_image": "/uploads/user/profile_image/1391/273ffa06-dc97-4b84-ba4c-43d24efb55e5.jpeg" + "joined_at": "May 19, 2023", + "profile_image": "/uploads/user/profile_image/1088/601827de-ad48-4d79-bd08-00f3b6de1979.jpeg" } ], "schema": { @@ -1722,45 +1973,45 @@ "example": [ { "type_of": "article", - "id": 276, - "title": "Down to a Sunless Sea195", - "description": "Scenester occupy swag. Yr godard single-origin coffee biodiesel beard artisan. Wes anderson yolo...", - "readable_publish_date": "Apr 14", - "slug": "down-to-a-sunless-sea195-51d4", - "path": "/org81/down-to-a-sunless-sea195-51d4", - "url": "http://localhost:3000/org81/down-to-a-sunless-sea195-51d4", + "id": 228, + "title": "A Passage to India208", + "description": "Raw denim small batch schlitz. Messenger bag kombucha heirloom celiac wes anderson portland. Hashtag...", + "readable_publish_date": "May 19", + "slug": "a-passage-to-india208-304i", + "path": "/org85/a-passage-to-india208-304i", + "url": "http://localhost:3000/org85/a-passage-to-india208-304i", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-04-14T14:45:38Z", + "published_timestamp": "2023-05-19T18:29:23Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/38-3b0c46cc0d5367229799d282c99b2c42f33501201cac1ceb5c643f9ee11f06c6.png", - "social_image": "http://localhost:3000/assets/38-3b0c46cc0d5367229799d282c99b2c42f33501201cac1ceb5c643f9ee11f06c6.png", - "canonical_url": "http://localhost:3000/org81/down-to-a-sunless-sea195-51d4", - "created_at": "2023-04-14T14:45:38Z", + "cover_image": "http://localhost:3000/assets/25-b4bb206b62bee552880440f638594e41dcd649ed9bd821af2e8dfc671d1d6813.png", + "social_image": "http://localhost:3000/assets/25-b4bb206b62bee552880440f638594e41dcd649ed9bd821af2e8dfc671d1d6813.png", + "canonical_url": "http://localhost:3000/org85/a-passage-to-india208-304i", + "created_at": "2023-05-19T18:29:23Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-04-14T14:45:38Z", - "last_comment_at": "2023-04-14T14:45:38Z", + "published_at": "2023-05-19T18:29:23Z", + "last_comment_at": "2023-05-19T18:29:23Z", "reading_time_minutes": 1, "tag_list": ["javascript", "html", "discuss"], "tags": "javascript, html, discuss", "user": { - "name": "Annabell \"Tyron\" \\:/ West", - "username": "username453", - "twitter_username": "twitter453", - "github_username": "github453", - "user_id": 1399, + "name": "Humberto \"Forest\" \\:/ Kling", + "username": "username616", + "twitter_username": "twitter616", + "github_username": "github616", + "user_id": 1095, "website_url": null, - "profile_image": "/uploads/user/profile_image/1399/15f1c715-4e10-4f57-9cca-7a2071dfdf23.jpeg", - "profile_image_90": "/uploads/user/profile_image/1399/15f1c715-4e10-4f57-9cca-7a2071dfdf23.jpeg" + "profile_image": "/uploads/user/profile_image/1095/094d3871-769d-4699-8492-3500407abc2d.jpeg", + "profile_image_90": "/uploads/user/profile_image/1095/094d3871-769d-4699-8492-3500407abc2d.jpeg" }, "organization": { - "name": "Schuster LLC", - "username": "org81", - "slug": "org81", - "profile_image": "/uploads/organization/profile_image/334/824e9ec6-b41c-4824-87e8-ae7721868787.png", - "profile_image_90": "/uploads/organization/profile_image/334/824e9ec6-b41c-4824-87e8-ae7721868787.png" + "name": "Hoppe-Gutmann", + "username": "org85", + "slug": "org85", + "profile_image": "/uploads/organization/profile_image/87/e01509ce-617e-4c29-beb7-174a8378e554.png", + "profile_image_90": "/uploads/organization/profile_image/87/e01509ce-617e-4c29-beb7-174a8378e554.png" } } ], @@ -1800,16 +2051,16 @@ "application/json": { "example": [ { - "id": 5, - "title": "An Instant In The Wind", - "slug": "trivial_exaggerate", - "description": "Excepturi illum tenetur nisi.", + "id": 1, + "title": "Nectar in a Sieve", + "slug": "polish_galaxy", + "description": "Ad veritatis quasi ipsam.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Et voluptas cupiditate voluptatibus.", - "processed_html": "

Et voluptas cupiditate voluptatibus.

\n\n", + "body_markdown": "Quia qui non est.", + "processed_html": "

Quia qui non est.

\n\n", "social_image": { "url": null }, @@ -1838,7 +2089,7 @@ "content": { "application/json": { "example": { - "id": 7, + "id": 3, "title": "Example Page", "slug": "example1", "description": "a new page", @@ -1965,16 +2216,16 @@ "content": { "application/json": { "example": { - "id": 10, - "title": "A Swiftly Tilting Planet", - "slug": "corn-laser", - "description": "Inventore ad qui dolore.", + "id": 6, + "title": "The Golden Apples of the Sun", + "slug": "premium-strict", + "description": "Alias qui maxime nesciunt.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "In sit sit voluptas.", - "processed_html": "

In sit sit voluptas.

\n\n", + "body_markdown": "Qui fugit voluptatibus ut.", + "processed_html": "

Qui fugit voluptatibus ut.

\n\n", "social_image": { "url": null }, @@ -2012,16 +2263,16 @@ "content": { "application/json": { "example": { - "id": 11, + "id": 7, "title": "New Title", - "slug": "authority-figure", - "description": "Et odio nostrum dolorem.", + "slug": "widen-general", + "description": "Aperiam nulla rerum quo.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Consequatur ex soluta libero.", - "processed_html": "

Consequatur ex soluta libero.

\n\n", + "body_markdown": "Asperiores rem esse beatae.", + "processed_html": "

Asperiores rem esse beatae.

\n\n", "social_image": { "url": null }, @@ -2049,16 +2300,16 @@ "content": { "application/json": { "example": { - "id": 13, - "title": "Little Hands Clapping", - "slug": "horizon_nursery", - "description": "Vel tenetur aspernatur mollitia.", + "id": 9, + "title": "That Good Night", + "slug": "hypothesis-conglomerate", + "description": "Praesentium molestiae non minima.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Omnis quia eaque aliquam.", - "processed_html": "

Consequatur sit illum voluptas.

\n\n", + "body_markdown": "Quis voluptatem nemo sequi.", + "processed_html": "

Rerum ipsam aut voluptate.

\n\n", "social_image": { "url": null }, @@ -2102,16 +2353,16 @@ "content": { "application/json": { "example": { - "id": 14, - "title": "The Skull Beneath the Skin", - "slug": "appointment-provision", - "description": "Et error fuga natus.", + "id": 10, + "title": "The Stars' Tennis Balls", + "slug": "doctor_freckle", + "description": "Ea neque voluptatem quam.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Quo quibusdam nisi numquam.", - "processed_html": "

Quo quibusdam nisi numquam.

\n\n", + "body_markdown": "In ducimus dolor accusantium.", + "processed_html": "

In ducimus dolor accusantium.

\n\n", "social_image": { "url": null }, @@ -2189,12 +2440,12 @@ "class_name": "PodcastEpisode", "id": 4, "path": "/codenewbie/slug-4", - "title": "5", - "image_url": "/uploads/podcast/image/8/624a3c09-8036-43e4-9187-4cbf6d3c1fd6.jpeg", + "title": "11", + "image_url": "/uploads/podcast/image/4/0ee1d311-800d-4dd9-b94d-7ced5880aca3.jpeg", "podcast": { - "title": "Maudite", + "title": "Alpha King Pale Ale", "slug": "codenewbie", - "image_url": "/uploads/podcast/image/8/624a3c09-8036-43e4-9187-4cbf6d3c1fd6.jpeg" + "image_url": "/uploads/podcast/image/4/0ee1d311-800d-4dd9-b94d-7ced5880aca3.jpeg" } } ], @@ -2247,8 +2498,8 @@ "example": { "type_of": "profile_image", "image_of": "user", - "profile_image": "/uploads/user/profile_image/1419/f2b13e85-a3a0-49cf-9da0-7922248be024.jpeg", - "profile_image_90": "/uploads/user/profile_image/1419/f2b13e85-a3a0-49cf-9da0-7922248be024.jpeg" + "profile_image": "/uploads/user/profile_image/1110/8ff43ce8-dba5-4c12-a8ad-4f164a02856d.jpeg", + "profile_image_90": "/uploads/user/profile_image/1110/8ff43ce8-dba5-4c12-a8ad-4f164a02856d.jpeg" }, "schema": { "type": "object", @@ -2321,8 +2572,8 @@ "example": { "result": "create", "category": "like", - "id": 13, - "reactable_id": 283, + "id": 1, + "reactable_id": 230, "reactable_type": "Article" } } @@ -2390,8 +2641,8 @@ "example": { "result": "none", "category": "like", - "id": 15, - "reactable_id": 285, + "id": 3, + "reactable_id": 232, "reactable_type": "Article" } } @@ -2476,19 +2727,19 @@ "application/json": { "example": [ { - "id": 806, + "id": 471, "name": "tag5", "bg_color_hex": null, "text_color_hex": null }, { - "id": 808, + "id": 472, "name": "tag6", "bg_color_hex": null, "text_color_hex": null }, { - "id": 809, + "id": 473, "name": "tag7", "bg_color_hex": null, "text_color_hex": null @@ -2519,16 +2770,16 @@ "application/json": { "example": { "type_of": "user", - "id": 1431, - "username": "username480", - "name": "Willy \"Myron\" \\:/ Herzog", - "twitter_username": "twitter480", - "github_username": "github480", + "id": 1122, + "username": "username643", + "name": "Kali \"Merrill\" \\:/ Wehner", + "twitter_username": "twitter643", + "github_username": "github643", "summary": null, "location": null, "website_url": null, - "joined_at": "Apr 14, 2023", - "profile_image": "/uploads/user/profile_image/1431/b547e3a6-5076-44dd-a4f6-9b85022b4e76.jpeg" + "joined_at": "May 19, 2023", + "profile_image": "/uploads/user/profile_image/1122/56ce37e0-4e2a-4bf6-bea0-c888e5a03b2e.jpeg" }, "schema": { "type": "object", @@ -2752,28 +3003,28 @@ "example": [ { "type_of": "video_article", - "id": 287, - "path": "/username499/the-millstone201-1kgb", + "id": 235, + "path": "/username663/those-barren-leaves-thrones-dominations215-4fhn", "cloudinary_video_url": "https://dw71fyauz7yz9.cloudfront.net/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f/thumbs-video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f-00001.png", - "title": "The Millstone201", - "user_id": 1452, + "title": "Those Barren Leaves, Thrones, Dominations215", + "user_id": 1143, "video_duration_in_minutes": "00:00", "video_source_url": "https://dw71fyauz7yz9.cloudfront.net/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f.m3u8", "user": { - "name": "Edgardo \"Monroe\" \\:/ Gusikowski" + "name": "Belinda \"Gary\" \\:/ Terry" } }, { "type_of": "video_article", - "id": 288, - "path": "/username500/a-many-splendoured-thing202-4gnk", + "id": 234, + "path": "/username662/a-many-splendoured-thing214-3l8b", "cloudinary_video_url": "https://dw71fyauz7yz9.cloudfront.net/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f/thumbs-video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f-00001.png", - "title": "A Many-Splendoured Thing202", - "user_id": 1453, + "title": "A Many-Splendoured Thing214", + "user_id": 1142, "video_duration_in_minutes": "00:00", "video_source_url": "https://dw71fyauz7yz9.cloudfront.net/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f.m3u8", "user": { - "name": "Arlena \"Catarina\" \\:/ Zulauf" + "name": "Monet \"Edwardo\" \\:/ Senger" } } ], @@ -3540,7 +3791,7 @@ "audience_segment_type": { "type": "string", "enum": [ - "testing", + "manual", "trusted", "posted", "no_posts_yet", @@ -3569,6 +3820,38 @@ } }, "required": ["name", "body_markdown", "placement_area"] + }, + "Segment": { + "description": "A manually managed audience segment", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The ID of the segment" + }, + "type_of": { + "type": "string", + "enum": ["manual"], + "default": "manual", + "description": "Marks the segment as manually managed (other types are internal)" + }, + "user_count": { + "type": "integer", + "description": "The current number of users in the segment" + } + } + }, + "SegmentUserIds": { + "type": "object", + "properties": { + "user_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 10000 + } + } } } }