class Eco::API::UseCases::Default::People::TransferAccountCase < Eco::API::Common::Loaders::UseCase name "transfer-account" type :sync # Usecase to **actually transfer a user/account** from one person to another person in the organization. # * **invocation command**: `-transfer-account-from` # # These are the steps and jobs it does: # 1. **pair** person entries (note: the `destination-id` entry could not be present, it will add it # in such a case). # 2. **retrieve** from server persons that were not included in `people`. # 3. **validation** # - a person should only receive account from just one user # - a person should only give account to just one user # - every account giver should have account # 4. **create jobs** # - **move** giver's and receiver's **accounts** to dummy email addresses. # * dummy email pattern: from name@domain.ltd --to--> demo+name.domain.ltd@ecoportal.co.nz # - **free up** giver's and receiver's **accounts**. # - **switch** email: set receiver's email to that of giver's dummy address. # * to ensure account transfer, as we moved accounts to dummy emails, those dummy addresses should be used # - **invite** receivers: adds account to the destination person in the dummy email. # * actual user/account transfer to the person/receiver # * **no notification** will be recived by the user, because of the dummy address at this stage # - **restore** email: sets the receiver email from the dummy address to the final email. # * the final email inbox will receive a **notification** of _email change_ # @note # - the `csv` should contain a column `destination-id` containing the person `id` or `external_id` of # the person that will be receiving the account. # - when running this case, it is recommended to use the option `-skip-batch-policies` # - it is highly recommended to either refresh the cache with `-get-people` or use the `-get-partial` option. # @param entries [Eco::API::Common::People::Entries] the input entries with the data. # @param people [Eco::API::Organization::People] target existing _People_ of the current update. # @param session [Eco::API::Session] the current session where the usecase kicks in. # @param options [Hash] the options that modify the case behaviour or bring some dependencies. # @option options [Hash] :include things that should be included. # * `:email` (Boolean) [false] if the `email` should be transferred as well (**command option**: `-include-email`) # @option options [Hash] :skip things that should be excluded. # * `:api_policies` (Boolean) [false] if the `api policies` should be skipped # (**command option**: `-skip-api-policies`) # @return [Void] def main(entries, _people, session, options, usecase) # rubocop:disable Metrics/AbcSize move = session.new_job("main", "move email accounts", :update, usecase, :account) free = session.new_job("main", "free up accounts", :update, usecase, :account) switch = session.new_job("main", "switch email", :update, usecase, :core) invite = session.new_job("post", "invite receivers", :update, usecase, :account) restore = session.new_job("post", "restore email", :update, usecase, :core) with_each_person_pair(entries) do |src_person, dst_person| # capture actual initial information src_doc = src_person.account.doc src_email = src_person.email src_dummy = dummy_email(src_person) dst_email = dst_person.email dst_dummy = dummy_email(dst_person) copy_src_email = options.dig(:include, :email) || !dst_person.account dst_end_email = copy_src_email ? src_email : dst_email # account email renamings are necessary to avoid uncertainty and ensure no email taken error move.add(dst_person) {|dst| dst.email = dst_dummy} move.add(src_person) {|src| src.email = src_dummy} # free accounts up! free.add([dst_person, src_person]) {|person| person.account = nil} # to effectivelly transfer the user/account, email should be the same during invite # otherwise the account doesn't actually get transferred but just copied switch.add(dst_person) {|dst| dst.email = src_dummy} # do the actual transfer of account invite.add(dst_person) {|dst| dst.account = src_doc} # recover the original email, if the receiver had account restore.add(dst_person) {|dst| dst.email = dst_end_email} end.tap do |units| next unless options[:simulate] units.each do |unit| puts unit.persons.map(&:external_id).join(" --> ") end end end private # if the person has account and an email different to demo+__@ecoportal.co.nz # it transforms email to demo+__@ecoportal.co.nz def dummy_email(person) return nil unless (email = person.email) return email if email.start_with?("demo") && email.end_with?("@ecoportal.co.nz") return email unless person.account "demo+#{email.split("@").join(".")}@ecoportal.co.nz" end def with_each_person_pair(entries) units = paired_entries(entries) do |unpaired_entries| report_missing_peer!(unpaired_entries) end.map do |entry_pair| person_pair = to_persons(entry_pair) new_unit(*entry_pair, *person_pair) end sort_units(units).tap do |uts| # check that there are no repeated sources or destinations, that sources have account validate_pairs!(uts) uts.each do |unit| yield(*unit.persons) if block_given? end end end # account givers that are receivers should give first # exchangers are placed at the beginning def sort_units(units) base = units.dup # cases where two persons exchange account exchangers = base.select do |source| base.any? {|dest| source.reversed_unit?(dest)} end exchangers | base.sort end # Entry helpers def to_persons(paired_entry) [].tap do |persons| micro.with_each(paired_entry, people, options) do |entry, person| next persons.push(person) unless person.new? person = session.batch.search([entry], silent: true).people.first if person persons.push(person) people << person next end log(:error) { "This person does not exist: #{entry.to_s(:identify)}" } exit(1) end end.tap do |persons| yield(*persons) if block_given? end end def paired_entries(entries) expect_destination_id!(entries) missing_peer = [] entries.each_with_object([]) do |source, out| if (peer = entry_peer(source, entries)) out.push([source, peer]) else missing_peer.push(source) end end.tap do yield(missing_peer) if block_given? end end def entry_peer(entry, entries) return nil unless (peer_id = entry_peer_id(entry)) entries.entry(id: peer_id, external_id: peer_id) || decouple_peer(entry) end def decouple_peer(entry) entry.new({ "id" => entry_peer_id(entry), "external_id" => entry_peer_id(entry), "email" => entry_peer_id(entry), "idx" => entry.idx }) end def entry_peer_id(entry) dest_id = entry.final_entry["destination-id"] dest_id unless dest_id.to_s.strip.empty? end def entry_peer_id?(entry) entry.final_entry.key?("destination-id") end # Unit type helpers def unit_type @unit_type ||= Struct.new(:src_entry, :dst_entry, :src_person, :dst_person) do def persons [src_person, dst_person] end def reversed_unit?(u_2) (src_person == u_2.dst_person) && (dst_person == u_2.src_person) end # givers go first def <=>(other) return -1 if src_person == other.dst_person return 1 if dst_person == other.src_person 0 end end end def new_unit(e_1, e_2, p_1, p_2) unit_type.new(e_1, e_2, p_1, p_2) end # Units helpers def find_repeated(units, &block) units.group_by(&block).select do |_k, v| v.count > 1 end.map {|_k, v| v.first} end def uniq_array(ary, &block) ary.each_with_object([]) do |e, uniq| uniq.push(e) unless uniq.any? {|chosen| block.call(chosen, e)} end end def validate_pairs!(units) # rubocop:disable Metrics/AbcSize missing_account = [] src_repeated = find_repeated(units) do |unit| unit.src_person.tap do |src_person| missing_account << (src_person.account ? nil : str_person_entry(unit.src_person, unit.src_entry)) end end.each_with_object([]) do |unit, lines| lines << str_person_entry(unit.src_person, unit.src_entry) end dst_repeated = find_repeated(units, &:dst_person).each_with_object([]) do |unit, lines| lines << str_person_entry(unit.dst_person, unit.dst_entry, unit.src_entry.idx) end missing_account.compact! return if [missing_account, src_repeated, dst_repeated].all?(&:empty?) report_missing_account(missing_account.join("\n")) unless missing_account.empty? msg = proc do |spot| "Transfers should be a 1.to.1 relation. The following #{spot} entries are repeated in the csv:" end report_repeated(src_repeated.join("\n"), msg["SOURCE"]) unless src_repeated.empty? report_repeated(dst_repeated.join("\n"), msg["DESTINATION"]) unless dst_repeated.empty? exit(1) end # Messaging & Validation def report_missing_peer!(unpaired_entries) return if unpaired_entries.empty? msg = "The following rows are missing 'destination-id':\n\n" msg += unpaired_entries.map {|entry| str_missing_peer(entry)}.join("\n") log(:error) { msg } exit(1) end def str_missing_peer(entry) "Source #{entry.to_s(:identify)} is missing 'destination-id'." end def str_person_entry(person, entry, row = nil) str_row = row ? "(actual row: #{row}) " : nil "#{str_row}id: '#{person.id || person.external_id}' email:#{person.email} -> Entry #{entry.to_s(:identify)}" end def expect_destination_id!(entries) unless entries.any? log(:error) { "Your csv is empty" } exit(1) end return if entry_peer_id?(entries.first) log(:error) { "You haven't defined a column 'destination-id' to whom the account should be transferred" } exit(1) end def report_missing_account(str) log(:error) { "The following source people do not have account:\n#{str}" } end def report_repeated(str, msg = "The following entries are repeated") log(:error) { "#{msg}\n#{str}" } end end