class Eco::API::UseCases::DefaultCases::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) micro = session.micro 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, people, session, options) 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| if options[:simulate] units.each {|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, people, session, options) units = paired_entries(entries, session) do |unpaired_entries| report_missing_peer!(unpaired_entries, session.logger) end.map do |entry_pair| person_pair = to_persons(entry_pair, people, session, options) new_unit(*entry_pair, *person_pair) end sort_units(units).tap do |units| # check that there are no repeated sources or destinations, that sources have account validate_pairs!(units, session.logger) units.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, people, session, options) micro = session.micro [].tap do |persons| micro.with_each(paired_entry, people, options) do |entry, person| next persons.push(person) unless person.new? if person = session.batch.search([entry], silent: true).people.first persons.push(person) people << person next end session.logger.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, session) expect_destination_id!(entries, session.logger) 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 |paired_entries| 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"] return 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?(u2) (src_person == u2.dst_person) && (dst_person == u2.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(e1, e2, p1, p2) unit_type.new(e1, e2, p1, p2) 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, logger) 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) do |unit| unit.dst_person end.each_with_object([]) do |unit, lines| lines << str_person_entry(unit.dst_person, unit.dst_entry, unit.src_entry.idx) end missing_account.compact! unless [missing_account, src_repeated, dst_repeated].all?(&:empty?) report_missing_account(missing_account.join("\n"), logger) unless missing_account.empty? msg = Proc.new 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"), logger, msg["SOURCE"]) unless src_repeated.empty? report_repeated(dst_repeated.join("\n"), logger, msg["DESTINATION"]) unless dst_repeated.empty? exit(1) end end # Messaging & Validation def report_missing_peer!(unpaired_entries, logger) unless unpaired_entries.empty? msg = "The following rows are missing 'destination-id':\n\n" msg += unpaired_entries.map {|entry| str_missing_peer(entry)}.join("\n") logger.error(msg) exit(1) end 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, logger) unless entries.length > 0 logger.error("Your csv is empty") exit(1) end unless entry_peer_id?(entries.first) logger.error("You haven't defined a column 'destination-id' to whom the account should be transferred") exit(1) end end def report_missing_account(str, logger) logger.error("The following source people do not have account:\n#{str}") end def report_repeated(str, logger, msg = "The following entries are repeated") logger.error("#{msg}\n#{str}") end end