diff --git a/.gitlab-ci.yaml b/.gitlab-ci.yaml index 587bdced82622f77bdede56d08fc3aecefa7fe7f..0ab8b90f8ce7327d78d449c8ab77046cbad3204a 100644 --- a/.gitlab-ci.yaml +++ b/.gitlab-ci.yaml @@ -524,9 +524,11 @@ sync: --database-admin-url="${HAF_ADMIN_POSTGRES_URL}" \ --with-apps \ --add-mocks=${ADD_MOCKS} + # Run reputation_tracker in background (parallel with hivemind sync) ${WORKING_DIR}/app/reputation_tracker/scripts/process_blocks.sh \ - --stop-at-block="${RUNNER_HIVEMIND_SYNC_IRREVERSIBLE_MAX_BLOCK}" \ - --postgres-url="${HAF_POSTGRES_URL}" + --stop-at-block="${RUNNER_HIVEMIND_SYNC_MAX_BLOCK}" \ + --postgres-url="${HAF_POSTGRES_URL}" & + REP_TRACKER_PID=$! ${WORKING_DIR}/docker_entrypoint.sh sync \ --log-mask-sensitive-data \ --pid-file hive_sync.pid \ @@ -535,11 +537,10 @@ sync: --prometheus-port 11011 \ --database-url="${HAF_POSTGRES_URL}" \ --community-start-block 4998000 + # Wait for reputation_tracker to complete before proceeding + wait $REP_TRACKER_PID pushd +2 ${WORKING_DIR}/app/ci/collect-db-stats.sh - ${WORKING_DIR}/app/reputation_tracker/scripts/process_blocks.sh \ - --stop-at-block="${RUNNER_HIVEMIND_SYNC_MAX_BLOCK}" \ - --postgres-url="${HAF_POSTGRES_URL}" after_script: - cp "$DATA_CACHE_HIVEMIND_DATADIR/$CI_JOB_NAME.log" "haf-$CI_JOB_NAME.log" || true # in after_script, so it's done even if the job fails diff --git a/hive/db/db_state.py b/hive/db/db_state.py index a2b9a0ed7b573b29030c8ffc8516a6b22e4b82dc..d6f9681c2822ece1303d8a40b0c985022ffe8d07 100644 --- a/hive/db/db_state.py +++ b/hive/db/db_state.py @@ -320,6 +320,10 @@ class DbState: # is_pre_process, drop, create cls.processing_indexes(True, True, False) + # Set tables to UNLOGGED for faster inserts (no WAL writes) + from hive.db.schema import set_logged_table_attribute + set_logged_table_attribute(cls.db(), False) + cls._indexes_were_disabled = True cls._indexes_were_enabled = False log.info("[MASSIVE] Indexes are disabled") @@ -367,7 +371,9 @@ class DbState: if cls._fk_were_enabled: return - from hive.db.schema import create_fk + # Set tables back to LOGGED before going live (generates WAL for durability) + from hive.db.schema import set_logged_table_attribute, create_fk + set_logged_table_attribute(cls.db(), True) start_time_foreign_keys = perf_counter() log.info("Recreating foreign keys") diff --git a/hive/db/schema.py b/hive/db/schema.py index 5987ab85521e5be201146364d1ca2efb60a50ee5..7e855d6a0810366cee464ecc7ce9f693c2f36b77 100644 --- a/hive/db/schema.py +++ b/hive/db/schema.py @@ -659,6 +659,7 @@ def setup_runtime_code(db): "hafapp_api.sql", "grant_hivemind_user.sql", "community.sql", + "community_utils.sql", "postgrest/utilities/exceptions.sql", "postgrest/utilities/validate_json_arguments.sql", "postgrest/utilities/api_limits.sql", @@ -848,21 +849,56 @@ def set_fillfactor(db): def set_logged_table_attribute(db, logged): - """Initializes/resets LOGGED/UNLOGGED attribute for tables which are intesively updated""" + """Initializes/resets LOGGED/UNLOGGED attribute for tables which are intensively updated. + Tables are converted in parallel to minimize total conversion time. + The largest table (hive_votes at ~319GB) is the bottleneck. + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + from time import perf_counter + + # Ordered by size descending - largest tables start first for better parallelism logged_config = [ - 'hive_accounts', - 'hive_permlink_data', - 'hive_post_tags', - 'hive_posts', - 'hive_post_data', - 'hive_votes', + 'hive_votes', # ~319 GB + 'hive_post_data', # ~127 GB + 'hive_posts', # ~83 GB + 'hive_permlink_data', # ~27 GB + 'hive_post_tags', # ~12 GB + 'hive_accounts', # ~748 MB ] - for table in logged_config.items(): - log.info(f"Setting {'LOGGED' if logged else 'UNLOGGED'} attribute on a table: {table}") - sql = """ALTER TABLE {} SET {}""" - db.query_no_return(sql.format(table, 'LOGGED' if logged else 'UNLOGGED')) + mode = 'LOGGED' if logged else 'UNLOGGED' + log.info(f"Converting {len(logged_config)} tables to {mode} in parallel...") + start_time = perf_counter() + + def convert_table(table): + """Convert a single table - runs in separate thread with own connection.""" + table_start = perf_counter() + thread_db = db.clone(f'logged_convert_{table}') + try: + sql = f"ALTER TABLE {SCHEMA_NAME}.{table} SET {mode}" + thread_db.query_no_return(sql) + elapsed = perf_counter() - table_start + return (table, elapsed, None) + except Exception as e: + elapsed = perf_counter() - table_start + return (table, elapsed, e) + finally: + thread_db.close() + + with ThreadPoolExecutor(max_workers=len(logged_config)) as executor: + futures = {executor.submit(convert_table, table): table for table in logged_config} + + for future in as_completed(futures): + table, elapsed, error = future.result() + if error: + log.error(f"Failed to set {mode} on {SCHEMA_NAME}.{table} after {elapsed:.1f}s: {error}") + raise error + else: + log.info(f"Set {mode} on {SCHEMA_NAME}.{table} in {elapsed:.1f}s") + + total_elapsed = perf_counter() - start_time + log.info(f"All {len(logged_config)} tables converted to {mode} in {total_elapsed:.1f}s") def execute_sql_script(query_executor, path_to_script): diff --git a/hive/db/sql_scripts/community.sql b/hive/db/sql_scripts/community.sql index f29100cc94c51d2ee3794f6c3185cfb712de64f0..8491f5f478f642d322d8ff90d505c50abf325e7b 100644 --- a/hive/db/sql_scripts/community.sql +++ b/hive/db/sql_scripts/community.sql @@ -1,25 +1,25 @@ -DROP FUNCTION IF EXISTS hivemind_app.insert_subscription; +DROP FUNCTION IF EXISTS hivemind_app.community_subscribe(INTEGER, INTEGER, TIMESTAMP, INTEGER, INTEGER); CREATE OR REPLACE FUNCTION hivemind_app.community_subscribe( _actor_id INTEGER, _community_id INTEGER, _date TIMESTAMP, _block_num INTEGER, _counter INTEGER -) RETURNS VOID AS $$ +) RETURNS TABLE(success BOOLEAN, error_message TEXT) AS $$ DECLARE _notification_first_block INTEGER; + _already_subscribed BOOLEAN; BEGIN - -- Insert subscription - INSERT INTO hivemind_app.hive_subscriptions - (account_id, community_id, created_at, block_num) - VALUES (_actor_id, _community_id, _date, _block_num); + _already_subscribed := hivemind_app.community_is_subscribed(_actor_id, _community_id); - -- Update community subscriber count - UPDATE hivemind_app.hive_communities - SET subscribers = subscribers + 1 - WHERE id = _community_id; + IF _already_subscribed THEN + RETURN QUERY SELECT FALSE, 'already subscribed'::TEXT; + RETURN; + END IF; + + INSERT INTO hivemind_app.hive_subscriptions(account_id, community_id, created_at, block_num) VALUES (_actor_id, _community_id, _date, _block_num); - -- Insert notification + UPDATE hivemind_app.hive_communities SET subscribers = subscribers + 1 WHERE id = _community_id; -- With clause is inlined, modified call to reptracker_endpoints.get_account_reputation. -- Reputation is multiplied by 7.5 rather than 9 to bring the max value to 100 rather than 115. @@ -51,8 +51,8 @@ BEGIN _date, r.id, hc.id, - 0, - 0, + NULL, + NULL, COALESCE(rep.rep, 25), '', hc.name, @@ -61,67 +61,469 @@ BEGIN JOIN hivemind_app.hive_communities AS hc ON hc.id = _community_id LEFT JOIN final_rep AS rep ON r.haf_id = rep.account_id WHERE r.id = _actor_id - AND _block_num > hivemind_app.block_before_irreversible('90 days') AND COALESCE(rep.rep, 25) > 0 AND r.id IS DISTINCT FROM hc.id ON CONFLICT (src, dst, type_id, post_id, block_num) DO NOTHING; END IF; + + RETURN QUERY SELECT TRUE, ''::TEXT; END; $$ LANGUAGE plpgsql; -DROP FUNCTION IF EXISTS hivemind_app.community_unsubscribe; +DROP FUNCTION IF EXISTS hivemind_app.community_unsubscribe(INTEGER, INTEGER); CREATE OR REPLACE FUNCTION hivemind_app.community_unsubscribe( _actor_id INTEGER, _community_id INTEGER -) RETURNS VOID AS $$ +) RETURNS TABLE(success BOOLEAN, error_message TEXT) AS $$ +DECLARE + _is_subscribed BOOLEAN; BEGIN - DELETE FROM hivemind_app.hive_subscriptions - WHERE account_id = _actor_id - AND community_id = _community_id; + _is_subscribed := hivemind_app.community_is_subscribed(_actor_id, _community_id); - UPDATE hivemind_app.hive_communities - SET subscribers = subscribers - 1 - WHERE id = _community_id; + IF NOT _is_subscribed THEN + RETURN QUERY SELECT FALSE, 'already unsubscribed'::TEXT; + RETURN; + END IF; + + DELETE FROM hivemind_app.hive_subscriptions WHERE account_id = _actor_id AND community_id = _community_id; + + UPDATE hivemind_app.hive_communities SET subscribers = subscribers - 1 WHERE id = _community_id; + + RETURN QUERY SELECT TRUE, ''::TEXT; END; $$ LANGUAGE plpgsql; -DROP FUNCTION IF EXISTS hivemind_app.set_community_role; -CREATE OR REPLACE FUNCTION hivemind_app.set_community_role( +DROP FUNCTION IF EXISTS hivemind_app.community_set_role; +CREATE OR REPLACE FUNCTION hivemind_app.community_set_role( + _actor_id INTEGER, _account_id INTEGER, _community_id INTEGER, _role_id INTEGER, _date TIMESTAMP, _max_mod_nb INTEGER, -- maximum number of roles >= to mod in a community _mod_role_threshold INTEGER -- minimum role id to be counted as -) RETURNS TABLE(status TEXT, mod_count BIGINT) AS $$ +) RETURNS TABLE(success BOOLEAN, error_message TEXT, is_subscribed BOOLEAN) AS $$ +DECLARE + _actor_role INTEGER; + _account_role INTEGER; + _mod_count BIGINT; + _is_subscribed BOOLEAN; BEGIN - RETURN QUERY - WITH mod_check AS ( - SELECT - CASE - WHEN _role_id >= _mod_role_threshold THEN - (SELECT COUNT(*) - FROM hivemind_app.hive_roles - WHERE community_id = _community_id - AND role_id >= _mod_role_threshold - AND account_id != _account_id) - ELSE 0 - END as current_mod_count - ), - insert_attempt AS ( - INSERT INTO hivemind_app.hive_roles (account_id, community_id, role_id, created_at) - SELECT _account_id, _community_id, _role_id, _date - FROM mod_check - WHERE current_mod_count < _max_mod_nb OR _role_id < _mod_role_threshold - ON CONFLICT (account_id, community_id) - DO UPDATE SET role_id = _role_id - RETURNING * - ) - SELECT - CASE - WHEN EXISTS (SELECT 1 FROM insert_attempt) THEN 'success' - ELSE 'failed_mod_limit' - END as status, - (SELECT current_mod_count FROM mod_check) as mod_count; + _actor_role := hivemind_app.get_community_role(_actor_id, _community_id); + + IF _actor_role < 4 THEN -- 4 = Role.mod + RETURN QUERY SELECT FALSE, 'only mods and up can alter roles'::TEXT, FALSE; + RETURN; + END IF; + + IF _actor_role <= _role_id THEN + RETURN QUERY SELECT FALSE, 'cannot promote to or above own rank'::TEXT, FALSE; + RETURN; + END IF; + + _account_role := hivemind_app.get_community_role(_account_id, _community_id); + + IF _account_role = 8 THEN -- 8 = Role.owner + RETURN QUERY SELECT FALSE, 'cant modify owner role'::TEXT, FALSE; + RETURN; + END IF; + + + IF _actor_id != _account_id THEN + IF _account_role >= _actor_role THEN + RETURN QUERY SELECT FALSE, 'cant modify a user with a higher role'::TEXT, FALSE; + RETURN; + END IF; + + IF _account_role = _role_id THEN + RETURN QUERY SELECT FALSE, 'role would not change'::TEXT, FALSE; + RETURN; + END IF; + END IF; + + -- Check mod limit if promoting to mod or above + IF _role_id >= _mod_role_threshold THEN + SELECT COUNT(*) INTO _mod_count + FROM hivemind_app.hive_roles + WHERE community_id = _community_id + AND role_id >= _mod_role_threshold + AND account_id != _account_id; + + IF _mod_count >= _max_mod_nb THEN + RETURN QUERY SELECT FALSE, 'moderator limit exceeded'::TEXT, FALSE; + RETURN; + END IF; + END IF; + + INSERT INTO hivemind_app.hive_roles (account_id, community_id, role_id, created_at) + VALUES (_account_id, _community_id, _role_id, _date) + ON CONFLICT (account_id, community_id) + DO UPDATE SET role_id = _role_id; + + _is_subscribed := hivemind_app.community_is_subscribed(_account_id, _community_id); + + RETURN QUERY SELECT TRUE, ''::TEXT, _is_subscribed; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS hivemind_app.register_community; +CREATE OR REPLACE FUNCTION hivemind_app.register_community( + _name VARCHAR, + _account_id INTEGER, + _block_date TIMESTAMP, + _block_num INTEGER, + _counter INTEGER +) RETURNS VOID AS $$ +DECLARE + _type_id INTEGER; + _notification_first_block INTEGER; +BEGIN + -- Extract type_id from name (6th character, after "hive-") + _type_id := SUBSTRING(_name, 6, 1)::INTEGER; + + INSERT INTO hivemind_app.hive_communities (id, name, type_id, created_at, block_num) + VALUES (_account_id, _name, _type_id, _block_date, _block_num); + + INSERT INTO hivemind_app.hive_roles (community_id, account_id, role_id, created_at) + VALUES (_account_id, _account_id, 8, _block_date); -- 8 = owner role id + + SELECT hivemind_app.block_before_irreversible('90 days') INTO _notification_first_block; + IF _block_num > _notification_first_block THEN + INSERT INTO hivemind_app.hive_notification_cache + (id, block_num, type_id, created_at, src, dst, dst_post_id, post_id, score, payload, community, community_title) + VALUES ( + hivemind_app.notification_id(_block_date, 1, _counter), + _block_num, + 1, + _block_date, + 0, + _account_id, + NULL, + NULL, + 35, + '', + _name, + '' + ); + END IF; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS hivemind_app.community_set_title; +CREATE OR REPLACE FUNCTION hivemind_app.community_set_title( + _actor_id INTEGER, + _account_id INTEGER, + _community_id INTEGER, + _title VARCHAR, + _date TIMESTAMP +) RETURNS TABLE(success BOOLEAN, error_message TEXT, is_subscribed BOOLEAN) AS $$ +DECLARE + _actor_role INTEGER; + _is_subscribed BOOLEAN; +BEGIN + _actor_role := hivemind_app.get_community_role(_actor_id, _community_id); + + -- 4 is mod + IF _actor_role < 4 THEN + RETURN QUERY SELECT FALSE, 'only mods can set user titles'::TEXT, FALSE; + RETURN; + END IF; + + INSERT INTO hivemind_app.hive_roles (account_id, community_id, title, created_at) + VALUES (_account_id, _community_id, _title, _date) + ON CONFLICT (account_id, community_id) + DO UPDATE SET title = _title; + + _is_subscribed := hivemind_app.community_is_subscribed(_account_id, _community_id); + + RETURN QUERY SELECT TRUE, ''::TEXT, _is_subscribed; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS hivemind_app.community_mute_post; +CREATE OR REPLACE FUNCTION hivemind_app.community_mute_post( + _actor_id INTEGER, + _community_id INTEGER, + _account_id INTEGER, + _permlink VARCHAR, + _muted_reasons INTEGER +) RETURNS TABLE(success BOOLEAN, error_message TEXT, post_id INTEGER, is_subscribed BOOLEAN) AS $$ +DECLARE + _actor_role INTEGER; + _is_muted BOOLEAN; + _post_id INTEGER; + _post_error TEXT; + _is_subscribed BOOLEAN; +BEGIN + _actor_role := hivemind_app.get_community_role(_actor_id, _community_id); + + IF _actor_role < 4 THEN + RETURN QUERY SELECT FALSE, 'only mods and above can mute posts'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + + SELECT p.post_id, p.error_message INTO _post_id, _post_error + FROM hivemind_app.get_post_id_by_permlink(_account_id, _permlink, _community_id) p; + + IF _post_id IS NULL THEN + RETURN QUERY SELECT FALSE, _post_error, NULL::INTEGER, FALSE; + RETURN; + END IF; + + SELECT is_muted INTO _is_muted FROM hivemind_app.hive_posts WHERE id = _post_id; + + IF _is_muted THEN + RETURN QUERY SELECT FALSE, 'post is already muted'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + + UPDATE hivemind_app.hive_posts + SET is_muted = true, muted_reasons = _muted_reasons + WHERE id = _post_id; + + _is_subscribed := hivemind_app.community_is_subscribed(_account_id, _community_id); + + RETURN QUERY SELECT TRUE, ''::TEXT, _post_id, _is_subscribed; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS hivemind_app.community_unmute_post; +CREATE OR REPLACE FUNCTION hivemind_app.community_unmute_post( + _actor_id INTEGER, + _community_id INTEGER, + _account_id INTEGER, + _permlink VARCHAR +) RETURNS TABLE(success BOOLEAN, error_message TEXT, post_id INTEGER, is_subscribed BOOLEAN) AS $$ +DECLARE + _actor_role INTEGER; + _is_muted BOOLEAN; + _parent_id INTEGER; + _parent_is_muted BOOLEAN; + _post_id INTEGER; + _post_error TEXT; + _is_subscribed BOOLEAN; +BEGIN + _actor_role := hivemind_app.get_community_role(_actor_id, _community_id); + + IF _actor_role < 4 THEN + RETURN QUERY SELECT FALSE, 'only mods and above can unmute posts'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + + SELECT p.post_id, p.error_message INTO _post_id, _post_error + FROM hivemind_app.get_post_id_by_permlink(_account_id, _permlink, _community_id) p; + + IF _post_id IS NULL THEN + RETURN QUERY SELECT FALSE, _post_error, NULL::INTEGER, FALSE; + RETURN; + END IF; + + SELECT is_muted, parent_id INTO _is_muted, _parent_id FROM hivemind_app.hive_posts WHERE id = _post_id; + + IF NOT _is_muted THEN + RETURN QUERY SELECT FALSE, 'post is not muted'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + + IF _parent_id IS NOT NULL THEN + SELECT is_muted INTO _parent_is_muted FROM hivemind_app.hive_posts WHERE id = _parent_id; + IF _parent_is_muted THEN + RETURN QUERY SELECT FALSE, 'parent post is muted'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + END IF; + + UPDATE hivemind_app.hive_posts + SET is_muted = false, muted_reasons = 0 + WHERE id = _post_id; + + _is_subscribed := hivemind_app.community_is_subscribed(_account_id, _community_id); + + RETURN QUERY SELECT TRUE, ''::TEXT, _post_id, _is_subscribed; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS hivemind_app.community_pin_post; +CREATE OR REPLACE FUNCTION hivemind_app.community_pin_post( + _actor_id INTEGER, + _community_id INTEGER, + _account_id INTEGER, + _permlink VARCHAR +) RETURNS TABLE(success BOOLEAN, error_message TEXT, post_id INTEGER, is_subscribed BOOLEAN) AS $$ +DECLARE + _actor_role INTEGER; + _is_pinned BOOLEAN; + _post_id INTEGER; + _post_error TEXT; + _is_subscribed BOOLEAN; +BEGIN + _actor_role := hivemind_app.get_community_role(_actor_id, _community_id); + + IF _actor_role < 4 THEN + RETURN QUERY SELECT FALSE, 'only mods and above can pin posts'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + + SELECT p.post_id, p.error_message INTO _post_id, _post_error + FROM hivemind_app.get_post_id_by_permlink(_account_id, _permlink, _community_id) p; + + IF _post_id IS NULL THEN + RETURN QUERY SELECT FALSE, _post_error, NULL::INTEGER, FALSE; + RETURN; + END IF; + + SELECT is_pinned INTO _is_pinned FROM hivemind_app.hive_posts WHERE id = _post_id; + + IF _is_pinned THEN + RETURN QUERY SELECT FALSE, 'post is already pinned'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + + UPDATE hivemind_app.hive_posts + SET is_pinned = true + WHERE id = _post_id; + + _is_subscribed := hivemind_app.community_is_subscribed(_account_id, _community_id); + + RETURN QUERY SELECT TRUE, ''::TEXT, _post_id, _is_subscribed; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS hivemind_app.community_unpin_post; +CREATE OR REPLACE FUNCTION hivemind_app.community_unpin_post( + _actor_id INTEGER, + _community_id INTEGER, + _account_id INTEGER, + _permlink VARCHAR +) RETURNS TABLE(success BOOLEAN, error_message TEXT, post_id INTEGER, is_subscribed BOOLEAN) AS $$ +DECLARE + _actor_role INTEGER; + _is_pinned BOOLEAN; + _post_id INTEGER; + _post_error TEXT; + _is_subscribed BOOLEAN; +BEGIN + _actor_role := hivemind_app.get_community_role(_actor_id, _community_id); + + IF _actor_role < 4 THEN + RETURN QUERY SELECT FALSE, 'only mods and above can unpin posts'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + + SELECT p.post_id, p.error_message INTO _post_id, _post_error + FROM hivemind_app.get_post_id_by_permlink(_account_id, _permlink, _community_id) p; + + IF _post_id IS NULL THEN + RETURN QUERY SELECT FALSE, _post_error, NULL::INTEGER, FALSE; + RETURN; + END IF; + + SELECT is_pinned INTO _is_pinned FROM hivemind_app.hive_posts WHERE id = _post_id; + + IF NOT _is_pinned THEN + RETURN QUERY SELECT FALSE, 'post is not pinned'::TEXT, NULL::INTEGER, FALSE; + RETURN; + END IF; + + UPDATE hivemind_app.hive_posts + SET is_pinned = false + WHERE id = _post_id; + + _is_subscribed := hivemind_app.community_is_subscribed(_account_id, _community_id); + + RETURN QUERY SELECT TRUE, ''::TEXT, _post_id, _is_subscribed; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS hivemind_app.community_flag_post; +CREATE OR REPLACE FUNCTION hivemind_app.community_flag_post( + _actor_id INTEGER, + _community_id INTEGER, + _account_id INTEGER, + _permlink VARCHAR, + _community_name VARCHAR +) RETURNS TABLE(success BOOLEAN, error_message TEXT, post_id INTEGER, team_members INTEGER[], is_subscribed BOOLEAN) AS $$ +DECLARE + _actor_role INTEGER; + _already_flagged BOOLEAN; + _team_members INTEGER[]; + _post_id INTEGER; + _post_error TEXT; + _is_subscribed BOOLEAN; +BEGIN + _actor_role := hivemind_app.get_community_role(_actor_id, _community_id); + + IF _actor_role <= -2 THEN + RETURN QUERY SELECT FALSE, 'muted users cannot flag posts'::TEXT, NULL::INTEGER, NULL::INTEGER[], FALSE; + RETURN; + END IF; + + SELECT p.post_id, p.error_message INTO _post_id, _post_error + FROM hivemind_app.get_post_id_by_permlink(_account_id, _permlink, _community_id) p; + + IF _post_id IS NULL THEN + RETURN QUERY SELECT FALSE, _post_error, NULL::INTEGER, NULL::INTEGER[], FALSE; + RETURN; + END IF; + + SELECT EXISTS( + SELECT 1 FROM hivemind_app.hive_notification_cache + WHERE community = _community_name + AND hive_notification_cache.post_id = _post_id + AND type_id = 9 -- flag_post + AND src = _actor_id + ) INTO _already_flagged; + + IF _already_flagged THEN + RETURN QUERY SELECT FALSE, 'user already flagged this post'::TEXT, NULL::INTEGER, NULL::INTEGER[], FALSE; + RETURN; + END IF; + + SELECT ARRAY_AGG(account_id) INTO _team_members + FROM hivemind_app.hive_roles + WHERE community_id = _community_id + AND role_id >= 4; -- better or equal to mod + + _is_subscribed := hivemind_app.community_is_subscribed(_account_id, _community_id); + + RETURN QUERY SELECT TRUE, ''::TEXT, _post_id, _team_members, _is_subscribed; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS hivemind_app.update_community_props; +CREATE OR REPLACE FUNCTION hivemind_app.update_community_props( + _actor_id INTEGER, + _community_id INTEGER, + _props JSONB +) RETURNS TABLE(success BOOLEAN, error_message TEXT, team_members INTEGER[]) AS $$ +DECLARE + _actor_role INTEGER; + _team_members INTEGER[]; +BEGIN + _actor_role := hivemind_app.get_community_role(_actor_id, _community_id); + + IF _actor_role < 6 THEN + RETURN QUERY SELECT FALSE, 'only admins can update props'::TEXT, NULL::INTEGER[]; + RETURN; + END IF; + + UPDATE hivemind_app.hive_communities + SET + title = CASE WHEN jsonb_exists(_props, 'title') THEN _props->>'title' ELSE title END, + about = CASE WHEN jsonb_exists(_props, 'about') THEN _props->>'about' ELSE about END, + lang = CASE WHEN jsonb_exists(_props, 'lang') THEN _props->>'lang' ELSE lang END, + is_nsfw = CASE WHEN jsonb_exists(_props, 'is_nsfw') THEN (_props->>'is_nsfw')::BOOLEAN ELSE is_nsfw END, + description = CASE WHEN jsonb_exists(_props, 'description') THEN _props->>'description' ELSE description END, + flag_text = CASE WHEN jsonb_exists(_props, 'flag_text') THEN _props->>'flag_text' ELSE flag_text END, + settings = CASE WHEN jsonb_exists(_props, 'settings') THEN (_props->>'settings')::JSONB ELSE settings END, + type_id = CASE WHEN jsonb_exists(_props, 'type_id') THEN (_props->>'type_id')::INTEGER ELSE type_id END + WHERE id = _community_id; + + SELECT ARRAY_AGG(account_id) INTO _team_members + FROM hivemind_app.hive_roles + WHERE community_id = _community_id + AND role_id >= 4; -- better or equal to mod + + RETURN QUERY SELECT TRUE, ''::TEXT, _team_members; END; $$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/hive/db/sql_scripts/community_utils.sql b/hive/db/sql_scripts/community_utils.sql new file mode 100644 index 0000000000000000000000000000000000000000..4984f84a0d53963df75d97d2aa80c390bcb11c32 --- /dev/null +++ b/hive/db/sql_scripts/community_utils.sql @@ -0,0 +1,59 @@ +DROP FUNCTION IF EXISTS hivemind_app.community_is_subscribed; +CREATE OR REPLACE FUNCTION hivemind_app.community_is_subscribed( + _account_id INTEGER, + _community_id INTEGER +) RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS( + SELECT 1 FROM hivemind_app.hive_subscriptions + WHERE community_id = _community_id + AND account_id = _account_id + ); +END; +$$ LANGUAGE plpgsql STABLE; + +DROP FUNCTION IF EXISTS hivemind_app.get_community_role; +CREATE OR REPLACE FUNCTION hivemind_app.get_community_role( + _account_id INTEGER, + _community_id INTEGER +) RETURNS INTEGER AS $$ +BEGIN + -- default to guest = 0 if no role + RETURN COALESCE( + (SELECT role_id FROM hivemind_app.hive_roles + WHERE community_id = _community_id AND account_id = _account_id), + 0 + ); +END; +$$ LANGUAGE plpgsql STABLE; + +DROP FUNCTION IF EXISTS hivemind_app.get_post_id_by_permlink; +CREATE OR REPLACE FUNCTION hivemind_app.get_post_id_by_permlink( + _account_id INTEGER, + _permlink VARCHAR, + _community_id INTEGER +) RETURNS TABLE(post_id INTEGER, error_message TEXT) AS $$ +DECLARE + _post_id INTEGER; + _post_community_id INTEGER; + _account_name VARCHAR; +BEGIN + SELECT hp.id, hp.community_id INTO _post_id, _post_community_id + FROM hivemind_app.live_posts_comments_view hp + JOIN hivemind_app.hive_permlink_data hpd ON hp.permlink_id = hpd.id + WHERE hp.author_id = _account_id AND hpd.permlink = _permlink; + + IF _post_id IS NULL THEN + SELECT name INTO _account_name FROM hivemind_app.hive_accounts WHERE id = _account_id; + RETURN QUERY SELECT NULL::INTEGER, ('post does not exists ' || _account_name || '/' || _permlink)::TEXT; + RETURN; + END IF; + + IF _post_community_id != _community_id THEN + RETURN QUERY SELECT NULL::INTEGER, 'post does not belong to a community'::TEXT; + RETURN; + END IF; + + RETURN QUERY SELECT _post_id, ''::TEXT; +END; +$$ LANGUAGE plpgsql STABLE; diff --git a/hive/indexer/community.py b/hive/indexer/community.py index 49d9c5ba1430ecc9ea2e8add9dee9c1d2dec3efc..8d72f08e65daac5e50e9ef1a34b9fed92dc43954 100644 --- a/hive/indexer/community.py +++ b/hive/indexer/community.py @@ -170,30 +170,19 @@ class Community: if not re.match(r'^hive-[123]\d{4,6}$', name): return - type_id = int(name[5]) + _id = Accounts.get_id(name) counter = cls._counter.increment(block_num) - # insert community - sql = f"""INSERT INTO {SCHEMA_NAME}.hive_communities (id, name, type_id, created_at, block_num) - VALUES (:id, :name, :type_id, :date, :block_num)""" - DbAdapterHolder.common_block_processing_db().query(sql, id=_id, name=name, type_id=type_id, date=block_date, block_num=block_num) - - # insert owner - sql = f"""INSERT INTO {SCHEMA_NAME}.hive_roles (community_id, account_id, role_id, created_at) - VALUES (:community_id, :account_id, :role_id, :date)""" - DbAdapterHolder.common_block_processing_db().query(sql, community_id=_id, account_id=_id, role_id=Role.owner.value, date=block_date) - - # insert community notification - # Howo: Maybe we should change this to set dst as the account creator instead - sql = f"""INSERT INTO {SCHEMA_NAME}.hive_notification_cache (id, block_num, type_id, created_at, src, dst, dst_post_id, post_id, score, payload, community, community_title) - SELECT {SCHEMA_NAME}.notification_id((:created_at)::timestamp, 1, :counter), n.* - FROM (VALUES(:block_num, 1, (:created_at)::timestamp, 0, :dst, 0, 0, 35, '', :community, '')) - AS n(block_num, type_id, created_at, src, dst, dst_post_id, post_id, score, payload, community, community_title) - WHERE n.score >= 0 AND n.src IS DISTINCT FROM n.dst - AND n.block_num > hivemind_app.block_before_irreversible('90 days') - """ - DbAdapterHolder.common_block_processing_db().query(sql, block_num=block_num, created_at=block_date, dst=_id, community=name, counter=counter) + sql = f"""SELECT {SCHEMA_NAME}.register_community(:name, :account_id, :block_date, :block_num, :counter)""" + DbAdapterHolder.common_block_processing_db().query_no_return( + sql, + name=name, + account_id=_id, + block_date=block_date, + block_num=block_num, + counter=counter + ) @classmethod def validated_id(cls, name): @@ -225,44 +214,6 @@ class Community: cls._names[cid] = name return cid - @classmethod - def _get_name(cls, cid): - if cid in cls._names: - return cls._names[cid] - sql = f"SELECT name FROM {SCHEMA_NAME}.hive_communities WHERE id = :id" - name = DbAdapterHolder.common_block_processing_db().query_one(sql, id=cid) - if cid: - cls._ids[name] = cid - cls._names[cid] = name - return name - - @classmethod - def get_all_muted(cls, community_id): - """Return a list of all muted accounts.""" - return DbAdapterHolder.common_block_processing_db().query_col( - f"""SELECT name FROM {SCHEMA_NAME}.hive_accounts - WHERE id IN (SELECT account_id FROM {SCHEMA_NAME}.hive_roles - WHERE community_id = :community_id - AND role_id < 0)""", - community_id=community_id, - ) - - @classmethod - def get_user_role(cls, community_id, account_id): - """Get user role within a specific community.""" - - return ( - DbAdapterHolder.common_block_processing_db().query_one( - f"""SELECT role_id FROM {SCHEMA_NAME}.hive_roles - WHERE community_id = :community_id - AND account_id = :account_id - LIMIT 1""", - community_id=community_id, - account_id=account_id, - ) - or Role.guest.value - ) - @classmethod def is_post_valid(cls, role): """Given a new post/comment, check if valid as per community rules @@ -352,8 +303,7 @@ class CommunityOp: # validate and read schema self._read_schema() - # validate permissions - self._validate_permissions() + # permissions are validated directly in SQL self.valid = True @@ -389,103 +339,143 @@ class CommunityOp: # Community-level commands if action == 'updateProps': - bind = ', '.join([k + " = :" + k for k in list(self.props.keys())]) - DbAdapterHolder.common_block_processing_db().query( - f"UPDATE {SCHEMA_NAME}.hive_communities SET {bind} WHERE id = :id", id=self.community_id, **self.props + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.update_community_props( + :actor_id, :community_id, :props + )""", + actor_id=self.actor_id, + community_id=self.community_id, + props=json.dumps(self.props) ) - self._notify_team('set_props', payload=json.dumps(read_key_dict(self.op, 'props'))) + if self._handle_result(result): + self._notify_team('set_props', team_members=result['team_members'], payload=json.dumps(read_key_dict(self.op, 'props'))) elif action == 'subscribe': params['counter'] = CommunityOp._counter.increment(self.block_num) - DbAdapterHolder.common_block_processing_db().query_no_return( - f"""SELECT {SCHEMA_NAME}.community_subscribe(:actor_id, :community_id, :date, :block_num, :counter)""", + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_subscribe(:actor_id, :community_id, :date, :block_num, :counter)""", **params, ) + self._handle_result(result) elif action == 'unsubscribe': - DbAdapterHolder.common_block_processing_db().query_no_return( - f"""SELECT {SCHEMA_NAME}.community_unsubscribe(:actor_id, :community_id)""", + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_unsubscribe(:actor_id, :community_id)""", **params, ) - + self._handle_result(result) # Account-level actions elif action == 'setRole': - result = DbAdapterHolder.common_block_processing_db().query_all( - f"""SELECT * FROM {SCHEMA_NAME}.set_community_role( - :account_id, :community_id, :role_id, :date, + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_set_role( + :actor_id, :account_id, :community_id, :role_id, :date, :max_mod_nb, :mod_role_threshold )""", max_mod_nb=MAX_MOD_NB, mod_role_threshold=Role.mod, **params ) - - if result[0]['status'] == 'success': - self._notify('set_role', payload=Role(self.role_id).name) - else: - Notify( - block_num=self.block_num, - type_id='error', - src_id=self.community_id, - community_id=self.community_id, - dst_id=self.actor_id, - when=self.date, - payload=f'Cannot set role: {Role(self.role_id).name} limit of {MAX_MOD_NB} moderators/admins/owners exceeded' - ) + self._handle_result(result, 'set_role', payload=Role(self.role_id).name) elif action == 'setUserTitle': - DbAdapterHolder.common_block_processing_db().query( - f"""INSERT INTO {SCHEMA_NAME}.hive_roles - (account_id, community_id, title, created_at) - VALUES (:account_id, :community_id, :title, :date) - ON CONFLICT (account_id, community_id) - DO UPDATE SET title = :title""", + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_set_title( + :actor_id, :account_id, :community_id, :title, :date + )""", **params, ) - self._notify('set_title', payload=self.title) - + self._handle_result(result, 'set_title', payload=self.title) # Post-level actions elif action == 'mutePost': - DbAdapterHolder.common_block_processing_db().query( - f"""UPDATE {SCHEMA_NAME}.hive_posts SET is_muted = '1', muted_reasons = :muted_reasons - WHERE id = :post_id""", - **params, + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_mute_post( + :actor_id, :community_id, :account_id, :permlink, :muted_reasons + )""", + actor_id=self.actor_id, + community_id=self.community_id, + account_id=self.account_id, + permlink=self.permlink, + muted_reasons=params['muted_reasons'] ) - self._notify('mute_post', payload=self.notes) + self._handle_result(result, 'mute_post', payload=self.notes) elif action == 'unmutePost': - DbAdapterHolder.common_block_processing_db().query( - f"""UPDATE {SCHEMA_NAME}.hive_posts SET is_muted = '0', muted_reasons = 0 - WHERE id = :post_id""", - **params, + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_unmute_post( + :actor_id, :community_id, :account_id, :permlink + )""", + actor_id=self.actor_id, + community_id=self.community_id, + account_id=self.account_id, + permlink=self.permlink ) - self._notify('unmute_post', payload=self.notes) + self._handle_result(result, 'unmute_post', payload=self.notes) elif action == 'pinPost': - DbAdapterHolder.common_block_processing_db().query( - f"""UPDATE {SCHEMA_NAME}.hive_posts SET is_pinned = '1' - WHERE id = :post_id""", - **params, + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_pin_post( + :actor_id, :community_id, :account_id, :permlink + )""", + actor_id=self.actor_id, + community_id=self.community_id, + account_id=self.account_id, + permlink=self.permlink ) - self._notify('pin_post', payload=self.notes) + self._handle_result(result, 'pin_post', payload=self.notes) elif action == 'unpinPost': - DbAdapterHolder.common_block_processing_db().query( - f"""UPDATE {SCHEMA_NAME}.hive_posts SET is_pinned = '0' - WHERE id = :post_id""", - **params, + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_unpin_post( + :actor_id, :community_id, :account_id, :permlink + )""", + actor_id=self.actor_id, + community_id=self.community_id, + account_id=self.account_id, + permlink=self.permlink ) - self._notify('unpin_post', payload=self.notes) + self._handle_result(result, 'unpin_post', payload=self.notes) elif action == 'flagPost': - self._notify_team('flag_post', payload=self.notes) + result = DbAdapterHolder.common_block_processing_db().query_row( + f"""SELECT * FROM {SCHEMA_NAME}.community_flag_post( + :actor_id, :community_id, :account_id, :permlink, :community + )""", + actor_id=self.actor_id, + community_id=self.community_id, + account_id=self.account_id, + permlink=self.permlink, + community=self.community + ) + if self._handle_result(result): + self._notify_team('flag_post', team_members=result['team_members'], post_id=result['post_id'], payload=self.notes) FSM.flush_stat('Community', perf_counter() - time_start, 1) return True - def _notify(self, op, **kwargs): + def _handle_result(self, result, operation_name=None, payload=None): + """Handle result from SQL operations with success/error_messages as notifications""" + if result and result['success']: + if operation_name: + post_id = result['post_id'] if 'post_id' in result.keys() else None + is_subscribed = result['is_subscribed'] if 'is_subscribed' in result.keys() else False + self._notify(operation_name, post_id=post_id, is_subscribed=is_subscribed, payload=payload) + return True + elif result: + Notify( + block_num=self.block_num, + type_id='error', + dst_id=self.actor_id, + when=self.date, + payload=result['error_message'], + community_id=self.community_id, + src_id=self.community_id + ) + return False + return True + + def _notify(self, op, post_id=None, is_subscribed=None, **kwargs): dst_id = None score = 35 if self.account_id: dst_id = self.account_id - if not self._subscribed(self.account_id): + if is_subscribed == False: score = 15 Notify( @@ -493,24 +483,15 @@ class CommunityOp: type_id=op, src_id=self.actor_id, dst_id=dst_id, - post_id=self.post_id, + post_id=post_id, when=self.date, community_id=self.community_id, score=score, **kwargs, ) - def _notify_team(self, op, **kwargs): + def _notify_team(self, op, team_members=None, post_id=None, **kwargs): """Send notifications to all team members (mod, admin, owner) in a community.""" - - team_members = DbAdapterHolder.common_block_processing_db().query_col( - f"""SELECT account_id FROM {SCHEMA_NAME}.hive_roles - WHERE community_id = :community_id - AND role_id >= :min_role_id""", - community_id=self.community_id, - min_role_id=Role.mod.value # 4 - ) - for member_id in team_members: # Skip sending notification to the source user (the one triggering the notification) if member_id == self.actor_id: @@ -521,7 +502,7 @@ class CommunityOp: type_id=op, src_id=self.actor_id, dst_id=member_id, - post_id=self.post_id, + post_id=post_id, when=self.date, community_id=self.community_id, score=35, @@ -574,25 +555,7 @@ class CommunityOp: assert self.account, 'permlink requires named account' _permlink = read_key_str(self.op, 'permlink', 256) assert _permlink, 'must name a permlink' - - sql = f""" - SELECT hp.id, community_id - FROM {SCHEMA_NAME}.live_posts_comments_view hp - JOIN {SCHEMA_NAME}.hive_permlink_data hpd ON hp.permlink_id=hpd.id - WHERE author_id=:_author AND hpd.permlink=:_permlink - """ - result = DbAdapterHolder.common_block_processing_db().query_row(sql, _author=self.account_id, _permlink=_permlink) - assert result, f'post does not exists {self.account}/{_permlink}' - result = dict(result) - - _pid = result.get('id', None) - assert _pid, f'post does not exists {self.account}/{_permlink}' - - _comm = result.get('community_id', None) - assert self.community_id == _comm, 'post does not belong to community' - self.permlink = _permlink - self.post_id = _pid def _read_role(self): _role = read_key_str(self.op, 'role', 16) @@ -646,88 +609,4 @@ class CommunityOp: assert community_type in valid_types, 'invalid community type' out['type_id'] = community_type assert out, 'props were blank' - self.props = out - - def _validate_permissions(self): - community_id = self.community_id - action = self.action - actor_role = Community.get_user_role(community_id, self.actor_id) - new_role = self.role_id - - if action == 'setRole': - assert actor_role >= Role.mod, 'only mods and up can alter roles' - assert actor_role > new_role, 'cannot promote to or above own rank' - account_role = Community.get_user_role(community_id, self.account_id) - assert account_role != Role.owner, 'cant modify owner role' - if self.actor != self.account: - assert account_role < actor_role, 'cant modify higher-role user' - assert account_role != new_role, 'role would not change' - elif action == 'updateProps': - assert actor_role >= Role.admin, 'only admins can update props' - elif action == 'setUserTitle': - # TODO: assert title changed? - assert actor_role >= Role.mod, 'only mods can set user titles' - elif action == 'mutePost': - assert not self._muted(), 'post is already muted' - assert actor_role >= Role.mod, 'only mods can mute posts' - elif action == 'unmutePost': - assert self._muted(), 'post is already not muted' - assert not self._parent_muted(), 'parent post is muted' - assert actor_role >= Role.mod, 'only mods can unmute posts' - elif action == 'pinPost': - assert not self._pinned(), 'post is already pinned' - assert actor_role >= Role.mod, 'only mods can pin posts' - elif action == 'unpinPost': - assert self._pinned(), 'post is already not pinned' - assert actor_role >= Role.mod, 'only mods can unpin posts' - elif action == 'flagPost': - assert actor_role > Role.muted, 'muted users cannot flag posts' - assert not self._flagged(), 'user already flagged this post' - elif action == 'subscribe': - assert not self._subscribed(self.actor_id), 'already subscribed' - elif action == 'unsubscribe': - assert self._subscribed(self.actor_id), 'already unsubscribed' - - def _subscribed(self, account_id): - """Check an account's subscription status.""" - sql = f"""SELECT EXISTS( - SELECT 1 FROM {SCHEMA_NAME}.hive_subscriptions - WHERE community_id = :community_id - AND account_id = :account_id - )""" - return DbAdapterHolder.common_block_processing_db().query_one(sql, community_id=self.community_id, account_id=account_id) - - def _muted(self): - """Check post's muted status.""" - sql = f"SELECT is_muted FROM {SCHEMA_NAME}.hive_posts WHERE id = :id" - return bool(DbAdapterHolder.common_block_processing_db().query_one(sql, id=self.post_id)) - - def _parent_muted(self): - """Check parent post's muted status.""" - parent_id = f"SELECT parent_id FROM {SCHEMA_NAME}.hive_posts WHERE id = :id" - sql = f"SELECT is_muted FROM {SCHEMA_NAME}.hive_posts WHERE id = ({parent_id})" - return bool(DbAdapterHolder.common_block_processing_db().query_one(sql, id=self.post_id)) - - def _pinned(self): - """Check post's pinned status.""" - sql = f"SELECT is_pinned FROM {SCHEMA_NAME}.hive_posts WHERE id = :id" - return bool(DbAdapterHolder.common_block_processing_db().query_one(sql, id=self.post_id)) - - def _flagged(self): - """Check user's flag status. Note that because hive_notification_cache gets flushed every 90 days, this means you can re-flag every 90 days""" - from hive.indexer.notify import NotifyType - - sql = f"""SELECT 1 FROM {SCHEMA_NAME}.hive_notification_cache - WHERE community = :community - AND post_id = :post_id - AND type_id = :type_id - AND src = :src""" - return bool( - DbAdapterHolder.common_block_processing_db().query_one( - sql, - community=self.community, - post_id=self.post_id, - type_id=NotifyType['flag_post'], - src=self.actor_id, - ) - ) + self.props = out \ No newline at end of file diff --git a/tests/api_tests/hivemind/tavern/bridge_api_patterns/account_notifications/test-safari.pat.json b/tests/api_tests/hivemind/tavern/bridge_api_patterns/account_notifications/test-safari.pat.json index 0576a243207a82c86cba4d3436f5b146d35b740f..d8e866cec251c76e667b028895a670e677823789 100644 --- a/tests/api_tests/hivemind/tavern/bridge_api_patterns/account_notifications/test-safari.pat.json +++ b/tests/api_tests/hivemind/tavern/bridge_api_patterns/account_notifications/test-safari.pat.json @@ -2,7 +2,7 @@ { "date": "2016-09-15T19:47:48", "id": "4062413112475650", - "msg": "error: post does not belong to community", + "msg": "error: post does not belong to a community", "score": 35, "type": "error", "url": "c/hive-198723"