Repository: CSCfi/rems
Pull request #1512 (2019-08-16 14:49:38)

Copy as new application
#832


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62b375898..0991b5529 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,13 @@ have notable changes.
 
 Changes since v2.4
 
+### Breaking changes
+- Removed support for old round-based workflows
+- Removed support for transferring REMS 1 data
+- Replace applications API with new one (for dynamic applications)
+
 ### Additions
+- Dynamic workflows
 - New field types: description, option, multiselect
 - Setting maximum length for a form field
 - Showing changes between two versions of an application
@@ -30,6 +36,7 @@ Changes since v2.4
 - Upgraded swagger-ui from 2 to 3
 - Extra pages (#472)
 - Full-text search for all application content (#873)
+- Creating a new application as a copy from an older application (#832) 
 
 ### Enhancements
 - Improved version information in footer
@@ -42,40 +49,6 @@ Changes since v2.4
 ### Fixes
 - Entitlement API
 
-## v2.5 "Maarintie" 2019-07-18
-
-### Breaking changes
-- Removed support for old round-based workflows
-- Removed support for transferring REMS 1 data
-- Replace applications API with new one (for dynamic applications)
-
-### Additions
-- Dynamic workflows
-
-This is the last release that still supports round based workflows. Please use this version to convert to dynamic workflows. There are also many changes and fixes since last version, which will be listed in the next release.
-
-WARNING! The migration has problems with databases where licenses have been revoked, if the related entitlements are still active. See #1372.
-
-1. Run lein run migrate in rems/ repository. NOTE! If you can't run lein on target server, use an SSH tunnel. Make sure you have no previous tunnels running!
-   ssh -L 5432:remsdbserver:5432 remsappserver
-   AND then run on your local machine:
-   DATABASE_URL="postgresql://user:pw@localhost/db_name" lein run migrate
-
-2. Create a dynamic workflow
-
-3. Check new, dynamic workflow id from database:
-   select * from workflow order by start desc;
-
-4. Run on your own machine lein run convert-to-dynamic <dynamic_workflow_id> NOTE! If you can't run lein on target server, see step 1 for tunneling.
-
-5. Verify from database that all applications have the new, dynamic workflow id in column wfid: select * from catalogue_item_application;
-
-6. Go to administration page in UI and archive all non-dynamic workflows. If you do not have admin privileges, add them by adding owner role for yourself into the database:
-   insert into roles (userid, role) = ('[userid]', 'owner')
-   where [userid] is the eppn of your account (email address).
-
-7. Verify from ui that different kind of applications still work.
-
 ## v2.4 "Tietotie" 2018-10-24
 
 Starting from this milestone releases will also include pre-built war and jar packages.
diff --git a/resources/sql/queries.sql b/resources/sql/queries.sql
index 161e7a3c2..99a4d620b 100644
--- a/resources/sql/queries.sql
+++ b/resources/sql/queries.sql
@@ -331,15 +331,17 @@ UPDATE workflow_licenses wl
 SET start = :start, endt = :end
 WHERE wl.licid = :licid;
 
--- :name get-workflow-licenses :? :*
-SELECT licid, start, endt as "end"
-FROM workflow_licenses
-WHERE wfid = :wfid
-
 -- :name get-workflow :? :1
 SELECT
   wf.id, wf.organization, wf.owneruserid, wf.modifieruserid, wf.title, wf.start, wf.endt AS "end",
-  wf.workflowBody::TEXT as workflow, wf.enabled, wf.archived
+  wf.workflowBody::TEXT as workflow, wf.enabled, wf.archived,
+  (SELECT json_agg(joined)
+   FROM (SELECT *, (SELECT json_agg(licloc)
+                    FROM license_localization licloc
+                    WHERE licloc.licid = lic.id) AS localizations
+         FROM workflow_licenses wflic
+         JOIN license lic ON (wflic.licid = lic.id)
+         WHERE wf.id = wflic.wfid) joined)::TEXT AS licenses
 FROM workflow wf
 /*~ (when (:catid params) */
 JOIN catalogue_item ci ON (wf.id = ci.wfid)
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index 778fd2da2..d81b44c1f 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -101,6 +101,7 @@
                  :applicant "Applicant"
                  :application "Application"
                  :applications "Applications"
+                 :copied-from "copied from"
                  :created "Created"
                  ;; TODO: Once the terms have stabilized, convert db and the
                  ;;   code to match the term in UI.
@@ -110,19 +111,20 @@
                           :closed "Closed"
                           :comment-requested "Review requested"
                           :commented "Reviewed"
-                          :remarked "Remarked"
+                          :copied-from "Copied from an old application"
                           :created "Created"
                           :decided "Decided"
                           :decision-requested "Decision requested"
                           :draft-saved "Saved"
-                          :licenses-added "Added terms of use"
                           :licenses-accepted "Accepted terms of use"
+                          :licenses-added "Added terms of use"
                           :member-added "Member added"
                           :member-invited "Member invited"
                           :member-joined "Member joined"
                           :member-removed "Member removed"
                           :member-uninvited "Member uninvited"
                           :rejected "Rejected"
+                          :remarked "Remarked"
                           :resources-changed "Resources changed"
                           :returned "Changes requested"
                           :submitted "Applied"
@@ -243,6 +245,7 @@
          :checkbox-checked "Yes"
          :checkbox-unchecked "No"
          :comment "Comment"
+         :copy-as-new "Copy as new application"
          :current-value "Current"
          :date "Time"
          :diff-hide "Hide changes"
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index 4639df08f..1dc61146b 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -101,6 +101,7 @@
                  :applicant "Hakija"
                  :application "Hakemus"
                  :applications "Hakemukset"
+                 :copied-from "kopioitu hakemuksesta"
                  :created "Luotu"
                  :description "Otsikko"
                  :empty "Ei hakemuksia."
@@ -108,19 +109,20 @@
                           :closed "Suljettu"
                           :comment-requested "Katselmointia pyydetty"
                           :commented "Katselmoitu"
-                          :remarked "Huomio kirjattu"
+                          :copied-from "Kopioitu vanhasta hakemuksesta"
                           :created "Luotu"
                           :decided "Päätetty"
                           :decision-requested "Päätöstä pyydetty"
                           :draft-saved "Tallennettu"
-                          :licenses-added "Käyttöehtoja lisätty"
                           :licenses-accepted "Käyttöehdot hyväksytty"
+                          :licenses-added "Käyttöehtoja lisätty"
                           :member-added "Jäsen lisätty"
                           :member-invited "Jäsen kutsuttu"
                           :member-joined "Jäsen liittyi"
                           :member-removed "Jäsen poistettu"
                           :member-uninvited "Jäsen poistettu"
                           :rejected "Hylätty"
+                          :remarked "Huomio kirjattu"
                           :resources-changed "Resursseja muutettu"
                           :returned "Palautettu muokattavaksi"
                           :submitted "Haettu"
@@ -241,6 +243,7 @@
          :checkbox-checked "Kyllä"
          :checkbox-unchecked "Ei"
          :comment "Kommentti"
+         :copy-as-new "Kopioi uutena hakemuksena"
          :current-value "Nykyinen"
          :date "Aika"
          :diff-hide "Piilota muutokset"
diff --git a/src/clj/rems/api/applications.clj b/src/clj/rems/api/applications.clj
index f7091e50f..c0d3115a6 100644
--- a/src/clj/rems/api/applications.clj
+++ b/src/clj/rems/api/applications.clj
@@ -27,8 +27,8 @@
   {:catalogue-item-ids [s/Int]})
 
 (s/defschema CreateApplicationResponse
-  {:success s/Bool
-   (s/optional-key :application-id) s/Int})
+  (assoc SuccessResponse
+         (s/optional-key :application-id) s/Int))
 
 (s/defschema User
   {:userid s/Str
@@ -57,13 +57,17 @@
   [Decider])
 
 (s/defschema AcceptInvitationResult
-  {:success s/Bool
-   (s/optional-key :application-id) s/Int
-   (s/optional-key :errors) [s/Any]})
+  (assoc SuccessResponse
+         (s/optional-key :application-id) s/Int
+         (s/optional-key :errors) [s/Any]))
 
 (s/defschema SaveAttachmentResponse
-  (merge SuccessResponse
-         {(s/optional-key :id) s/Int}))
+  (assoc SuccessResponse
+         (s/optional-key :id) s/Int))
+
+(s/defschema CopyAsNewResponse
+  (assoc SuccessResponse
+         (s/optional-key :application-id) s/Int))
 
 ;; Api implementation
 
@@ -105,17 +109,21 @@
   ;; TODO: schema could do these coercions for us
   (update-present cmd :decision keyword))
 
+(defn parse-command [request command-type]
+  (-> request
+      (coerce-command-from-api)
+      (assoc :type command-type
+             :actor (getx-user-id)
+             :time (time/now))))
+
 (defn api-command [command-type request]
-  (let [command (-> request
-                    (coerce-command-from-api)
-                    (assoc :type command-type
-                           :actor (getx-user-id)
-                           :time (time/now)))
-        errors (applications/command! command)]
-    (if errors
-      (ok {:success false
-           :errors (:errors errors)})
-      (ok {:success true}))))
+  (let [response (-> request
+                     (parse-command command-type)
+                     (applications/command!))]
+    (-> response
+        (assoc :success (not (:errors response)))
+        ;; hide possibly sensitive events, but allow other explicitly returned data
+        (dissoc :events))))
 
 (defmacro command-endpoint [command schema & [additional-doc]]
   (let [path (str "/" (name command))]
@@ -124,7 +132,15 @@
        :roles #{:logged-in}
        :body [request# ~schema]
        :return SuccessResponse
-       (api-command ~command request#))))
+       (ok (api-command ~command request#)))))
+
+(defn accept-invitation [invitation-token]
+  (if-let [application-id (applications/get-application-by-invitation-token invitation-token)]
+    (api-command :application.command/accept-invitation
+                 {:application-id application-id
+                  :token invitation-token})
+    {:success false
+     :errors [{:type :t.actions.errors/invalid-token :token invitation-token}]}))
 
 (def my-applications-api
   (context "/my-applications" []
@@ -173,6 +189,13 @@
       :return CreateApplicationResponse
       (ok (applications/create-application! (getx-user-id) (:catalogue-item-ids request))))
 
+    (POST "/copy-as-new" []
+      :summary "Create a new application as a copy of an existing application."
+      :roles #{:logged-in}
+      :body [request commands/CopyAsNewCommand]
+      :return CopyAsNewResponse
+      (ok (api-command :application.command/copy-as-new request)))
+
     (GET "/commenters" []
       :summary "Available third party commenters"
       :roles #{:handler}
@@ -222,7 +245,7 @@
       :roles #{:logged-in}
       :query-params [invitation-token :- (describe s/Str "invitation token")]
       :return AcceptInvitationResult
-      (ok (applications/accept-invitation (getx-user-id) invitation-token)))
+      (ok (accept-invitation invitation-token)))
 
     (command-endpoint :application.command/accept-invitation commands/AcceptInvitationCommand)
     (command-endpoint :application.command/accept-licenses commands/AcceptLicensesCommand)
diff --git a/src/clj/rems/api/schema.clj b/src/clj/rems/api/schema.clj
index 1f2968adf..5ee964b59 100644
--- a/src/clj/rems/api/schema.clj
+++ b/src/clj/rems/api/schema.clj
@@ -95,6 +95,13 @@
 
 (def UserId s/Str)
 
+(s/defschema WorkflowLicense
+  {:type s/Str
+   :start DateTime
+   :textcontent s/Str
+   :localizations [s/Any]
+   :end (s/maybe DateTime)})
+
 (s/defschema Workflow
   {:id s/Int
    :organization s/Str
@@ -102,7 +109,7 @@
    :modifieruserid UserId
    :title s/Str
    :workflow s/Any
-   :licenses [License]
+   :licenses [WorkflowLicense]
    :start DateTime
    :end (s/maybe DateTime)
    :expired s/Bool
@@ -161,6 +168,8 @@
    :application/created DateTime
    :application/modified DateTime
    (s/optional-key :application/first-submitted) DateTime
+   (s/optional-key :application/copied-from) {:application/id s/Int
+                                              :application/external-id (s/maybe s/Str)}
    :application/last-activity DateTime
    :application/applicant s/Str
    :application/applicant-attributes {s/Keyword s/Str}
diff --git a/src/clj/rems/api/services/workflow.clj b/src/clj/rems/api/services/workflow.clj
index cf00f2c8d..dd5b1be66 100644
--- a/src/clj/rems/api/services/workflow.clj
+++ b/src/clj/rems/api/services/workflow.clj
@@ -32,31 +32,18 @@
      result
      {:success (not (nil? (:id result)))})))
 
-(defn update-workflow! [{:keys [id] :as command}]
-  (let [workflow (workflow/get-workflow id)
-        archived-licenses (filter :archived (:licenses workflow))
-        catalogue-items
-        (->> (catalogue/get-localized-catalogue-items {:workflow id
-                                                       :archived false})
+(defn update-workflow! [command]
+  (let [catalogue-items
+        (->> (catalogue/get-localized-catalogue-items {:workflow (:id command) :archived false})
              (map #(select-keys % [:id :title :localizations])))]
-    (cond
-      (and (:archived command) (seq catalogue-items))
+    (if (and (:archived command) (seq catalogue-items))
       {:success false
-       :errors [{:type :t.administration.errors/workflow-in-use
-                 :catalogue-items catalogue-items}]}
-
-      (and (not (:archived command)) (seq archived-licenses))
-      {:success false
-       :errors [{:type :t.administration.errors/license-archived
-                 :licenses archived-licenses}]}
-
-      :else
+       :errors [{:type :t.administration.errors/workflow-in-use :catalogue-items catalogue-items}]}
       (do
-        (db/update-workflow!
-         (merge (select-keys command [:id :enabled :archived :title])
-                (when-let [handlers (:handlers command)]
-                  {:workflow (json/generate-string {:type :workflow/dynamic
-                                                    :handlers handlers})})))
+        (db/update-workflow! (merge (select-keys command [:id :enabled :archived :title])
+                                    (when-let [handlers (:handlers command)]
+                                      {:workflow (json/generate-string {:type :workflow/dynamic
+                                                                        :handlers handlers})})))
         {:success true}))))
 
 (defn get-workflow [id] (workflow/get-workflow id))
diff --git a/src/clj/rems/application/commands.clj b/src/clj/rems/application/commands.clj
index f87c3f936..df907a6d6 100644
--- a/src/clj/rems/application/commands.clj
+++ b/src/clj/rems/application/commands.clj
@@ -111,6 +111,9 @@
                   :email s/Str}
          :comment s/Str))
 
+(s/defschema CopyAsNewCommand
+  CommandBase)
+
 (def command-schemas
   {#_:application.command/require-license
    :application.command/accept-invitation AcceptInvitationCommand
@@ -132,6 +135,7 @@
    :application.command/save-draft SaveDraftCommand
    :application.command/submit SubmitCommand
    :application.command/uninvite-member UninviteMemberCommand
+   :application.command/copy-as-new CopyAsNewCommand
    #_:application.command/withdraw})
 
 (s/defschema Command
@@ -246,9 +250,11 @@
   (when (contains? (all-members application) userid)
     {:errors [{:type :already-member :application-id (:id application)}]}))
 
+(defn- ok-with-data [data events]
+  (assoc data :events events))
+
 (defn- ok [& events]
-  {:success true
-   :events events})
+  (ok-with-data nil events))
 
 (defmethod command-handler :application.command/save-draft
   [cmd _application _injections]
@@ -392,9 +398,10 @@
   [cmd application injections]
   (or (already-member-error application (:actor cmd))
       (invitation-token-error application (:token cmd))
-      (ok {:event/type :application.event/member-joined
-           :application/id (:application-id cmd)
-           :invitation/token (:token cmd)})))
+      (ok-with-data {:application-id (:application-id cmd)}
+                    [{:event/type :application.event/member-joined
+                      :application/id (:application-id cmd)
+                      :invitation/token (:token cmd)}])))
 
 (defmethod command-handler :application.command/remove-member
   [cmd application _injections]
@@ -417,14 +424,34 @@
            :application/member (:member cmd)
            :application/comment (:comment cmd)})))
 
+(defmethod command-handler :application.command/copy-as-new
+  [cmd application {:keys [create-application!]}]
+  (let [result (create-application! (:actor cmd) (map :catalogue-item/id (:application/resources application)))
+        _ (assert (:success result) {:result result})
+        new-app-id (:application-id result)]
+    (ok-with-data
+     {:application-id new-app-id}
+     ;; TODO: it would be better to refactor create-application! so that it won't persist the created event, but it'll be returned here explicitly
+     ;; TODO: add copied-to event to the original application
+     [{:event/type :application.event/draft-saved
+       :application/id new-app-id
+       :application/field-values (->> (:form/fields (:application/form application))
+                                      (map (fn [field]
+                                             [(:field/id field) (:field/value field)]))
+                                      (into {}))}
+      {:event/type :application.event/copied-from
+       :application/id new-app-id
+       :application/copied-from (select-keys application [:application/id :application/external-id])}])))
+
 (defn- add-common-event-fields-from-command [event cmd]
-  (assoc event
-         :event/time (:time cmd)
-         :event/actor (:actor cmd)
-         :application/id (:application-id cmd)))
+  (-> event
+      (update :application/id (fn [app-id]
+                                (or app-id (:application-id cmd))))
+      (assoc :event/time (:time cmd)
+             :event/actor (:actor cmd))))
 
 (defn- enrich-result [result cmd]
-  (if (:success result)
+  (if (:events result)
     (update result :events (fn [events]
                              (mapv #(add-common-event-fields-from-command % cmd) events)))
     result))
@@ -451,7 +478,7 @@
                  :field-values []
                  :actor "applicant"}]
     (testing "executes command when user is authorized"
-      (is (:success (handle-command command application {}))))
+      (is (not (:errors (handle-command command application {})))))
     (testing "fails when command fails validation"
       (is (thrown-with-msg? ExceptionInfo #"Value does not match schema"
                             (handle-command (assoc command :time 3) application {}))))
@@ -460,5 +487,4 @@
       ;; and only depend on the roles and permissions
       (let [application (permissions/remove-role-from-user application :applicant "applicant")
             result (handle-command command application {})]
-        (is (not (:success result)))
-        (is (= [{:type :forbidden}] (:errors result)))))))
+        (is (= {:errors [{:type :forbidden}]} result))))))
diff --git a/src/clj/rems/application/events.clj b/src/clj/rems/application/events.clj
index 4e11f8a61..61928be66 100644
--- a/src/clj/rems/application/events.clj
+++ b/src/clj/rems/application/events.clj
@@ -121,6 +121,11 @@
 (s/defschema SubmittedEvent
   (assoc EventBase
          :event/type (s/enum :application.event/submitted)))
+(s/defschema CopiedFromEvent
+  (assoc EventBase
+         :event/type (s/enum :application.event/copied-from)
+         :application/copied-from {:application/id s/Int
+                                   :application/external-id (s/maybe s/Str)}))
 
 (def event-schemas
   {:application.event/approved ApprovedEvent
@@ -142,7 +147,8 @@
    :application.event/remarked RemarkedEvent
    :application.event/resources-changed ResourcesChangedEvent
    :application.event/returned ReturnedEvent
-   :application.event/submitted SubmittedEvent})
+   :application.event/submitted SubmittedEvent
+   :application.event/copied-from CopiedFromEvent})
 
 (s/defschema Event
   (apply r/dispatch-on :event/type (flatten (seq event-schemas))))
diff --git a/src/clj/rems/application/model.clj b/src/clj/rems/application/model.clj
index ae914d5a6..69c5f1249 100644
--- a/src/clj/rems/application/model.clj
+++ b/src/clj/rems/application/model.clj
@@ -25,12 +25,14 @@
     :application.command/invite-member
     :application.command/uninvite-member
     :application.command/accept-licenses
-    :application.command/change-resources})
+    :application.command/change-resources
+    :application.command/copy-as-new})
 
 (def ^:private non-submittable-application-commands
   #{:application.command/remove-member
     :application.command/uninvite-member
-    :application.command/accept-licenses})
+    :application.command/accept-licenses
+    :application.command/copy-as-new})
 
 (def ^:private handler-all-commands
   #{:application.command/remark
@@ -51,7 +53,8 @@
 
 (def ^:private created-permissions
   {:applicant submittable-application-commands
-   :member #{:application.command/accept-licenses}
+   :member #{:application.command/accept-licenses
+             :application.command/copy-as-new}
    :reporter #{:see-everything
                :application.command/remark}
    ;; member before accepting an invitation
@@ -87,8 +90,8 @@
               :application.command/close}})
 
 (def ^:private closed-permissions
-  {:applicant #{}
-   :member #{}
+  {:applicant #{:application.command/copy-as-new}
+   :member #{:application.command/copy-as-new}
    :handler #{:see-everything}
    :commenter #{:see-everything}
    :decider #{:see-everything}
@@ -319,6 +322,12 @@
   (-> application
       (assoc :application/state :application.state/closed)))
 
+(defmethod event-type-specific-application-view :application.event/copied-from
+  [application event]
+  (-> application
+      (assoc :application/copied-from (:application/copied-from event))
+      (assoc ::submitted-answers (::draft-answers application))))
+
 (deftest test-event-type-specific-application-view
   (testing "supports all event types"
     (is (= (set (keys events/event-schemas))
@@ -432,20 +441,19 @@
 
 (defn- enrich-resources [app-resources get-catalogue-item]
   (->> app-resources
-       (map :catalogue-item/id)
-       (map get-catalogue-item)
-       (map (fn [item]
-              {:catalogue-item/id (:id item)
-               :resource/id (:resource-id item)
-               :resource/ext-id (:resid item)
-               :catalogue-item/title (assoc (localization-for :title item)
-                                            :default (:title item))
-               ;; TODO: remove unused keys
-               :catalogue-item/start (:start item)
-               :catalogue-item/end (:end item)
-               :catalogue-item/enabled (:enabled item)
-               :catalogue-item/expired (:expired item)
-               :catalogue-item/archived (:archived item)}))
+       (map (fn [resource]
+              (let [item (get-catalogue-item (:catalogue-item/id resource))]
+                {:catalogue-item/id (:catalogue-item/id resource)
+                 :resource/ext-id (:resource/ext-id resource)
+                 :resource/id (:resource-id item)
+                 :catalogue-item/title (assoc (localization-for :title item)
+                                              :default (:title item))
+                 ;; TODO: remove unused keys
+                 :catalogue-item/start (:start item)
+                 :catalogue-item/end (:end item)
+                 :catalogue-item/enabled (:enabled item)
+                 :catalogue-item/expired (:expired item)
+                 :catalogue-item/archived (:archived item)})))
        (sort-by :catalogue-item/id)
        vec))
 
diff --git a/src/clj/rems/db/applications.clj b/src/clj/rems/db/applications.clj
index cdf3ba717..8939758f6 100644
--- a/src/clj/rems/db/applications.clj
+++ b/src/clj/rems/db/applications.clj
@@ -111,7 +111,8 @@
    :validate-fields form-validation/validate-fields
    :secure-token secure-token
    :get-catalogue-item catalogue/get-localized-catalogue-item
-   :get-catalogue-item-licenses get-catalogue-item-licenses})
+   :get-catalogue-item-licenses get-catalogue-item-licenses
+   :create-application! create-application!})
 
 (declare get-unrestricted-application)
 
@@ -124,25 +125,13 @@
   (jdbc/execute! db/*db* ["LOCK TABLE application_event IN SHARE ROW EXCLUSIVE MODE"])
   (let [app (get-unrestricted-application (:application-id cmd))
         result (commands/handle-command cmd app db-injections)]
-    (if (:success result)
+    (if (not (:errors result))
       (doseq [event (:events result)]
-        (events/add-event! event))
-      result)))
-
-(defn accept-invitation [user-id invitation-token]
-  (or (when-let [application-id (:id (db/get-application-by-invitation-token {:token invitation-token}))]
-        (let [response (command! {:type :application.command/accept-invitation
-                                  :actor user-id
-                                  :application-id application-id
-                                  :token invitation-token
-                                  :time (time/now)})]
-          (if-not response
-            {:success true
-             :application-id application-id}
-            {:success false
-             :errors (:errors response)})))
-      {:success false
-       :errors [{:type :t.actions.errors/invalid-token :token invitation-token}]}))
+        (events/add-event! event)))
+    result))
+
+(defn get-application-by-invitation-token [invitation-token]
+  (:id (db/get-application-by-invitation-token {:token invitation-token})))
 
 ;;; Fetching applications (for API) (incl. caching)
 
diff --git a/src/clj/rems/db/workflow.clj b/src/clj/rems/db/workflow.clj
index 60f262b99..58e42e161 100644
--- a/src/clj/rems/db/workflow.clj
+++ b/src/clj/rems/db/workflow.clj
@@ -1,39 +1,66 @@
 (ns rems.db.workflow
-  (:require [rems.db.core :as db]
-            [rems.db.licenses :as licenses]
+  (:require [clj-time.core :as time-core]
+            [clojure.test :refer [deftest is]]
+            [rems.db.core :as db]
             [rems.db.users :as users]
-            [rems.json :as json]))
-
-;; XXX: Overwriting :start and :end from license table with :start and :end
-;;      from workflow_license table seems error-prone - they could at least
-;;      be named differently to avoid confusion.
-;;
-;;      See a related comment in rems.db.licenses regarding the use of
-;;      various start and end times.
-(defn- join-workflow-license-with-license [workflow-license]
-  (-> (:licid workflow-license)
-      licenses/get-license
-      (assoc :start (:start workflow-license)
-             :end (:end workflow-license))))
-
-(defn- get-workflow-licenses [id]
-  (->> {:wfid id}
-       db/get-workflow-licenses
-       (mapv join-workflow-license-with-license)))
+            [rems.json :as json]
+            [rems.util :refer [update-present]])
+  (:import [org.joda.time DateTime DateTimeZone]))
+
+(defn- parse-db-time [s]
+  (when s
+    (-> (DateTime/parse s)
+        (.withZone DateTimeZone/UTC))))
+
+(deftest test-parse-db-time
+  (is (= nil (parse-db-time nil)))
+  (is (= (time-core/date-time 2019 1 30 7 56 38 627) (parse-db-time "2019-01-30T09:56:38.627616+02:00")))
+  (is (= (time-core/date-time 2015 2 13 12 47 26) (parse-db-time "2015-02-13T14:47:26+02:00"))))
+
+;; TODO: This should probably be in rems.db.licenses.
+(defn- format-license [license]
+  (-> license
+      (select-keys [:type :textcontent :localizations])
+      (assoc :start (parse-db-time (:start license)))
+      (assoc :end (parse-db-time (:end license)))))
+
+(defn- format-workflow
+  [{:keys [id organization owneruserid modifieruserid title workflow start end expired enabled archived licenses]}]
+  {:id id
+   :organization organization
+   :owneruserid owneruserid
+   :modifieruserid modifieruserid
+   :title title
+   :workflow workflow
+   :start start
+   :end end
+   :expired expired
+   :enabled enabled
+   :archived archived
+   :licenses (mapv format-license licenses)})
 
 (defn- enrich-and-format-workflow [wf]
   (-> wf
-      (update :workflow json/parse-string)
-      (assoc :licenses (get-workflow-licenses (:id wf)))
-      db/assoc-expired
-      (update-in [:workflow :handlers] #(mapv users/get-user %))))
+      (update-present :workflow update :handlers #(mapv users/get-user %))
+      format-workflow))
+
+(defn- parse-workflow-body [json]
+  (json/parse-string json))
+
+(defn- parse-licenses [json]
+  (json/parse-string json))
 
 (defn get-workflow [id]
   (-> {:wfid id}
       db/get-workflow
+      (update :workflow parse-workflow-body)
+      (update :licenses parse-licenses)
+      db/assoc-expired
       enrich-and-format-workflow))
 
 (defn get-workflows [filters]
   (->> (db/get-workflows)
-       (map enrich-and-format-workflow)
-       (db/apply-filters filters)))
+       (map #(update % :workflow parse-workflow-body))
+       (map db/assoc-expired)
+       (db/apply-filters filters)
+       (mapv enrich-and-format-workflow)))
diff --git a/src/cljc/rems/text.cljc b/src/cljc/rems/text.cljc
index e832f68b9..ca9f40c8d 100644
--- a/src/cljc/rems/text.cljc
+++ b/src/cljc/rems/text.cljc
@@ -69,19 +69,20 @@
    :application.event/closed :t.applications.events/closed
    :application.event/comment-requested :t.applications.events/comment-requested
    :application.event/commented :t.applications.events/commented
-   :application.event/remarked :t.applications.events/remarked
+   :application.event/copied-from :t.applications.events/copied-from
    :application.event/created :t.applications.events/created
    :application.event/decided :t.applications.events/decided
    :application.event/decision-requested :t.applications.events/decision-requested
    :application.event/draft-saved :t.applications.events/draft-saved
-   :application.event/licenses-added :t.applications.events/licenses-added
    :application.event/licenses-accepted :t.applications.events/licenses-accepted
+   :application.event/licenses-added :t.applications.events/licenses-added
    :application.event/member-added :t.applications.events/member-added
    :application.event/member-invited :t.applications.events/member-invited
    :application.event/member-joined :t.applications.events/member-joined
    :application.event/member-removed :t.applications.events/member-removed
    :application.event/member-uninvited :t.applications.events/member-uninvited
    :application.event/rejected :t.applications.events/rejected
+   :application.event/remarked :t.applications.events/remarked
    :application.event/resources-changed :t.applications.events/resources-changed
    :application.event/returned :t.applications.events/returned
    :application.event/submitted :t.applications.events/submitted})
diff --git a/src/cljs/rems/actions/accept_licenses.cljs b/src/cljs/rems/actions/accept_licenses.cljs
index 4acb93d67..92db4c84b 100644
--- a/src/cljs/rems/actions/accept_licenses.cljs
+++ b/src/cljs/rems/actions/accept_licenses.cljs
@@ -8,10 +8,11 @@
 (rf/reg-event-fx
  ::send-accept-licenses
  (fn [_ [_ {:keys [application-id licenses on-finished]}]]
+   (status-modal/common-pending-handler! (text :t.actions/accept-licenses))
    (post! "/api/applications/accept-licenses"
           {:params {:application-id application-id
                     :accepted-licenses licenses}
-           :handler (fn [_] (on-finished))
+           :handler (partial status-modal/common-success-handler! on-finished)
            :error-handler status-modal/common-error-handler!})
    {}))
 
diff --git a/src/cljs/rems/actions/change_resources.cljs b/src/cljs/rems/actions/change_resources.cljs
index 9abaf8c70..621d19536 100644
--- a/src/cljs/rems/actions/change_resources.cljs
+++ b/src/cljs/rems/actions/change_resources.cljs
@@ -38,14 +38,15 @@
 (rf/reg-event-fx
  ::send-change-resources
  (fn [_ [_ {:keys [application-id resources comment on-finished]}]]
+   (status-modal/common-pending-handler! (text :t.actions/change-resources))
    (post! "/api/applications/change-resources"
           {:params (merge {:application-id application-id
                            :catalogue-item-ids (vec resources)}
                           (when comment
                             {:comment comment}))
-           :handler (fn [_]
-                      (collapse-action-form action-form-id)
-                      (on-finished))
+           :handler (partial status-modal/common-success-handler! (fn [_]
+                                                                    (collapse-action-form action-form-id)
+                                                                    (on-finished)))
            :error-handler status-modal/common-error-handler!})
    {}))
 
diff --git a/src/cljs/rems/actions/invite_member.cljs b/src/cljs/rems/actions/invite_member.cljs
index 3bb662d81..eb8fa93a5 100644
--- a/src/cljs/rems/actions/invite_member.cljs
+++ b/src/cljs/rems/actions/invite_member.cljs
@@ -28,14 +28,15 @@
 (rf/reg-event-fx
  ::send-invite-member
  (fn [_ [_ {:keys [member application-id on-finished]}]]
+   (status-modal/common-pending-handler! (text :t.actions/invite-member))
    (if-let [errors (validate-member member)]
      (status-modal/set-error! {:result {:errors errors}})
      (post! "/api/applications/invite-member"
             {:params {:application-id application-id
                       :member member}
-             :handler (fn [_]
-                        (collapse-action-form action-form-id)
-                        (on-finished))
+             :handler (partial status-modal/common-success-handler! (fn [_]
+                                                                      (collapse-action-form action-form-id)
+                                                                      (on-finished)))
              :error-handler status-modal/common-error-handler!}))
    {}))
 
diff --git a/src/cljs/rems/application.cljs b/src/cljs/rems/application.cljs
index c5f77ba58..99e56d5c1 100644
--- a/src/cljs/rems/application.cljs
+++ b/src/cljs/rems/application.cljs
@@ -18,7 +18,7 @@
             [rems.actions.return-action :refer [return-action-button return-form]]
             [rems.actions.review :refer [review-action-button review-form]]
             [rems.application-util :refer [accepted-licenses? form-fields-editable? get-member-name]]
-            [rems.atoms :refer [external-link file-download flash-message info-field readonly-checkbox textarea document-title success-symbol]]
+            [rems.atoms :refer [external-link file-download flash-message info-field readonly-checkbox textarea document-title]]
             [rems.collapsible :as collapsible]
             [rems.common-util :refer [index-by]]
             [rems.fields :as fields]
@@ -187,6 +187,19 @@
                           (get-in (:identity db) [:user :eppn])))
    {:db (assoc-in db [::edit-application :validation-errors] nil)}))
 
+(rf/reg-event-fx
+ ::copy-as-new-application
+ (fn [{:keys [db]} _]
+   (let [application-id (get-in db [::application :application/id])]
+     (status-modal/common-pending-handler! (text :t.form/copy-as-new))
+     (post! "/api/applications/copy-as-new"
+            {:params {:application-id application-id}
+             :handler (partial status-modal/common-success-handler!
+                               (fn [response]
+                                 (dispatch! (str "/#/application/" (:application-id response)))))
+             :error-handler status-modal/common-error-handler!}))
+   {}))
+
 (defn- save-attachment [{:keys [db]} [_ field-id file description]]
   (let [application-id (get-in db [::application :application/id])]
     (status-modal/common-pending-handler! description)
@@ -282,6 +295,11 @@
                    :class :btn-primary
                    :on-click #(rf/dispatch [::submit-application (text :t.form/submit)])}])
 
+(defn- copy-as-new-button []
+  [button-wrapper {:id "copy-as-new"
+                   :text (text :t.form/copy-as-new)
+                   :on-click #(rf/dispatch [::copy-as-new-application])}])
+
 (defn- application-fields [application edit-application]
   (let [field-values (:field-values edit-application)
         show-diff (:show-diff edit-application)
@@ -331,18 +349,36 @@
                                        :accepted (contains? accepted-licenses (:license/id license))
                                        :readonly readonly?)]))
          (if (accepted-licenses? application userid)
-           [:div#has-accepted-licenses.alert.alert-success (success-symbol) (text :t.form/has-accepted-licenses)]
+           (text :t.form/has-accepted-licenses)
            (when (contains? permissions :application.command/accept-licenses)
              [:div.commands
               ;; TODO consider saving the form first so that no data is lost for the applicant
               [accept-licenses-action-button application-id (mapv :license/id licenses) #(reload! application-id)]]))]}])))
 
+(defn- format-application-id [config application]
+  (let [id-column (get config :application-id-column :id)]
+    (case id-column
+      :external-id (:application/external-id application)
+      :id (:application/id application)
+      (:application/id application))))
+
+(defn- application-link [application prefix]
+  (let [config @(rf/subscribe [:rems.config/config])]
+    [:a {:href (str "/#/application/" (:application/id application))}
+     (when prefix
+       (str prefix " "))
+     (format-application-id config application)]))
 
 (defn- format-event [event]
   {:userid (:event/actor event)
    :event (localize-event (:event/type event))
-   :comment (if (= :application.event/decided (:event/type event))
+   :comment (case (:event/type event)
+              :application.event/decided
               (str (localize-decision (:application/decision event)) ": " (:application/comment event))
+
+              :application.event/copied-from
+              [application-link (:application/copied-from event) (text :t.applications/application)]
+
               (:application/comment event))
    :request-id (:application/request-id event)
    :commenters (:application/commenters event)
@@ -395,13 +431,6 @@
          {:phase :approve :text :t.phases/approve}
          {:phase :result :text :t.phases/approved}]))
 
-(defn- application-id-value [config application]
-  (let [id-column (get config :application-id-column :id)]
-    (case id-column
-      :external-id (:application/external-id application)
-      :id (:application/id application)
-      (:application/id application))))
-
 (defn- application-state [application config]
   (let [state (:application/state application)
         last-activity (:application/last-activity application)
@@ -421,7 +450,11 @@
                       (phases (get-application-phases state))]
                      [info-field
                       (text :t.applications/application)
-                      [:span#application-id (application-id-value config application)]
+                      [:<>
+                       [:span#application-id (format-application-id config application)]
+                       (when-let [original-app (:application/copied-from application)]
+                         [:<>
+                          " (" (text :t.applications/copied-from) " " [application-link original-app nil] ")"])]
                       {:inline? true}]
                      [info-field
                       (text :t.applications/description)
@@ -542,7 +575,8 @@
                               :application.command/add-licenses [add-licenses-action-button]
                               :application.command/approve [approve-reject-action-button]
                               :application.command/reject [approve-reject-action-button]
-                              :application.command/close [close-action-button]]]
+                              :application.command/close [close-action-button]
+                              :application.command/copy-as-new [copy-as-new-button]]]
     (distinct (for [[command action] (partition 2 commands-and-actions)
                     :when (contains? (:application/permissions application) command)]
                 action))))
@@ -602,7 +636,7 @@
                               :contents [format-validation-errors application errors]}])])]
     [:div
      [:div {:class "float-right"} [pdf-button (:application/id application)]]
-     [document-title (str (text :t.applications/application) " " (application-id-value config application))]
+     [document-title (str (text :t.applications/application) " " (format-application-id config application))]
      (text :t.applications/intro)
      (into [:div] messages)
      [application-state application config]
diff --git a/src/cljs/rems/atoms.cljs b/src/cljs/rems/atoms.cljs
index 453dc98a9..44841543b 100644
--- a/src/cljs/rems/atoms.cljs
+++ b/src/cljs/rems/atoms.cljs
@@ -36,11 +36,6 @@
 (defn close-symbol []
   [:i.fa {:class "fa-times"}])
 
-(defn success-symbol []
-  [:span.fa-stack {:aria-label (text :t.form/success)}
-   [:i {:class "fas fa-circle fa-stack-1x icon-stack-background"}]
-   [:i {:class "fas fa-check-circle fa-stack-1x text-success"}]])
-
 (defn textarea [attrs]
   [autosize/textarea (merge {:min-rows 5}
                             (update attrs :class #(str/trim (str "form-control " %))))])
diff --git a/src/cljs/rems/status_modal.cljs b/src/cljs/rems/status_modal.cljs
index 93ee8b1c6..3bf63dff8 100644
--- a/src/cljs/rems/status_modal.cljs
+++ b/src/cljs/rems/status_modal.cljs
@@ -6,7 +6,6 @@
   Use the functions `set-pending!`, `set-success!` and `set-error!` to control its state.
   See `rems.status-modal/status-modal` for values to use in the calls."
   (:require [re-frame.core :as rf]
-            [rems.atoms :as atoms]
             [rems.common-util :refer [deep-merge]]
             [rems.guide-functions]
             [rems.modal :as modal]
@@ -29,7 +28,9 @@
 (defn- status-icon [success? error-content]
   (cond
     (and (not success?) (not error-content)) [spinner/small]
-    success? (atoms/success-symbol)
+    success? [:span.fa-stack {:aria-label (text :t.form/success)}
+              [:i {:class "fas fa-circle fa-stack-1x icon-stack-background"}]
+              [:i {:class "fas fa-check-circle fa-stack-1x text-success"}]]
     error-content [:span.fa-stack {:aria-label (text :t.form/failed)}
                    [:i {:class "fas fa-circle fa-stack-1x icon-stack-background"}]
                    [:i {:class "fas fa-times-circle fa-stack-1x text-danger"}]]))


Test patch:
diff --git a/src/clj/rems/db/test_data.clj b/src/clj/rems/db/test_data.clj
index 5ee02844f..7f54006b9 100644
--- a/src/clj/rems/db/test_data.clj
+++ b/src/clj/rems/db/test_data.clj
@@ -62,7 +62,8 @@
   (let [command (merge {:time (time/now)}
                        command)
         result (applications/command! command)]
-    (assert (nil? result) {:command command :result result})))
+    (assert (not (:errors result))
+            {:command command :result result})))
 
 (defn- transpose-localizations [m]
   (->> m
diff --git a/test/clj/rems/api/test_applications.clj b/test/clj/rems/api/test_applications.clj
index 184ca3735..7f0a9f0a5 100644
--- a/test/clj/rems/api/test_applications.clj
+++ b/test/clj/rems/api/test_applications.clj
@@ -184,7 +184,8 @@
                (map :event/type (get application :application/events))))
         (is (= #{"application.command/remove-member"
                  "application.command/uninvite-member"
-                 "application.command/accept-licenses"}
+                 "application.command/accept-licenses"
+                 "application.command/copy-as-new"}
                (set (get application :application/permissions))))))
 
     (testing "getting dynamic application as handler"
@@ -372,7 +373,17 @@
                     "application.event/resources-changed"
                     "application.event/remarked"
                     "application.event/approved"]
-                   applicant-event-types))))))))
+                   applicant-event-types)))))
+
+      (testing "copy as new"
+        (let [result (send-command user-id {:type :application.command/copy-as-new
+                                            :application-id application-id})]
+          (is (:success result)
+              {:result result})
+          (is (:application-id result)
+              {:result result})
+          (is (not= application-id (:application-id result))
+              "should create a new application"))))))
 
 (deftest test-application-create
   (let [api-key "42"
diff --git a/test/clj/rems/api/test_workflows.clj b/test/clj/rems/api/test_workflows.clj
index dd5072e37..0b33b81cb 100644
--- a/test/clj/rems/api/test_workflows.clj
+++ b/test/clj/rems/api/test_workflows.clj
@@ -1,10 +1,8 @@
 (ns ^:integration rems.api.test-workflows
   (:require [clojure.test :refer :all]
-            [rems.api.services.licenses :as licenses]
             [rems.api.services.workflow :as workflow]
             [rems.api.testing :refer :all]
             [rems.common-util :refer [index-by]]
-            [rems.db.core :as db]
             [rems.db.test-data :as test-data]
             [rems.db.testing :refer [sync-with-database-time]]
             [rems.handler :refer [handler]]
@@ -84,13 +82,6 @@
         wfid (test-data/create-dynamic-workflow! {:organization "abc"
                                                   :title "dynamic workflow"
                                                   :handlers ["bob" "carl"]})
-        lic-id (test-data/create-license! {})
-        _ (db/create-workflow-license! {:wfid wfid :licid lic-id})
-
-        archive-license! #(licenses/update-license! {:id lic-id
-                                                     :enabled true
-                                                     :archived %})
-
         ;; this is a subset of what we expect to get from the api
         expected {:id wfid
                   :organization "abc"
@@ -109,48 +100,42 @@
                   (select-keys
                    (first (filter #(= wfid (:id %)) wfs))
                    (keys expected))))
-        update! #(-> (request :put "/api/workflows/update")
-                     (json-body (merge {:id wfid} %))
-                     (authenticate api-key user-id)
-                     handler
-                     read-ok-body)]
+        update #(-> (request :put "/api/workflows/update")
+                    (json-body (merge {:id wfid} %))
+                    (authenticate api-key user-id)
+                    handler
+                    read-ok-body)]
     (sync-with-database-time)
     (testing "before changes"
       (is (= expected (fetch))))
     (testing "disable and archive"
-      (is (:success (update! {:enabled false :archived true})))
+      (is (:success (update {:enabled false :archived true})))
       (is (= (assoc expected
                     :enabled false
                     :archived true)
              (fetch))))
     (testing "re-enable"
-      (is (:success (update! {:enabled true})))
+      (is (:success (update {:enabled true})))
       (is (= (assoc expected
                     :archived true)
              (fetch))))
     (testing "unarchive"
-      (is (:success (update! {:archived false})))
+      (is (:success (update {:archived false})))
       (is (= expected
              (fetch))))
     (testing "change title"
-      (is (:success (update! {:title "x"})))
+      (is (:success (update {:title "x"})))
       (is (= (assoc expected
                     :title "x")
              (fetch))))
     (testing "change handlers"
-      (is (:success (update! {:handlers ["owner" "alice"]})))
+      (is (:success (update {:handlers ["owner" "alice"]})))
       (is (= (assoc expected
                     :title "x"
                     :workflow {:type "workflow/dynamic"
                                :handlers [{:email "owner@example.com" :name "Owner" :userid "owner"}
                                           {:email "alice@example.com" :name "Alice Applicant" :userid "alice"}]})
-             (fetch))))
-    (testing "cannot unarchive if license is archived"
-      (update! {:archived true})
-      (archive-license! true)
-      (is (not (:success (update! {:archived false}))))
-      (archive-license! false)
-      (is (:success (update! {:archived false}))))))
+             (fetch))))))
 
 (deftest workflows-api-filtering-test
   (let [enabled-wf (test-data/create-dynamic-workflow! {})
diff --git a/test/clj/rems/application/test_commands.clj b/test/clj/rems/application/test_commands.clj
index 2d620269e..b3ebbaa3d 100644
--- a/test/clj/rems/application/test_commands.clj
+++ b/test/clj/rems/application/test_commands.clj
@@ -36,7 +36,8 @@
    :get-license (constantly nil)
    :get-user (constantly nil)
    :get-users-with-role (constantly nil)
-   :get-attachments-for-application (constantly nil)})
+   :get-attachments-for-application (constantly nil)
+   :create-application! (constantly nil)})
 
 ;; could rework tests to use model/build-application-view instead of this
 (defn apply-events [application events]
@@ -50,7 +51,7 @@
   ([application cmd injections]
    (let [cmd (merge command-defaults cmd)
          result (commands/handle-command cmd application injections)]
-     (assert-ex (not (:success result)) {:cmd cmd :result result})
+     (assert-ex (:errors result) {:cmd cmd :result result})
      result)))
 
 (defn- ok-command
@@ -59,7 +60,7 @@
   ([application cmd injections]
    (let [cmd (merge command-defaults cmd)
          result (commands/handle-command cmd application injections)]
-     (assert-ex (:success result) {:cmd cmd :result result})
+     (assert-ex (not (:errors result)) {:cmd cmd :result result})
      (let [events (:events result)]
        (events/validate-events events)
        ;; most tests expect only one event, so this avoids having to wrap the expectation to a list
@@ -1140,3 +1141,48 @@
                     :application/comment "second remark"
                     :application/public false}
                    event-2))))))))
+
+(deftest test-copy-as-new
+  (let [old-app-id 100
+        new-app-id 200
+        injections {:create-application! (fn [user cat-items]
+                                           (is (= applicant-user-id user)
+                                               "applicant for new application")
+                                           (is (= [10 20] cat-items)
+                                               "catalogue items for new application")
+                                           {:success true
+                                            :application-id new-app-id})}
+        application (apply-events nil [{:event/type :application.event/created
+                                        :event/time test-time
+                                        :event/actor applicant-user-id
+                                        :application/id old-app-id
+                                        :application/external-id "2019/42"
+                                        :application/resources [{:catalogue-item/id 10
+                                                                 :resource/ext-id "urn:11"}
+                                                                {:catalogue-item/id 20
+                                                                 :resource/ext-id "urn:21"}]
+                                        :application/licenses []
+                                        :form/id 40
+                                        :workflow/id 50
+                                        :workflow/type :workflow/dynamic}
+                                       {:event/type :application.event/draft-saved
+                                        :event/time test-time
+                                        :event/actor applicant-user-id
+                                        :application/id old-app-id
+                                        :application/field-values {1 "foo" 2 "bar"}}])]
+    (testing "creates a new application with the same form answers"
+      (is (= [{:event/type :application.event/draft-saved
+               :event/time test-time
+               :event/actor applicant-user-id
+               :application/id new-app-id
+               :application/field-values {1 "foo" 2 "bar"}}
+              {:event/type :application.event/copied-from
+               :event/time test-time
+               :event/actor applicant-user-id
+               :application/id new-app-id
+               :application/copied-from {:application/id old-app-id
+                                         :application/external-id "2019/42"}}]
+             (ok-command application
+                         {:type :application.command/copy-as-new
+                          :actor applicant-user-id}
+                         injections))))))
diff --git a/test/clj/rems/application/test_model.clj b/test/clj/rems/application/test_model.clj
index 8716edb5c..ca14f45f3 100644
--- a/test/clj/rems/application/test_model.clj
+++ b/test/clj/rems/application/test_model.clj
@@ -355,6 +355,22 @@
                                                                                    {:field/value "bar"}]}})]
             (is (= expected-application (apply-events events)))
 
+            (testing "> copied from"
+              (let [events (conj events {:event/type :application.event/copied-from
+                                         :event/time (DateTime. 3000)
+                                         :event/actor "applicant"
+                                         :application/id 1
+                                         :application/copied-from {:application/id 42
+                                                                   :application/external-id "2019/42"}})
+                    expected-application (deep-merge expected-application
+                                                     {:application/last-activity (DateTime. 3000)
+                                                      :application/events events
+                                                      :application/copied-from {:application/id 42
+                                                                                :application/external-id "2019/42"}
+                                                      :application/form {:form/fields [{:field/previous-value "foo"}
+                                                                                       {:field/previous-value "bar"}]}})]
+                (is (= expected-application (apply-events events)))))
+
             (testing "> accepted licenses"
               (let [events (conj events {:event/type :application.event/licenses-accepted
                                          :event/time (DateTime. 2500)
@@ -900,16 +916,17 @@
   (testing "everyone can accept invitation"
     (let [created (reduce model/calculate-permissions nil [{:event/type :application.event/created
                                                             :event/actor "applicant"}])]
-      (is (= #{:application.command/accept-invitation}
-             (permissions/user-permissions created "joe")))))
+      (is (contains? (permissions/user-permissions created "joe")
+                     :application.command/accept-invitation))))
   (testing "nobody can accept invitation for closed application"
     (let [closed (reduce model/calculate-permissions nil [{:event/type :application.event/created
                                                            :event/actor "applicant"}
                                                           {:event/type :application.event/closed
                                                            :event/actor "applicant"}])]
-      (is (= #{}
-             (permissions/user-permissions closed "joe")
-             (permissions/user-permissions closed "applicant"))))))
+      (is (not (contains? (permissions/user-permissions closed "joe")
+                          :application.command/accept-invitation)))
+      (is (not (contains? (permissions/user-permissions closed "applicant")
+                          :application.command/accept-invitation))))))
 
 (deftest test-apply-user-permissions
   (let [application (-> (model/application-view nil {:event/type :application.event/created
diff --git a/test/clj/rems/db/test_transactions.clj b/test/clj/rems/db/test_transactions.clj
index 9b540e785..89f2f93b2 100644
--- a/test/clj/rems/db/test_transactions.clj
+++ b/test/clj/rems/db/test_transactions.clj
@@ -61,7 +61,7 @@
         ;; column to the events table with constraint `UNIQUE (appId, appVersion)`.
         observed-app-version-marker 999
         mark-observed-app-version (fn [result _cmd application]
-                                    (if (and (:success result)
+                                    (if (and (not (:errors result))
                                              (= :application.event/draft-saved (:event/type (first (:events result)))))
                                       (assoc-in result [:events 0 :application/field-values observed-app-version-marker]
                                                 (str (count (:application/events application))))
diff --git a/test/clj/rems/test_browser.clj b/test/clj/rems/test_browser.clj
index 3ab9b11fb..a69cc3f34 100644
--- a/test/clj/rems/test_browser.clj
+++ b/test/clj/rems/test_browser.clj
@@ -166,7 +166,8 @@
 (defn accept-licenses []
   (doto *driver*
     (scroll-and-click :accept-licenses-button)
-    (wait-visible :has-accepted-licenses)))
+    (wait-visible :status-success)
+    (scroll-and-click :modal-ok)))
 
 (defn send-application []
   (doto *driver*
diff --git a/test/clj/rems/test_locales.clj b/test/clj/rems/test_locales.clj
index e031e491d..e25c05098 100644
--- a/test/clj/rems/test_locales.clj
+++ b/test/clj/rems/test_locales.clj
@@ -10,9 +10,11 @@
             [taoensso.tempura.impl :refer [compile-dictionary]])
   (:import (java.io FileNotFoundException)))
 
-(def loc-en (read-string (slurp (io/resource "translations/en.edn"))))
+(defn loc-en []
+  (read-string (slurp (io/resource "translations/en.edn"))))
 
-(def loc-fi (read-string (slurp (io/resource "translations/fi.edn"))))
+(defn loc-fi []
+  (read-string (slurp (io/resource "translations/fi.edn"))))
 
 (defn map-structure
   "Recurse into map m and replace all leaves with true."
@@ -21,8 +23,8 @@
     (reduce-kv (fn [m k v] (assoc m k (transform v))) {} m)))
 
 (deftest test-all-languages-defined
-  (is (= (map-structure loc-en)
-         (map-structure loc-fi))))
+  (is (= (map-structure (loc-en))
+         (map-structure (loc-fi)))))
 
 (defn- translation-keywords-in-use []
   ;; git grep would be nice, but circleci's git grep doesn't have -o
diff --git a/test/clj/rems/test_text.clj b/test/clj/rems/test_text.clj
index 210c31981..67b4232ab 100644
--- a/test/clj/rems/test_text.clj
+++ b/test/clj/rems/test_text.clj
@@ -18,7 +18,7 @@
        (not (.contains str "Unknown"))))
 
 (deftest test-all-state-localizations
-  (is (= (-> (:states (:applications (:t loc-en)))
+  (is (= (-> (:states (:applications (:t (loc-en))))
              (dissoc :unknown)
              (keys)
              (sort))
@@ -35,7 +35,7 @@
 
 (deftest test-all-event-localizations
   (let [event-types (keys events/event-schemas)]
-    (is (= (-> (:events (:applications (:t loc-en)))
+    (is (= (-> (:events (:applications (:t (loc-en))))
                (dissoc :unknown)
                (keys)
                (sort))