From 290923f0aea3d8218d90700ee9de67e7453f9680 Mon Sep 17 00:00:00 2001 From: Francis Santiago Date: Tue, 22 Sep 2026 20:42:53 +0200 Subject: [PATCH] :sparkles: Add bulk profile deletion CLI Allow administrative tools to submit a list of profile emails through the structured PREPL interface instead of evaluating Clojure expressions. Reuse the existing audited deletion path and expose the command through manage.py for automated maintenance jobs. AI-assisted-by: gpt-5 --- backend/scripts/manage.py | 24 +++++++++ backend/src/app/srepl/cli.clj | 47 ++++++++++++++++++ backend/src/app/srepl/main.clj | 49 ++++--------------- backend/test/backend_tests/srepl_cli_test.clj | 32 ++++++++++++ 4 files changed, 112 insertions(+), 40 deletions(-) create mode 100644 backend/test/backend_tests/srepl_cli_test.clj diff --git a/backend/scripts/manage.py b/backend/scripts/manage.py index 56141571e7..f600b948b9 100755 --- a/backend/scripts/manage.py +++ b/backend/scripts/manage.py @@ -137,6 +137,23 @@ def search_profile(email): if isinstance(res, list): print(tabulate(res, headers="keys")) +def delete_profiles_in_bulk(path): + with open(path, encoding="utf-8") as email_file: + emails = [line.strip() for line in email_file if line.strip()] + + if not emails: + raise RuntimeError("the email file is empty") + + params = { + "cmd": "delete-profiles-in-bulk", + "params": { + "emails": emails, + } + } + + res = run_cmd(params) + print(f"Enqueued: {res['deleted']} / {res['total']}") + def derive_password(password): params = { "cmd": "derive-password", @@ -161,6 +178,7 @@ available_commands = ( "create-profile", "update-profile", "delete-profile", + "delete-profiles-in-bulk", "search-profile", "derive-password", ) @@ -178,6 +196,7 @@ parser.add_argument("-n", "--fullname", help="fullname", action="store") parser.add_argument("-e", "--email", help="email", action="store") parser.add_argument("-p", "--password", help="password", action="store") parser.add_argument("-c", "--connect", help="connect to PREPL", action="store", default="tcp://localhost:6063") +parser.add_argument("--file", help="file containing one email per line", action="store") parser.add_argument("--skip-tutorial", help="mark tutorial as viewed", action="store_true") parser.add_argument("--skip-walkthrough", help="mark walkthrough as viewed", action="store_true") @@ -237,4 +256,9 @@ elif args.action == "search-profile": search_profile(email) +elif args.action == "delete-profiles-in-bulk": + if args.file is None: + parser.error("delete-profiles-in-bulk requires --file") + + delete_profiles_in_bulk(args.file) diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index c47043cbad..3152714a70 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -13,11 +13,14 @@ [app.common.schema.generators :as sg] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] + [app.loggers.audit :as audit] [app.rpc.commands.auth :as cmd.auth] [app.rpc.commands.profile :as cmd.profile] [app.setup :as-alias setup] [app.tokens :as tokens] + [app.worker :as wrk] [cuerdas.core :as str])) (defn coercer @@ -122,6 +125,50 @@ " where email similar to ? order by created_at desc limit 100")] (db/exec! conn [sql email])))))) +(defn delete-profiles! + "Enqueue the standard profile deletion task for each provided email." + ([system emails] + (delete-profiles! system emails + {:triggered-by "prepl" + :cause "explicit call to delete-profiles-in-bulk"})) + ([system emails context] + (let [deleted-at (ct/minus (ct/now) (cf/get-deletion-delay))] + (db/tx-run! system + (fn [system] + (reduce + (fn [{:keys [deleted total]} email] + (if-let [profile (some-> (db/get* system :profile + {:email (str/lower email)} + {::db/remove-deleted false}) + (cmd.profile/decode-row))] + (do + (audit/insert system + {:name "delete-profile" + :type "action" + :profile-id (:id profile) + :tracked-at deleted-at + :props (audit/profile->props profile) + :context context}) + (wrk/invoke! (-> system + (assoc ::wrk/task :delete-object) + (assoc ::wrk/params {:object :profile + :deleted-at deleted-at + :id (:id profile)}))) + {:deleted (inc deleted) :total (inc total)}) + {:deleted deleted :total (inc total)})) + {:deleted 0 :total 0} + emails)))))) + +(defmethod exec-command "delete-profiles-in-bulk" + [{:keys [emails]}] + (when-not (and (vector? emails) (seq emails) (every? string? emails)) + (ex/raise :type :assertion + :code :invalid-arguments + :hint "a non-empty email list should be provided")) + + (some-> (get-current-system) + (delete-profiles! emails))) + (defmethod exec-command "derive-password" [{:keys [password]}] (derive-password password)) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index fdc0caea1a..14620e1573 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -34,6 +34,7 @@ [app.rpc.commands.profile :as profile] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] + [app.srepl.cli :as cli] [app.srepl.helpers :as h] [app.srepl.procs.file-repair :as procs.file-repair] [app.system :as sys] @@ -745,47 +746,15 @@ (defn delete-profiles-in-bulk! [system path] - (letfn [(process-data! [system deleted-at emails] - (loop [emails emails - deleted 0 - total 0] - (if-let [email (first emails)] - (if-let [profile (some-> (db/get* system :profile - {:email (str/lower email)} - {::db/remove-deleted false}) - (profile/decode-row))] - (do - (audit/insert system - {:name "delete-profile" - :type "action" - :profile-id (:id profile) - :tracked-at deleted-at - :props (audit/profile->props profile) - :context {:triggered-by "srepl" - :cause "explicit call to delete-profiles-in-bulk!"}}) - (wrk/invoke! (-> system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :profile - :deleted-at deleted-at - :id (:id profile)}))) - (recur (rest emails) - (inc deleted) - (inc total))) - (recur (rest emails) - deleted - (inc total))) - {:deleted deleted :total total})))] + (let [path (fs/path path)] + (when-not (fs/exists? path) + (throw (ex-info "path does not exists" {:path path}))) - (let [path (fs/path path) - deleted-at (ct/minus (ct/now) (cf/get-deletion-delay))] - - (when-not (fs/exists? path) - (throw (ex-info "path does not exists" {:path path}))) - - (db/tx-run! system - (fn [system] - (with-open [reader (io/reader path)] - (process-data! system deleted-at (line-seq reader)))))))) + (with-open [reader (io/reader path)] + (cli/delete-profiles! system + (line-seq reader) + {:triggered-by "srepl" + :cause "explicit call to delete-profiles-in-bulk!"})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; CASCADE FIXING diff --git a/backend/test/backend_tests/srepl_cli_test.clj b/backend/test/backend_tests/srepl_cli_test.clj new file mode 100644 index 0000000000..e73eb5ae08 --- /dev/null +++ b/backend/test/backend_tests/srepl_cli_test.clj @@ -0,0 +1,32 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en EspaƱa SL + +(ns backend-tests.srepl-cli-test + (:require + [app.srepl.cli :as cli] + [app.system :as system] + [clojure.test :as t])) + +(t/deftest delete-profiles-in-bulk-command + (let [received (atom nil)] + (with-redefs [system/system {:service :test} + cli/delete-profiles! (fn [system emails] + (reset! received [system emails]) + {:deleted 2 :total 2})] + (t/is (= {:deleted 2 :total 2} + (cli/exec {:cmd "delete-profiles-in-bulk" + :params {:emails ["one@example.com" + "two@example.com"]}}))) + (t/is (= [{:service :test} + ["one@example.com" "two@example.com"]] + @received))))) + +(t/deftest delete-profiles-in-bulk-requires-emails + (t/is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"non-empty email list" + (cli/exec {:cmd "delete-profiles-in-bulk" + :params {:emails []}}))))